ID |
|
|---|---|
Status |
Ready |
Bucket |
architecture |
Priority |
3 |
Theme |
interface-union |
Created |
2026-07-09 |
Updated |
2026-07-09 |
Per-participant explicit join paths on multi-table interface/union child fields
Single-cardinality multi-table polymorphic child fields (a field returning an interface or union backed by several participant tables) only support one join shape today: a single foreign key auto-discovered per participant from the participant’s table back to the parent table. R452 closes the silent-wrong-data hole by rejecting every richer shape at build time. This item is the deferred capability R452’s rejection points at: letting an author state a join path per participant, so multi-table child fields can serve the shapes auto-discovery cannot.
Four cases need it:
-
Multi-FK disambiguation. A participant table with more than one FK back to the parent (auto-discovery finds two and fails; the author must pick which FK correlates the child).
-
Condition joins. A participant correlated to the parent by a non-FK predicate (
@reference(path: [{condition: …}])on a single-table field today). -
Multi-hop key chains. A participant reached through an intermediate join table rather than a direct FK.
-
Same-table self-FK participants. A participant whose table equals the parent/hub table, where
parsePathderives no correlation (R452 rejects this with a cause-specific deferred message keyed to this item).
Syntax decision: a new repeatable directive, @referenceFor
A field-level @reference cannot express this: FieldBuilder.resolveChildPolymorphicJoinPaths (FieldBuilder.java:6765-6779) parses the field’s single @reference path once per participant, applying the same stated path against each participant’s own table, so one stated path can be terminal-correct for at most one participant. Per-participant correlation needs a construct that binds a distinct path to each participant type.
directive @referenceFor(
"Name of the participant type this path applies to. Case sensitive."
type: String!
"Join path from the parent type's table to this participant's table."
path: [ReferenceElement!]!
) repeatable on FIELD_DEFINITION
type Address {
occupant: AddressOccupant
@referenceFor(type: "Customer", path: [{key: "customer_address_id_fkey"}])
@referenceFor(type: "Staff", path: [{condition: {className: "...", methodName: "..."}}])
}
One application states one fact at its natural grain: "for participant type, the path from the parent is path`". Both arguments are flat (a scalar and the existing `ReferenceElement list); no input wrapper. path reuses the ReferenceElement grammar ({table:}, {key:}, {condition:}) and BuildContext.parsePath’s element-resolution machinery (`BuildContext.java:1329-1350) unchanged.
Alternatives considered and rejected (principles consult, 2026-07-09):
-
Revive
@multitableReference(routes: [ReferencesForType!]). The prior art (directives.graphqls:158-160,:185-191, still declared-but-rejected; retirement was R44,changelog.md:534). Rejected: theroutes:input-wrapper list splices thetypeNameandpathaxes into one entry, the exact smell the "Directives carry only what the SDL author needs to say" principle names; and reviving a hard-removed name whose live rejection message says "no longer supported" muddies legacy migration. The name stays retired; its rejection text gets updated to point at@referenceFor(see Implementation). -
Extend
@referencewith an optionalfor:argument. Rejected: widens@reference’s contract at every location it applies (arguments, input fields) where `for:is meaningless, the "argument most callsites never fill" smell; and it would entangle with R435’s repeated-application concatenation semantics (see next paragraph). -
Extend
@discriminate/@discriminator. Rejected on grain: the correlation is a fact of the (field, participant) pair, not of the participant type. The same object type can participate in several polymorphic fields with different parents, so object-level placement states the fact at the wrong grain.@discriminateis also the single-table mechanism; multi-table dispatch is defined by its absence.
Repetition-semantics contrast (must be explicit in the docs). Repeated @reference applications concatenate into one running chain in authored order (R435, reference.adoc:88-104). Repeated @referenceFor applications are keyed by type: and are independent; each application’s path: list is the complete path for that participant, so there is no concatenation axis. Two directives on the same location sharing the ReferenceElement payload but with opposite repetition semantics is a real author-model hazard; the reference page carries a contrast note, and duplicate type: values across applications are rejected rather than merged.
Semantics
-
Legal only on child fields returning a multi-table interface or union (table-backed and record-backed parents, all four producer arms:
FieldBuilder.java:980,:1004,:6134). Any other placement is a structural rejection: single-table (discriminated) polymorphic fields, non-polymorphic fields, root query fields. -
type:must name a table-bound participant (ParticipantRef.TableBound) of the field’s return type, case-sensitively. Unknown names, non-member types, andUnboundparticipants reject with a message listing the valid participant names. -
Override-merge with auto-discovery. Participants without an application keep single-FK auto-discovery; an application overrides only its named participant. Multi-FK disambiguation therefore needs a route on the ambiguous participant only.
-
Path direction. Source is the parent’s table, the same source
resolveChildPolymorphicJoinPathspasses toparsePathtoday (parentTable.tableName(); for record-backed parents this is the parent key owner table). Terminal must be the participant’s table: the per-route terminal-target verdict (Check 1,BuildContext.computeTerminalTargetVerdict) is enforced withMismatchrejecting and naming the participant. This is per-route, unlike today’s multi-table arm which ignores the verdict entirely. -
Same-table self-FK participants. Auto-discovery’s same-table skip (
BuildContext.java:1271-1274) stays; an explicit{key: "<self-FK>"}route resolves through the normal FK lookup and produces a single-hop correlation. The slot orientation of a self-FK hop (which side correlates the parent, which the child) must be pinned by an execution test; both columns live on the same table and a flipped orientation is silently wrong data.
Model: sealed per-participant correlation carrier
R452’s type lift carries resolved parent-FK slot pairs per participant. This item generalizes that carrier into a two-arm sealed type on ChildField.InterfaceField / UnionField (ChildField.java:702/:733), decided once at classification:
-
KeyTupleWhere(On.ColumnPairs slots): the branch joins nothing; correlation is a key-tuple WHERE against the parent’s bound key values (single form) or theVALUESjoin predicate (batched forms). Auto-discovered routes, multi-FK-disambiguated routes, and same-table self-FK routes all lower here. This is R452’s carrier arm, renamed at most. -
JoinedCorrelation(List<JoinStep> hops): the branch joins real tables. Each hop’sOnalready distinguishes FK bridge (On.ColumnPairs) from authored predicate (On.Predicate); multi-hop is list length greater than one, condition correlation is a hop carryingOn.Predicate. Non-empty enforced at construction.
The consult specifically steered away from a three-arm FkSlots/Condition/Chain seal: that shape splices two orthogonal axes (correlation kind and hop count) the resolved JoinStep/On model already carries, and duplicates the ColumnPairs/Predicate fork inside the chain arm. participantJoinPaths becomes Map<String, ParticipantCorrelation>; TypeFetcherGenerator (:587-614) threads it unchanged; the three emitter forms dispatch exhaustively on the seal.
Deferred-rejection discipline across slices. ReferenceElement admits conditions and multi-element lists from the moment the directive is declared, but emitters gain arms slice by slice. Each slice’s classifier rejects, with a DEFERRED message keyed to the pending slice, every route shape whose emitter arm has not shipped; the classifier physically cannot construct JoinedCorrelation before slice 2. This extends R452’s pattern forward and keeps every intermediate trunk state green against arbitrary schemas.
Implementation
Sliced because the seams are real: each slice lands green on trunk and ships an author-usable capability.
Slice 1: directive, classification, carrier generalization
-
Declare
@referenceForindirectives.graphqls; register inSchemaDirectiveRegistry; constants inBuildContext(DIR_REFERENCE_FOR,ARG_TYPE). -
New
BuildContextentry point that resolves an explicit element list for a stated (source, target) pair, factored fromparsePath’s element-resolution loop, returning `ParsedPathwith terminal verdict.parsePathitself is unchanged. -
resolveChildPolymorphicJoinPaths: read applications; validate placement, membership, duplicatetype:; merge explicit routes over auto-discovery; produce the sealed carrier. Aggregate per-participant errors rather than short-circuiting on the first (today’sfailon first participant hides later ones). -
Update R452’s two entry-point messages: the same-table deferred rejection (rule 1b) and the FK-count context wrapper (rule 1c) both become live steers to
@referenceForwith a one-line usage sketch. Update the@multitableReferencerejection text (FieldBuilder.java:2144) andmultitableReference.adoc’s migration section to name `@referenceForas the successor for per-route paths. -
Structural rejections: directive on any non-multi-table-polymorphic field or non-field location.
-
DEFERRED rejections: routes resolving to more than one hop (keyed to slice 2) or to a predicate hop (keyed to slice 3).
-
Ships: multi-FK disambiguation and same-table self-FK. Both lower to
KeyTupleWhere;branchParentFkWhere(MultiTablePolymorphicEmitter.java:1154) andbatchedBranchJoinPredicate(:1743) are untouched beyond consuming the new carrier type, since the parent side in all three forms is bound values, not a joined alias.
Slice 2: multi-hop chains
JoinedCorrelation with all-FK hops, in all three cardinality forms (single buildStage1Block, batched list, batched connection). Each branch gains bridging joins from the participant table back toward the parent, reusing JoinPathEmitter.generateAliases / emitBridgingJoin (JoinPathEmitter.java:44-149), which the polymorphic emitter does not invoke today. Correlation to the parent stays value-bound: the hop-0 source columns (parent-table side) are compared against parentRecord values (single) or joined to parentInput (batched). Retires the slice-2 deferral.
Slice 3: condition correlation
A hop carrying On.Predicate needs the parent table in scope as a SQL alias, which no form has today. Each such branch joins the parent table (aliased) bound to the parent key values (parentRecord / parentInput), then applies the two-arg condition method between parent alias and participant alias via JoinPathEmitter.emitTwoArgMethodCall (:194-198). Also covers filter:-carrying FK hops if stated. Retires the slice-3 deferral, at which point every shape @referenceFor can state is emittable.
User documentation (first-client check)
New reference page docs/manual/reference/directives/referenceFor.adoc, draft skeleton:
@referenceFordeclares an explicit join path for one participant of a multi-table interface or union child field. By default the rewrite auto-discovers each participant’s join as the single foreign key from the participant’s table to the parent’s table;@referenceForreplaces that discovery for the named participant only. Use it when a participant has more than one foreign key to the parent (pick one with{key:}), is reached through an intermediate table (chain multiple elements), correlates by a non-FK predicate ({condition:}), or shares the parent’s own table (state the self-referencing{key:}).Signature:
directive @referenceFor(type: String!, path: [ReferenceElement!]!) repeatable on FIELD_DEFINITION.typenames the participant, case-sensitively.pathis the same element grammar as@reference, read from the parent’s table toward the participant’s table.Contrast with
@reference: repeated@referenceapplications concatenate into one chain; repeated@referenceForapplications are independent, one per participant, and eachpath:is that participant’s complete path. Declaring the sametypetwice is a build error. Participants you do not name keep automatic discovery.
Plus: rewrite the constraints bullet in polymorphic-types.adoc (~line 122) from "auto-discovered per-branch FK paths are the supported multi-table-child idiom" to "auto-discovery is the default; @referenceFor is the explicit per-participant surface", with the four cases; extend the "Polymorphic child fields" how-to section with one worked @referenceFor example; cross-link from reference.adoc and multitableReference.adoc "See also". Doc slices ship with the code slice that makes them true (slice 1 documents the FK cases and names the deferred ones as pending).
Tests
Pipeline tier (RecordParentMultiTablePolymorphicPipelineTest style, inline SDL fixtures, assert on the field record or rejection):
-
Multi-FK disambiguation: participant with two FKs to the parent plus
@referenceFor {key:}classifies toKeyTupleWherewith the chosen FK’s slots; without the directive, the FK-count rejection now steers to@referenceFor. -
Same-table self-FK route classifies; unknown
type:, duplicatetype:, non-participanttype:, directive on a single-table polymorphic field and on a plain field all reject structurally. -
Per-route terminal mismatch rejects naming the participant.
-
Slice-1 deferrals: condition route and multi-hop route produce DEFERRED messages keyed to slices 3 and 2.
-
Override-merge: one participant explicit, the other auto-discovered; both present in the carrier.
-
Producer-arm coverage: interface and union, table-backed and record-backed parent (four arms) across the above.
Execution tier (sakila): film has two FKs to language (language_id, original_language_id), a real multi-FK fixture for slice 1: a Language parent with a polymorphic child whose Film participant is disambiguated by @referenceFor. Assert per-parent row correctness in all three forms (single, batched list, connection); the R452 symptom (arbitrary row per parent) is the regression being guarded. Sakila has no self-referencing FK; the self-FK execution case uses the fixtures-codegen schema (add one if absent). Slice 2 adds a multi-hop route through a join table (film_actor shape); slice 3 a condition route; each asserts correct correlated rows, not just green classification.
Out of scope
-
Root-level (query) multi-table polymorphic fields: R382 / R76 territory.
-
Ordering and pagination semantics: the connection
sortcontract is unchanged; routes affect only the parent correlation. -
Per-participant
fieldsJoin/orderByemission (R76). -
Reworking
fkCountMessagewording for non-polymorphic call sites (unchanged from R452’s scoping).
Relationship to R452
R452 (the build-time gate plus type lift) is the predecessor and a hard dependency; it must land first. Its rule 1b (same-table participant) uses the deferred-rejection arm carrying this item’s slug, and its rule 1c wraps auto-discovery FK-count failures on these fields with a pointer here. Slice 1 turns both sites into live steers to @referenceFor. R452’s rule 1a (reject field-level @reference on these fields) stays permanently: @referenceFor is the sanctioned surface, and bare @reference on a multi-table child field remains a rejection whose message names the replacement. The carrier generalization in this item builds directly on R452’s type lift; if R452 lands the lift as On.ColumnPairs, slice 1 wraps it as the KeyTupleWhere arm.