Go com SQLite: banco de dados embutido para aplicações leves

Go com SQLite: banco de dados embutido para aplicações leves

Go com SQLite: modernc e mattn, o stub que compila sem cgo e falha ao rodar, WAL, chaves estrangeiras e busy_timeout, bancos em memória que se misturam entre testes, afinidades de tipo e tabelas STRICT, ALTER TABLE atual e o backup que só funciona com VACUUM INTO.
Golang

• • 19 min de leitura

SQLite é o banco de dados mais implantado do mundo. Está presente em navegadores, sistemas operacionais, dispositivos móveis, aplicativos desktop e em bilhões de dispositivos embarcados. Diferente de MySQL e PostgreSQL, ele não é um servidor separado — é uma biblioteca que a aplicação carrega diretamente, armazenando tudo em um único arquivo no disco.

Para Go, isso significa zero infraestrutura: sem Docker, sem processo separado, sem configuração de rede. O banco sobe junto com a aplicação e encerra junto com ela. Essa característica o torna ideal para CLIs, ferramentas de desenvolvimento, aplicações desktop, testes de integração e serviços com volume moderado de dados.

Os dois drivers disponíveis

Go tem dois drivers principais para SQLite, com filosofias diferentes:

mattn/go-sqlite3 — usa CGo para vincular a biblioteca C do SQLite. Performance excelente e total compatibilidade com a biblioteca oficial. Requer um compilador C (GCC ou Clang) na máquina de build e no ambiente de CI.

modernc.org/sqlite — port puro de Go do SQLite, transpilado automaticamente do código C original. Não requer CGo nem compilador C. Ligeiramente mais lento, mas sem dependências externas de build.

Para a maioria dos projetos, modernc.org/sqlite é a escolha mais prática por eliminar a dependência do compilador C:

# Driver puro Go — recomendado para a maioria dos casos
go get modernc.org/sqlite

# Driver CGo — melhor performance, requer GCC
go get github.com/mattn/go-sqlite3

Conexão e configuração

package main

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

    _ "modernc.org/sqlite"
)

func abrirSQLite(caminho string) (*sql.DB, error) {
    // DSN do SQLite: caminho do arquivo ou ":memory:" para banco em memória
    dsn := fmt.Sprintf("%s?_journal_mode=WAL&_foreign_keys=on&_busy_timeout=5000", caminho)

    db, err := sql.Open("sqlite", dsn)
    if err != nil {
        return nil, fmt.Errorf("abrir SQLite: %w", err)
    }

    // SQLite não suporta múltiplas conexões de escrita simultâneas
    // Uma única conexão de escrita é o padrão recomendado
    db.SetMaxOpenConns(1)
    db.SetMaxIdleConns(1)
    db.SetConnMaxLifetime(0) // conexões vivem para sempre

    ctx, cancelar := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancelar()

    if err := db.PingContext(ctx); err != nil {
        db.Close()
        return nil, fmt.Errorf("verificar conexão: %w", err)
    }

    log.Printf("SQLite aberto: %s", caminho)
    return db, nil
}

func main() {
    // Banco em arquivo
    db, err := abrirSQLite("dados.db")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    fmt.Println("SQLite pronto")
}

Os parâmetros do DSN merecem atenção:

  • _journal_mode=WAL — Write-Ahead Logging permite leituras concorrentes enquanto uma escrita está em andamento, melhorando significativamente a performance
  • _foreign_keys=on — chaves estrangeiras são desabilitadas por padrão no SQLite; este parâmetro as ativa
  • _busy_timeout=5000 — espera até 5 segundos quando o banco está locked, em vez de retornar erro imediatamente

Banco em memória para testes

Uma das vantagens mais valiosas do SQLite é a possibilidade de criar bancos completamente em memória — perfeitos para testes de integração que precisam de um banco real sem infraestrutura:

