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,valorsó 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 parafk:tabelaefklabel: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/Deletenão aceitamJoin(sintaxe não-portável entre bancos) — use subquery noWhere: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,
})
}