Pular para o conteúdo principal

victorstein.dev

96/100 Dias de Golang - SQL sem ORM com database/sql e sqlc

Table of Contents

# SQL sem ORM com database/sql e sqlc

Até agora, sempre que precisamos de banco de dados usamos o GORM (dia 31 e no Mini CI/CD do dia 78). O GORM é prático: definimos structs, chamamos db.Create, db.Find e ele escreve o SQL por nós. Mas isso tem um preço: o SQL fica escondido, queries mais complexas viram uma sequência de métodos encadeados difícil de ler, e às vezes o ORM gera algo bem diferente do que imaginávamos.

Na comunidade Go é muito comum o caminho oposto: escrever SQL de verdade. Hoje vamos ver duas formas de fazer isso: o pacote database/sql da biblioteca padrão e o sqlc, uma ferramenta que gera código Go a partir de arquivos .sql.

# Preparando o banco

Vamos usar PostgreSQL com Docker:

docker run -d --name loja-db -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=loja -p 5432:5432 postgres:17

E o schema, escrito como uma migração do Goose, que vimos no dia 87. O arquivo db/migrations/00001_criar_tabelas.sql:

-- +goose Up
CREATE TABLE usuarios (
    id        BIGSERIAL PRIMARY KEY,
    nome      TEXT NOT NULL,
    email     TEXT NOT NULL UNIQUE,
    criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE pedidos (
    id             BIGSERIAL PRIMARY KEY,
    usuario_id     BIGINT NOT NULL REFERENCES usuarios(id),
    valor_centavos BIGINT NOT NULL,
    status         TEXT NOT NULL DEFAULT 'pendente'
);

-- +goose Down
DROP TABLE pedidos;
DROP TABLE usuarios;

Aplicando com o Goose:

goose -dir ./db/migrations postgres "postgres://postgres:postgres@localhost:5432/loja?sslmode=disable" up

# database/sql

O pacote database/sql define uma interface comum para bancos relacionais. Ele não sabe falar com nenhum banco específico: para isso precisamos de um driver. Para PostgreSQL, o mais usado é o pgx:

go get github.com/jackc/pgx/v5@v5.7.6
package main

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"log"
	"time"

	_ "github.com/jackc/pgx/v5/stdlib"
)

type Usuario struct {
	ID       int64
	Nome     string
	Email    string
	CriadoEm time.Time
}

func criarUsuario(ctx context.Context, db *sql.DB, nome, email string) (Usuario, error) {
	var u Usuario
	err := db.QueryRowContext(ctx,
		`INSERT INTO usuarios (nome, email) VALUES ($1, $2)
		 RETURNING id, nome, email, criado_em`,
		nome, email,
	).Scan(&u.ID, &u.Nome, &u.Email, &u.CriadoEm)
	return u, err
}

func buscarUsuario(ctx context.Context, db *sql.DB, id int64) (Usuario, error) {
	var u Usuario
	err := db.QueryRowContext(ctx,
		`SELECT id, nome, email, criado_em FROM usuarios WHERE id = $1`, id,
	).Scan(&u.ID, &u.Nome, &u.Email, &u.CriadoEm)
	return u, err
}