func abrirSQLiteMemoria() (*sql.DB, error) {
    // ":memory:" cria um banco que existe apenas na RAM
    // "file::memory:?cache=shared" permite múltiplas conexões ao mesmo banco em memória
    db, err := sql.Open("sqlite", "file::memory:?cache=shared&_foreign_keys=on")
    if err != nil {
        return nil, err
    }

    db.SetMaxOpenConns(1)
    return db, db.Ping()
}

Atenção ao cache=shared: ele faz todos os sql.Open do processo com esse mesmo DSN enxergarem o mesmo banco em memória — num teste, um segundo sql.Open viu a tabela criada pelo primeiro. Com testes rodando em paralelo, um contamina o outro. Para isolar, dê um nome diferente a cada banco: fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()).

Schema e migrações

package main

import (
    "context"
    "database/sql"
    "fmt"
)

const schemaPrincipal = `
CREATE TABLE IF NOT EXISTS categorias (
    id        INTEGER PRIMARY KEY AUTOINCREMENT,
    nome      TEXT NOT NULL,
    slug      TEXT NOT NULL UNIQUE,
    criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS tarefas (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    categoria_id INTEGER REFERENCES categorias(id),
    titulo       TEXT NOT NULL,
    descricao    TEXT,
    concluida    BOOLEAN NOT NULL DEFAULT 0,
    prioridade   INTEGER NOT NULL DEFAULT 0 CHECK(prioridade BETWEEN 0 AND 3),
    prazo        DATETIME,
    criado_em    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    atualizado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS tags (
    id   INTEGER PRIMARY KEY AUTOINCREMENT,
    nome TEXT NOT NULL UNIQUE
);

CREATE TABLE IF NOT EXISTS tarefas_tags (
    tarefa_id INTEGER NOT NULL REFERENCES tarefas(id) ON DELETE CASCADE,
    tag_id    INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
    PRIMARY KEY (tarefa_id, tag_id)
);

CREATE INDEX IF NOT EXISTS idx_tarefas_categoria ON tarefas(categoria_id);
CREATE INDEX IF NOT EXISTS idx_tarefas_concluida ON tarefas(concluida);
CREATE INDEX IF NOT EXISTS idx_tarefas_prazo ON tarefas(prazo);
`

func inicializarSchema(ctx context.Context, db *sql.DB) error {
    _, err := db.ExecContext(ctx, schemaPrincipal)
    if err != nil {
        return fmt.Errorf("inicializar schema: %w", err)
    }
    return nil
}

Tipos em SQLite: o sistema de afinidades

SQLite usa um sistema de tipos diferente dos demais bancos. Em vez de tipos rígidos, usa afinidades de tipo:

Afinidade SQLite Tipos declarados Tipo Go
INTEGER INT, INTEGER, BIGINT int64, bool
REAL REAL, FLOAT, DOUBLE float64
TEXT TEXT, VARCHAR, CHAR string, time.Time
BLOB BLOB []byte
NUMERIC DECIMAL, NUMERIC string (para precisão)

time.Time é armazenado como TEXT no formato RFC3339 ou como INTEGER (Unix timestamp). A conversão precisa ser manual:

type Tarefa struct {
    ID          int64
    CategoriaID sql.NullInt64
    Titulo      string
    Descricao   sql.NullString
    Concluida   bool
    Prioridade  int
    Prazo       sql.NullTime
    CriadoEm   time.Time
    AtualizadoEm time.Time
}

Repositório completo de tarefas

package main

import (
    "context"
    "database/sql"
    "fmt"
    "time"
)

type TarefaInput struct {
    CategoriaID *int64
    Titulo      string
    Descricao   string
    Prioridade  int
    Prazo       *time.Time
}

type RepoTarefas struct {
    db *sql.DB
}

func NovoRepoTarefas(db *sql.DB) *RepoTarefas {
    return &RepoTarefas{db: db}
}

