Conventions for the code the generator emits; the reference companion to the "Generated code is a consumer artifact" axiom in Graphitron Development Principles.
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.$project(sel.getFieldsGroupedByResultKey(), 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. 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. Its value-coercion strategies 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: LookupRows.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.
Column-to-column comparison: ColumnComparison
The companion rule to the one above, on the other axis. Where DSL.val(rawValue,
col.getDataType()) covers "bind a value against a column", every comparison of two catalog
columns goes through no.sikt.graphitron.render.ColumnComparison and none is spelled inline.
The reason is that the two columns can disagree on their Java type while agreeing on their SQL
type. A jOOQ Converter is a client-side type mapping only: it changes what Field.getType()
reports and cannot change the column’s SQL type. A consumer whose codegen selects a <forcedType>
by column path rather than by type therefore lands the converter on one end of a foreign key and
not the other, and the two ends diverge: one stays Field<Short> while the other becomes
Field<String>. a.eq(b) then does not compile, and the whole generated module fails with it,
even though a = b was always a valid, index-usable predicate.
ColumnComparison reconciles that with coerce, which reinterprets a field’s Java type and leaves
the rendered SQL alone. Reach for it, never for cast: cast puts a real SQL CAST around the
column and costs the index on it.
| Entry point | When the right operand is |
|---|---|
|
another aliased table column. Overloaded for the walk’s |
|
some other |
|
a bare Java value, so there is nothing for |
Two properties are worth knowing before touching a call site. A pair whose types agree, or either
of whose types is unknown, emits left.eq(right) unchanged, so no approved generated output moves
for a schema that compiles today. And the argument is coerced onto the receiver in both directions:
neither "raw" nor "referenced" is recoverable from a TypeName, and since neither operand is a
bind value the direction is invisible in the emitted SQL, so the rule is chosen for mechanical
reviewability rather than for semantics.
A .onKey(Keys.<CONSTANT>) join needs none of this: it names a catalog constant and is type-blind
by construction. So is any comparison whose two operands take their type from the same
ColumnRef, which is most of the generator’s eq sites and the reason the mint has as few callers
as it does.
DTO-parent batching
When a record-backed parent’s backing class (reflected from its producer) 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
KeyLift.Lifter(LifterRef) verdict, the LifterRef carrying the declaring class + method name.
The residue SourceKey derives its Wrap.Row from that lift arm, and the correlation is a
ParentCorrelation.OnLiftedSlots (target table + column tuple, single-hop by construction). Each
lifted slot folds 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 ParentCorrelation.OnLiftedSlots.columns(), 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.
The rule does not apply to helpers that are not Table-bound (e.g. cursor encode/decode helpers
operating on Field<?> / SortField<?>).
Cursor encode/decode
The emitter companion to the "Model metadata over parallel type systems" principle: each cursor
column’s jOOQ DataType is already known, so cursor round-tripping goes through the column
metadata rather than a hand-rolled type-tag system (i:, s:, l:).
-
Encode/decode a seek value:
field.getDataType().convert(rawValue); theDataTypeand its registeredConverterdo the coercion, exactly as with `DSL.val’s two-argument form. -
The no-seek case:
DSL.noField(field)where a column position must be present but no seek value applies, rather than a null placeholder or an omitted column that would misalign the tuple.
OrderByResult (pairing List<SortField<?>> with List<Field<?>>) is the carrier these operate
over.