func listarUsuarios(ctx context.Context, db *sql.DB) ([]Usuario, error) {
	rows, err := db.QueryContext(ctx, `SELECT id, nome, email, criado_em FROM usuarios ORDER BY id`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var usuarios []Usuario
	for rows.Next() {
		var u Usuario
		if err := rows.Scan(&u.ID, &u.Nome, &u.Email, &u.CriadoEm); err != nil {
			return nil, err
		}
		usuarios = append(usuarios, u)
	}
	return usuarios, rows.Err()
}

func main() {
	ctx := context.Background()

	db, err := sql.Open("pgx", "postgres://postgres:postgres@localhost:5432/loja")
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	db.SetMaxOpenConns(10)
	db.SetMaxIdleConns(5)
	db.SetConnMaxLifetime(30 * time.Minute)

	if err := db.PingContext(ctx); err != nil {
		log.Fatal("banco indisponível: ", err)
	}

	for _, u := range []struct{ nome, email string }{
		{"Victor", "victor@exemplo.com"},
		{"Ana", "ana@exemplo.com"},
	} {
		criado, err := criarUsuario(ctx, db, u.nome, u.email)
		if err != nil {
			log.Fatal(err)
		}
		fmt.Printf("criado: id=%d nome=%s\n", criado.ID, criado.Nome)
	}

	usuarios, err := listarUsuarios(ctx, db)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("total de usuários:", len(usuarios))

	_, err = buscarUsuario(ctx, db, 999)
	if errors.Is(err, sql.ErrNoRows) {
		fmt.Println("usuário 999 não encontrado")
	}

	_, err = criarUsuario(ctx, db, "Outro Victor", "victor@exemplo.com")
	fmt.Println("erro ao repetir email:", err)
}

Saída:

criado: id=1 nome=Victor
criado: id=2 nome=Ana
total de usuários: 2
usuário 999 não encontrado
erro ao repetir email: ERROR: duplicate key value violates unique constraint "usuarios_email_key" (SQLSTATE 23505)

Vamos por partes.

O driver. O _ "github.com/jackc/pgx/v5/stdlib" importa o pacote só pelo efeito colateral, igual fizemos com o net/http/pprof no dia 92: ao ser carregado, ele registra o driver "pgx" no database/sql. Daí em diante usamos só a API padrão.

sql.Open não conecta. Ele só valida os argumentos e prepara o objeto. A primeira conexão acontece quando ela é necessária, por isso o PingContext logo no início: se o banco estiver fora, descobrimos na hora em vez de na primeira requisição.

*sql.DB é um pool de conexões, e não uma conexão única. Ele deve ser criado uma vez e compartilhado pela aplicação inteira (é seguro usar de várias goroutines). O SetMaxOpenConns limita quantas conexões abertas o pool pode ter, o SetMaxIdleConns quantas ficam paradas esperando e o SetConnMaxLifetime recicla conexões antigas.

Uma linha ou várias. QueryRowContext + Scan para uma linha. QueryContext para várias, percorrendo com rows.Next(). Três detalhes que é fácil esquecer:

  • defer rows.Close(): sem ele, a conexão não volta para o pool;
  • rows.Err() depois do loop: o rows.Next() retorna false tanto quando as linhas acabam quanto quando dá erro no meio do caminho. Só o rows.Err() diferencia os dois casos;
  • A ordem das variáveis no Scan precisa bater exatamente com a ordem das colunas no SELECT.

sql.ErrNoRows: quando o QueryRow não encontra nada, o Scan retorna esse erro. Usamos errors.Is para tratar como “não encontrado” em vez de erro genérico.

Sempre o context: todas as funções têm uma versão ...Context. Se a requisição HTTP for cancelada, a query é cancelada no banco também, como vimos no dia 89.

## Placeholders e SQL injection

Repare que os valores nunca são colocados direto na string do SQL. Usamos $1, $2 (no PostgreSQL; no MySQL e SQLite é ?) e passamos os valores separados. Assim o banco recebe a query e os dados separadamente, e nunca confunde um com o outro.

Nunca faça isso:

// NUNCA: SQL injection
query := fmt.Sprintf("SELECT * FROM usuarios WHERE email = '%s'", email)

Se alguém enviar como email ' OR '1'='1, a query vira WHERE email = '' OR '1'='1' e retorna todos os usuários. Com placeholders, esse texto é tratado só como um email esquisito.

## O problema

O database/sql funciona muito bem e não esconde nada. Mas olhando o código, dá para ver o incômodo: as três funções repetem a lista de colunas e o Scan campo por campo. Com 30 tabelas e 150 queries, isso vira muito código repetitivo. E, pior, os erros só aparecem em tempo de execução: um nome de coluna errado, um Scan com variáveis a menos ou fora de ordem… tudo compila normalmente e só explode quando a query roda.

# sqlc

O sqlc resolve exatamente isso. A ideia é inversa à do ORM: em vez de escrever Go e gerar SQL, escrevemos SQL e ele gera Go. Ele lê o schema do banco e as queries, valida tudo e gera funções Go com tipos corretos, fazendo os Scan por nós.

Instalando:

go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0

## Configuração

Um arquivo sqlc.yaml na raiz do projeto:

version: "2"
sql:
  - engine: "postgresql"
    schema: "db/migrations"
    queries: "db/queries"
    gen:
      go:
        package: "db"
        out: "internal/db"
        emit_json_tags: true
  • schema aponta para a pasta das migrações. O sqlc entende o formato do Goose: ele lê só a parte -- +goose Up e ignora o Down. Ou seja, a mesma migração serve para criar o banco e para o sqlc conhecer as tabelas;
  • queries é onde ficam os nossos arquivos .sql;
  • out é onde o código Go vai ser gerado;
  • emit_json_tags adiciona tags json nas structs (útil para devolver direto na API).

## Escrevendo as queries

db/queries/usuarios.sql:

-- name: CriarUsuario :one
INSERT INTO usuarios (nome, email)
VALUES ($1, $2)
RETURNING *;

-- name: BuscarUsuario :one
SELECT * FROM usuarios
WHERE id = $1;

-- name: ListarUsuarios :many
SELECT * FROM usuarios
ORDER BY id;

-- name: BuscarUsuariosPorNome :many
SELECT * FROM usuarios
WHERE nome ILIKE '%' || sqlc.arg(busca)::text || '%'
ORDER BY nome;

db/queries/pedidos.sql:

-- name: CriarPedido :one
INSERT INTO pedidos (usuario_id, valor_centavos)
VALUES ($1, $2)
RETURNING *;

-- name: AtualizarStatusPedido :execrows
UPDATE pedidos SET status = $2
WHERE id = $1;

-- name: TotalPorUsuario :many
SELECT u.nome,
       count(p.id) AS qtd_pedidos,
       coalesce(sum(p.valor_centavos), 0)::bigint AS total_centavos
FROM usuarios u
LEFT JOIN pedidos p ON p.usuario_id = u.id
GROUP BY u.id, u.nome
ORDER BY total_centavos DESC;

É SQL normal, com um comentário em cima de cada query dizendo o nome da função que vai ser gerada e o que ela retorna:

  • :one: uma linha (retorna a struct);
  • :many: várias linhas (retorna um slice);
  • :exec: não retorna nada além do erro;
  • :execrows: retorna quantas linhas foram afetadas.

O sqlc.arg(busca) dá um nome ao parâmetro. Sem ele, o sqlc tenta adivinhar o nome pela coluna (no WHERE id = $1, o parâmetro vira id). Numa expressão como essa ele não tem de onde tirar o nome, e a função seria gerada como BuscarUsuariosPorNome(ctx, dollar_1 string).

## Gerando o código

sqlc generate

São criados quatro arquivos em internal/db. O models.go tem uma struct para cada tabela, com os tipos convertidos do schema:

type Pedido struct {
	ID            int64  `json:"id"`
	UsuarioID     int64  `json:"usuario_id"`
	ValorCentavos int64  `json:"valor_centavos"`
	Status        string `json:"status"`
}

type Usuario struct {
	ID       int64     `json:"id"`
	Nome     string    `json:"nome"`
	Email    string    `json:"email"`
	CriadoEm time.Time `json:"criado_em"`
}

E cada query vira um método. Olha o CriarUsuario gerado:

const criarUsuario = `-- name: CriarUsuario :one
INSERT INTO usuarios (nome, email)
VALUES ($1, $2)
RETURNING id, nome, email, criado_em
`

type CriarUsuarioParams struct {
	Nome  string `json:"nome"`
	Email string `json:"email"`
}

func (q *Queries) CriarUsuario(ctx context.Context, arg CriarUsuarioParams) (Usuario, error) {
	row := q.db.QueryRowContext(ctx, criarUsuario, arg.Nome, arg.Email)
	var i Usuario
	err := row.Scan(
		&i.ID,
		&i.Nome,
		&i.Email,
		&i.CriadoEm,
	)
	return i, err
}

É exatamente o código que escrevemos à mão com o database/sql! O RETURNING * foi expandido para a lista de colunas, os parâmetros viraram uma struct CriarUsuarioParams e o Scan está na ordem certa. Sem mágica e sem reflection em tempo de execução: é só código Go comum, que dá para ler e debugar.

Para queries que não retornam uma tabela inteira, como o TotalPorUsuario, ele cria uma struct só para o resultado, com os tipos deduzidos do SQL:

type TotalPorUsuarioRow struct {
	Nome          string `json:"nome"`
	QtdPedidos    int64  `json:"qtd_pedidos"`
	TotalCentavos int64  `json:"total_centavos"`
}

O ::bigint no SQL é importante: sem ele o sum retornaria um numeric e o sqlc geraria um tipo menos conveniente.

E o db.go define a interface DBTX, que tanto o *sql.DB quanto o *sql.Tx implementam. É isso que permite usar as mesmas queries dentro e fora de transações:

type DBTX interface {
	ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
	PrepareContext(context.Context, string) (*sql.Stmt, error)
	QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
	QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}

func New(db DBTX) *Queries {
	return &Queries{db: db}
}

func (q *Queries) WithTx(tx *sql.Tx) *Queries {
	return &Queries{
		db: tx,
	}
}

Nos testes, essa interface também facilita trocar o banco por um mock, como vimos nos dias 61 e 62.

## Usando o código gerado

package main

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"log"

	"github.com/jackc/pgx/v5/pgconn"
	_ "github.com/jackc/pgx/v5/stdlib"

	"loja/internal/db"
)