func (r *RepoTarefas) Criar(ctx context.Context, input TarefaInput) (*Tarefa, error) {
    query := `
        INSERT INTO tarefas (categoria_id, titulo, descricao, prioridade, prazo)
        VALUES (?, ?, ?, ?, ?)
        RETURNING id, categoria_id, titulo, descricao, concluida,
                  prioridade, prazo, criado_em, atualizado_em
    `

    var catID sql.NullInt64
    if input.CategoriaID != nil {
        catID = sql.NullInt64{Int64: *input.CategoriaID, Valid: true}
    }

    var desc sql.NullString
    if input.Descricao != "" {
        desc = sql.NullString{String: input.Descricao, Valid: true}
    }

    var prazo sql.NullTime
    if input.Prazo != nil {
        prazo = sql.NullTime{Time: *input.Prazo, Valid: true}
    }

    var t Tarefa
    err := r.db.QueryRowContext(ctx, query,
        catID, input.Titulo, desc, input.Prioridade, prazo,
    ).Scan(
        &t.ID, &t.CategoriaID, &t.Titulo, &t.Descricao,
        &t.Concluida, &t.Prioridade, &t.Prazo,
        &t.CriadoEm, &t.AtualizadoEm,
    )
    if err != nil {
        return nil, fmt.Errorf("criar tarefa: %w", err)
    }

    return &t, nil
}

func (r *RepoTarefas) BuscarPorID(ctx context.Context, id int64) (*Tarefa, error) {
    query := `
        SELECT id, categoria_id, titulo, descricao, concluida,
               prioridade, prazo, criado_em, atualizado_em
        FROM tarefas
        WHERE id = ?
    `

    var t Tarefa
    err := r.db.QueryRowContext(ctx, query, id).Scan(
        &t.ID, &t.CategoriaID, &t.Titulo, &t.Descricao,
        &t.Concluida, &t.Prioridade, &t.Prazo,
        &t.CriadoEm, &t.AtualizadoEm,
    )
    if err == sql.ErrNoRows {
        return nil, fmt.Errorf("tarefa %d não encontrada", id)
    }
    if err != nil {
        return nil, fmt.Errorf("buscar tarefa: %w", err)
    }

    return &t, nil
}

type FiltroTarefas struct {
    Concluida   *bool
    CategoriaID *int64
    Prioridade  *int
    Limite      int
    Offset      int
}

func (r *RepoTarefas) Listar(ctx context.Context, filtro FiltroTarefas) ([]Tarefa, error) {
    // Construção dinâmica de query com filtros opcionais
    base := `
        SELECT id, categoria_id, titulo, descricao, concluida,
               prioridade, prazo, criado_em, atualizado_em
        FROM tarefas
        WHERE 1=1
    `
    args := []any{}

    if filtro.Concluida != nil {
        base += " AND concluida = ?"
        args = append(args, *filtro.Concluida)
    }

    if filtro.CategoriaID != nil {
        base += " AND categoria_id = ?"
        args = append(args, *filtro.CategoriaID)
    }

    if filtro.Prioridade != nil {
        base += " AND prioridade = ?"
        args = append(args, *filtro.Prioridade)
    }

    base += " ORDER BY prioridade DESC, criado_em DESC"

    if filtro.Limite > 0 {
        base += " LIMIT ?"
        args = append(args, filtro.Limite)
    }

    if filtro.Offset > 0 {
        base += " OFFSET ?"
        args = append(args, filtro.Offset)
    }

    rows, err := r.db.QueryContext(ctx, base, args...)
    if err != nil {
        return nil, fmt.Errorf("listar tarefas: %w", err)
    }
    defer rows.Close()

    var tarefas []Tarefa
    for rows.Next() {
        var t Tarefa
        if err := rows.Scan(
            &t.ID, &t.CategoriaID, &t.Titulo, &t.Descricao,
            &t.Concluida, &t.Prioridade, &t.Prazo,
            &t.CriadoEm, &t.AtualizadoEm,
        ); err != nil {
            return nil, fmt.Errorf("scan tarefa: %w", err)
        }
        tarefas = append(tarefas, t)
    }

    return tarefas, rows.Err()
}

