← Roadmap

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.

This page builds intuition. The mechanics of per-participant join paths live in the sibling explainer; read that first if @referenceFor is new to you. The batched capability itself is spec'd in the R487 plan.
The one-sentence version A polymorphic child field's batched fetch correlates each participant against a 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:

Child holds the FK: Payment

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

Parent holds the FK: Address

customer.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 resolves

sourceSide() is customer.customer_id, the bound key. parentInput carries it.

payment.customer_id
  = parentInput.field("customer_id")  // ✓ column exists
Address branch breaks

sourceSide() 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?

The 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?

A condition hop-0 joins the parent alias and correlates it on the parent's key, so it lives entirely inside the bound-key columns 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.

Single form reads the parent row

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.

Batched form reads the parent key

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:

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 DataLoader key does double duty: it correlates the branch and identifies the parent for result routing. Correlation columns satisfy the first but not the second, since they are not unique per parent. So the key cannot simply become the correlation columns; the open fork R487 decides is whether it becomes (bound key + correlation columns) as one widened key, or per-participant keys, keeping per-parent identity intact while also carrying what each branch needs to join.

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)