var ErrEmailEmUso = errors.New("email já cadastrado")

func cadastrar(ctx context.Context, q *db.Queries, nome, email string) (db.Usuario, error) {
	u, err := q.CriarUsuario(ctx, db.CriarUsuarioParams{Nome: nome, Email: email})

	var pgErr *pgconn.PgError
	if errors.As(err, &pgErr) && pgErr.Code == "23505" { // unique_violation
		return db.Usuario{}, ErrEmailEmUso
	}
	return u, err
}

// criarUsuarioComPedido cria o usuário e o primeiro pedido na mesma transação
func criarUsuarioComPedido(ctx context.Context, conn *sql.DB, nome, email string, valor int64) error {
	tx, err := conn.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer tx.Rollback() // não faz nada se o Commit já tiver acontecido

	q := db.New(conn).WithTx(tx)

	u, err := q.CriarUsuario(ctx, db.CriarUsuarioParams{Nome: nome, Email: email})
	if err != nil {
		return fmt.Errorf("criando usuário: %w", err)
	}

	if valor <= 0 {
		return errors.New("valor do pedido inválido")
	}

	if _, err := q.CriarPedido(ctx, db.CriarPedidoParams{UsuarioID: u.ID, ValorCentavos: valor}); err != nil {
		return fmt.Errorf("criando pedido: %w", err)
	}

	return tx.Commit()
}