func (r *RepoTarefas) Concluir(ctx context.Context, id int64) error {
    resultado, err := r.db.ExecContext(ctx,
        "UPDATE tarefas SET concluida = 1, atualizado_em = CURRENT_TIMESTAMP WHERE id = ?",
        id,
    )
    if err != nil {
        return fmt.Errorf("concluir tarefa: %w", err)
    }

    if n, _ := resultado.RowsAffected(); n == 0 {
        return fmt.Errorf("tarefa %d não encontrada", id)
    }

    return nil
}

func (r *RepoTarefas) Deletar(ctx context.Context, id int64) error {
    resultado, err := r.db.ExecContext(ctx,
        "DELETE FROM tarefas WHERE id = ?", id)
    if err != nil {
        return fmt.Errorf("deletar tarefa: %w", err)
    }

    if n, _ := resultado.RowsAffected(); n == 0 {
        return fmt.Errorf("tarefa %d não encontrada", id)
    }

    return nil
}

Relacionamento muitos-para-muitos: tags

func (r *RepoTarefas) AdicionarTag(ctx context.Context, tarefaID int64, nomeTag string) error {
    tx, err := r.db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer func() {
        if err != nil {
            tx.Rollback()
        }
    }()

    // Insere a tag se não existir (INSERT OR IGNORE)
    _, err = tx.ExecContext(ctx,
        "INSERT OR IGNORE INTO tags (nome) VALUES (?)", nomeTag)
    if err != nil {
        return fmt.Errorf("inserir tag: %w", err)
    }

    // Obtém o ID da tag
    var tagID int64
    err = tx.QueryRowContext(ctx,
        "SELECT id FROM tags WHERE nome = ?", nomeTag).Scan(&tagID)
    if err != nil {
        return fmt.Errorf("buscar tag: %w", err)
    }

    // Associa tag à tarefa (INSERT OR IGNORE evita duplicatas)
    _, err = tx.ExecContext(ctx,
        "INSERT OR IGNORE INTO tarefas_tags (tarefa_id, tag_id) VALUES (?, ?)",
        tarefaID, tagID,
    )
    if err != nil {
        return fmt.Errorf("associar tag: %w", err)
    }

    return tx.Commit()
}

type TarefaComTags struct {
    Tarefa
    Tags []string
}

func (r *RepoTarefas) BuscarComTags(ctx context.Context, id int64) (*TarefaComTags, error) {
    tarefa, err := r.BuscarPorID(ctx, id)
    if err != nil {
        return nil, err
    }

    rows, err := r.db.QueryContext(ctx, `
        SELECT t.nome
        FROM tags t
        INNER JOIN tarefas_tags tt ON tt.tag_id = t.id
        WHERE tt.tarefa_id = ?
        ORDER BY t.nome
    `, id)
    if err != nil {
        return nil, fmt.Errorf("buscar tags: %w", err)
    }
    defer rows.Close()

    resultado := &TarefaComTags{Tarefa: *tarefa}
    for rows.Next() {
        var tag string
        if err := rows.Scan(&tag); err != nil {
            return nil, err
        }
        resultado.Tags = append(resultado.Tags, tag)
    }

    return resultado, rows.Err()
}

Testes de integração com banco em memória

package main

import (
    "context"
    "database/sql"
    "testing"

    _ "modernc.org/sqlite"
)

func configurarBancoDeTeste(t *testing.T) *sql.DB {
    t.Helper()

    db, err := abrirSQLiteMemoria()
    if err != nil {
        t.Fatalf("abrir banco de teste: %v", err)
    }

    ctx := context.Background()
    if err := inicializarSchema(ctx, db); err != nil {
        db.Close()
        t.Fatalf("inicializar schema: %v", err)
    }

    t.Cleanup(func() { db.Close() })
    return db
}

