Relations
A relation links two entities by a key pair. You declare relations in a schema-level references:
list; the loader turns each entry into a named relation that drives two things — the foreign-key DDL
the migrator can emit, and the name you pass to include to expand the relation at query time.
Where a relation is declared
Section titled “Where a relation is declared”references: is a top-level list, a sibling of entities:. It is the only form that produces a
relation. There is no relations: key, and there is no per-entity relation list.
entities: - name: customer fields: - name: customer_name type: string
- name: sales_order fields: - name: order_no type: string modifiers: [required, unique] - name: customer_id type: string nullable: true - name: status type: string
- name: sales_order_line fields: - name: order_id type: string modifiers: [required] - name: product_id type: string - name: qty type: int
references: - name: customer_orders parent: customer.id child: sales_order.customer_id type: onetomany
- name: order_lines parent: sales_order.id child: sales_order_line.order_id type: onetomanyreferences[] keys
Section titled “references[] keys”| Key | Type | Meaning |
|---|---|---|
name | string | Relation name. Also the FK constraint name and the include key. |
description | string | Free text. |
parent | entity.field | The referenced (“one”) side. A bare entity defaults the field to id. |
child | entity.field | The FK-holding (“many”) side. Same bare-entity rule. |
from | entity.field | Alias for parent, used only when parent is empty. |
to | entity.field | Alias for child, used only when child is empty. |
fromentity / fromfield / toentity / tofield | string | Explicit quadruple. Setting fromentity or toentity overrides parent/child entirely and is passed through with no direction flip. |
type | string | Cardinality. See below. |
join | string | Junction entity for manytomany. Stored and never read. |
onDelete | string | Referential action, emitted as ON DELETE <value> upper-cased. |
onUpdate | string | Parsed and discarded. No ON UPDATE clause is ever emitted. |
Omitting name synthesises <from-entity>_<to-entity> after the multi-file merge. Unnamed
references are never collapsed onto each other during merge, so two unnamed entries both survive.
Cardinality and direction
Section titled “Cardinality and direction”type: is normalised by trimming, lower-casing and stripping hyphens, so one-to-many, OneToMany
and onetomany are the same token.
type: | Aliases | Declaring side (from) | Target side (to) | Query-time shape |
|---|---|---|---|---|
onetomany | hasmany | parent | child | has-many |
onetoone | — | parent | child | has-one when from field is the declaring entity’s primary key, otherwise belongs-to |
manytoone | belongsto | child (flipped) | parent | belongs-to |
manytomany | — | parent | child | belongs-to key mapping, attached as an array |
| omitted / unrecognised | — | parent | child | has-many |
Modelling patterns
Section titled “Modelling patterns”One-to-one
Section titled “One-to-one”Split a wide entity, or hold sensitive columns in a separate table. Put the key on the dependent side
and point parent at the owner’s primary key.
references: - name: employee_salary parent: employee.id child: salary_breakup.employee_id type: onetooneNothing enforces the “one” part. A one-to-one relation emits the same single-column foreign key as a
one-to-many; add a unique index on the child key to make the cardinality real.
entities: - name: salary_breakup indexes: - name: salary_breakup_employee_unique fields: [employee_id] unique: trueOne-to-many
Section titled “One-to-many”The common case. The child holds the key; the parent side is the primary key.
references: - name: customer_orders parent: customer.id child: sales_order.customer_id type: onetomany onDelete: RESTRICTMany-to-many
Section titled “Many-to-many”Model it explicitly with a bridge entity and two one-to-many references. The bridge is a normal entity, so it can carry its own columns, indexes, access rules and history.
entities: - name: student fields: - name: full_name type: string
- name: course fields: - name: title type: string
- name: enrolment fields: - name: student_id type: string modifiers: [required] - name: course_id type: string modifiers: [required] - name: enrolled_on type: date indexes: - name: enrolment_pair_unique fields: [student_id, course_id] unique: true
references: - name: student_enrolments parent: student.id child: enrolment.student_id type: onetomany - name: course_enrolments parent: course.id child: enrolment.course_id type: onetomanySelf-referencing
Section titled “Self-referencing”An entity may reference itself; the key column is just another field on the same table.
entities: - name: employee fields: - name: manager_id type: string nullable: true
references: - name: employee_manager parent: employee.id child: employee.manager_id type: onetomanyinclude expands one level; it does not recurse down a self-reference. Walking the tree uses the
query layer’s traversal form, which compiles to WITH RECURSIVE and takes the self-referencing
column as via (direction ancestors or descendants, with $depth and $path selectable out of
the CTE). Traversal is a library-level query form — the REST query string has no parameter for it.
Recursive CTEs are available on PostgreSQL, DuckDB and SQLite; ClickHouse does not support them.
The field-level references: shorthand
Section titled “The field-level references: shorthand”A field may carry an inline foreign-key block. This is the loader’s spelling — an object, not a string:
fields: - name: customer_id type: string references: entity: customer field: id onDelete: CASCADE onUpdate: NO ACTION| Key | Meaning |
|---|---|
entity | Target entity name. |
field | Target column. |
onDelete | One of CASCADE, SET NULL (or SETNULL), RESTRICT; anything else parses as no action. |
onUpdate | Same vocabulary. |
Referential integrity per backend
Section titled “Referential integrity per backend”Foreign-key DDL is produced by the backend dialect, and only one backend produces any.
| Backend | ALTER TABLE … ADD CONSTRAINT … FOREIGN KEY | Notes |
|---|---|---|
| PostgreSQL | Yes | Schema-qualified on both sides; ON DELETE appended when set. |
| DuckDB | No | The dialect refuses; adding a constraint to an existing table is not supported. |
| SQLite | No | The engine would need the FK inline in CREATE TABLE; it emits none, and the dialect refuses at migrate time. |
| ClickHouse | No | No foreign-key constraints in the engine. |
data.svc wires PostgreSQL for tenant datastores, so PostgreSQL is the case that matters in a
deployment.
The emitted statement for customer_orders above:
ALTER TABLE "acme"."sales_order" ADD CONSTRAINT "customer_orders" FOREIGN KEY ("customer_id") REFERENCES "acme"."customer" ("id") ON DELETE RESTRICT;The constraint takes the relation’s name. Unnamed relations fall back to
fk_<table>_<column>_<target>.
Ownership and cascade behaviour
Section titled “Ownership and cascade behaviour”| Declaration | Effect |
|---|---|
onDelete: CASCADE / RESTRICT / SET NULL / NO ACTION | Appended verbatim, upper-cased, to the FK DDL — on the path that emits FK DDL. |
onUpdate: … | Discarded. No ON UPDATE clause is generated on any backend. |
retention.onfkconstraint | fail (default), cascade or anonymize_refs — what the retention runner does when a purge would violate a foreign key. Any other value is a load error. |
onDelete is not validated: whatever you write is upper-cased and pasted into the DDL, so
onDelete: casacde produces a statement the database rejects and the FK step fails. Deleting a
parent row through the API does not run a cascade in application code — cascade means the database
clause, on the backend and migration path that emit it.
Soft delete changes what a cascade would even see. Every entity carries deletedon/deletedby by
default, so a delete is an UPDATE, not a DELETE, and no FK action fires at all.
Traversing a relation at query time
Section titled “Traversing a relation at query time”include expands a relation by name. Resolution runs in this order for entity E and name R:
- Entity-level relations named
R. - A schema relation named
Rwhosefromentity isE. - A schema relation keyed
E.R. - Any schema relation with
fromentityEandtoentityR— the target-entity shorthand, soinclude=customerworks without knowing the relation name. - Reverse direction: any relation whose
toentity isEand whose name — or whosefromentity name — isR. A belongs-to is synthesised with the keys swapped, so a child can ask for its parent.
An unresolved name fails the query with unknown relation "<name>" on "<entity>".
Over HTTP
Section titled “Over HTTP”The REST list and get endpoints take ?include= as a comma-separated list of relation names. The
query string carries names only — per-relation projection, filter, ordering and limit are available
to in-process callers, not over HTTP. The full endpoint reference is at /api/data/.
GET /rest/sales_order?status=approved&include=order_lines&orderby=-createdon&limit=25GET /rest/sales_order/id/01J8Z0F5K7QN3W?include=order_linesGET /rest/sales_order_line?include=order_linesThe third request is the reverse direction: sales_order_line resolves order_lines through rule 5
and gets its parent order back.
Result shape
Section titled “Result shape”Included rows attach to each parent row under the relation name. A has-many relation yields an
array — empty, never absent. A belongs-to or has-one relation yields a single object, or null when
the key does not match.
{ "id": "01J8Z0F5K7QN3W", "order_no": "SO-1042", "customer_id": "01J8Z0EW0000AA", "status": "approved", "order_lines": [ { "id": "01J8Z0FA0001", "order_id": "01J8Z0F5K7QN3W", "product_id": "01J8Z0P1", "qty": 3 }, { "id": "01J8Z0FA0002", "order_id": "01J8Z0F5K7QN3W", "product_id": "01J8Z0P7", "qty": 1 } ]}How the expansion executes
Section titled “How the expansion executes”An include is not a join. Each relation at each level compiles to its own statement, run after the
parent query and filtered by child_key IN (<parent key values>), then grouped back onto the parent
rows in memory. Nested includes recurse over the child rows the same way.
Direction determines the key pair:
| Query-time shape | Parent key | Child key |
|---|---|---|
| has-many, has-one | the parent’s local key (its primary key) | the child’s foreign key |
| belongs-to | the foreign key on the queried row | the target’s key |
Filtering across a relation
Section titled “Filtering across a relation”Dot paths in a filter do not join. user.country = "US" compiles to a single quoted identifier,
"user.country", which is not a column. Filter on the local foreign-key column, or expand the
relation and filter the child query in process.
Relations across layers and planes
Section titled “Relations across layers and planes”| Situation | Behaviour |
|---|---|
| Same relation name in two schema files | Last file wins; the walk is lexical and recursive, so a file rename can change which definition survives. |
| Two unnamed references | Both survive — unnamed entries are never merged. |
| A product or tenant layer adds a relation | Additive. A name collision keeps the base layer’s relation and logs a diagnostic; folding never fails the load. |
The two entities sit in different scope: planes (tenant, shared, superadmin) | The relation is dropped. A relation survives plane subsetting only when both of its entities are in the same plane. |
An entity marked final: true | Sealed against any layer contribution, relations included. |