Chapter 2 — Models & schema
Models live in src/apps/blog/models.rs.
Live tests against docker PG in
tests/cookbook_chapter02_models.rs.
Run with DATABASE_URL=... cargo test --test cookbook_chapter02_models -- --test-threads=1.
2.11 / 2.12 #[derive(Model)] + Auto<i64> / Auto<i32>
What: Derive macro registers the struct with the global inventory and emits objects() / typed save / FromRow impls. Auto<T> PKs translate to BIGSERIAL (i64) / SERIAL (i32); the macro skips them on INSERT and assigns the returning value.
API: rustango::Model (re-exported from rustango_macros); rustango::sql::Auto.
Recipe (models.rs):
#[derive(Model, Debug, Clone)]
#[rustango(table = "cookbook_author", display = "name")]
pub struct Author {
#[rustango(primary_key)]
pub id: Auto<i64>,
#[rustango(max_length = 80)]
pub name: String,
// ...
}
Verified by: tests/cookbook_chapter02_models.rs::save_assigns_auto_pk
2.13 Option<T> → nullable column
What: Wrap any field type in Option<T> and the column becomes NULL-able; None round-trips as SQL NULL.
Recipe: pub bio: Option<String> on Author.
Verified by: option_field_round_trips_null
2.14 #[rustango(default = "...")] + 2.29 auto_now_add
What: default = "expr" emits DEFAULT <expr> in DDL. The mixin auto_now_add is sugar for "wrap in Auto<T> + DB DEFAULT NOW()" so the column auto-fills on INSERT and the macro skips it.
Recipe: #[rustango(auto_now_add)] pub joined_at: Auto<chrono::DateTime<chrono::Utc>>.
Verified by: auto_now_add_assigns_at_insert
2.15 #[rustango(unique)]
What: Per-column UNIQUE constraint. Duplicate inserts fail with a SQL unique-violation error.
Recipe: #[rustango(unique, max_length = 200)] pub email: String.
Verified by: unique_constraint_rejects_duplicates
2.16 #[rustango(min = N, max = M)] → CHECK + client validation
What: Defense in depth — the macro adds a CHECK constraint to DDL and a client-side range validator. save() rejects out-of-range values before the round-trip with ExecError::OutOfRange.
Recipe: #[rustango(min = 1, max = 5)] pub score: i64.
Verified by: min_max_check_rejects_out_of_range
2.17 #[rustango(max_length = N)]
What: String columns become VARCHAR(N) instead of TEXT. Without it, plain String is TEXT.
Recipe: #[rustango(max_length = 80)] pub name: String.
Verified by: implicit (every cookbook_* table uses VARCHAR for max_length fields).
2.18 #[rustango(index)] (field-level)
What: Single-column index on the field. index(unique) for unique-indexes, index(name = "...") to override the auto name.
Recipe: #[rustango(fk = "cookbook_author", index)] pub author_id: i64.
Verified by: fk_column_round_trips
2.18b #[rustango(unique_together = "col1, col2")] — composite UNIQUE
What: Container-level Django-shape unique_together. Emits CREATE UNIQUE INDEX <table>_<col1>_<col2>_uq ON <table> (col1, col2) so the DB rejects duplicate pairs even though neither column on its own is unique. Sister attr index_together = "..." for non-unique composite indexes. Both auto-derive the index name from the column list (override pending — see v0.19 roadmap).
Recipe (models.rs):
#[derive(Model)]
#[rustango(
table = "cookbook_membership",
unique_together = "org_id, user_id",
)]
pub struct Membership {
#[rustango(primary_key)]
pub id: Auto<i64>,
pub org_id: i64,
pub user_id: i64,
pub role: String,
}
Verified by: unique_together_emits_composite_unique_index_in_schema,
unique_together_rejects_duplicate_pair
Caveat: today the duplicate surfaces as the raw Postgres
duplicate key value violates unique constraint "..." message. A
DRF-style UniqueTogetherValidator that pre-checks at form
validation time and emits friendly per-field errors is tracked as
v0.19.1.
Also during this slice — the legacy container-level
#[rustango(index = "col1, col2", unique, name = "...")] syntax was
found unparseable (the trailing-flag block didn't compose under the
syn parse_nested_meta API). Removed the broken trailing-flag block;
index = "..." is now bare-only (composite, non-unique).
2.20 #[rustango(fk = "table")] — basic foreign key
What: Adds a BIGINT FK column and a REFERENCES <table>(id) constraint. The on = "..." sub-attr overrides the target column name. See also Chapter 17 fk = "self" for tree shapes.
Recipe: #[rustango(fk = "cookbook_author", index)] pub author_id: i64.
Verified by: fk_column_round_trips
2.26 serde_json::Value → JSONB
What: Field of type serde_json::Value becomes a JSONB column. Nested structures round-trip without manual encoding.
Recipe: pub metadata: serde_json::Value.
Verified by: jsonb_field_round_trips_structured_data
2.28 chrono::DateTime<Utc> / Option<DateTime> → TIMESTAMPTZ
What: Maps to TIMESTAMPTZ. Option<DateTime> is nullable.
Recipe: pub published_at: Option<chrono::DateTime<chrono::Utc>>.
Verified by: datetime_option_round_trips
2.21 #[rustango(o2o = "table")] — one-to-one (UNIQUE FK)
What: Same shape as fk but enforces a UNIQUE constraint so the relation is 1:1. Duplicate inserts into the FK column fail.
Recipe: #[rustango(o2o = "cookbook_author")] pub author_id: i64 on AuthorProfile.
Verified by: o2o_unique_fk_rejects_duplicate
2.22 #[rustango(m2m(name, to, through, src, dst))] — M2M through
What: Container-level attribute that emits a junction-table accessor <name>_m2m() returning an M2MManager. The macro doesn't auto-create the through table; you create it by adding a regular junction model + migration. Reads/writes go through the junction table directly.
Recipe (models.rs):
#[rustango(
table = "cookbook_post",
m2m(name = "tags", to = "cookbook_tag",
through = "cookbook_post_tag",
src = "post_id", dst = "tag_id"),
)]
pub struct Post { ... }
// CRUD on the junction — bare-name methods (v0.43+):
post.tags_m2m().all(&pool).await?; // -> Vec<i64>
post.tags_m2m().add(42, &pool).await?;
post.tags_m2m().remove(42, &pool).await?;
post.tags_m2m().set(&[1, 2, 3], &pool).await?;
post.tags_m2m().clear(&pool).await?;
let has = post.tags_m2m().contains(42, &pool).await?;
The _pool aliases (all_pool / add_pool / etc.) stay as #[deprecated] forwarders for source-compat with pre-#941 code — they emit one warning each.
Verified by: m2m_through_junction_table_round_trips
2.22b #[rustango(through(name, far, far_fk_column, intermediate, intermediate_fk_column))] — Eloquent hasManyThrough
What: Container-level attribute that emits three items per relation traversing the source → intermediate → far chain:
| Method | Type | Eloquent analog |
|---|---|---|
<name>_through(&self) -> QuerySet<Far> | chainable accessor | $model->relation |
<name>_through_fetch(&self, &pool) -> Vec<Far> | bare-name hot path | $model->relation->get() |
<name>_through_count(&self, &pool) -> i64 | scalar | $model->relation->count() |
Generated SQL shape:
SELECT <far>.* FROM <far>
WHERE <far_fk_column> IN (
SELECT id FROM <intermediate> WHERE <intermediate_fk_column> = <my_pk>
)
Built via WhereExpr::InSubquery — portable across PG / MySQL / SQLite, no LATERAL or backend-specific syntax. Issue #817.
Recipe (Country hasManyThrough Post via User):
#[derive(Model)]
#[rustango(
table = "country",
through(
name = "posts",
far = "Post",
far_fk_column = "author_id",
intermediate = "User",
intermediate_fk_column = "country_id",
),
)]
pub struct Country { ... }
// Bare-name hot path — no _pool in user-visible code:
let posts: Vec<Post> = country.posts_through_fetch(&pool).await?;
let n: i64 = country.posts_through_count(&pool).await?;
// Chainable when composition is needed:
country.posts_through()
.filter("title__startswith", "Hello ")
.order_by(&[("id", true)])
.limit(10)
.fetch_pool(&pool).await?;
Identifiers are SQL column / table names (not Rust field names) — sidesteps the multi-hop filter substrate gap. A Rust-field-name shorthand can sit on top once that substrate lands without breaking this surface. Optional intermediate_pk_column = "..." defaults to "id".
Verified by: tests/model_through_relation_sqlite_live.rs
2.22c #[rustango(reverse_has(name, child, child_fk_column))] — Eloquent whereHas / whereDoesntHave + relation accessor
What: Container-level attribute that emits five items per relation — the full Eloquent $model->relation family for FK-reverse:
| Method | Type | Eloquent analog |
|---|---|---|
<name>(&self) -> QuerySet<Child> | bare chainable accessor | $model->relation |
<name>_fetch(&self, &pool) -> Vec<Child> | bare hot path | $model->relation->get() |
<name>_count(&self, &pool) -> i64 | scalar | $model->relation->count() |
<name>_exists_expr() | WhereExpr | whereHas |
<name>_not_exists_expr() | WhereExpr | whereDoesntHave |
Built via WhereExpr::Exists + Expr::OuterRef (whereHas branch) and QuerySet::filter (accessor / count / fetch branch) — both portable across PG / MySQL / SQLite. The writer's scope-stack resolves OuterRef(col) to the outer queryset's table at SQL-emit time. Issue #830.
Recipe (Post hasMany Comment):
#[derive(Model)]
#[rustango(
table = "post",
reverse_has(name = "comments", child = "Comment",
child_fk_column = "post_id"),
)]
pub struct Post { ... }
// Bare-name hot paths — no _pool in user-visible code:
let all: Vec<Comment> = post.comments_fetch(&pool).await?;
let n: i64 = post.comments_count(&pool).await?;
// Chainable when composition is needed:
post.comments()
.filter("body__startswith", "hello")
.order_by(&[("id", true)])
.limit(10)
.fetch_pool(&pool).await?;
// whereHas — posts with at least one comment:
Post::objects()
.where_raw(Post::comments_exists_expr())
.fetch_pool(&pool).await?;
// whereDoesntHave — posts with no comments:
Post::objects()
.where_raw(Post::comments_not_exists_expr())
.fetch_pool(&pool).await?;
Same SQL-column-name convention as through(...) — sidesteps the multi-hop filter gap. Optional self_pk_column = "..." defaults to "id".
Status: FK-reverse subset only — M2M / GFK whereHas, sub-predicate closures, has(rel, '>', N) count comparisons, and withCount-style annotate-by-relation remain follow-up slices.
Verified by: tests/model_reverse_has_sqlite_live.rs
2.22d #[rustango(global_scope(name, apply))] — Eloquent global scopes
What: Container-level attribute that declares an auto-applied filter — every QuerySet built for that model implicitly carries the scope's WHERE without the caller chaining .filter(...). The substrate Eloquent uses for soft-delete hiding, tenant isolation, "published only" lenses, etc. Issue #820.
Scopes fold in at every compile entry — SELECT (fetch_pool / Model::all), DELETE (compile_delete), aggregate (count_pool / Model::count), UPDATE. ValuesQuerySet / dates / etc. inherit via their delegating compile().
Recipe:
use rustango::core::{Filter, Op, SqlValue, WhereExpr};
fn active_only() -> WhereExpr {
WhereExpr::Predicate(Filter {
column: "is_active",
op: Op::Eq,
value: SqlValue::Bool(true),
})
}
#[derive(Model)]
#[rustango(
table = "post",
global_scope(name = "active", apply = active_only),
)]
pub struct Post { … }
// Auto-applied — emits `WHERE is_active = true`:
Post::objects().fetch_pool(&pool).await?;
Post::all(&pool).await?; // bare-name shortcut, also scoped
Post::count(&pool).await?; // aggregate also scoped
// Per-name opt-out (Eloquent withoutGlobalScope):
Post::objects().without_global_scope("active").fetch_pool(&pool).await?;
// Wholesale opt-out (Eloquent withoutGlobalScopes):
Post::objects().without_global_scopes().fetch_pool(&pool).await?;
Repeated #[rustango(global_scope(...))] attributes accumulate; duplicate names are rejected at macro-parse time. The apply value is a function path (resolves in the consumer's scope at macro expansion).
Verified by: tests/model_global_scope_sqlite_live.rs
2.27 Auto<uuid::Uuid> + auto_uuid — UUID PKs
What: #[rustango(auto_uuid)] is sugar for primary_key + auto + DEFAULT gen_random_uuid(). Postgres' pgcrypto extension supplies the v4. Macro skips the column on INSERT; the returning value lands in Auto<Uuid>.
Recipe:
#[derive(Model)]
#[rustango(table = "cookbook_session")]
pub struct Session {
#[rustango(auto_uuid)]
pub id: Auto<Uuid>,
pub user_token: String,
}
Verified by: auto_uuid_assigns_server_side_uuid
2.30 #[rustango(soft_delete)] — tombstone deletes
What: Mark an Option<DateTime<Utc>> field as the soft-delete tombstone. Currently captured in the model's SCHEMA.soft_delete_column so the ORM can layer the alive-when-NULL filter and override delete() to UPDATE the tombstone instead of DELETE FROM.
Recipe:
#[derive(Model)]
pub struct ArchiveNote {
#[rustango(primary_key)]
pub id: Auto<i64>,
pub note: String,
#[rustango(soft_delete)]
pub deleted_at: Option<chrono::DateTime<chrono::Utc>>,
}
Verified by: soft_delete_column_round_trips_and_deleted_at_defaults_null
2.19 #[rustango(check(name, expr))] — table-level CHECK
What: Container-level CHECK constraint with a chosen name and a raw SQL boolean expression. Rejects inserts that violate the predicate at the DB.
Recipe (models.rs):
#[derive(Model)]
#[rustango(
table = "cookbook_inventory_item",
check(name = "cookbook_inventory_item_qty_chk",
expr = "qty >= 0 AND price_cents > 0"),
)]
pub struct InventoryItem { ... }
Verified by: table_level_check_rejects_invalid_row
2.23 #[rustango(fk_composite(name, to, from, on))] — composite FK
What: Multi-column foreign key. The from = (...) columns on this model reference the on = (...) columns on the target table. The DDL emits a single CONSTRAINT … FOREIGN KEY (a, b) REFERENCES tgt (x, y) so the DB rejects unmatched pairs.
Recipe:
#[derive(Model)]
#[rustango(
table = "cookbook_pair_link",
fk_composite(
name = "pair_target_fk",
to = "cookbook_pair_target",
from = ("left_ref", "right_ref"),
on = ("a_id", "b_id"),
),
)]
pub struct PairLink { ... }
Verified by: fk_composite_rejects_unmatched_pair
2.24 / 2.25 #[rustango(generic_fk(name, ct_column, pk_column))] + ContentType lookup
What: Generic foreign key — pairs a target_content_type_id BIGINT column with a target_object_pk BIGINT column. The framework knows the pair logically points at "any registered model's row." Model::SCHEMA.generic_relations exposes the metadata; admin uses it to render clickable links via render_generic_fk_link.
ContentType::for_model::<T>() looks up the ContentType row for any registered model after ensure_seeded(&pool) populates the registry.
Recipe:
#[derive(Model)]
#[rustango(
table = "cookbook_activity",
generic_fk(
name = "target",
ct_column = "target_content_type_id",
pk_column = "target_object_pk",
),
)]
pub struct Activity { ... }
ensure_seeded(&pool).await?;
let ct = ContentType::for_model::<Author>(&pool).await?.unwrap();
let mut act = Activity {
target_content_type_id: ct.id_value()?,
target_object_pk: author_id,
action: "viewed".into(), ..
};
act.save(&pool).await?;
Verified by: generic_fk_schema_and_content_type_lookup
2.24b Typed <name>_pool accessor on the GFK target (#239)
What: The Model derive emits one <name>_pool(&pool) async method per #[rustango(generic_fk(name = "..."))] declaration. Reads self.<ct_column> + self.<pk_column>, calls ContentType::by_id, and fetches the target row as a serde_json::Value. Stand-in for Django's activity.target lazy accessor.
Recipe:
#[derive(Model)]
#[rustango(generic_fk(name = "target", ct_column = "...", pk_column = "..."))]
pub struct Activity { /* ... */ }
if let Some(target_json) = activity.target_pool(&pool).await? {
println!("{}", target_json["title"]);
}
Returns Ok(None) gracefully when the ContentType is stale or the target row was deleted — never panics on a dangling polymorphic pointer.
Verified by: tests/gfk_typed_accessors.rs::typed_accessor_resolves_to_target_row_as_json. Live in examples/gfk_demo.
2.24c Typed set_<name>_for::<T> setter (#240)
What: Companion to 2.24b — Model derive emits set_<name>_for::<T: Model>(&pool, target_pk) per declaration. Resolves the ContentType for T via the cached registry and assigns both columns on self. Stand-in for Django's activity.target = post one-liner.
Recipe:
let mut act = Activity {
id: Auto::Unset,
target_content_type_id: 0,
target_object_pk: 0,
action: "tagged".into(),
..
};
act.set_target_for::<Post>(&pool, post_pk).await?;
act.insert(&pool).await?;
Two columns assigned in one call — caller never deals with the integer CT id by hand.
Verified by: tests/gfk_typed_accessors.rs::typed_setter_assigns_ct_and_pk_for_target_model. Live in examples/gfk_demo.
2.24d Admin list view collapses GFK pair into one link (#241)
What: When list_display names a generic_fk relation by its name, the admin renders a single column whose cells are <a href="/{target_table}/{pk}">{app_label}.{model_name} #{pk}</a> — same shape contenttypes::render_generic_fk_link emits on the detail page.
Recipe:
#[derive(Model)]
#[rustango(
generic_fk(name = "target", ct_column = "...", pk_column = "..."),
admin(list_display = "action, target, created_at"),
)]
pub struct Activity { /* ... */ }
The admin list view at /__admin/cookbook_activity shows action | target | created_at, where target is one clickable link per row. Raw ct_column / pk_column integers stay hidden.
Implementation prefetches the page's distinct CT ids once before the row loop (usually 1 round-trip per distinct target type), so the cell render is hot-path.
Verified by: tests/admin_gfk_list_render_live.rs.
2.25 Django Meta parity (v0.42 batch)
One recipe per attr — eleven container-level Meta-shape attrs landed in the v0.42 series. Every one is parsed by #[derive(Model)], validated at compile time, and exposed on ModelSchema::<field> so future codegen / admin / DRF surfaces can read the metadata without re-parsing.
2.25.1 #[rustango(managed = false)] (PR #558)
What: Django Meta.managed = False — makemigrations skips the model entirely (the operator owns the table's DDL). Useful for views, partitioned tables, foreign tables, or any schema the framework shouldn't touch.
Recipe: #[rustango(table = "external_view", managed = false)]. The model still gets ORM read access; nothing emits CREATE / ALTER / DROP.
2.25.2 #[rustango(db_table_comment = "...")] (PR #589)
What: Django 4.2+ Meta.db_table_comment — attached to the DB catalog so ops tooling (data-lineage docs, schema explorers) sees it.
Render shape:
- Postgres: post-table
COMMENT ON TABLE "<t>" IS '...' - MySQL: inline
) COMMENT='...'trailer - SQLite: no-op (no native table comments)
#[rustango(table = "orders", db_table_comment = "Customer purchase records — see /docs/orders.md")]
2.25.3 #[rustango(get_latest_by = "col" | "-col")] (PR #590)
What: Django Meta.get_latest_by — default sort column for QuerySet::latest_default(&pool) / earliest_default(&pool) when the caller doesn't pass a field name explicitly. -col reverses (descending).
Recipe:
#[rustango(table = "post", get_latest_by = "-created_at")]
pub struct Post { /* ... */ }
// Now:
let newest = Post::objects().latest_default(&pool).await?;
let oldest = Post::objects().earliest_default(&pool).await?;
2.25.4 #[rustango(citext)] (PR #566 / #344)
What: Django postgres-contrib CITextField — case-insensitive comparisons without query-side LOWER(...) wrapping. Field-level (lives on a String column).
Render shape:
- Postgres: column type becomes
CITEXT(the dialect auto-emitsCREATE EXTENSION IF NOT EXISTS citext;prelude) - SQLite:
TEXT COLLATE NOCASE - MySQL:
VARCHAR(N)/TEXT COLLATE utf8mb4_general_ci
#[rustango(max_length = 200, citext)] pub email: String,
2.25.5 #[rustango(fk = "...", on_delete = "...")] (PR #592)
What: Django ForeignKey(on_delete=...) — referential-integrity action when the parent row is deleted.
Accepted values (case-insensitive): cascade / restrict / set_null / set_default / no_action. Omitting falls back to the dialect default (NO ACTION everywhere). Macro errors at compile time if on_delete is set without fk / o2o, or if the action name is unknown.
#[rustango(fk = "post", on = "id", on_delete = "cascade")]
pub post_id: i64, // delete the parent post → comment goes too
2.25.6 #[rustango(extra_permissions = "code:Label, ...")] (PR #591)
What: Django Meta.permissions = [(codename, name), ...] — extra permission codenames seeded alongside the auto-generated add / change / delete / view. auto_create_permissions_pool writes one row per pair under <table>.<codename>.
#[rustango(table = "post", permissions, extra_permissions = "approve:Can approve posts, archive:Can archive posts")]
Granted via the usual set_user_perm_pool / role machinery.
2.25.7 #[rustango(default_permissions = "view,change")] (PR #594)
What: Django Meta.default_permissions — opt out of the full CRUD set. Empty (the default) seeds all four; "view,change" seeds only view + change. Useful for read-mostly reference tables where add / delete are operator-only.
#[rustango(table = "country", permissions, default_permissions = "view")]
2.25.8 #[rustango(exclude(...))] (PR #593)
What: Django postgres-contrib ExclusionConstraint — "no two rows of group X may overlap in column Y" via PG EXCLUDE USING gist (...). Container-level, multi-instance.
#[rustango(
table = "booking",
exclude(
name = "no_overlap",
using = "gist",
elements = "room_id WITH =, during WITH &&",
),
exclude(
name = "active_only",
elements = "room_id WITH =",
where = "cancelled_at IS NULL",
),
)]
PG-only: MySQL/SQLite have no equivalent; the migration writer skips emission with a tracing::warn! so the rest of the migration applies cleanly.
2.25.9 #[rustango(index_when(...))] (PR #599)
What: Django Index(fields=[...], condition=Q(...)) — non-unique partial index. Sibling of unique_when (UNIQUE variant). Container-level.
#[rustango(
table = "post",
index_when(
columns = "status, created_at",
condition = "deleted_at IS NULL",
name = "active_recent_posts_idx",
),
)]
Render shape:
- PG + SQLite:
CREATE INDEX ... WHERE <expr>(native partial-index support) - MySQL: plain
CREATE INDEXwith the condition dropped + a tracing warning
2.25.10 #[rustango(default_related_name = "...")] (PR #600)
What: Django Meta.default_related_name — the accessor name reverse-relation managers use when an FK / M2M field doesn't override it. Validated at compile time as snake_case ASCII.
Recipe: #[rustango(table = "post", default_related_name = "posts")]. Stored on ModelSchema::default_related_name. Declarative-only today (rustango doesn't auto-emit reverse managers yet) — the metadata is the foundation for that work.
2.25.11 #[rustango(base_manager_name = "...")] (PR #601)
What: Django Meta.base_manager_name — Manager subclass that <instance>.<relation>_set uses when resolving reverse-relation managers. Distinct from default_manager_name (what Model.objects returns at the class level).
Recipe: #[rustango(base_manager_name = "PostManagerExt")]. Validated as a Rust identifier so it's safe to re-emit as code later. Same declarative-only posture as default_related_name.
2.25.12 #[rustango(required_db_vendor = "...")] (PR #602)
What: Django Meta.required_db_vendor — declares which DB backend the model is intended to run against. manage check --deploy walks every model and warns when the declared vendor doesn't match the active pool.dialect().name() — catches "I forgot to switch DATABASE_URL" at deploy time rather than the first runtime hit on a backend-specific feature.
Accepted values: postgres (aliases: postgresql, pg) / mysql (alias: mariadb) / sqlite (alias: sqlite3). Macro normalizes to the canonical dialect name.
#[rustango(table = "geo_audit", required_db_vendor = "postgres")]
pub struct GeoAudit { /* uses PG-only GiST + array ops */ }
Run manage check --deploy against a SQLite pool:
[warning] model `GeoAudit` declares `required_db_vendor = "postgres"` but the
active database backend is `sqlite` — queries that depend on
backend-specific features may fail
2.25.13 #[rustango(required_db_features = "...")] (PR #604)
What: Django Meta.required_db_features — finer-grained sibling of required_db_vendor. Lists capability tokens the model depends on (e.g. "json_path", "listen_notify", "hstore", "gist_index", "window_functions"). manage check --deploy walks every model and warns when the active Dialect::supports(token) returns false.
Tokens advertised by default impl (portable across all three backends): window_functions, recursive_cte, cte, json_extract, expression_index, plus dialect-conditional partial_index + returning.
PG-only tokens (advertised by Postgres::supports): array_type, range_type, hstore, citext, listen_notify / notify, row_security, gin_index, gist_index, spgist_index, brin_index, unique_constraint_deferred, exclusion_constraint, tablespaces, json_path, json_query.
Unknown tokens → returns false so the deploy check fires (safe default for aspirational declarations).
#[rustango(
table = "event_outbox",
required_db_features = "listen_notify, json_path",
)]
pub struct EventOutbox { /* PG `LISTEN` channel + JSON path queries */ }
Composes with required_db_vendor — set both for fail-fast deploy validation:
#[rustango(
table = "spatial_audit",
required_db_vendor = "postgres",
required_db_features = "gist_index, exclusion_constraint",
)]
manage check --deploy on a SQLite pool produces one warning per unsupported token + one for the vendor mismatch.
2.25.14 include = "..." on index_when / unique_when (PR #605)
What: Django Index(fields=..., include=[...]) covering-index parity. Optional sub-attr on both index_when(...) and unique_when(...). Lists non-key columns that travel along with the index leaf so PG can serve queries entirely from the index without a heap visit (index-only scans).
Render shape:
- PG 11+:
CREATE INDEX <name> ON <table> (key_cols) INCLUDE (non_key_cols)— emitted before the WHERE-suffix. - MySQL / SQLite: clause dropped with a
tracing::warn!. Operators wanting covers on those backends should add a redundant non-key column to the key tuple.
#[rustango(
table = "post",
index_when(
columns = "status",
condition = "deleted_at IS NULL",
name = "active_post_cover_idx",
include = "title, created_at",
),
unique_when(
columns = "tenant_id, slug",
condition = "deleted_at IS NULL",
name = "active_post_slug_unique",
include = "title",
),
)]
Reads SELECT title, created_at FROM post WHERE status = 'published' AND deleted_at IS NULL get index-only scans without touching the heap.
2.25.16 #[rustango(order_with_respect_to = "...")] (PR #610)
What: Django Meta.order_with_respect_to = "parent_fk" — names the FK field this model's instances are ordered relative to. Django auto-generates a _order integer column + admin reordering UI when set.
#[derive(Model)]
#[rustango(table = "section_item", order_with_respect_to = "section_id")]
pub struct SectionItem {
#[rustango(primary_key)]
pub id: i64,
#[rustango(fk = "section", on = "id")]
pub section_id: i64,
pub title: String,
}
Stored on ModelSchema::order_with_respect_to: Option<&'static str>. Macro validates Rust-identifier shape so typos surface at derive time.
Behavior today: declarative-only. The migration writer + admin surfaces still treat every model identically. Future codegen will key off the metadata to auto-emit the _order column and reorder helpers (set_<rel>_order(&[pk1, pk2, ...])).