func TestRepoTarefas_CriarEBuscar(t *testing.T) {
    db := configurarBancoDeTeste(t)
    repo := NovoRepoTarefas(db)
    ctx := context.Background()

    input := TarefaInput{
        Titulo:     "Estudar Go",
        Descricao:  "Completar o módulo de banco de dados",
        Prioridade: 2,
    }

    criada, err := repo.Criar(ctx, input)
    if err != nil {
        t.Fatalf("criar tarefa: %v", err)
    }

    if criada.ID == 0 {
        t.Error("ID não foi gerado")
    }
    if criada.Titulo != input.Titulo {
        t.Errorf("título esperado '%s', obtido '%s'", input.Titulo, criada.Titulo)
    }

    buscada, err := repo.BuscarPorID(ctx, criada.ID)
    if err != nil {
        t.Fatalf("buscar tarefa: %v", err)
    }

    if buscada.Titulo != criada.Titulo {
        t.Errorf("títulos não conferem: '%s' vs '%s'",
            criada.Titulo, buscada.Titulo)
    }
}

func TestRepoTarefas_Concluir(t *testing.T) {
    db := configurarBancoDeTeste(t)
    repo := NovoRepoTarefas(db)
    ctx := context.Background()

    tarefa, _ := repo.Criar(ctx, TarefaInput{Titulo: "Tarefa de teste"})

    if err := repo.Concluir(ctx, tarefa.ID); err != nil {
        t.Fatalf("concluir tarefa: %v", err)
    }

    atualizada, _ := repo.BuscarPorID(ctx, tarefa.ID)
    if !atualizada.Concluida {
        t.Error("tarefa deveria estar marcada como concluída")
    }
}

func TestRepoTarefas_NaoEncontrada(t *testing.T) {
    db := configurarBancoDeTeste(t)
    repo := NovoRepoTarefas(db)
    ctx := context.Background()

    _, err := repo.BuscarPorID(ctx, 99999)
    if err == nil {
        t.Error("esperava erro para ID inexistente")
    }
}

Backup e manutenção

func fazerBackup(ctx context.Context, db *sql.DB, destino string) error {
    // O método mais simples: usar o comando VACUUM INTO
    _, err := db.ExecContext(ctx, "VACUUM INTO ?", destino)
    if err != nil {
        return fmt.Errorf("backup: %w", err)
    }
    return nil
}

func otimizarBanco(ctx context.Context, db *sql.DB) error {
    comandos := []string{
        "PRAGMA optimize",   // atualiza estatísticas do query planner
        "PRAGMA wal_checkpoint(TRUNCATE)", // consolida WAL
        "VACUUM",            // compacta o arquivo do banco
        "ANALYZE",           // atualiza estatísticas de índices
    }

    for _, cmd := range comandos {
        if _, err := db.ExecContext(ctx, cmd); err != nil {
            return fmt.Errorf("otimizar (%s): %w", cmd, err)
        }
    }

    return nil
}

Limitações do SQLite a conhecer

SQLite é excelente para muitos casos, mas tem limitações importantes:

Concorrência de escrita. SQLite suporta múltiplos leitores simultâneos, mas apenas um escritor por vez. Para aplicações com alta carga de escrita concorrente, MySQL ou PostgreSQL são mais adequados.

Tipos de dados. O sistema de afinidades é flexível, mas pode surpreender. Um campo INTEGER comum aceita texto sem erro. Desde a versão 3.37 (2021), tabelas declaradas com STRICT no fim do CREATE TABLE passam a recusar o valor do tipo errado, com cannot store TEXT value in INTEGER column — para projetos novos, é o modo recomendado.

ALTER TABLE limitado. O SQLite não suporta todas as formas de ALTER TABLE. Adicionar colunas sempre funcionou; renomear coluna existe desde a versão 3.25 (2018) e remover coluna desde a 3.35 (2021) — o SQLite 3.53 embutido no modernc.org/sqlite atual aceita as duas. Mudar o tipo de uma coluna ou alterar restrições continua exigindo recriar a tabela.

