Kyrux
Voltar

ORM

ORM leve e fluente integrado ao framework. Usa generics, reflection cacheada e SQL explícito com placeholders — sem magia, sem surpresas.

import "kyrux/core/orm"

Definição de model

Um model é qualquer struct Go com campos exportados. Use a tag kyrux para configurar comportamento:

type Post struct {
    ID        int64     `kyrux:"pk"`
    Titulo    string    `kyrux:"size:200"`
    Slug      string    `kyrux:"size:200,unique"`
    Publicado bool      `kyrux:"default:false"`
    CriadoEm time.Time `kyrux:"column:criado_em,default:NOW()"`
}

// Model com campos sensíveis:
type Cliente struct {
    ID    int64  `kyrux:"pk"`
    Nome  string
    CPF   string `kyrux:"size:14,encrypt"` // AES-256-GCM: cifrado no banco, decifrado na leitura
    Token string `kyrux:"hash"`            // Argon2id: hash na escrita, nunca revertido
}

Tags disponíveis

Tag Descrição
kyrux:"pk" Chave primária. Ignorado no INSERT, preenchido de volta após criação.
kyrux:"column:nome" Override do nome da coluna SQL. Padrão: snake_case do nome do campo Go.
kyrux:"size:N" Tamanho máximo — usado no makemigrations para gerar VARCHAR(N).
kyrux:"unique" Gera CREATE UNIQUE INDEX no makemigrations (efeito apenas na migration).
kyrux:"default:valor" Valor SQL usado no INSERT quando o campo for zero Go. Ex: default:NOW(), default:true, default:0. Literal SQL — sem placeholder ?.
kyrux:"hash" Hash automático Argon2id+pepper na escrita (Create/Update). Nunca revertido.
kyrux:"encrypt" AES-256-GCM — cifra na escrita, decifra automaticamente na leitura. Requer FIELD_ENCRYPTION_KEY.
kyrux:"login" Exclusivo do auth.User. Marca o campo de login (username ou email). Apenas um campo por struct. Imutável após o primeiro migrate.
kyrux:"autonow" CURRENT_TIMESTAMP automático em todo Update (ex: updated_at). Também preenche no Create se o campo estiver zerado.
kyrux:"fk:tabela" REFERENCES tabela(id) + índice na migration. A tabela referenciada precisa existir antes (declare-a primeiro ou em migration anterior).

default:valor — quando o campo tiver valor zero Go ("", 0, false), o ORM usa o literal diretamente no SQL (VALUES (..., NOW(), ...)), sem passar como argumento. Útil para timestamps, UUIDs e qualquer função SQL de banco. Como entra sem placeholder, valor só aceita número, string entre aspas simples ou identificador/palavra-chave opcionalmente seguido de () (NOW(), CURRENT_TIMESTAMP, true, 'pending') — qualquer outra forma faz o framework recusar o model com panic na primeira vez que ele é usado (erro de configuração, não de runtime). O mesmo vale para fk:tabela e fklabel:coluna: só identificadores simples.

Nome da tabela

Gerado automaticamente a partir do nome do struct em snake_case plural:

Struct Tabela
User users
Post posts
Category categories
UserProfile user_profiles
Address addresses

From vs FromDB

Função Recebe Uso
orm.From[T](connName string) Nome da conexão registrada ("default", "analytics", ...) Quando você só tem o nome da conexão em mãos, sem fw.DB por perto.
orm.FromDB[T](db *database.DB) Uma conexão já resolvida (fw.DB.Use(), fw.DB.Use("analytics"), com ou sem WithSchema) O padrão usado em toda esta seção — o que views e funções em models/ normalmente recebem.

orm.From[T]("default") e orm.FromDB[T](fw.DB.Use()) chegam ao mesmo lugar — From só resolve o nome para uma *database.DB internamente e delega para FromDB. Passar uma *database.DB para From (ou uma string para FromDB) não compila — são assinaturas diferentes; escolha conforme o que você já tem em mãos.

