Views

A view 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 appends s (or es when the name already ends in s), so non-English-plural names like Summary produce summarys — 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.
The parser does not inspect the SQL body — it cannot detect a body that references a model missing from 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_manyfind_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.field exists,
  • 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.
Multiple @@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.
When @@materialized is set:
  • The generated delegate gains a pub async fn refresh(&self) -> Result<()> method that emits REFRESH MATERIALIZED VIEW CONCURRENTLY <name>.
  • The migration emits CREATE MATERIALIZED VIEW <name> … plus CREATE UNIQUE INDEX <name>_pkey ON <name> (<id_column>) to back the concurrent refresh.
  • @@no_unique is rejected: concurrent refresh requires a unique index, and CrateStack will not silently downgrade to a non-concurrent refresh that takes ACCESS EXCLUSIVE.
Refresh is never automatic. See the Materialized views guide for refresh trigger patterns.

Generated surface

For a view named ActiveCustomer declared with the schema above, the macro emits (in cratestack_schema::models):
Rust field names are kept verbatim from the schema (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:
Views never expose insert, update, or delete. This is enforced at the type levelViewDescriptor does not implement the WriteSource trait that powers write builders, so the bound on those builders simply fails to hold.

Parse-time validation summary

  1. Materialized views guide — when and how to call refresh()
  2. ADR 0003: SQL views as projections of models — design rationale
  3. Migrations — how view DDL flows through the migration runner