Sem suporte a funções de janela em versões antigas. A partir do SQLite 3.25 (2018), funções de janela são suportadas — mas versões antigas do sistema operacional podem ter versões mais antigas do SQLite.

O SQLite troca o servidor por um arquivo, e com isso some quase toda a infraestrutura — e aparecem cuidados próprios. O modo WAL e o busy_timeout tornam a concorrência aceitável; STRICT traz de volta a verificação de tipos; VACUUM INTO é o backup seguro, porque o arquivo sozinho não contém o que está no WAL. Entre os drivers, o modernc.org/sqlite mantém o binário estático e o mattn/go-sqlite3 exige cgo em todo build. Para testes, o banco em memória é imbatível, desde que cada teste tenha o seu.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um script de backup noturno copia o arquivo dados.db com cp enquanto a aplicação está rodando com journal_mode=WAL. Num dia de restauração, o backup abre, mas faltam os dados das últimas horas, ou até tabelas inteiras. O que o cp deixou para trás, e qual é a forma certa de copiar?

Ver resposta

✓ Resposta: No modo WAL, as escritas novas vão primeiro para o arquivo dados.db-wal, e só são transferidas para o dados.db nos checkpoints, que o SQLite faz periodicamente. Copiar apenas o .db captura o estado do último checkpoint, sem nada do que está no WAL. Num teste com uma tabela criada e 100 linhas inseridas em modo WAL, a cópia do .db feita logo em seguida abriu sem a tabela: a consulta devolveu no such table: t, porque tudo ainda estava no -wal. Copiar os dois arquivos juntos também não é seguro, porque a aplicação pode escrever entre as duas cópias. A forma certa é pedir ao próprio SQLite: VACUUM INTO 'backup.db', mostrado no artigo, gera uma cópia consistente e compacta; no mesmo teste, ela saiu com as 100 linhas. A alternativa é a API de backup online do SQLite, disponível pelos drivers. E, como qualquer backup, esse precisa de restauração testada periodicamente, porque o defeito do cp só aparece no dia em que o backup é necessário.

Exercício 2

Um time troca o modernc.org/sqlite pelo mattn/go-sqlite3 em busca de desempenho. O build do CI, que usa CGO_ENABLED=0 para gerar binários estáticos, continua passando, mas o serviço cai ao subir. Qual é a mensagem, e por que o problema não apareceu na compilação?

Ver resposta

✓ Resposta: O mattn/go-sqlite3 é um invólucro da biblioteca C do SQLite e precisa do cgo para funcionar. Quando compilado com CGO_ENABLED=0, ele não falha: usa uma versão de fachada, que compila normalmente e só reclama quando é usada. Num teste, o programa compilou e, no primeiro Ping, devolveu Binary was compiled with 'CGO_ENABLED=0', go-sqlite3 requires cgo to work. This is a stub. Como o erro só aparece em tempo de execução, o CI verde não protege nada, a menos que haja um teste que abra o banco com o binário final. As saídas são três: ligar o cgo no build, com um compilador C no CI e, em geral, abrir mão do binário estático, porque a libc passa a ser dependência; ficar com o modernc.org/sqlite, que é Go puro e funciona com CGO_ENABLED=0; ou medir antes de trocar. A diferença de desempenho entre os dois raramente é o gargalo de uma aplicação que usa SQLite, e o custo operacional do cgo é certo.

Exercício 3

O artigo configura o SQLite com db.SetMaxOpenConns(1). Um desenvolvedor escreve um método que abre uma transação com r.db.BeginTx e, dentro dela, chama r.BuscarPorID(ctx, id), que usa r.db e não a transação. O programa trava para sempre nessa linha. Por quê?

Ver resposta

