Relationships
Connect objects with lookups and master-detail — cascade rules, filtered pickers, hierarchies, junction objects, and roll-ups.
A relationship is just a field. Point a lookup or masterDetail
field at another object and ObjectOS wires up everything on top:
foreign-key integrity, the record picker, related lists on
the parent page, and expand in queries.
| Type | Cardinality | Delete default | Use for |
|---|---|---|---|
lookup | Many-to-one | set_null | Loose references (ticket → assignee) |
masterDetail | Many-to-one, owned | cascade | Parent-child ownership (order → line items) |
tree | Self-referencing | — | Hierarchies (categories, org charts) |
user | Many-to-one | set_null | Person picker — a lookup specialized to sys_user |
summary | Roll-up onto parent | — | Aggregate children (sum, count, avg) |
Lookup (many-to-one)
The workhorse. References a record in another object:
import { ObjectSchema, Field } from '@objectstack/spec/data';
export const Contact = ObjectSchema.create({
name: 'contact',
fields: {
account: Field.lookup('account', {
label: 'Account',
required: true,
}),
},
});The platform validates that the referenced record exists — foreign-key integrity is enforced on every write.
Tip: Name the field after the object it points to —
account, notaccount_id. Related lists,expand, and the AI Builder all read better that way.
What happens when the parent is deleted
Control cascade behavior with deleteBehavior:
| Value | On parent delete |
|---|---|
set_null | Child's reference is cleared (lookup default) |
cascade | Child records are deleted too |
restrict | Delete is blocked while children exist |
Filtered lookups
Constrain which records the picker offers. Use lookupFilters for
static conditions and dependsOn to scope candidates by another field
on the same record:
contact: Field.lookup('contact', {
label: 'Contact',
dependsOn: ['account'], // only contacts of the chosen account
lookupFilters: [
{ field: 'is_active', operator: 'eq', value: true },
],
})Warning: The legacy
referenceFilters: string[]property (e.g.['is_active = true']) is removed in 16.0 — it is stripped at parse time, and even before removal the record-picker UI never read it, so it filtered nothing. The structuredlookupFilters+dependsOnshown above is the only supported form.
Master-detail (owned children)
masterDetail is a lookup with ownership semantics: the child belongs
to the parent, cascade-deletes with it by default, and can be edited
inline as line items on the parent form.
export const OrderLine = ObjectSchema.create({
name: 'order_line',
fields: {
order: Field.masterDetail({ reference: 'order', label: 'Order' }),
product: Field.lookup('product', { label: 'Product' }),
quantity: Field.number({ label: 'Quantity', min: 1 }),
},
});| Aspect | Behavior |
|---|---|
| Delete | Cascades to children at runtime, unless deleteBehavior: 'restrict' |
| Ownership | Child records are owned by the parent record |
| Editing | Optional inline line-item editing on the parent form |
| Roll-ups | summary fields are only valid on the master side |
Choose lookup when the reference is optional or shared; choose
masterDetail when a child record is meaningless without its parent.
Self-referencing lookups & trees
A lookup can point back at its own object to build hierarchies:
parent_account: Field.lookup('account', {
label: 'Parent Account',
description: 'Parent company in hierarchy',
})For dedicated hierarchies (categories, org charts) use the tree field
type — a self-referencing lookup stored and expanded like a lookup.
Note the engine does not run a cycle check on write, so a chain
that loops back on itself is not automatically rejected.
Tip: Pair a
tree/ self-lookup field with a tree list view to render the hierarchy as nested rows.
Related lists
You get these for free. When a lookup points at a parent, child records automatically appear as a related list on the parent's detail page:
- Contact has
account: Field.lookup('account') - → the Account detail page shows a Contacts related list
No extra configuration needed.
Many-to-many: junction objects
ObjectOS has no direct many-to-many field — model it as a junction object: an object with two relationship fields, one to each side.
// Student ↔ Course, joined by Enrollment
export const Enrollment = ObjectSchema.create({
name: 'enrollment',
fields: {
student: Field.masterDetail({ reference: 'student', label: 'Student' }),
course: Field.masterDetail({ reference: 'course', label: 'Course' }),
grade: Field.select({ label: 'Grade', options: [ /* ... */ ] }),
},
indexes: [
{ fields: ['student', 'course'], unique: true }, // one enrollment per pair
],
});Each side sees the junction records as a related list, and the junction object itself is a natural home for attributes of the pairing (grade, role, date joined).
Roll-ups (summary fields)
Aggregate child records onto the parent with a summary field. Only
valid on master objects — the parent side of a master-detail:
// On the Order object
total_lines: {
type: 'summary',
label: 'Line Count',
summaryOperations: {
object: 'order_line', // child object to aggregate
field: 'quantity', // field to aggregate
function: 'sum', // count | sum | min | max | avg
filter: { status: 'active' }, // new in 16.0: aggregate only matching children
},
}Summary fields are read-only and computed from the children — you never write them directly.
New in 16.0: the optional filter is a query-style where
predicate — only child rows matching it are aggregated (e.g. count only
status == 'active' children), and the roll-up re-aggregates when a
child moves in or out of the predicate. It also lets several summaries
roll up the same child object into different totals.
Querying across relationships
Load related records in one call with expand — the query API follows
lookup fields and inlines the referenced records, so you don't fan out
N+1 requests from the client.
Where to go next
| Page | Why |
|---|---|
| Data Model | Objects, fields, and the schema these relationships live in |
| Formulas | Compute values across a record with CEL |
| Validation Rules | Cross-field rules and unique constraints |
| Views | Tree views, kanban, and related-list surfaces |
| Field Types | Full reference for lookup, master_detail, tree, summary |
| AI Builder | Describe the relationship in chat and let the platform wire it |