ID |
|
|---|---|
Status |
Spec |
Bucket |
bug |
Theme |
nodeid |
Created |
2026-10-06 |
Updated |
2026-10-06 |
A list @nodeId argument at a java.util.List producer parameter is refused by the store although the generator decodes it
Goal
A root @service whose list-typed @nodeId argument lands on a java.util.List parameter builds, and each decoded id reaches one element of that list. That holds at any key arity, and for each element type the generator supports: the node type’s generated record, its sole key column’s Java type, or, where typeName: names a union or interface, a record supertype every member’s record is. The generator already emits this decode. The fact store (the in-memory database a build captures the schema, the jOOQ catalog and the classpath into, and derives refusals from) compares the parameter’s container, java.util.List, where it should compare the element. So today every such signature fails the build, and the message tells the author to declare the type they already declared. After this item the store judges the element. A list argument whose parameter is not a java.util.List, or a single id whose parameter is a multi-valued container, is refused by name, because the generator can emit neither.
The motivating shape, over sakila’s composite-key FilmActor (key actor_id, film_id):
type Query {
filmActorsByIds(ids: [ID!]! @nodeId(typeName: "FilmActor")): [FilmActor!]!
@service(service: {className: "...FilmActorCarrierService", method: "filmActorsByIds"})
}
public static List<FilmActorRecord> filmActorsByIds(List<FilmActorRecord> ids, DSLContext dsl)
Today this fails the build with "argument 'ids' carries the @nodeId(typeName: "FilmActor") and the producer method declares a parameter 'ids' of that name, so the decoded key lands there, but that key is 2 columns and one parameter takes one value; declare 'ids' as the generated record of that node type’s own table …". After this item it builds, and the query returns the two film-actor rows the ids name. The contrast is the same argument on Set<FilmActorRecord> ids: still refused, because the emitted list decode hands over a java.util.List, and now refused with a message that says so and names List<FilmActorRecord> as the fix.
What is true today
Verified against trunk at 8f41aa2 with throwaway store-tier probes beside NodeIdDecodeDefectTest and PolymorphicNodeIdDecodeTest (seeded parameter Map.of("", "java.util.List", "0", <element>), the census shape CodeRows already understands):
-
List<film_categoryRecord>at the compositeFilmCategory:KEY_ARITY_EXCEEDS_SLOT arity 2 ... slot java.util.List, no row inintent_node_id_decode. The reported bug. -
List<String>at the one-columnFilm, whose key column the fixture types asString, so the element agrees:KEY_COLUMN_TYPE_DISAGREEMENT arity 1 column film_id java.lang.String slot java.util.List, no destination. Wider than the report: every list-typed named parameter is refused, at any arity, record or not. -
Set<film_categoryRecord>:KEY_ARITY_EXCEEDS_SLOT ... slot java.util.Set. The refusal is right and the message is wrong. -
List<org.jooq.UpdatableRecord>at theAddressOccupantunion producer slot:SLOT_NOT_SUPERTYPE_OF_MEMBERfor bothCustomerandStaff, no destination. The polymorphic sibling has the same defect.PolymorphicNodeIdSlotPipelineTest.aProducerListParameterTakesTheListVariantclassifiesPublicNodeIdServiceStub.getOccupantsByUpdatableRecordsfine because it never runs the store detections.
Why, by symbol:
-
intent_node_id_decode_slot.java_typeis the root of the parameter’s declared type (code_type.root_class), deliberately, so it equalsintent_argmapping_bound_parameter_type.java_type. Its column comment leaves the list reading to consumers ("that a list of node ids is a coherent request … is a consumer’s reading"). -
Four readers compare that root with a record or column type and none applies the list reading: the slot arm of
intent_node_id_decode(JOOQ_RECORD,SINGLE_KEY_COLUMNand thePOLY_CONTAINERassignability test),intent_node_id_decode_defect(both verdicts), and theSLOT_NOT_SUPERTYPE_OF_MEMBERarm ofintent_node_id_polymorphic_decode_defect. -
The generator peels exactly one
java.util.List:ServiceCatalog.takesTheNodeTablesRecordandServiceCatalog.elementTypeNameclassify the slot, andServiceMethodCallEmitter.isListTypepicks the list decode helper (register(..., isListType(javaType)),decodeList,decodeContainerList) for all three@nodeIdarms. It keys on the raw typejava.util.Listand on nothing else. In particular it never consults the SDL argument’s list-ness, and it treats aSet,Collectionor jOOQResultas single-valued. -
The census already holds the peeled type:
code_method_parameter.element_classanddelivery(MANYwhere aList,Set,CollectionorResultwas peeled;CodeCapturewrites them on the parameter row, and the same facts per type are oncode_type_element). The SDL side holds the argument’s shape ongraphql_argument.is_list/list_depth. No new capture is needed. -
No sakila or generator-test schema puts a list
@nodeIdargument on a@servicefield at all, which is why nothing caught this. The only list cases are input-bean members (assignFilmActorRecordListand siblings), which areINPUT_FIELDsites outside the defect view’s population.
The sis migration’s six refused services (List<UndervisningsaktivitetRecord> / List<UndervisningsenhetRecord> over an eight-column key) are this shape exactly. Why the same snapshot validated clean earlier that day is not this item’s question. The SQL says a missing code_method_parameter row draws no slot row and so no verdict, which is the classpath-capture gap, not this one.
Design
The slot relation states the slot’s shape and where one decoded value lands
Two columns on intent_node_id_decode_slot, beside java_type. java_type stays the root, so the equality with intent_argmapping_bound_parameter_type and that column’s argument both stand. Both columns are decided at the ARGUMENT site, on both carriers, from three facts. The first is the root argument’s graphql_argument.is_list / list_depth. The second is the parameter’s root class, which is java_type itself (code_type.root_class on the named arm, intent_argmapping_bound_parameter_type.java_type on the mapped one). The third is the parameter’s peeled element_class and delivery. On the named arm those are code_method_parameter.element_class / delivery, which the slot view already joins as mp, so no new join is needed. On the mapped arm, intent_argmapping_bound_parameter_type gains the same two columns on its classpath arm, read off the mp it already joins, and NULL on its routine arm.
-
slot_shape, closed vocabularySINGLE/LIST/MISMATCH, and NULL where this relation does not judge the shape: -
LIST: the argument is a list at depth one and the parameter’s root class isjava.util.List. Naming the class is not the coincidencecode_type_element.delivery’s comment warns against, which is answering cardinality with a container name. The question here is the identity of the one class the emitter’s list helpers build, the same kind of test as `java_type = k.record_class. -
SINGLE: the argument is not a list and the parameter’s delivery is notMANY(DIRECT,WRAPPED, or a root class with no element). -
MISMATCH: every other typed combination. That covers a list argument at a parameter whose root is notjava.util.List(aSet, aCollection, a scalar,Object, a record), a single id at aMANYparameter, and a list nested deeper than one level. -
NULL:
java_typeis NULL (a primitive, a type variable, no class at the position), at theINPUT_FIELDsite, and on a mappedROUTINEpair. NULL means readers keep today’s reading ofjava_type, so the population edgesNodeIdDecodeDefectTestpins (anUntypeableParameterAtACompositeKeyIsStillRefusedOnTheArity,anInputFieldSlotDrawsNoVerdict) do not move. -
landing_type: the type one decoded value is handed to. The parameter’selement_classonLIST,java_typeotherwise. It is never compared onMISMATCH, and is carried there only so a message can name it.
Agreement with the emitter is by test, not by construction, and that is a stated limit. List-ness is decided in Java three times (ServiceCatalog.takesTheNodeTablesRecord, ServiceCatalog.elementTypeName, ServiceMethodCallEmitter.isListType, the last over a TypeName, because CallSiteExtraction does not carry it), and this item decides it once more in SQL. The enforcers are the pipeline-tier store assertion beside PolymorphicNodeIdSlotPipelineTest.aProducerListParameterTakesTheListVariant and the execution-tier pins under Tests, and the slot_shape column comment names them. There is one known divergence. element_class is the type with every container peeled (CodeCapture.deliveryOf loops), while the emitter unwraps exactly one List<…>. So List<Optional<Integer>> or List<Set<FilmActorRecord>> reads as LIST with the inner class as its landing, while the emitter routes it elsewhere. The store keeps no per-position type arguments, so this cannot be closed from stored facts at bug-fix scale. The landing_type comment states it, with javac as the backstop, on the family’s own terms for operands it cannot read.
Rewrite the java_type column comment’s last sentence: the list reading now lives on landing_type, because the four readers below need the same one. Add comments for the new columns, on the slot view and on intent_argmapping_bound_parameter_type, in the file’s register.
The readers compare landing_type, and each defect view states its own shape verdict
-
intent_node_id_decode, slot arm: everys.java_typein the destinationCASEand the admission predicate becomess.landing_type, including thesql_table.record_class_fqnexclusion and theintent_record_slot_assignable.slot_type_nametest. Adds.slot_shape IS DISTINCT FROM 'MISMATCH': a mismatched slot has no destination. The destination vocabulary is unchanged. ALISTslot resolvesJOOQ_RECORD,SINGLE_KEY_COLUMNorPOLYMORPHIC_RECORDexactly as its element would, and list-ness is carried to emission the way it is today, by the slot’sTypeName. -
intent_node_id_decode_defect(node types,NAMED_PARAMETER, unchanged population): -
The two existing verdicts read
landing_typewhere they readjava_typeand requireslot_shape IS DISTINCT FROM 'MISMATCH'. -
A third verdict,
SLOT_SHAPE_MISMATCH, whereslot_shape = 'MISMATCH'. It keeps the inner join tointent_resolved_node_key_shape, so containers still miss this view by construction, the disjointness its comment argues for, andaritystays NOT NULL. Since aCASEover the row now picks among three verdicts with shape first, it stays one pass. The comment’s "decided by the arity alone" becomes "by the shape, then the arity". -
New columns:
slot_shape,record_class(fromk.record_class), andlanding_java_typebesideslot_java_type, which keeps the root. Those let a message name the fix. -
intent_node_id_polymorphic_decode_defect(containers, existing population, both carriers): -
The
slottedCTE carriess.landing_typeasjava_type, andslot_shapealongside. -
A sixth verdict,
SLOT_SHAPE_MISMATCH, as one more arm overslottedWHERE slot_shape = 'MISMATCH'. -
SLOT_NOT_SUPERTYPE_OF_MEMBERaddsslot_shape IS DISTINCT FROM 'MISMATCH', a precedence stated inside the one view that has both arms. -
The three verdicts about the container itself (
SINGLE_TABLE_CONTAINER,NO_TABLE_MEMBERS,MEMBER_NOT_NODE_TYPE) are not about the slot and still fire. A slot that is both mismatched and names a container with no table members draws both errors, and each is its own fix. -
The verdict name recurs across the two vocabularies, as each family’s consumer composes its own prose for its own population.
Messages (NodeIdDecodeDefects, NodeIdPolymorphicDecodeDefects)
-
NodeIdDecodeDefects.Verdict.SLOT_SHAPE_MISMATCHand itsrejectionOfarm,Rejection.structural, both directions sharing thelead(...)clause: -
List argument: "…, and the argument is a list, so the decoded ids are handed over as a java.util.List, but 'ids' takes Set; declare 'ids' as List<FilmActorRecord>". Above arity one the suggested element is the record’s simple name. At arity one it is the sole column’s Java type, with the record named as the alternative.
-
Single id: "…, but 'id' takes List, which a list argument fills with one decoded value per id; declare 'id' as <element>, or make the argument a list".
-
KEY_ARITY_EXCEEDS_SLOTnames the record instead of describing it, and wraps it atLISTshape: "declare 'ids' as List<FilmActorRecord>" / "declare 'key' as InventoryRecord". This is the Backlog item’s open point 3. TheargMappingalternative stays. -
KEY_COLUMN_TYPE_DISAGREEMENTquotes the landing type and wraps the remedy the same way atLIST. -
NodeIdPolymorphicDecodeDefectsreads the landing type into its existingslotJavaTypeoperand, soSLOT_NOT_SUPERTYPE_OF_MEMBER’s prose names the element it compared. It also gains `Verdict.SLOT_SHAPE_MISMATCH, whose prose suggests "a java.util.List of a type every implementation’s record is (UpdatableRecord<?>, …)" for the list direction, in that class’s existing remedy vocabulary.
What this item does not change
-
The generator. It already decodes every shape this item admits. The asymmetry left is that it classifies a
MISMATCHslot (a list argument at aSet, a single id at aList) without complaint and would emit code that fails at runtime or at javac. The store now refuses that by name before emission. That is the existing division of labour: `ServiceCatalog.nodeIdSlotExtraction’s javadoc leaves arity and type to the store, and shape joins them. -
The
INPUT_FIELDsite (Backlog open point 4, confirmed). The defect view draws no row there by its stated population edge, andslot_shapeis NULL there. A nested@nodeIdmember reaching a list of records is a bean-member decode (InputBeanResolver;NodeIdRecordInputBeanPipelineTestcoversList<FilmActorRecord>), not this bug. -
Which family judges a mapped node-type slot. The node-type view stays
NAMED_PARAMETER-only, and a mapped pair isintent_argmapping_projection_defect’s to refuse. `slot_shapeis computed on the mapped arm all the same, because the polymorphic view’s supertype arm and the decode destinations read that arm. Whether the argMapping family’sBARE_NODE_IDadmits a record or list-of-record parameter at a composite key is unverified. It is not this item’s, and if it bites it gets its own Backlog item. -
Which containers are supported (Backlog open point 2).
java.util.Listonly, because that is what the emitter builds. SupportingSetwould mean a generator change and a store change together, and nothing has asked for it. It is refused by name instead.
Tests
Store tier, graphitron-model:
-
CodeRows.parameterwriteselement_class/deliveryon thecode_method_parameterrow it seeds, from the samedeliveredByit already uses forcode_type_element. Today it leaves them NULL, which real capture never does, and the slot view now reads them. -
NodeIdDecodeDefectTest, besideaRecordOfTheNodeTypesOwnTableIsNoDefectAtAnyArityand its siblings, aseedListProducerhelper (Map.of("", "java.util.List", "0", element), plus the root argument seeded as a list): -
aListOfTheNodeTypesOwnRecordIsNoDefectAtAnyArity: no row, destinationJOOQ_RECORD 2. -
aListOfTheSoleKeyColumnsTypeIsNoDefect: no row,SINGLE_KEY_COLUMN 1. -
aListWhoseElementDisagreesIsRefusedOnTheElement:KEY_COLUMN_TYPE_DISAGREEMENTwithlanding_java_typethe element andslot_java_typejava.util.List. -
aSetOfTheRecordIsAShapeMismatchandaSingleIdAtAListParameterIsAShapeMismatch:SLOT_SHAPE_MISMATCH, no destination. -
The existing cases unchanged.
seedArgumentNodeIdseeds its argument throughseedArgument, which writes a non-listgraphql_argumentrow, so they are alreadySINGLE. The list cases seed the argument list-typed first (aSeededStore.seedArgumentoverload taking the list shape), whichseedArgumentNodeIdthen leaves alone. -
NodeIdDecodeDestinationTestasserts onslots(dsl). Extend its rendering withslot_shape/landing_typeonly if a case there needs them, and leave the existing expectations alone. -
PolymorphicNodeIdDecodeTest:aListOfARecordSupertypeIsAssignableFromEveryMembersRecord(no defect, twoPOLYMORPHIC_RECORDdestinations), the same through anargMappingpair (the mapped carrier this view also reads), and aSetcase drawing the polymorphic view’s ownSLOT_SHAPE_MISMATCHand noSLOT_NOT_SUPERTYPE_OF_MEMBER, with no row for it inintent_node_id_decode_defect, so the two views stay disjoint.
Pipeline tier, graphitron (NodeIdDecodeDefectsTest, which captures real SDL against the sakila catalog and this module’s test classes):
-
New stubs on
PublicNodeIdServiceStub:getFilmsByInventoryKeys(List<InventoryRecord>),getFilmsByIntegerKeys(List<Integer>),getFilmsByInventoryKeySet(Set<InventoryRecord>).getOccupantsByUpdatableRecordsalready exists. Theschema(nodeType, method)template gains a list-argument sibling. -
aListOfTheNodeTypesRecordTakesACompositeKeyandaListOfTheKeyColumnsOwnTypeIsNoDefect: empty detection. -
aSetParameterAtAListArgumentIsRefusedNamingTheList: the exact message. -
aCompositeKeyAtASingleValuedParameterIsRejectedNamingTheCountAndTheColumns: expectation updated to the record-naming remedy. -
PolymorphicNodeIdSlotPipelineTestgets a store-detection assertion besideaProducerListParameterTakesTheListVariant, so the classification test and the store verdict cannot drift apart again.
Execution tier, graphitron-sakila-example. This is the evidence the goal is delivered, because it is the motivating SDL building, compiling at Java 17 and running:
-
Query.filmActorsByIds(ids: [ID!]! @nodeId(typeName: "FilmActor"))on a newFilmActorCarrierService.filmActorsByIds(List<FilmActorRecord> ids, DSLContext dsl)that fetches the rows the records key, and a test inGraphQLQueryTest(beside theassignFilmActorRecordListcase) that encodes two film-actor ids and gets those two rows back in order. -
An arity-one sibling,
filmsByNodeIdService(ids: [ID!]! @nodeId(typeName: "Film"))on aList<Integer>parameter, asserting the titles. Both fail the build on trunk today, which makes them the regression pins too.
Other solutions we’ve considered
-
Peel in each reader instead of on the slot relation. Four readers (two arms of
intent_node_id_decode, and two defect views) would each joincode_type_elementandgraphql_argumentand restate one rule, and they drift. The slot relation’s comment left the list reading to its consumers, and none of the four applied it. A reading four relations need is a fact, so it is stated once, on the relation they all read. -
Peel on
code_type_element.delivery = 'MANY'instead ofroot_class = 'java.util.List'. That would read aSet<XRecord>as the record and admit a parameter the emitter cannot fill. The store has to agree with what is emitted, not with what the census calls multiplying. -
Peel on the parameter alone, ignoring the argument’s list-ness. That mirrors the generator more literally, but it turns
id: ID!onList<XRecord>, refused today with the wrong message, into a pass that fails at runtime with aClassCastException. A refusal with the right message is strictly better.