✓ Resposta: Com uma única conexão no pool, a transação aberta está segurando essa conexão até o Commit ou o Rollback. O BuscarPorID, por usar r.db, pede uma conexão ao pool, e o pool, que não pode abrir uma segunda, fica esperando a primeira ser liberada. Só que a primeira só será liberada quando o método terminar, e o método está esperando o BuscarPorID: é um impasse dentro da mesma goroutine, sem erro nenhum, a menos que o contexto tenha prazo. É o mesmo mecanismo medido no artigo sobre database/sql, em que um rows não fechado com pool de uma conexão fez a consulta seguinte esperar até o prazo. A correção é fazer as consultas feitas dentro de uma transação usarem a própria transação: o padrão habitual é os métodos do repositório receberem uma interface com os métodos comuns a *sql.DB e *sql.Tx, como QueryRowContext e ExecContext, e o chamador decidir qual passar. Esse cuidado vale para qualquer banco; com o SQLite e uma conexão só, o defeito apenas aparece mais cedo.

Exercício 4

Uma coluna prioridade INTEGER passa a receber, por um erro no front-end, valores como "alta". O SQLite grava sem reclamar, e o Scan para int começa a falhar dias depois, na hora de listar. Como o SQLite permite isso, e como fazer o banco recusar na entrada?

Ver resposta

✓ Resposta: No sistema de afinidades do SQLite, o tipo declarado da coluna é uma preferência, e não uma regra: ao receber um texto que não parece número, uma coluna INTEGER comum simplesmente o guarda como texto. Num teste, o INSERT de 'texto' numa coluna INTEGER foi aceito sem erro. O defeito só aparece na leitura, quando o Scan tenta converter "alta" em int, longe do lugar e do momento em que o dado ruim entrou. Desde a versão 3.37, a palavra STRICT no fim do CREATE TABLE faz o SQLite verificar os tipos como os outros bancos: na mesma medição, a tabela STRICT recusou o valor com cannot store TEXT value in INTEGER column. Para tabelas existentes, uma restrição CHECK(typeof(prioridade) = 'integer') tem efeito parecido, e o CHECK(prioridade BETWEEN 0 AND 3) do próprio artigo já recusa textos, porque a comparação falha. A validação no Go continua necessária, mas o banco passa a ser a última barreira, e não um depósito que aceita tudo.

Exercício 5

A suíte de testes usa abrirSQLiteMemoria, com o DSN file::memory:?cache=shared. Tudo passa rodando em sequência. Quando o time acrescenta t.Parallel() para acelerar, testes começam a falhar de forma intermitente, encontrando registros que não criaram. Explique e corrija.

Ver resposta

✓ Resposta: O cache=shared com o nome :memory: faz todas as conexões do processo que usam esse mesmo DSN enxergarem o mesmo banco em memória. Num teste, um segundo sql.Open com o DSN idêntico viu a tabela e a linha criadas pelo primeiro. Em sequência, cada teste fecha o seu *sql.DB no t.Cleanup antes de o próximo abrir, o banco some quando a última conexão fecha, e o problema não aparece. Em paralelo, vários testes ficam abertos ao mesmo tempo sobre o mesmo banco, e um encontra as tarefas do outro: contagens erradas, IDs inesperados, um CREATE TABLE que falha porque a tabela já existe. A correção é dar a cada teste um banco com nome próprio, fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()), o que mantém o compartilhamento entre as conexões de um mesmo teste e isola os testes entre si. O nome do teste já é único na suíte; com subtestes, t.Name() inclui o caminho completo, como TestRepo/criar.

Comentários

Mais em Golang

Go na nuvem: deploy no Railway, Fly.io e Google Cloud Run
Go na nuvem: deploy no Railway, Fly.io e Google Cloud Run

Deploy de uma API Go no Railway, no Fly.io e no Google Cloud Run: CLIs…

Structs: definição, inicialização e campos embutidos
Structs: definição, inicialização e campos embutidos

Structs em Go: literais com nome e posicionais, valor zero, ponteiros, structs…

Arrays e Slices: a espinha dorsal das coleções em Go
Arrays e Slices: a espinha dorsal das coleções em Go

Arrays e slices em Go: tamanho no tipo, len e cap, fatiamento que compartilha…