Relationships
Zeptoz supports first-class relationship metadata with:
belongs_tohas_onehas_manymany_to_many- self-relations (targeting the same collection)
This page explains:
- what each relation type means
- how it looks in payloads and responses
- how
auto-assignbehaves on create and update
CLI
Command format:
zeptoz relation add <collection> <name> <belongs_to|has_one|has_many|many_to_many> <target> [options]For optional flags and parameter details, refer to CLI Reference.
Add relations with:
zeptoz relation add task owner belongs_to users --required --auto auth.id
zeptoz relation add project members many_to_many users
zeptoz relation add user tasks has_many task --via ownerFor belongs_to/has_one, Zeptoz auto-creates FK field <relation>_id when missing.
belongs_to
Use when the current record points to one parent record.
- Example:
task belongs_to user(task.owner -> users.id) - Storage: FK scalar on source collection (
owner_id) - Cardinality: many
taskrows can reference oneusersrow
Typical command:
zeptoz relation add task owner belongs_to users --requiredDelete/update actions:
--on-delete restrict|cascade|set_null(defaultrestrict)--on-update restrict|cascade|set_null(defaultrestrict)
Write payload:
{ "data": { "title": "Ship v1", "owner": "1" } }Read with expand:
GET /api/task?expand=ownerhas_one
Use when the current record has exactly one counterpart on another collection.
- Example:
user has_one profile(inverse ofprofile belongs_to user) - Usually modeled with
--via <target_relation>so Zeptoz knows inverse mapping - Cardinality: one source row maps to at most one target row
Typical command:
zeptoz relation add user profile has_one profile --via userhas_many
Use for inverse parent -> children traversal.
- Example:
user has_many tasks(inverse oftask belongs_to user) - No join table needed; it reads by target FK through
--via - Cardinality: one source row maps to many target rows
Typical command:
zeptoz relation add user tasks has_many task --via ownerRead with expand:
GET /api/user?expand=tasksmany_to_many
Use when both sides can have multiple related rows.
- Example:
project many_to_many users(members) - Zeptoz creates/uses a through table for edges
- Cardinality: many source rows <-> many target rows
Typical command:
zeptoz relation add project members many_to_many usersCreate payload:
{ "data": { "name": "Alpha", "members": ["1", "2"] } }Patch payload (partial edge operations):
{ "data": { "members": { "add": ["3"], "remove": ["2"] } } }Self-relations
Self-relations point to the same collection.
- Example:
category belongs_to categoryasparent - Useful for trees/threads
- Current guard: direct self-cycle (
id == parent) is blocked on update
Typical command:
zeptoz relation add category parent belongs_to categoryAPI Read
Use expand:
curl -s 'http://127.0.0.1:3000/api/task?expand=owner,tags' \
-H 'Authorization: Bearer <token>'Relation filters:
?owner=<id>forbelongs_to/has_one?tags_any=<id>formany_to_many
API Write
Write relation payloads in data:
belongs_to/has_one:"owner": "123"many_to_manycreate:"tags": ["1","2"]many_to_manypatch:"tags": {"set":["1"],"add":["2"],"remove":["3"]}
Auto-Assign
Auto-assign lets server set relation values automatically so client does not need to send parent IDs manually.
Declarative resolver sources:
auth.idrequest.path.<param>request.query.<key>literal:<value>
How it works
- Request comes in.
- Zeptoz checks relation metadata for
auto_assign. - It resolves value from configured source.
- Value is injected before relation validation/write.
Example A: assign owner from auth user
zeptoz relation add task owner belongs_to users --required --auto auth.idThen this create request is valid even without owner in payload:
{ "data": { "title": "Write docs" } }Zeptoz sets owner_id = <auth user id>.
Example B: force overwrite client value (--strict)
zeptoz relation add task owner belongs_to users --required --auto auth.id --strictBehavior:
- if client sends
"owner": "999", server overwrites it with authenticated user id.
Example C: path/query/literal sources
request.path.<param>: good for nested route conventionsrequest.query.<key>: useful for controlled internal APIsliteral:<value>: fixed tenant/system linkage
Update behavior and immutability
- Default is immutable-after-create for auto-assigned relations.
- Use
--mutable-after-createif reassignment is intended. - If relation has
on_update=restrict, reassignment is rejected.
Delete behavior (on_delete)
restrict: block deleting target if dependents exist.cascade: delete dependent source rows automatically.set_null: set dependent FK tonull(requires non-required relation).
Common confusion points
auto_assignfills relation value, but target record must still exist.has_manyis read-side relation; writes typically happen on the inversebelongs_to.expandaffects response shape only; it does not change stored data.
Runtime Internals
When CRUD requests run, Zeptoz applies relation logic in a deterministic order.
Create (POST /api/{collection})
- Parse payload
dataand load relation metadata for the collection. - Preprocess relation keys:
belongs_to/has_one: normalize id, resolveauto_assignwhen configured, validate target record exists, then map relation key to FK column.many_to_many: accept array payload on create, normalize ids, validate every target id exists.has_many: ignored on write path (read-side only).
- Insert base row into collection table.
- Apply deferred relation writes:
- FK relations are already persisted via FK column mapping.
many_to_manyedges are inserted into through table.
- If
expandis requested, Zeptoz reloads record and attaches expanded relation objects.
Read (GET /api/{collection} and GET /api/{collection}/{id})
- Parse scalar filters and relation filters from query.
- Apply relation filter rules:
<relation>=<id>forbelongs_to/has_one(FK equality).<relation>_any=<id>formany_to_many(through-tableEXISTS).
- Fetch base rows.
- If
expandis present:belongs_to/has_one: batch-fetch targets by FK ids and attach underexpand.<relation>.has_many: resolve--viatarget relation and fetch children by inverse FK.many_to_many: read edge rows from through table, fetch target records, attach array underexpand.<relation>.
Update (PATCH /api/{collection}/{id})
- Load existing row and preprocess relation payload (same parsing/validation rules as create).
- For FK relations:
- enforce reassignment guard when
on_update=restrict. - enforce self-relation cycle guard (
id == relation value) for patch. - enforce immutable auto-assign when configured (
immutable_after_create).
- enforce reassignment guard when
- Update scalar/FK columns on base row.
- Apply
many_to_manyoperations from{set, add, remove}:set: clear all existing edges for source id, then insert provided set.add: insert new edges.remove: delete selected edges.
- Reload and apply
expandif requested.
Delete (DELETE /api/{collection}/{id})
Before deleting target row, Zeptoz evaluates incoming references from other collections for belongs_to / has_one relations:
on_delete=restrict: block delete if dependents exist.on_delete=cascade: delete dependent source rows.on_delete=set_null: set dependent FK toNULL(rejected if relation is required).
After these actions succeed, Zeptoz deletes the target row.