CRUD básico
CRUD é a sigla para Create, Read, Update e Delete, que representa as quatro operações básicas usadas para trabalhar com registros em um banco de dados. Essas operações permitem cadastrar novas informações, consultar registros existentes, modificar seus valores e remover dados que não são mais necessários.
No SQLite, essas operações são realizadas principalmente pelos comandos SQL INSERT, SELECT, UPDATE e DELETE. Quando trabalhamos com Go, podemos executar esses comandos por meio do pacote database/sql, que fornece métodos para enviar as operações ao banco e receber seus resultados.
| Operação | Comando SQL | Responsabilidade |
|---|---|---|
| Create | INSERT | Insere novos registros. |
| Read | SELECT | Consulta registros existentes. |
| Update | UPDATE | Modifica registros existentes. |
| Delete | DELETE | Remove registros. |
Para acompanhar os exemplos, vamos trabalhar com uma tabela chamada produtos, que representa um cadastro simples de produtos. Cada produto terá um identificador, um nome, um preço, uma quantidade em estoque e um campo que indica se está ativo.
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))
);
O campo id representa a chave primária e permite que o SQLite gere automaticamente o identificador de cada produto. O campo preco_centavos armazena o preço como um número inteiro, evitando problemas de precisão que podem aparecer quando valores monetários são representados por números de ponto flutuante. O campo estoque possui o valor padrão 0, enquanto ativo começa com 1, indicando que o produto está ativo. As condições CHECK impedem preços e quantidades negativas, além de restringirem o campo ativo aos valores 0 e 1.
Antes de executar as operações, precisamos abrir o banco e garantir que a tabela exista. Podemos fazer isso durante a inicialização da aplicação.
package main
import (
"database/sql"
"log"
_ "modernc.org/sqlite"
)
func main() {
if err := executar(); err != nil {
log.Fatal(err)
}
}
func executar() error {
// Cria o gerenciador de conexões com SQLite.
db, err := sql.Open("sqlite", "dados.db")
if err != nil {
return err
}
// Libera os recursos quando a função terminar.
defer db.Close()
// Limita o pool a uma conexão.
db.SetMaxOpenConns(1)
// Verifica se o banco está acessível.
if err := db.Ping(); err != nil {
return err
}
// Cria a tabela caso ela ainda não exista.
_, err = db.Exec(`
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))
);
`)
if err != nil {
return err
}
return nil
}
Esse código estabelece o acesso ao arquivo dados.db e executa o comando responsável por criar a tabela. Como usamos CREATE TABLE IF NOT EXISTS, a aplicação não tentará recriar a tabela caso ela já exista, embora isso também não modifique uma estrutura criada anteriormente. Nos próximos exemplos, vamos considerar que a conexão já foi estabelecida e que temos acesso à variável db, do tipo *sql.DB. Os exemplos podem ser colocados em funções que recebam esse gerenciador de conexões.
Inserindo dados
A operação de inserção é responsável por adicionar novos registros a uma tabela. No SQL, essa operação é realizada pelo comando INSERT INTO, que permite informar a tabela, as colunas que receberão os dados e os valores que serão armazenados.
Imagine que precisamos cadastrar um produto chamado Teclado, com preço de R$ 159,90 e quantidade inicial de 10 unidades. Podemos executar a seguinte instrução.
-- Insere um novo produto.
INSERT INTO produtos (nome, preco_centavos, estoque)
VALUES ('Teclado', 15990, 10);
O comando INSERT INTO produtos indica em qual tabela o registro será inserido. Dentro dos primeiros parênteses, informamos as colunas que receberão os valores, enquanto VALUES contém as informações que serão armazenadas, seguindo a mesma ordem das colunas. Nesse exemplo, não precisamos informar o campo id, porque ele foi declarado como INTEGER PRIMARY KEY, permitindo que o SQLite escolha automaticamente um identificador. Também não informamos o campo ativo, então o banco preencherá essa coluna com o valor padrão 1.
Depois da inserção, o registro terá uma estrutura semelhante a esta.
| id | nome | preco_centavos | estoque | ativo |
|---|---|---|---|---|
| 1 | Teclado | 15990 | 10 | 1 |
Inserindo dados com Go
Para executar uma inserção pelo Go, usamos o método Exec() do database/sql. Esse método é indicado para comandos SQL que não precisam retornar linhas de resultado, como INSERT, UPDATE e DELETE.
// Insere um produto no banco.
resultado, err := db.Exec(
"INSERT INTO produtos (nome, preco_centavos, estoque) VALUES (?, ?, ?)",
"Teclado",
15990,
10,
)
if err != nil {
return err
}
O método db.Exec() recebe o comando SQL e os valores que serão enviados ao banco. Observe que usamos o símbolo ? no lugar de escrever os valores diretamente na consulta. Cada ? representa um parâmetro, que será preenchido pelo argumento correspondente. Nesse exemplo, o primeiro parâmetro recebe "Teclado", o segundo recebe 15990 e o terceiro recebe 10.
Essa separação permite que o driver trabalhe com os valores sem precisar montar comandos SQL concatenando informações. Além de facilitar o trabalho com valores dinâmicos, esse mecanismo ajuda a evitar SQL Injection, que acontece quando entradas são incorporadas de maneira insegura ao comando SQL. Vamos aprofundar esse assunto no tópico de consultas com segurança.
O método Exec() retorna dois valores, um sql.Result e um error. O primeiro contém informações sobre a operação executada, enquanto o segundo indica se ocorreu algum erro durante a execução.
Recuperando o ID do registro inserido
Depois de cadastrar um produto, podemos precisar conhecer seu identificador, principalmente quando outras operações dependem do registro que acabou de ser criado. O sql.Result oferece o método LastInsertId(), que permite recuperar o identificador gerado pela inserção quando esse recurso é suportado pelo driver e pelo banco.
// Insere um novo produto.
resultado, err := db.Exec(
"INSERT INTO produtos (nome, preco_centavos, estoque) VALUES (?, ?, ?)",
"Mouse",
7990,
20,
)
if err != nil {
return err
}
// Recupera o identificador gerado.
id, err := resultado.LastInsertId()
if err != nil {
return err
}
fmt.Println("Produto cadastrado com ID:", id)
Nesse exemplo, o SQLite gera automaticamente o identificador do produto, porque a coluna id foi definida como INTEGER PRIMARY KEY e não recebeu um valor explícito. O método LastInsertId() retorna esse identificador como um int64, permitindo que a aplicação continue trabalhando com o registro cadastrado. Para executar o exemplo, precisamos importar o pacote fmt, responsável pela impressão da mensagem.
Inserindo vários registros
O SQLite também permite inserir vários registros por meio de um único comando INSERT. Para isso, podemos informar diferentes conjuntos de valores, separados por vírgulas.
-- Insere três produtos em uma operação.
INSERT INTO produtos (nome, preco_centavos, estoque)
VALUES
('Monitor', 89990, 5),
('Mousepad', 2990, 30),
('Cabo USB', 1990, 50);
Cada conjunto de valores representa um novo registro. Isso evita executar um comando separado para cada produto quando já temos várias informações disponíveis para cadastro. No Go, também podemos executar essa operação usando Exec() e passando os valores como parâmetros.
// Insere vários produtos em uma única operação.
_, err := db.Exec(`
INSERT INTO produtos (nome, preco_centavos, estoque)
VALUES
(?, ?, ?),
(?, ?, ?),
(?, ?, ?)
`,
"Monitor", 89990, 5,
"Mousepad", 2990, 30,
"Cabo USB", 1990, 50,
)
if err != nil {
return err
}
Os valores continuam sendo passados separadamente, seguindo a ordem dos parâmetros presentes no comando SQL. Cada grupo de três parâmetros corresponde às informações de um produto. Uma diferença é que LastInsertId() não retorna uma lista com os identificadores de todos os registros inseridos. Para recuperar informações de vários registros criados, existem outros mecanismos, como a cláusula RETURNING, disponível no SQLite a partir da versão 3.35.0.
Buscando dados
A operação de busca é responsável por recuperar informações que já estão armazenadas no banco. No SQL, usamos o comando SELECT, que permite escolher quais colunas serão retornadas e de qual tabela os dados serão consultados.
-- Retorna todas as colunas da tabela.
SELECT * FROM produtos;
O comando SELECT indica que queremos consultar informações, enquanto o símbolo * representa todas as colunas disponíveis. Já FROM produtos indica que os dados devem ser buscados na tabela produtos. Embora SELECT * seja válido, normalmente é melhor informar explicitamente as colunas necessárias, porque isso evita buscar informações que não serão usadas e deixa mais claro quais dados a consulta deve retornar.
-- Retorna apenas as colunas informadas.
SELECT id, nome, preco_centavos
FROM produtos;
Nesse segundo exemplo, o banco retorna apenas o identificador, o nome e o preço de cada produto. As colunas estoque e ativo não fazem parte do resultado, porque não foram solicitadas.
Buscando dados com Go
O database/sql oferece dois métodos principais para executar consultas que retornam registros. O QueryRow() é usado quando esperamos trabalhar com apenas um registro, enquanto Query() é usado quando precisamos percorrer vários registros. Ambos trabalham com o comando SQL SELECT, mas retornam estruturas diferentes. O QueryRow() retorna um *sql.Row, enquanto o Query() retorna um *sql.Rows, que permite percorrer as linhas recebidas.
Podemos começar com uma consulta que retorna o primeiro produto, considerando a ordem dos identificadores.
// Variáveis que receberão os dados.
var id int64
var nome string
var preco int64
// Busca o primeiro produto pelo ID.
err := db.QueryRow(`
SELECT id, nome, preco_centavos
FROM produtos
ORDER BY id ASC
LIMIT 1
`).Scan(&id, &nome, &preco)
if err != nil {
return err
}
fmt.Println("ID:", id)
fmt.Println("Nome:", nome)
fmt.Println("Preço em centavos:", preco)
O método QueryRow() executa a consulta, enquanto Scan() copia os valores retornados para as variáveis informadas. Os argumentos de Scan() precisam corresponder às colunas selecionadas, tanto na quantidade quanto na ordem. Nesse exemplo, id recebe o identificador, nome recebe o texto correspondente e preco recebe o valor armazenado em preco_centavos. O operador & permite que Scan() modifique diretamente o conteúdo dessas variáveis.
Usamos ORDER BY id ASC para definir a ordem dos registros e LIMIT 1 para retornar apenas o primeiro. Sem uma ordenação explícita, o SQLite não garante qual registro será considerado primeiro. Também precisamos considerar que uma consulta pode não encontrar nenhum registro. Nesse caso, QueryRow() não retorna o erro diretamente, pois ele será disponibilizado quando chamarmos Scan().
Se a consulta não encontrar registros, Scan() retornará sql.ErrNoRows. Por isso, em uma aplicação real, precisamos diferenciar a ausência de resultados de outros erros, como problemas na consulta ou falhas de acesso ao banco.
Atualizando dados
A operação de atualização é responsável por modificar informações de registros que já existem no banco. No SQL, usamos o comando UPDATE, que permite indicar a tabela, as colunas que serão modificadas e quais registros devem receber os novos valores.
Imagine que o produto Teclado, identificado pelo ID 1, teve seu preço alterado de R$ 159,90 para R$ 149,90.
-- Atualiza o preço do produto com ID igual a 1.
UPDATE produtos
SET preco_centavos = 14990
WHERE id = 1;
O comando UPDATE produtos indica a tabela que será modificada. A palavra SET define quais colunas receberão novos valores, enquanto WHERE estabelece uma condição para selecionar os registros que devem ser atualizados. Nesse caso, apenas o produto cujo id seja igual a 1 será selecionado para atualização. As outras colunas permanecem com seus valores anteriores, porque não foram informadas no comando SET.
Também podemos modificar várias colunas em uma mesma operação.
-- Atualiza o preço e a quantidade em estoque.
UPDATE produtos
SET
preco_centavos = 14990,
estoque = 15
WHERE id = 1;
O SQLite permite informar várias atribuições depois de SET, separando cada uma por vírgula. Isso permite modificar diferentes informações do mesmo registro sem executar um comando separado para cada campo.
Atualizando dados com Go
No Go, usamos Exec() para executar comandos UPDATE, porque essa operação não precisa retornar linhas de resultado.
// Informações do produto que será atualizado.
id := int64(1)
novoPreco := int64(14990)
novoEstoque := int64(15)
// Atualiza o produto pelo identificador.
resultado, err := db.Exec(`
UPDATE produtos
SET preco_centavos = ?, estoque = ?
WHERE id = ?
`, novoPreco, novoEstoque, id)
if err != nil {
return err
}
// Consulta a quantidade de linhas afetadas.
linhas, err := resultado.RowsAffected()
if err != nil {
return err
}
fmt.Println("Linhas afetadas:", linhas)
Os parâmetros recebem os valores de novoPreco, novoEstoque e id, seguindo a ordem em que aparecem na consulta. Dessa maneira, conseguimos atualizar registros com valores recebidos de variáveis sem precisar modificar manualmente a string SQL. O método RowsAffected() retorna um número inteiro que representa a quantidade de linhas afetadas pelo comando.
Se nenhum produto corresponder à condição informada no WHERE, o SQLite não modifica nenhum registro e a operação normalmente retorna zero linhas afetadas, sem considerar isso um erro de execução. Podemos verificar esse resultado para identificar quando nenhum registro foi encontrado.
// Verifica se algum registro foi encontrado.
if linhas == 0 {
fmt.Println("Nenhum produto encontrado")
}
Cuidado ao atualizar registros
Um dos principais cuidados com UPDATE é não esquecer a condição WHERE quando queremos modificar apenas um registro.
-- Atualiza todos os produtos da tabela.
UPDATE produtos
SET preco_centavos = 14990;
Esse comando é válido, mas modifica o preço de todos os produtos cadastrados, porque não existe nenhuma condição restringindo os registros que devem ser atualizados. O WHERE não é obrigatório para executar um UPDATE, mas normalmente precisamos dele quando a intenção é modificar um produto específico. Também precisamos lembrar que o SQLite verifica as restrições definidas na tabela durante a atualização. Por exemplo, se tentarmos armazenar um preço negativo, a condição CHECK (preco_centavos >= 0) impedirá a operação.
Removendo dados
A operação de remoção é responsável por excluir registros que já estão armazenados no banco. No SQL, usamos o comando DELETE FROM, que permite indicar de qual tabela os dados serão removidos e, quando necessário, estabelecer uma condição.
Imagine que precisamos remover o produto cujo identificador é 3.
-- Remove o produto com ID igual a 3.
DELETE FROM produtos
WHERE id = 3;
O comando DELETE FROM produtos indica que queremos remover registros da tabela produtos. Já WHERE id = 3 restringe a operação ao registro que possui esse identificador. Quando o SQLite executa esse comando, o registro correspondente é removido da tabela. Se nenhum produto possuir o identificador informado, a operação não remove nenhum registro e não retorna um erro apenas por isso.
Removendo dados com Go
Assim como nas operações de inserção e atualização, usamos o método Exec() para executar um DELETE.
// Identificador do produto que será removido.
id := int64(3)
// Remove o produto pelo ID.
resultado, err := db.Exec(
"DELETE FROM produtos WHERE id = ?",
id,
)
if err != nil {
return err
}
// Verifica a quantidade de registros removidos.
linhas, err := resultado.RowsAffected()
if err != nil {
return err
}
fmt.Println("Registros removidos:", linhas)
O valor de id é enviado separadamente para o parâmetro ?, que faz parte da condição WHERE. O SQLite executa a remoção e o RowsAffected() informa quantas linhas foram afetadas. Podemos usar esse resultado para verificar se o produto existia, porque a ausência de um registro não representa necessariamente uma falha no comando SQL. A consulta pode ter sido executada corretamente, mas não encontrou nenhum produto correspondente à condição.
// Verifica se algum produto foi removido.
if linhas == 0 {
fmt.Println("Produto não encontrado")
} else {
fmt.Println("Produto removido com sucesso")
}
Cuidado ao remover registros
Assim como acontece com UPDATE, precisamos ter cuidado com comandos DELETE que não possuem uma condição WHERE.
-- Remove todos os registros da tabela.
DELETE FROM produtos;
Esse comando remove todos os registros da tabela produtos, mas não remove sua estrutura. Depois da operação, a tabela continua existindo, apenas sem os registros que estavam armazenados. Isso é diferente de DROP TABLE, que remove a própria tabela do banco, incluindo sua estrutura.
Também precisamos considerar que, depois de uma exclusão confirmada, não existe um comando automático de desfazer. Dependendo da situação, a recuperação pode exigir um backup ou uma transação que ainda não tenha sido confirmada.
Em sistemas que precisam manter um histórico de registros, também podemos trabalhar com exclusão lógica. Nesse modelo, em vez de remover o produto, modificamos uma coluna para indicar que ele está inativo.
-- Marca o produto como inativo sem removê-lo.
UPDATE produtos
SET ativo = 0
WHERE id = 3;
Nesse exemplo, o registro continua armazenado no banco, mas passa a ter ativo = 0. Isso permite manter suas informações e decidir quais produtos devem aparecer nas consultas, de acordo com as regras da aplicação.
Buscando por ID
A busca por identificador é uma das operações mais comuns em aplicações que trabalham com bancos relacionais. Como cada produto possui uma chave primária, podemos consultar um registro específico informando seu id, sem depender de campos que podem se repetir, como nome ou preço.
Para isso, usamos SELECT com uma condição WHERE.
-- Busca o produto que possui o ID igual a 2.
SELECT id, nome, preco_centavos, estoque, ativo
FROM produtos
WHERE id = 2;
Nesse exemplo, o SQLite procura o registro cujo identificador é igual a 2. Como id é uma chave primária, não podemos ter dois produtos com o mesmo identificador, então a consulta pode retornar no máximo um registro. Essa característica permite trabalhar diretamente com QueryRow(), que foi criado para consultas nas quais esperamos apenas uma linha de resultado.
Buscando um produto por ID com Go
Podemos criar uma função que receba o gerenciador de conexões e o identificador de um produto. A função será responsável por executar a consulta e retornar as informações encontradas.
func buscarProduto(db *sql.DB, id int64) error {
// Variáveis que receberão os dados.
var nome string
var preco int64
var estoque int64
var ativo int64
// Busca o produto pelo identificador.
err := db.QueryRow(`
SELECT nome, preco_centavos, estoque, ativo
FROM produtos
WHERE id = ?
`, id).Scan(
&nome,
&preco,
&estoque,
&ativo,
)
if err != nil {
return err
}
fmt.Println("ID:", id)
fmt.Println("Nome:", nome)
fmt.Println("Preço em centavos:", preco)
fmt.Println("Estoque:", estoque)
fmt.Println("Ativo:", ativo)
return nil
}
A função recebe db *sql.DB, que representa o gerenciador de conexões, e id int64, que representa o identificador do produto que queremos consultar. O método QueryRow() executa a consulta e Scan() copia as colunas retornadas para as variáveis correspondentes. Observe que não precisamos criar uma variável para receber o id do banco, porque já temos esse valor como argumento da função e não o incluímos no SELECT.
Podemos chamar essa função passando o identificador desejado.
// Busca o produto com ID igual a 2.
if err := buscarProduto(db, 2); err != nil {
return err
}
Tratando um registro não encontrado
Quando QueryRow() não encontra nenhum registro, o erro sql.ErrNoRows é retornado por Scan(). Esse erro precisa ser tratado de maneira diferente de outros problemas, porque ele indica apenas que a consulta não encontrou um registro correspondente.
Podemos melhorar a função anterior verificando especificamente essa condição.
func buscarProduto(db *sql.DB, id int64) error {
var nome string
var preco int64
var estoque int64
var ativo int64
// Busca o produto pelo ID.
err := db.QueryRow(`
SELECT nome, preco_centavos, estoque, ativo
FROM produtos
WHERE id = ?
`, id).Scan(
&nome,
&preco,
&estoque,
&ativo,
)
// Verifica se nenhum registro foi encontrado.
if errors.Is(err, sql.ErrNoRows) {
fmt.Println("Produto não encontrado")
return nil
}
// Trata outros erros da consulta.
if err != nil {
return err
}
fmt.Println("Nome:", nome)
fmt.Println("Preço:", preco)
fmt.Println("Estoque:", estoque)
fmt.Println("Ativo:", ativo)
return nil
}
Nesse exemplo, usamos errors.Is() para verificar se o erro recebido corresponde a sql.ErrNoRows. Para isso, precisamos importar o pacote errors, além do fmt que já aparece nos exemplos anteriores. Se o produto não existir, a função mostra uma mensagem e retorna nil, tratando a ausência do registro como uma situação esperada. Essa é apenas uma decisão do exemplo. Em aplicações maiores, normalmente precisamos considerar se a função deve devolver um erro específico para que outra parte do sistema decida como responder.
Se ocorrer outro erro, como uma consulta inválida ou um problema de acesso ao banco, ele será retornado normalmente. A diferença principal é que sql.ErrNoRows representa uma consulta sem resultados, enquanto outros erros podem indicar problemas na execução da operação.
Listando vários registros
Nem sempre precisamos buscar apenas um produto. Em sistemas de cadastro, é comum listar todos os registros disponíveis para mostrar suas informações em uma tabela, uma página ou uma resposta de API.
Para consultar vários registros, podemos executar um SELECT sem restringir a busca a um identificador específico.
-- Consulta todos os produtos cadastrados.
SELECT id, nome, preco_centavos, estoque, ativo
FROM produtos;
Essa consulta pode retornar várias linhas, dependendo da quantidade de produtos armazenados. Por isso, em Go, usamos o método Query() em vez de QueryRow(). O Query() retorna um *sql.Rows, que representa os resultados da consulta e permite percorrer cada registro individualmente.
Percorrendo resultados com Go
Para ler vários registros, usamos o método Next(), que avança para a próxima linha disponível no resultado. Enquanto existir uma linha para ser lida, o método retorna true.
// Consulta os produtos cadastrados.
rows, err := db.Query(`
SELECT id, nome, preco_centavos, estoque, ativo
FROM produtos
`)
if err != nil {
return err
}
// Libera os recursos da consulta ao terminar a função.
defer rows.Close()
// Percorre os registros retornados.
for rows.Next() {
var id int64
var nome string
var preco int64
var estoque int64
var ativo int64
// Copia os valores da linha atual.
err := rows.Scan(
&id,
&nome,
&preco,
&estoque,
&ativo,
)
if err != nil {
return err
}
fmt.Println(id, nome, preco, estoque, ativo)
}
// Verifica se ocorreu algum erro durante a leitura.
if err := rows.Err(); err != nil {
return err
}
O método db.Query() executa a consulta e retorna um *sql.Rows. Em seguida, usamos defer rows.Close() para garantir que os recursos associados ao resultado sejam liberados quando a função terminar. Dentro do for, o método rows.Next() avança para o próximo registro disponível. A cada iteração, criamos as variáveis que receberão as informações e chamamos rows.Scan() para copiar os valores da linha atual.
Quando não existem mais registros, rows.Next() retorna false e o laço termina. Entretanto, esse retorno também pode acontecer quando existe algum erro durante a leitura, por isso verificamos rows.Err() depois do laço. Se a consulta não encontrar nenhum produto, Query() normalmente retorna um resultado vazio, sem gerar sql.ErrNoRows. Nesse caso, o for simplesmente não executa nenhuma iteração.
Por que precisamos fechar o Rows?
O *sql.Rows mantém recursos associados à consulta, incluindo o acesso à conexão que está sendo usada. Enquanto esses recursos não são liberados, a conexão pode permanecer ocupada e indisponível para outras operações. Por isso, normalmente chamamos defer rows.Close() logo depois de verificar que db.Query() foi executado corretamente.
rows, err := db.Query("SELECT id, nome FROM produtos")
if err != nil {
return err
}
// Garante a liberação dos recursos.
defer rows.Close()
Quando percorremos todos os registros até o final, o database/sql normalmente fecha o resultado automaticamente. Mesmo assim, manter o defer rows.Close() é uma boa prática, porque também cobre situações em que a leitura termina antes de percorrer todas as linhas. Esse fechamento não encerra o sql.DB, ele apenas libera os recursos do resultado, permitindo que o gerenciador continue sendo usado em outras operações.
Filtrando registros com WHERE
O comando WHERE permite estabelecer condições para selecionar quais registros devem fazer parte de uma consulta. Em vez de buscar todos os produtos cadastrados, podemos solicitar apenas aqueles que atendem a determinados critérios.
Imagine que precisamos consultar somente os produtos que possuem unidades disponíveis no estoque.
-- Busca produtos com estoque maior que zero.
SELECT id, nome, estoque
FROM produtos
WHERE estoque > 0;
A condição estoque > 0 indica que o SQLite deve retornar apenas os registros em que a quantidade armazenada na coluna estoque seja maior que zero. Também podemos consultar produtos com preços específicos, buscar registros por nome ou combinar várias condições.
Operadores de comparação
O WHERE permite trabalhar com diferentes operadores para comparar valores.
| Operador | Significado |
|---|---|
= | Igual a |
<> | Diferente de |
!= | Diferente de |
> | Maior que |
< | Menor que |
>= | Maior ou igual a |
<= | Menor ou igual a |
Por exemplo, podemos buscar produtos que custam mais de R$ 100,00.
-- Busca produtos acima de R$ 100,00.
SELECT id, nome, preco_centavos
FROM produtos
WHERE preco_centavos > 10000;
O valor 10000 representa R$ 100,00 em centavos. Como os preços foram armazenados como números inteiros, podemos compará-los diretamente sem precisar converter os valores durante a consulta. Também podemos buscar produtos que possuem exatamente determinada quantidade em estoque.
-- Busca produtos com exatamente 10 unidades.
SELECT id, nome, estoque
FROM produtos
WHERE estoque = 10;
Combinando condições com AND e OR
O WHERE também permite combinar várias condições por meio dos operadores lógicos AND e OR. O operador AND exige que todas as condições sejam verdadeiras para que o registro faça parte do resultado.
-- Busca produtos ativos que possuem estoque.
SELECT id, nome, estoque
FROM produtos
WHERE ativo = 1 AND estoque > 0;
Nesse exemplo, o produto precisa estar ativo e possuir pelo menos uma unidade em estoque. Se uma das condições não for atendida, o registro não será retornado. Já o operador OR permite que o registro seja retornado quando pelo menos uma das condições for verdadeira.
-- Busca produtos sem estoque ou inativos.
SELECT id, nome, estoque, ativo
FROM produtos
WHERE estoque = 0 OR ativo = 0;
Também podemos combinar AND e OR na mesma consulta. Nesse caso, é recomendado usar parênteses para deixar explícito como as condições devem ser avaliadas.
-- Busca produtos ativos que possuem estoque ou preço baixo.
SELECT id, nome, preco_centavos, estoque
FROM produtos
WHERE ativo = 1
AND (estoque > 0 OR preco_centavos < 5000);
O SQLite avalia primeiro a expressão entre parênteses. Depois, verifica se o produto também possui ativo = 1. Sem os parênteses, a precedência dos operadores poderia produzir um resultado diferente do esperado.
Filtrando dados com Go
No Go, podemos passar os valores das condições como parâmetros da consulta. Isso permite criar filtros dinâmicos, de acordo com as informações recebidas pela aplicação.
// Valores usados no filtro.
precoMinimo := int64(5000)
estoqueMinimo := int64(0)
// Busca produtos ativos que atendem às condições.
rows, err := db.Query(`
SELECT id, nome, preco_centavos, estoque
FROM produtos
WHERE preco_centavos >= ?
AND estoque > ?
AND ativo = 1
`, precoMinimo, estoqueMinimo)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id int64
var nome string
var preco int64
var estoque int64
// Lê os dados do produto encontrado.
if err := rows.Scan(&id, &nome, &preco, &estoque); err != nil {
return err
}
fmt.Println(id, nome, preco, estoque)
}
return rows.Err()
Nesse exemplo, o primeiro parâmetro representa o preço mínimo e o segundo representa a quantidade mínima em estoque. O banco retorna apenas os produtos ativos que atendem às duas condições. Observe que os valores são passados separadamente, mas a estrutura do comando SQL continua definida pela aplicação. Esse é o comportamento esperado para consultas que recebem informações dinâmicas.
Buscando textos com LIKE
O operador LIKE permite procurar textos que correspondem a determinado padrão. Ele é bastante comum em campos de pesquisa, principalmente quando precisamos encontrar registros cujo nome contém uma palavra ou parte dela.
-- Busca produtos cujo nome começa com Mouse.
SELECT id, nome
FROM produtos
WHERE nome LIKE 'Mouse%';
O símbolo % representa uma sequência de zero ou mais caracteres. Nesse exemplo, o SQLite procura nomes que começam com Mouse, podendo existir outros caracteres depois dessa palavra. Também podemos colocar % antes e depois de uma palavra para buscar nomes que contenham determinado texto em qualquer posição.
-- Busca produtos que possuem USB no nome.
SELECT id, nome
FROM produtos
WHERE nome LIKE '%USB%';
O SQLite também reconhece o símbolo _ dentro de padrões LIKE, que representa exatamente um caractere. No Go, podemos montar o padrão de busca separadamente e passá-lo como parâmetro.
// Texto informado para pesquisa.
busca := "Mouse"
// Monta o padrão que será pesquisado.
padrao := "%" + busca + "%"
// Consulta os produtos correspondentes.
rows, err := db.Query(
"SELECT id, nome FROM produtos WHERE nome LIKE ?",
padrao,
)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id int64
var nome string
if err := rows.Scan(&id, &nome); err != nil {
return err
}
fmt.Println(id, nome)
}
return rows.Err()
Nesse exemplo, a concatenação acontece apenas para criar o valor do parâmetro, não para modificar a estrutura do comando SQL. O padrão %Mouse% é enviado separadamente ao driver, permitindo que o SQLite faça a comparação. Por padrão, o LIKE do SQLite não diferencia letras maiúsculas e minúsculas para os caracteres ASCII comuns, como A e a. Entretanto, esse comportamento não se estende automaticamente a todos os caracteres Unicode, como letras acentuadas.
Também precisamos considerar que % e _ possuem significado especial no LIKE. Se a pesquisa precisar tratar esses caracteres como texto comum, será necessário configurar um caractere de escape.
Comparando valores NULL
Quando uma coluna aceita NULL, não devemos usar os operadores comuns de igualdade para verificar a ausência de informação. Por exemplo, a expressão abaixo não encontra corretamente os registros cujo valor é NULL.
-- Não é a forma correta de buscar valores NULL.
SELECT id, nome
FROM produtos
WHERE nome = NULL;
Isso acontece porque NULL representa a ausência de um valor e as comparações comuns envolvendo NULL normalmente produzem um resultado desconhecido, em vez de verdadeiro ou falso. Para verificar valores NULL, usamos IS NULL ou IS NOT NULL.
-- Busca registros sem um valor no campo informado.
SELECT id, nome
FROM produtos
WHERE nome IS NULL;
Na tabela dos exemplos, a coluna nome possui NOT NULL, então essa consulta não deverá retornar registros válidos. Entretanto, o mesmo mecanismo pode ser usado em tabelas com campos opcionais. Para buscar registros em que uma coluna possui algum valor, usamos IS NOT NULL.
-- Busca registros com nome informado.
SELECT id, nome
FROM produtos
WHERE nome IS NOT NULL;
Ordenando resultados
Quando executamos uma consulta SQL, o banco não garante a ordem dos registros retornados, a menos que uma ordenação seja definida explicitamente. Para controlar essa ordem, usamos a cláusula ORDER BY, que permite organizar os resultados de acordo com uma ou mais colunas.
Imagine que precisamos listar os produtos pelo nome, em ordem alfabética.
-- Ordena os produtos pelo nome.
SELECT id, nome, preco_centavos
FROM produtos
ORDER BY nome ASC;
A palavra ORDER BY indica que os resultados devem ser ordenados pela coluna nome. Já ASC representa a ordem crescente, que é o comportamento padrão quando não informamos uma direção. Também podemos ordenar os resultados em ordem decrescente usando DESC.
-- Ordena os produtos do maior para o menor preço.
SELECT id, nome, preco_centavos
FROM produtos
ORDER BY preco_centavos DESC;
Nesse caso, os produtos com os maiores valores em preco_centavos aparecem primeiro, enquanto os produtos mais baratos aparecem depois.
ASC e DESC
Os dois principais modificadores de ordenação são ASC e DESC.
| Modificador | Comportamento |
|---|---|
ASC | Ordena em ordem crescente. |
DESC | Ordena em ordem decrescente. |
Para números, a ordem crescente começa pelos menores valores, enquanto a decrescente começa pelos maiores. Para textos, a ordenação depende das regras de comparação usadas pelo banco. Por exemplo, podemos ordenar os produtos do menor para o maior estoque.
-- Produtos com menor estoque aparecem primeiro.
SELECT id, nome, estoque
FROM produtos
ORDER BY estoque ASC;
Ordenando por várias colunas
O SQLite também permite ordenar os resultados por mais de uma coluna. Isso é útil quando diferentes registros possuem o mesmo valor na primeira coluna usada na ordenação.
-- Ordena por preço e depois pelo identificador.
SELECT id, nome, preco_centavos
FROM produtos
ORDER BY preco_centavos DESC, id ASC;
Nesse exemplo, os produtos são organizados primeiro pelo preço, do maior para o menor. Quando dois produtos possuem o mesmo preço, o SQLite compara seus identificadores e organiza esses registros por id em ordem crescente. Essa segunda condição permite definir uma ordem consistente mesmo quando existem valores repetidos na primeira coluna.
Ordenando resultados com Go
No Go, a ordenação faz parte do próprio comando SQL. O método Query() executa a consulta e devolve os registros na ordem definida pelo banco.
// Consulta os produtos ordenados por preço.
rows, err := db.Query(`
SELECT id, nome, preco_centavos
FROM produtos
ORDER BY preco_centavos DESC, id ASC
`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id int64
var nome string
var preco int64
if err := rows.Scan(&id, &nome, &preco); err != nil {
return err
}
fmt.Println(id, nome, preco)
}
return rows.Err()
Nesse exemplo, não precisamos ordenar os dados manualmente depois que eles chegam ao Go, porque o SQLite já executa essa operação durante a consulta. Também precisamos considerar que ordenar por texto não significa necessariamente aplicar todas as regras de ordenação alfabética da língua portuguesa. O SQLite possui mecanismos chamados collations, que definem como valores textuais devem ser comparados.
A configuração padrão BINARY compara os valores de acordo com sua representação em texto. Já NOCASE permite ignorar diferenças entre letras maiúsculas e minúsculas ASCII comuns.
-- Ordena os nomes ignorando diferenças comuns de maiúsculas.
SELECT id, nome
FROM produtos
ORDER BY nome COLLATE NOCASE ASC;
O COLLATE NOCASE não oferece, por padrão, todas as regras de comparação de caracteres acentuados ou outros caracteres Unicode. Para aplicações que precisam de uma ordenação linguística mais completa, podem ser necessárias outras configurações.
Limitando resultados
Quando uma tabela possui muitos registros, nem sempre precisamos retornar todas as informações de uma vez. Buscar uma quantidade muito grande de registros pode aumentar o trabalho do banco e o volume de dados que a aplicação precisa processar. Para controlar a quantidade de linhas retornadas por uma consulta, usamos a cláusula LIMIT.
Imagine que precisamos buscar apenas os cinco primeiros produtos, considerando a ordem dos identificadores.
-- Retorna no máximo cinco produtos.
SELECT id, nome, preco_centavos
FROM produtos
ORDER BY id ASC
LIMIT 5;
O LIMIT 5 informa que a consulta deve retornar no máximo cinco registros. Se existirem menos de cinco produtos, o SQLite retornará apenas os registros disponíveis. Observe que usamos ORDER BY id ASC antes de LIMIT. Isso é necessário quando queremos definir quais registros devem aparecer primeiro, porque uma consulta sem ordenação não garante uma ordem específica.
Limitando resultados com Go
Podemos passar a quantidade máxima de registros como parâmetro da consulta.
// Quantidade máxima de produtos.
limite := 5
// Consulta os primeiros produtos.
rows, err := db.Query(`
SELECT id, nome, preco_centavos
FROM produtos
ORDER BY id ASC
LIMIT ?
`, limite)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id int64
var nome string
var preco int64
if err := rows.Scan(&id, &nome, &preco); err != nil {
return err
}
fmt.Println(id, nome, preco)
}
return rows.Err()
Nesse exemplo, o valor de limite determina a quantidade máxima de registros que a consulta poderá retornar. Como o valor é passado separadamente, podemos alterá-lo conforme as necessidades da aplicação, sem precisar modificar o comando SQL. Quando o limite vem de uma entrada externa, é recomendado validar o valor antes de executar a consulta. Isso evita números negativos, limites excessivamente altos ou outras condições que não fazem sentido para a operação.
Ignorando registros com OFFSET
Além de limitar a quantidade de resultados, podemos informar quantos registros devem ser ignorados antes de começar a retornar os dados. Para isso, usamos a cláusula OFFSET, normalmente combinada com LIMIT.
-- Ignora os cinco primeiros produtos e retorna os próximos cinco.
SELECT id, nome, preco_centavos
FROM produtos
ORDER BY id ASC
LIMIT 5 OFFSET 5;
O LIMIT 5 estabelece que queremos receber no máximo cinco registros, enquanto OFFSET 5 indica que os cinco primeiros registros devem ser ignorados. Dessa forma, considerando a ordenação pelo identificador, a consulta começa a retornar os produtos a partir da sexta posição.
Podemos modificar o OFFSET para consultar diferentes partes do resultado.
| LIMIT | OFFSET | Registros retornados |
|---|---|---|
| 5 | 0 | Do 1º ao 5º |
| 5 | 5 | Do 6º ao 10º |
| 5 | 10 | Do 11º ao 15º |
| 5 | 15 | Do 16º ao 20º |
Essa combinação é bastante comum em sistemas que precisam dividir os registros em páginas, evitando retornar todas as informações em uma única consulta.
Criando uma paginação simples
A paginação é um mecanismo que permite dividir os resultados de uma consulta em grupos menores. Imagine uma aplicação que possui 100 produtos cadastrados, mas precisa mostrar apenas 10 produtos por página. Em vez de retornar todos os registros, podemos calcular quantos devem ser ignorados para encontrar o início da página solicitada.
O cálculo do deslocamento pode ser feito pela seguinte expressão.
offset = (pagina - 1) * limite
Por exemplo, se estamos consultando a terceira página e cada página possui 10 produtos, o deslocamento será (3 - 1) * 10, resultando em 20. Isso significa que a consulta deve ignorar os primeiros 20 registros e retornar os próximos 10.
Podemos implementar esse comportamento em Go usando LIMIT e OFFSET como parâmetros.
func listarPagina(db *sql.DB, pagina, limite int) error {
// Impede números de página inválidos.
if pagina < 1 {
return fmt.Errorf("a página deve ser maior que zero")
}
// Impede limites inválidos ou excessivos.
if limite < 1 || limite > 100 {
return fmt.Errorf("o limite deve estar entre 1 e 100")
}
// Calcula quantos registros serão ignorados.
offset := (pagina - 1) * limite
// Consulta os produtos da página solicitada.
rows, err := db.Query(`
SELECT id, nome, preco_centavos
FROM produtos
ORDER BY id ASC
LIMIT ? OFFSET ?
`, limite, offset)
if err != nil {
return err
}
defer rows.Close()
// Percorre os registros da página.
for rows.Next() {
var id int64
var nome string
var preco int64
if err := rows.Scan(&id, &nome, &preco); err != nil {
return err
}
fmt.Println(id, nome, preco)
}
return rows.Err()
}
A função recebe o gerenciador de conexões, o número da página e a quantidade máxima de registros que devem ser retornados. Primeiro, verificamos se os valores informados são válidos. Depois, calculamos o offset e executamos a consulta com os parâmetros correspondentes.
O ORDER BY id ASC estabelece uma ordem consistente, enquanto LIMIT determina a quantidade máxima de resultados e OFFSET informa quantos registros devem ser ignorados. O laço for percorre os produtos retornados, e rows.Err() permite verificar se ocorreu algum erro durante a leitura.
Podemos chamar a função informando a página desejada.
// Retorna a primeira página com até 10 produtos.
if err := listarPagina(db, 1, 10); err != nil {
return err
}
// Retorna a segunda página com até 10 produtos.
if err := listarPagina(db, 2, 10); err != nil {
return err
}
Nesse exemplo, a primeira chamada retorna até 10 produtos a partir do primeiro registro. A segunda chamada ignora os 10 primeiros e retorna os próximos 10, considerando a ordenação definida na consulta.
Uma característica importante é que OFFSET trabalha com posições dentro do resultado, e não com identificadores. Isso significa que OFFSET 10 não procura o produto com ID 10, mas ignora os 10 primeiros registros da consulta. Se determinados produtos forem removidos ou novos registros forem inseridos entre duas consultas, os resultados das páginas também poderão mudar.
Além disso, deslocamentos muito grandes podem aumentar o trabalho do banco, porque ele ainda precisa percorrer ou localizar os registros anteriores ao ponto solicitado. Em aplicações que precisam trabalhar com grandes quantidades de dados, também podemos usar paginação baseada em identificadores, conhecida como keyset pagination.
Nesse modelo, em vez de informar quantos registros devem ser ignorados, informamos o último identificador recebido na consulta anterior.
-- Retorna os próximos produtos depois do ID informado.
SELECT id, nome, preco_centavos
FROM produtos
WHERE id > 10
ORDER BY id ASC
LIMIT 5;
Nesse exemplo, a consulta retorna até cinco produtos cujo identificador seja maior que 10, seguindo a ordem crescente. Esse mecanismo pode evitar o custo de percorrer muitos registros anteriores, principalmente quando existe um índice adequado para a condição de busca.
A paginação com LIMIT e OFFSET continua sendo uma opção simples para sistemas menores ou consultas com deslocamentos moderados. Já a paginação baseada em identificadores pode ser interessante quando precisamos percorrer grandes conjuntos de dados seguindo uma ordem bem definida.