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 + naively pluralized (active_customers). The naive pluralizer appendss(oreswhen the name already ends ins), so non-English-plural names likeSummaryproducesummarys— 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)
Binds a view column to a typed source field on one of the from models. Lets the parser:
- validate that
Model.fieldexists, - check the Rust type of the view column matches the source field,
- propagate column-level policies if the source field has any.
@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:
- The generated delegate gains a
pub async fn refresh(&self) -> Result<()>method that emitsREFRESH MATERIALIZED VIEW CONCURRENTLY <name>. - 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
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