ID |
|
|---|---|
Status |
Spec |
How-to recipe and Sakila fixture for grouped collections via Field<Result<R>> @externalField + multiset
Problem
A common service shape is "child rows grouped by a category, exposed on the parent as a list of synthetic bucket types": e.g. Opptak.grupperteOpptakshendelser: [GrupperteOpptakshendelser!] where each bucket carries a category id, a category name, and the events in that category. Users today reach for @service and hand-write a Map<ParentRecord, List<Bucket>> batch loader: build a key set, issue the join query, bucket rows in Java, reconstruct the group records. The grouping is not declarative, the inner records are constructed outside Graphitron scope so sub-resolvers on them lose the framework’s batching path, and the same pattern recurs across every service that has "events grouped by category", "lines grouped by status", "items grouped by tag".
A code trace through the rewrite suggests the existing @externalField machinery already supports the obvious alternative, a static method returning Field<Result<R>> produced by jOOQ multiset(…) GROUP BY …, plus a load-bearing classifier addition. The natural shape pushes the bucket Java record’s inner-list accessor to be Result<RentalRecord> (jOOQ multiset’s native materialisation), not List<RentalRecord>. The classifier currently rejects Result<R> accessors because ServiceCatalog.peelContainer (ServiceCatalog.java:816) does raw-class equality on List.class / Set.class. R109 adds Result.class to that recognition set as a first-class deliverable, validates the path end-to-end with a Sakila fixture, and writes the recipe.
What we believe is true (and want to prove)
The structural argument that the path is mostly already wired:
-
ServiceCatalog.reflectExternalField(ServiceCatalog.java:569) only requires the return to be a parameterisedorg.jooq.Field<…>; it does not constrain the type argument.Field<Result<R>>and any other parameterisedField<X>pass. -
ExternalFieldDirectiveResolver.resolvehas no list-cardinality rejection. -
FieldBuilderclassifies@externalFieldintoComputedFieldregardless of the GraphQL field’s wrapper (FieldBuilder.java:3231-3241); there is nowrapper().isList()guard on the@externalFieldarm. -
FetcherEmitterwiresComputedFieldasnew ColumnFetcher<>(DSL.field(name))with no list branching (FetcherEmitter.java:139-144); jOOQ multiset round-trips through the parent’s result Record under that alias, and graphql-java iterates whatever list is found. -
deriveBatchKeyFromTypedAccessor(FieldBuilder.java:3033, classifier-side cardinality check at FieldBuilder.java:3025) auto-derivesAccessorKeyedManyon the second hop from a list-axis typed-TableRecordaccessor on a Java-record parent, where the element class is a jOOQTableRecordsubtype. -
GraphitronSchemaValidator.validateComputedField(GraphitronSchemaValidator.java:828) only rejectsComputedFieldcarrying a join path (the deferred condition-join lift form), not list cardinality.
The one structural gap: ServiceCatalog.peelContainer (ServiceCatalog.java:816) performs raw-class equality (rawCls == List.class, rawCls == Set.class) when classifying the container axis of an accessor’s return type. Result<R> extends List<R> (jOOQ’s Result interface, R extends Record) but the equality check rejects it; the accessor falls through to AccessorDerivation.None and the second-hop lift never fires. Recommending List<R> as the bucket-record accessor shape would force users to call .convertFrom(…) to coerce the multiset result and is contrary to jOOQ’s idiomatic multiset usage. Adding an explicit Result.class arm to peelContainer is the load-bearing change R109 absorbs.
The empirical question is whether jOOQ’s multiset round-trips cleanly through ColumnFetcher’s `record.get(DSL.field(name)) lookup on the parent select with the bucket Java record materialised correctly under that alias. Either it works as the trace predicts (modulo the peelContainer widening), or a small concrete gap surfaces, at which point R109 forks the gap into a follow-up plan and ships only what works.
Classifier extension
ServiceCatalog.peelContainer adds an explicit org.jooq.Result.class arm alongside the existing List.class / Set.class equality checks:
-
rawCls == java.util.List.class || rawCls == org.jooq.Result.classselectsContainerKind.LIST; -
rawCls == java.util.Set.classselectsContainerKind.SET; -
otherwise the existing
Optional.empty()fall-through stands.
Element-type extraction continues via pt.getActualTypeArguments()[0]. Result<R>’s sole type variable `R flows directly into the List<R> supertype, so the existing extraction is correct without any subtype-traversal logic. The arm is deliberately narrow rather than List.class.isAssignableFrom(rawCls): the broader form would silently accept arbitrary List / Set subclasses (including ones that reorder type variables, such as class Tagged<K, T> extends ArrayList<T> where the element axis is not at type-argument zero), falling through to AccessorDerivation.None with no diagnostic. Keeping the producer narrow preserves the classifier guarantee that every accepted shape has type-argument zero as the element axis, which is what deriveBatchKeyFromTypedAccessor and the emitter arms downstream rely on.
Two coordinated updates ride along:
-
`peelContainer’s javadoc names the accepted container raw classes ("returning X, List<X>, or Set<X>"); update it to read "X, List<X>, Set<X>, or Result<X>" so the documentation matches the code.
The classifier’s existing cardinality contract at FieldBuilder.java:3030 continues to cover the cardinality side; no new structural check is needed.
Deliverables
-
Classifier extension and pipeline-tier coverage. Add the
Result.classarm toServiceCatalog.peelContainer; update its javadoc to includeResult<X>alongside the existingX / List<X> / Set<X>shapes. Add aResultPayloadfixture tono.sikt.graphitron.codereferences.dummyreferences.AccessorPayloads(a Java record with aResult<FilmRecord>accessor, FQN-cited from the SDL fixture). Add an enum arm toGraphitronSchemaBuilderTest.AccessorDerivedBatchKeyCasenamedACCESSOR_ROWKEYED_MANY_LIST_FIELD_RESULT_ACCESSORwhose schema parentsfilms: [Film!]!on aPayload @recordbacked byResultPayload, and whose assertions match the existingACCESSOR_ROWKEYED_MANY_LIST_FIELD_LIST_ACCESSORarm:RecordTableFieldwithBatchKey.AccessorKeyedMany, accessor method namefilms, hop target-key columns equal to the element table’s PK. -
Sakila fixture in
graphitron-sakila-serviceproving the path end-to-end. Sakila has the right shape for this without a contrived domain model: rentals belong to inventory, which belongs to a film, which belongs to film-categories. A natural fixture isCustomer.rentalsByCategory: [RentalsByCategory!]where eachRentalsByCategorybucket carries the category id, the category name, and the customer’s rentals in that category. Concretely:-
A new
CustomerExtensions.rentalsByCategory(Customer customer)returningField<Result<…>>from amultisetcorrelated to the parent customer row, grouped byfilm_category.category_id. -
A
RentalsByCategoryJava record with componentscategoryId: Integer,categoryName: String,rentals: Result<RentalRecord>so the second-hop classifier auto-derivesAccessorKeyedManyfrom the typedrentals()accessor via the widenedpeelContainer. -
Schema additions on
Customer(a@tabletype at schema.graphqls:362) and a newRentalsByCategory@recordtype whoserentalsfield returns[Rental!]!.
-
-
Execution-tier test in
GraphQLQueryTest(graphitron-rewrite/graphitron-sakila-example/src/test/java/no/sikt/graphitron/rewrite/test/querydb/GraphQLQueryTest.java), named in the same<root>_<field>_<assertion>style as the existinginventoryById_filmCardData_firesAccessorKeyedSingleLiftThroughCustomJavaRecordneighbour at line 333. The test runs{ customerById(customer_id: ["1"]) { customerId rentalsByCategory { categoryName rentals { rentalId rentalDate } } } }(customer_idis[ID]per schema.graphqls:20, hence the string literal) and asserts both:-
The bucket projection populates
categoryNameand the right number of buckets per customer (the multiset GROUP BY is correct). -
The inner
rentals { rentalDate }resolves a non-PK column not present on the liftedRentalRecord, proving the second-hopAccessorKeyedManylift fires and batch-fetches the full rows by PK.
-
-
How-to recipe in
docs/manual/how-to/computed-fields.adocas a fourth subsection alongside the existing "ScalarField<T>`" (line 68), "Lifted `Field<TableRecord<?>>`" (line 72), and "Lifted `Field<CustomJavaRecord>`" (line 104) sections. The new subsection ("Lifted `Field<Result<R>>for grouped collections via `multiset`") covers:-
The motivating shape (parent → list of bucket records → list of inner table records).
-
The static-method signature and a worked Sakila example matching the fixture above.
-
The bucket Java record shape and why the typed
Result<RentalRecord>accessor is what makes the inner lift work. -
An explicit recommendation of
Result<R>overList<R>for the bucket-record accessor: jOOQ multiset materialises intoResult<R>natively, soconvertFromcoercion is unnecessary; either shape classifies asAccessorKeyedManyafter thepeelContainerarm is added, butResult<R>is the idiomatic jOOQ choice. -
The cross-link to
result-types.adoc:133for the broader@recorddecision tree. -
A "constraints" bullet noting Postgres-only
multisetsupport if relevant after fixture work. -
A "see also" pointer back to the existing lift forms.
-
-
Cross-link addition from the "Picking a variant: a quick decision tree" section of
docs/manual/how-to/result-types.adoc(line 133) and its "See also" block (line 149) to the newcomputed-fields.adocsubsection, so a reader on the result-types path discovers the multiset bucket pattern.
Tasks
In order:
-
Add the
Result.classarm toServiceCatalog.peelContainer; update its javadoc and theaccessor-rowkey-shape-resolveddescription at FieldBuilder.java:3019 in the same change. -
Add the
ResultPayloadfixture and the newACCESSOR_ROWKEYED_MANY_LIST_FIELD_RESULT_ACCESSORenum arm; confirm the pipeline-tier test passes viamvn install -Plocal-db. The classifier extension is independently load-bearing on the recipe’sResult<R>recommendation and ships at this point regardless of what happens downstream. -
Add the schema, extension method, and Java record for the Sakila grouping fixture; confirm it builds.
-
Add the execution-tier test with the assertions above; confirm it passes. Gating checkpoint: if the test fails because
ColumnFetcher’s `record.get(DSL.field(name))lookup against a multiset-projected alias misbehaves (the empirical risk the trace did not eliminate), do not paper over it. Surface the concrete error, file a sibling Backlog item under a differentR<n>for theColumnFetchergap, and ship R109 as classifier-only with the recipe and Sakila fixture deferred to that sibling. -
If any other trace assumption turns out wrong (bucket-record mapping needs jOOQ-side help, an unrelated classifier path rejects the shape), apply the same fork-then-narrow rule. Do not expand framework changes under R109 beyond the
peelContainerarm and the description update. -
Write the
computed-fields.adocsubsection from the working fixture. -
Add the cross-link from
result-types.adoc:133and its "See also" block at line 149. -
Confirm the docs site renders cleanly:
mvn -f graphitron-rewrite/pom.xml install -Plocal-db(no-P!docs) so the AsciiDoctor render exercises the new content.
Done means
-
peelContainerwidening,ResultPayloadfixture, and pipeline-tier classifier-arm test merged on trunk. -
Sakila fixture and execution test merged on trunk.
-
New "Lifted
Field<Result<R>>`" subsection visible in the rendered how-to (`computed-fields.html), with a working code example matching the fixture and theResult<R>recommendation. -
A reader who arrives at "I want to expose grouped child collections" can find the recipe from either
external-code.adoc,computed-fields.adoc, orresult-types.adocwithout bouncing off the@servicerecipe.
Out of scope
-
Arbitrary
List/Setsubclasses beyondResult. The classifier extension is deliberately a narrowResult.classarm rather than open-ended subtype-assignability; user types that subclassListorSetcontinue to fall through toAccessorDerivation.Noneand require a@batchKeyLifterworkaround. Adding further arms is fine in a follow-up if a concrete demand surfaces. -
Result<Record>(untyped jOOQRecord) accessors. Element resolution requires the element class to be aTableRecordsubtype viasvc.resolveTableByRecordClass; this constraint is unchanged and continues to apply. -
Multi-database
multisetportability. Postgres-only viaJSON_ARRAYAGGfor now; documentation footnote, not a blocker. -
Interaction with
@overrideand@splitQueryfrom the motivating Opptak example. The trace did not exercise those compositions; the recipe should not claim they compose. Filing a follow-up if downstream demand surfaces is fine. -
ColumnFetchervalue-by-name lookup behaviour against multiset-projected aliases. If it fails, fork into a sibling rather than expanding R109.
Open risks
-
ColumnFetcher’s `record.get(DSL.field(name))lookup against a multiset alias might need an explicitClass<?>argument the wayParticipantColumnReferenceFielddoes (FetcherEmitter.java:152-155). Containment plan: surface the concrete error when the fixture fails, file the gap as a separate item, document only the part that works. -
multisetSQL portability is Postgres-only viaJSON_ARRAYAGG. If we ever broaden the supported DB matrix, the recipe needs a portability note. For now Postgres is the only target, so this is a documentation footnote, not a blocker. -
Interaction with
@overrideand@splitQueryfrom the motivating Opptak example was not exercised by the structural trace. If the recipe is going to claim those compose, the fixture should cover them; otherwise the doc should call out the gap and link a follow-up item.