Concept explainer · R499 · R500 · theme: codegen-correctness
Flattened selection sets and shared result keys
How the generated $fields projection reads a GraphQL query, why edges.node and nodes collapse into one bucket, and what "merge the occurrences" actually means. Read this first, then the R499 plan.
$fields method builds one SQL SELECT list from graphql-java's flattened result-key map, in which a single key can carry several selected fields from different paths of the query; a projection that honours only the first occurrence serves the other paths a row they never asked for.
The problem, concretely
Take the Sakila example schema's stores connection ([Store!]! @asConnection). A Relay connection offers the same nodes twice, as edges { node } and as the nodes shortcut, and a client (or a router composing independent fragments) may use both in one operation with different sub-selections of a reference field:
query {
stores(first: 10) {
edges { node { address { district } } }
nodes { address { phone postalCode } }
}
}
Both sides read the same generated SQL row per store: the projection is computed once, and address becomes one correlated MULTISET subquery aliased "address". If that subquery projects only what edges.node asked for (district), the nodes mapper then reads phone from a row type that does not contain it. The result is a per-row jOOQ error, Field "..." is not contained in row type, and silent null data on the diverging side. That is R499, observed in production-shaped testing against the opptak subgraph.
Why one bucket holds both
The connection fetcher hands the whole connection-level selection set, unnavigated, to the node type's $fields. graphql-java's getFieldsGroupedByResultKey() flattens the entire subtree and groups by each field's leaf result key (alias, or field name when unaliased), not by path. So the map for the query above contains entries for edges, node, nodes, pageInfo... and one entry address whose value list holds two SelectedFields: the one under edges.node and the one under nodes.
The generated switch matches on the field name and lets everything else fall through a default arm. Connection wrapper keys (edges, node, cursor...) are not fields of the node type, so they are skipped, and the node's own leaf names hit their arms regardless of the path they arrived by.
The grouping deliberately erases the path. When the same result key arrives by two paths with different sub-selections, the bucket holds both occurrences, and code that binds entry.getValue().get(0) silently throws the second one away. That single expression is the R499 defect site.
Quiz The same query selects scalar columns divergently: edges { node { storeId } } and nodes { lastUpdate }. Does the first-occurrence bug corrupt them too?
$fields always projects the node table's base columns. Divergence only matters for arms that descend into a nested selection: inline reference fields (TableField / LookupTableField) and nesting wrappers. That asymmetry is why the bug hid until reference fields were selected on both sides.The tempting fix that cannot work
If one occurrence is not enough, why not run the switch once per occurrence and let the accumulator deduplicate?
An inline reference arm emits DSL.multiset(...).as("address"). Every .as(...) call mints a fresh jOOQ Field, so the LinkedHashSet accumulator cannot collapse two emissions: the SELECT ends up with two subqueries under the same SQL alias.
The set only dedupes raw table.X adds, because jOOQ caches those per aliased table instance.
Keep one arm emission per result key, but make its nested descent operate on the union of every occurrence's sub-selection, recursively at every depth. The address subquery then projects district, phone and postalCode; each side's mapper reads only what it asked for, and extra columns in the row are harmless.
That is R499's core move: the union happens on the selection side, before emission, not on the emitted fields.
Arguments: the part a union cannot paper over
Inline reference arms also read runtime arguments off the selected field (filter args, pagination first, routine call args). GraphQL validation never merges sibling selection sets, so this operation is perfectly legal:
edges { node { films(first: 5) { title } } }
nodes { films(first: 10) { title } }
One projection cannot serve both: there is a single films subquery, and it takes one LIMIT. Any merge has to decide what happens here.
Quiz Under R499's contract, what does the generated code do with the divergent first: arguments above?
get(0)'s arguments serves provably wrong data to the other path, which is the exact failure class R499 exists to kill. And there is no build-time home for the check: the occurrence set is a property of the incoming query, not the schema, so the only available enforcer is a runtime guard. Fail loud, name the field and the conflicting values. Occurrences that agree (the overwhelmingly common case, and every no-argument field) proceed normally.R499 and R500: same substrate, different axis
Grouping is by result key, but the switch and the SQL alias go by field name. Those two identities diverge in exactly one situation: client aliases.
edges.node.address and nodes.address. Same result key, same name, one bucket. Fix: merge sub-selections within the bucket, guard name/argument divergence.
Fails silently wrong today, hence priority.
a: address { district } and b: address { phone }. Two result keys, two buckets, and both arms project .as("address"): a duplicate SQL alias. Fix needs projections aliased by result key and result-key-aware readers.
Fails loud today (jOOQ duplicate-alias error), hence lower priority.
Quiz A client sends myAddr: address { district } under edges.node and address { phone } under nodes. Which item owns that shape?
myAddr and address are distinct keys, so this is the cross-bucket duplicate-name case: both arms fire independently and collide on the .as("address") SQL alias. That is R500's axis. The two fixes are orthogonal by design, which is why they are two items.Where the fix lives
One defect site covers every route in: plain connections and polymorphic connections both funnel into the same generated $fields loop (the polymorphic path goes through a restrictTo view that already preserves full occurrence lists). The merge and the divergence guard are schema-independent, so they land on a shared util-singleton scaffold rather than being copied into every generated type class; the guard's applicability derives from the same structural fact that makes an arm read arguments off the selection (ArgumentValueSource.FromSelectedField), so future arm variants inherit it instead of silently reverting to first-occurrence picking. The reasoning behind both choices is the development principles (single-sourced facts, every invariant has an enforcer); the fail-loud stance is the same instinct as typed rejection, applied at the only tier that can see a query.
Where it stands (as of 2026-07-17)
- specR499 (within-bucket occurrence merge + fail-loud argument guard): plan body written, awaiting Spec → Ready sign-off by an independent session.
- backlogR500 (result-key-aware aliasing for aliased duplicates): filed from the R499 trace, no plan body yet.
- backlogR481 shares the jOOQ "not contained in row type" symptom string on an unrelated code path; listed here only to prevent mis-triage.