Technical and architectural principles that govern the rewrite pipeline. For Graphitron’s strategic/philosophical principles, see graphitron-principles.md.
The typed-rejection narrative (the sealed Resolved shape across the resolver siblings, the Rejection taxonomy, the Levenshtein-ranked candidate hint contract) is consolidated at Typed rejection; this doc retains the principle-list shape, with the rejection-related principle below collapsed to a forward pointer.
Generation-thinking
Before implementing a generator body, ensure the model carries what the generator needs ; pre-resolved, generation-ready. GraphitronSchemaBuilder reads directives once and resolves everything: table names, column references, method names, extraction strategies. Generators receive a model in terms of "what to emit", not "what to interpret".
Signs a model type needs more pre-resolution: - A generator switches on a raw string, or recomputes a derived name from a field name. - The same multi-arm type switch recurs across multiple generators. - Generation and calling are conflated in the same model type. - A generator branches on a predicate over pre-resolved data (e.g. which side of a join holds the FK). The decision was not resolved, only its inputs were ; lift the fork into the model as a sealed sub-variant. Rule of thumb: if two consumers (generators, validators, resolvers, dispatchers) evaluate the same predicate over a model field, the branch belongs in the model. The same predicate evaluated by multiple consumers is a sign the resolver is under-specified, and an opportunity for one site to drift from another.
Sealed hierarchies over enums for typed information
When different variants of a concept carry different data, use a sealed interface ; not an enum with a shared field set. An enum forces every variant to have the same shape; a sealed record hierarchy gives each variant exactly the fields it needs.
SourceKey.Reader illustrates the pattern at its current shape: ColumnRead carries no payload (catalog-FK columns live on the surrounding SourceKey, not the reader), AccessorCall(AccessorRef) carries the typed instance accessor on a @record parent’s backing class, SourceRowsCall(LifterRef) carries the developer-supplied @sourceRow static lifter, ServiceTableRecord(ClassName) carries the developer-declared typed jOOQ TableRecord subclass the @service method returns, and ServiceUntypedRecord carries no payload (untyped Record/scalar service return). Each variant holds exactly the data its rows-method body needs to read parent-side input; none carry fields they don’t use. SourceKey.Wrap is a sibling sealed: Row / Record / TableRecord(ClassName), where the TableRecord arm carries the developer-declared subclass and the other two arms carry nothing. The compiler enforces exhaustive switches ; when a new variant is added, every switch that doesn’t handle it becomes a compile error.
When variants split on independent axes, use sealed sub-interfaces per axis (or separate records orthogonal to the variant axis) rather than inventing a god accessor whose meaning depends on the variant. The DataLoader-backed source side is the worked example: SourceKey.Wrap carries per-row key shape, SourceKey.Reader the body input contract, SourceKey.Cardinality (ONE / MANY) the per-source row count, and a sibling LoaderRegistration record carries Container (POSITIONAL_LIST / MAPPED_SET) and Dispatch (LOAD_ONE / LOAD_MANY). Each dispatch site reads off whichever axis it forks on instead of reconstructing it from a conflated permit. The Dispatch axes chapter narrates the four-axis split, the cross-axis invariants the compact constructor pins, and the consumer-side dispatch shapes. The smell to watch for: a single shared accessor whose meaning depends on the variant (e.g. "FK source columns" in one arm and "child target columns" in another), or a sealed permit name that splices two axes together. The split pushes the per-axis meaning into the type system.
Directives carry only what the SDL author needs to say
Directive arguments should be flat scalars whenever the directive site already disambiguates the axis. An input-object wrapper is justified only when the directive carries several genuinely-orthogonal pieces of information at once; the default is a single typed scalar (String!, Int!, an enum), and reaching for an input wrapper is a deliberate decision rather than a default.
@field(name: String!) is the worked example. The directive applies on four sites (FIELD_DEFINITION, INPUT_FIELD_DEFINITION, ARGUMENT_DEFINITION, ENUM_VALUE); the site itself tells the classifier which axis is being bound (column vs argument vs enum-value), so the directive only carries the underlying name. There is no @field(target: { axis, name }) wrapper, because the axis is structural.
The smell to watch for is an input wrapper that most callsites fill in two-of-four slots on. SDL authors end up typing a structured literal where a string would have served, and the directive’s failure-mode surface widens from "the named thing didn’t resolve" to a cross-product of "field A missing", "field B given but A wasn’t", "A and B given but inconsistent". ExternalCodeReference (name, className, method, argMapping) is the existing case that new directive surfaces should not lean on; new directives default to `@field’s shape.
Classification belongs at the parse boundary
Reading the reflection java.lang.reflect.Type tree is permitted only at builder-side classifiers that convert reflection output into the typed model. Today five files cross that boundary: ServiceCatalog (for @service and @tableMethod parameter classification, including the post-R7 classifySourcesType for DataLoader source parameters); ServiceDirectiveResolver (for @externalField reference methods); SourceRowDirectiveResolver (R110, for the developer-supplied @sourceRow lifter signature); ClassAccessorResolver (R88, for resolving an SDL output field’s accessor on a @record-Java-backed parent against the backing class’s method set); and FieldBuilder (for @sourceRow argument-mapping and accessor reflection on @record parents whose backing class carries typed FK accessors). Each converts raw reflection output into MethodRef.Param, SourceKey, LoaderRegistration, AccessorRef, or AccessorResolution values (each carrying a ParamSource where applicable). Everything downstream ; validator, generator ; switches on the pre-classified values and never touches reflection types.
The boundary lives at JooqCatalog: it is the canonical permitted holder of raw jOOQ types (Table<?>, ForeignKey<?,?>) and the path classifier code goes through. Two other classes also import org.jooq directly today: BuildContext (ForeignKey for @reference validation messages that need to enumerate keys for the candidate hint) and catalog/CatalogBuilder (ForeignKey + Table for the LSP completion-data snapshot, which marshals catalog metadata into a wire format the LSP server consumes). TypeBuilder, FieldBuilder, and ServiceCatalog consume the classified output via JooqCatalog rather than holding raw types directly. If a generator needs information not yet in a taxonomy record, the fix is to add a component and extract the value in the builder ; not to reach past the taxonomy boundary.
CallSiteExtraction illustrates the principle for argument extraction: the builder decides once (at classify time) which extraction strategy applies to each argument ; one of five direct strategies (Direct, EnumValueOf, TextMapLookup, ContextArg, JooqConvert) or one of two sealed sub-groupers covering nested-input traversal (NestedInputField, for @condition on INPUT_FIELD_DEFINITION) and NodeId decode (NodeIdDecodeKeys.{SkipMismatchedElement | ThrowOnMismatch}) ; and stores that decision in CallParam.extraction or ParamSource.Arg.extraction. The generator switches on the pre-classified value and emits code directly.
Capability interfaces and sealed switches serve different roles
When a generation pattern applies uniformly across multiple field variants, use an orthogonal capability interface rather than an N-way instanceof chain. Established interfaces: SqlGeneratingField, MethodBackedField, BatchKeyField.
Capabilities express what is uniformly true across variants; sealed switches express what varies by identity. Use a capability when the generator treats variants identically (iterate SqlGeneratingField.filters() regardless of leaf type). Use a sealed switch when the generator forks on identity (which $fields arm to emit, which rows-method signature to synthesise). Capabilities don’t eliminate exhaustiveness bookkeeping ; they relocate it.
Narrow component types over broad interfaces
Field record components are declared with the narrowest type the classifier can guarantee rather than the broad sealed-interface root. A field whose return type is always table-bound declares ReturnTypeRef.TableBoundReturnType directly; a field whose return type is always polymorphic declares ReturnTypeRef.PolymorphicReturnType directly.
This pushes classification certainty into the type system: code that receives a ServiceTableField knows its returnType is TableBoundReturnType without a runtime check.
Sub-taxonomies for resolution outcomes
Complex resolution outcomes get their own sealed type rather than being stored as raw strings. SourceKey.Wrap (sealed: Row / Record / TableRecord(ClassName)) is the key-shape sub-taxonomy on MethodRef.Param.Sourced and ParamSource.Sources, TableRef of GraphitronType.TableBackedType, ColumnRef of InputField.ColumnField. The type of a field tells you exactly what states it can be in.
Each new sub-taxonomy proposal comes with a one-line note on what distinct information it carries that a sibling cannot ; otherwise it’s probably a field on an existing record. At milestone boundaries, audit which sub-taxonomies could collapse now that their forcing functions are visible.
Builder-internal sealed hierarchies for multi-target classification
When a builder step classifies inputs into many variants that project into different generation-ready outputs, introduce a builder-internal sealed hierarchy. It captures the full classification, enables exhaustive projection into each target, and is discarded before reaching the model.
ArgumentRef (see argument-resolution.md) classifies every GraphQL argument once into a variant (ColumnArg, OrderByArg, PaginationArgRef, TableInputArg, etc.). Separate projection steps then switch on the classified values to produce GeneratedConditionFilter, LookupMapping, OrderBySpec, and PaginationSpec ; each projection is exhaustive and independent. The alternative ; multiple independent passes that implicitly coordinate by skipping each other’s arguments (e.g., buildFilters() skipping pagination args using the same hardcoded names as buildPaginationSpec()) ; is fragile and makes adding new argument types error-prone.
The key distinction from model-level sealed hierarchies: builder-internal hierarchies are ephemeral. They exist to structure a complex builder decision, not to carry information to generators. Generators never see ArgumentRef ; they see the projected results.
Builder-step results are sealed, not strings or out-params
Every builder-step lift returns a sealed Resolved; rejection is a typed variant, never a string or out-param. The full narrative (sealed Resolved shape across the thirteen resolver siblings, the Rejection taxonomy, the BuildContext.candidateHint contract) lives at Typed rejection.
Model metadata over parallel type systems
When the model already carries typed information, runtime data formats should derive from that metadata rather than inventing a parallel type system.
OrderByResult pairs List<SortField<?>> with List<Field<?>> ; each cursor column’s DataType is already known. Cursor encode/decode should use field.getDataType().convert() for type-safe round-tripping, and DSL.noField(field) for the no-cursor seek case. This eliminates the need for a hand-rolled type-tag system (i:, s:, l:) in the cursor format ; the column metadata is the type information.
The general principle: when the model has already classified and resolved type information at build time, that same information should drive any runtime format that needs types. A parallel type system in the runtime format is redundant and will diverge.
Wire-format encoding is a boundary concern, never a model concern
Opaque wire formats (Relay NodeId base64 strings, Relay cursor strings, federation _Any representations) decode at the DataFetcher boundary into typed column tuples; everything downstream sees those tuples, not the wire shape. Conversely, the projection layer encodes column tuples back into wire format only at the same boundary. Variants representing the wire shape don’t survive in the model.
R50 is the worked example. The retired wire-shape carriers ; InputField.NodeIdField / NodeIdReferenceField / NodeIdInFilterField / IdReferenceField, ChildField.NodeIdField / NodeIdReferenceField, BodyParam.NodeIdIn, LookupMapping.NodeIdMapping, ArgumentRef.ScalarArg.NodeIdArg ; were each "the model says this is a NodeId" markers that forced downstream emitters to call NodeIdEncoder.hasIds(…) or similar wire-aware helpers across the boundary. The replacement: CallSiteExtraction.NodeIdDecodeKeys.{SkipMismatchedElement | ThrowOnMismatch} lives at the carrier slot where decode happens (input-fields, arg-level filters, lookup-key bindings); CallSiteCompaction.NodeIdEncodeKeys(HelperRef.Encode) lives at the projection slot where encode happens (column-shape carriers on output); BodyParam.ColumnPredicate.{Eq | In | RowEq | RowIn} carries the predicate shape over decoded column tuples without needing to know the predicate came from a wire-format input. Standard column predicates and lookup VALUES rows fall out for free.
The pattern matches Connection-cursor encode/decode (which already lives at ConnectionHelper.encodeCursor / decodeCursor and never reached the model), and the federation _Any rep flow (which reads the rep at EntityFetcherDispatch.resolveByReps and walks alternatives over decoded values, never as opaque blobs).
The general rule: for any opaque wire format, classify the failure mode (skip vs throw) and the direction (encode vs decode) at the boundary, never below it. A "this is a NodeId" or "this is a base64 cursor" marker spreading through the model is the same family of smell as a parallel runtime type system ; both are bypasses around classified information that the boundary already carries.
Wire boundaries are typed adapter / composer pairs
Where the generator emits a method that crosses the wire-format boundary, it emits it in pair with a composer: the adapter takes DataFetchingEnvironment (or the wire-shape input) and produces typed values; the composer takes those typed values and does the actual work. The two share name and a table-anchor parameter, and the composer’s signature is exactly the shape the adapter yields after decoding. The boundary is the pair, not the adapter alone.
The worked example is the generated QueryConditions.<method>(Table, DataFetchingEnvironment) plus the user-written <X>Conditions.<method>(Table, …) it forwards to. The adapter decodes NodeId strings, walks input maps, and hands typed jOOQ values across the boundary; the composer takes those values (Row<N><T1, …, TN>, String, List<T>, …) and composes a Condition. Same name, same table-first arg, two halves of one boundary.
The smell to watch for is asymmetric typing across the pair: most often the adapter erasing type information the composer needs (e.g. an arity-erased RowN instead of the Row<N><T1, …, TN> the decoder actually produced). When that happens, the composer’s signature stops documenting the contract, and column-shape errors that should be compile failures become DSL-runtime surprises. The fix is to honour the type the decoder produces, not to widen the composer to absorb the loss. R79’s switch from RowN to Row<N><…> is the application of this principle: the adapter side of the boundary already had Record<N><T1, …, TN> from the typed instanceof pattern; the composer side just needed to type its argument the same way.
The principle generalises beyond QueryConditions: any (env, …) → typed-args adapter that the generator emits in front of a user-written or emitter-written composer should land with the same symmetric typing. Drift between the two is a smell that the adapter is hiding the boundary rather than crossing it.
Validator mirrors classifier invariants
Every classifier decision that implies a generator branch must fail at validate time if that branch is unimplemented. The validator reads the same dispatch sets the generator does, so an unsupported classification surfaces as a build-time error rather than a runtime UnsupportedOperationException. The dispatch state lives in TypeFetcherGenerator as a four-way disjoint partition over every GraphitronField sealed leaf: IMPLEMENTED_LEAVES (real fetcher arm), PROJECTED_LEAVES (emitted inline by TypeClassGenerator.$fields), NOT_DISPATCHED_LEAVES (cannot reach the fetcher switch), and STUBBED_VARIANTS.keySet() (stub-emitting variants). The partition is exhaustive and disjoint by construction; GeneratorCoverageTest.everyGraphitronFieldLeafHasAKnownDispatchStatus enforces both properties. This closes the gap between "the schema classifies cleanly" and "the emitter has an arm for this leaf". ValidateMojo consumes the stubbed-variant set and fails the build by default.
The rule extends beyond stubbed variants: when a classifier introduces a new invariant (e.g. “@asConnection` not allowed on inline `TableField”), the validator should reject it by the same mechanism the generator relies on ; no generator-side invariant goes unchecked at validate time. This keeps "problems caught at build time" honest and the generator’s builder-invariant assumptions emitter-side safe.
Classifier guarantees shape emitter assumptions
The rule above flows in one direction: a classifier rejection becomes a build-time error via the validator. The reverse direction also matters. A classifier acceptance can let an emitter assume narrower shapes, so the emitted code reads as tight as if it were hand-written: no defensive casts, no wildcard locals, no instanceof guards. The principle anchors on three layers:
-
Type-system narrowing at the producer. The narrowness the consumer needs lives in the producer’s signature ; a record component, a return type, a sealed sub-variant. Once carried in the type, the contract is mechanically enforced and the consumer compiles unchanged.
-
Pipeline-tier tests. SDL → classified model → generated
TypeSpeccoverage pins the end-to-end shape; a regression that breaks the narrowed contract trips the pipeline test before the cross-module compile. -
The
graphitron-sakila-examplecompile as cross-module backstop.mvn compile -pl :graphitron-sakila-example -Plocal-dbagainst a real jOOQ catalog catches any classifier/emitter mismatch during the build, before any code reaches a consumer.
Compared with defensive runtime casts (which can throw ClassCastException on a real request, days after the build passed) or var-typed locals fed into parameterised entry points (which abandon the strict-shape guarantee entirely), the type-system-narrowed shape is the safest expression of the contract.
Two worked examples illustrate the principle and the candidate type-system lifts that would carry each contract structurally:
-
@tableMethodroot fetcher.ServiceCatalog.reflectTableMethodrejects developer methods whose return type is wider than the generated jOOQ table class.TypeFetcherGenerator.buildQueryTableMethodFetcherdeclares<SpecificTable> table = Method.x(…)with no cast, and feeds the local directly into<SpecificTable>Type.$fields(…)which expects exactly that type. The candidate type-system lift is multi-record type-token threading: parameteriseMethodRef.StaticOnlyandReturnTypeRef.TableBoundReturnTypeon a shared type token, then thread it through every site that constructs or reads either. The lift’s blast radius is structural; jOOQ helper boundaries also accept type erasure per § "Selection-aware queries", capping how far the bound carries. Pre-lift, the pipeline tests plus the sakila-example compile pin the shape; post-lift, the signature carries the contract. -
ColumnFieldparent table. The classifier produces aColumnFieldonly on a table-backed parent. The candidate type-system lift is a single-record addition: add a non-nullparentTablerecord component toColumnFieldat construction, populated by the classifier. The lift eliminates the parameter currently threaded intoTypeFetcherGenerator.generateTypeSpecand theIllegalStateExceptionreachability arm in the switch.
When a contributor wants to record a producer-consumer linkage explicitly ; because the type-system lift isn’t viable for the key and the principle’s three anchors don’t visibly tie the two sites together ; the recommended mechanism is a javadoc {@link} from the consumer to the producer (and optionally back). {@link} is IDE-refactor-tracked (renaming the producer auto-updates the link), carries no prose burden, and reuses the doc-tool the codebase already has. No custom annotation, no audit infrastructure to maintain.
Rule: if you relax a producer’s check body, audit every emitter site that consumes the corresponding shape, in the same commit. The pipeline tests and the cross-module compile are the safety net.
Pipeline tests are the primary behavioural tier
Behaviour is asserted at the SDL → classified model → generated TypeSpec pipeline layer, not at the per-variant unit tier. Per-variant structural tests (method names, return types, which methods exist) are bookkeeping; the primary signal that a feature works is that a realistic SDL produces a realistic TypeSpec end-to-end through the classifier. New features earn a pipeline test first; unit tests cover structural invariants that pipeline coverage would make repetitive.
Complementary tiers layered above: compilation of graphitron-sakila-example against real jOOQ classes (type correctness); execution of the generated code against real PostgreSQL (behaviour correctness). Code-string assertions on generated method bodies are banned at every tier; they test implementation, not behaviour, and break on every refactor.
For canonical tier names, file locations, the decision rubric, and the @UnitTier / @PipelineTier / @CompilationTier / @ExecutionTier meta-annotations, see Test-tier guide.
Documentation names only live tests/code
Two failure modes share one principle: trusted documentation that the code does not mechanically pin.
The narrow failure mode: javadoc, plan prose, and README references that name a test, method, or class must name one that exists today. A javadoc comment saying "enforced by `GeneratorCoverageTest.everyGraphitronFieldLeafHasAKnownDispatchStatus`" when that method does not exist is worse than no comment ; it’s a false invariant that readers trust. When a plan’s wording anticipates a method, class, or test that the same plan will create, phrase it as "C3 adds `X`" rather than "as asserted by `X`".
The broader failure mode: invariant claims that no live test, type, or assertion pins. A javadoc, annotation description, or doc paragraph saying "the producer rejects X so the emitter may assume Y" is exactly the same false-invariant family the narrow form names ; the symbols referenced may exist today, but if X gets silently relaxed (a ClassName.equals widened to startsWith, an instanceof arm broadened), the claim is silently false and readers still trust it. The fix is the same: pin the invariant in the type system or in a test that fails when the invariant fails, and let the documentation describe what’s pinned rather than make a claim of its own.
Reviewers check this explicitly during Draft → Approved and Pending Review → Done transitions; spot-check both forms.
Compilation against real jOOQ is a test tier
mvn compile -pl :graphitron-sakila-example -Plocal-db against a real jOOQ catalog is the primary check that generated emission is type-correct. Unit tests assert structure; pipeline tests assert SDL → TypeSpec shape; compilation catches "the Field<Record4<Int,Str,Int,Str>> parameter doesn’t line up with the emitted DSL call" without a hand-written assertion. Every generator change must pass -Plocal-db compile before merging.
The complementary tier above it, execution against a real PostgreSQL via the same fixture database, is the behaviour check. Together, compile + execute replace the body-content assertions that the "generation-thinking" principle bans.
See Test-tier guide for build commands and guidance on choosing between tiers.
Generator Java version vs. generated output Java version
Graphitron is a code generator. The Java version used to build the generator is independent of the Java version of the source it emits.
-
Generator implementation (everything under
graphitron-rewrite/) may freely use Java 25 features ; sealed classes, pattern matching, records, switch expressions, switch patterns, text blocks, scoped values, and so on. The parent pom’srequireJavaVersionenforcer pins the minimum to 25. -
Generated source files must target Java 17. Consumers compile Graphitron’s output with their own toolchain, which may be Java 17. Generator authors are responsible for ensuring that any syntax emitted into generated files is valid Java 17 ; no switch patterns, no sequenced collections API, nothing that requires 21+.
graphitron-sakila-examplecompiles with<release>17</release>to verify this.
The practical implication: when adding code to a generator, distinguish between code in the generator (unrestricted) and code emitted by the generator (Java 17).
Rewrite builds independently of legacy Graphitron modules
graphitron-rewrite/pom.xml is a self-contained Maven aggregator. mvn install -f graphitron-rewrite/pom.xml on a clean local repo builds the rewrite modules (graphitron-javapoet, graphitron, graphitron-fixtures-codegen, graphitron-sakila-db, graphitron-sakila-service, graphitron-maven-plugin, graphitron-sakila-example, graphitron-lsp, roadmap-tool) without resolving any legacy artifact (graphitron-common, graphitron-java-codegen, graphitron-maven-plugin, graphitron-schema-transform). The rewrite tree publishes its own graphitron-javapoet at 10-SNAPSHOT, distinct from the legacy 9-SNAPSHOT coord.
The invariant is enforced by graphitron-rewrite/scripts/verify-standalone-build.sh, which runs the aggregator against a fresh empty local repo and greps the resulting repo for forbidden legacy coords. Any future change that pulls a legacy dep back into the rewrite tree fails this check.
Rationale: rewrite is the successor; consumers migrating off the legacy generator should be able to depend on the rewrite aggregator alone. The rewrite-owned Maven plugin (graphitron-maven-plugin) is the entry point; the legacy plugin and schema-transform module remain available for consumers who haven’t migrated yet, but rewrite code does not import from them.
Emitter Conventions
Return types
DataFetchers return Result<Record> ; no DTOs, no TypeMappers. GraphQL-Java traverses records using the registered field DataFetchers. Exception: Connection fields return ConnectionResult, a generated carrier wrapping Result<Record> + pagination context.
Selection-aware queries
DataFetchingFieldSelectionSet and SelectedField are threaded through all table method signatures, structurally committing to selection-aware queries:
-
Top-level queries: call
Type.$fields(sel, table, env)for the column list, thendsl.select(fields).from(table)… -
Inline nesting: use jOOQ
multiset(select(columns).from(CHILD).where(…)).as("alias")returningField<?>(type-erased). Use type erasure at every helper method boundary ; jOOQ generic types compound badly with nesting depth, causing slow compile times. -
@splitQuery: separate DataLoader; parent fetches FK/PK columns, child batches by those keys.
Selection-driven queries produce different SQL per request, preventing cached query-plan reuse. This is an acceptable trade-off for wide tables with large optional columns; for narrow tables (≤ 10 columns) where most fields are always requested, TABLE.* is simpler and the dynamic-column overhead exceeds the benefit.
Error quality
BuildContext.candidateHint(attempt, candidates) sorts candidates by Levenshtein distance. The Levenshtein-suggestion contract has consolidated onto BuildContext and Rejection (the rejection-construction sites), with classifier-side callers thinning out as rejections are produced through the typed sealed-result path. Today: 17 occurrences across five files ; 7 in BuildContext, 3 in TypeBuilder, 3 in Rejection, 2 in FieldBuilder, 2 in EnumMappingResolver. When adding new jOOQ existence checks in the validator or builder, follow the same pattern ; pass the relevant candidate list from JooqCatalog to candidateHint, or produce the rejection through Rejection.unknownName(…) so the candidate list rides on the typed result.
Column value binding: DSL.val(rawValue, col.getDataType())
When emitting code that binds a raw GraphQL input value (from an input map or env.getArgument(…))
to a specific jOOQ column, always use the two-argument form:
DSL.val(rawValue, table.COL.getDataType())
Do not use the one-argument form with a Java-side cast (DSL.val((JavaType) rawValue)):
-
GraphQL-Java delivers enum values as
String; a Java cast to the jOOQ enum class throwsClassCastExceptionat runtime. -
GraphQL-Java delivers
IDscalars asString; a cast toLong(or any numeric PK type) also throws. -
The one-argument form ignores the column’s registered jOOQ
Converterentirely.
The two-argument form hands rawValue to the column’s DataType and its registered Converter
at bind time. No SQL CAST is rendered; the coercion is purely Java-side, inside jOOQ.
CallSiteExtraction solves a different problem. Direct, EnumValueOf, TextMapLookup,
and JooqConvert exist to produce a typed Java value for a condition/ordering method parameter
; code paths where a developer-written method expects the column’s Java type, not a jOOQ Field<T>.
For inline jOOQ DSL expressions (INSERT values(…), UPDATE set(…), DELETE/UPDATE where(…)
predicates), DSL.val(rawValue, col.getDataType()) does the coercion inside jOOQ without any
Java-side step, and no CallSiteExtraction switch is needed.
Precedent: LookupValuesJoinEmitter.addRowBuildingCore (search for DSL.val with two arguments).
For DTO-parent batching ; where the parent’s backing class is a plain POJO or Java record without a
jOOQ FK to the child’s @table ; see DTO-parent batching below; the lifter
contract is how the schema author hands the framework a typed key when the catalog can’t supply one.
DTO-parent batching
When a @record parent’s backing class is not a jOOQ TableRecord, the catalog cannot supply the
FK columns the column-keyed DataLoader path needs to batch a child @table field. The @sourceRow
directive closes that gap: the schema author supplies a static Java method that lifts a RowN<…>
batch key out of the parent DTO, plus the targetColumns (column names on the child table) the
key positions match. The classifier reflects on the lifter once at build time, validates the per-position
column-class match, and produces a SourceKey whose Reader is SourceRowsCall(lifter) and whose
Wrap is Row, carrying a JoinStep.LiftedHop (target table + slot list, single-hop by construction)
in the path and a LifterRef (declaring class + method name) on the reader. The lifted slots are
JoinSlot.LifterSlot permits, each folding source-side and target-side onto a single ColumnRef by
construction (DataLoader key tuple IS target-column tuple as a type fact, not a prose precondition).
The emitter feeds that into the existing SplitRowsMethodEmitter.buildListMethod path with no
identity branching: target accessors come from WithTarget.slots(), key extraction from the lifter
call. The lifter is the single place where the DTO → key mapping lives; if a schema author needs
a different key, they author a new lifter method, not a new emitter arm.
Helper-locality
Emitted helper methods that bind column references to a specific aliased jOOQ Table instance always take the Table as a parameter ; never declare it locally. Callers from different paths (root fetcher, inline subquery, Split-rows method) need to pass distinct aliases for the same target table; a locally-declared Table forces the wrong alias on every caller but the one the helper was first written for.
Pattern (canonical example, <fieldName>OrderBy emitted by TypeFetcherGenerator.buildOrderByHelperMethod):
private static OrderByResult <fieldName>OrderBy(DataFetchingEnvironment env, <Table> table) { ... }
Each call site supplies the alias appropriate to its scope: the root fetcher passes its declared <entity>Table; a Split-rows method passes its FK-chain terminal alias; an inline subquery passes its correlated alias. The helper’s column references resolve through the parameter.
Compliant emitters (audit 2026-04-25): TypeFetcherGenerator.buildOrderByHelperMethod, QueryConditionsGenerator, LookupValuesJoinEmitter (root + child forms), InlineLookupTableFieldEmitter. ConnectionHelperClassGenerator.pageRequest / encodeCursor / decodeCursor are not Table-bound (operate on Field<?> / SortField<?>) and the rule does not apply.