Relationships, joins, and population
Reference one or many documents, model polymorphic targets, expose inverse joins, and populate safely.
Relationship fields store forward references. Join fields derive the inverse view by querying a target collection. Population replaces stored references with authorized target documents in an operation response. This distinction determines storage, mutation, and read cost.
Store one relationship
field.Relationship("author", "users").
Required().
OnDelete(field.ReferenceDeleteRestrict)The stored value is the target document ID. The target collection must exist, and writes validate that the selected document exists, passes the target’s access, and satisfies any option filter. A required reference cannot use nullify-on-delete because automatic cleanup would create a value ordinary validation rejects.
By default the actor must be able to read the target. To let people link to documents they may not
read, such as sending a friend request to a user with a private profile, set the target
collection’s Reference rule. It decides what may be linked to and replaces Read for this check
only; population still applies Read, so the link does not reveal the target. A draft target also
needs draft access.
Store many or polymorphic relationships
Use Relationships for a list to one collection, or PolymorphicRelationships for a polymorphic list:
field.Relationships("reviewers", "users")
field.PolymorphicRelationships("subjects", "posts", "media")A polymorphic wire value carries both the target collection and document ID so an ID collision between collections is unambiguous. Generated TypeScript keeps that discriminated shape. Singular and list cardinality are schema contracts; changing them requires a reviewed migration.
Relationship and upload references can be localized. The selected locale controls which forward reference is read or changed, and population uses the same locale/fallback chain.
Narrow author choices
Option filters derive server-validated predicates from the current document. A category can, for example, limit an editor picker to users with a matching category:
field.Relationship("editor", "users").
FilterOptionRules(field.OptionFilter(
"category", field.FilterEquals, "category",
))Add more rules for multiple conditions, or use OptionFilterFor for polymorphic targets. Supported
comparisons are equals, not-equals, like, contains, and ordered greater/less variants.
The admin uses these predicates when listing choices, and the operation engine revalidates them on write. They never grant target read access. A target must pass both ordinary authorization and the option predicate; hiding a picker is not security.
Choose delete behavior
OnDelete controls current documents when a target is permanently deleted:
| Action | Behavior |
|---|---|
ReferenceDeleteNullify |
Clear a singular value or remove matching members from a list |
ReferenceDeleteRestrict |
Reject the target deletion while a surviving reference exists |
ReferenceDeleteCascade |
Permanently delete each document that references the target too |
Required references default to restrict and cannot use nullify. A restricted delete fails with
delete_restricted, and its message and issues name each blocking field, such as
invoices.profile, without disclosing document IDs.
Cascade is for owned data. Deleting a user with profiles.learner set to cascade deletes their
profiles in the same transaction:
field.Relationship("learner", "learners").Required().OnDelete(field.ReferenceDeleteCascade)Each cascaded document goes through the ordinary delete lifecycle as a system operation: the target’s delete was already authorized, so the owner’s access rules do not run, but its hooks, versions, and its own delete policies do. Owners cascade in turn, a restrict anywhere in the tree rejects the whole delete, and cycles stop. An owner in a trash-enabled collection is deleted permanently, including one already in the trash, so no reference to the deleted target survives. Cascade applies to a singular, unlocalized relationship or upload outside arrays and blocks, and not to globals, which cannot be deleted.
Version snapshots are immutable and are not rewritten when a current target is deleted; restoring old content can therefore expose a historical reference that must still pass current validation and access rules.
Soft deletion moves the target into trash rather than applying permanent-delete cleanup. Plan retention and restore behavior before choosing a delete action.
Add an inverse join
Suppose each post stores category. A category can display the posts that point back to it without
duplicating IDs:
ridu.Collection{
Slug: "categories",
Fields: field.Fields{
field.Text("name").Required(),
field.Join("posts", "posts", "category").
Limit(20).
DefaultColumns("title", "status", "updatedAt").
DefaultSort("-updatedAt").
AllowCreate(true),
},
}Join fields are read-only computed output and are supported on collections, not globals. The target
path must be a compatible relationship back to the source collection. Limit is between 1 and 100. Columns, default sort, and inline-create preference configure the admin table; they do not
authorize target reads or creates.
Reads query the target collection through its access rule, hooks, localization, and redaction.
Callers can omit expensive join output with the local API’s OutputFields selection.
Mutate a join atomically
The join itself is derived, so changing membership updates the target documents’ forward field. Use the delta operation:
const result = await client.mutateJoin(
'categories',
category.id,
'posts',
{
additions: ['post_123', 'post_456'],
removals: ['post_789']
}
);
console.log(result.added, result.removed, result.doc.posts);One request accepts between 1 and 100 unique target IDs. The same ID cannot appear in both deltas.
Ridu locks and rechecks the source and targets, requires source-field visibility plus target update
access, runs every target update through normal validation/hooks, and rolls the complete set back on
failure. A concurrent reparent produces conflict rather than silently stealing membership.
Populate forward references
Without population, a relationship returns its stored ID/reference. Ask for target documents only when the response needs them:
const post = await client.find('posts', 'post_123', {
populate: {
author: { depth: 1, select: { name: true, avatar: true } }
}
});Population batches work, reapplies target access and field redaction, respects localization, and supports nested relationships under groups, arrays, and blocks. The operation-wide limits are 64 explicit paths, depth 5, 256 expanded schema paths, and 4,096 materialized related documents. See Querying data for REST encoding and cost controls.
Migration implications
Removing or narrowing a target list, changing cardinality, removing a relationship, or shrinking a nested root can leave current values or version snapshots that later bind to reused identities. Ridu’s migration planner rejects these unsafe reference-shape changes.
Add targets additively where possible. For destructive changes, use an application-owned data transition, verify it on restored data, then create the schema artifact. Confirmed collection/field renames use stable identity and schema-addressed rewrites. Read PostgreSQL migrations before evolving live relationships.
See Fields for all constructors and the field reference for
exact typed options.