ID |
|
|---|---|
Status |
Spec |
Bucket |
architecture |
Priority |
6 |
Theme |
mutations-errors |
Created |
2026-05-20 |
Updated |
2026-05-20 |
Nested input types in @mutation fields
MutationInputResolver structurally rejects any InputField.NestingField on an @mutation input with the message "nested input types in @mutation fields are not yet supported" (MutationInputResolver.java:464). The rejection is Rejection.structural with no plan-slug pointer, so consumers hitting this today have no roadmap item to track against. Unlike the sibling ColumnReferenceField / CompositeColumnReferenceField arms in the same switch (which point at R24’s join-projection work) and unlike the multi-table parent-child INSERT case (R122), a plain nested non-@table input that maps onto columns of the same DML target table has no roadmap coverage at all.
Forcing-function schema
input EndreOrganisasjonInput @table(name: "ORGANISASJON") {
id: ID! @nodeId(typeName: "URegOrganisasjon")
originalnavn: String! @field(name: "NAVN_ORIGINAL")
lokalisering: LokaliseringInput!
}
input LokaliseringInput {
landkode: String!
bynavn: String
regionnavn: String
}
type Mutation {
endreOrganisasjon(input: [EndreOrganisasjonInput!]!): EndreOrganisasjonPayload
@mutation(typeName: UPDATE)
}
LokaliseringInput is a plain (non-@table) input that groups columns of ORGANISASJON. There is no second table involved; this is purely a grouping shape on the consumer side. Today this fails to classify with the NestingField structural rejection, even though every leaf column targets the same DML table as the outer @table input.
Relationship to neighbouring items
-
R122 (compound mutations) covers nested inputs that introduce additional tables (parent + child INSERT). A non-
@tablenested input that flattens onto the outer table’s columns is structurally a different shape and shouldn’t have to wait for R122. -
R24 (
NodeIdReferenceFieldjoin projection) covers FK-target@nodeIdcarriers, orthogonal axis. -
R96 (input-type variant reshape) and R94 (input-record shape) ship the per-input-type Java class the validator pre-step walks; the emitted class already recurses into nested input components via
fromMap, so the validation surface composes without further work.
Design
The classifier already does the hard work. BuildContext.classifyInputField (BuildContext.java:1659-1695) recognises a plain (non-@table) input object on a @table-input field and recursively classifies its children against the outer table’s TableRef. Each leaf returns as the same admissible carrier shape it would carry at the input root (InputField.ColumnField, ColumnReferenceField, CompositeColumnField, CompositeColumnReferenceField), wrapped in an InputField.NestingField that records the SDL grouping. The classifier already rejects unresolvable leaves via UnclassifiedType and circular references via "circular input type reference detected" ; both error paths land in Unresolved before admission.
The only thing standing between today’s classification and a working nested @mutation is the structural rejection at MutationInputResolver.java:480 and the downstream emitters' assumption that every input field is reachable as a single in.get("name") lookup. The wire-path mechanism for nested access already exists for the query-side condition path: CallSiteExtraction.NestedInputField(outerArgName, leafPath, leaf) (used at FieldBuilder.java:1603 for @condition implicit body params). The mutation emitters are the only consumers that still hard-code a single-segment access.
Resolution of the Backlog-stage open questions
-
Column-coverage is already enforced at classification time. The leaf-side classifier returns
Unresolvedfor any field whose column can’t be reached on the outer table, the parent NestingField then propagates that as anUnclassifiedTypeon the containing input. No new check is needed; reuse what’s there. -
@field(name:)scoping: the outer@tableis the resolution context, period.BuildContext.java:1672passesresolvedTablethrough unchanged when recursing into the nested input, so@field(name:)and the SDL-name-defaults-to-column-name fallback both already address the outer table’s columns. Document this in the user docs; no code change. -
Nested +
@nodeId: admit. The leaf classifier dispatches on@nodeIdbefore the nesting branch (BuildContext.java:1634), so@nodeId-carrying leaves under aNestingFieldproduce the same carrier shapes (same-table →ColumnField/CompositeColumnField; FK-target →ColumnReferenceField/CompositeColumnReferenceField) as they do at the input root. The R189 admission rule (liftedSourceColumnson the input’s own table) carries over unchanged because the outer table is the input’s own table. -
DML verb coverage: admit on all four (INSERT, UPDATE, UPSERT, DELETE). The nesting shape is purely a wire-format detail; the DML emit shape (column list, SET clause, WHERE predicate, PK coverage) is over the flattened leaves, which are normal carriers. R130’s INSERT carve-out on
CompositeColumnFieldstays ; orthogonal axis. -
List-typed nested inputs: reject.
nf.list() == true(e.g.lokalisering: [LokaliseringInput!]) has no obvious meaning when flattening onto one outer row, and no forcing-function schema exists. Emit a structural rejection naming this item so the deferral has a redirect.
Representation choice
The factory ArgumentRef.InputTypeArg.TableInputArg.of (ArgumentRef.java:277-313) is the gate between admission and emission. Two viable representations:
-
A. Add
NestingFieldto theLookupKeyField/SetFieldsealed permits and have every emitter handle it. Honest to the SDL shape, but every emit site (emitSetMapPuts,emitSetExcludedPuts,emitSetVColNameAdds,emitSetBulkCellAdds,emitSetVFieldPuts, and the correspondinglookupKeywalkers) grows a recursive arm. -
B. Flatten in the factory. The factory walks
NestingFieldand emits its admissible-carrier leaves intofields/lookupKeyFields/setFieldsdirectly, rewrapping each leaf’sextractionfrom its current shape intoCallSiteExtraction.NestedInputField(argName, accessPath, originalExtraction)so the wire access knows to descend.LookupKeyField/SetFieldpermits stay closed againstNestingField; the partition stays purely flat-carrier-typed.
Recommend B, and the framing is principled rather than convenient: SDL nesting is a wire-format shape, not a DML shape. The DML model is flat columns on one table; the partition that drives SET / WHERE / VALUES emit is over flat carriers. CallSiteExtraction is the canonical home for wire-decode strategy ; NestedInputField already lives there for the condition path ; and the access path attaches to the leaf’s extraction at the boundary between admission and emit, exactly where wire concerns belong. The model’s flat-leaf partition is what’s true downstream; the NestingField envelope persists only for consumers that legitimately need the SDL shape (validation diagnostics, LSP hovers), which is why retaining it on TableInputArg.fields() reads as faithful preservation rather than half-flattening.
The partition lists (setFields() / lookupKeyFields()) carry the flat-leaf view. Consumers walk leaves only.
Implementation sketch
-
MutationInputResolveradmission (MutationInputResolver.java:442-486):-
Remove the
NestingFieldarm of the structural-rejection switch. -
In the per-field admission loop, when
fisNestingField, recurse onnf.fields()applying the same arm checks (so a buriedCompositeColumnFieldon INSERT still trips the R130 deferred rejection). -
Reject
nf.list() == truewithRejection.structuralnaming this item. -
Reject
@valueon aNestingFieldcarrier with a clear message (@value applies to leaf input fields, not nested input groupings; mark the leaves under '<name>' individually). When R188 lands and@valuedisappears, this rejection disappears with it.
-
-
TableInputArg.offlattening (ArgumentRef.java:277-313):-
Before partitioning, walk
fieldsand project eachNestingFieldto its admissible-carrier leaves withextractionrewrapped intoCallSiteExtraction.NestedInputField(argName, accessPath, leafExtraction). The access path is the list of SDL names from input root to leaf (e.g.["lokalisering", "landkode"]). -
The partition (LookupKey vs Set) then runs over the flat-leaf list.
valueMarkedNames(today aSet<String>of input-root SDL names) widens toSet<List<String>>keyed by access path so@valueon a leaf under a nesting field partitions correctly. Update the upstream collection inMutationInputResolverto emit dotted-path keys. -
setFields().stream()…map(f → f.name())consumers want the leaf’s local SDL name (for presence checks against the nested map) AND the access path (for wire fetch). Both are present on the leaf’sNestingField-wrapped extraction; emit-site refactor in step 4 reads both.
-
-
LookupMappingResolver.buildLookupBindings:-
Descend into
NestingFieldwhen constructingInputColumnBindingentries, so the binding records the leaf’s column and the leaf’s access path.fieldBindingsthen includes nested leaves under their access path, and the PK-coverage check atMutationInputResolver.java:501-519works unchanged (it consumesfieldBindings.targetColumns().sqlName(); the column identity, not the field name).
-
-
Emit-site refactor (
TypeFetcherGenerator.emitSet*and the parallel WHERE/lookup-key emitters):-
Replace the literal
presenceLocal.containsKey($S)/valueMapLocal.get($S)codegen pattern with a helper that consumes aList<String>access path. For a single-segment path, the emitted code is identical to today. For a multi-segment path, the emitted code chains the presence walk down the path before the leaf read; at the leaf,containsKeydecides whether the column is written, and the value (which may benull) decides what it’s written to:The presence contract honors the same absent-vs-null distinction at every nesting layer that top-level mutation inputs already honor:java if (in.containsKey("lokalisering")) { var outer = in.get("lokalisering"); if (outer instanceof Map<?,?> m) { if (m.containsKey("landkode")) { sets.put(t.LANDKODE, DSL.val(__m.get("landkode"), t.LANDKODE.getDataType())); } } } -
Key absent at any layer → no claim about that subtree; skip every leaf under it.
-
Key present with
nullouter value (lokalisering: null) → no claim about the group; skip every leaf under it. (Reading 1 of the outer-null fork; the literal-symmetric "clear every column in the group" reading is rejected as a sharp edge with no forcing-function schema.) -
Key present with a Map at the outer layer → descend; each inner leaf’s absent-vs-null is honored independently per the leaf-level rule below.
-
Leaf key absent → skip the SET write for that column.
-
Leaf key present with
null→ emitSET <col> = NULL(explicit clear). -
Leaf key present with a value → emit
SET <col> = <value>. This means sendinglokalisering: { landkode: "NO", bynavn: null }writeslandkode='NO', bynavn=NULLand leavesregionnavnuntouched, matching the top-level absent-vs-null semantics. Document this contract verbatim in the user docs so the difference between an absent key, a null outer, and a null leaf is visible to consumers. -
Apply the same refactor to the bulk-INSERT row walkers (
in.get(rowIdx)becomes the row-local map; from there the same access-path walk produces the cell read) and to the SET-via-EXCLUDED upsert arm.
-
-
InputColumnBindingGroupshape:-
MapGrouptoday carries an SDL field name as the wire-format key. Either extend the record to carry an access path (default singleton) or replace the name with a path field. The MutationInputResolver pre-step (R94’s input-record validator surface) already recurses into nested input components viafromMap, so the validator side composes without change onceInputColumnBindingGroupknows the access path.
-
-
User documentation (first-client check):
-
Section in
docs/manual/tutorial/05-mutations.adoc: "Grouping fields with nested input types". Show theEndreOrganisasjonInput/LokaliseringInputforcing-function schema verbatim, walk the resulting mutation call (mutation { endreOrganisasjon(input: [{ id, originalnavn, lokalisering: { landkode, bynavn } }]) }), and call out three things: (a) the nested grouping has no DML semantics ; it’s purely a wire-format ergonomics shape; (b)@field(name:)on a nested leaf targets the outer table; (c) absent-vs-null is honored at every nesting layer ; an absent outer key or anullouter value skips the whole group, a present outer with a Map descends and each leaf’s absent-vs-null decides whether the column is written and whether the write isNULLor a value. Worked example:lokalisering: { landkode: "NO", bynavn: null }writeslandkode='NO', bynavn=NULLand leavesregionnavnuntouched. The chapter has no current section on nested input shapes; insertion point is right after the multi-column@mutation(typeName: UPDATE)example. -
Cross-reference from
docs/manual/reference/directives/mutation.adoc: one paragraph in the "Input shape" subsection noting that nested non-@tableinputs are admitted as a grouping shape, with a pointer to the tutorial chapter for the worked example. -
No changes needed to
directives/table.adoc(the nested grouping is not@table-backed) or to the diagnostics glossary unless step 1’s@value-on-NestingField rejection ships before R188 retires@value; in which case the glossary gains one entry.
-
-
Existing classifier invariant update (
MutationInputResolver.java):-
The Invariant #4 contract (MutationInputResolver.java:319-323) ;
setFields()on UPDATE equals the@value-marked admissible leaves ; generalises: under nesting it’s the flat-projected@value-marked leaves in SDL declaration order withNestingFieldchildren expanded in place. Update the class javadoc text accordingly; the producing code holds unchanged because it reads the flat leaf list. -
The access-path invariant for nested-leaf extractions (every leaf flattened out of a
NestingFieldcarries aCallSiteExtraction.NestedInputFieldwhose access path’s first segment is the immediately-enclosingNestingField’s SDL name) is mechanically pinned by the sealed-variant carrier on the leaf’s `extractionslot and by the pipeline-tier coverage step 4 above.
-
Interaction with neighbouring items
-
R188 (replace
@valuewith PK-default partition): orthogonal on the partition axis but interacts on the@conditionaxis. The factory flattening runs before partition, so post-R188 the partition becomes "is leaf’s resolved column in the PK" instead of "is leaf’s access path invalueMarkedNames`" ; that part is partition-agnostic. The `@conditioninteraction is sharper: R188 introduces a layeredWhereFilterssub-taxonomy (InputTypeLayer/InputFieldLayer/ etc.) onTableInputArg, andInputFieldLayeris keyed by SDL field name with no slot for an access path. A@conditionon aNestingFieldleaf has nowhere to attach in that layered shape. Two viable resolutions: (a) widenInputFieldLayerto carry an access path (singleton for non-nested leaves, multi-segment for nested), or (b) defer nested-leaf@conditionto a follow-up item and reject it during admission. Land R188 first if practical so R186’s path-rewrap step doesn’t first introduce a typed shape (Set<List<String>>ofvalueMarkedNames) that R188 immediately discards; the carrier-of-path claim (CallSiteExtraction.NestedInputFieldrewrap) survives either ordering, so R186-first is technically possible but adds churn. -
R122 (compound mutations): strictly disjoint. R122 admits nested
@table-backed inputs that introduce additional DML targets; R186 admits nested non-@tableinputs that flatten onto the outer DML target. The two arms in the BuildContext nesting branch (table-bearing vs. non-table-bearing nested input objects) stay clearly separated. -
R189 (FK-target
@nodeIdcarriers on@mutationinputs): composes. ANestingFieldleaf can carry@nodeId(typeName: T)and produce aColumnReferenceField/CompositeColumnReferenceFieldexactly as it would at the input root. The R189 admission predicate is leaf-local; R186’s flattening preserves the carrier shape, so the predicate matches transparently. -
R171 (sealed
InputLikeTypeparent): independent. No collision; both can land in either order.
Tests
Tier choices reflect the project’s test-pyramid guidance.
-
Pipeline tier (
GraphitronSchemaBuilderTest) ; primary classification coverage: -
DML_NESTING_FIELD_ADMITTED: replace today’sDML_NESTING_FIELD_DEFERREDcase (asserts the rejection) with one that asserts admission. Forcing-function schema is the R186 body’sEndreOrganisasjonInputshape against a@table(name: "organisasjon")plusLokaliseringInput { landkode, bynavn, regionnavn }mapping to columns on the outer table. Assert the resultingMutationField.MutationUpdateTableField’s `setFields()contains the flattened leaves in SDL declaration order with their access paths. -
DML_NESTING_LIST_REJECTED: list-typed nested input (lokalisering: [LokaliseringInput!]) tripsRejection.structuralnaming R186. -
DML_NESTING_VALUE_ON_NESTING_REJECTED:@valueon aNestingFieldcarrier trips the per-field rejection (drop on R188 landing). -
DML_NESTING_UNRESOLVABLE_LEAF: nested input with a leaf that doesn’t resolve to an outer-table column producesUnclassifiedTypewith the existing candidate-hint message (regression assertion: the nesting branch’s error path is unchanged). -
DML_NESTING_DEEP: two layers deep (a: { b: { c: String } }) classifies the same way the one-layer case does; assert the access path is["a", "b", "c"]. -
DML_INSERT_NESTING_OK,DML_DELETE_NESTING_OK,DML_UPSERT_NESTING_OK: one case per remaining verb, smaller scope (one leaf each) to confirm DML-verb-coverage admission. -
DML_NESTING_WITH_NODEID_FK_TARGET: aNestingFieldleaf carrying@nodeId(typeName: T)against an FK-target NodeType producesColumnReferenceFieldunder the nesting, withliftedSourceColumnson the outer table. -
Compilation tier (
SakilaCompilationTestor equivalent): one fixture schema with the forcing-functionEndreOrganisasjonInputshape against a Sakila table (or fixtures-codegen schema if the URegOrganisasjon shape is too narrow). Verify generated Java compiles under Java 17. Catches emit-site refactor bugs in step 4 that the classification tests can’t see. -
Execution tier (Sakila DB via
SakilaServiceTest): one end-to-end execution test per DML verb (INSERT, UPDATE, UPSERT, DELETE) using a small nested-input shape against an actual Sakila table. Confirms the access-path walk in the emitted Java produces the right SQL against a real PG, that the absent-vs-null contract from step 4 is observable on the wire (absent outer key → no group writes; outer key withnullvalue → no group writes; outer key with a Map and leaf absent → no write for that column; outer key with a Map and leafnull→SET <col> = NULL; outer key with a Map and leaf value →SET <col> = <value>), and that PK-coverage still trips when expected. -
LSP tier (
LspNodeTypeHoveror equivalent): confirm hover-on-leaf inside aNestingFieldreports the outer table’s column (the lookup chain that already works for top-level leaves should compose transparently ; but assert it to lock in the behaviour).
Roadmap entries
When this item completes:
-
Remove
DML_NESTING_FIELD_DEFERREDfrom the pipeline-test enum; the deferred case becomes the admitted case. -
Add
changelog.mdentry capturing the landing SHA and the new admitted shape (this is the kind of milestone worth keeping in the changelog). -
Update `MutationInputResolver’s class-level javadoc so the "Invariant #7 (nested input)" wording reflects admission rather than rejection.
-
The "R128 / R122 territory" comment at
MutationInputResolver.java:477-478deletes with the rejection arm.
Out of scope
-
Nested
@tableinputs that introduce a second DML target ; that’s R122’s territory. -
Nested inputs whose leaves are themselves
@table-backed shapes (R23’s multi-parent territory on the output side has no symmetric input meaning yet). -
List-typed nested inputs (
lokalisering: [LokaliseringInput!]) ; rejected in step 1 of Implementation; revisit when a forcing-function schema appears. -
Validation-side composition of nested input shapes with
@validator/@constraint: R94’s input-record shape already recurses through nested components viafromMap, so the validation surface composes; no new validator work needed here.