ORM — Object-Relational Mapper — é uma camada que mapeia structs Go para tabelas de banco de dados, gerando SQL automaticamente. O GORM é o ORM mais popular do ecossistema Go, com suporte a MySQL, PostgreSQL, SQLite e SQL Server.
A decisão de usar ORM versus SQL puro é uma das mais debatidas em desenvolvimento backend. ORMs aceleram o desenvolvimento inicial, eliminam SQL repetitivo e facilitam migrações. Em contrapartida, podem gerar queries ineficientes, obscurecer o que acontece no banco e tornar problemas de performance difíceis de diagnosticar.
A posição pragmática: use GORM para CRUD simples e migrações, escreva SQL explícito para queries complexas e de alta performance. O GORM não impede isso — permite executar SQL bruto a qualquer momento.
Instalação
go get gorm.io/gorm
go get gorm.io/driver/postgres # PostgreSQL
go get gorm.io/driver/mysql # MySQL
go get gorm.io/driver/sqlite # SQLite (usa cgo: não funciona com CGO_ENABLED=0)
Conexão e configuração
package main
import (
"fmt"
"log"
"os"
"time"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func abrirPostgres(dsn string) (*gorm.DB, error) {
config := &gorm.Config{
Logger: logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
logger.Config{
SlowThreshold: 200 * time.Millisecond,
LogLevel: logger.Warn, // loga queries lentas e erros
IgnoreRecordNotFoundError: true,
Colorful: true,
},
),
NowFunc: func() time.Time {
return time.Now().UTC()
},
PrepareStmt: true, // cache de prepared statements
}
db, err := gorm.Open(postgres.Open(dsn), config)
if err != nil {
return nil, fmt.Errorf("abrir postgres: %w", err)
}
// Configurar o pool de conexões subjacente
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxOpenConns(25)
sqlDB.SetMaxIdleConns(10)
sqlDB.SetConnMaxLifetime(5 * time.Minute)
return db, nil
}
func abrirSQLite(caminho string) (*gorm.DB, error) {
return gorm.Open(sqlite.Open(caminho), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
}
Modelos: a base do GORM
import (
"time"
"gorm.io/gorm"
)
// gorm.Model inclui ID, CreatedAt, UpdatedAt e DeletedAt (soft delete)
type Categoria struct {
gorm.Model
Nome string `gorm:"size:100;not null;uniqueIndex"`
Slug string `gorm:"size:100;not null;uniqueIndex"`
}
type Produto struct {
gorm.Model
CategoriaID uint `gorm:"not null;index"`
Categoria Categoria `gorm:"foreignKey:CategoriaID"`
Nome string `gorm:"size:200;not null;index"`
Descricao string `gorm:"type:text"`
Preco float64 `gorm:"type:decimal(10,2);not null"`
Estoque int `gorm:"not null;default:0"`
Ativo bool `gorm:"not null;index"` // sem default: ver criarProduto
Tags []Tag `gorm:"many2many:produto_tags"`
}
type Tag struct {
gorm.Model
Nome string `gorm:"size:50;not null;uniqueIndex"`
Produtos []Produto `gorm:"many2many:produto_tags"`
}
type Usuario struct {
gorm.Model
Nome string `gorm:"size:200;not null"`
Email string `gorm:"size:200;not null;uniqueIndex"`
Senha string `gorm:"size:255;not null"`
Ativo bool `gorm:"not null;default:true"`
Pedidos []Pedido `gorm:"foreignKey:UsuarioID"`
}
type Pedido struct {
gorm.Model
UsuarioID uint `gorm:"not null;index"`
Usuario Usuario `gorm:"foreignKey:UsuarioID"`
Total float64 `gorm:"type:decimal(10,2);not null;default:0"`
Status string `gorm:"size:50;not null;default:'pendente'"`
ItensPedido []ItemPedido `gorm:"foreignKey:PedidoID"`
}
type ItemPedido struct {
gorm.Model
PedidoID uint `gorm:"not null;index"`
ProdutoID uint `gorm:"not null;index"`
Produto Produto `gorm:"foreignKey:ProdutoID"`
Quantidade int `gorm:"not null"`
PrecoUnit float64 `gorm:"type:decimal(10,2);not null"`
}
// O GORM gera o nome da tabela pluralizando em inglês: sem estes métodos,
// as tabelas seriam "categoria" e "item_pedidos", e o SQL escrito à mão
// adiante falharia com relation does not exist
func (Categoria) TableName() string { return "categorias" }
func (ItemPedido) TableName() string { return "itens_pedido" }
AutoMigrate: migrações automáticas
func executarMigracoes(db *gorm.DB) error {
return db.AutoMigrate(
&Categoria{},
&Tag{},
&Produto{},
&Usuario{},
&Pedido{},
&ItemPedido{},
)
}
AutoMigrate cria tabelas que não existem, adiciona colunas faltantes e cria índices. Ele não remove colunas: um campo tirado da struct deixa a coluna no banco. Mas altera tipos, e sem pedir confirmação. Num teste com PostgreSQL, trocar size:100 por size:50 num campo que guardava um texto de 80 caracteres gerou ALTER COLUMN "nome" TYPE varchar(50) USING "nome"::varchar(50): o cast explícito cortou o valor para 50 caracteres, sem erro nenhum. Por isso o AutoMigrate serve bem a protótipos e testes; em produção, as mudanças de schema devem ser migrações versionadas e revisadas, com golang-migrate ou equivalente.
CRUD básico
package main
import (
"errors"
"fmt"
"gorm.io/gorm"
)
type ProdutoInput struct {
CategoriaID uint
Nome string
Descricao string
Preco float64
Estoque int
}
// CREATE
func criarProduto(db *gorm.DB, input ProdutoInput) (*Produto, error) {
produto := Produto{
CategoriaID: input.CategoriaID,
Nome: input.Nome,
Descricao: input.Descricao,
Preco: input.Preco,
Estoque: input.Estoque,
// Explícito de propósito. Com default:true na tag, o GORM trata o
// false como "não informado" e o banco grava true
Ativo: true,
}
if err := db.Create(&produto).Error; err != nil {
return nil, fmt.Errorf("criar produto: %w", err)
}
return &produto, nil
}
// READ — por ID
func buscarProdutoPorID(db *gorm.DB, id uint) (*Produto, error) {
var produto Produto
err := db.First(&produto, id).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("produto %d não encontrado", id)
}
if err != nil {
return nil, fmt.Errorf("buscar produto: %w", err)
}
return &produto, nil
}
// READ — com preload de relacionamentos
func buscarProdutoCompleto(db *gorm.DB, id uint) (*Produto, error) {
var produto Produto
err := db.
Preload("Categoria").
Preload("Tags").
First(&produto, id).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("produto %d não encontrado", id)
}
return &produto, err
}
// UPDATE — apenas campos alterados
func atualizarProduto(db *gorm.DB, id uint, campos map[string]any) error {
resultado := db.Model(&Produto{}).Where("id = ?", id).Updates(campos)
if resultado.Error != nil {
return fmt.Errorf("atualizar produto: %w", resultado.Error)
}
if resultado.RowsAffected == 0 {
return fmt.Errorf("produto %d não encontrado", id)
}
return nil
}
// DELETE — soft delete (marca deleted_at, não remove fisicamente).
// O produto é carregado antes: com db.Delete(&Produto{}, id), o hook
// BeforeDelete receberia uma struct vazia, com ID 0
func deletarProduto(db *gorm.DB, id uint) error {
produto, err := buscarProdutoPorID(db, id)
if err != nil {
return err
}
if err := db.Delete(produto).Error; err != nil {
return fmt.Errorf("deletar produto: %w", err)
}
return nil
}
// DELETE permanente — ignora soft delete
func deletarProdutoPermanente(db *gorm.DB, id uint) error {
var produto Produto
if err := db.Unscoped().First(&produto, id).Error; err != nil {
return fmt.Errorf("buscar produto %d: %w", id, err)
}
return db.Unscoped().Delete(&produto).Error
}
Consultas avançadas
type FiltroProduto struct {
CategoriaID uint
ApenasAtivo bool
PrecoMin, PrecoMax float64
Busca string // ILIKE é do PostgreSQL
Limite, Pagina int
}
func listarProdutosAvancado(db *gorm.DB, f FiltroProduto) ([]Produto, int64, error) {
var produtos []Produto
var total int64
query := db.Model(&Produto{})
// Filtros condicionais
if f.CategoriaID > 0 {
query = query.Where("categoria_id = ?", f.CategoriaID)
}
if f.ApenasAtivo {
query = query.Where("ativo = ?", true)
}
if f.PrecoMin > 0 {
query = query.Where("preco >= ?", f.PrecoMin)
}
if f.PrecoMax > 0 {
query = query.Where("preco <= ?", f.PrecoMax)
}
if f.Busca != "" {
query = query.Where("nome ILIKE ?", "%"+f.Busca+"%")
}
// Conta o total antes da paginação
if err := query.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("contar produtos: %w", err)
}
// Aplica paginação e ordenação
limite := f.Limite
if limite <= 0 {
limite = 20
}
err := query.
Preload("Categoria").
Preload("Tags").
Order("nome ASC").
Limit(limite).
Offset((f.Pagina - 1) * limite).
Find(&produtos).Error
return produtos, total, err
}
// Scopes: encapsulando condições reutilizáveis
func ScopeAtivo(db *gorm.DB) *gorm.DB {
return db.Where("ativo = ?", true)
}
func ScopeComEstoque(db *gorm.DB) *gorm.DB {
return db.Where("estoque > 0")
}
func ScopeCategoria(id uint) func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Where("categoria_id = ?", id)
}
}
func produtosDisponiveisNaCategoria(db *gorm.DB, categoriaID uint) ([]Produto, error) {
var produtos []Produto
err := db.
Scopes(ScopeAtivo, ScopeComEstoque, ScopeCategoria(categoriaID)).
Order("preco ASC").
Find(&produtos).Error
return produtos, err
}
Relacionamentos: has many, belongs to, many2many
// Has Many: Um usuário tem muitos pedidos
func pedidosDoUsuario(db *gorm.DB, usuarioID uint) ([]Pedido, error) {
var pedidos []Pedido
err := db.
Where("usuario_id = ?", usuarioID).
Preload("ItensPedido.Produto").
Order("created_at DESC").
Find(&pedidos).Error
return pedidos, err
}
// Many to Many: associando tags a produto
func associarTags(db *gorm.DB, produtoID uint, nomeTags []string) error {
var produto Produto
if err := db.First(&produto, produtoID).Error; err != nil {
return err
}
var tags []Tag
for _, nome := range nomeTags {
var tag Tag
// FirstOrCreate: busca ou cria se não existir
db.FirstOrCreate(&tag, Tag{Nome: nome})
tags = append(tags, tag)
}
// Association replaces existing tags
return db.Model(&produto).Association("Tags").Replace(tags)
}
// Contando associações
func contagemPorCategoria(db *gorm.DB) ([]map[string]any, error) {
var resultados []map[string]any
err := db.Model(&Produto{}).
Select("categorias.nome as categoria, count(produtos.id) as total").
Joins("JOIN categorias ON categorias.id = produtos.categoria_id").
Where("produtos.ativo = ?", true).
Group("categorias.id, categorias.nome").
Order("total DESC").
Scan(&resultados).Error
return resultados, err
}
Transações no GORM
type ItemInput struct {
ProdutoID uint
Quantidade int
}
func criarPedidoCompleto(db *gorm.DB, usuarioID uint, itens []ItemInput) (*Pedido, error) {
var pedido Pedido
err := db.Transaction(func(tx *gorm.DB) error {
// Cria o pedido
pedido = Pedido{
UsuarioID: usuarioID,
Status: "confirmado",
}
if err := tx.Create(&pedido).Error; err != nil {
return fmt.Errorf("criar pedido: %w", err)
}
var totalPedido float64
for _, item := range itens {
// Busca produto e bloqueia para atualização (SELECT ... FOR UPDATE).
// O Set("gorm:query_option", "FOR UPDATE") da versão 1 do GORM é
// ignorado em silêncio na versão 2: o SELECT sai sem a trava
var produto Produto
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
First(&produto, item.ProdutoID).Error
if err != nil {
return fmt.Errorf("buscar produto %d: %w", item.ProdutoID, err)
}
if produto.Estoque < item.Quantidade {
return fmt.Errorf("estoque insuficiente para %s", produto.Nome)
}
// Cria item do pedido
itemPedido := ItemPedido{
PedidoID: pedido.ID,
ProdutoID: produto.ID,
Quantidade: item.Quantidade,
PrecoUnit: produto.Preco,
}
if err := tx.Create(&itemPedido).Error; err != nil {
return fmt.Errorf("criar item: %w", err)
}
// Atualiza estoque
if err := tx.Model(&produto).
Update("estoque", gorm.Expr("estoque - ?", item.Quantidade)).Error; err != nil {
return fmt.Errorf("atualizar estoque: %w", err)
}
totalPedido += produto.Preco * float64(item.Quantidade)
}
// Atualiza total do pedido
return tx.Model(&pedido).Update("total", totalPedido).Error
})
if err != nil {
return nil, err
}
return &pedido, nil
}
SQL bruto quando necessário
// Raw SQL para queries complexas
func relatorioVendas(db *gorm.DB, mes, ano int) ([]map[string]any, error) {
var resultados []map[string]any
err := db.Raw(`
SELECT
c.nome AS categoria,
COUNT(DISTINCT p.id) AS total_pedidos,
SUM(ip.quantidade) AS unidades_vendidas,
SUM(ip.quantidade * ip.preco_unit) AS receita_total,
AVG(ip.preco_unit) AS ticket_medio
FROM itens_pedido ip
JOIN pedidos p ON p.id = ip.pedido_id
JOIN produtos pr ON pr.id = ip.produto_id
JOIN categorias c ON c.id = pr.categoria_id
WHERE
EXTRACT(MONTH FROM p.created_at) = ?
AND EXTRACT(YEAR FROM p.created_at) = ?
AND p.status != 'cancelado'
AND p.deleted_at IS NULL
GROUP BY c.id, c.nome
ORDER BY receita_total DESC
`, mes, ano).Scan(&resultados).Error
return resultados, err
}
// Exec para statements sem retorno
func desativarProdutosSemEstoque(db *gorm.DB) (int64, error) {
resultado := db.Exec(
"UPDATE produtos SET ativo = false WHERE estoque = 0 AND ativo = true")
return resultado.RowsAffected, resultado.Error
}
Hooks: lógica antes e depois de operações
// BeforeCreate é chamado automaticamente antes de cada INSERT
func (p *Produto) BeforeCreate(tx *gorm.DB) error {
if p.Preco <= 0 {
return fmt.Errorf("preço deve ser positivo")
}
if p.Nome == "" {
return fmt.Errorf("nome é obrigatório")
}
return nil
}
// AfterCreate é chamado após cada INSERT bem-sucedido
func (p *Produto) AfterCreate(tx *gorm.DB) error {
// Poderia disparar evento, invalidar cache, etc.
fmt.Printf("Produto criado: ID=%d Nome=%s\n", p.ID, p.Nome)
return nil
}
// BeforeDelete previne deleção de produtos com pedidos. Só funciona
// se o Delete receber o produto carregado: o hook vê a struct passada
func (p *Produto) BeforeDelete(tx *gorm.DB) error {
var count int64
if err := tx.Model(&ItemPedido{}).Where("produto_id = ?", p.ID).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return fmt.Errorf("produto possui %d pedidos — não pode ser excluído", count)
}
return nil
}
Testes com GORM e SQLite em memória
package main
import (
"testing"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func configurarDBTeste(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("abrir DB de teste: %v", err)
}
// Cada conexão com ":memory:" abre um banco vazio e separado. Com o pool
// livre, uma consulta que caia numa conexão nova dá "no such table"
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("obter sql.DB: %v", err)
}
sqlDB.SetMaxOpenConns(1)
if err := db.AutoMigrate(&Categoria{}, &Tag{}, &Produto{},
&Usuario{}, &Pedido{}, &ItemPedido{}); err != nil {
t.Fatalf("migrar schema de teste: %v", err)
}
t.Cleanup(func() { sqlDB.Close() })
return db
}
func TestCriarProduto(t *testing.T) {
db := configurarDBTeste(t)
// Cria categoria primeiro
cat := Categoria{Nome: "Eletrônicos", Slug: "eletronicos"}
db.Create(&cat)
produto, err := criarProduto(db, ProdutoInput{
CategoriaID: cat.ID,
Nome: "Notebook",
Preco: 4500.00,
Estoque: 10,
})
if err != nil {
t.Fatalf("criar produto: %v", err)
}
if produto.ID == 0 {
t.Error("ID não foi gerado")
}
// Verifica no banco
buscado, err := buscarProdutoPorID(db, produto.ID)
if err != nil {
t.Fatalf("buscar produto: %v", err)
}
if buscado.Nome != "Notebook" {
t.Errorf("nome esperado 'Notebook', obtido '%s'", buscado.Nome)
}
}
func TestSoftDelete(t *testing.T) {
db := configurarDBTeste(t)
cat := Categoria{Nome: "Teste", Slug: "teste"}
db.Create(&cat)
produto, _ := criarProduto(db, ProdutoInput{
CategoriaID: cat.ID,
Nome: "Produto Soft Delete",
Preco: 100.0,
})
deletarProduto(db, produto.ID)
// Soft delete — não encontrado na busca normal
_, err := buscarProdutoPorID(db, produto.ID)
if err == nil {
t.Error("produto deletado ainda foi encontrado")
}
// Mas existe com Unscoped
var count int64
db.Unscoped().Model(&Produto{}).Where("id = ?", produto.ID).Count(&count)
if count == 0 {
t.Error("produto deveria existir com Unscoped")
}
}
O GORM economiza o SQL repetitivo do CRUD, dos relacionamentos e das transações, e o preço é que boa parte do comportamento passa a vir de convenções que não aparecem no código. O nome da tabela sai de um pluralizador em inglês, o valor zero de um campo com default é tratado como ausente, a sintaxe da versão 1 é ignorada em silêncio, o hook só enxerga a struct que recebeu e o AutoMigrate altera tipos de coluna sem perguntar. Nenhum desses casos gera erro de compilação; todos aparecem no SQL. Por isso o modo logger.Info, que imprime cada comando gerado, é a ferramenta mais útil para aprender o GORM, e testes contra o banco de verdade valem mais que qualquer suposição sobre o que ele faz.
Fontes e leituras recomendadas
-
Documentação oficial do GORM — Guia completo com exemplos de todos os recursos. https://gorm.io/docs/
-
GORM — Associations — Referência completa de relacionamentos. https://gorm.io/docs/associations.html
-
GORM — Migrations — Estratégias de migração automática e manual. https://gorm.io/docs/migration.html
-
golang-migrate — Migrações versionadas compatíveis com GORM. https://github.com/golang-migrate/migrate
-
GORM — Hooks — Referência de todos os hooks disponíveis. https://gorm.io/docs/hooks.html
-
sqlc — Alternativa ao ORM: gera código Go tipado a partir de SQL. https://sqlc.dev
Exercícios
Exercício 1
Para economizar espaço, alguém troca a tag de Nome em Categoria de size:100 para size:50 e faz o deploy. A aplicação sobe sem erro, o AutoMigrate roda, e semanas depois os usuários notam nomes de categoria cortados no meio. O que aconteceu, se o banco não reclamou?
Ver resposta
✓ Resposta: O AutoMigrate compara o tamanho declarado na struct com o da coluna e, quando diferem, altera a coluna. No PostgreSQL, ele gera ALTER COLUMN "nome" TYPE varchar(50) USING "nome"::varchar(50). Uma redução simples de tamanho seria recusada pelo banco se algum valor não coubesse, mas o USING com cast explícito faz o PostgreSQL cortar o texto em vez de falhar. Num teste com um valor de 80 caracteres, a migração terminou sem erro e o valor ficou com 50. A perda é silenciosa e irreversível, a menos que haja backup. É por isso que o AutoMigrate não deve rodar sozinho em produção: uma migração versionada mostraria o ALTER na revisão de código, e o time poderia verificar antes se algum valor seria cortado, com uma consulta como SELECT count(*) FROM categorias WHERE length(nome) > 50.
Exercício 2
Sem os métodos TableName do artigo, o AutoMigrate roda sem erro, o CRUD funciona, mas o relatorioVendas e o contagemPorCategoria falham com relation "itens_pedido" does not exist e relation "categorias" does not exist. Quais tabelas o GORM criou, e por que só o SQL escrito à mão quebra?
Ver resposta
✓ Resposta: O GORM deriva o nome da tabela do nome do tipo, convertendo para snake_case e pluralizando com regras do inglês. Num teste, Produto, Pedido e Usuario viraram produtos, pedidos e usuarios, porque basta acrescentar um s. Já Categoria virou categoria, sem plural: o pluralizador trata as terminações -ia e -ta como plurais latinos já prontos, como media e data, e o mesmo aconteceria com Conta, Nota ou Garantia, enquanto Fatura vira faturas. E ItemPedido virou item_pedidos, com o plural na última palavra. Tudo o que passa pelo próprio GORM usa os mesmos nomes gerados, e por isso o CRUD funciona. O SQL escrito à mão usa os nomes que um falante de português esperaria, e falha. As saídas são declarar o nome com o método TableName, como no artigo, ou configurar uma NamingStrategy no gorm.Config com SingularTable: true para toda a aplicação. Em qualquer caso, vale conferir as tabelas criadas no banco antes de escrever a primeira consulta manual.
Exercício 3
Um administrador cadastra um produto que ainda não deve aparecer na loja, com Ativo: false. O produto aparece na vitrine assim mesmo. O modelo tinha Ativo bool `gorm:"not null;default:true"`. Por que o false se perdeu, e quais são as correções?
Ver resposta
✓ Resposta: O GORM não sabe distinguir um false informado de um bool que ninguém preencheu: os dois são o valor zero do tipo. Quando o campo tem default na tag e está com o valor zero, o GORM o deixa de fora do INSERT, e o banco aplica o padrão. Num teste, o produto criado com Ativo: false foi lido de volta com true. O mesmo vale para 0 num inteiro e "" numa string com default. Há três correções: tirar o default da tag e preencher o campo sempre no código, como o artigo passou a fazer; usar um ponteiro, *bool, em que nil significa "não informado" e false é um valor real; ou usar sql.NullBool. A atualização com Updates tem a mesma armadilha quando recebe uma struct, porque ignora os campos com valor zero. Com um map[string]any, como no atualizarProduto, o false é gravado.
Exercício 4
Um código migrado de um projeto antigo trava o produto com tx.Set("gorm:query_option", "FOR UPDATE").First(&produto, id) antes de baixar o estoque. Compila, roda, e os testes passam. Sob carga, dois pedidos simultâneos vendem a última unidade duas vezes. O que o log em logger.Info mostraria?
Ver resposta
✓ Resposta: O gorm:query_option era a forma de acrescentar texto ao fim da consulta na versão 1 do GORM. Na versão 2, o Set continua existindo, para guardar valores na instância, mas essa chave não é mais lida por ninguém. Num teste, o log mostrou SELECT * FROM "produtos" WHERE "produtos"."id" = 1 AND "produtos"."deleted_at" IS NULL ORDER BY "produtos"."id" LIMIT 1, sem FOR UPDATE. Sem a trava, as duas transações leem o mesmo estoque, as duas passam na verificação e as duas descontam. Os testes não pegam o defeito porque rodam uma transação de cada vez. Com tx.Clauses(clause.Locking{Strength: "UPDATE"}), a mesma consulta saiu com FOR UPDATE no fim, e a segunda transação espera a primeira terminar. Uma alternativa que dispensa a trava é fazer a verificação no próprio UPDATE: UPDATE produtos SET estoque = estoque - ? WHERE id = ? AND estoque >= ?, conferindo se RowsAffected é 1.
Exercício 5
O hook BeforeDelete do artigo deveria impedir a exclusão de produtos com pedidos. Com a versão antiga de deletarProduto, que chamava db.Delete(&Produto{}, id), um produto com pedidos foi excluído normalmente. O hook rodou? Por que não bloqueou?
Ver resposta
✓ Resposta: O hook rodou, mas sobre a struct que foi passada ao Delete, e ela estava vazia. O id do segundo argumento entra apenas na cláusula WHERE do SQL; o GORM não carrega o registro para preencher a struct. Dentro do hook, p.ID valia 0, a contagem de itens com produto_id = 0 deu zero, e a exclusão seguiu. Num teste com um produto que tinha um item de pedido, o hook imprimiu p.ID = 0 e o Delete afetou uma linha. Com o produto carregado antes, como na versão atual de deletarProduto, o hook recebeu o ID certo e devolveu produto possui 1 pedidos, cancelando a exclusão. Para uma regra que não pode ser contornada, nem por um Delete escrito de outro jeito nem por um SQL bruto, o lugar dela é o banco: uma chave estrangeira de itens_pedido para produtos com ON DELETE RESTRICT. Ela não impede o soft delete, que é um UPDATE, mas impede a exclusão definitiva.