func main() {
	ctx := context.Background()

	conn, err := sql.Open("pgx", "postgres://postgres:postgres@localhost:5432/loja")
	if err != nil {
		log.Fatal(err)
	}
	defer conn.Close()

	q := db.New(conn)

	victor, err := cadastrar(ctx, q, "Victor", "victor@exemplo.com")
	if err != nil {
		log.Fatal(err)
	}
	ana, err := cadastrar(ctx, q, "Ana", "ana@exemplo.com")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("criados: %+v\n", victor)

	_, err = cadastrar(ctx, q, "Outro Victor", "victor@exemplo.com")
	fmt.Println("repetindo email:", err)

	for _, p := range []db.CriarPedidoParams{
		{UsuarioID: victor.ID, ValorCentavos: 15990},
		{UsuarioID: victor.ID, ValorCentavos: 4500},
		{UsuarioID: ana.ID, ValorCentavos: 89900},
	} {
		if _, err := q.CriarPedido(ctx, p); err != nil {
			log.Fatal(err)
		}
	}

	linhas, err := q.AtualizarStatusPedido(ctx, db.AtualizarStatusPedidoParams{ID: 1, Status: "pago"})
	fmt.Println("pedidos atualizados:", linhas, err)

	if err := criarUsuarioComPedido(ctx, conn, "Bruno", "bruno@exemplo.com", 0); err != nil {
		fmt.Println("transação desfeita:", err)
	}
	if err := criarUsuarioComPedido(ctx, conn, "Carla", "carla@exemplo.com", 12000); err != nil {
		log.Fatal(err)
	}

	encontrados, _ := q.BuscarUsuariosPorNome(ctx, "bru")
	fmt.Println("usuários com 'bru' no nome:", len(encontrados))

	totais, err := q.TotalPorUsuario(ctx)
	if err != nil {
		log.Fatal(err)
	}
	for _, t := range totais {
		fmt.Printf("%-7s pedidos=%d total=R$ %.2f\n", t.Nome, t.QtdPedidos, float64(t.TotalCentavos)/100)
	}

	_, err = q.BuscarUsuario(ctx, 999)
	if errors.Is(err, sql.ErrNoRows) {
		fmt.Println("usuário 999 não encontrado")
	}
}

