Composite Keys
Two model-level attributes take a bracketed, ordered list of local field names instead of applying to a single field:@@id([...])— the model’s primary key spans every listed column.@@unique([...])— a unique constraint spans every listed column. A model may declare several.
@readonly / @server_only
on @@id’s case) — but they differ in what they unlock today.
@@unique it’s also part of the
generated index name (applications_tenant_id_name_environment_key,
following the same <table>_<column>_key convention as field-level
@unique). Reordering the list is a real schema change — cratestack migrate diff emits a drop-and-recreate, not a no-op.
@@id([...]) — composite primary key
cratestack-migrate emits a real multi-column PRIMARY KEY constraint
for it, and cratestack check validates the field list at authoring
time. Both backends are covered.
Not usable in a running app yet
include_server_schema! and include_embedded_schema! reject any
model declaring @@id([...]) with a compile error — query builders,
axum/RPC routing, and all three client generators (cratestack-client-rust
/ -dart / -typescript) still assume exactly one scalar @id column
throughout. A schema using @@id([...]) today is authorable and
migratable, but not yet loadable by a server or embedded app. Track
issue #136 for
status.
@@unique([...]) — composite unique constraint
cratestack-migrate emits CREATE UNIQUE INDEX <table>_<col1>_<col2>_key ON <table> (<col1>, <col2>) for each declared @@unique([...]), on both
Postgres and SQLite — the same DDL a hand-written unique index would
produce. cratestack check validates it the same way as @@id([...]).
Unlike @@id, this one compiles through codegen without complaint —
there’s no ORM-level feature depending on it yet, so there’s nothing to
reject.
What it enables today
A real, enforced database constraint. This matters beyond integrity: Postgres will only acceptINSERT ... ON CONFLICT (a, b, c) DO UPDATE
when a unique index over exactly that tuple exists, so a hand-written
idempotent upsert targeting a composite key now has something to
conflict on.
What it doesn’t do yet
- No query-builder helper for “look up by this tuple” — that’s still a
hand-written
WHEREclause. .upsert(...)only targets the primary key. Widening it to accept a@@unique([...])conflict target is a separate, future addition; this attribute only gets the constraint into the database, not into the generated builder.
Read Next
- Migrations — how
cratestack migrate diffturns schema changes into the SQL these constraints compile to - Field Attributes — the single-field
@id/@uniquethese compose with - Upsert — today’s primary-key-only conflict target