Views
Aview declares a read-only, SQL-defined projection over one or more existing model blocks. Views generate a typed Rust struct, a ViewDelegate with find_many / find_unique, and CREATE VIEW DDL during migration generation.
For the rationale, see ADR 0003.
Minimal example
Block header
<Name>— PascalCase. Becomes the Rust struct name unchanged (ActiveCustomer) and the SQL view identifier as snake-cased + pluralized (active_customers), using the same pluralizer shared with model table names (cratestack_core::route_naming::pluralize): a trailingsgetsesappended (Bus->buses), a consonant +ybecomesies(Summary->summaries,Category->categories), and everything else gets a bares(Day->days) — your hand-written DDL or SQL body needs to use that exact identifier.from <Model>, …— the source-model dependency list. Each name must resolve to an existing model; the parser rejects unknown ones. The list also orders view DDL after source-model DDL during migration generation.
from, nor a from model the body doesn’t use. Both are developer responsibilities. (CI’s verify migration replay catches body errors at the database boundary before they ship.)
Fields
Fields use the same syntax as model fields, plus two view-specific attributes.@id (required)
Exactly one field must carry @id. Required for find_unique and (when @@materialized is set) the unique index that backs concurrent refresh.
Views without a natural unique key opt out:
@@no_unique makes the accessor return a separate ViewDelegateNoUnique<'_, V> instead of the standard ViewDelegate<'_, V, PK>. The no-unique delegate exposes only find_many — find_unique and refresh() are absent at the type level, so a call like runtime.views().revenue_by_day().find_unique(()) is a compile error rather than a runtime “WHERE = $1” footgun.
@@no_unique is incompatible with @@materialized (see below): concurrent refresh requires a unique index.
@from(Model.field)
Documents that a view column is sourced from a typed field on one of the
from models — a hint for readers about provenance, not something the
parser checks today. @from(...) is parsed like any other unrecognized
@... field attribute: an opaque raw string. There is currently no
validation that Model.field exists, that the view column’s Rust type
matches the source field’s, or that column-level policies on the source
field propagate to the view. Treat it as documentation until that
validation lands.
@from is optional. Columns without it are computed — the developer declares the Rust type and the macro trusts the SQL.
SQL body attributes
A view must declare at least one of@@server_sql, @@embedded_sql, or @@sql.
@@server_sql("…") and @@embedded_sql("…")
Per-backend SQL bodies. Required when the dialects diverge — Postgres aggregate casts (COUNT(o.id)::int), DISTINCT ON, JSON functions, window function variants, and many others are not portable to SQLite.
If only one is declared, the view is backend-specific. Building the other target with this view in scope is a clear compile error pointing at the missing attribute.
@@sql("…")
Shorthand that applies to both backends. The macro emits a cargo warning that single-string portability is the developer’s responsibility. Use only when the SQL body is genuinely a portable subset.
@@allow("read", …)
Same authorization machinery as models, but only the "read" action is supported. Any other action ("create", "update", "delete") is a parse-time error — views are not writable.
@@allow("read", …) rules combine with OR, same as on models.
No @@allow means no rows visible. Views inherit the same default-deny posture models have: a view with no @@allow("read", …) rule declared produces an implicit WHERE FALSE in every read query. This is intentional — it forces explicit authorisation rather than allowing accidental data exposure. Use @@allow("read", auth() != null) for a “any authenticated caller can read” stance.
@@materialized (server-only)
Marks the view as a Postgres materialized view. Server-only — building this view with the embedded backend enabled is a hard compile error referencing ADR 0003. There is no silent fallback to a regular view.
@@materialized is set:
- Calling
refresh()on this view’s delegate succeeds, emittingREFRESH MATERIALIZED VIEW CONCURRENTLY <name>.refresh()itself is not conditionally generated — everyViewDelegateexposespub async fn refresh(&self) -> Result<(), CratestackError>unconditionally, and the check is a runtime one: it returnsCratestackError::Forbiddenif the view isn’t@@materialized. (The separateViewDelegateNoUniqueused by@@no_uniqueviews omitsrefresh()at the type level, as noted above — but that’s a distinct, unique-index-driven restriction, not how non-materialized-but-unique views are handled.) - The migration emits
CREATE MATERIALIZED VIEW <name> …plusCREATE UNIQUE INDEX <name>_pkey ON <name> (<id_column>)to back the concurrent refresh. @@no_uniqueis rejected: concurrent refresh requires a unique index, and CrateStack will not silently downgrade to a non-concurrent refresh that takesACCESS EXCLUSIVE.
Generated surface
For a view namedActiveCustomer declared with the schema above, the macro emits (in cratestack_schema::models):
orderCount, not order_count); the row decoder looks columns up by their schema-side name via the macro-emitted <sql_name> AS "<rust_name>" aliases in select_projection. Scalar Int is i64.
On the runtime:
insert, update, or delete. This is enforced at the type level — ViewDescriptor does not implement the WriteSource trait that powers write builders, so the bound on those builders simply fails to hold.
Parse-time validation summary
@from(M.f) is not validated by the parser today — it’s an opaque, unchecked attribute (see above). There is no parse-time check that M.f exists, that the model is in from, or that the field type matches.
Read Next
- Materialized views guide — when and how to call
refresh() - ADR 0003: SQL views as projections of models — design rationale
- Migrations — how view DDL flows through the migration runner