Leitura

All — buscar todos

db := fw.DB.Use()

posts, err := orm.FromDB[Post](db).All()

// Com filtros
posts, err := orm.FromDB[Post](db).
    Where("publicado = ?", true).
    OrderBy("criado_em DESC").
    Limit(10).
    Offset(20).  // página 3
    All()

First — buscar o primeiro

Retorna sql.ErrNoRows se nenhuma linha for encontrada.

post, err := orm.FromDB[Post](db).
    Where("slug = ?", slug).
    First()

if errors.Is(err, sql.ErrNoRows) {
    ctx.Error(404)
    return
}

Count — contar linhas

total, err := orm.FromDB[Post](db).Count()

publicados, err := orm.FromDB[Post](db).
    Where("publicado = ?", true).
    Count()

Métodos de filtro encadeáveis

Para o filtro comum (igualdade, comparação, LIKE, NULL), prefira os métodos tipados abaixo: eles validam o nome da coluna e não têm risco de SQL injection por descuido. Where/OrWhere continuam existindo para o que os tipados não cobrem — SQL livre, então a segurança depende de nunca concatenar entrada do usuário na condição, só passá-la em args.

Método Descrição
WhereEq(col string, val any) / OrWhereEq(...) col = ? — forma tipada e segura de Where("col = ?", val).
WhereNe(col string, val any) col <> ?
WhereGt/WhereGte/WhereLt/WhereLte(col string, val any) col >, >=, <, <= ?
WhereLike(col, pattern string) / OrWhereLike(...) col LIKE ? — inclua os % no pattern: WhereLike("nome", "%maria%").
WhereNull(col string) / WhereNotNull(col string) col IS NULL / col IS NOT NULL.
Where(cond string, args ...any) Condição AND em SQL livre — use ? como placeholder, nunca concatene valores na string. Múltiplos Where são combinados com AND. Idêntico a WhereSQL, mantido por compatibilidade.
OrWhere(cond string, args ...any) Condição OR em SQL livre (precedência: AND liga mais forte, como no SQL). Idêntico a OrWhereSQL.
WhereSQL(cond string, args ...any) / OrWhereSQL(...) Sinônimos de Where/OrWhere — nome recomendado quando o filtro é SQL livre de propósito, deixando isso explícito na leitura do código.
WhereIn(col string, vals ...any) col IN (...) com expansão de placeholders. Aceita slice: WhereIn("id", ids). Lista vazia = nenhum resultado; lista com mais de 5000 valores gera erro (pagine ou use subquery/JOIN).
OrderBy(cols ...string) Define ORDER BY — múltiplas colunas: OrderBy("criado_em DESC", "id ASC"). orm.Asc("col")/orm.Desc("col") são sintaxe alternativa para montar isso programaticamente: OrderBy(orm.Desc("criado_em"), orm.Asc("id")).
Join(tabela, on) / LeftJoin(tabela, on) JOIN para filtrar pela tabela relacionada. O SELECT vira tabela_base.* — o resultado continua []T.
Select(cols ...string) Restringe as colunas do SELECT (padrão: SELECT *). Colunas fora da lista ficam com zero value no struct — não use para depois Update o registro inteiro de volta.
Distinct() SELECT DISTINCT.
Limit(n int) Máximo de linhas retornadas.
Offset(n int) Linhas a pular — use com Limit para paginação.
// Antes (SQL livre) — continua funcionando, mas sem validação de coluna:
orm.FromDB[Post](db).Where("status = ?", status).All()

// Preferido — coluna validada, mesmo resultado:
orm.FromDB[Post](db).WhereEq("status", status).All()

Métodos de execução

