Rustango docs
← Cookbook

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:

MethodTypeEloquent 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) -> i64scalar$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:

MethodTypeEloquent 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) -> i64scalar$model->relation->count()
<name>_exists_expr()WhereExprwhereHas
<name>_not_exists_expr()WhereExprwhereDoesntHave

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 = Falsemakemigrations 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-emits CREATE 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 INDEX with 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, ...])).