GORM: ORM para Go, migrações e relacionamentos

GORM: ORM para Go, migrações e relacionamentos

GORM na prática: modelos, relacionamentos, scopes, transações com FOR UPDATE via clause.Locking, hooks que só veem a struct recebida, nomes de tabela pluralizados em inglês, o default que engole o false e o AutoMigrate que altera tipos e trunca dados sem avisar.
Golang

• • 19 min de leitura

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

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.

Comentários

Mais em Golang

Profiling e otimização de performance em Go
Profiling e otimização de performance em Go

Profiling em Go medido: benchmarks com -benchmem, pprof por HTTP e em testes…

Injeção de dependência sem frameworks
Injeção de dependência sem frameworks

Injeção de dependência em Go sem framework: construtores que validam o que é…

Domain-Driven Design aplicado a projetos Go
Domain-Driven Design aplicado a projetos Go

Domain-Driven Design em Go: linguagem compartilhada, entidades com eventos…