References between objects
Business objects point at each other through reference fields, in three shapes:
referenceOne— a single link to another object (an order’s customer, a product’s tax classification).referenceMany— several links (a product’s categories, a partner’s groups).hasMany— owned children (an order’s line items, a partner’s additional addresses).
Two more kinds are reference-shaped without being one of those. A referenceOne can
point at a single row of another object’s hasMany collection rather than the object
as a whole — an invoice line’s order line; that’s a row reference, the same field
kind with one extra piece of metadata. And treeParent names another record of the
same business object — a location’s parent location — declaring no target, and playing
by its own rules.
Reference vs. ownership
Section titled “Reference vs. ownership”A referenceOne / referenceMany is a link by identity to a record that lives on
its own. A hasMany is composition — the children belong to the parent, are
created and edited with it, and have no independent existence (you don’t manage a line
item outside its order).
What a reference renders as
Section titled “What a reference renders as”A reference stores an id, but a screen shows a name. Every surface that renders a
reference — a picker’s options, a reference cell, a record’s page title and
breadcrumb — asks the target object how it reads, and the answer is
representation:
defineBusinessObject("master/Product", { name: field.string(), … }, { representation: "name" });A reference to a product now renders its name wherever it appears. Documents
name their number (representation: "documentNumber"), most master data its
name, locations and lots their code. It is a compiler-checked path: a string
or text field of the business object, or a dotted route through a struct to one
("person.fullName").
Leave it out and references render as #<id>, as they also do when the named
field is empty on a record. Omitting it is a real choice, not an oversight: a
goods movement is identified by what it did, not by a label.
To render a reference in a cell of your own, useReferenceLabels(target, ids) from
@onerp/react/data resolves the ids a reference field stores into labels, batched and
cached, so twenty rows naming the same customer cost one request. useRepresentation
beside it is the layer under that — it returns the formatter, which needs the loaded
target record rather than an id. None of this bears on search: what a picker’s typeahead
matches comes from each field’s own
searchable flag.
References are protected
Section titled “References are protected”Two guarantees hold for every reference field, wherever it lives in a business object’s tree — a root field or a field on a nested line:
- A referenced record cannot be deleted. The delete guard scans every reference to
it declared anywhere in the registry and refuses with
core.cross_bo_reference(422), naming the referencing objects — references held only by child lines included (an order line’s product blocks deleting that product). The scan reads every lifecycle state, so the referencing record’s own makes no difference: an archived document blocks the delete, and so does an unfinished draft. The error names the draft, and discarding it clears the delete. OnlyreferenceOnealso carries a real foreign key — areferenceManyis an array column, whose elements Postgres cannot key — so the key is a backstop for a framework bug, and the guard is what produces the readable error. - A draft cannot be referenced. Drafts are tentative and invisible to the
rest of the system; a reference to one is rejected at input validation, at any
depth. Row references and
treeParentare the two exceptions — see below.
Tree parents play by their own rules
Section titled “Tree parents play by their own rules”treeParent is the counter-example to both. It is a self-reference, so constraint sync
writes no foreign key for it, and it gets its own delete guard: a record whose tree
children still exist is refused with core.tree_children (422), counting active and
archived children but not drafts. And a draft may deliberately name a draft parent —
a co-draft, or an existing active record — while an operational write may only parent
onto an operational record. That asymmetry is why parent existence is checked per world
instead of by the world-blind reference-target check. See
Lifecycle.
Row references: a link to one row, not a record
Section titled “Row references: a link to one row, not a record”orderLine: field.referenceOne("sales/SalesOrder", { path: "items", access: "system",}),The business object is named exactly the way any other reference names one. The optional
path is the dotted route down to one of its hasMany collections — "items" here, and
a longer route wherever the target nests one collection inside another. Every segment must
be a hasMany; boot validation resolves each one and fails loud on a typo or a path
through a scalar, naming the segment that didn’t resolve.
The value is the referenced row’s own id — nothing more. A record reference and a
row reference store the same kind of value in the same kind of column; path only says
which table that id lives in. That is what lets the column carry a real
FOREIGN KEY … ON DELETE RESTRICT and lets an aggregate over referencing rows join on
the id directly. The keys are DEFERRABLE INITIALLY DEFERRED, so a unit of work that
creates a record and something referencing it need not order its inserts.
Finding the record a row belongs to
Section titled “Finding the record a row belongs to”The id names the row, not its owner. To reach the owning record, ask for the record that contains the row — ordinary filter language, with the aggregate as the unit you load:
const { items: orders } = await manager.list(SalesOrderSchema, { filter: { items: { some: { id: { equals: rowId } } } },});You never load a row on its own: it has no independent existence, so it can only be written through the record that owns it.
Drafts and row references
Section titled “Drafts and row references”The draft-cannot-be-referenced check doesn’t apply to a row reference: the value is a
row id, and the check resolves records. So a row inside a draft record is referenceable
where the draft record itself would not be. Nothing reaches that state through the
sanctioned path — BusinessObjectManager never returns a draft — and the foreign key
still guarantees the row exists.
No picker yet
Section titled “No picker yet”There is no control for choosing a row of another record: <f.Reference> throws when
pointed at one, rather than rendering a picker over the target’s records and writing an
id the foreign key would reject at commit. Picking a source document line is an ordinary
ERP interaction, so this is a gap to fill, not a rule about the schema.
Row-reference fields are written by whatever spawns the row — an action copying order
lines onto an invoice. access: "system" is the usual choice, keeping the form runtime
from offering to edit it, but nothing forces it: access governs form editability only,
not which code may write the field. Read-only surfaces show the raw row id — resolving it
to something human needs a batched lookup that doesn’t exist yet.
A link is live — which is exactly why documents copy
Section titled “A link is live — which is exactly why documents copy”A reference reads the related record’s current data: rename a product and every object that references it shows the new name immediately. That is right for identity and wrong for the facts that fix a document’s outcome, which is why a document captures those as its own copy — see Documents capture their data.