Skip to content

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.

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).

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.

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. Only referenceOne also carries a real foreign key — a referenceMany is 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 treeParent are the two exceptions — see below.

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.

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.

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.

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.

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.

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.