Saída (com as tabelas vazias):

criados: {ID:1 Nome:Victor Email:victor@exemplo.com CriadoEm:2025-12-13 10:00:00.924541 -0300 -03}
repetindo email: email já cadastrado
pedidos atualizados: 1 <nil>
transação desfeita: valor do pedido inválido
usuários com 'bru' no nome: 0
Ana     pedidos=1 total=R$ 899.00
Victor  pedidos=2 total=R$ 204.90
Carla   pedidos=1 total=R$ 120.00
usuário 999 não encontrado

O código ficou bem mais limpo: nada de Scan, nada de lista de colunas, e tudo tipado. Alguns pontos que valem destacar:

Tratando erros do banco. O erro de email duplicado vem do PostgreSQL com o código 23505 (unique_violation). Com errors.As pegamos o *pgconn.PgError de dentro do erro e o transformamos num erro da nossa aplicação, o ErrEmailEmUso. Assim quem chama o cadastrar não precisa conhecer códigos do PostgreSQL. É o padrão de erros sentinela do dia 35.

Transações. O criarUsuarioComPedido cria o usuário e o pedido juntos: ou os dois são salvos, ou nenhum. O truque é o defer tx.Rollback() logo depois do BeginTx: se a função retornar por qualquer erro, a transação é desfeita; se chegar no Commit, o Rollback depois dele não faz nada. O WithTx faz as queries geradas rodarem dentro da transação.

O Bruno tinha um pedido de valor 0, então o usuário dele chegou a ser inserido, mas a transação foi desfeita. Por isso a busca por “bru” não encontrou ninguém. Consultando a tabela direto no banco:

 id |  nome
----+--------
  1 | Victor
  2 | Ana
  5 | Carla

Repare que a Carla ficou com o id 5, e não 3. As sequences do PostgreSQL (o que está por trás do BIGSERIAL) não voltam atrás em rollback ou em erro: o id 3 foi consumido pela tentativa de cadastrar o email repetido, e o id 4 pelo Bruno. Buracos nos IDs são normais e não indicam problema nenhum.

## Erros em tempo de geração

A maior vantagem do sqlc é validar as queries antes do programa rodar. Se eu digitar errado o nome de uma coluna:

-- name: BuscarUsuario :one
SELECT * FROM usuarios
WHERE emial = $1;
$ sqlc generate
# package db
db/queries/usuarios.sql:8:7: column "emial" does not exist

Com o database/sql puro, esse erro só apareceria quando alguém chamasse essa função em produção. E se uma migração renomear uma coluna, o sqlc generate aponta todas as queries que quebraram, e o compilador do Go aponta todo o código que usava o campo antigo.

Uma prática comum é rodar o sqlc generate no CI e falhar o build se o código gerado estiver diferente do que foi commitado, ou rodar o sqlc diff, que faz exatamente essa verificação.

# GORM ou sqlc?

  • GORM: produtividade em CRUDs simples, associações automáticas, AutoMigrate. Bom para protótipos e quando o time não quer escrever SQL;
  • database/sql puro: zero dependências além do driver. Bom para poucas queries ou queries montadas dinamicamente;
  • sqlc: SQL explícito com segurança de tipos e validação antes de rodar. Fica melhor conforme as queries ficam mais complexas.

O ponto fraco do sqlc são as queries dinâmicas, como uma tela de busca com filtros opcionais, em que o WHERE muda dependendo do que o usuário preencheu. Para esses casos dá para usar truques no SQL (WHERE ($1::text IS NULL OR nome = $1)) ou um query builder como o squirrel só naquela parte.

Também existe a opção de usar o pgx direto, sem o database/sql: ele tem uma API própria, um pouco mais rápida e com mais recursos do PostgreSQL. O sqlc suporta isso com sql_package: "pgx/v5" no sqlc.yaml. Fiquei com o database/sql aqui porque é a interface padrão, que funciona com qualquer banco.

# Referências

  1. Documentação database/sql
  2. Go - Accessing relational databases
  3. sqlc - Documentação
  4. sqlc - Getting started with PostgreSQL
  5. pgx