Structs e banco de dados
Quando trabalhamos com bancos de dados em Go, precisamos de uma maneira de representar as informações recebidas pelas consultas dentro da aplicação. Embora o banco organize os registros em tabelas, o Go trabalha com tipos como strings, números, booleanos e structs. Para conectar essas duas formas de organizar informações, podemos criar structs que representam os dados armazenados nas tabelas.
Uma struct permite reunir diferentes campos em uma única estrutura, facilitando o trabalho com registros que possuem várias informações relacionadas. Em vez de manter uma variável separada para cada coluna do banco, podemos representar um produto, usuário ou pedido por meio de um único valor.
O pacote database/sql fornece mecanismos para enviar os valores de uma struct ao banco e copiar os resultados das consultas para seus campos. Entretanto, essa conversão não acontece automaticamente apenas porque criamos uma struct, pois precisamos indicar quais informações serão enviadas e quais campos receberão os valores retornados.
Para acompanhar os exemplos, vamos continuar trabalhando com produtos. Dessa vez, também teremos campos opcionais e informações de data, permitindo entender como representar esses valores no Go.
CREATE TABLE IF NOT EXISTS produtos (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,
preco_centavos INTEGER NOT NULL CHECK (preco_centavos >= 0),
estoque INTEGER NOT NULL DEFAULT 0 CHECK (estoque >= 0),
ativo INTEGER NOT NULL DEFAULT 1 CHECK (ativo IN (0, 1)),
descricao TEXT,
criado_em TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
atualizado_em TEXT
);
A tabela possui campos obrigatórios, como nome e preco_centavos, além de campos opcionais, como descricao e atualizado_em. O campo criado_em recebe automaticamente a data e o horário da inserção, enquanto ativo indica se o produto está ativo, usando os valores 0 e 1.
Como adicionamos novas colunas à tabela usada nos exemplos anteriores, precisamos garantir que o banco possua essa estrutura. O comando CREATE TABLE IF NOT EXISTS não modifica tabelas existentes, então podemos trabalhar com um novo arquivo chamado structs.db para evitar conflitos com os exemplos anteriores.
Os próximos códigos consideram que o banco já foi aberto e que temos acesso à variável db, do tipo *sql.DB.
Criando structs para representar tabelas
Uma struct é um tipo de dado do Go que permite agrupar diferentes campos em uma única estrutura. Cada campo possui um nome e um tipo, permitindo representar informações que pertencem a um mesmo registro.
Imagine que precisamos representar um produto dentro da aplicação. Podemos criar uma struct com os campos principais da tabela.
// Representa um produto cadastrado.
type Produto struct {
ID int64
Nome string
PrecoCentavos int64
Estoque int64
Ativo bool
}
Nesse exemplo, a struct Produto representa um produto que possui um identificador, um nome, um preço, uma quantidade em estoque e um estado que indica se está ativo. Cada campo possui um tipo correspondente à informação que será armazenada na aplicação.
O campo ID foi definido como int64, porque o SQLite trabalha com identificadores inteiros que podem ocupar até 64 bits. Os campos PrecoCentavos e Estoque também usam int64, enquanto Nome usa string e Ativo usa bool, que representa valores verdadeiros ou falsos.
Podemos relacionar os campos da struct com as colunas da tabela.
| Coluna SQLite | Campo Go | Tipo Go |
|---|---|---|
id | ID | int64 |
nome | Nome | string |
preco_centavos | PrecoCentavos | int64 |
estoque | Estoque | int64 |
ativo | Ativo | bool |
Observe que os nomes dos campos não precisam ser exatamente iguais aos nomes das colunas. No banco, usamos preco_centavos, enquanto no Go usamos PrecoCentavos, seguindo a convenção de nomes da linguagem.
O database/sql não depende dessa semelhança para identificar os campos. A associação entre as colunas e os valores da struct acontece quando executamos as operações SQL e informamos onde os dados devem ser armazenados.
Também precisamos considerar que o SQLite não possui uma classe de armazenamento exclusiva para booleanos. Nesse caso, a coluna ativo foi definida como INTEGER, armazenando 0 para falso e 1 para verdadeiro. O database/sql consegue trabalhar com essa conversão em situações compatíveis, permitindo receber esses valores em um campo bool.
Criando valores a partir de uma struct
Depois de definir a struct, podemos criar valores que representam produtos dentro da aplicação, sem precisar acessar o banco imediatamente.
// Cria um produto na memória.
produto := Produto{
Nome: "Teclado",
PrecoCentavos: 15990,
Estoque: 10,
Ativo: true,
}
Nesse exemplo, criamos um produto com nome, preço, quantidade e estado. O campo ID não foi informado, então recebe seu valor zero, que para int64 é 0. Isso acontece porque os campos de uma struct recebem automaticamente o valor zero do seu tipo quando não são inicializados.
Esse produto existe apenas na memória da aplicação. Criar uma struct não significa que seus dados foram armazenados no banco, porque ainda precisamos executar um comando SQL responsável pela inserção.
Também podemos acessar ou modificar seus campos diretamente.
// Acessa o nome do produto.
fmt.Println(produto.Nome)
// Modifica a quantidade em estoque.
produto.Estoque = 15
// Modifica o estado do produto.
produto.Ativo = false
Essas alterações acontecem apenas na variável produto. Mesmo que ela represente um registro que já existe no banco, modificar seus campos não executa automaticamente um UPDATE. Para persistir as alterações, precisamos enviar os novos valores ao SQLite por meio de uma operação SQL.
Os campos da struct precisam ter letra maiúscula?
No Go, identificadores que começam com letra maiúscula são exportados, ou seja, podem ser acessados por outros pacotes. Essa característica é especialmente útil quando organizamos a aplicação em diferentes arquivos e pacotes.
type Produto struct {
Nome string
Estoque int64
}
Nesse exemplo, Nome e Estoque são campos exportados. Se fossem declarados como nome e estoque, ficariam acessíveis apenas dentro do pacote em que foram definidos.
O database/sql não exige que todos os campos sejam exportados para trabalhar com structs, porque podemos passar diretamente os endereços dos campos acessíveis ao código que executa a consulta. Entretanto, manter os campos exportados facilita o trabalho quando precisamos compartilhar essas estruturas entre diferentes pacotes da aplicação.
Convertendo struct para dados do banco
Depois de criar uma struct, precisamos entender como enviar seus valores para o banco. Essa conversão acontece quando extraímos os campos da estrutura e os passamos como argumentos para uma operação SQL, permitindo que o driver associe cada valor ao parâmetro correspondente.
Imagine que temos um produto armazenado na memória e precisamos cadastrá-lo na tabela produtos.
// Produto que será cadastrado.
produto := Produto{
Nome: "Mouse",
PrecoCentavos: 7990,
Estoque: 20,
Ativo: true,
}
Podemos enviar seus campos ao banco usando o método Exec().
// Insere os dados da struct no banco.
resultado, err := db.Exec(`
INSERT INTO produtos (
nome,
preco_centavos,
estoque,
ativo
)
VALUES (?, ?, ?, ?)
`,
produto.Nome,
produto.PrecoCentavos,
produto.Estoque,
produto.Ativo,
)
if err != nil {
return err
}
O comando INSERT INTO define quais colunas receberão os dados, enquanto os placeholders ? representam os valores que serão enviados separadamente. Observe que não passamos a struct inteira ao método Exec(), mas informamos seus campos individualmente, seguindo a ordem definida no comando SQL.
O campo produto.Ativo possui o tipo bool, enquanto a coluna correspondente no SQLite possui o tipo INTEGER. Nesse caso, o driver SQLite trabalha com a representação numérica do booleano, armazenando 1 para true e 0 para false.
Também não precisamos informar o identificador durante o cadastro, porque a coluna id foi definida como INTEGER PRIMARY KEY, permitindo que o SQLite escolha automaticamente seu valor. As colunas criado_em e atualizado_em também foram omitidas, então a primeira receberá o valor definido em DEFAULT CURRENT_TIMESTAMP, enquanto a segunda receberá NULL.
Criando uma função para inserir structs
Em vez de escrever o comando SQL toda vez que precisamos cadastrar um produto, podemos criar uma função responsável por receber a struct e executar a inserção.
func inserirProduto(db *sql.DB, produto *Produto) error {
// Insere os campos do produto.
resultado, err := db.Exec(`
INSERT INTO produtos (
nome,
preco_centavos,
estoque,
ativo
)
VALUES (?, ?, ?, ?)
`,
produto.Nome,
produto.PrecoCentavos,
produto.Estoque,
produto.Ativo,
)
if err != nil {
return fmt.Errorf("erro ao inserir produto: %w", err)
}
// Recupera o identificador gerado pelo SQLite.
id, err := resultado.LastInsertId()
if err != nil {
return fmt.Errorf("erro ao recuperar ID: %w", err)
}
// Atualiza o ID da struct recebida.
produto.ID = id
return nil
}
A função recebe o gerenciador de conexões e um ponteiro para Produto, representado por *Produto. Usamos um ponteiro porque queremos modificar o campo ID da struct original depois que o banco gerar o identificador.
O método Exec() insere os valores do produto, enquanto LastInsertId() recupera o identificador gerado pela operação. Depois disso, atribuímos esse valor ao campo produto.ID, fazendo com que a struct também passe a conhecer o identificador do registro armazenado no banco.
Podemos chamar a função da seguinte maneira.
// Cria o produto.
produto := Produto{
Nome: "Mouse",
PrecoCentavos: 7990,
Estoque: 20,
Ativo: true,
}
// Envia o produto para o banco.
if err := inserirProduto(db, &produto); err != nil {
return err
}
// Mostra o identificador gerado.
fmt.Println("Produto cadastrado:", produto.ID)
O operador & permite passar o endereço da variável produto para a função. Como recebemos um ponteiro, as alterações realizadas em seus campos também aparecem na variável original.
Essa diferença é importante porque, se a função recebesse apenas Produto, sem o ponteiro, ela trabalharia com uma cópia da struct. Nesse caso, modificar o campo ID dentro da função não alteraria automaticamente a variável que foi passada como argumento.
Atualizando o banco a partir de uma struct
O mesmo princípio pode ser aplicado quando precisamos atualizar um registro. Em vez de passar cada informação separadamente para a função, podemos receber uma struct contendo os dados que devem ser armazenados.
func atualizarProduto(db *sql.DB, produto Produto) error {
// Atualiza os dados do produto pelo identificador.
resultado, err := db.Exec(`
UPDATE produtos
SET
nome = ?,
preco_centavos = ?,
estoque = ?,
ativo = ?
WHERE id = ?
`,
produto.Nome,
produto.PrecoCentavos,
produto.Estoque,
produto.Ativo,
produto.ID,
)
if err != nil {
return fmt.Errorf("erro ao atualizar produto: %w", err)
}
// Verifica se algum registro foi encontrado.
linhas, err := resultado.RowsAffected()
if err != nil {
return err
}
if linhas == 0 {
return sql.ErrNoRows
}
return nil
}
Nesse exemplo, o campo ID é usado na condição WHERE, enquanto os outros campos fornecem os novos valores. O método RowsAffected() permite verificar se algum registro foi afetado pela operação, retornando 0 quando nenhum produto corresponde ao identificador informado.
A função recebe Produto por valor porque não precisamos modificar a struct original. Ela apenas lê os campos e envia seus valores ao banco. Também precisamos considerar que essa função atualiza todos os campos informados no comando, mesmo que apenas um deles tenha sido modificado na aplicação.
Convertendo dados do banco para struct
Quando executamos uma consulta SQL, o banco retorna valores correspondentes às colunas selecionadas. Para trabalhar com esses dados de maneira organizada, podemos copiá-los diretamente para os campos de uma struct, reconstruindo a representação do registro dentro da aplicação.
Imagine que precisamos buscar um produto pelo identificador. Podemos executar a consulta e receber os valores diretamente em uma variável do tipo Produto.
// Struct que receberá os dados.
var produto Produto
// Busca o produto pelo identificador.
err := db.QueryRow(`
SELECT id, nome, preco_centavos, estoque, ativo
FROM produtos
WHERE id = ?
`, 1).Scan(
&produto.ID,
&produto.Nome,
&produto.PrecoCentavos,
&produto.Estoque,
&produto.Ativo,
)
if err != nil {
return err
}
fmt.Println("Produto:", produto.Nome)
O método QueryRow() executa a consulta e retorna um *sql.Row, que representa o resultado esperado. Já Scan() é responsável por copiar os valores retornados para os campos informados, respeitando a ordem das colunas definidas no comando SELECT.
Observe que usamos &produto.ID, &produto.Nome e os demais campos, porque Scan() precisa receber os endereços das variáveis que serão modificadas. Depois da execução, a struct contém uma representação dos dados retornados pelo banco.
Essa conversão não cria nenhum vínculo automático entre a variável e o registro armazenado. Se modificarmos produto.Nome depois da consulta, a alteração acontecerá apenas na memória, até que um comando UPDATE seja executado.
Criando uma função para buscar structs
Podemos organizar essa operação em uma função que recebe o identificador e retorna uma struct preenchida com os valores encontrados.
func buscarProduto(db *sql.DB, id int64) (Produto, error) {
var produto Produto
// Busca o produto pelo identificador.
err := db.QueryRow(`
SELECT id, nome, preco_centavos, estoque, ativo
FROM produtos
WHERE id = ?
`, id).Scan(
&produto.ID,
&produto.Nome,
&produto.PrecoCentavos,
&produto.Estoque,
&produto.Ativo,
)
if err != nil {
return Produto{}, fmt.Errorf("erro ao buscar produto: %w", err)
}
return produto, nil
}
A função retorna dois valores, um Produto e um error. Quando a consulta é executada corretamente, retornamos a struct preenchida e um erro igual a nil. Se ocorrer algum problema, retornamos Produto{}, que representa o valor zero da struct, junto com o erro correspondente.
Também podemos receber sql.ErrNoRows caso o identificador informado não exista. Como usamos %w para preservar o erro original, a função que chamou buscarProduto() poderá identificar essa condição por meio de errors.Is().
// Busca o produto com ID igual a 1.
produto, err := buscarProduto(db, 1)
if errors.Is(err, sql.ErrNoRows) {
fmt.Println("Produto não encontrado")
return nil
}
if err != nil {
return err
}
// Trabalha com os dados da struct.
fmt.Println("Nome:", produto.Nome)
fmt.Println("Estoque:", produto.Estoque)
Nesse exemplo, a função responsável pela consulta apenas devolve o registro ou o erro encontrado. Já o código que fez a chamada decide como tratar a ausência de um produto, mantendo as responsabilidades separadas.
Usando Scan
O método Scan() faz parte do pacote database/sql e é responsável por copiar os valores retornados por uma consulta para variáveis do Go. Ele pode ser chamado tanto em um *sql.Row, retornado por QueryRow(), quanto em um *sql.Rows, retornado por Query().
O Scan() não identifica automaticamente os campos de uma struct pelo nome. Ele trabalha principalmente com a posição das colunas retornadas, então precisamos informar os destinos na mesma ordem em que os valores aparecem no resultado da consulta.
// Consulta três colunas.
err := db.QueryRow(`
SELECT id, nome, estoque
FROM produtos
WHERE id = ?
`, 1).Scan(
&produto.ID,
&produto.Nome,
&produto.Estoque,
)
Nesse exemplo, o primeiro valor retornado pela consulta será armazenado em produto.ID, o segundo em produto.Nome e o terceiro em produto.Estoque. Se alterarmos a ordem das colunas no SELECT, também precisaremos ajustar a ordem dos argumentos de Scan().
A quantidade de argumentos também precisa corresponder à quantidade de colunas retornadas. Se a consulta retornar três colunas e informarmos apenas dois destinos, o método retornará um erro.
Por que usamos o operador &?
O operador & permite obter o endereço de uma variável, criando um ponteiro para o local em que seu valor está armazenado na memória. O Scan() precisa desse endereço porque sua responsabilidade é modificar o conteúdo da variável com as informações recebidas do banco.
var nome string
// Scan recebe o endereço da variável nome.
err := db.QueryRow(
"SELECT nome FROM produtos WHERE id = ?",
1,
).Scan(&nome)
Nesse exemplo, &nome representa um ponteiro para a variável nome. Quando Scan() recebe o valor retornado pela consulta, ele consegue armazená-lo diretamente nessa variável.
Não poderíamos simplesmente passar nome nesse caso, porque isso enviaria seu valor atual, e não um destino que o método pudesse modificar. O Scan() espera receber ponteiros ou outros destinos compatíveis com o mecanismo de leitura do database/sql.
Conversão de tipos durante o Scan
O Scan() também consegue realizar algumas conversões entre os tipos retornados pelo banco e os tipos das variáveis do Go. Por exemplo, uma coluna que armazena um inteiro pode ser recebida em uma variável int64, enquanto uma coluna textual normalmente pode ser recebida em uma string.
O pacote também permite receber valores inteiros compatíveis em variáveis booleanas.
var ativo bool
// Converte o valor 0 ou 1 para bool.
err := db.QueryRow(
"SELECT ativo FROM produtos WHERE id = ?",
1,
).Scan(&ativo)
if err != nil {
return err
}
fmt.Println("Ativo:", ativo)
Nesse exemplo, quando o banco retorna 1, o campo recebe true. Quando retorna 0, recebe false. Outros valores inteiros podem provocar um erro de conversão, porque não representam um booleano válido para esse mecanismo.
Entretanto, nem todas as conversões são possíveis. Se uma coluna contiver um texto que não representa um número e tentarmos recebê-lo em uma variável int64, o Scan() poderá retornar um erro.
Também precisamos considerar que o SQLite possui um sistema de tipos flexível. Em tabelas comuns, determinados campos podem armazenar valores de classes diferentes das declaradas, então a aplicação precisa estar preparada para lidar com possíveis incompatibilidades.
Lendo várias structs com Scan
Quando uma consulta retorna vários registros, podemos combinar Query(), Rows.Next() e Scan() para criar um slice de structs. Cada linha retornada pelo banco será convertida em um novo valor da struct e adicionada ao slice.
func listarProdutos(db *sql.DB) ([]Produto, error) {
// Consulta os produtos cadastrados.
rows, err := db.Query(`
SELECT id, nome, preco_centavos, estoque, ativo
FROM produtos
ORDER BY id ASC
`)
if err != nil {
return nil, err
}
// Libera os recursos da consulta.
defer rows.Close()
// Slice que armazenará os produtos encontrados.
produtos := make([]Produto, 0)
for rows.Next() {
var produto Produto
// Copia os dados da linha atual para a struct.
err := rows.Scan(
&produto.ID,
&produto.Nome,
&produto.PrecoCentavos,
&produto.Estoque,
&produto.Ativo,
)
if err != nil {
return nil, err
}
// Adiciona o produto ao slice.
produtos = append(produtos, produto)
}
// Verifica erros durante a leitura.
if err := rows.Err(); err != nil {
return nil, err
}
return produtos, nil
}
O método Query() executa a consulta e retorna um *sql.Rows, que permite percorrer os registros. Dentro do laço, criamos uma nova variável produto e usamos Scan() para preencher seus campos com os valores da linha atual.
Depois disso, append() adiciona o produto ao slice. Como essa operação acontece a cada iteração, o resultado será um conjunto de structs, cada uma representando um registro encontrado no banco.
Quando a consulta não encontra registros, o slice permanece vazio. Nesse caso, retornamos um slice com tamanho zero, sem considerar a ausência de resultados um erro. Também verificamos rows.Err() depois do laço, porque podem ocorrer erros durante a leitura dos registros.
Podemos chamar essa função e percorrer os produtos recebidos.
// Busca todos os produtos.
produtos, err := listarProdutos(db)
if err != nil {
return err
}
// Percorre as structs retornadas.
for _, produto := range produtos {
fmt.Println(produto.ID, produto.Nome)
}
Observe que a função listarProdutos() não precisa conhecer como os dados serão apresentados. Ela apenas consulta os registros e devolve um slice de structs, permitindo que outras partes da aplicação decidam como trabalhar com essas informações.
Trabalhando com datas
Quando armazenamos datas e horários no banco, precisamos considerar que o SQLite não possui uma classe de armazenamento específica para esse tipo de informação. Em vez disso, podemos representar datas usando texto, números inteiros ou números reais, dependendo do formato escolhido.
Na tabela produtos, definimos as colunas criado_em e atualizado_em como TEXT. Isso significa que vamos trabalhar com datas representadas por strings, seguindo um formato consistente.
CREATE TABLE IF NOT EXISTS produtos (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,
preco_centavos INTEGER NOT NULL,
estoque INTEGER NOT NULL DEFAULT 0,
ativo INTEGER NOT NULL DEFAULT 1,
-- Armazena a data e o horário de criação.
criado_em TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Armazena a data e o horário da última atualização.
atualizado_em TEXT
);
O CURRENT_TIMESTAMP faz com que o SQLite preencha automaticamente a coluna criado_em com a data e o horário da inserção, usando UTC. O formato retornado normalmente é YYYY-MM-DD HH:MM:SS, como 2026-10-12 14:30:00.
Já atualizado_em não possui um valor padrão obrigatório, então poderá receber NULL enquanto o produto não tiver sido atualizado. Como essa coluna foi definida como TEXT, também precisaremos converter seus valores quando quisermos trabalhar com datas diretamente no Go.
O tipo time.Time
No Go, o pacote time oferece o tipo time.Time, que representa uma data e um horário. Esse tipo fornece métodos para comparar datas, calcular diferenças de tempo, converter fusos horários e formatar valores para apresentação.
// Obtém a data e o horário atuais.
agora := time.Now()
fmt.Println(agora)
O método time.Now() retorna a data e o horário atuais do sistema, enquanto o tipo time.Time permite trabalhar com essas informações sem precisar manipular manualmente cada parte da data.
Por exemplo, podemos representar o momento em que um produto foi criado.
type ProdutoData struct {
ID int64
Nome string
CriadoEm time.Time
}
Nesse exemplo, CriadoEm possui o tipo time.Time, porque queremos trabalhar com uma data dentro da aplicação. Entretanto, precisamos lembrar que a coluna correspondente no SQLite foi definida como TEXT, então devemos considerar a conversão entre a representação textual do banco e o tipo de data do Go.
Convertendo texto do banco para time.Time
Quando uma consulta retorna uma data armazenada como TEXT, podemos receber o valor em uma string e depois convertê-lo para time.Time.
var criadoEmTexto string
// Busca a data de criação do produto.
err := db.QueryRow(`
SELECT criado_em
FROM produtos
WHERE id = ?
`, 1).Scan(&criadoEmTexto)
if err != nil {
return err
}
// Converte o texto para time.Time.
criadoEm, err := time.Parse(time.DateTime, criadoEmTexto)
if err != nil {
return err
}
fmt.Println("Criado em:", criadoEm)
O método Scan() copia o valor da coluna criado_em para a variável criadoEmTexto. Em seguida, usamos time.Parse() para interpretar essa string e criar um valor do tipo time.Time.
O primeiro argumento de time.Parse() representa o formato esperado, enquanto o segundo contém o texto que será convertido. Nesse caso, usamos time.DateTime, que corresponde ao formato 2006-01-02 15:04:05.
O Go possui uma característica diferente de outras linguagens ao definir formatos de data. Em vez de usar símbolos como YYYY e MM, ele usa uma data de referência específica, baseada em 02/01/2006 15:04:05.
| Parte | Referência no Go |
|---|---|
| Ano | 2006 |
| Mês | 01 |
| Dia | 02 |
| Hora | 15 |
| Minuto | 04 |
| Segundo | 05 |
Por isso, quando escrevemos 2006-01-02 15:04:05, estamos definindo o formato esperado, e não informando uma data real que será armazenada.
Como o formato usado pelo SQLite nesse exemplo não contém informações de fuso horário, time.Parse() interpreta o valor recebido como UTC. Isso corresponde ao comportamento de CURRENT_TIMESTAMP, que também trabalha com UTC.
A conversão explícita é importante porque não devemos assumir que todo driver SQLite converterá automaticamente uma coluna TEXT em time.Time. O comportamento pode depender do driver, do tipo declarado e de suas configurações.
Convertendo time.Time para texto
Também podemos fazer o processo contrário, convertendo um valor time.Time para uma string que será armazenada no SQLite.
// Obtém o horário atual em UTC.
agora := time.Now().UTC()
// Converte a data para o formato do SQLite.
dataFormatada := agora.Format(time.DateTime)
fmt.Println(dataFormatada)
O método UTC() converte a representação do horário para UTC, enquanto Format() retorna uma string seguindo o formato informado. Nesse exemplo, usamos time.DateTime, mantendo o mesmo padrão que será usado posteriormente durante a leitura.
Podemos aplicar esse mecanismo quando precisamos atualizar a data de modificação de um produto.
// Obtém o horário atual em UTC.
atualizadoEm := time.Now().UTC().Format(time.DateTime)
// Atualiza a data de modificação do produto.
_, err := db.Exec(`
UPDATE produtos
SET atualizado_em = ?
WHERE id = ?
`, atualizadoEm, 1)
if err != nil {
return err
}
Nesse exemplo, a aplicação define o horário da atualização e envia o valor formatado ao SQLite. A coluna atualizado_em recebe a string correspondente, mantendo o padrão usado pela coluna criado_em.
Também poderíamos deixar que o próprio SQLite definisse esse horário usando CURRENT_TIMESTAMP.
-- Atualiza a data de modificação usando o horário do SQLite.
UPDATE produtos
SET atualizado_em = CURRENT_TIMESTAMP
WHERE id = 1;
Nesse segundo caso, não precisamos gerar o horário no Go, porque o SQLite fica responsável por preencher o campo durante a operação.
É importante entender que DEFAULT CURRENT_TIMESTAMP não atualiza automaticamente a data sempre que um registro é modificado. Ele fornece um valor padrão durante a inserção. Para controlar a data de atualização, precisamos executar essa operação explicitamente ou configurar outro mecanismo no banco.
Formatando datas para apresentação
Depois de converter a data para time.Time, podemos apresentá-la em outros formatos sem precisar modificar o valor armazenado no banco.
// Formata a data para o padrão brasileiro.
dataFormatada := criadoEm.Format("02/01/2006 15:04")
fmt.Println("Criado em:", dataFormatada)
O método Format() permite definir como uma data será representada em texto. Nesse exemplo, usamos o formato DD/MM/AAAA HH:MM, que costuma ser mais familiar em aplicações brasileiras.
Se precisarmos apresentar o horário em outro fuso, podemos converter o valor usando In().
// Carrega o fuso horário de São Paulo.
local, err := time.LoadLocation("America/Sao_Paulo")
if err != nil {
return err
}
// Converte o horário UTC para o fuso informado.
dataLocal := criadoEm.In(local)
// Formata a data para apresentação.
fmt.Println(dataLocal.Format("02/01/2006 15:04"))
O método time.LoadLocation() carrega as regras do fuso horário informado, enquanto In() representa o mesmo instante naquele fuso. Isso permite manter datas armazenadas em UTC e convertê-las apenas quando precisamos apresentá-las ao usuário.
Essa separação ajuda a evitar inconsistências quando uma aplicação possui usuários em regiões diferentes. O banco mantém uma referência de horário consistente, enquanto a apresentação pode considerar o fuso de cada usuário.
Trabalhando com campos NULL
Nem todos os campos de uma tabela precisam possuir um valor. Quando uma coluna permite armazenar NULL, precisamos representar essa possibilidade no Go, porque tipos comuns como string, int64 e bool não possuem um valor específico para indicar a ausência de informação.
Uma string vazia não representa necessariamente um valor ausente, assim como o número 0 não significa que uma informação numérica não foi cadastrada. Por isso, o pacote database/sql oferece tipos próprios para trabalhar com campos que podem receber NULL.
Na tabela produtos, a coluna descricao é opcional, permitindo cadastrar produtos sem informar uma descrição.
-- Insere um produto sem descrição.
INSERT INTO produtos (nome, preco_centavos)
VALUES ('Teclado', 15990);
Como a coluna descricao não foi informada e não possui um valor padrão definido, o SQLite armazenará NULL nesse campo.
Usando sql.NullString
O tipo sql.NullString permite representar um texto que pode receber NULL. Ele possui dois campos principais, String, que armazena o valor textual, e Valid, que indica se existe um valor válido.
// Representa uma descrição que pode ser NULL.
var descricao sql.NullString
// Busca a descrição do produto.
err := db.QueryRow(`
SELECT descricao
FROM produtos
WHERE id = ?
`, 1).Scan(&descricao)
if err != nil {
return err
}
// Verifica se existe uma descrição.
if descricao.Valid {
fmt.Println("Descrição:", descricao.String)
} else {
fmt.Println("Produto sem descrição")
}
Nesse exemplo, Scan() recebe o endereço de uma variável sql.NullString. Se o banco retornar um texto, o campo String receberá esse valor e Valid será igual a true. Se o banco retornar NULL, Valid será igual a false, indicando que a coluna não possui um valor armazenado.
Essa estrutura permite distinguir um campo ausente de uma string vazia, porque ambos representam situações diferentes.
// Representa um valor NULL.
semDescricao := sql.NullString{
Valid: false,
}
// Representa uma string vazia.
descricaoVazia := sql.NullString{
String: "",
Valid: true,
}
No primeiro caso, Valid é false, então o valor representa NULL. No segundo, Valid é true, indicando que existe um texto armazenado, mesmo que ele não contenha caracteres.
Inserindo valores NULL
Os tipos como sql.NullString também podem ser passados diretamente como parâmetros das consultas, porque implementam os mecanismos necessários para converter seus valores antes de enviá-los ao banco.
// Produto sem descrição.
descricao := sql.NullString{
Valid: false,
}
// Insere um produto com descrição NULL.
_, err := db.Exec(`
INSERT INTO produtos (
nome,
preco_centavos,
descricao
)
VALUES (?, ?, ?)
`,
"Mouse",
7990,
descricao,
)
if err != nil {
return err
}
Nesse exemplo, o campo descricao recebe NULL, porque o valor passado possui Valid: false. Se quisermos armazenar uma descrição, podemos informar seu conteúdo e definir Valid como true.
// Produto com descrição.
descricao := sql.NullString{
String: "Mouse sem fio",
Valid: true,
}
O database/sql também aceita nil como argumento para representar NULL, desde que a coluna permita armazenar esse valor. Entretanto, sql.NullString pode ser mais conveniente quando precisamos manter a informação sobre a presença ou ausência do campo dentro de uma struct.
Outros tipos que aceitam NULL
Além de sql.NullString, o pacote database/sql oferece outros tipos para representar valores opcionais.
| Tipo | Valor representado |
|---|---|
sql.NullString | Texto que pode ser NULL. |
sql.NullInt64 | Número inteiro que pode ser NULL. |
sql.NullFloat64 | Número de ponto flutuante que pode ser NULL. |
sql.NullBool | Booleano que pode ser NULL. |
sql.NullTime | Data que pode ser NULL. |
Todos esses tipos possuem um campo Valid, que indica se o valor recebido não era NULL. A diferença está no campo responsável por armazenar a informação, como String, Int64, Float64, Bool ou Time.
Por exemplo, se tivéssemos uma coluna opcional chamada peso, poderíamos representar seu valor usando sql.NullFloat64.
// Representa um peso que pode estar ausente.
var peso sql.NullFloat64
// Verifica se existe um valor.
if peso.Valid {
fmt.Println("Peso:", peso.Float64)
} else {
fmt.Println("Peso não informado")
}
Também existe o tipo genérico sql.Null[T], disponível a partir do Go 1.22, que permite representar valores opcionais usando uma estrutura parametrizada por tipo.
// Representa um texto opcional.
var descricao sql.Null[string]
// Verifica se o valor existe.
if descricao.Valid {
fmt.Println(descricao.V)
}
Nesse exemplo, o campo V armazena o valor correspondente ao tipo informado entre colchetes. O comportamento é semelhante ao de sql.NullString, mas permite trabalhar com uma estrutura genérica para os tipos aceitos pelo mecanismo.
Para os próximos exemplos, vamos continuar usando sql.NullString e os outros tipos específicos, porque deixam mais claro qual informação está sendo representada.
Trabalhando com datas opcionais
A coluna atualizado_em da tabela produtos também aceita NULL, porque um produto pode ter sido cadastrado sem passar por nenhuma atualização.
Como essa coluna foi declarada como TEXT, podemos receber seu valor usando sql.NullString e depois convertê-lo para time.Time, caso exista uma data armazenada.
// Representa uma data opcional armazenada como texto.
var atualizadoEm sql.NullString
// Busca a última atualização do produto.
err := db.QueryRow(`
SELECT atualizado_em
FROM produtos
WHERE id = ?
`, 1).Scan(&atualizadoEm)
if err != nil {
return err
}
// Verifica se existe uma data de atualização.
if atualizadoEm.Valid {
data, err := time.Parse(time.DateTime, atualizadoEm.String)
if err != nil {
return err
}
fmt.Println("Atualizado em:", data)
} else {
fmt.Println("Produto ainda não foi atualizado")
}
Nesse exemplo, primeiro verificamos se a coluna possui um valor. Se Valid for igual a true, convertemos o texto para time.Time. Caso contrário, sabemos que o banco retornou NULL, então não precisamos executar nenhuma conversão.
O pacote também oferece sql.NullTime, mas ele não converte automaticamente qualquer texto de data para time.Time. Esse tipo funciona quando o valor recebido do driver é compatível com time.Time, então precisamos considerar como o driver representa as informações retornadas pelo banco.
Como nossa coluna foi declarada como TEXT, a conversão explícita com time.Parse() evita depender de um comportamento específico do driver.
Separando modelo de banco e modelo da aplicação
Até agora, usamos structs para representar diretamente os registros retornados pelo banco. Essa abordagem funciona bem em aplicações pequenas, principalmente quando os campos da tabela correspondem às informações que precisamos manter na memória.
Entretanto, conforme a aplicação cresce, nem sempre a representação usada pelo banco é a mais adequada para trabalhar com os dados dentro do Go. Algumas informações podem precisar de conversões, enquanto outras existem apenas para controlar como os registros são armazenados.
Por exemplo, o SQLite representa booleanos por meio de valores inteiros, enquanto o Go possui o tipo bool. Também podemos armazenar datas como TEXT, mas trabalhar com elas como time.Time dentro da aplicação. Já campos opcionais podem ser representados por sql.NullString no acesso ao banco, enquanto outras partes da aplicação podem preferir ponteiros ou estruturas próprias.
Para separar essas responsabilidades, podemos criar um modelo que representa os dados do banco e outro que representa os dados da aplicação.
Criando um modelo para o banco
O modelo de banco representa as informações de acordo com a maneira como elas serão recebidas ou enviadas pelo mecanismo de persistência. Nesse caso, podemos manter tipos compatíveis com as classes de armazenamento do SQLite e os recursos do database/sql.
// Representa os dados conforme são armazenados no SQLite.
type ProdutoRegistro struct {
ID int64
Nome string
PrecoCentavos int64
Estoque int64
Ativo int64
Descricao sql.NullString
CriadoEm string
AtualizadoEm sql.NullString
}
Nesse exemplo, usamos int64 para representar o campo ativo, porque o SQLite armazena booleanos como inteiros. Também usamos string para a data de criação, já que sua coluna foi declarada como TEXT.
As colunas opcionais descricao e atualizado_em foram representadas por sql.NullString, permitindo receber tanto valores textuais quanto NULL. Essa estrutura está próxima da representação dos dados no banco, facilitando sua leitura por meio de Scan().
O nome ProdutoRegistro foi escolhido para indicar que essa struct representa um registro persistido no banco. Ela não precisa ser a mesma estrutura que será usada pelas outras partes da aplicação.
Criando um modelo para a aplicação
O modelo da aplicação pode representar as mesmas informações de uma maneira mais adequada para o código Go. Nesse caso, podemos usar bool para indicar o estado do produto, time.Time para trabalhar com datas e ponteiros para representar campos opcionais.
// Representa um produto dentro da aplicação.
type ProdutoDetalhado struct {
ID int64
Nome string
PrecoCentavos int64
Estoque int64
Ativo bool
Descricao *string
CriadoEm time.Time
AtualizadoEm *time.Time
}
Nesse exemplo, o campo Ativo usa bool, porque dentro da aplicação queremos trabalhar diretamente com true e false. O campo CriadoEm usa time.Time, permitindo comparar e formatar datas sem precisar converter strings toda vez que uma operação for executada.
Já Descricao usa *string, enquanto AtualizadoEm usa *time.Time. Esses ponteiros permitem representar a ausência de um valor por meio de nil, diferenciando campos opcionais daqueles que sempre precisam conter uma informação.
Por exemplo, podemos criar um produto com uma descrição.
// Descrição que será associada ao produto.
descricao := "Teclado mecânico"
// Cria um produto dentro da aplicação.
produto := ProdutoDetalhado{
Nome: "Teclado",
PrecoCentavos: 15990,
Estoque: 10,
Ativo: true,
Descricao: &descricao,
}
Nesse exemplo, o campo Descricao recebe o endereço da variável descricao. Isso permite representar um texto existente, enquanto um valor nil indicaria que nenhuma descrição foi informada.
Observe que CriadoEm não foi preenchido. Nesse caso, o campo recebe o valor zero de time.Time, porque a data ainda será definida pelo banco durante a inserção.
Convertendo o modelo do banco para o modelo da aplicação
Depois de definir as duas structs, precisamos criar uma função responsável por converter os valores recebidos do banco para os tipos esperados pela aplicação.
Essa função deve transformar os campos que possuem representações diferentes, como o inteiro usado para booleanos, as datas armazenadas como texto e os campos que podem receber NULL.
func converterParaAplicacao(registro ProdutoRegistro) (ProdutoDetalhado, error) {
// Converte a data de criação para time.Time.
criadoEm, err := time.Parse(time.DateTime, registro.CriadoEm)
if err != nil {
return ProdutoDetalhado{}, fmt.Errorf(
"erro ao converter data de criação: %w",
err,
)
}
// Verifica se o valor booleano é válido.
if registro.Ativo != 0 && registro.Ativo != 1 {
return ProdutoDetalhado{}, fmt.Errorf("estado do produto inválido")
}
// Converte a descrição opcional.
var descricao *string
if registro.Descricao.Valid {
valor := registro.Descricao.String
descricao = &valor
}
// Converte a data de atualização opcional.
var atualizadoEm *time.Time
if registro.AtualizadoEm.Valid {
data, err := time.Parse(
time.DateTime,
registro.AtualizadoEm.String,
)
if err != nil {
return ProdutoDetalhado{}, fmt.Errorf(
"erro ao converter data de atualização: %w",
err,
)
}
atualizadoEm = &data
}
// Cria o modelo da aplicação com os valores convertidos.
produto := ProdutoDetalhado{
ID: registro.ID,
Nome: registro.Nome,
PrecoCentavos: registro.PrecoCentavos,
Estoque: registro.Estoque,
Ativo: registro.Ativo == 1,
Descricao: descricao,
CriadoEm: criadoEm,
AtualizadoEm: atualizadoEm,
}
return produto, nil
}
A função recebe um ProdutoRegistro, que representa os valores retornados pelo banco, e devolve um ProdutoDetalhado, que possui os tipos esperados pela aplicação. Primeiro, convertemos a data de criação usando time.Parse(), depois verificamos se o valor de Ativo corresponde a 0 ou 1.
Para converter o booleano, usamos a expressão registro.Ativo == 1, que retorna true quando o valor é igual a 1 e false quando é igual a 0. Essa conversão permite trabalhar com o campo como um booleano dentro da aplicação.
Também verificamos se a descrição possui um valor. Quando registro.Descricao.Valid é verdadeiro, criamos uma variável contendo o texto e armazenamos seu endereço em descricao. Caso contrário, o ponteiro permanece nil, representando a ausência de informação.
O mesmo princípio é aplicado à data de atualização. Se o banco retornou uma data, convertemos seu conteúdo para time.Time e armazenamos um ponteiro para o valor. Se retornou NULL, o campo permanece nil.
Depois dessas conversões, criamos o ProdutoDetalhado com os valores correspondentes e retornamos a struct pronta para ser usada pela aplicação.
Convertendo o modelo da aplicação para o modelo do banco
Também podemos criar uma função responsável pelo processo contrário, recebendo os dados da aplicação e preparando seus valores para serem enviados ao SQLite.
Nesse caso, precisamos converter booleanos para inteiros, datas para texto e ponteiros opcionais para valores que possam representar NULL.
func converterParaRegistro(produto ProdutoDetalhado) ProdutoRegistro {
// Cria o registro com os campos principais.
registro := ProdutoRegistro{
ID: produto.ID,
Nome: produto.Nome,
PrecoCentavos: produto.PrecoCentavos,
Estoque: produto.Estoque,
}
// Converte o booleano para inteiro.
if produto.Ativo {
registro.Ativo = 1
} else {
registro.Ativo = 0
}
// Converte a descrição opcional.
if produto.Descricao != nil {
registro.Descricao = sql.NullString{
String: *produto.Descricao,
Valid: true,
}
}
// Converte a data de criação, caso tenha sido informada.
if !produto.CriadoEm.IsZero() {
registro.CriadoEm = produto.CriadoEm.UTC().Format(time.DateTime)
}
// Converte a data de atualização opcional.
if produto.AtualizadoEm != nil {
registro.AtualizadoEm = sql.NullString{
String: produto.AtualizadoEm.UTC().Format(time.DateTime),
Valid: true,
}
}
return registro
}
Nesse exemplo, criamos um ProdutoRegistro com os campos principais e depois convertemos as informações que possuem representações diferentes. O campo Ativo recebe 1 quando o booleano é verdadeiro e 0 quando é falso.
O campo Descricao recebe um sql.NullString válido quando existe um ponteiro para uma string. Caso contrário, permanece com o valor zero de sql.NullString, que possui Valid: false e representa NULL.
Para as datas, usamos Format(time.DateTime) depois de converter os horários para UTC. Também verificamos CriadoEm.IsZero(), porque uma struct recém-criada pode não possuir uma data de criação. Nesse caso, deixamos o campo vazio no modelo de banco, permitindo que a operação de inserção omita essa coluna e deixe o SQLite aplicar seu valor padrão.
Essa função apenas prepara os dados, sem executar nenhuma operação SQL. A decisão sobre quais colunas serão inseridas ou atualizadas continua sendo responsabilidade da função que acessa o banco.
Buscando um produto com os dois modelos
Agora podemos juntar os conceitos em uma função responsável por consultar o banco, preencher o modelo de persistência e converter o resultado para o modelo da aplicação.
func buscarProdutoDetalhado(
db *sql.DB,
id int64,
) (ProdutoDetalhado, error) {
var registro ProdutoRegistro
// Busca os dados conforme estão representados no banco.
err := db.QueryRow(`
SELECT
id,
nome,
preco_centavos,
estoque,
ativo,
descricao,
criado_em,
atualizado_em
FROM produtos
WHERE id = ?
`, id).Scan(
®istro.ID,
®istro.Nome,
®istro.PrecoCentavos,
®istro.Estoque,
®istro.Ativo,
®istro.Descricao,
®istro.CriadoEm,
®istro.AtualizadoEm,
)
if err != nil {
return ProdutoDetalhado{}, fmt.Errorf(
"erro ao buscar produto: %w",
err,
)
}
// Converte o registro para o modelo da aplicação.
produto, err := converterParaAplicacao(registro)
if err != nil {
return ProdutoDetalhado{}, err
}
return produto, nil
}
Nesse exemplo, Scan() preenche os campos de ProdutoRegistro com os valores retornados pelo SQLite. Depois disso, chamamos converterParaAplicacao(), que transforma as informações para os tipos definidos em ProdutoDetalhado.
A função responsável pela consulta não precisa conhecer todos os detalhes da conversão, porque essa responsabilidade foi colocada em uma função separada. Isso também facilita reaproveitar a mesma conversão quando diferentes consultas retornam registros com a mesma estrutura.
Podemos chamar a função e trabalhar diretamente com o modelo da aplicação.
// Busca um produto pelo identificador.
produto, err := buscarProdutoDetalhado(db, 1)
if err != nil {
return err
}
// Trabalha com os dados convertidos.
fmt.Println("Nome:", produto.Nome)
fmt.Println("Ativo:", produto.Ativo)
fmt.Println("Criado em:", produto.CriadoEm.Format("02/01/2006 15:04"))
// Verifica se existe uma descrição.
if produto.Descricao != nil {
fmt.Println("Descrição:", *produto.Descricao)
}
Nesse caso, a aplicação recebe os dados prontos para serem trabalhados, sem precisar conhecer diretamente sql.NullString ou realizar a conversão das datas em cada operação.
Inserindo um produto com os dois modelos
Também podemos aplicar essa separação durante a inserção. Primeiro, recebemos o modelo da aplicação e convertemos seus valores para o modelo do banco. Depois, executamos o comando SQL usando os campos preparados.
func inserirProdutoDetalhado(
db *sql.DB,
produto ProdutoDetalhado,
) (int64, error) {
// Converte o produto para o modelo do banco.
registro := converterParaRegistro(produto)
// Insere os dados no SQLite.
resultado, err := db.Exec(`
INSERT INTO produtos (
nome,
preco_centavos,
estoque,
ativo,
descricao
)
VALUES (?, ?, ?, ?, ?)
`,
registro.Nome,
registro.PrecoCentavos,
registro.Estoque,
registro.Ativo,
registro.Descricao,
)
if err != nil {
return 0, fmt.Errorf("erro ao inserir produto: %w", err)
}
// Recupera o identificador gerado.
id, err := resultado.LastInsertId()
if err != nil {
return 0, err
}
return id, nil
}
Nesse exemplo, a função recebe um ProdutoDetalhado, converte seus valores para ProdutoRegistro e executa a inserção. Observe que não enviamos criado_em ou atualizado_em, porque queremos deixar que o SQLite preencha a data de criação automaticamente e mantenha a data de atualização como NULL.
O campo Descricao, por outro lado, é enviado diretamente como sql.NullString. Como esse tipo implementa a interface necessária para fornecer valores ao driver, o database/sql consegue enviar seu conteúdo ou NULL, dependendo do estado de Valid.
Podemos criar um produto e cadastrá-lo usando essa função.
// Define uma descrição para o produto.
descricao := "Teclado mecânico com iluminação"
// Cria o modelo da aplicação.
produto := ProdutoDetalhado{
Nome: "Teclado",
PrecoCentavos: 15990,
Estoque: 10,
Ativo: true,
Descricao: &descricao,
}
// Insere o produto no banco.
id, err := inserirProdutoDetalhado(db, produto)
if err != nil {
return err
}
fmt.Println("Produto cadastrado com ID:", id)
A função recebe os dados organizados em uma struct, prepara os valores para o SQLite e executa a operação. Isso permite que a aplicação trabalhe com tipos mais adequados ao Go, enquanto a camada responsável pelo banco controla as conversões necessárias para armazenar as informações.
Essa separação não é obrigatória para todos os projetos. Em aplicações pequenas, uma única struct pode ser suficiente, principalmente quando os tipos do banco correspondem diretamente aos tipos usados pela aplicação. Entretanto, quando existem muitas conversões, campos opcionais ou diferenças entre a estrutura do banco e a representação esperada pelo restante do código, manter modelos separados pode facilitar a organização e a manutenção.