Método Retorno Descrição
All() []T, error Todas as linhas do filtro.
Each(fn func(*T) error) error Streaming linha a linha — memória O(1), para result sets grandes.
First() / Last() *T, error Primeira/última linha (Last inverte a ordenação; sem OrderBy usa PK DESC).
Exists() bool, error SELECT 1 ... LIMIT 1 — mais barato que Count.
Count() int64, error Número de linhas do filtro.
Sum/Avg/Min/Max(col) float64, error Agregações numéricas (NULL → 0).
GetOrCreate(defaults *T) *T, bool, error Busca; se não existir, insere defaults (created=true). Preencha defaults com os valores do filtro também. Se o Create colidir com uma constraint UNIQUE (corrida: outro processo criou entre a busca e a inserção), refaz a busca automaticamente em vez de propagar o erro — só protege de verdade quando as colunas do filtro têm UNIQUE no banco.
UpdateOrCreate(values, defaults *T) bool, error Atualiza as linhas do filtro; se nenhuma, insere defaults. Mesma recuperação de corrida de GetOrCreate.

Criação

Passe sempre um ponteiro para que o campo PK seja preenchido de volta.

post := Post{
    Titulo:    "Olá Kyrux",
    Slug:      "ola-kyrux",
    Publicado: true,
}

err := orm.Create(db, &post)
fmt.Println(post.ID) // preenchido com o ID gerado pelo banco

PostgreSQL usa RETURNING internamente — sem round-trip extra. MySQL e SQLite usam LastInsertId.

Para inserir muitas linhas, CreateAll faz um único INSERT multi-VALUES (chunks de 500 — equivalente ao bulk_create do Django):

posts := []*Post{{Titulo: "a"}, {Titulo: "b"}, {Titulo: "c"}}
err := orm.CreateAll(db, posts)
// No PostgreSQL, posts[i].ID são preenchidos de volta.

Atualização

Exige ao menos um Where para evitar updates acidentais em toda a tabela.

err := orm.FromDB[Post](db).
    Where("id = ?", 1).
    Update(map[string]any{
        "titulo":    "Título atualizado",
        "publicado": true,
    })

Deleção

Exige ao menos um Where para evitar deleções acidentais em toda a tabela.

err := orm.FromDB[Post](db).
    Where("id = ?", 1).
    Delete()

Paginação

Paginate executa um COUNT(*) + SELECT com LIMIT/OFFSET em uma só chamada e retorna metadados prontos para uso no template.

page := ctx.QueryInt("page", 1)

p, err := orm.FromDB[Post](db).
    Where("publicado = ?", true).
    OrderBy("criado_em DESC").
    Paginate(page, 20) // página atual, itens por página

// p.Items      → []Post da página atual
// p.Total      → total de registros
// p.TotalPages → número de páginas
// p.HasNext    → true se há próxima página
// p.HasPrev    → true se há página anterior
// p.Page       → página atual
// p.PageSize   → itens por página

render.For("blog").Render(ctx, "lista.html", map[string]any{
    "page": p,
})

Para tabelas grandes e feeds infinitos, PaginateNoCount evita o COUNT(*) (caro no PostgreSQL): busca pageSize+1 linhas e infere HasNext. Total fica em -1 e TotalPages em 0 (desconhecidos):

p, err := orm.FromDB[Post](db).OrderBy("id DESC").PaginateNoCount(page, 20)

Paginate/PaginateNoCount limitam pageSize a 1000 (valores maiores são reduzidos silenciosamente) — importante quando page/pageSize vêm direto de query string sem validação própria.

Ambas ainda usam OFFSET, que fica mais caro conforme a página avança (o banco varre e descarta as linhas puladas). Para tabelas muito grandes ou scroll infinito, use PaginateAfter — keyset (cursor) pagination, cujo custo não cresce com a posição da página:

cursor := ctx.QueryInt64("cursor", 0) // 0 = primeira página

p, err := orm.FromDB[Post](db).
    Where("publicado = ?", true).
    PaginateAfter("id", cursor, false, 20) // col, after, desc, limit

// p.Items      → []Post da página atual
// p.NextCursor → passe para PaginateAfter na próxima chamada
// p.HasNext    → true se há próxima página

