Skip to content

Relationships

Zeptoz supports first-class relationship metadata with:

  • belongs_to
  • has_one
  • has_many
  • many_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-assign behaves on create and update

CLI

Command format:

bash
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:

bash
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 owner

For 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 task rows can reference one users row

Typical command:

bash
zeptoz relation add task owner belongs_to users --required

Delete/update actions:

  • --on-delete restrict|cascade|set_null (default restrict)
  • --on-update restrict|cascade|set_null (default restrict)

Write payload:

json
{ "data": { "title": "Ship v1", "owner": "1" } }

Read with expand:

bash
GET /api/task?expand=owner

has_one

Use when the current record has exactly one counterpart on another collection.

  • Example: user has_one profile (inverse of profile 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:

bash
zeptoz relation add user profile has_one profile --via user

has_many

Use for inverse parent -> children traversal.

  • Example: user has_many tasks (inverse of task 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:

bash
zeptoz relation add user tasks has_many task --via owner

Read with expand:

bash
GET /api/user?expand=tasks

many_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:

bash
zeptoz relation add project members many_to_many users

Create payload:

json
{ "data": { "name": "Alpha", "members": ["1", "2"] } }

Patch payload (partial edge operations):

json
{ "data": { "members": { "add": ["3"], "remove": ["2"] } } }

Self-relations

Self-relations point to the same collection.

  • Example: category belongs_to category as parent
  • Useful for trees/threads
  • Current guard: direct self-cycle (id == parent) is blocked on update

Typical command:

bash
zeptoz relation add category parent belongs_to category

API Read

Use expand:

bash
curl -s 'http://127.0.0.1:3000/api/task?expand=owner,tags' \
  -H 'Authorization: Bearer <token>'

Relation filters:

  • ?owner=<id> for belongs_to / has_one
  • ?tags_any=<id> for many_to_many

API Write

Write relation payloads in data:

  • belongs_to / has_one: "owner": "123"
  • many_to_many create: "tags": ["1","2"]
  • many_to_many patch: "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.id
  • request.path.<param>
  • request.query.<key>
  • literal:<value>

How it works

  1. Request comes in.
  2. Zeptoz checks relation metadata for auto_assign.
  3. It resolves value from configured source.
  4. Value is injected before relation validation/write.

Example A: assign owner from auth user

bash
zeptoz relation add task owner belongs_to users --required --auto auth.id

Then this create request is valid even without owner in payload:

json
{ "data": { "title": "Write docs" } }

Zeptoz sets owner_id = <auth user id>.

Example B: force overwrite client value (--strict)

bash
zeptoz relation add task owner belongs_to users --required --auto auth.id --strict

Behavior:

  • 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 conventions
  • request.query.<key>: useful for controlled internal APIs
  • literal:<value>: fixed tenant/system linkage

Update behavior and immutability

  • Default is immutable-after-create for auto-assigned relations.
  • Use --mutable-after-create if 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 to null (requires non-required relation).

Common confusion points

  • auto_assign fills relation value, but target record must still exist.
  • has_many is read-side relation; writes typically happen on the inverse belongs_to.
  • expand affects 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})

  1. Parse payload data and load relation metadata for the collection.
  2. Preprocess relation keys:
    • belongs_to / has_one: normalize id, resolve auto_assign when 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).
  3. Insert base row into collection table.
  4. Apply deferred relation writes:
    • FK relations are already persisted via FK column mapping.
    • many_to_many edges are inserted into through table.
  5. If expand is requested, Zeptoz reloads record and attaches expanded relation objects.

Read (GET /api/{collection} and GET /api/{collection}/{id})

  1. Parse scalar filters and relation filters from query.
  2. Apply relation filter rules:
    • <relation>=<id> for belongs_to / has_one (FK equality).
    • <relation>_any=<id> for many_to_many (through-table EXISTS).
  3. Fetch base rows.
  4. If expand is present:
    • belongs_to / has_one: batch-fetch targets by FK ids and attach under expand.<relation>.
    • has_many: resolve --via target relation and fetch children by inverse FK.
    • many_to_many: read edge rows from through table, fetch target records, attach array under expand.<relation>.

Update (PATCH /api/{collection}/{id})

  1. Load existing row and preprocess relation payload (same parsing/validation rules as create).
  2. 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).
  3. Update scalar/FK columns on base row.
  4. Apply many_to_many operations from {set, add, remove}:
    • set: clear all existing edges for source id, then insert provided set.
    • add: insert new edges.
    • remove: delete selected edges.
  5. Reload and apply expand if 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 to NULL (rejected if relation is required).

After these actions succeed, Zeptoz deletes the target row.