Consultas com segurança
Quando uma aplicação trabalha com banco de dados, nem todas as informações enviadas para as consultas são definidas diretamente no código. Muitas vezes, precisamos receber valores informados por usuários, como nomes, identificadores, e-mails ou filtros de pesquisa. Essas informações não devem ser incorporadas diretamente aos comandos SQL, porque podem conter caracteres que modificam o comportamento da consulta.
O pacote database/sql oferece mecanismos para enviar valores separadamente dos comandos SQL, permitindo que o driver trate essas informações como dados, e não como parte da instrução. Além disso, precisamos tratar corretamente os erros retornados pelo banco, identificar quando uma consulta não encontra registros e controlar o tempo que uma operação pode permanecer em execução.
Para acompanhar os exemplos, vamos continuar trabalhando com a tabela produtos, criada anteriormente.
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))
);
Os exemplos consideram que o banco SQLite já foi aberto e que temos acesso à variável db, do tipo *sql.DB. Os trechos que retornam erros podem ser colocados dentro de funções que recebam esse gerenciador de conexões.
Usando parâmetros nas consultas
Quando precisamos executar uma consulta com valores que podem mudar, podemos passar esses valores como parâmetros em vez de escrevê-los diretamente no comando SQL. Um parâmetro representa uma posição reservada para receber um valor durante a execução da consulta, permitindo separar a estrutura do comando das informações que serão enviadas ao banco.
No SQLite, uma das formas de representar parâmetros é usando o símbolo ?, conhecido como placeholder. Cada placeholder representa um valor que será informado separadamente ao executar a operação.
Imagine que precisamos buscar um produto pelo identificador.
-- Busca um produto com ID igual a 5.
SELECT id, nome, preco_centavos
FROM produtos
WHERE id = 5;
Nesse exemplo, o valor 5 faz parte diretamente do comando SQL. Isso funciona quando já conhecemos o identificador, mas em uma aplicação normalmente precisamos buscar produtos com valores diferentes, recebidos por meio de variáveis.
Em Go, podemos substituir esse valor por um placeholder.
// Identificador do produto.
id := int64(5)
// Variáveis que receberão os dados.
var nome string
var preco int64
// Executa a consulta passando o ID separadamente.
err := db.QueryRow(`
SELECT nome, preco_centavos
FROM produtos
WHERE id = ?
`, id).Scan(&nome, &preco)
if err != nil {
return err
}
O símbolo ? indica onde o valor de id será associado à consulta. Observe que o identificador não é concatenado à string SQL, ele é passado como um argumento separado para QueryRow(). O database/sql trabalha em conjunto com o driver para preparar e executar a operação, associando o valor ao parâmetro correspondente.
Essa separação permite executar a mesma estrutura de consulta com diferentes identificadores, sem precisar modificar manualmente o comando SQL.
Usando vários parâmetros
Uma consulta pode possuir vários placeholders, permitindo enviar diferentes valores em uma mesma operação. Nesse caso, precisamos observar a ordem em que eles aparecem no comando SQL, porque os argumentos são associados às posições correspondentes.
Imagine que precisamos buscar produtos ativos com um preço mínimo e determinada quantidade em estoque.
// Valores que serão usados na consulta.
precoMinimo := int64(5000)
estoqueMinimo := int64(10)
// Executa a consulta com três parâmetros.
rows, err := db.Query(`
SELECT id, nome, preco_centavos, estoque
FROM produtos
WHERE preco_centavos >= ?
AND estoque >= ?
AND ativo = ?
`, precoMinimo, estoqueMinimo, 1)
if err != nil {
return err
}
// Libera os recursos da consulta.
defer rows.Close()
for rows.Next() {
var id int64
var nome string
var preco int64
var estoque int64
if err := rows.Scan(&id, &nome, &preco, &estoque); err != nil {
return err
}
fmt.Println(id, nome, preco, estoque)
}
return rows.Err()
O primeiro placeholder recebe precoMinimo, o segundo recebe estoqueMinimo e o terceiro recebe o valor 1. Cada argumento corresponde a uma condição da consulta, seguindo a ordem em que os placeholders foram escritos. Se essa ordem for alterada incorretamente, a consulta poderá retornar resultados diferentes dos esperados, mesmo sem apresentar um erro.
Os parâmetros também podem ser usados em comandos INSERT, UPDATE e DELETE, porque não são exclusivos das operações de consulta.
// Insere um produto usando parâmetros.
_, err := db.Exec(
"INSERT INTO produtos (nome, preco_centavos, estoque) VALUES (?, ?, ?)",
"Teclado",
15990,
10,
)
if err != nil {
return err
}
Nesse exemplo, os três valores são enviados separadamente para o SQLite. O mesmo mecanismo pode ser aplicado a operações de atualização e remoção, permitindo trabalhar com informações dinâmicas sem precisar alterar a estrutura do comando SQL.
Parâmetros nomeados
Além do placeholder ?, o SQLite oferece outras formas de representar parâmetros. Uma delas é usar nomes, permitindo identificar diretamente qual informação deve ser associada a determinada posição da consulta.
Podemos escrever parâmetros nomeados usando prefixos como :, @ e $. No Go, o pacote database/sql oferece a função sql.Named(), que permite informar o nome e o valor de um parâmetro.
// Nome que será pesquisado.
nome := "Teclado"
var id int64
// Executa uma consulta com parâmetro nomeado.
err := db.QueryRow(`
SELECT id
FROM produtos
WHERE nome = :nome
`, sql.Named("nome", nome)).Scan(&id)
if err != nil {
return err
}
Nesse exemplo, :nome representa o parâmetro definido no comando SQL. Já sql.Named("nome", nome) associa o conteúdo da variável nome ao parâmetro correspondente. Observe que o primeiro argumento de sql.Named() contém apenas o nome, sem o caractere :.
Os parâmetros nomeados podem facilitar a leitura de consultas maiores, principalmente quando existem várias condições. Entretanto, precisamos considerar que a sintaxe aceita depende do banco e do driver. Para os próximos exemplos com SQLite, vamos continuar usando ?, porque é suficiente para as operações que estamos estudando.
Evitando SQL Injection
SQL Injection é uma vulnerabilidade que acontece quando informações externas são incorporadas de maneira insegura a um comando SQL, permitindo que uma entrada modifique a estrutura ou o comportamento da consulta. Isso pode acontecer quando uma aplicação monta comandos SQL concatenando strings recebidas de usuários.
Imagine que temos uma função responsável por buscar produtos pelo nome. Uma maneira insegura de construir essa consulta seria concatenar diretamente o nome informado.
// Nome recebido pela aplicação.
nome := "Teclado"
// Forma insegura de construir uma consulta.
query := "SELECT id, nome FROM produtos WHERE nome = '" + nome + "'"
// Executa o comando montado.
rows, err := db.Query(query)
Quando o nome recebido é "Teclado", o comando produzido parece correto.
SELECT id, nome
FROM produtos
WHERE nome = 'Teclado';
O problema é que a aplicação não controla necessariamente o conteúdo da variável nome. Se essa informação vier de um formulário, uma API ou outra entrada externa, ela pode conter caracteres que fazem parte da sintaxe SQL.
Por exemplo, imagine que o valor informado seja:
' OR 1=1 --
Como a aplicação concatena esse conteúdo diretamente na string SQL, o comando resultante seria semelhante a este.
SELECT id, nome
FROM produtos
WHERE nome = '' OR 1=1 --';
Observe que o conteúdo informado modificou a condição da consulta. A expressão OR 1=1 faz com que a condição seja verdadeira para todos os registros, enquanto -- inicia um comentário SQL, fazendo com que o restante daquela linha seja ignorado.
Em vez de buscar apenas um produto pelo nome, a consulta pode retornar todos os produtos da tabela. Esse é um exemplo simples de SQL Injection, mas a gravidade depende das operações disponíveis, das permissões do banco e da maneira como a aplicação constrói seus comandos.
Evitando a vulnerabilidade com parâmetros
Para evitar esse problema, precisamos manter a estrutura SQL separada dos valores recebidos pela aplicação. Em vez de concatenar o nome ao comando, podemos passar seu conteúdo como um parâmetro.
// Nome recebido pela aplicação.
nome := "' OR 1=1 --"
// Executa a consulta com um placeholder.
rows, err := db.Query(
"SELECT id, nome FROM produtos WHERE nome = ?",
nome,
)
if err != nil {
return err
}
defer rows.Close()
Nesse exemplo, o conteúdo da variável nome é enviado como um valor de texto, e não como parte do comando SQL. O SQLite não interpreta OR 1=1 como uma nova condição, porque esses caracteres fazem parte do valor associado ao placeholder.
A consulta continua procurando um produto cujo nome seja exatamente igual ao texto informado. Se nenhum registro possuir esse nome, o resultado será vazio, sem que a estrutura da consulta seja modificada.
O principal cuidado é nunca concatenar diretamente valores externos à estrutura SQL quando eles deveriam ser enviados como parâmetros. Isso vale para nomes, identificadores, e-mails, filtros e outras informações recebidas pela aplicação.
Os parâmetros protegem a estrutura da consulta, mas não substituem outras verificações necessárias. Uma aplicação ainda precisa validar as informações recebidas, controlar quais operações cada usuário pode executar e evitar expor dados que não deveriam estar disponíveis.
Diferença entre montar SQL manualmente e usar placeholders
Montar SQL manualmente significa construir um comando por meio de strings, normalmente concatenando valores ou usando funções de formatação. Isso não é necessariamente um problema quando estamos trabalhando apenas com partes fixas e controladas pela aplicação, mas se torna perigoso quando valores externos são incorporados diretamente à instrução.
Imagine que precisamos consultar produtos com um preço mínimo informado pelo usuário. Poderíamos construir a consulta usando fmt.Sprintf().
// Preço mínimo recebido como texto.
precoMinimo := "5000"
// Monta o comando SQL inserindo o valor diretamente.
query := fmt.Sprintf(
"SELECT id, nome FROM produtos WHERE preco_centavos >= %s",
precoMinimo,
)
// Executa o comando.
rows, err := db.Query(query)
O fmt.Sprintf() substitui %s pelo conteúdo da variável, produzindo uma nova string SQL. Como esse conteúdo é incorporado diretamente ao comando, ele pode modificar sua estrutura caso contenha instruções SQL inesperadas. Esse tipo de construção não deve ser usado para inserir valores externos nas consultas.
A forma adequada é manter o comando fixo e passar o preço como parâmetro.
// Preço mínimo já convertido e validado.
precoMinimo := int64(5000)
// Executa a consulta sem concatenar o valor.
rows, err := db.Query(
"SELECT id, nome FROM produtos WHERE preco_centavos >= ?",
precoMinimo,
)
if err != nil {
return err
}
defer rows.Close()
Nesse segundo exemplo, o valor de precoMinimo é associado ao placeholder, enquanto a estrutura do comando permanece definida pela aplicação. Além de evitar a interpretação dos valores como SQL, essa separação torna o código mais simples de manter, porque não precisamos adicionar aspas ou realizar conversões manuais para montar a consulta.
O que não pode ser substituído por placeholders?
Os placeholders são usados para representar valores de uma consulta, mas não substituem elementos que fazem parte de sua estrutura, como nomes de tabelas, nomes de colunas ou palavras-chave SQL.
Por exemplo, não podemos passar o nome de uma coluna como parâmetro de uma cláusula ORDER BY e esperar que o SQLite interprete esse valor como um identificador.
// Nome da coluna desejada.
coluna := "nome"
// Não ordena pela coluna indicada na variável.
rows, err := db.Query(
"SELECT id, nome FROM produtos ORDER BY ?",
coluna,
)
Nesse caso, o placeholder representa o valor de texto "nome", e não a coluna nome. A consulta pode ser executada, mas não produzirá a ordenação esperada, porque o SQLite estará ordenando por uma expressão constante, e não pelos valores da coluna.
Quando precisamos trabalhar com partes dinâmicas da estrutura SQL, uma alternativa é definir explicitamente quais valores são permitidos e construir o comando apenas com opções controladas pela aplicação.
// Campo de ordenação recebido pela aplicação.
ordenarPor := "preco"
// Define as colunas permitidas.
var coluna string
switch ordenarPor {
case "nome":
coluna = "nome"
case "preco":
coluna = "preco_centavos"
case "estoque":
coluna = "estoque"
default:
return fmt.Errorf("campo de ordenação inválido")
}
// Monta o comando com uma coluna previamente permitida.
query := fmt.Sprintf(`
SELECT id, nome, preco_centavos
FROM produtos
ORDER BY %s ASC
`, coluna)
// Executa a consulta.
rows, err := db.Query(query)
if err != nil {
return err
}
defer rows.Close()
Nesse exemplo, a variável coluna recebe apenas nomes definidos diretamente no código. Mesmo que ordenarPor venha de uma entrada externa, seu conteúdo não é inserido diretamente no comando SQL, porque primeiro passa por uma verificação que aceita somente as opções conhecidas.
Essa técnica é conhecida como lista de valores permitidos (allowlist). Ela é usada quando precisamos permitir determinadas partes dinâmicas de um comando SQL, mas não podemos representá-las por parâmetros. O mesmo cuidado se aplica a nomes de tabelas e direções de ordenação, como ASC e DESC.
Tratando erros em consultas
As operações com banco de dados podem falhar por diferentes motivos, como comandos SQL inválidos, problemas de conexão, valores incompatíveis com a estrutura da tabela ou violações de restrições. Por isso, precisamos verificar os erros retornados pelos métodos do database/sql, evitando que a aplicação continue trabalhando como se uma operação tivesse sido executada corretamente.
Em Go, os métodos responsáveis por executar operações normalmente retornam um valor do tipo error. Quando esse valor é diferente de nil, significa que ocorreu algum problema durante a execução.
Imagine que precisamos cadastrar um produto.
// Executa a inserção.
_, err := db.Exec(
"INSERT INTO produtos (nome, preco_centavos) VALUES (?, ?)",
"Teclado",
15990,
)
// Verifica se ocorreu algum erro.
if err != nil {
return err
}
O método Exec() retorna um sql.Result e um error. Como não precisamos das informações do resultado nesse exemplo, usamos _ para ignorar o primeiro retorno. Já o erro precisa ser verificado, porque a operação pode ter falhado e o produto pode não ter sido cadastrado.
Se a tabela produtos não existir, por exemplo, o SQLite retornará um erro indicando que não encontrou a tabela. Também podemos receber erros quando tentamos armazenar informações que violam as regras definidas durante a criação da estrutura.
Erros de restrições do banco
As restrições são regras definidas nas tabelas para controlar quais valores podem ser armazenados. Na tabela produtos, por exemplo, definimos que o preço não pode ser negativo por meio de CHECK (preco_centavos >= 0).
Se tentarmos cadastrar um produto com preço negativo, a operação será rejeitada.
// Tenta cadastrar um produto com preço negativo.
_, err := db.Exec(
"INSERT INTO produtos (nome, preco_centavos) VALUES (?, ?)",
"Teclado",
-100,
)
if err != nil {
fmt.Println("Erro ao cadastrar produto:", err)
return err
}
Nesse caso, o SQLite retorna um erro porque o valor informado não atende à restrição CHECK. O mesmo pode acontecer quando tentamos armazenar NULL em uma coluna NOT NULL ou repetir um valor protegido por uma restrição UNIQUE.
O database/sql permite receber esses erros, mas os detalhes específicos dependem do driver. Se precisarmos identificar exatamente qual restrição foi violada, podemos trabalhar com os códigos de erro fornecidos pelo SQLite por meio da biblioteca escolhida.
Não é recomendado depender apenas do texto de uma mensagem de erro para identificar sua causa, porque essas mensagens podem variar entre drivers e versões. Também devemos evitar devolver mensagens internas do banco diretamente para usuários finais, já que elas podem revelar informações sobre a estrutura da aplicação.
Adicionando contexto aos erros
Quando uma aplicação possui várias funções responsáveis por acessar o banco, retornar apenas o erro original pode dificultar a identificação da operação que falhou. Para resolver isso, podemos adicionar uma mensagem que descreva o que estávamos tentando executar.
O pacote fmt oferece a função fmt.Errorf(), que permite criar uma mensagem de erro e preservar o erro original usando %w.
func cadastrarProduto(
db *sql.DB,
nome string,
preco int64,
) error {
// Executa a inserção.
_, err := db.Exec(
"INSERT INTO produtos (nome, preco_centavos) VALUES (?, ?)",
nome,
preco,
)
if err != nil {
return fmt.Errorf("erro ao cadastrar produto: %w", err)
}
return nil
}
Nesse exemplo, se a inserção falhar, a função retorna uma mensagem indicando que o problema aconteceu durante o cadastro de um produto. O %w preserva o erro original dentro do novo erro, permitindo que outras partes da aplicação verifiquem sua causa posteriormente.
Essa prática também é conhecida como encadeamento de erros (error wrapping). Ela permite adicionar informações ao erro conforme ele passa pelas diferentes funções da aplicação, sem perder a possibilidade de identificar erros específicos.
Tratando erros durante a leitura
Quando trabalhamos com Query(), os erros não acontecem necessariamente apenas na execução inicial da consulta. Eles também podem aparecer durante a leitura dos registros, principalmente quando o resultado possui várias linhas.
Por isso, precisamos verificar os erros retornados por Query(), Scan() e Rows.Err().
// Executa a consulta.
rows, err := db.Query(`
SELECT id, nome
FROM produtos
`)
if err != nil {
return fmt.Errorf("erro ao consultar produtos: %w", err)
}
defer rows.Close()
// Percorre os registros.
for rows.Next() {
var id int64
var nome string
// Lê os valores da linha atual.
if err := rows.Scan(&id, &nome); err != nil {
return fmt.Errorf("erro ao ler produto: %w", err)
}
fmt.Println(id, nome)
}
// Verifica se ocorreu algum erro durante a leitura.
if err := rows.Err(); err != nil {
return fmt.Errorf("erro ao percorrer produtos: %w", err)
}
O método Query() pode retornar um erro caso a consulta não consiga ser iniciada. Já Scan() pode falhar quando não consegue converter ou copiar os valores retornados para as variáveis informadas. Por exemplo, tentar armazenar um texto que não representa um número em uma variável inteira pode provocar um erro de conversão.
O método rows.Next() retorna false tanto quando os registros terminam quanto quando acontece algum problema durante a leitura. Por isso, precisamos chamar rows.Err() depois do laço, permitindo distinguir o fim normal dos resultados de uma falha na operação.
Também usamos defer rows.Close() para liberar os recursos associados ao resultado quando a função terminar. Isso permite que a conexão seja devolvida ao pool e fique disponível para outras operações.
Verificando operações de atualização e remoção
Nem toda operação que não modifica registros representa um erro. Imagine que precisamos atualizar um produto pelo identificador, mas nenhum registro possui o ID informado. Nesse caso, o comando SQL pode ser executado corretamente, mesmo sem encontrar um produto correspondente.
Podemos verificar essa situação por meio do método RowsAffected().
// Atualiza o preço do produto.
resultado, err := db.Exec(
"UPDATE produtos SET preco_centavos = ? WHERE id = ?",
14990,
10,
)
if err != nil {
return err
}
// Recupera a quantidade de linhas afetadas.
linhas, err := resultado.RowsAffected()
if err != nil {
return err
}
// Verifica se algum registro foi encontrado.
if linhas == 0 {
fmt.Println("Nenhum produto encontrado")
}
O método RowsAffected() retorna a quantidade de linhas afetadas pelo comando, quando o banco e o driver oferecem esse recurso. Se o resultado for 0, significa que a operação não afetou nenhum registro. No exemplo, isso normalmente indica que não existe um produto com o identificador informado.
O mesmo mecanismo pode ser usado em operações DELETE, permitindo verificar se algum registro foi removido. Entretanto, precisamos lembrar que uma operação pode afetar uma linha mesmo quando o novo valor informado é igual ao valor que já estava armazenado.
Tratando resultado não encontrado
Quando buscamos um registro específico, precisamos considerar que ele pode não existir. Isso acontece frequentemente em consultas por identificador, porque a aplicação pode receber um ID que já foi removido ou que nunca esteve cadastrado.
No database/sql, o método QueryRow() possui um comportamento específico para essas situações. Ele retorna um *sql.Row, mas os erros da consulta são disponibilizados quando chamamos Scan().
Se nenhum registro for encontrado, Scan() retorna o erro sql.ErrNoRows.
// Identificador que será pesquisado.
id := int64(10)
var nome string
// Busca o nome de um produto.
err := db.QueryRow(
"SELECT nome FROM produtos WHERE id = ?",
id,
).Scan(&nome)
// Verifica se o registro não existe.
if errors.Is(err, sql.ErrNoRows) {
fmt.Println("Produto não encontrado")
return nil
}
// Verifica outros erros da consulta.
if err != nil {
return err
}
fmt.Println("Produto:", nome)
Nesse exemplo, usamos errors.Is() para verificar se o erro corresponde a sql.ErrNoRows. A função errors.Is() faz parte do pacote errors e consegue identificar um erro mesmo quando ele está envolvido por outros erros criados com %w.
Se o produto não existir, mostramos uma mensagem e retornamos nil, tratando a ausência do registro como uma situação esperada. Essa é uma decisão do exemplo, porque em outras aplicações podemos precisar devolver o erro para que a função responsável pela operação decida como responder.
Retornando o erro para outra função
Em aplicações maiores, normalmente é melhor separar a função que consulta o banco da função que decide como tratar o resultado. Podemos fazer isso retornando sql.ErrNoRows quando nenhum registro for encontrado, permitindo que outra parte da aplicação identifique essa condição.
func buscarNomeProduto(db *sql.DB, id int64) (string, error) {
var nome string
// Busca o produto pelo identificador.
err := db.QueryRow(
"SELECT nome FROM produtos WHERE id = ?",
id,
).Scan(&nome)
if err != nil {
return "", fmt.Errorf("erro ao buscar produto %d: %w", id, err)
}
return nome, nil
}
Nesse exemplo, a função recebe o identificador e tenta buscar o nome do produto. Se a consulta falhar, retornamos uma string vazia e um erro que preserva a causa original. Caso o produto seja encontrado, retornamos seu nome e um erro igual a nil.
Agora, podemos chamar essa função e tratar o resultado separadamente.
// Busca o produto com ID igual a 10.
nome, err := buscarNomeProduto(db, 10)
// Verifica se o produto não existe.
if errors.Is(err, sql.ErrNoRows) {
fmt.Println("Produto não encontrado")
return nil
}
// Trata outros erros.
if err != nil {
return err
}
fmt.Println("Produto encontrado:", nome)
Mesmo que a função buscarNomeProduto() tenha adicionado uma mensagem ao erro por meio de fmt.Errorf(), conseguimos identificar sql.ErrNoRows usando errors.Is(). Isso acontece porque o erro original foi preservado com %w.
Dessa forma, a função responsável pela consulta não precisa decidir o que fazer quando um registro não existe. Ela apenas retorna o resultado da operação, enquanto a função que a chamou pode definir o comportamento adequado.
Diferença entre QueryRow e Query sem resultados
Existe uma diferença importante entre consultas executadas com QueryRow() e Query() quando nenhum registro é encontrado.
O QueryRow() retorna sql.ErrNoRows por meio de Scan() quando não encontra um registro. Já Query() normalmente retorna um *sql.Rows vazio, sem considerar a ausência de registros um erro.
// Consulta produtos que possuem estoque.
rows, err := db.Query(`
SELECT id, nome
FROM produtos
WHERE estoque > ?
`, 1000)
if err != nil {
return err
}
defer rows.Close()
// Controla se algum registro foi encontrado.
encontrou := false
for rows.Next() {
encontrou = true
var id int64
var nome string
if err := rows.Scan(&id, &nome); err != nil {
return err
}
fmt.Println(id, nome)
}
// Verifica erros durante a leitura.
if err := rows.Err(); err != nil {
return err
}
// Verifica se a consulta retornou registros.
if !encontrou {
fmt.Println("Nenhum produto encontrado")
}
Nesse exemplo, a variável encontrou começa com false e recebe true quando o laço encontra pelo menos um registro. Se a consulta não retornar nenhuma linha, o for não será executado e a variável continuará com o valor false.
O Query() não retorna sql.ErrNoRows apenas porque não encontrou registros, porque uma consulta que retorna vários resultados pode legitimamente retornar uma lista vazia. Essa diferença precisa ser considerada ao desenvolver funções de busca e listagem.
Trabalhando com timeout usando context
Algumas operações com banco de dados podem demorar mais do que o esperado. Isso pode acontecer quando uma consulta precisa processar muitos registros, quando existe uma disputa por conexões disponíveis ou quando o banco está ocupado executando outras operações.
Para evitar que uma operação permaneça esperando por tempo indeterminado, podemos definir um limite de tempo para sua execução. No Go, esse controle é feito por meio do pacote context, que permite transmitir informações sobre cancelamento e prazo máximo entre diferentes partes da aplicação.
Um context.Context representa o contexto de execução de uma operação. Ele pode informar quando uma tarefa foi cancelada ou quando o prazo estabelecido terminou, permitindo que funções compatíveis interrompam o trabalho que não precisa mais continuar.
O database/sql oferece versões de seus métodos que recebem um context.Context, como QueryContext(), QueryRowContext() e ExecContext().
| Método | Responsabilidade |
|---|---|
PingContext() | Verifica o acesso ao banco considerando um contexto. |
ExecContext() | Executa comandos SQL considerando um contexto. |
QueryContext() | Executa consultas que retornam várias linhas. |
QueryRowContext() | Executa consultas que retornam no máximo uma linha. |
Esses métodos funcionam de maneira semelhante às versões sem Context, mas recebem um argumento adicional que permite controlar o cancelamento da operação.
Criando um timeout com context.WithTimeout
O pacote context oferece a função context.WithTimeout(), que permite criar um contexto com um tempo máximo de duração. Para isso, informamos um contexto existente e um valor do tipo time.Duration, que representa a quantidade de tempo permitida.
// Cria um contexto com limite de dois segundos.
ctx, cancel := context.WithTimeout(
context.Background(),
2*time.Second,
)
// Libera os recursos do contexto ao terminar a função.
defer cancel()
A função context.Background() cria um contexto básico, sem prazo ou cancelamento definido. Já context.WithTimeout() cria um contexto derivado, que será cancelado quando o tempo informado terminar ou quando a função cancel() for chamada.
O primeiro retorno é o novo contexto, armazenado na variável ctx. O segundo é uma função de cancelamento, armazenada em cancel, que permite encerrar o contexto antes do prazo terminar.
Usamos defer cancel() para garantir que os recursos associados ao contexto sejam liberados quando a função terminar, mesmo que a operação seja concluída antes do limite de tempo. Para executar esse exemplo, precisamos importar os pacotes context e time.
Executando uma consulta com timeout
Depois de criar o contexto, podemos passá-lo para QueryRowContext(), permitindo que a consulta respeite o prazo estabelecido.
// Cria um contexto com limite de dois segundos.
ctx, cancel := context.WithTimeout(
context.Background(),
2*time.Second,
)
defer cancel()
var nome string
// Executa a consulta considerando o timeout.
err := db.QueryRowContext(
ctx,
"SELECT nome FROM produtos WHERE id = ?",
1,
).Scan(&nome)
if err != nil {
return err
}
fmt.Println("Produto:", nome)
O método QueryRowContext() recebe o contexto como primeiro argumento, seguido pelo comando SQL e pelos valores dos parâmetros. Nesse exemplo, a operação possui um limite de dois segundos, que começa a ser contado quando o contexto é criado.
Se a consulta terminar antes desse prazo, o resultado será recebido normalmente. Caso o prazo termine antes da conclusão, o contexto será cancelado e o database/sql poderá interromper a operação, dependendo do suporte oferecido pelo driver.
O tempo definido pelo contexto também pode incluir a espera por uma conexão disponível no pool. Isso significa que o limite não representa necessariamente apenas o tempo gasto pelo SQLite executando o comando SQL.
Tratando erros de timeout
Quando um contexto atinge seu prazo máximo, ele passa a indicar um erro chamado context.DeadlineExceeded. Esse erro representa uma operação cujo tempo permitido foi ultrapassado.
Também existe context.Canceled, que indica que o contexto foi cancelado antes de concluir normalmente.
Podemos verificar esses erros por meio de errors.Is() ou consultar diretamente o estado do contexto com ctx.Err().
// Cria um contexto com timeout.
ctx, cancel := context.WithTimeout(
context.Background(),
2*time.Second,
)
defer cancel()
var nome string
// Executa a consulta.
err := db.QueryRowContext(
ctx,
"SELECT nome FROM produtos WHERE id = ?",
1,
).Scan(&nome)
// Verifica se o prazo foi ultrapassado.
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return fmt.Errorf("tempo limite da consulta excedido: %w", ctx.Err())
}
// Verifica se o contexto foi cancelado.
if errors.Is(ctx.Err(), context.Canceled) {
return fmt.Errorf("consulta cancelada: %w", ctx.Err())
}
// Trata os demais erros.
if err != nil {
return err
}
fmt.Println("Produto:", nome)
O método ctx.Err() retorna nil enquanto o contexto continua ativo. Quando o prazo termina, ele retorna context.DeadlineExceeded, enquanto um cancelamento antecipado normalmente produz context.Canceled.
Existe uma diferença entre o erro do contexto e o erro retornado pelo driver. Dependendo do momento em que a operação foi interrompida, o driver pode retornar uma mensagem própria, por isso consultar ctx.Err() ajuda a identificar se o prazo terminou ou se houve um cancelamento.
Também precisamos considerar que o cancelamento não significa necessariamente que o banco interromperá a operação de maneira instantânea. O comportamento depende do driver, da operação executada e do suporte ao cancelamento oferecido pelo banco.
Usando timeout com QueryContext
Quando precisamos consultar vários registros, podemos usar QueryContext(), que funciona de maneira semelhante a Query(), mas recebe um contexto como primeiro argumento.
Nesse caso, o contexto pode controlar tanto a execução inicial da consulta quanto a leitura dos registros retornados.
// Cria um contexto com limite de três segundos.
ctx, cancel := context.WithTimeout(
context.Background(),
3*time.Second,
)
defer cancel()
// Executa a consulta.
rows, err := db.QueryContext(ctx, `
SELECT id, nome, preco_centavos
FROM produtos
WHERE ativo = ?
`, 1)
if err != nil {
return err
}
// Libera os recursos da consulta.
defer rows.Close()
// Percorre os registros.
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)
}
// Verifica erros durante a leitura.
if err := rows.Err(); err != nil {
return err
}
Nesse exemplo, criamos um contexto com limite de três segundos e o passamos para QueryContext(). O método retorna um *sql.Rows, que permite percorrer os registros normalmente por meio de Next() e Scan().
Se o contexto for cancelado durante a leitura, o resultado poderá ser interrompido antes de retornar todas as linhas. Por isso, precisamos manter a verificação de rows.Err(), que permite identificar erros ocorridos enquanto os registros estavam sendo percorridos.
Também precisamos manter defer rows.Close(), porque o contexto não substitui a responsabilidade de liberar os recursos associados ao resultado da consulta.
Usando timeout com ExecContext
O controle por contexto não está limitado às operações de busca. Também podemos definir um timeout para comandos INSERT, UPDATE e DELETE por meio do método ExecContext().
// Cria um contexto com limite de dois segundos.
ctx, cancel := context.WithTimeout(
context.Background(),
2*time.Second,
)
defer cancel()
// Atualiza o preço de um produto.
resultado, err := db.ExecContext(ctx, `
UPDATE produtos
SET preco_centavos = ?
WHERE id = ?
`, 14990, 1)
if err != nil {
return err
}
// Consulta quantas linhas foram afetadas.
linhas, err := resultado.RowsAffected()
if err != nil {
return err
}
fmt.Println("Linhas afetadas:", linhas)
O método ExecContext() funciona de maneira semelhante ao Exec(), mas permite que a operação seja cancelada quando o prazo do contexto terminar. Isso pode ser útil em situações nas quais o banco está ocupado ou a operação precisa esperar por uma conexão disponível.
Entretanto, o timeout não representa um mecanismo automático para desfazer alterações que já foram confirmadas. Se uma operação de escrita for concluída antes do cancelamento, seus efeitos poderão permanecer no banco. Para garantir que várias operações sejam confirmadas ou desfeitas em conjunto, precisamos trabalhar com transações.
Passando o contexto entre funções
Em aplicações maiores, o contexto normalmente é criado em uma parte da aplicação e passado para as funções responsáveis pelas operações no banco. Isso permite manter o mesmo controle de cancelamento durante diferentes etapas de uma solicitação.
Podemos criar uma função que recebe um context.Context, o gerenciador de conexões e o identificador de um produto.
func buscarProduto(
ctx context.Context,
db *sql.DB,
id int64,
) (string, error) {
// Define um timeout para a consulta.
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
var nome string
// Executa a consulta com o contexto recebido.
err := db.QueryRowContext(ctx, `
SELECT nome
FROM produtos
WHERE id = ?
`, id).Scan(&nome)
if err != nil {
// Verifica se o contexto foi encerrado.
if ctx.Err() != nil {
return "", fmt.Errorf("consulta interrompida: %w", ctx.Err())
}
return "", fmt.Errorf("erro ao buscar produto: %w", err)
}
return nome, nil
}
Nesse exemplo, a função recebe um contexto existente e cria outro com limite de dois segundos. O novo contexto continua associado ao contexto original, então a consulta poderá ser cancelada tanto pelo prazo definido na função quanto por um cancelamento recebido da parte da aplicação que fez a chamada.
Se a operação falhar porque o contexto foi encerrado, retornamos o erro correspondente. Caso contrário, preservamos o erro original da consulta, que também pode representar situações como sql.ErrNoRows.
Podemos chamar essa função passando um contexto criado pela aplicação.
// Cria um contexto básico.
ctx := context.Background()
// Busca um produto usando o contexto.
nome, err := buscarProduto(ctx, db, 1)
if err != nil {
return err
}
fmt.Println("Produto:", nome)
Em aplicações HTTP, normalmente recebemos um contexto associado à própria requisição por meio de r.Context(). Esse contexto permite que uma operação no banco seja cancelada quando a requisição for encerrada ou cancelada pelo cliente, evitando que a aplicação continue executando consultas que não são mais necessárias.
O tempo máximo deve ser definido de acordo com as necessidades da operação. Um prazo muito curto pode cancelar consultas que estão funcionando corretamente, enquanto um prazo excessivamente longo pode manter recursos ocupados por mais tempo do que o necessário.
Também precisamos diferenciar o timeout do context dos mecanismos de espera e bloqueio do SQLite. O context controla o prazo da operação no Go, enquanto configurações próprias do SQLite, como busy_timeout, controlam quanto tempo o banco pode esperar quando encontra determinadas situações de bloqueio.