col precisa ser única e ordenável (tipicamente a PK ou uma coluna autonow); PaginateAfter adiciona col > ? (ou col < ? com desc: true) e ordena por col — não combine com um OrderBy próprio na mesma query, pois PaginateAfter substitui a ordenação por col. Diferente de Paginate, não há Total/TotalPages/números de página — só "próxima página existe ou não".

Relações: filtrar com Join, carregar com Prefetch

Declare a FK no model com kyrux:"fk:tabela" (gera REFERENCES + índice na migration). Para filtrar pela tabela relacionada, use Join — o SELECT usa tabela_base.*, então o resultado continua sendo []T sem conflito de colunas (qualifique as colunas do Where):

// Posts de usuários ativos:
posts, _ := orm.FromDB[Post](db).
    Join("users", "users.id = posts.user_id").
    Where("users.is_active = ?", true).
    All()

Para carregar os registros relacionados, use Prefetch — o equivalente explícito do prefetch_related do Django (1 query + agrupamento em memória, sem N+1):

posts, _ := orm.FromDB[Post](db).All()
ids := make([]int64, len(posts))
for i, p := range posts { ids[i] = p.ID }

// 1 query: SELECT * FROM comentarios WHERE post_id IN (...)
porPost, _ := orm.Prefetch[Comentario](db, "post_id", ids,
    func(c *Comentario) int64 { return c.PostID })

for _, p := range posts {
    comentarios := porPost[p.ID] // []Comentario do post
}

Update/Delete não aceitam Join (sintaxe não-portável entre bancos) — use subquery no Where: Where("user_id IN (SELECT id FROM users WHERE ...)").

No template:

{{ range .page.Items }}
    <article>{{ .Titulo }}</article>
{{ end }}

{{ if .page.HasPrev }}
    <a href="?page={{ sub .page.Page 1 }}">← Anterior</a>
{{ end }}
{{ if .page.HasNext }}
    <a href="?page={{ add .page.Page 1 }}">Próxima →</a>
{{ end }}

Multi-tenant com schema

// Middleware de tenant define o schema
db := fw.DB.Use().WithSchema("tenant_" + tenantID)

// Todas as queries usam o schema automaticamente
posts, _ := orm.FromDB[Post](db).Where("publicado = ?", true).All()
// → SELECT * FROM tenant_abc.posts WHERE publicado = ?

post := Post{Titulo: "Novo"}
orm.Create(db, &post)
// → INSERT INTO tenant_abc.posts (titulo, ...) VALUES (?)

Compatibilidade de drivers

O ORM detecta o driver automaticamente. Você escreve sempre ? — para PostgreSQL os placeholders são reescritos para $1, $2, ... internamente.

Driver Placeholder gerado
postgres, pgx $1, $2, ...
mysql, sqlite ?

Uso em models

Padrão recomendado — funções no pacote models que recebem *database.DB:

// apps/blog/models/models.go
package models

import (
    "kyrux/core/database"
    "kyrux/core/orm"
)

type Post struct {
    ID        int64  `kyrux:"pk"`
    Titulo    string
    Publicado bool
}

func ListarPublicados(db *database.DB) ([]Post, error) {
    return orm.FromDB[Post](db).
        Where("publicado = ?", true).
        OrderBy("id DESC").
        All()
}

func BuscarPorID(db *database.DB, id int64) (*Post, error) {
    return orm.FromDB[Post](db).
        Where("id = ?", id).
        First()
}

func Criar(db *database.DB, post *Post) error {
    return orm.Create(db, post)
}
// apps/blog/views/views.go
func ListaView(ctx *router.Context) {
    posts, err := models.ListarPublicados(fw.DB.Use())
    if err != nil {
        ctx.Error(500)
        return
    }
    render.For("blog").Render(ctx, "lista.html", map[string]any{
        "posts": posts,
    })
}