Concept explainer · R481 · R487 · theme: interface-union
Parent-holds-FK correlation in polymorphic child fields
Which table owns the foreign key decides how graphitron correlates a polymorphic participant to its parent. When the parent owns it, the batched fetch path has a hole; this page is the intuition for why, and what closing it takes.
@referenceFor is new to you. The batched capability itself is spec'd in the R487 plan.
parentInput VALUES table keyed on the parent's primary key; that only works when the correlating column sits on the child, so a participant whose FK lives on the parent asks parentInput for a column it never carried.
The problem, concretely
Take a Sakila Customer with a polymorphic list field whose members live in different tables:
type Customer {
related: [CustomerRelated] # union of Payment | Address
}
union CustomerRelated = Payment | Address
A customer has many payments and one address, so the field is a list. Two participants, two ways the foreign key can point:
Paymentpayment.customer_id → customer.customer_id. The FK column lives on the participant's own table.
To find a customer's payments, match payment.customer_id against the customer's primary key value.
Addresscustomer.address_id → address.address_id. The FK column lives on the parent table.
To find a customer's address, match address.address_id against the customer's address_id value, not its primary key.
How batched correlation actually joins
For a list or connection field, graphitron never fires one query per parent (see the batching model). It collects every parent in the execution batch into one parentInput VALUES table, runs a single UNION-ALL with one branch per participant, and joins each branch back to parentInput. The VALUES table is aliased to the parent's bound key columns, parentSourceKey.columns(), its primary key.
Each branch's WHERE is emitted by parentInputSlotPredicate, which reads the parent side of the correlation slot out of the VALUES table by column name:
<participant>.<slot.targetSide()>
.eq(parentInput.field("<slot.sourceSide().sqlName()>", ...))
So everything hinges on one question: is slot.sourceSide() (the parent-side column) one of the columns parentInput actually carries?
Payment branch resolvessourceSide() is customer.customer_id, the bound key. parentInput carries it.
payment.customer_id
= parentInput.field("customer_id") // ✓ column exists
Address branch breakssourceSide() is customer.address_id, not the bound key. parentInput never aliased it.
address.address_id
= parentInput.field("address_id") // ✗ returns null
jOOQ's field(name) returns null for an absent column, and the generated code is broken at runtime.
Quiz The field is a list. Doesn't cardinality tell graphitron the FK direction, so it could just orient the join the right way?
selfRefFkOnSource = !isList hint orients same-table self-FKs only, where cardinality genuinely picks the navigation direction. A cross-table FK like customer.address_id points where the schema put it; a list field can perfectly well name a parent-holds-FK participant (its branch just contributes at most one row per parent). So cardinality does not save you, and the broken form classifies and emits today.Not every correlation reads a parent column
Only the FK-oriented correlations trip on this. A participant reached by a {condition:} predicate joins the parent's own aliased table into the branch and pins it to the batch through parentInputKeyPredicate, which reads the bound key, exactly what parentInput carries. So the hole is specific: it is a KeyTupleWhere slot, or a JoinedCorrelation FK hop-0 slot, whose parent side is a column outside the parent's primary key.
Quiz Three participants on one batched field: (a) child-holds-FK, (b) parent-holds-FK, (c) correlated by a {condition:}. Which branches read a column parentInput might not carry?
parentInput already carries. The child-holds-FK slot reads the bound key too. Only the parent-holds-FK slot reaches for a non-key parent column, so it is the one shape the batched form cannot express today.Why single cardinality already works, and batched does not
The single-valued form of the very same field was fixed by R481. Seeing why its fix does not carry over is the crux of R487.
It correlates off parentRecord, the actual parent row. R481 made the parent-projection walk carry the correlation column onto that row (a ParentRowDemand capability), so parentRecord.getAddressId() is simply there to read.
It correlates off parentInput, a VALUES table that carries only the DataLoader key. Projecting a column onto the parent row does not put it into the key, so the batched branch still cannot find it. The column has to be threaded through the key.
R481 closed the single-cardinality crash and, for the batched case it could not yet build, installed a build-time gate: FieldBuilder.classifyParticipantRoute rejects a list or connection field whose correlation reads an off-key parent column as a typed Deferred, keyed to R487, steering the author toward single cardinality where the relationship is single-valued anyway. That is graphitron's typed-rejection discipline: a shape the generator cannot yet emit correctly is a first-class rejection, never a silent guess.
What R487 has to carry, and the decision it turns on
The capability is: make the batched forms carry each participant's parent-side correlation columns, not just the bound key, all the way through the DataLoader key and into parentInput. Three moving parts:
- the per-parent key extraction (
buildRecordParentKeyExtractionon table-backed parents, the accessor arms on record-backed ones) must project the union of correlation columns; parentInputmust alias those columns soparentInput.field("address_id")resolves;- key identity and dedup semantics need a deliberate decision.
That last one is the subtle part. Today the DataLoader key is the bound key, unique per parent, so two parents never collide. Correlation columns carry no such guarantee: two customers can share an address_id.
Quiz Tempting shortcut: just key the DataLoader by the FK correlation columns (address_id) instead of the primary key. What goes wrong?
The other pickup question is reuse: R481 already ships a single-cardinality parent-holds-FK projection mechanism (the ParentRowDemand union across participant join paths). R487 asks whether that same projection can feed the batched extraction side, so the two cardinalities share one way of naming which parent columns a participant needs.
Where it stands (as of 2026-07-20)
- landedR481: single-cardinality parent-holds-FK reads are projected correctly (the
ParentRowDemandcapability), and the batched case is gated at build time with aDeferredrejection pointing at R487. - backlogR487: carry the participants' parent-side correlation columns through the DataLoader key into
parentInput, so batched list and connection forms emit correct SQL instead of hitting the gate. Open at pickup: the key-identity fork (widened key vs per-participant keys) and whether R481's projection mechanism can be reused for the extraction side.