DB y ORM — guía exhaustiva¶
Esta es la guía dedicada al stack DB nativo de Fitz: driver Postgres
puro + ORM declarativo + paridad bit-a-bit fitz run ↔ fitz build.
A diferencia del cap 31 de la guía
que sirve como resumen del lenguaje, este documento es la referencia
completa que cubre cada pieza, cada operador, cada receta y cada
limitación honesta.
Hito del proyecto (v0.10.0 + v0.10.1 + v0.10.2) — Fase 10 entera + Fase 10.b (paridad bit-a-bit codegen) + cap 31 (documentación) cierran el bloque "stack web first-class del lado server".
Índice¶
- 1. Panorama vecino y diferenciales
- 2. Quickstart
- 3. Driver
db: query/exec crudo - 4.
@table,@primary,@column: declarar el mapping - 5. Read methods:
.all,.first,.count,.where - 6. QueryBuilder reference: chain y terminales
- 7. Operadores extendidos en
.where(...) - 8. Write methods:
.insert,.update,.delete - 9. Aggregates scalar + GROUP BY
- 10. Relations:
@belongs_to,@has_one,@has_many - 11. Navigation methods + chain
- 12. Eager loading con
.preload(...) - 13. JSONB:
Map<Str, Any>↔jsonb - 14. Arrays Postgres:
List<scalar>↔T[] - 15. NULL en arrays:
List<scalar?> - 16.
Map<Str, T>concreto homogéneo - 17. Array ops en
.where(...) - 18. Date / Time / Timestamp / UUID
- 19. Recetas — paginación
- 20. Recetas — búsqueda
- 21. Recetas — search filters combinatorios
- 22. Recetas — Auth + ORM (queries scoped al user autenticado)
- 23. Recetas — HTTP CRUD completo
- 24. Recetas — Cron job de limpieza
- 25. Recetas — Bulk operations
- 26. Recetas — Schema idempotente al boot
- 26.b. Transactions (v0.10.14)
- 26.c. Migraciones automáticas (v0.10.16)
- 27. Performance
- 28. Limitaciones honestas y deuda explícita
- 29. CLI con DB: cómo cada subcomando interactúa
- 30. Ejemplos runnable y boilerplates
1. Panorama vecino y diferenciales¶
En SQLAlchemy/Django ORM/ActiveRecord/Hibernate/Prisma/Diesel la combinación DB driver + ORM se construye sumando librerías opcionales al proyecto:
| Stack vecino | Driver | ORM | Cómo se acopla |
|---|---|---|---|
| Python + FastAPI | psycopg2 o asyncpg |
SQLAlchemy 2.x async o Tortoise | pip install ambos. ORM resuelve mapping en runtime con metaclass + reflection. |
| Python + Django | psycopg2 |
Django ORM | Tightly coupled con el framework. Migrations via comando aparte. |
| Ruby + Rails | pg gem |
ActiveRecord | Bundler. ActiveRecord es parte del framework. |
| Java + Spring | JDBC driver | Hibernate / JPA | Maven. Anotaciones @Entity/@OneToMany resueltas en runtime con reflection AOP. |
| Node + Express | pg |
Prisma o TypeORM o Sequelize | npm install. Prisma genera schema separado (prisma generate), TypeORM usa decoradores TS resueltos en runtime. |
| Rust + Axum | tokio-postgres o sqlx |
Diesel | Cargo. Diesel pide derives + queries macros (table! macro genera código en compile-time). |
| Go + Gin | pgx |
gorm o sqlc |
go.mod. gorm usa struct tags + reflection runtime. sqlc genera código desde queries SQL. |
Cuesta lo siguiente: 3-5 dependencias mínimo por proyecto. Decoradores
"mágicos" que se resuelven en runtime con reflection (Spring AOP /
JPA). Generación de schema separada (Prisma exige prisma generate
antes de cada build). Tipado opcional que no respeta el shape real
de la tabla. Y a la hora de compilar a binario nativo: imposible
para Python/Ruby/PHP, parche para Node (con pkg/nexe/bun build
y limitaciones), funciona en Go (pgx + gorm) pero arrastra un ORM
separado del compilador, funciona en Rust (Diesel/sqlx) pero pide
macros derive + crates externas.
En Fitz el DB driver y el ORM son parte del lenguaje. El módulo
db viene con un driver Postgres puro escrito en Fitz/Rust (~2400
LoC en src/db.rs, sin link a libpq, sin tokio-postgres/sqlx/
diesel) que habla wire protocol v3.0 + SCRAM-SHA-256 + parser de
los 11 tipos OID core. Encima del driver, 6 decoradores nativos
(@table, @primary, @column, @belongs_to, @has_many,
@has_one) — con kwargs on_delete/on_update/fk/via —
declaran el mapping type Fitz ↔ tabla Postgres. El type checker valida estáticamente que
@primary exista, que @belongs_to apunte a un type existente,
que los métodos del QueryBuilder<Row> preserven el tipo del row
a lo largo de toda la chain. Y el codegen produce un binario nativo
que ejecuta queries SQL constantes en compile-time (cada
.where(fn(u) => u.age > 18) se traduce al fragmento "age" > $1
DURANTE EL CODEGEN, zero overhead runtime para construir SQL).
Los 6 diferenciales únicos¶
- DB nativa, no librería. El driver Postgres + el ORM viven en
el binario
fitz. Ceropip install psycopg2, cerogem install pg, cerocargo add tokio-postgres, ceronpm install pg. Cuando hacésfitz buildel binario nativo embebe el driver — un.exe/ELF/Mach-O standalone que habla wire protocol v3.0 + SCRAM-SHA-256 sin link a libpq. - SQL constante en codegen-time. Cada
.where(closure)se walka del AST DURANTE EL CODEGEN, el fragmento SQL queda hard-coded en el binario. Zero overhead runtime para construir SQL. Comparable a Diesel/sqlx, mejor que SQLAlchemy/ ActiveRecord/Hibernate que construyen SQL via objetos en runtime cada vez. - Paridad bit-a-bit
fitz run↔fitz build. Lo que ves funcionar en el intérprete (rapid feedback) funciona idéntico en el binario nativo (deploy a prod). Cero "anda en local pero no en server". 16 tests E2E de paridad codegen + 27 evaluator E2E corren contrapostgres:16en cada push amainvia jobdb-postgrescon service container. - Decorators del lenguaje, no anotaciones.
@table/@primary/@column/@belongs_to/@has_many/@has_oneson parte del compilador (lexer + parser + type checker + codegen), no anotaciones procesadas por una lib opcional. El checker exige@primaryúnico, valida que@belongs_to("X")apunte a un type existente, infiere los signatures de los navigation methods. Spring@Entity/JPA + Hibernate resuelven esto en runtime con reflection — Fitz lo hace en compile-time. - Eager loading con dispatch estático.
.preload("posts")con el relation name como Str literal en compile-time produce unmatchexhaustivo emitido por el codegen. Typos (.preload( "post")sin la "s" final) detectados en compile-time, no runtime. Comparable a Diesel'sbelonging_tomacros, mejor que SQLAlchemyjoinedload(User.posts)donde el typo recién aparece comoAttributeErroral evaluar. - Integrado con el resto del lenguaje. Tipos custom +
Result<T>+?+match+ decoradores apilables (@authenticated+@get+ handler que llamaType.where(...) .all(db).await?) + middleware/CORS + body deserialization + WebSockets + cron jobs. El ORM no es una "isla" con sus propias reglas, encaja exactamente con HTTP nativo + auth + jobs + WebSockets.
Ningún otro lenguaje moderno combina los 6 puntos en el binario base sin macros derive ni introspection runtime.
2. Quickstart¶
Un programa Fitz minimal que se conecta a Postgres, declara una tabla, inserta un row, y lo trae de vuelta:
@table("users") type User {
@primary id: Int = 0
name: Str
age: Int
}
async fn main() -> Result<Str> {
let db = db.connect("postgres://postgres:postgres@localhost/demo?sslmode=disable").await?
// Crear la tabla si no existe (idempotente al boot).
db.exec("CREATE TABLE IF NOT EXISTS users (
id bigserial PRIMARY KEY,
name text NOT NULL,
age bigint NOT NULL
)", []).await?
// Insert: bigserial auto-asigna el id.
let inserted = User.insert(db, User { id: 0, name: "ada", age: 35 }).await?
print("nueva user con id = {inserted.id}")
// SELECT con WHERE.
let found = User.where(fn(u) => u.id == inserted.id).first(db).await?
print("encontrada: {found.name} ({found.age})")
// SELECT all.
let all = User.all(db).await?
print("total: {len(all)}")
return Ok("OK")
}
print(main().await)
Salida esperada (con Postgres real corriendo):
Tres piezas:
db.connect(url)devuelveDbConn(Future<Result<DbConn>>).@table("users")sobre untypecon@primarylo habilita para el ORM.User.insert/all/where/firstson métodos estáticos sobre el type (no sobre instancias).
Todo lo demás del documento expande estos tres.
3. Driver db: query/exec crudo¶
El módulo built-in db (siempre disponible, sin import) tiene
cuatro funciones core:
db.connect(url) -> Future<Result<DbConn>>¶
Establece conexión + abre un pool de conexiones internamente. Lazy: las conexiones reales se levantan on-demand cuando llega el primer query.
URL formato estándar Postgres:
Parámetros soportados:
sslmode=disable|require|verify-ca|verify-full— controla TLS. Defaultdisablesi no se especifica. Detalle en sub-sección "TLS strict" abajo.sslrootcert=path/to/ca.pem— custom CA bundle (PEM) paraverify-ca/verify-full. Sin esto, el driver usa el Mozilla root CA bundle (webpki-roots) in-binary. Path relativo al CWD del proceso.application_name=mi-app— passthrough al server.
TLS strict (v0.10.23)¶
Habilitado desde v0.10.23 (Fase 10.1.b) — apuntar el driver a
managed Postgres real (Heroku, RDS, Supabase, Neon, Aiven, Render
PG, Crunchy Bridge, etc.) sin downgrade a sslmode=disable.
Implementado con rustls puro Rust + webpki-roots (Mozilla CA
bundle in-binary). Cero deps system — no requiere OpenSSL/
SChannel/SecTransport instalado.
sslmode |
TLS | Cert chain | Hostname | Cuándo usar |
|---|---|---|---|---|
disable |
❌ | — | — | Local dev sin TLS (Postgres en Docker localhost, etc.) |
require |
✅ | ❌ | ❌ | Dev/staging contra Postgres internos sin CA pública. NO usar en prod (MITM) |
verify-ca |
✅ | ✅ | ❌ | Cert custom donde el CN/SAN difiere del hostname (proxies, port forward) |
verify-full |
✅ | ✅ | ✅ | Recomendado producción — modo usado por todos los managed Postgres |
// Supabase / Neon / RDS — exigen TLS verify-full
let db = db.connect(
"postgres://user:pass@db.proyecto.supabase.co:5432/postgres?sslmode=verify-full"
).await?
// Postgres interno con cert self-signed firmado por una CA corporativa
let db = db.connect(
"postgres://user:pass@db.intra:5432/myapp?sslmode=verify-full&sslrootcert=/etc/ssl/corp-ca.pem"
).await?
Combinaciones inválidas abortan en el parser con mensaje claro
(no esperan a runtime):
- sslmode=disable&sslrootcert=... (contradictorio — el cert no se usa).
- sslmode=require&sslrootcert=... (inconsistente — require no verifica).
- sslrootcert=... sin sslmode= (cert sin uso definido).
Out of scope MVP (deuda visible, sin presión):
- sslmode=prefer/allow (negociación dinámica con downgrade). El
flow correcto requiere reconectar si el server rechaza TLS — los
drivers Postgres modernos lo desalientan (vulnerable a MITM
stripping). Usá disable o require/verify-full explícito.
- Client cert auth (sslcert=/sslkey=/sslpassword=).
Patrón enterprise raramente usado vs TLS server-only.
db.query(sql, params) -> Future<Result<List<DbRow>>>¶
Query crudo. Devuelve cada row como un DbRow opaco con las
columnas named (parseo lazy + métodos tipados de extracción).
Los parámetros van como $1, $2, etc. (positional, NO named).
let rows = db.query("SELECT id, email FROM users WHERE active = $1 AND age > $2",
[true, 18]).await?
// rows: List<DbRow>
let r: DbRow = rows[0]
let id: Int = r.get_int("id")? // Result<Int>
let email: Str = r.get_str("email")? // Result<Str>
Métodos sobre DbRow (desde v0.10.22, paridad bit-a-bit
intérprete↔codegen):
| Método | Retorno | Notas |
|---|---|---|
r.get_int(col) |
Result<Int> |
Falla si la col es NULL, no existe, o el tipo PG no es int |
r.get_str(col) |
Result<Str> |
Falla si NULL/no existe; acepta text/varchar/uuid/json/etc. |
r.get_float(col) |
Result<Float> |
float8/float4/numeric/etc. |
r.get_bool(col) |
Result<Bool> |
bool PG |
r.len() |
Int |
número de columnas del row |
Para handlers HTTP que retornan rows crudos sin extracción, podés
devolver Result<List<DbRow>> directo — el codegen auto-serializa
cada row a {col: val, ...} en el JSON response (ver Boilerplate
api-multi-tenant Enfoque B para el patrón canónico).
db.exec(sql, params) -> Future<Result<Int>>¶
Para statements que no retornan rows (INSERT/UPDATE/DELETE sin RETURNING, DDL, etc.). Devuelve el número de rows afectadas.
let affected = db.exec(
"UPDATE users SET last_seen = NOW() WHERE id = $1",
[42]
).await?
print("rows afectadas: {affected}")
db.close() -> Future<Result<Null>>¶
Cierra el pool. Idempotente (llamar 2 veces no es error). Queries posteriores fallan con error claro.
db.is_closed() -> Bool¶
Sync. Devuelve true si la conexión fue cerrada via .close().
Útil para checks defensivos antes de armar una query:
Tipos pasados como parámetros¶
Los params (segundo arg de query/exec) es List<Any> con
auto-coerción a tipos Postgres según el value Fitz:
| Tipo Fitz | Tipo Postgres |
|---|---|
Int |
int8 (BIGINT) |
Float |
float8 (DOUBLE PRECISION) |
Str |
text |
Bool |
bool |
Null |
NULL |
List<Int> |
int8[] |
List<Str> |
text[] |
Map<Str, Any> |
jsonb |
Heterogéneos en lista (List<Any>) → cada elemento se coerce
individualmente.
4. @table, @primary, @column: declarar el mapping¶
Tres decoradores básicos para mapear type Fitz a tabla Postgres.
@table("nombre_tabla")¶
Sobre un type. Indica que el ORM debe mapearlo a la tabla
especificada.
Convención: nombre de tabla en lowercase + plural snake_case
(users, blog_posts, order_line_items). El nombre del type
Fitz puede ser cualquier identificador válido (típicamente
PascalCase singular).
Schemas custom (v0.10.21): @table("schema.name") mapea a
una tabla en un schema Postgres no-public. Útil para
multi-tenant via schemas, separación dev/test, o módulos
aislados.
@table("analytics.events") type Event {
@primary id: Int = 0
name: Str
@db_default("NOW()") at: Str = ""
}
fitz db diff emite CREATE SCHEMA IF NOT EXISTS "analytics";
+ CREATE TABLE "analytics"."events" (...) automáticamente. El
ORM nativo (SELECT/INSERT/UPDATE/DELETE) usa el qualified name
en TODAS las queries. Sin . en el arg, schema=public
(comportamiento default — compat con código pre-v0.10.21).
@primary¶
Sobre un field. Uno o varios @primary por type con
@table. Un solo @primary Int con default = 0 → Postgres
bigserial auto-asigna. Str UUID → cliente genera.
@table("users") type User {
@primary id: Int = 0 // bigserial PRIMARY KEY (auto)
email: Str
}
@table("sessions") type Session {
@primary token: Str // text PRIMARY KEY (UUID del cliente)
user_id: Int
}
Composite primary key (v0.10.27): N @primary arman un PK
tuple. Migrations emite PRIMARY KEY (col1, col2) table-level.
Cada columna lleva su tipo individual (sin bigserial — los
valores los provee el user explícitos):
@table("memberships") type Membership {
@primary org_id: Int = 0
@primary user_id: Int = 0
role: Str = "member"
joined_at: Str = ""
}
// Insert con valores explícitos del PK tuple
let m = Membership { org_id: 1, user_id: 42, role: "admin" }
let r = Membership.insert(conn, m).await?
Limitaciones composite PK en v0.10.27:
- Sentinel id: 0 auto-bigserial NO aplica (es single-PK only).
- Navigation methods (@belongs_to, @has_many, @has_one)
apuntan al single PK del target. BelongsTo a composite PK
→ error de checker explícito.
- same field declared twice as @primary → error claro.
@column(name="...")¶
Sobre un field. Para cuando el nombre del field Fitz difiere del de la columna en Postgres (camelCase vs snake_case típico):
@table("orders") type Order {
@primary id: Int = 0
@column(name="customer_id") customer: Int
@column(name="created_at") created: Str // ISO 8601
total_amount: Float // sin @column: mismo nombre en ambos
}
Los kwargs van con = (no :). Solo name= está soportado por
ahora; sql_type= (override del tipo Postgres inferido del Fitz)
queda como mini-fase futura.
Defaults¶
Los fields pueden tener default literal:
@table("users") type User {
@primary id: Int = 0
email: Str
name: Str
role: Str = "user" // default literal
active: Bool = true
metadata: Map<Str, Any> = {}
}
INSERT desde Fitz: si el field se omite del struct literal, el default Fitz se usa al construir el value que va a Postgres.
Mapping de tipos Fitz → Postgres por default¶
| Tipo Fitz | Postgres column type |
|---|---|
Int |
bigint (o bigserial si es @primary id: Int = 0) |
Float |
double precision |
Str |
text |
Bool |
boolean |
Str? |
text NULL |
Int? |
bigint NULL |
List<Int> |
bigint[] |
List<Str> |
text[] |
List<Float> |
double precision[] |
List<Bool> |
boolean[] |
List<Int?> |
bigint[] (con NULL aceptable en elementos) |
Map<Str, Any> |
jsonb |
Map<Str, Int> (T concreto) |
jsonb (shape homogéneo) |
Date (v0.10.24) |
date |
DateTime (v0.10.24) |
timestamptz (siempre UTC) |
Uuid (v0.10.24) |
uuid |
Date/DateTime/Uuid son tipos built-in nativos (no Str ISO
8601) desde v0.10.24. Tienen constructors estáticos
(Date.today()/Date.tomorrow()/Date.yesterday()/Date.from_ymd(y,m,d)?/
Date.parse(s)?, DateTime.now()/DateTime.epoch()/DateTime.from_timestamp(secs)?/
DateTime.parse(s)?, Uuid.v4()/Uuid.v7()/Uuid.nil()/Uuid.parse(s)?),
métodos instancia (getters .year()/.month()/.day()/.weekday()/
.hour()/.minute()/.second()/.timestamp(), formato
.to_str()/.format(fmt), conversión .to_datetime()/.date(),
y a partir de v0.10.30 Tier B: aritmética (.add_days(n)/
.add_months(n)/.add_years(n) + .subtract_* symétrico; DateTime
también suma .add_seconds/minutes/hours(n)), diff
(d1.diff_days(d2) → signed Int; DateTime también
.diff_seconds/minutes/hours/days), comparison nativa con
</>/<=/>= entre Date-Date y DateTime-DateTime, y display de
timezone (DateTime.to_local() formatea en TZ del sistema; .in_tz(iana)
→ Result<Str> con cualquier IANA name como "America/Argentina/Buenos_Aires").
Paridad bit-a-bit fitz run ↔ fitz build desde v0.10.26: el
codegen emite chrono/uuid (+ chrono-tz desde v0.10.30) en Cargo.toml,
helpers de marshaling/parsing condicionales, y __IntoPgValue/
__FromFitzDbRow para los 3 tipos. Round-trip transparente con
Postgres.
Convención de defaults: el user escribe happens_on: Date = ""
(sentinel Str) y provee el valor real al construir la Instance
con Date.from_ymd(2026, 12, 25)? o desde HTTP body JSON (que
deserializa automático "2026-12-25" → Value::Date).
El user crea las tablas con CREATE TABLE (manualmente, via
db.exec(...) al boot, o via fitz db diff/migrate desde
v0.10.16).
@hidden: ocultar fields de la frontera HTTP¶
A partir de v0.10.11, el decorator @hidden marca un field
como invisible para el JSON I/O:
@table("users") type User {
@primary id: Int = 0
email: Str = ""
name: Str = ""
@hidden password_hash: Str = "" // <-- NUNCA cruza HTTP
role: Str = "user"
}
Qué cambia:
- __to_fitz_json skipea el field — el cliente HTTP NUNCA ve
password_hash en cualquier response que devuelva un User
(directo, como field de Post.author, eager-loaded via
.preload("author"), etc.).
- __FromFitzJson rechaza el field — si el cliente envía un body
con {"password_hash": "..."}, el server responde 400 con
"campo no declarado".
- El ORM lo persiste normalmente en Postgres — el INSERT lo
incluye, el SELECT lo trae de vuelta, .update(...) lo
modifica. Solo cambia el boundary HTTP.
Cuándo usar:
- Campos sensibles (password_hash, tokens internos, claves API).
- Metadata interna que no debe leakearse (timestamps de auditoría
sin sentido para el cliente, internal_status para flags
privados, etc.).
Cuándo NO usar (alternativas mejores):
- Campos que el cliente envía al register/login pero no debe
recibir de vuelta: usá un type dedicado RegisterInput /
Credentials separado del User table type. @hidden cubre
el lado "no exponer" pero el flujo input + persistencia separa
responsabilidades mejor.
Ortogonal a @table: @hidden también funciona en types
plain HTTP sin @table:
type ResponseEnvelope {
data: Map<Str, Any>
@hidden internal_trace_id: Str = "" // log interno, no al cliente
}
@index(...) para composite / unique / partial indexes (v0.10.27)¶
Decorator a nivel type (no a field) que declara índices que
fitz db diff y fitz db migrate emiten automáticamente:
@table("posts")
@index(author_id) // simple
@index(status, published_at) // composite
@index(slug, unique=true) // unique
@index(status, name="published_idx", where_=status == "published") // partial
type Post {
@primary id: Int = 0
author_id: Int = 0
slug: Str = ""
status: Str = "draft"
published_at: Str = ""
}
Kwargs:
- unique=true — CREATE UNIQUE INDEX.
- name="..." — override del nombre. Default
idx_<table>_<col1>_<col2>...[_uniq].
- where_=... — partial index. Acepta cualquier expresión
comparativa simple traducida a SQL (col == "x", col > N,
col is_in [a, b], etc.). El nombre del kwarg es where_
porque where es reservada del lenguaje.
- using="<method>" — v0.10.28. Method override. Whitelist:
btree (default, no se emite USING), hash, gin, gist,
brin, spgist. Habilita full-text (gin sobre tsvector),
ranges (gist), large tables sintéticas (brin) sin bajar a
db.exec. Otro valor → error de compilación citando los
soportados.
@table("docs")
@index(body_tsv, using="gin") // full-text search
@index(price_range, using="gist") // range queries
@index(created_at, using="brin") // grandes tablas time-series
type Doc { ... }
Cuándo aplica:
- fitz db diff muestra los CREATE INDEX que faltan.
- fitz db migrate los aplica.
- Si la tabla ya existe con índices distintos, el diff muestra
el delta (drop + create cuando cambian columnas, drop puro
cuando sobran).
Lo que NO está:
- Expression indexes sobre llamadas/funciones ( — CERRADO v0.10.32 (Tier
C.2). Usá lower(email),
to_tsvector('english', body))@index(expression="lower(email)", unique=true,
name="users_email_lower_uniq") para emitir
CREATE UNIQUE INDEX "users_email_lower_uniq" ON users (lower(email)).
Combinable con where_=/using= igual que los index regulares.
Drift check incompleto: la introspect no parsea
pg_index.indexprs — el diff puede generar DROP+CREATE espurio
si el expression cambia con mismo name. Workaround: nombrar
explícito con name= para drift name-based reliable.
- CERRADO v0.10.29: el diff de indexes ahora detecta cambios
en using / where_clause / unique / columns aunque el
nombre + cols sean iguales, emitiendo DROP INDEX + CREATE INDEX
para regenerar. Antes era name-based puro y requería renombrar
el índice para forzar regen. El comparator de where_clause
normaliza whitespace + case para evitar regens espurios; using
trata None y Some("btree") como equivalentes.
@unique(col1, col2, ...) — composite uniqueness shortcut (v0.10.29)¶
Alias ergonómico de @index("col1, col2", unique=true) con
sintaxis más directa: bare idents posicionales en lugar de Str
con commas + kwarg. Apilable.
@table("users")
@unique(email, tenant_id) // composite UNIQUE
@unique(slug, name="users_slug_unique") // single col + nombre custom
type User {
@primary id: Int = 0
email: Str
tenant_id: Int
slug: Str
}
Emite los mismos CREATE UNIQUE INDEX que @index(unique=true).
Diferencias:
- Solo soporta
name="..."como kwarg. Parawhere_=/using=usar@index(unique=true, ...)directo — el shortcut es para uniqueness simple. - Acepta bare idents (
@unique(email, tenant_id)) o Str con commas (@unique("email, tenant_id"), compat con@index). - Auto-naming:
<table>_<col1>_<col2>_..._uniq.
@check_constraint("sql_expr", name="optional") (v0.10.29)¶
Decorator type-level que emite CHECK (<expr>) en CREATE TABLE.
La expresión se pasa literal al SQL — Fitz no parsea SQL para
validarla contra el shape del type, el user es responsable.
@table("users")
@check_constraint("age >= 0 AND age <= 150")
@check_constraint("status IN ('active', 'pending', 'deleted')")
@check_constraint("email LIKE '%@%'", name="users_email_format")
type User {
@primary id: Int = 0
email: Str
age: Int
status: Str
}
Emite (adentro del CREATE TABLE):
CONSTRAINT "chk_users_0" CHECK (age >= 0 AND age <= 150),
CONSTRAINT "chk_users_1" CHECK (status IN ('active', 'pending', 'deleted')),
CONSTRAINT "users_email_format" CHECK (email LIKE '%@%')
Auto-naming: chk_<table>_<idx> cuando no se especifica name=.
Limitaciones MVP:
Sin drift check— CERRADO v0.10.31 (Tier A.7). La introspect (fitz db inspectyfitz db diff) ahora leepg_constraint.contype='c'viapg_get_constraintdefy populaSchema.tables[].check_constraints. El diff emiteChange::DropCheckConstraintpara los del current ausentes en target,Change::AddCheckConstraintpara los nuevos del target, yDROP+ADDcuando el expr cambia con el mismo name.Solo en CREATE TABLE— CERRADO v0.10.31 (Tier A.5). Si agregás un@check_constraintdespués de que la tabla ya existe,fitz db diffahora emiteALTER TABLE ... ADD CONSTRAINT <name> CHECK (<expr>);. Severity = Risky (puede fallar runtime si rows existentes violan el predicado —fitz db diff --check-destructivelo marca como[RISKY]).- Caveat de canonicalización:
pg_get_constraintdefdevuelve el expr canonicalizado por Postgres (espacios + case normalizados), no la string literal original. Si el user escribe@check_constraint("price>0")y Postgres lo emite como(price > 0), el diff puede disparar DROP+ADD espurio en la primera corrida después de upgrade. Deuda menor — refinable con un SQL normalizer si entra presión.
Cross-schema FK transparente (v0.10.29)¶
Cuando un type referencia con @belongs_to("User") un type que
vive en un schema distinto al actual (@table("public.User")
referenciado desde @table("tenants.Membership")), el FK SQL
emit usa REFERENCES "public"."users"(id) con schema qualifier
automáticamente. Sin cambio de sintaxis — el user sigue
escribiendo @belongs_to("User") con nombre Fitz, y Fitz
resuelve el schema desde el @table del target.
@table("public.users")
type User {
@primary id: Int = 0
name: Str
}
@table("tenants.memberships")
type Membership {
@primary id: Int = 0
@belongs_to("User") user_id: Int // FK cross-schema transparente
role: Str
}
Emite:
ALTER TABLE "tenants"."memberships"
ADD CONSTRAINT "memberships_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "public"."users" ("id");
Same-schema (target y current en public por default, o
ambos en el mismo schema custom) → emite REFERENCES "users"(id)
sin qualifier, paridad bit-a-bit con el comportamiento anterior
(NO breaking change para boilerplates que asumen public).
Limitación MVP: la introspect NO popula —
CERRADO v0.10.31 (Tier A.8). La introspect ahora pulla
references_schemaccu.table_schema AS ref_schema y popula references_schema =
Some(...) cuando difiere del schema local (mismo schema → None
para matchear convención del schema_from_program). Habilita
drift end-to-end para FKs cross-schema modificadas off-Fitz.
5. Read methods: .all, .first, .count, .where¶
Los read methods son estáticos sobre el type (Type.method), no sobre instancias.
Type.all(db) -> Future<Result<List<Type>>>¶
Devuelve todas las rows de la tabla:
⚠️ Sin paginación, esto trae TODAS las rows. Sobre tablas
chicas (<10000 rows) está bien; sobre tablas grandes, usar
.where(...).limit(...).offset(...) (ver sección 19 Paginación).
Type.where(closure) -> QueryBuilder<Type>¶
Empieza una chain de filtros. El closure recibe un nominal del
row y devuelve un Bool. El checker valida estáticamente que el
closure referencie fields existentes en el type. El translator
DURANTE EL CODEGEN walka el AST del closure y emite SQL
parametrizado constante.
// SQL emitido: SELECT ... FROM users WHERE "age" > $1 AND "role" = $2
let admins = User.where(fn(u) => u.age > 18 and u.role == "admin").all(db).await?
.where(...) NO ejecuta la query. Devuelve un QueryBuilder<User>
que se sigue encadenando con chain methods (sección 6) o termina
con .all(db) / .first(db) / .count(db).
Type.first(db) -> Future<Result<Type>> (sin where = primer row de la tabla)¶
Atajo equivalente a Type.where(fn(_) => true).first(db). Devuelve
el primer row de la tabla (el server elige el orden — usar
.order_by(...) para garantizar determinismo). Err si la tabla
está vacía.
Type.count(db) -> Future<Result<Int>>¶
Atajo equivalente a Type.where(fn(_) => true).count(db). Total
de rows de la tabla:
6. QueryBuilder reference: chain y terminales¶
El QueryBuilder<Row> retornado por Type.where(...) (o
Type.preload(...), ver sección 12) es inmutable. Cada chain
method devuelve un nuevo builder con el state acumulado. El SQL
final se compone en el terminal.
Chain methods (preservan el QueryBuilder)¶
.where(closure)¶
Suma otro filtro al WHERE. Múltiples .where(...) se combinan
con AND:
// SQL emitido: WHERE "age" >= $1 AND "role" = $2
let qb = User.where(fn(u) => u.age >= 18).where(fn(u) => u.role == "admin")
let result = qb.all(db).await?
Equivalente a:
(Estilísticamente, prefiere el AND adentro del mismo closure — queda más legible y el codegen emite el mismo SQL).
.order_by(closure, ascending: Bool)¶
Ordena por el field referenciado en el closure. ascending: true
default; false para DESC.
// SQL emitido: ORDER BY "age" DESC
let top = User.where(fn(u) => u.active)
.order_by(fn(u) => u.age, ascending: false)
.all(db).await?
Múltiples .order_by(...) se acumulan:
// SQL emitido: ORDER BY "role" ASC, "age" DESC
let sorted = User.where(fn(u) => u.active)
.order_by(fn(u) => u.role) // ASC default
.order_by(fn(u) => u.age, ascending: false)
.all(db).await?
.limit(n: Int)¶
LIMIT N. Solo último prevalece si se llama múltiples veces.
.offset(n: Int)¶
OFFSET N. Solo último prevalece.
.group_by(closure) -> Aggregated<Row>¶
Cambia el tipo retornado a Aggregated<Row>. Los terminales
disponibles ahora son distintos (sección 9 GROUP BY).
let by_role = User.group_by(fn(u) => u.role).count(db).await?
// by_role: List<Map<Str, Any>> con un row por grupo.
.preload(relation_name: Str) -> QueryBuilder<Row>¶
Eager loading. El relation_name es Str literal en compile-time (no se acepta var). Ver sección 12.
let users_with_posts = User.preload("posts").all(db).await?
// Cada user.posts ya está hidratado — cero queries adicionales.
Terminales (ejecutan el SQL final)¶
.all(db) -> Future<Result<List<Row>>>¶
Ejecuta y devuelve todas las rows que matchean:
.first(db) -> Future<Result<Row>>¶
Ejecuta con LIMIT 1. Err si no hay match:
.count(db) -> Future<Result<Int>>¶
Ejecuta SELECT COUNT(*) ... WHERE .... Más eficiente que
.all(db).await?.len():
.sum(closure, db) / .avg(closure, db) / .min(closure, db) / .max(closure, db)¶
Aggregates scalar sobre el field referenciado en el closure. Ver sección 9.
let total_age: Float = User.where(fn(u) => u.active).sum(fn(u) => u.age, db).await?
let avg_age: Float = User.where(fn(u) => u.active).avg(fn(u) => u.age, db).await?
Nota: .sum/.avg/.min/.max devuelven Float (cast
::float8 automático en el SQL para simplificar el wire protocol).
.count devuelve Int.
.update(db, changes: Map) / .delete(db) -> Future<Result<Int>>¶
Write terminales con guard .where(...) obligatorio (sección 8):
let updated_rows = User.where(fn(u) => u.id == 42).update(db, {"role": "admin"}).await?
let deleted_rows = User.where(fn(u) => u.id == 42).delete(db).await?
7. Operadores extendidos en .where(...)¶
El translator del closure → SQL soporta muchas operaciones más allá de comparators básicos:
Comparators¶
User.where(fn(u) => u.age == 18) // "age" = $1
User.where(fn(u) => u.age != 18) // "age" <> $1
User.where(fn(u) => u.age < 18) // "age" < $1
User.where(fn(u) => u.age <= 18) // "age" <= $1
User.where(fn(u) => u.age > 18) // "age" > $1
User.where(fn(u) => u.age >= 18) // "age" >= $1
Lógicos¶
User.where(fn(u) => u.age >= 18 and u.active) // ... AND ...
User.where(fn(u) => u.age >= 18 or u.role == "vip") // ... OR ...
User.where(fn(u) => not u.active) // NOT ...
Asociatividad estándar; usar paréntesis para agrupar explícito:
// (age >= 18 AND role = 'admin') OR id = 1
User.where(fn(u) => (u.age >= 18 and u.role == "admin") or u.id == 1)
Aritméticos (incluyendo % mod)¶
User.where(fn(u) => u.age + 5 > 25) // "age" + $1 > $2
User.where(fn(u) => u.age * 2 < 50) // "age" * $1 < $2
User.where(fn(u) => u.age % 2 == 0) // "age" % $1 = $2 (pares)
between(lo, hi) sobre fields numéricos¶
is_in([a, b, c]) sobre cualquier field¶
User.where(fn(u) => u.id.is_in([1, 2, 3])) // "id" = ANY($1::int8[])
User.where(fn(u) => u.role.is_in(["admin", "moderator"]))
Lista vacía → predicado false literal (no rompe el query, el
SELECT simplemente no matchea nada). IN () no es SQL válido,
así que el translator emite false como predicado equivalente.
⚠️ Caveat MVP: el arg de .is_in(...) debe ser un List
literal directo (.is_in([1, 2, 3]) o .is_in([x, y])). Una
variable del scope externo NO funciona como arg directo
(.is_in(some_var) → error). Los items adentro de la lista
sí pueden ser variables (.is_in([min_id, max_id]) OK).
Métodos sobre columns Str¶
User.where(fn(u) => u.email.is_null()) // "email" IS NULL
User.where(fn(u) => u.email.is_not_null()) // "email" IS NOT NULL
User.where(fn(u) => u.email.like("%@example.com")) // "email" LIKE $1
User.where(fn(u) => u.email.ilike("%ADA%")) // "email" ILIKE $1 (case-insensitive)
User.where(fn(u) => u.email.starts_with("ada")) // "email" LIKE $1 (con "ada%")
User.where(fn(u) => u.email.ends_with("@x.com")) // "email" LIKE $1 (con "%@x.com")
User.where(fn(u) => u.email.contains("ada")) // "email" LIKE $1 (con "%ada%")
Patterns con %/_/\ se escapan automáticamente en
starts_with/ends_with/contains (NO en like/ilike donde
el user controla el pattern manualmente).
Variables externas al closure¶
El translator soporta vars del scope exterior al closure como parámetros:
let min_age = 18
let role_filter = "admin"
// SQL emitido: WHERE "age" >= $1 AND "role" = $2 con args [18, "admin"]
let adults = User.where(fn(u) => u.age >= min_age and u.role == role_filter).all(db).await?
Útil para handlers HTTP donde el filter viene de un query param:
@get("/users/by-role/{role}")
async fn by_role(role: Str) -> Result<List<User>> {
return User.where(fn(u) => u.role == role).all(db).await
}
Array ops (ver sección 17 para más)¶
Post.where(fn(p) => p.tags.has("rust")) // $1 = ANY(tags)
Post.where(fn(p) => p.tags.contains_all(["rust", "postgres"])) // tags @> $1
Post.where(fn(p) => p.tags.contained_in(["rust", "postgres", "go"])) // tags <@ $1
Tabla resumen de soporte de variables externas¶
| Operador / Method | Var externa soportada |
|---|---|
Comparators (==, !=, <, <=, >, >=) |
✅ ambos lados |
Lógicos (and/or/not) |
n/a |
Aritméticos (+/-/*///%) |
✅ ambos lados |
.between(low, high) |
✅ low/high vars OK |
.is_in(literal_list) |
⚠️ List arg literal; items adentro OK |
.like(pat) / .ilike(pat) |
✅ pat var OK |
.starts_with(s) / .ends_with(s) / .contains(s) |
❌ Str literal REQUERIDO |
.is_null() / .is_not_null() |
n/a (sin args) |
.has(v) |
❌ literal escalar REQUERIDO |
.contains_all([...]) / .contained_in([...]) |
❌ literal escalares REQUERIDOS |
.has_key(s) (JSONB) |
✅ var Str OK |
.get(s) (JSONB) |
✅ var Str OK |
.has_all_keys([...]) / .has_any_keys([...]) (JSONB) |
❌ List literal de Str |
.contains_json({...}) (JSONB) |
❌ Map literal con values primitivos |
.has_path([...]) (JSONB) |
❌ List literal de Str |
.path_text([...]) / .path_int([...]) / .path_float([...]) / .path_bool([...]) (JSONB) |
❌ List literal de Str |
.matches(q) / .plainto_matches(q) (tsvector full-text) |
✅ var Str OK |
Cuando algo del translator no alcanza, bajar a db.query(...)
crudo con SQL escrito a mano.
Lo que NO soporta el translator¶
- Field access sobre nested types (no JOINs implícitos):
u.posts.titleno funciona. UsarPost.where(fn(p) => p.user_id == u.id)aparte. - Llamadas a fns custom adentro del closure: el closure tiene que ser un bloque expresión sobre el row.
matchadentro del closure.if/elseadentro del closure (puede agregarse como refinamiento; hoy un sólo expr-block sin branches).- String interpolation adentro del closure:
u.email == "{prefix}@x.com"no se evalúa al SQL — usar concatenación afuera y pasar var.
8. Write methods: .insert, .update, .delete¶
Los write methods modifican el state de la DB. Cada uno tiene su propio safety check.
Type.insert(db, row) -> Future<Result<Type>>¶
Inserta un row. Si el @primary es Int con default = 0, Postgres
lo auto-asigna (bigserial) y el resultado tiene el id real:
let inserted = User.insert(db, User {
id: 0, // auto-asignado por Postgres
email: "ada@x.com",
age: 35,
role: "admin"
}).await?
print("nueva id: {inserted.id}") // e.g. 42
INSERT emite RETURNING * internamente, así que el row devuelto
tiene todos los fields hidratados (incluyendo cualquier default
declarado del lado SQL).
Type.bulk_insert(rows, db, batch_size=1000) -> Future<Result<Int>> (v0.10.27)¶
Inserta múltiples rows en batches multi-tuple VALUES (...), (...), ....
Default batch_size=1000 (configurable). Devuelve el conteo total
de rows insertadas:
let rows: List<User> = []
let mut i = 0
while (i < 5000) {
rows.push(User { id: 0, name: "user_{i}", role: "guest" })
i = i + 1
}
let n = User.bulk_insert(rows, db).await?
print("inserted: {n}") // 5000
Sentinel id: 0: detectado de la PRIMERA row. Si la primera
trae id: 0, el SQL omite la columna PK en TODAS las rows del
batch (Postgres genera con bigserial). Si la primera trae
id > 0, todas las N rows incluyen PK explícito. Shape uniforme
exigido (no mezclar). Composite PK: valores explícitos siempre
(no hay sentinel multi-col).
No emite RETURNING * (a diferencia de .insert): si
necesitás los IDs auto-generados de cada row, usá .insert en
loop. bulk_insert está optimizado para seeds/migraciones donde
solo importa el conteo.
Ver sección 25 para más patterns bulk.
QueryBuilder.update(db, changes: Map) -> Future<Result<Int>>¶
Sobre un QueryBuilder<Row> con .where(...) previo obligatorio.
El ORM rechaza estáticamente updates sin guard (Type.update(db,
{...}) directo sin .where(...) → error de codegen). Esto
previene el accidente clásico de "olvidé el WHERE":
// ✅ Con guard — actualiza el row específico
let updated_rows = User.where(fn(u) => u.id == 42)
.update(db, {"age": 36, "role": "admin"})
.await?
print("rows actualizadas: {updated_rows}")
// ❌ Sin guard — error de codegen
let oops = User.update(db, {"role": "user"}).await?
// ↑ error: .update() requiere .where(...) previo. Para
// actualizar TODAS las rows, usá .where(fn(_) => true).update(...).
El segundo arg de .update es un Map con Str keys y values
del tipo de la columna. Acepta:
- Map literal heterogéneo:
{"age": 36, "role": "admin", "active": true}. - List literal:
{"tags": ["rust", "postgres"]}(mapea atext[]). - Map literal nested:
{"metadata": {"k": 1, "k2": "x"}}(mapea ajsonb).
Post.where(fn(p) => p.id == 1)
.update(db, {
"title": "nuevo título",
"tags": ["rust", "postgres", "fitz"], // List → text[]
"metadata": {"draft": false, "ts": 1700000000} // Map → jsonb
})
.await?
QueryBuilder.delete(db) -> Future<Result<Int>>¶
Mismo safety pattern: .where(...) obligatorio. Devuelve el
número de rows borradas.
// ✅ Con guard
let deleted = User.where(fn(u) => u.role == "trial" and u.age < 18)
.delete(db).await?
// ❌ Sin guard — error de codegen
let oops = User.delete(db).await?
// ↑ error: .delete() requiere .where(...) previo. Para
// borrar TODAS las rows, usá .where(fn(_) => true).delete(db).
Para "borrar todo" intencionalmente: .where(fn(_) => true).delete(db)
es explícito y compila. Pero db.exec("TRUNCATE TABLE ...", [])
es generalmente mejor (más rápido + resetea el counter de
bigserial).
9. Aggregates scalar + GROUP BY¶
Dos paths separados:
- Scalar: sobre
QueryBuilder<Row>→ devuelveFloat(oIntpara.count). - GROUP BY: sobre
Aggregated<Row>(creado con.group_by) → devuelveList<Map<Str, Any>>.
El checker distingue ambos paths estáticamente con la variante
Type::Aggregated(Box<Type>).
Aggregates scalar sobre QueryBuilder¶
let total: Int = User.count(db).await?
let avg_age: Float = User.avg(fn(u) => u.age, db).await?
let max_age: Float = User.max(fn(u) => u.age, db).await?
let min_age: Float = User.min(fn(u) => u.age, db).await?
let sum_logins: Float = User.where(fn(u) => u.active).sum(fn(u) => u.login_count, db).await?
Cast ::float8 automático en avg/sum/min/max para que el
wire protocol no necesite parsear numeric (deuda menor; el driver
soporta solo OIDs core en MVP).
GROUP BY¶
.group_by(closure) cambia el tipo retornado del builder a
Aggregated<Row>:
// SQL emitido: SELECT "role", COUNT(*) AS count FROM users GROUP BY "role"
let by_role = User.group_by(fn(u) => u.role).count(db).await?
// by_role: List<Map<Str, Any>>
// by_role[0] → {"role": "admin", "count": 3}
// by_role[1] → {"role": "user", "count": 47}
Sobre Aggregated<Row>, los chain methods preservan el
tipo (siguen siendo Aggregated<Row>):
.where(closure)— agrega un filtro AND al WHERE pre-GROUP BY..order_by(closure)— ordena el output post-aggregate..limit(n)/.offset(n)— pagina el output..group_by(closure)— agrega otra columna al GROUP BY.
Terminales aggregate (todos devuelven
Future<Result<List<Map<Str, Any>>>>):
.count(db).sum(closure, db).avg(closure, db).min(closure, db).max(closure, db)
⚠️ .all/.first/.update/.delete NO son válidos sobre
Aggregated<Row> — error claro en compile-time. Para colapsar
los grupos, usar siempre un aggregate terminal.
Cada row del resultado tiene:
- El field del
group_bycon su value original (e.g."role": "admin"). - Un campo numérico con el resultado del aggregate, named según el método y el field:
.count(db)→"count": <Int>.sum(closure, db)→"sum_<field>": <Float>.avg(closure, db)→"avg_<field>": <Float>
// SQL: SELECT "role", AVG("age")::float8 AS avg_age FROM users GROUP BY "role"
let avg_age_by_role = User.group_by(fn(u) => u.role).avg(fn(u) => u.age, db).await?
// avg_age_by_role[0] → {"role": "admin", "avg_age": 35.5}
GROUP BY combinado con WHERE¶
.group_by(...) se puede encadenar después de .where(...):
(El order matters semánticamente — .where filtra antes del GROUP BY,
equivale a SQL WHERE ... GROUP BY ...).
Limitaciones GROUP BY actuales¶
- GROUP BY multi-column: solo single closure por ahora.
GROUP BY a, brequiere helper futuro. - HAVING clause: no soportado en MVP. Workaround: filtrar el
resultado del lado Fitz con
.filter(fn(row) => row["count"] > 5). - Aggregates múltiples en el mismo GROUP BY: no soportado.
Workaround: dos queries separadas o
db.query(...)crudo con el SQL completo. List<Map<Str, Any>>en HTTP returns — CERRADO v0.10.4. El codegen HTTP ahora serializa automáticamente este shape a JSON viaimpl __MapKey for __FitzValueen el preludio HTTP. Endpoints comoGET /statsconUser.group_by(...).count(db).awaitfuncionan end-to-end enfitz buildcon paridad bit-a-bit contrafitz run. Verexamples/guide/31b-orm-crud-http.fitzendpoint/stats/by-email.
10. Relations: @belongs_to, @has_one, @has_many¶
Decoradores sobre fields para declarar relations cross-table.
@belongs_to("Target")¶
El field marcado ES la columna FK real en la tabla. Entra al SELECT, al INSERT, al UPDATE. Mapping clásico:
@table("posts") type Post {
@primary id: Int = 0
title: Str
@belongs_to("User") user_id: Int // FK column real en posts
}
El field user_id es una columna bigint REFERENCES users(id) en
Postgres. El decorator habilita el navigation method post.user_id(db)
que resuelve al User correspondiente (sección 11).
@has_many("Target", via="fk_column")¶
El field marcado es virtual — NO entra al SELECT/INSERT normal.
El via indica cuál columna de la tabla Target apunta hacia atrás.
@table("users") type User {
@primary id: Int = 0
email: Str
@has_many("Post", via="user_id") posts: List<Post>
}
El field posts: List<Post> no es una columna en users; es un
shortcut del ORM que se hidrata con:
user.posts(db)— navigation method (1 SELECT, sección 11).User.preload("posts").all(db)— eager loading batch (1 SELECT para todos los posts, sección 12).
@has_one("Target", via="fk_column")¶
Igual que @has_many pero con cardinalidad 1:1. El field virtual
es Target? (nullable) en lugar de List<Target>:
@table("users") type User {
@primary id: Int = 0
email: Str
@has_one("Profile", via="user_id") profile: Profile?
}
@table("profiles") type Profile {
@primary id: Int = 0
@belongs_to("User") user_id: Int
bio: Str
}
Navigation: user.profile(db) -> Result<Profile> (Err si no
existe).
Kwargs on_delete / on_update de las relations¶
Las relations aceptan kwargs on_delete=... y on_update=...
sobre el MISMO decorator (no son decorators separados). Valores
como string literal: "cascade", "set_null", "restrict",
"no_action". El ORM persiste el FK action en la metadata pero
NO genera la migración (MVP). El user crea la tabla con
db.exec("CREATE TABLE ... user_id bigint REFERENCES users(id) ON
DELETE CASCADE", []) manualmente:
@table("comments") type Comment {
@primary id: Int = 0
body: Str
@belongs_to("Post", on_delete="cascade") post_id: Int
}
@table("posts") type Post {
@primary id: Int = 0
title: Str
@belongs_to("User", on_delete="set_null", on_update="cascade")
author_id: Int
@has_many("Comment", via="post_id", on_delete="cascade")
comments: List<Comment>
}
Si en el futuro entra fitz db migrate, leerá esos kwargs para
generar el DDL apropiado.
Kwargs fk / via para nombres FK custom¶
Por default, el ORM asume convención de nombre. Para overrides:
@belongs_to("Target", fk="custom_fk_field")— cuando el field FK en este type NO sigue la convención<target>_id:
@has_many("Target", via="custom_fk_column")/@has_one(..., via="...")— cuando la columna FK en la tabla Target NO se llama<this>_id:
Cuándo usar cuál¶
| Cardinalidad | Decorator del side dueño | Field del otro side |
|---|---|---|
| 1 → N (User tiene N Post) | @has_many("Post", via="user_id") posts: List<Post> |
@belongs_to("User") user_id: Int |
| 1 → 1 (User tiene 1 Profile) | @has_one("Profile", via="user_id") profile: Profile? |
@belongs_to("User") user_id: Int |
| N → 1 (cada Order tiene 1 Customer) | (no necesario del side N) | @belongs_to("Customer") customer_id: Int |
| M ↔ N (Post ↔ Tag via post_tags) | Manual — tabla intermedia con dos @belongs_to. Ver receta sección 21. |
11. Navigation methods + chain¶
Después de declarar relations, cada field genera un método de navegación sobre la instancia que resuelve la relation a runtime.
BelongsTo: instance.fk_field(db) -> Future<Result<Target>>¶
let post = Post.where(fn(p) => p.id == 1).first(db).await?
let author: User = post.user_id(db).await?
print("autor de '{post.title}': {author.email}")
El método se llama como el field FK (user_id, no user).
El runtime ejecuta SELECT * FROM users WHERE id = $1 con el FK
value como param.
HasMany: instance.virtual_field(db) -> Future<Result<List<Target>>>¶
let user = User.where(fn(u) => u.id == 1).first(db).await?
let user_posts: List<Post> = user.posts(db).await?
print("posts de {user.email}: {len(user_posts)}")
El método se llama como el field virtual declarado (posts).
El runtime ejecuta SELECT * FROM posts WHERE user_id = $1.
HasOne: instance.virtual_field(db) -> Future<Result<Target>>¶
let user = User.where(fn(u) => u.id == 1).first(db).await?
let profile: Profile = user.profile(db).await?
// Err si el user no tiene profile (es Result<Profile>, no Result<Profile?>).
Navigation chain: instance.field() (sin db) devuelve QueryBuilder¶
Cuando la navigation se llama SIN el db arg (args.is_empty()),
devuelve un QueryBuilder<Target> que sigue encadenando:
// Equivale a SELECT * FROM posts WHERE user_id = $1 ORDER BY id DESC LIMIT 5
let latest_5 = user.posts()
.order_by(fn(p) => p.id, ascending: false)
.limit(5)
.all(db).await?
Por diseño:
user.posts(db)— terminal directo (ejecuta query).user.posts()— empieza chain (no ejecuta hasta el terminal).
El segundo es útil para filtros adicionales sobre la relation:
let recent_drafts = user.posts()
.where(fn(p) => p.status == "draft" and p.created_at > "2026-01-01")
.order_by(fn(p) => p.created_at, ascending: false)
.limit(10)
.all(db).await?
N+1 manual (sin .preload)¶
Si tenés N users y querés sus posts:
let users = User.all(db).await?
for u in users {
let posts = u.posts(db).await? // 1 query por user (N+1)
print("{u.email}: {len(posts)} posts")
}
Esto hace N+1 queries (1 para User.all + N para cada
u.posts(db)). Para evitarlo, usar .preload(...) (sección 12).
12. Eager loading con .preload(...)¶
Cierra el N+1 con dispatch estático en compile-time. El
relation name viaja como Str literal en .preload(...); el
codegen emite un match exhaustivo por type con la rama
correspondiente. Typos detectados en compile-time, no
runtime.
Uso básico¶
// 1 query batch para users + 1 query batch para TODOS los posts
// WHERE user_id IN (id_user_1, id_user_2, ...)
let users: List<User> = User.preload("posts").all(db).await?
for u in users {
print("{u.email}: {len(u.posts)} posts")
// ↑ ya está hidratado, cero queries adicionales
for p in u.posts {
print(" - {p.title}")
}
}
Total queries: 2 (vs N+1 manual).
Typos en relation name¶
El relation name como Str literal queda hard-coded en el binario:
let users = User.preload("post").all(db).await?
// ↑ typo: "post" en lugar de "posts"
// → error de codegen:
// relation "post" no existe en User. Conocidas: posts.
Compile-time, no runtime. SQLAlchemy te dice esto al evaluar
User.posts con un typo; Fitz te lo dice al hacer fitz build.
Combinado con .where¶
.preload(...) se puede encadenar con .where(...):
Orden de llamadas no importa funcionalmente:
// Misma query final
let r1 = User.where(fn(u) => u.active).preload("posts").all(db).await?
let r2 = User.preload("posts").where(fn(u) => u.active).all(db).await?
Limitaciones del .preload en MVP¶
- BelongsTo eager via convention (deuda #2 cerrada v0.10.5):
Post.preload("user").all(db)ahora funciona cuando el type declara el companion field. Convención:@belongs_to("User") user_id: Int+ sibling fielduser: User?(mismo type, name derivado stripping_id, tipo Nullable). El checker registra el companion como BelongsToCompanion; el codegen emite el batch SELECT inverso (target.id IN parent.fk DISTINCT); el fielduserse inicializaNonepor default y queda poblado post-preload. Sin el sibling declarado, sigue el workaround manual conis_in(...): - Single-level.
.preload("posts").preload("posts.comments")no soportado (cargar posts + comments de cada post en 3 queries). Workaround:.preload("posts")+ N+1 manual sobreu.posts. - Sin filtrado por relation:
.preload("posts").where(fn(u) => u.active)filtra users, no posts. Para filtrar posts pre-load, hacer la query separada con.where(fn(p) => p.user_id.is_in( user_ids)).
13. JSONB: Map<Str, Any> ↔ jsonb¶
Un field data: Map<Str, Any> se mapea a columna jsonb. INSERT
serializa con serde_json (preserve_order para mantener orden de
inserción) y cast ::jsonb. SELECT parsea el text JSON de vuelta
a Map Fitz preservando shape heterogéneo.
Declaración¶
Insert con JSONB heterogéneo¶
let e = Event.insert(db, Event {
id: 0,
name: "click",
data: {
"page": "/home",
"ts": 1700000000,
"user_agent": "Mozilla/5.0",
"active": true,
"session_id": null
}
}).await?
Update incremental sobre JSONB¶
Para actualizar todo el JSONB, pasar el Map completo:
Event.where(fn(e) => e.id == 42)
.update(db, {"data": {"page": "/about", "ts": 1700001000}})
.await?
Para actualizar una key específica del JSONB sin re-escribir
todo: bajar a db.exec crudo con el JSON operator ||:
SELECT y consumo¶
let e = Event.where(fn(e) => e.id == 42).first(db).await?
print(e.data["page"]) // "/home"
print(e.data["ts"]) // 1700000000 (Int adentro del Map<Str, Any>)
print(e.data["session_id"]) // null
Los values del Map<Str, Any> mantienen su tipo Fitz original:
Int/Float/Str/Bool/Null/List<Any>/Map<Str, Any>
nested.
Nested Maps¶
JSONB anidado se preserva:
let e = Event.insert(db, Event {
id: 0,
name: "purchase",
data: {
"item": {"id": 42, "name": "T-shirt", "price": 19.99},
"qty": 2,
"tags": ["promo", "winter-sale"]
}
}).await?
let back = Event.where(fn(e) => e.id == e.id).first(db).await?
let item = back.data["item"]
print(item["name"]) // "T-shirt"
JSON operators integrados en .where(...) (v0.10.5)¶
Cinco method calls sobre fields jsonb (Map<Str, ...>) se
mapean a operadores nativos Postgres:
| Method | SQL emitido | Postgres operator |
|---|---|---|
e.data.has_key("foo") |
"data" ? $1 |
key exists |
e.data.has_all_keys(["a", "b"]) |
"data" ?& $1::text[] |
all keys exist |
e.data.has_any_keys(["a", "b"]) |
"data" ?| $1::text[] |
any key exists |
e.data.contains_json({"k": "v"}) |
"data" @> $1::jsonb |
jsonb contains |
e.data.get("foo") |
("data"->>$1) |
text extract |
Ejemplos end-to-end:
// "todos los events que tengan la key 'page'"
let with_page = Event.where(fn(e) => e.data.has_key("page")).all(db).await?
// "todos los events que tengan TANTO 'page' como 'user'"
let with_both = Event.where(fn(e) =>
e.data.has_all_keys(["page", "user"])
).all(db).await?
// "todos los events con CUALQUIERA de las keys 'code' o 'extra'"
let either = Event.where(fn(e) =>
e.data.has_any_keys(["code", "extra"])
).all(db).await?
// "todos los events cuyo jsonb contenga AL MENOS {page: '/home'}"
let from_home = Event.where(fn(e) =>
e.data.contains_json({"page": "/home"})
).all(db).await?
// .get(key) devuelve text — comparable contra Str literal:
// "todos los events del usuario 'ada'"
let ada_events = Event.where(fn(e) =>
e.data.get("user") == "ada"
).all(db).await?
Caveats MVP (refinable):
.has_key(s)/.get(s)aceptan vars externas como arg (passan por el translator general)..has_all_keys([...])/.has_any_keys([...])/.contains_json({...})requieren literales (List o Map literal directo, no var)..contains_json({...})solo acepta values primitivos (Int/Float/Str/Bool/Null). Maps/Lists nested adentro: workaround condb.query(...)crudo..get(key) == valuecompara texto (single-level). Para nested path access + cast tipado, ver bloque siguiente (path operators v0.10.29).
JSON path operators avanzados (v0.10.29)¶
Cinco method calls sobre fields jsonb (Map<Str, ...>) que cierran
el gap de .get/.has_key (single-level, text-only) habilitando
acceso a paths anidados con cast tipado al tipo Fitz pedido.
Cada uno recibe 1 arg List<Str> literal con el path; emiten SQL
nativo Postgres con #> (jsonb path) o #>> (text path) + cast
opcional.
| Method | SQL emitido | Para qué |
|---|---|---|
e.data.has_path(["a", "b"]) |
"data" #> $1::text[] IS NOT NULL |
el path existe |
e.data.path_text(["a", "b"]) |
("data" #>> $1::text[]) |
extract text |
e.data.path_int(["a", "b"]) |
(("data" #>> $1::text[])::bigint) |
extract + cast Int |
e.data.path_float(["a", "b"]) |
(("data" #>> $1::text[])::float8) |
extract + cast Float |
e.data.path_bool(["a", "b"]) |
(("data" #>> $1::text[])::boolean) |
extract + cast Bool |
Ejemplos end-to-end:
// JSONB anidado: {"user": {"id": 5, "name": "ada"}, "score": 1.5, "active": true}
// ¿El path existe?
let with_uid = Event.where(fn(e) =>
e.data.has_path(["user", "id"])
).all(db).await?
// Filtro tipado por Int adentro del jsonb:
let uid_5 = Event.where(fn(e) =>
e.data.path_int(["user", "id"]) == 5
).all(db).await?
// Filtro tipado por Float:
let high_score = Event.where(fn(e) =>
e.data.path_float(["score"]) > 1.0
).all(db).await?
// Filtro Bool directo (path null → row rechazado):
let active = Event.where(fn(e) =>
e.data.path_bool(["active"])
).all(db).await?
Caveats MVP (refinable):
- El path debe ser
List<Str>literal con al menos 1 elemento (vars externas como path quedan como deuda menor — el caller típico arma el path inline). - Path vacío (
.has_path([])) → error claro citando.is_not_null()como alternativa. - El cast asume que el path apunta a un value del tipo declarado
(
path_int⇒ JSONB number,path_bool⇒ JSONB boolean, etc.). Si el value es de otro tipo, Postgres lanza error de cast en runtime (mensaje propagado tal cual por el driver). - Si el path no existe, el extract devuelve NULL — en
WHEREel row se rechaza (semántica SQL estándar). - Comparar
path_int(...) == 0para distinguir "no existe" vs "existe con valor 0" → usarhas_path([...])adicional. - Para chain estilo
e.data.get("a").get("b")(azúcar sobrepath_text(["a", "b"])) → deuda menor del próximo bloque.
Para casos no cubiertos, sigue disponible el escape hatch crudo:
Full-text search con @@ (v0.10.29)¶
Dos method calls sobre fields Str para Postgres full-text
search vía operator @@. El field SQL puede ser tsvector
declarado con @column(sql_type="tsvector"); el cast a tsquery
del lado lookup se hace automáticamente.
| Method | SQL emitido |
|---|---|
e.body_tsv.matches("query") |
"body_tsv" @@ to_tsquery($1) |
e.body_tsv.plainto_matches("hola") |
"body_tsv" @@ plainto_tsquery($1) |
Diferencia:
to_tsquery(text): el query soporta syntax avanzada de Postgres ('cat & dog','cat | dog','cat & !mouse', prefix'cat:*'). Errores de syntax SQL-side → runtime error.plainto_matches(text): el query es text libre (input del user). Postgres convierte en tsquery con AND implícito entre tokens. Más seguro para search bars donde el user escribe cualquier cosa.
Ejemplos:
@table("docs")
@index(body_tsv, using="gin") // v0.10.28 — gin para full-text
type Doc {
@primary id: Int = 0
body: Str
@column(sql_type="tsvector") body_tsv: Str
}
// search avanzado: soporta operators de tsquery
let matches = Doc.where(fn(d) =>
d.body_tsv.matches("postgres & (full | text)")
).all(db).await?
// search del user libre (typical search bar):
let user_input = body.q
let matches = Doc.where(fn(d) =>
d.body_tsv.plainto_matches(user_input)
).all(db).await?
Caveats:
- El field declarado debe ser
StrFitz-side; el sql_type real puede sertsvector(via@column(sql_type="tsvector")). - Las funciones
to_tsquery/plainto_matchesno piden config de language; usan el default Postgres (típicamente'english'). Para multi-lang, bajar adb.querycon cast explícito:to_tsquery('spanish', $1). Ranking (— CERRADO v0.10.32 (Tier C.1). Usáts_rank) NO está en MVP.order_by(fn(d) => -d.body_tsv.rank("query"))para emitirORDER BY ts_rank("body_tsv", to_tsquery('query')) DESC. Varianteplainto_rankpara plain queries. El query string se inlina como SQL literal en MVP — vars comoqueryargumento del user requierendb.querydirecto.
Null Fitz → NULL real (no la string "null")¶
let e = Event.insert(db, Event { id: 0, name: "x", data: {"k": null} }).await?
// Postgres: data = '{"k": null}'::jsonb → JSONB key con valor JSON null
vs "null" Str que sería el literal string.
14. Arrays Postgres: List<scalar> ↔ T[]¶
12 array OIDs soportados (bool[]/int2[]/int4[]/int8[]/
text[]/varchar[]/float4[]/float8[]/date[]/timestamp[]/
timestamptz[]/uuid[]).
Declaración¶
@table("posts") type Post {
@primary id: Int = 0
title: Str
tags: List<Str> // text[]
scores: List<Int> // int8[]
weights: List<Float> // float8[]
flags: List<Bool> // bool[]
}
Insert con arrays¶
let p = Post.insert(db, Post {
id: 0,
title: "Hola",
tags: ["rust", "postgres", "fitz"],
scores: [10, 20, 30],
weights: [0.1, 0.5, 0.9],
flags: [true, false, true]
}).await?
SELECT round-trip preserva orden¶
let back = Post.where(fn(p) => p.id == p.id).first(db).await?
print(back.tags[0]) // "rust"
print(back.scores[1]) // 20
Update con array literal¶
Append a un array (workaround crudo)¶
Los operadores array_append/array_remove/array_cat no están
en el translator del ORM en MVP — usar db.exec crudo.
15. NULL en arrays: List<scalar?>¶
Postgres permite NULL adentro de arrays (int8[] NULL con
elementos {1, NULL, 3}). Fitz lo mapea con List<scalar?>:
@table("readings") type Reading {
@primary id: Int = 0
samples: List<Int?> // int8[] con elementos nullable
}
let r = Reading.insert(db, Reading {
id: 0,
samples: [10, null, 30, null, 50] // Int?
}).await?
let back = Reading.where(fn(r) => r.id == r.id).first(db).await?
// back.samples: List<Int?> con NULLs preservados
for v in back.samples {
match v {
Ok(n) => print("valor: {n}")
Err(_) => print("(null)")
}
}
El text format Postgres {1,NULL,3} se parsea/encodea
simétricamente. El parser distingue NULL (sin quotes) del literal
"NULL" (con quotes).
Cuándo usar arrays nullable¶
Datos de sensores donde "NaN" o "no medido" es semánticamente distinto a 0:
@table("temperature_readings") type TempReading {
@primary id: Int = 0
location_id: Int
samples: List<Float?> // null = sensor no reportó esta muestra
timestamp: Str
}
Para arrays donde NULL no tiene sentido (e.g. tags: List<Str>),
usar la versión non-nullable: List<Str> (no List<Str?>).
16. Map<Str, T> concreto homogéneo¶
Alternativa a Map<Str, Any> cuando todos los values son del mismo
tipo primitivo (Int/Float/Str/Bool). El marshaling es directo
(HashMap
@table("counters") type CounterSnapshot {
@primary id: Int = 0
period: Str
counts: Map<Str, Int> // jsonb con shape homogéneo
}
let snap = CounterSnapshot.insert(db, CounterSnapshot {
id: 0,
period: "2026-05",
counts: {"clicks": 1234, "views": 5678, "purchases": 42}
}).await?
let back = CounterSnapshot.where(fn(s) => s.id == s.id).first(db).await?
print(back.counts["clicks"]) // 1234 (Int, no Any)
Restricciones¶
- K debe ser Str. Postgres jsonb keys son strings.
Map<Int, Int>→ error claro en codegen. - T debe ser primitivo concreto (Int/Float/Str/Bool). Nested
Maps (
Map<Str, Map<Str, Int>>) no soportados en MVP — usarMap<Str, Any>.
Cuándo usar Map<Str, T> vs Map<Str, Any>¶
| Caso | Preferir |
|---|---|
| Shape homogéneo conocido (e.g. contadores, métricas) | Map<Str, T> (más eficiente, tipo concreto) |
| Shape heterogéneo (e.g. settings dinámicas, metadata libre) | Map<Str, Any> (flexible) |
| Anidado en cualquier nivel | Map<Str, Any> (MVP no permite T compuesto) |
17. Array ops en .where(...)¶
Tres operadores Postgres sobre arrays mapeados a method calls Fitz adentro del closure:
.has(elem) → $1 = ANY(column)¶
¿El elemento está en el array?
// SQL emitido: WHERE $1 = ANY("tags")
let rusty = Post.where(fn(p) => p.tags.has("rust")).all(db).await?
.contains_all([a, b, ...]) → column @> $1¶
¿El array contiene TODOS los elementos especificados?
// SQL emitido: WHERE "tags" @> $1::text[]
let both = Post.where(fn(p) => p.tags.contains_all(["rust", "postgres"])).all(db).await?
.contained_in([a, b, ...]) → column <@ $1¶
¿TODOS los elementos del array están en la lista especificada?
// SQL emitido: WHERE "scores" <@ $1::int8[]
let small = Post.where(fn(p) => p.scores.contained_in([1, 2, 3, 4, 5])).all(db).await?
Combinaciones¶
Como cualquier otro filtro, se combinan con AND/OR:
let curated = Post.where(fn(p) =>
p.tags.has("featured") and
p.scores.contains_all([100]) and
not p.archived
).all(db).await?
Caveat MVP¶
Los array ops (has/contains_all/contained_in) requieren
args como literales del tipo escalar del array: Int/Float/Str/
Bool. Variables del scope externo NO se aceptan como arg
directo del method. Workaround: bajar a db.query(...) crudo:
// ❌ ERROR: vars adentro de array ops no soportadas
let some_tag = "rust"
Post.where(fn(p) => p.tags.has(some_tag)).all(db).await?
// ↑ MVP: el arg debe ser literal
// ✅ Workaround: db.query crudo con $param
let rows = db.query(
"SELECT * FROM posts WHERE $1 = ANY(tags)",
[some_tag]
).await?
Refinamiento futuro probable: permitir vars del scope externo en los args de array ops, paralelo a lo que ya funciona para comparators básicos.
18. Date / Time / Timestamp / UUID¶
Estos tipos Postgres se modelan como Str ISO 8601 (canonical
para Date/Time) o Str formato canonical UUID en MVP. El
driver hace el round-trip text↔Postgres correctamente; el type
Fitz no tiene primitivos Date/DateTime/UUID dedicados
todavía.
Date / Time / Timestamp / Timestamptz¶
@table("events") type Event {
@primary id: Int = 0
name: Str
occurred_at: Str // timestamp / timestamptz
occurred_date: Str // date
occurred_time: Str // time
}
// Formatos canónicos:
let e = Event.insert(db, Event {
id: 0,
name: "alarm",
occurred_at: "2026-05-26T16:30:00Z", // RFC 3339 / ISO 8601
occurred_date: "2026-05-26", // YYYY-MM-DD
occurred_time: "16:30:00" // HH:MM:SS
}).await?
Comparaciones lexicográficas funcionan correctamente para ISO 8601 (las strings se ordenan en orden temporal):
UUID¶
@table("sessions") type Session {
@primary token: Str // UUID v4 canonical
user_id: Int
expires_at: Str
}
// Generar UUID v4 del lado Fitz (placeholder hasta tener builtin uuid):
let token = "550e8400-e29b-41d4-a716-446655440000" // hardcoded para el ejemplo
let s = Session.insert(db, Session {
token: token,
user_id: 42,
expires_at: "2026-12-31T23:59:59Z"
}).await?
UUID generation built-in (uuid.v4() ?) queda como mini-fase
futura.
Limitaciones¶
- Sin Date/DateTime/UUID nativos: validación de formato es
responsabilidad del user al insertar. Postgres rechaza con error
si el formato es inválido (lo cual se propaga como
Result::Err). - Sin aritmética de fechas adentro del translator: `e.occurred_at
- interval '1 day'
no funciona. Workaround:db.query(...)crudo con SQL que usaINTERVAL/AGE/EXTRACT`. - Timezone handling: timestamptz se preserva en UTC; conversión a local timezone es responsabilidad del cliente Fitz que consume.
19. Recetas — paginación¶
Offset/Limit clásico¶
El patrón más simple. Para pages chicas y datasets que no cambian constantemente:
@get("/users")
async fn list_users(page: Int, page_size: Int) -> Result<List<User>> {
let offset = (page - 1) * page_size
return User.where(fn(u) => u.active)
.order_by(fn(u) => u.id) // ⚠️ ORDER BY obligatorio
.limit(page_size)
.offset(offset)
.all(db).await
}
Caveat crítico: SIN un ORDER BY determinístico, Postgres NO
garantiza orden estable entre pages. Siempre incluir
.order_by(fn(u) => u.id) (o el field que defina el orden de
display).
Performance: para offsets muy grandes (e.g. página 1000 con page_size=10 = offset 10000), Postgres lee todas las rows hasta el offset y descarta — costoso. Usar cursor-based para datasets grandes.
Cursor-based (más eficiente para datasets grandes)¶
@get("/users")
async fn list_users(after_id: Int, page_size: Int) -> Result<List<User>> {
return User.where(fn(u) => u.id > after_id and u.active)
.order_by(fn(u) => u.id)
.limit(page_size)
.all(db).await
}
// Cliente llama:
// /users?after_id=0&page_size=20 → primera página
// /users?after_id=20&page_size=20 → segunda (último id de la prev)
Tradeoffs:
- ✅ Performance constante O(log N) por page (usa el index del
id). - ✅ Inmune a inserts/deletes durante la paginación.
- ❌ No permite saltos directos (no hay "página 47", solo "siguientes 20 después del último visto").
Página + total para UI con paginador clásico¶
@get("/users")
async fn list_users(page: Int, page_size: Int) -> Result<Map<Str, Any>> {
let offset = (page - 1) * page_size
let users: List<User> = User.where(fn(u) => u.active)
.order_by(fn(u) => u.id)
.limit(page_size)
.offset(offset)
.all(db).await?
let total: Int = User.where(fn(u) => u.active).count(db).await?
return Ok({
"users": users,
"total": total,
"page": page,
"page_size": page_size,
"total_pages": (total + page_size - 1) / page_size
})
}
⚠️ Mismo caveat del cap 31: Map<Str, Any> en HTTP returns
no serializa a JSON automáticamente todavía (gap residual). Para
este caso, definir un type PaginatedUsers { ... } concreto.
20. Recetas — búsqueda¶
Búsqueda simple por prefijo / substring¶
@get("/users/search")
async fn search_users(q: Str) -> Result<List<User>> {
// Match por substring case-insensitive sobre email + name.
return User.where(fn(u) => u.email.ilike("%{q}%") or u.name.ilike("%{q}%"))
.order_by(fn(u) => u.id)
.limit(50)
.all(db).await
}
⚠️ .ilike(...) con % adelante (e.g. "%ada%") NO usa el index
del field → escaneo lineal. Para tablas grandes, considerar:
- Trigram index (
pg_trgmextension):CREATE INDEX users_email_trgm ON users USING gin (email gin_trgm_ops). - Full-text search (
tsvector+tsquery).
Full-text search con tsvector (workaround crudo)¶
@get("/articles/search")
async fn search_articles(q: Str) -> Result<List<Map<Str, Any>>> {
// tsquery + ts_rank requieren db.query crudo en MVP.
return db.query(
"SELECT id, title, ts_rank(search_vector, query) AS rank
FROM articles, websearch_to_tsquery($1) query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 20",
[q]
).await
}
Pre-requisito: la tabla articles tiene una columna
search_vector tsvector actualizada (typical via trigger sobre
title + body).
Búsqueda en arrays (tags)¶
@get("/posts/by-tag/{tag}")
async fn by_tag(tag: Str) -> Result<List<Post>> {
return Post.where(fn(p) => p.tags.has(tag))
.order_by(fn(p) => p.id, ascending: false)
.limit(50)
.all(db).await
}
Búsqueda en JSONB (workaround crudo)¶
@get("/events/by-page/{page}")
async fn by_page(page: Str) -> Result<List<Map<Str, Any>>> {
return db.query(
"SELECT id, name, data FROM events
WHERE data->>'page' = $1
ORDER BY id DESC
LIMIT 50",
[page]
).await
}
21. Recetas — search filters combinatorios¶
Construir queries dinámicas según los filtros que el cliente envía. Patrón: cada filter opcional se aplica condicionalmente.
type UserFilters {
role: Str?
min_age: Int?
max_age: Int?
active_only: Bool
name_contains: Str?
}
@post("/users/search")
async fn search(filters: UserFilters) -> Result<List<User>> {
// Empezamos con un base query.
let qb = User.where(fn(u) => u.id > 0) // condición trivial inicial
// Sumar filtros condicionalmente.
// Nota: la API actual del ORM NO soporta chain dinámico
// (.where condicional adentro de un if).
// Workaround: pattern match sobre los filtros y usar
// múltiples query branches, o usar db.query crudo con
// SQL construido programáticamente.
// Patrón con .where múltiples (combinando con AND):
if filters.active_only {
// ... aquí necesitaríamos algo como qb = qb.where(...)
}
// En MVP, el approach más limpio es:
return search_dynamic(filters).await
}
async fn search_dynamic(f: UserFilters) -> Result<List<User>> {
// SQL armado a mano con db.query crudo.
let where_parts = ["u.id > 0"]
// ... build dynamic SQL ...
// (este patrón se documenta más limpio cuando el ORM soporte
// chain condicional. Hoy es deuda residual.)
}
Chain dinámico condicional (v0.10.5)¶
A pesar de que el SQL se construye en compile-time, el receiver
del chain puede ser una variable mutable — el codegen emite cada
chain method como (receiver).with_<x>(...) y el QueryBuilder<T>
runtime es cloneable. Esto habilita el patrón "armar filters
condicionalmente" sin bajar a db.query crudo:
async fn search(min_age: Int, active_only: Bool, name_like: Str, db: DbConn)
-> Result<List<User>>
{
let qb = User.where(fn(u) => u.age >= min_age)
if (active_only) {
qb = qb.where(fn(u) => u.active)
}
if (name_like != "") {
qb = qb.where(fn(u) => u.name.like(name_like))
}
return qb.order_by(fn(u) => u.id).all(db).await
}
Funciona también con .order_by(...), .limit(n), .offset(n):
async fn paginated(page: Int, page_size: Int, sort_desc: Bool, db: DbConn)
-> Result<List<User>>
{
let qb = User.where(fn(u) => u.age > 0)
if (sort_desc) {
qb = qb.order_by(fn(u) => -u.age)
} else {
qb = qb.order_by(fn(u) => u.age)
}
if (page_size > 0) {
qb = qb.limit(page_size).offset((page - 1) * page_size)
}
return qb.all(db).await
}
Caveat: cada chain method genera un fragmento SQL constante (que
respeta las restricciones del closure, ver sec 7). El SHAPE del
chain es dinámico (se decide en runtime cuáles branches del if
toman), pero cada fragmento individual sigue siendo compile-time.
Patrón alternativo: filters fijos con match¶
Si los filtros son pocos y las combinaciones bounded, también
funciona el approach de branches separados sin construir un qb
mutable:
@get("/users/by-status/{status}")
async fn by_status(status: Str) -> Result<List<User>> {
return match status {
"active" => User.where(fn(u) => u.active).all(db).await
"inactive" => User.where(fn(u) => not u.active).all(db).await
"admins" => User.where(fn(u) => u.role == "admin").all(db).await
_ => Err("status no válido")
}
}
Esta forma es más declarativa cuando las combinaciones se conocen
de antemano. La forma dinámica con qb = qb.where(...) brilla
cuando hay N filtros opcionales independientes.
22. Recetas — Auth + ORM (queries scoped al user autenticado)¶
El ORM se integra naturalmente con auth nativa (cap 28).
@table("users") type User {
@primary id: Int = 0
email: Str
role: Str
}
@table("posts") type Post {
@primary id: Int = 0
title: Str
body: Str
@belongs_to("User") author_id: Int
}
@auth_provider
async fn auth(headers: Map<Str, Str>) -> Result<User> {
let token = headers.get("authorization")?
let claims = jwt.decode(token, "mi-secret")?
let email = claims["email"]
return User.where(fn(u) => u.email == email).first(db).await
}
// GET /my-posts — solo los posts del user autenticado.
@authenticated
@get("/my-posts")
async fn my_posts(user: User) -> Result<List<Post>> {
return Post.where(fn(p) => p.author_id == user.id)
.order_by(fn(p) => p.id, ascending: false)
.all(db).await
}
// POST /posts — crea un post atribuído al user autenticado.
type PostInput { title: Str, body: Str }
@authenticated
@post("/posts")
async fn create_post(user: User, body: PostInput) -> Result<Post> {
let p = Post {
id: 0,
title: body.title,
body: body.body,
author_id: user.id // ← user inyectado por el auth provider
}
return Post.insert(db, p).await
}
// DELETE /posts/{id} — solo si el user es el author O es admin.
@authenticated
@delete("/posts/{id}")
async fn delete_post(user: User, id: Int) -> Result<Int> {
let post = Post.where(fn(p) => p.id == id).first(db).await?
if post.author_id != user.id and user.role != "admin" {
return Err("no autorizado para borrar este post")
}
return Post.where(fn(p) => p.id == id).delete(db).await
}
Patrón canonical: user.id en cada filter¶
Cada handler protegido que opera sobre datos del user incluye
u.author_id == user.id (o equivalente) en el WHERE. Cero
"olvidé el filter del owner" porque el user lo inyecta el
provider explícitamente al handler.
Combinar con @admin¶
@admin
@delete("/users/{id}")
async fn delete_user(id: Int, admin: User) -> Result<Int> {
return User.where(fn(u) => u.id == id).delete(db).await
}
@admin ya valida estáticamente que user.role == "admin" —
solo ejecuta el handler si el caller es admin.
23. Recetas — HTTP CRUD completo¶
El showcase de combinar todo el stack está en
examples/guide/31b-orm-crud-http.fitz (~135 LoC) que el cap 31
referencia. Cubre:
GET /health checkGET /users(list all)GET /users/{id}(get one)POST /users(create, bodyUserInput)PUT /users/{id}(update, bodyUserInput)DELETE /users/{id}GET /users/{id}/posts(relation query)POST /posts(create con FK al user)GET /users-with-posts(eager loading con.preload)GET /user-count(aggregate scalar)
Patterns demostrados:
- Types separados para DB shape vs HTTP entrada (
UservsUserInput). El input no incluyeid(auto-asignado) niposts(virtual). Mejor cohesión que reusarUserpara ambos. env_or("DATABASE_URL", default)para configuración via env var con fallback.- Helper
open_db()para reusar la URL en cada handler. Result<T>con?para propagar errores ORM hasta el cliente (500 con{"error": "..."}automático).
Variante con state shared (1 conn pool global)¶
El ejemplo del cap 31 abre una conn por request via open_db().
Para usar un pool global compartido entre handlers (más eficiente
en producción):
// Top-level: connect una sola vez al boot.
let db = db.connect(env_or("DATABASE_URL", "postgres://...")).await?
db.exec("CREATE TABLE IF NOT EXISTS users (...)", []).await?
@server(3000)
fn main() => 0
// Cada handler usa el `db` top-level.
@get("/users")
async fn list_users() -> Result<List<User>> {
return User.all(db).await
}
Funciona en fitz run. En fitz build el codegen detecta db
como state HTTP compartido y lo emite como Arc<Mutex<DbConn>>
(deuda F17 cerrada). Validar bit-a-bit en el ejemplo es deuda
futura — el patrón open_db() del ejemplo es la versión segura.
24. Recetas — Cron job de limpieza¶
Cron jobs + ORM se combinan naturalmente para tareas batch:
@table("sessions") type Session {
@primary id: Int = 0
token: Str
user_id: Int
expires_at: Str // ISO 8601
}
// Cada hora, borrar sessions expiradas.
@cron("0 * * * *")
async fn cleanup_expired_sessions() {
let now_iso = "2026-05-26T16:00:00Z" // placeholder; hasta tener now() builtin
let deleted = Session.where(fn(s) => s.expires_at < now_iso)
.delete(db).await
match deleted {
Ok(n) => print("[cleanup] {n} sessions expiradas borradas")
Err(e) => print("[cleanup] error: {e}")
}
}
Drafts viejos sin auto-publish¶
@table("posts") type Post {
@primary id: Int = 0
title: Str
status: Str // "draft" | "published" | "archived"
created_at: Str
}
@cron("0 0 * * 0") // cada domingo a medianoche
async fn archive_old_drafts() {
let threshold = "2026-01-01T00:00:00Z" // 6 meses atrás (placeholder)
let archived = Post.where(fn(p) => p.status == "draft" and p.created_at < threshold)
.update(db, {"status": "archived"})
.await
match archived {
Ok(n) => print("[archive] {n} drafts archivados")
Err(e) => print("[archive] error: {e}")
}
}
Daily stats compute¶
@cron("5 0 * * *") // 00:05 cada día
async fn compute_daily_stats() {
// Total users activos.
let active = User.where(fn(u) => u.active).count(db).await
// Promedio de posts por user.
let avg_posts = db.query(
"SELECT AVG(c)::float8 AS avg_posts FROM (
SELECT COUNT(*) AS c FROM posts GROUP BY author_id
) sub",
[]
).await
// ... persist stats a una tabla `daily_stats` ...
print("[stats] daily compute done")
}
25. Recetas — Bulk operations¶
Insert múltiple — Type.bulk_insert(rows, db) (v0.10.27)¶
async fn seed_users(db: DbConn) -> Result<Int> {
let rows: List<User> = []
let mut i = 0
while (i < 1000) {
rows.push(User { id: 0, name: "user_{i}", role: "guest" })
i = i + 1
}
let n = User.bulk_insert(rows, db).await?
print("inserted: {n}")
return Ok(n)
}
Emite INSERT INTO users ("name", "role") VALUES ($1, $2), ($3, $4), ...
en batches de 1000 rows por default (configurable con
User.bulk_insert(rows, db, batch_size=500)). Devuelve el conteo
total de rows insertadas.
Sentinel id: 0 auto-bigserial: la primera row del batch
decide. Si su PK es 0 (Int sentinel), el SQL omite la columna
PK en TODAS las rows del batch (Postgres genera con
bigserial). Si la primera row trae PK > 0, las N rows incluyen
PK explícito. Asunción: shape uniforme — no mezclar rows con y
sin PK en el mismo bulk_insert.
Composite PK (v0.10.27): el batch lleva los N valores del PK
tuple explícitos en cada row. Sentinel no aplica (no hay
bigserial para composite).
Costos: 1 round-trip por batch (~1000 rows). Para datasets
mayores (cientos de miles +), db.copy_in (COPY FROM STDIN)
sigue como deuda futura.
Patrón anterior (loop con .insert) sigue válido para casos
de <100 rows o cuando necesitás la Instance devuelta por cada
insert con todos los defaults aplicados.
Update múltiple sobre set de IDs¶
let ids = [1, 2, 3, 4, 5]
// SQL emitido: UPDATE users SET "role" = $1 WHERE "id" = ANY($2::int8[])
let updated = User.where(fn(u) => u.id.is_in(ids))
.update(db, {"role": "vip"})
.await?
print("updated rows: {updated}")
Delete por batch¶
let deleted = Post.where(fn(p) => p.status == "spam")
.delete(db).await?
print("spam posts borrados: {deleted}")
26. Recetas — Schema idempotente al boot¶
Pattern canonical para que el binario "se auto-bootee" creando las tablas si no existen:
async fn boot_schema(db: DbConn) -> Result<Null> {
db.exec("CREATE TABLE IF NOT EXISTS users (
id bigserial PRIMARY KEY,
email text NOT NULL UNIQUE,
name text NOT NULL,
role text NOT NULL DEFAULT 'user',
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT NOW()
)", []).await?
db.exec("CREATE TABLE IF NOT EXISTS posts (
id bigserial PRIMARY KEY,
title text NOT NULL,
body text NOT NULL,
status text NOT NULL DEFAULT 'draft',
tags text[] NOT NULL DEFAULT '{}',
metadata jsonb NOT NULL DEFAULT '{}',
author_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT NOW()
)", []).await?
// Índices comunes.
db.exec("CREATE INDEX IF NOT EXISTS posts_author_idx ON posts(author_id)", []).await?
db.exec("CREATE INDEX IF NOT EXISTS posts_tags_gin ON posts USING gin (tags)", []).await?
return Ok(null)
}
async fn main() -> Result<Null> {
let db = db.connect(env_or("DATABASE_URL", "postgres://postgres:postgres@localhost/demo?sslmode=disable")).await?
boot_schema(db).await?
// ... resto del programa (HTTP server, jobs, etc.) ...
return Ok(null)
}
Migraciones manuales versionadas¶
Hasta tener fitz db diff/migrate (Fase 10.6+), las migraciones
explícitas se manejan con un patrón simple:
async fn run_migrations(db: DbConn) -> Result<Null> {
db.exec("CREATE TABLE IF NOT EXISTS schema_migrations (
version text PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT NOW()
)", []).await?
let applied = db.query("SELECT version FROM schema_migrations", []).await?
let applied_versions = applied.map(fn(r) => r["version"])
// 001_initial.sql
if not applied_versions.has("001_initial") {
db.exec("CREATE TABLE users (...)", []).await?
db.exec("INSERT INTO schema_migrations (version) VALUES ('001_initial')", []).await?
print("[migrate] aplicada: 001_initial")
}
// 002_add_posts.sql
if not applied_versions.has("002_add_posts") {
db.exec("CREATE TABLE posts (...)", []).await?
db.exec("INSERT INTO schema_migrations (version) VALUES ('002_add_posts')", []).await?
print("[migrate] aplicada: 002_add_posts")
}
return Ok(null)
}
Workflow CLI dedicado a migrations queda como Fase 10.6+.
26.b. Transactions (v0.10.14)¶
Cuando un handler hace varias escrituras que deben ser
atómicas (escenario clásico: transferí dinero de cuenta A
a cuenta B; crear un Order + sus N OrderItems; sumar puntos a
un usuario + log de la operación), envolvelas en
db.transaction(fn(tx) -> Result<T> { ... }).
API canónica¶
async fn transfer(db: DbConn, body: TransferInput) -> Result<Int> {
return db.transaction(fn(tx) -> Result<Int> {
// `tx: DbConn` es del mismo tipo que `db`, pero pegado a
// la misma conn física durante toda la tx. Todos los
// métodos del ORM (`.insert/.update/.delete/.first/.all`)
// y el escape hatch (`tx.query/.exec`) funcionan sin
// cambios — pasás `tx` en lugar de `db`.
let _ = Account
.where(fn(a) => a.id == body.from_id)
.update(tx, { "balance": Account.balance - body.amount })
.await?
let _ = Account
.where(fn(a) => a.id == body.to_id)
.update(tx, { "balance": Account.balance + body.amount })
.await?
return Ok(body.amount)
}).await
}
Garantías¶
- Atomicidad —
BEGINantes del callback,COMMITautomático si retornaOk,ROLLBACKautomático si retornaErro si paniquea. Imposible quedarse a mitad de camino — sin llamadas manuales a commit/rollback que olvidarse. - Aislamiento — todas las queries adentro del callback usan la misma conexión física. El acquire del pool sucede una sola vez al inicio de la tx; las queries siguientes no compiten por slot del semaphore con otras requests concurrentes.
- Cleanup automático — la conn vuelve al pool al final (sea OK o Err). Sin connection leaks ni transactions colgadas server-side.
- Preserva el
Erroriginal del callback — si retornásErr(MyError { ... }), el caller recibe eseMyErrorintacto (no se aplana a un Str del driver).
Recetas¶
Insert padre + hijos (Order + OrderItems)¶
type OrderItem {
@primary id: Int = 0
@belongs_to("Order") order_id: Int = 0
sku: Str = ""
qty: Int = 0
}
@table("orders") type Order {
@primary id: Int = 0
@belongs_to("User") user_id: Int = 0
total: Float = 0.0
@has_many("OrderItem", via="order_id") items: List<OrderItem> = []
@db_default created_at: Str = ""
}
async fn place_order(
db: DbConn,
user_id: Int,
items: List<NewItem>,
) -> Result<Int> {
let total = items.map(fn(it) => it.unit_price * it.qty).sum()
return db.transaction(fn(tx) -> Result<Int> {
let order = Order.insert(tx, Order {
id: 0,
user_id: user_id,
total: total,
created_at: "",
}).await?
// Si CUALQUIERA de los inserts de items falla, el order
// tampoco persiste (ROLLBACK auto).
for it in items {
let _ = OrderItem.insert(tx, OrderItem {
id: 0,
order_id: order.id,
sku: it.sku,
qty: it.qty,
}).await?
}
return Ok(order.id)
}).await
}
Rollback explícito por validación¶
return db.transaction(fn(tx) -> Result<Int> {
let account = Account
.where(fn(a) => a.id == account_id)
.first(tx)
.await?
if (account.balance < amount) {
// Retornar Err dispara ROLLBACK automático.
// El balance NO se modificó (todavía).
return Err("saldo insuficiente")
}
let _ = Account
.where(fn(a) => a.id == account_id)
.update(tx, { "balance": account.balance - amount })
.await?
return Ok(amount)
}).await
Escape hatch (tx.query / tx.exec)¶
Adentro de la tx también podés usar SQL crudo via los métodos del DbConn. Útil para queries que el ORM no expresa (CTEs, window functions, JSON operators avanzados):
return db.transaction(fn(tx) -> Result<Int> {
let _ = tx.exec(
"UPDATE accounts SET locked_at = NOW() WHERE id = $1",
[account_id],
).await?
let _ = tx.exec(
"INSERT INTO audit_log (user_id, action) VALUES ($1, $2)",
[account_id, "lock"],
).await?
return Ok(1)
}).await
Diferencias clave vs otros ORMs¶
- SQLAlchemy usa
session.commit()/session.rollback()explícitos. Si tu handler retorna antes de commit, la tx queda colgada server-side hasta que la conn se cierre. Fácil de olvidarse. Fitz no permite ese error — elOk/Errdel callback decide commit/rollback automático. - Diesel (Rust) usa
conn.transaction(|tx| {...})similar a Fitz — patrón validado por décadas en el ecosistema Rust. - Prisma (TypeScript) tiene
prisma.$transaction([...])con array de operaciones declarativas (menos flexible que closures) Yprisma.$transaction(async (tx) => {...})(closure-based, equivalente al de Fitz).
Sintaxis del callback (v0.10.15)¶
Desde v0.10.15, el callback puede ser tanto FnExpr inline como
fn nombrada — paridad bit-a-bit entre fitz run y fitz build.
Inline (recomendado para closures cortos):
return db.transaction(async fn(tx) -> Result<Int> {
let _ = User.insert(tx, body).await?
return Ok(1)
}).await
Nombrada (cuando reusás la misma lógica de tx en varios endpoints o querés testearla aislada):
async fn create_user_tx(tx: DbConn) -> Result<Int> {
let _ = User.insert(tx, get_default_user()).await?
return Ok(1)
}
return db.transaction(create_user_tx).await
Captures del outer scope: el body inline puede usar vars del
handler que envuelve la tx (body.field, user.id, args del
handler, etc.) sin pasarlos explícitamente. El codegen emite
doble move Rust (outer closure + async move inner) para
capturar correctamente.
Lo que NO soporta el MVP (deuda futura)¶
Nested transactions (SAVEPOINT)— CERRADO v0.10.31 (Tier A.4).db.transaction(fn(tx){ ... tx.transaction(fn(inner){ ... }) })ahora detecta el nesting viatx_depthy emiteSAVEPOINT fitz_sp_<N>/RELEASE SAVEPOINT/ROLLBACK TO SAVEPOINTen lugar deBEGIN/COMMIT/ROLLBACK. Inner Err deja el outer intacto (rollback parcial); inner Ok release el savepoint y el outer commitea las queries del outer + las del inner exitoso. Sin sintaxis nueva — el mismotx.transactionfunciona ambos casos.Niveles de aislamiento custom— CERRADO v0.10.31 (Tier A.9).db.transaction(closure, isolation="SERIALIZABLE")con kwarg. Whitelist defensiva:SERIALIZABLE,REPEATABLE READ,READ COMMITTED,READ UNCOMMITTED, opcionalmente combinados conREAD ONLY/READ WRITE("SERIALIZABLE READ ONLY"). El outer BEGIN emiteBEGIN ISOLATION LEVEL <...>; nested ignora (Postgres no permite ISOLATION en SAVEPOINT — el nivel lo fija el outer).- Transacciones read-only — combinable con isolation:
db.transaction(closure, isolation="REPEATABLE READ READ ONLY").
26.c. Migraciones automáticas (v0.10.16)¶
fitz db diff introspecciona el schema real de Postgres, lo
compara con los @table type declarados en tu programa, y emite
el SQL ALTER TABLE necesario para sincronizarlos. fitz db
migrate aplica un directorio de migration files versionados
con tracking idempotente en la tabla _fitz_migrations. Sin
deps externas (Alembic / Flyway / Liquibase / TypeORM CLI):
todo vive en el binario fitz.
Subcomandos¶
| Subcomando | Qué hace |
|---|---|
fitz db diff [archivo.fitz] [--out file.sql] |
Compara schema declarado (en archivo.fitz o [bin].main del manifest) con el real, emite SQL al stdout o file. |
fitz db migrate [--dry-run] |
Aplica los .sql pendientes del dir ./migrations en orden alfabético, registra cada uno en _fitz_migrations. --dry-run muestra qué se aplicaría sin tocar la DB. |
fitz db status |
Lista cada archivo .sql con badge ✓ applied / → PENDING. |
fitz db new <name> |
Crea migrations/YYYYMMDDHHMMSS_<name>.sql con stub vacío. |
URL: lee DATABASE_URL env var, o pasa --url
postgres://.... Dir: ./migrations por default, override con
--dir.
Workflow canónico¶
# 1. Editás `@table type User { ... name: Str = "" }` agregando campo.
# 2. Generás migration vacía con timestamp.
fitz db new add_name_to_users
# → migrations/20260529150000_add_name_to_users.sql
# 3. Generás el SQL automático y lo redirigís a la migration.
fitz db diff > migrations/20260529150000_add_name_to_users.sql
# 4. Aplicás contra la DB.
fitz db migrate
# → ✓ 1 migration(s) aplicada(s): 20260529150000
Política¶
- Tracking: tabla
_fitz_migrationsconversion TEXT PRIMARY KEY+applied_at TIMESTAMPTZ DEFAULT NOW(). - Idempotente: re-correr
migratecon todo aplicado es no-op (✓ todas las migrations ya aplicadas). - Determinístico: el diff emite cambios en orden seguro (CREATE TABLE → ADD/DROP/ALTER COLUMN → CREATE INDEX → DROP FK → ADD FK → DROP TABLE).
- Quoted identifiers: todos los
CREATE TABLE/ALTER COLUMNquotean nombres ("users","email") — los names con reserved words o caracteres especiales no rompen.
Down migrations + fitz db rollback (v0.10.17)¶
Las migrations soportan secciones explícitas -- UP / -- DOWN
para permitir rollback. Backward-compatible: archivos sin
marcadores siguen siendo "UP implícito sin DOWN" (no se pueden
revertir, pero migrate los aplica igual).
-- Migration: add_email_to_users
-- Created: 2026-05-29T20:30:00Z
-- UP
ALTER TABLE "users" ADD COLUMN "email" text NOT NULL DEFAULT '';
-- DOWN
ALTER TABLE "users" DROP COLUMN "email";
fitz db migrate # aplica el UP
fitz db rollback # ejecuta el DOWN del último applied + borra
# registro en _fitz_migrations
fitz db rollback --count 3 # revierte las últimas 3
Política:
fitz db new <name>(v0.10.17) genera stubs con-- UPy-- DOWNpor convención — borrá la sección DOWN solo si la migration es genuinamente irreversible.- El marcador es case-insensitive sobre línea propia:
-- UP,-- up,--UP,-- Upmatchean.-- UP fooNO (chars extra → SQL comment normal). - Sección DOWN vacía / solo whitespace → tratada como
None(irreversible). - Si querés revertir N>1 y una de las target NO tiene
-- DOWN, el rollback aborta ANTES de tocar la DB con mensaje específico citando el filename. Cero estado parcial. - Cada
revert_migrationcorre adentro de su propia tx (atomic por migration). Rollback de N>1 es N tx individuales — si la k-ésima falla en runtime, las anteriores ya persistieron. Para "todo o nada" sobre N migrations, escribí una migration única con todo el rollback. - No hay limitación de "última N en filename order" — el
rollback usa
applied_at DESCdel tracking, no el orden de los archivos. Si aplicaste migration A, después B, después reaplicaste A (caso raro pero posible vía stamp futuro), rollback revierte A primero.
Renames seguros via @renamed_from(...) (v0.10.17)¶
El diff detecta renames mediante el decorator transient
@renamed_from("nombre_anterior") sobre el field o el type
mismo. Sin el decorator, un rename Fitz-side
name → full_name se ve como DROP COLUMN name + ADD COLUMN
full_name, perdiendo los datos. El decorator hace que el
diff emita ALTER TABLE ... RENAME COLUMN en su lugar.
// Rename de column: campo Fitz `full_name` viene del column
// SQL anterior `name`.
@table("users") type User {
@primary id: Int = 0
@renamed_from("name") full_name: Str = ""
}
// Rename de tabla: type Fitz `User` mapea a la tabla SQL
// `users` que ANTES se llamaba `legacy_users`.
@table("users") @renamed_from("legacy_users") type User {
@primary id: Int = 0
}
fitz db diff emite:
ALTER TABLE "legacy_users" RENAME TO "users";
ALTER TABLE "users" RENAME COLUMN "name" TO "full_name";
Orden seguro: los renames van PRIMERO en el output (antes
de cualquier ADD/DROP COLUMN o ALTER COLUMN). Esto garantiza
que las acciones siguientes referencian los nombres post-rename
(sin esto, un ALTER COLUMN "full_name" TYPE varchar sobre
una tabla recién renombrada fallaría porque el column todavía
no existe con ese nombre).
Decorator transient — borralo después de aplicar la migration. El diff lo ignora silenciosamente cuando ya NO hay match en current (la migration ya se aplicó):
- Si target tiene
@renamed_from("old")Y current.columns contiene "old" Y NO contiene "new" → emite RENAME COLUMN. - Si target tiene
@renamed_from("old")pero current.columns ya solo tiene "new" → no-op silencioso (typical caso post-migration aplicada). - Si target tiene
@renamed_from("old")Y current contiene AMBOS "old" y "new" → no-op (sin colisión accidental — el diff típico tratará "new" como existente y "old" como a droppear según el resto del schema).
Por qué decorator y no subcomando (fitz db rename):
- Subcomando divorcia el rename del cambio en el code: fácil de olvidar uno o el otro.
- Decorator es declarativo, vive temporal en el code, atómico con el cambio del nombre del field/type.
- Después de aplicar, el user borra una línea — equivalente a cerrar un PR.
History + offline SQL + squash (v0.10.20)¶
fitz db history — audit log de migrations aplicadas, orden
applied_at DESC. Cruza tracking con files del dir; si una
version está applied pero el file fue removido (caso típico de
db stamp <legacy> o post-squash), aparece como (file removido).
fitz db history
# version applied_at filename
# -------------------- -------------------------------- ----------
# 20260530120000 2026-05-30 10:53:24.800092-03 create_posts.sql
# 20260530100000 2026-05-30 10:53:24.775132-03 create_users.sql
# 2 migration(s) applied.
fitz db migrate --sql — Offline SQL mode. Emite el SQL
pendiente al stdout en vez de ejecutarlo. Sigue conectándose
para leer _fitz_migrations (qué está applied) y skipear esas.
Útil para handoff a un DBA que aplica manual. Rechaza .fitz
data migrations (no se materializan como SQL offline).
fitz db migrate --sql > pending.sql
# 1 migration emitida — pasalo al DBA.
psql -h prod-db -f pending.sql
# Después marcalo como applied:
fitz db stamp 20260530120000
fitz db squash <from> <to> — Combina las migrations del
rango [from, to] (inclusive) en una sola. Concatena los UP
en orden + los DOWN en orden inverso (para que el rollback siga
funcionando). Mueve los files originales a migrations/squashed/
(no los borra — quedan como histórico). Actualiza el tracking
para apuntar al nuevo squashed.
fitz db squash 20260101000000 20260301000000
# ✓ tracking actualizado: 47 versions removidas, stamped `20260101000000`
# ✓ 47 migration(s) squashed → migrations/20260101000000_squashed.sql.
# Originales en migrations/squashed/.
Política:
- Solo
.sql: rechaza.fitzen el rango (squashing de scripts del lenguaje no es semánticamente trivial — abríl manualmente). - Rango mínimo 2: squashear 1 migration es no-op.
- Tracking inteligente: si alguna del range estaba applied
en la DB, borra todas del rango de
_fitz_migrationsy stampea solofrom(el nuevo squashed). Si ninguna estaba applied, no toca tracking —migrateaplicará el squashed como una migration normal. - Pre-flight: aborta antes de tocar files si el squashed ya existe (evita sobre-escribir si re-corrés sin pensar).
--no-tracking: skipea la actualización del tracking en_fitz_migrations(para repos sin DB de staging accesible desde el dev — útil en CI-only). Quedás responsable de stampear manual el squashed en cada DB existente.
Caso de uso típico: repo con 100+ migrations viejas que el equipo ya aplicó hace meses. Squashear las primeras 80 acelera el bootstrap de devs nuevos (1 migration en vez de 80) sin afectar a quienes ya las aplicaron (el tracking las trata como una sola).
Data migrations en .fitz (v0.10.19)¶
fitz db migrate ahora reconoce DOS extensiones en migrations/:
.sql (DDL/DML SQL crudo, splittable en -- UP/-- DOWN) y
.fitz (scripts del propio lenguaje con db.query/db.exec/
db.transaction adentro). Se intercalan en orden cronológico por
el prefijo timestamp del filename.
Las .fitz migrations habilitan transforms que SQL crudo no
expresa con elegancia: back-fills con lógica condicional, parseo
de JSON viejo a columns nuevas, HTTP calls a un service externo
durante la migración, etc.
Convención del archivo .fitz: declarar async fn migrate(db:
DbConn) -> Result<Null>. Opcionalmente async fn rollback(db:
DbConn) -> Result<Null> para que fitz db rollback ande.
// migrations/20260530150000_backfill_full_name.fitz
async fn migrate(db: DbConn) -> Result<Null> {
// 1 UPDATE sería suficiente acá, pero el patrón con loop
// demuestra que tenés acceso completo al lenguaje:
match db.query("SELECT id, first_name, last_name FROM users WHERE full_name IS NULL", []).await {
Ok(rows) => {
for r in rows {
let id = r.get("id")
let first = r.get("first_name")
let last = r.get("last_name")
let _ = db.exec(
"UPDATE users SET full_name = $1 || ' ' || $2 WHERE id = $3",
[first, last, id],
).await?
}
return Ok(null)
}
Err(e) => return Err(e),
}
}
async fn rollback(db: DbConn) -> Result<Null> {
let _ = db.exec("UPDATE users SET full_name = NULL", []).await?
return Ok(null)
}
Política:
- Discovery: archivos
.sqly.fitzse mezclan en el dirmigrations/. Orden por prefijo timestamp del filename. - Tracking: misma tabla
_fitz_migrationsque.sql. La version es el prefix del filename. - Atomicidad:
.fitzmigrations NO se envuelven en tx automáticamente. El user decide la granularidad: típicamentereturn db.transaction(fn(tx) -> Result<Null> { ... }).awaitadentro del cuerpo demigrate. Es responsabilidad del script ser idempotente o atómico según su semántica. - Validación pre-run: el runner parsea el
.fitzy verifica que declaraasync fn migrate(db: DbConn) -> Result<Null>antes de tocar la DB. - Rollback opcional: si el
.fitzdeclaraasync fn rollback(db: DbConn) -> Result<Null>,fitz db rollbackla invoca + borra el registro de tracking. Si NO la declara, rollback aborta pre-flight con mensaje claro (paralelo a.sqlsin-- DOWN). dbestá pre-bindeado al env del script alValue::DbConnde la conn del CLI. NO requieredb.connect(url)adentro (el CLI ya conectó por vos viaDATABASE_URL/--url).- El env del script tiene los builtins normales (
print,len,env_or,jwt,hash, etc.). Imports relativos al dir de la migration funcionan.
Cuándo usar .fitz vs .sql:
.sql— DDL puro (CREATE TABLE / ADD COLUMN / CREATE INDEX), back-fills triviales (UPDATE users SET x = 1 WHERE x IS NULL), fixtures de seed. 80% de las migrations..fitz— back-fills con lógica condicional o loops, parseo de JSON viejo a columns nuevas, HTTP calls a un service externo durante la migración, transforms que requieren state que SQL crudo no expresa elegantemente.
Caveat: las .fitz migrations corren via intérprete
(no codegen), igual que fitz run. Para migrations grandes con
miles de iteraciones, considerá hacer el bulk via 1 UPDATE
SQL en una .sql separada en lugar de loop iterativo en .fitz.
Drift check + stamping (v0.10.18)¶
fitz db check corre el diff y devuelve exit 0 si el
schema declarado matchea la DB, exit 1 con el SQL pendiente
al stderr si hay drift. Hook clave para CI bloqueante.
fitz db check src/main.fitz
# Sin drift:
# ✓ schema sincronizado — schema declarado matchea la DB
# (exit 0)
#
# Con drift:
# ✗ drift detectado — 2 change(s) pendiente(s):
#
# ALTER TABLE "users" ADD COLUMN "email" text NOT NULL;
# ...
#
# 💡 corré `fitz db diff > migrations/<file>.sql` + `fitz db migrate`
# (exit 1)
Patrón típico en .github/workflows/*.yml:
- name: Schema drift check
run: fitz db check src/main.fitz
env:
DATABASE_URL: ${{ secrets.STAGING_DB_URL }}
fitz db stamp <version> marca una migration como
aplicada en _fitz_migrations sin ejecutar el SQL. Útil
para adoptar Fitz en una DB legacy donde el schema ya está
aplicado manualmente (sin stamp, migrate intentaría re-aplicar
y fallaría con duplicados o sobrescribiría seed data).
# Adoptás Fitz en una DB existente:
# 1. Generás migration que matchea el schema actual:
fitz db diff src/main.fitz > migrations/20260530000000_initial.sql
# 2. Marcás la migration como aplicada SIN ejecutarla (la DB
# ya tiene las tables):
fitz db stamp 20260530000000
# Output:
# ✓ stamped: 20260530000000
# 3. A partir de acá, `fitz db migrate` aplica solo las nuevas:
fitz db migrate
fitz db stamp --all marca todas las pending del dir como
aplicadas en una pasada. Útil cuando tenés varias migrations
legacy ya aplicadas manualmente. Idempotente (versions ya
applied → no-op silencioso).
Idempotencia: stamp sobre una version ya applied → no-op
silencioso (✓ no-op: version X ya estaba aplicada). El
ON CONFLICT DO NOTHING interno cubre además el race entre dos
stamps concurrentes sobre la misma version.
Warning sobre versions inexistentes: si pasás una version
que NO existe en el dir migrations/, el stamp emite warning
pero igual la inserta — patrón "adopto una version legacy que
NUNCA voy a tener como file". El warning evita typos accidentales.
Defaults SQL via @db_default("expr")¶
Desde v0.10.16, @db_default acepta un arg Str opcional con la
expresión SQL del default. Si está, fitz db diff emite
DEFAULT <expr> en el CREATE TABLE / ADD COLUMN
automáticamente. Si el field gana o pierde el arg después,
emite ALTER TABLE ... ALTER COLUMN ... SET/DROP DEFAULT.
@table("events") type Event {
@primary id: Int = 0
title: Str = ""
// Marker-only — el ORM skipea el INSERT, pero la migration
// NO mete `DEFAULT NOW()` automático. El user lo agrega
// a mano si lo quiere.
@db_default created_at: Str = ""
// Con arg SQL — la migration emite `updated_at timestamp
// with time zone NOT NULL DEFAULT NOW()` automáticamente.
@db_default("NOW()") updated_at: Str = ""
// Otras expresiones válidas: UUID auto-generado, contadores,
// strings literales, etc. Es SQL literal, no validado por Fitz.
@db_default("gen_random_uuid()") tracking_id: Str = ""
}
El diff es idempotente: re-correr fitz db diff tras
aplicar la migration no propone cambios. La normalización
maneja:
- Case-insensitive de função calls:
NOW()↔now()↔Now()matchean (Postgres devuelvenow()lowercase desdeinformation_schema.column_default). - Strip de casts redundantes:
'public'::text↔'public'matchean (PG agrega casts automáticos a literales Str). - Trim whitespace.
NO intentamos evaluar expresiones equivalentes — now() y
CURRENT_TIMESTAMP son ambos válidos para timestamptz pero
los tratamos como distintos. Convención: elegí UNO en tu schema
y manteneté consistente.
Limitaciones explícitas del MVP¶
- Renames solo via
@renamed_from(v0.10.17): si NO ponés el decorator, un renamename → full_namese ve comoDROP + ADDperdiendo datos. El decorator hace el rename seguro; ver sub-sección "Renames seguros" arriba. ALTER COLUMN ... TYPEsin USING: cambios de tipo incompatibles (text → int) fallan. Editá la migration para agregarUSING (col::int)o data migration script.- Solo schema
public: futuras schemas custom requieren refinamiento del introspector. - Rollback de N>1 NO es atómico (v0.10.17): cada
revert_migrationcorre en su propia tx. Para "todo o nada" sobre N migrations, escribí una migration única con todo el rollback adentro.
Por qué Fitz hace esto distinto¶
- Cero deps externas: ni
pip install alembicninpm install typeorm. Todo en el binariofitz. - Schema desde el código tipado: la fuente de verdad es el
@table type User { ... }del lenguaje, no un YAML aparte ni reflection runtime. Si el type cambia, el diff lo detecta. - Paridad bit-a-bit con el resto del stack: usa el mismo
driver Postgres puro que el ORM (
db::introspect_schemahabla wire protocol v3.0 víainformation_schema+pg_catalog). - Idempotente por diseño: el tracking en
_fitz_migrationses estándar; re-corrermigrateodiffes siempre seguro.
27. Performance¶
Hay un benchmark publicable contra SQLAlchemy en
benchmarks/orm-vs-sqlalchemy/:
3 endpoints idénticos (GET /users, GET /users/{id}, POST /users)
sobre dos boilerplates equivalentes (api-postgres-fitz con ORM
nativo vs api-postgres-python con SQLAlchemy interop). Headline
(corrida v0.10.13, hardware Intel Core Ultra 7 + 64GB + Docker WSL2):
| Métrica | Fitz ORM | Python+SQLAlchemy | Speedup |
|---|---|---|---|
| Memory peak | 9.2 MB | 51.0 MB | 5.54x más eficiente |
GET /users p50 |
4.88 ms | 37.85 ms | 7.76x |
GET /users/{id} p50 |
3.60 ms | 31.87 ms | 8.85x |
GET /users/{id} RPS |
2604 | 296 | 8.80x |
Cold start 0.14s vs 0.22s. Image 131MB vs 258MB. POST sequential queda en empate (~108ms p50 ambos) — bottleneck del bench mismo (curl loop con subshell overhead), no del server.
Las observaciones conceptuales abajo explican por qué Fitz tiende
a ganar. Para reproducir el bench: bash benchmarks/orm-vs-sqlalchemy/run.sh.
SQL constante en codegen-time¶
Cada .where(closure) se walka del AST DURANTE EL CODEGEN, el
fragmento SQL queda hard-coded en el binario. Zero overhead
runtime para construir SQL:
Emite Rust equivalente a:
// Pseudocódigo del codegen
let qb = __FitzQueryBuilder::<UserData>::new(
"users",
"\"id\", \"email\", \"name\", \"age\", \"role\""
);
let qb = qb.with_where(
"(\"age\" > $1 AND \"role\" = $2)",
vec![into_pg(18), into_pg("admin")]
);
qb.all(&db).await
El fragmento "(\"age\" > $1 AND \"role\" = $2)" es un string
literal embebido en el binario. Compare con SQLAlchemy 2.x:
# SQLAlchemy: cada call construye un AST de objetos que se
# evalúa a SQL en runtime.
session.execute(
select(User).where(
(User.age > 18) & (User.role == "admin")
)
).all()
Cada select(...), where(...), &, etc. construyen objetos
Python en memoria. Compile-time vs runtime construction.
Pool de conexiones automático¶
db.connect(url) levanta un pool interno (10 conns por default).
Reconnect automático si una conn muere. Health check con
Weak<DbPool> para auto-cleanup.
Sin configuración por parte del user. Para apps que necesitan
ajustar tamaño del pool, queda como mini-fase futura
(db.connect_with(url, pool_size=20)).
Driver puro vs libpq¶
El driver Fitz habla wire protocol v3.0 directamente. Comparable
en perf a tokio-postgres/sqlx-postgres (también puros en Rust)
y a pgx de Go. Mejor que drivers que pasan por libpq (libpq
agrega un layer de copy + GIL en bindings Python).
Eager loading: 2 queries vs N+1¶
let users = User.preload("posts").all(db).await?
// 2 queries totales: SELECT * FROM users, luego SELECT * FROM posts WHERE user_id IN (...)
vs sin preload con 100 users → 101 queries. Diferencia de orden de magnitud típicamente.
Optimizaciones cerradas que impactaron el bench¶
- B-1 (v0.10.13) — Driver Postgres
Extended Query Protocolbatchea los 5 mensajes (Parse/Bind/Describe/Execute/Sync) en un únicowrite_all_bytes(...)+TCP_NODELAYactivo.GET /users/{id}pasó de 43.70ms p50 → 3.60ms p50 (12x más rápido, de "30% más lento que Python" a "8.85x más rápido"). - Pool singleton per URL (v0.10.9) —
db.connect(url)cachea elArc<DbConnHandle>global; calls subsiguientes a la misma URL devuelvenArc::clone()en vez de crear pool nuevo (fix connection leak crítico que rompía boilerplates bajo carga sostenida). __to_fitz_jsondeadlock fix (v0.10.10) — el conditional emit del field has_many virtual hacía re-lock del mismo Mutex no-reentrante. Liberar el guard antes del__to_fitz_jsonrecursivo destrabó el preload hang.
Próximas extensiones del bench (no bloqueantes)¶
- Mixed workload realista (reads + writes intercalados con ratio típico OLTP 80/20).
- Bulk inserts (1k+ rows en una transaction).
- Queries con JOINs / preload eager loading (necesita
api-orm-fullcomo bench target). - Escritura concurrente con saturación del pool.
Quedan como run-extended.sh cuando aparezca demanda real para
publicar comparativa más amplia.
- Memory usage bajo carga sostenida.
Detalle en docs/roadmap.md → "Plan boilerplates ORM/DB
post-Fase 10".
28. Limitaciones honestas y deuda explícita¶
Lo que NO está en el MVP, con plan de cierre y workaround recomendado:
Migraciones automáticas — CERRADO en v0.10.16¶
fitz db diff/migrate/status/new ya están en el binario. Ver
sección 26.c para el workflow canónico (incluyendo
@db_default("NOW()") que emite defaults SQL automáticamente
desde v0.10.16) y las limitaciones del MVP (no detecta renames,
no down migrations).
Transactions — CERRADO en v0.10.14 (fn nombrada) + v0.10.15 (FnExpr inline)¶
db.transaction(fn(tx) -> Result<T> { ... }) con
auto-rollback en Err y captures del scope outer. Ver
sección 26.b. Deuda futura (NO bloquea uso real): nested
transactions con SAVEPOINT, niveles de aislamiento custom,
read-only transactions.
Composite primary keys — CERRADO v0.10.27¶
N @primary fields por type. Migrations emite PRIMARY KEY (a, b)
constraint table-level; SELECT/INSERT/UPDATE/DELETE respetan
composite. Sentinel id: 0 auto-bigserial NO aplica (el user
provee los N valores explícitos del PK tuple). Navigation
belongs_to/has_many requiere single PK del target.
@table("memberships") type Membership {
@primary org_id: Int = 0
@primary user_id: Int = 0
role: Str = ""
}
TLS strict (sslmode=require / verify-ca / verify-full)¶
- Status: MVP soporta solo
sslmode=disable. TLS llega en sub-paso 10.1.b (StartTLS + cert validation). - Workaround: para Postgres detrás de un proxy (e.g. PgBouncer
- nginx con TLS termination), apuntar Fitz al endpoint sin TLS interno. Para conexiones a managed DB (Heroku, RDS, Supabase) que exigen TLS, esperar 10.1.b.
Date / Time / Timestamp / UUID nativos¶
- Status: se modelan como
StrISO 8601 / formato canonical UUID. El driver hace round-trip correctamente. - Workaround: el user maneja parsing/formatting del lado Fitz.
- Cuándo: mini-fase aparte. Decisión: implementar como tipos
built-in
Date/DateTime/UUIDcon métodos.
JSON operators avanzados¶
Los operadores principales (?, ?&, ?|, @>, ->>, #>,
#>>, @@) están disponibles como method calls sobre fields
jsonb / tsvector (ver sección 13). Lo que queda como deuda menor:
.has_all_keys/has_any_keys/contains_json/has_path/path_*requieren args literales (no vars del scope outer para el array de keys)..contains_json({...})solo acepta values primitivos (no Maps anidados).- CERRADO v0.10.29: nested path access + cast tipado
(
has_path/path_text/path_int/path_float/path_bool) y full-text search (matches/plainto_matchesconto_tsquery/plainto_tsquery). - Chain estilo
e.data.get("a").get("b")(azúcar sobrepath_text) sigue como deuda menor — el caller usa la versión explícitapath_text(["a", "b"]). Operadores faltantes:— CERRADOS v0.10.32 (Tier C.1 + C.3).||(concat jsonb),ts_rank||jsonb merge viaqb.merge_jsonb(db, field, patch)(ejecutaUPDATE tbl SET "field" = "field" || $1::jsonb WHERE <where>, preservando keys existentes).ts_rankvia.order_by(fn(u) => -u.body.rank("q"))para ordenar por relevancia full-text. Detalle en sección 28 (closures de.where()) + sección 31 (terminales del QueryBuilder).
Refinamientos pendientes del query builder¶
- Composite / partial / unique indexes via
@index(...)— CERRADO v0.10.27. Decorator@index(field1, field2, ..., unique=true, name="...", where_=...)declarado al type emiteCREATE INDEXauto desdefitz db diff/migrate. Soporta composite (multi-col), unique, partial (WHERE clause), y override del nombre. Expression indexes (sobrefunc(col)) quedan como deuda menor. - Bulk insert eficiente — CERRADO v0.10.27.
Type.bulk_insert([rows], db, batch_size=1000)emite multi-row VALUES en batches configurables. Paridad bit-a-bitfitz run↔fitz build. Sentinelid: 0auto-bigserial detectado de la PRIMERA row (asume shape uniforme — todas con o todas sin PK explícita). Para composite PK los valores van explícitos. db.copy_in(...)para inserts masivos: PostgresCOPY FROM STDIN(millones de rows en segundos) no está en el driver. Para datasets <100k rows,bulk_insertcubre el caso. Mini-fase aparte si entra presión real para datasets grandes (cientos de miles +).fitz db inspect/ introspection del schema real: no existe. Probablemente entra junto confitz db diff/migrate.
Refinamientos menores del sistema de tipos / codegen¶
- Nullable refinement en patterns complejos:
match obj { null => x, u => u.field }conobj: T?refinauaTdesde v0.10.6. Refinement aplica solo aPattern::Identdirecto — Tuples / OkBinding / ErrBinding sobre Nullable quedan como deuda menor. - Escape runtime en LIKE patterns con vars:
.starts_with( var)no escapa%/_del input runtime. Si la var tiene esos caracteres, se interpretan como wildcards SQL. Consistente con.like(var)— el user controla el pattern.
29. CLI con DB: cómo cada subcomando interactúa¶
Desde v0.10.16, Fitz tiene un subcomando dedicado fitz db ...
con varias acciones (diff / migrate / status / new /
rollback / check / stamp / history / squash / inspect
desde v0.10.28) — ver sección 26.c para el workflow canónico y
limitaciones del MVP. El resto de los subcomandos generales del CLI
también funcionan naturalmente con programas que usan el módulo db
y el ORM.
fitz db diff/migrate/status/new/rollback/check/stamp/history/squash/inspect — workflow de migraciones + introspect¶
export DATABASE_URL="postgres://fitz:fitz@localhost/myapp"
fitz db new add_email_to_users # crea migration vacía con UP/DOWN
fitz db diff > migrations/<file>.sql # genera ALTER TABLE auto
fitz db migrate # aplica + tracking idempotente
fitz db migrate --sql # v0.10.20: emite SQL offline (DBA)
fitz db status # lista applied/pending
fitz db history # v0.10.20: audit log applied_at DESC
fitz db rollback # revierte el último (--count N)
fitz db check # CI: exit 0 sync / exit 1 drift
fitz db stamp 20260530000000 # adopt DB legacy: marca sin ejecutar
fitz db stamp --all # marca todas las pending
fitz db squash <from> <to> # v0.10.20: combina migrations en una
fitz db inspect # v0.10.28: introspect del schema real
fitz db inspect --table users # focaliza una sola tabla
fitz db inspect --schema tenant_a # filtra por schema (default public)
fitz db inspect --all-schemas # v0.10.29: lista TODOS los schemas user-defined
fitz db inspect --json # output machine-readable
Detalle completo en sección 26.c.
fitz db inspect — introspect del schema real (v0.10.28)¶
Lista lo que la DB realmente tiene (no lo que tu código declara
— para eso está fitz db diff). Útil para:
- Auditar el schema antes de cambiar tipos (
fitz db inspect --table usersantes de migrar). - Descubrir tables/columns legacy creadas fuera de Fitz.
- Comparar dev vs prod (
fitz db inspect --url $PROD > prod.txty diff manual). - Generar reportes machine-readable para scripts externos via
--json.
Vista texto plano (default):
Schema: public
Table: users (3 cols)
id bigint NOT NULL PK
email text NOT NULL
deleted_at timestamp with time zone NULL
Indexes:
idx_users_email_active UNIQUE (email) WHERE (deleted_at IS NULL)
Table: posts (3 cols)
id bigint NOT NULL PK
author_id bigint NOT NULL
title text NOT NULL
Foreign keys:
posts_author_id_fkey: author_id -> users(id) ON DELETE CASCADE
Shape JSON estable (--json):
{
"schema": "public",
"tables": [
{
"name": "users",
"schema": "public",
"columns": [
{ "name": "id", "sql_type": "bigint", "nullable": false,
"default": null, "is_primary": true }
],
"primary_key": ["id"],
"indexes": [
{ "name": "idx_users_email_active", "columns": ["email"],
"unique": true, "where_clause": "(deleted_at IS NULL)",
"using": null }
],
"foreign_keys": []
}
]
}
Notas:
- NO se conecta al programa Fitz — solo a la DB. Independiente del proyecto Fitz local.
- Si la tabla no existe en el schema filtrado, emite mensaje claro
(no error):
(no se encontró la tabla \X`)`. - El
usingaparece comonullcuando es btree (default Postgres); como"gin"/"gist"/"brin"/etc. cuando el índice tiene method override. - Tables en schemas distintos a
publicquedan filtradas por default — pasar--schema <name>para verlas. - v0.10.29 —
--all-schemaslista TODOS los schemas user-defined a la vez (incluidopublic), agrupados con su propia sub-vista. Mutuamente excluyente con--schema. Útil para auditar bases multi-tenant sin tener que correr el comando una vez por schema. Combinable con--table Xpara filtrar un nombre puntual en todos los schemas. JSON shape:{"schemas": [{"schema": "ops", "tables": [...]}, ...]}(sort alfabético determinístico).
Observabilidad — FITZ_DB_LOG (v0.10.28)¶
Env var opt-in que loguea cada query del driver a stderr post- ejecución. Zero overhead si no está seteada.
export FITZ_DB_LOG=1 # mode simple: SQL + tiempo
export FITZ_DB_LOG=verbose # además params (truncados a 80 chars)
Output:
[fitz-db 1.2ms] SELECT id, email FROM users WHERE id = $1
[fitz-db 4.1ms verbose] INSERT INTO users (name) VALUES ($1) params=[$1="ada"]
- A stderr, NO contamina el stdout del programa.
- SQL multi-línea se colapsa a una sola línea para facilitar grep.
- Params largos (>80 chars) se truncan con
…final por seguridad básica (no se vuelca un BLOB entero al log). - v0.10.29 — En mode
verbose, los params correspondientes a campos sensibles (password/passwd/secret/api_key/apikey/api_token/auth_token/access_token/refresh_token/id_token/private_key/credential/passphrase/session_key/session_token/csrf_token) se enmascaran como<redacted>en el output. Ejemplo:UPDATE users SET password = $1 WHERE id = $2con params["super_secret", 42]logueaparams=[$1=<redacted>, $2=42]. Heurística best-effort que mira ~50 chars antes del placeholder + descarta matches separados porWHERE/AND/OR/etc. — sobre-redacta en bordes ambiguos (INSERT con varias columnas) por seguridad. NO sustituye una review general del código antes de habilitarverboseen prod, pero cierra el agujero más obvio de secrets en plain text en stderr. - Loguea todas las queries: SELECT/INSERT/UPDATE/DELETE/DDL +
queries internas del ORM (auto-genera). Cubre tanto
fitz runcomofitz build(mismo cratefitz::db). - El mode se fija al primer acceso del proceso (LazyLock). Cambios mid-run de la env var NO se reflejan.
Pool tuning — FITZ_DB_MAX_CONNS (v0.10.29)¶
Env var opt-in para overridear el pool size del driver. Default
10 conexiones simultáneas máximas por URL. Útil para apps con
mucho concurrent load (> 10 requests simultáneos que pegan a la
DB) o apps con muy poco load donde 10 conns es overkill.
export FITZ_DB_MAX_CONNS=50 # apps con mucho load HTTP
export FITZ_DB_MAX_CONNS=3 # apps batch / cron con poco load
- Parsea + clamp a
[1, 200]. Valores fuera de rango o no numéricos → fallback a default 10. - Aplica a TODOS los pools de TODAS las URLs del proceso (es
global, no per URL). El cache de pools por URL (
connect_url) sigue siendo único. - LazyLock — cambios mid-run NO se reflejan. Reinicia el proceso.
— CERRADO v0.10.31 (Tier A.3).db.connect(url, max_conns=N)como kwarg dedicado del lenguaje queda como deuda menordb.connect(url, max_conns=20)ahora funciona como kwarg del lenguaje (paralelo a la env var, con override per-process). Validación1 ≤ N ≤ 1000con error claro. Implementado vía override de la env var antes del connect — caveat: si un connect previo cacheó max_conns default, el override no aplica.
Errores del driver con SQL contexto (v0.10.29)¶
Cuando una query falla con un error del servidor Postgres
(DbError::Server), el mensaje devuelto al programa Fitz ahora
incluye:
- El SQLSTATE code entre corchetes (e.g.
[23505]=unique_violation,[23503]=foreign_key_violation,[42P01]=undefined_table). El usuario puede grep / match por código sin parsear el mensaje libre del severity. - El SQL one-line truncado a 200 chars que disparó el error.
- Los params bindeados con redaction de secrets (mismo
filtro que
FITZ_DB_LOG=verbose—password/secret/token/api_key/auth_token/etc. quedan como<redacted>).
Antes:
Después (v0.10.29):
ERROR [23505]: duplicate key value violates unique constraint "users_email_key"
[sql: INSERT INTO users (email, password) VALUES ($1, $2)
params=[$1="ada@x.com", $2=<redacted>]]
El usuario puede ver inmediatamente: - Qué constraint se violó (mensaje canónico). - Qué tipo de violación (SQLSTATE). - Qué query la disparó (sin abrir stacktrace ni mirar el log). - Qué params sin filtrar secrets.
El enriquecimiento aplica a todos los errores del wire protocol,
incluyendo db.exec(), db.query(), métodos del ORM (.insert,
.update, .delete, etc.). Errores que NO son del server (I/O,
protocol mismatch, TLS) pasan sin enriquecer (el contexto SQL no
aplica).
fitz run [archivo] — con DB¶
export DATABASE_URL="postgres://postgres:postgres@localhost/demo?sslmode=disable"
fitz run examples/guide/31-orm.fitz
Comportamiento:
- El intérprete carga el módulo
dbbuilt-in al boot (sin import explícito necesario). db.connect(url)levanta el pool de conexiones lazy.- Todas las queries van contra Postgres real — sin paridad fake, el evaluator habla wire protocol v3.0 igual que el binario.
- Si
connectfalla (URL inválida, server down, password wrong, sslmode=require sin TLS support), el programa termina conResult::Errpropagado al top-level. - Hot reload via
fitz dev(ver abajo) re-arranca el proceso conservando el state de la DB.
fitz build [archivo] — con DB¶
export DATABASE_URL="postgres://prod-host:5432/myapp?sslmode=disable"
fitz build src/main.fitz
./main
Comportamiento:
- El codegen detecta el uso de
db.connect/db.query/db.exec - cualquier llamada a métodos del ORM (
Type.all/.where/ etc.) y enciende los flagsuses_db = true. - El
Cargo.tomlemitido NO suma deps externas — el driver Postgres está embebido ensrc/db.rsque se copia al preludio del crate generado. Cerotokio-postgres/sqlx/dieselen el binario. - El binario nativo (
~5-10 MBstandalone) habla wire protocol v3.0 directamente — corre en cualquier host que tenga Postgres accesible por TCP, sin requerir libpq instalado. - Paridad bit-a-bit con
fitz run: los outputs de cada query son idénticos (validados en CI con jobdb-postgres+ service containerpostgres:16).
fitz check [archivo] — con DB¶
Comportamiento:
- NO se conecta a Postgres. El checker valida estáticamente sin tocar la DB.
- Valida los decoradores ORM (
@tablecon string literal,@primaryúnico,@belongs_to("X")con X siendo un type declarado, kwargs de relations con valores válidos). - Valida el shape de los closures de
.where(...)/.order_by(...)/.preload(...)contra los fields del type. - Refina los tipos de las chain methods:
User.where(...)→QueryBuilder<User>,.first(db).await?→User,.group_by(...)→Aggregated<User>, etc. - Detecta typos en
.preload("post")vs"posts"cuando el type tiene@has_manydeclarado. - Detecta missing
.where(...)antes de.update(...)/.delete(...)(compile-time error). - Sin conexión real → no detecta drift entre el shape declarado
en
typey el shape real en Postgres (eso requierefitz db inspectfuturo).
fitz openapi <archivo> — con DB¶
Comportamiento:
- NO se conecta a Postgres. Como
fitz check, es read-only sobre el AST. - Los handlers HTTP que llaman al ORM aparecen en el schema con
los tipos refinados (e.g. response
200typed comoList<User>si el handler retornaResult<List<User>>desdeUser.all(db).await). - Operaciones que usan
.group_by(...)retornandoList<Map<Str, Any>>aparecen con schemaadditionalProperties: true(Map free-form).
fitz test [filter] — con DB¶
export FITZ_TEST_PG_URL="postgres://postgres:postgres@localhost/fitz_test?sslmode=disable"
fitz test # corre todos los @test del proyecto
fitz test integration # filter por substring del nombre
Comportamiento:
- Los
@test fnque usan DB siguen las mismas reglas que cualquier test: ejecutados serializados (per default), output cargo-style. - Tu test escribe sus fixtures explícitas — el ORM no tiene
"test factories" built-in. Pattern típico:
@test async fn test_user_creation() { let db = db.connect(env_or("FITZ_TEST_PG_URL", "...")).await? db.exec("DROP TABLE IF EXISTS users", []).await? db.exec("CREATE TABLE users (id bigserial PRIMARY KEY, email text)", []).await? let u = User.insert(db, User { id: 0, email: "ada@x.com" }).await? let count = User.count(db).await? assert_eq(count, 1) } - ⚠️ No hay transaction rollback automático entre tests
(deuda residual de transactions). Para isolation real entre
tests del mismo módulo, drop+recreate la tabla al inicio de
cada test, o usar prefijos únicos por test (e.g.
users_test_xxx).
fitz dev [--file] — con DB¶
export DATABASE_URL="postgres://postgres:postgres@localhost/demo?sslmode=disable"
fitz dev --file src/main.fitz
Comportamiento:
- Watcher de archivos: cuando un
.fitzcambia, el child process (tu programa) se mata y se respawnea. - El pool de conexiones se cierra y se re-abre en cada reload (no hay continuidad de conexión a través de respawns).
- Postgres mantiene los datos entre reloads (es estado en disk, no en memoria del binario).
- Útil para iterar handlers HTTP que tocan DB: editás el handler, Ctrl+S, y el server se levanta de nuevo en ~1-2s con los datos intactos.
fitz repl — con DB¶
Multi-line input + env persistente entre líneas:
fitz> let db = db.connect("postgres://postgres:postgres@localhost/demo?sslmode=disable").await?
fitz> @table("users") type User { @primary id: Int = 0, email: Str }
fitz> let users = User.all(db).await?
fitz> for u in users { print(u.email) }
fitz> :type users
users : List<User>
Comportamiento:
- El
dbdefinido persiste entre líneas — no hay que reconectar cada query. :type <expr>muestra el tipo refinado del ORM (e.g.User.where(...)→QueryBuilder<User>).:envlista las vars definidas, incluyendodb: DbConnsi se conectó.- Útil para explorar una DB existente: definir el
typecon@table, hacer queries ad-hoc, validar shapes antes de meter el código alsrc/main.fitz.
fitz fmt [archivos] — sin interacción con DB¶
El formatter es puramente sintáctico — no toca el módulo db
de manera especial. Los closures de .where(...) se formatean
como cualquier otra expresión.
fitz lint [archivos] — sin interacción con DB¶
Mismo caso: el linter detecta unused_variable/unused_import/
etc. independiente de si el programa usa DB.
Subcomandos planeados (NO implementados todavía)¶
| Subcomando | Función | Status |
|---|---|---|
fitz db diff [--from-snapshot] |
Compara el shape de los type Fitz con @table contra el schema real Postgres + emite el diff DDL. |
Roadmap Fase 10.6+ |
fitz db migrate [--up/--down] |
Aplica/revierte migrations versionadas. Lee migrations/NNNN_*.sql o auto-generadas del diff. |
Roadmap Fase 10.6+ |
fitz db inspect [--table=X] |
Imprime el schema real de Postgres en formato type Fitz, útil para introspeccionar DBs existentes. |
Roadmap Fase 10.6+ |
fitz db seed [<file>] |
Carga fixtures desde archivos JSON/SQL. | Refinamiento futuro |
fitz db console |
Wrapper de psql con el DATABASE_URL del manifest. |
Refinamiento menor |
Detalle planeado en docs/roadmap.md → "Fase 10.6+: workflows
DB".
30. Ejemplos runnable y boilerplates¶
Dos ejemplos en examples/guide/ cubren los casos canónicos:
examples/guide/31-orm.fitz (pedagógico, ~100 LoC)¶
Muestra el shape canónico del ORM end-to-end: @table con
todos los decoradores, insert, where + first, chain
order_by/limit/offset, operadores extendidos
(starts_with/is_in/between), aggregates scalar
(count/avg), GROUP BY con Aggregated<Row>, navigation
belongs_to/has_many, eager loading con .preload, y update/delete
con guard. fitz build produce binario que NO requiere Postgres
real al compilar; el connect runtime falla con Err clara si
la URL es inválida.
examples/guide/31b-orm-crud-http.fitz (CRUD HTTP end-to-end, ~135 LoC)¶
Combina todo el stack Fitz: types User/Post con
decoradores ORM completos, HTTP nativo (@get/@post/@put/
@delete + path params), body deserialization con types custom
dedicados (UserInput/PostInput), Result<T> con ?,
env_or(...) para leer DATABASE_URL, y @server(port).
Endpoints: list/get/create/update/delete sobre users, relation
queries, eager loading con .preload(...), aggregate scalar.
Requiere Postgres real para correr; compila con fitz build
aunque no haya DB local.
Boilerplates Dockerizados (post-v0.10.2, planeados)¶
Dos boilerplates Dockerizados cerrarán el ciclo demostrando el ORM en proyectos productivos:
- Boilerplate 6 convertido: el actual
api-postgres-python(SQLAlchemy + Postgres) se reescribe a Fitz ORM puro sobre el mismo dominiotasks. LoC counts antes/después + benchmarks side-by-side. - Boilerplate 7 nuevo dedicado al ORM full: dominio rico
(blog/CMS o e-commerce básico) con relations cross-table,
JSONB, arrays, aggregates + GROUP BY, operadores extendidos,
auth nativa + ORM, WebSockets + ORM (notificaciones realtime),
cron jobs (limpieza de drafts). Benchmarks
wrk/ohacomprometidos vs SQLAlchemy.
Plan detallado en la memoria de proyecto + docs/roadmap.md →
"Plan boilerplates ORM/DB post-Fase 10".
Cierre¶
Este documento es referencia viva. Cada release del proyecto que toque el ORM (refinamiento del translator, nuevos operadores, codegen de algún caso que faltaba) actualiza la sección correspondiente con el patrón canonical + cualquier deuda residual derivada.
Roadmap del documento:
- v0.10.2 — este doc creado (todas las secciones cubren MVP de Fase 10/10.b).
- Próximo refresh — cuando llegue Fase 10.6 (migraciones automáticas) o 10.7 (transactions), las secciones 28 (limitaciones) marcan deuda como CERRADA y suman las nuevas recetas / API.
- Cuando aparezcan benchmarks reales del boilerplate 7, la sección 27 (Performance) deja el placeholder y suma números concretos vs SQLAlchemy.
- 2026-05-29 (v0.10.13) — primera corrida publicable del
bench MVP
api-postgres-fitzvsapi-postgres-python. Headline: Fitz ORM es 5-10x más rápido y 5.5x más eficiente en memoria que SQLAlchemy en read workloads sustained. Detalle completo en Benchmarks.
Para tirar dudas / proponer recetas nuevas / reportar gaps del ORM, abrir un issue en GitHub.