ID |
|
|---|---|
Status |
Spec |
Created |
2026-05-19 |
Updated |
2026-05-19 |
Centralize ResultType column-read emission for @record parents
Every emitter that needs to "read a named column off the parent record"
reconstructs its own switch over ResultType and emits subtly different
code per arm: JooqTableRecordType → .get(Tables.X.COL),
JooqRecordType → .get(sqlName), JavaRecordType → .camelCase(),
PojoResultType.Backed → .getCamelCase(). The same predicate (which
kind of result parent? which Java syntax reads a column?) is evaluated
at every site, and each site picks its own subset of arms:
FetcherEmitter.propertyOrRecordValue collapses two arms with ||,
GeneratorUtils.buildFkRowKey keeps all four,
GeneratorUtils.backingClassOf throws on two of them.
The duplication is a drift hazard: when a fifth ResultType variant is
added (or a current arm’s emission shape changes), each site has to be
audited and patched independently, and the subset-selection asymmetry
makes it easy to miss one.
Audit findings
ResultType is a five-arm sealed interface (GraphitronType.java:91):
JooqTableRecordType, JooqRecordType, JavaRecordType,
PojoResultType.Backed, PojoResultType.NoBacking. Three predicates
sit on top of it today, asked at distinct sites:
-
"Emit a column read off the parent." The shared one.
FetcherEmitter.propertyOrRecordValue(FetcherEmitter.java:802) andGeneratorUtils.buildFkRowKey(GeneratorUtils.java:210). Five arms on the fetcher path (NoBacking falls back toPropertyDataFetcher.fetching(name)); four arms on the row-key path (NoBacking is rejected upstream byderiveFkRecordParentSourceat FieldBuilder.java:4184). The jOOQ arms vary by whether a table reference is in hand: with a table,.get(Tables.X.COL); without one,.get(sqlName). -
"Give me the parent’s backing class." A different question.
GeneratorUtils.backingClassOf(line 339) andSourceRowDirectiveResolver.parentBackingClass(line 450). Both accept onlyPojoResultType.Backed+JavaRecordType; jOOQ arms are rejected. R180 leaves these alone. -
"Does the parent admit a backing-class cast?" Single-site predicate (
BuildContext.java:580,target instanceof NoBackingexclusion). Not column-read; out of scope.
The per-site arm-set variation on the column-read predicate is meaningful policy, not incidental drift: NoBacking has no column to read, so the row-key site’s upstream rejection and the fetcher site’s property-name fallback are deliberate. R180 centralizes the emission shape; per-site guards stay where they are.
A second asymmetry the audit surfaces, deferred (see Non-goals):
propertyOrRecordValue consumes a pre-resolved
AccessorResolution.Resolved for the Java/Pojo arms (the builder
reflected on the backing class at classification time and picked the
right Method or Field). buildFkRowKey does not: it synthesizes
get<Camel>(sqlName) for Pojo.Backed and <camel>(sqlName) for
JavaRecordType, riding on the assumption that the backing class’s
accessor names follow that convention. The dispatcher API surfaces
this asymmetry as distinct arms so it is visible at the type level
rather than hidden inside two near-duplicate if chains.
Implementation
Introduce a sealed ColumnReadShape value type and a single dispatcher
that produces it from (ResultType parent, ColumnRef column, String
sqlName, AccessorResolution.Resolved accessor). Each callsite
exhaustively switches on the shape and wraps it in its own surrounding
syntactic context (DataFetcher factory vs. inline expression inside
DSL.row(…)).
New type and dispatcher
Add to no.sikt.graphitron.rewrite.generators (sibling of
FetcherEmitter and GeneratorUtils):
sealed interface ColumnReadShape {
record TableColumnRef(TableRef table, ColumnRef column)
implements ColumnReadShape {} // JooqTableRecord + table known
record ColumnByName(String sqlName)
implements ColumnReadShape {} // JooqRecord, or JooqTableRecord without table
record ResolvedAccessor(AccessorResolution.Resolved accessor, ClassName backingClass)
implements ColumnReadShape {} // pre-resolved Java/Pojo accessor
record SyntheticGetter(ClassName backingClass, String getterName)
implements ColumnReadShape {} // Pojo.Backed via "get<Camel>" convention
record SyntheticRecordAccessor(ClassName backingClass, String camelName)
implements ColumnReadShape {} // JavaRecord via "<camel>()" convention
record PropertyByName(String name)
implements ColumnReadShape {} // NoBacking (fetcher-only fallback)
}
final class RecordColumnReads {
static ColumnReadShape shapeOf(
GraphitronType.ResultType parent,
ColumnRef column, // nullable (column ref may be unresolved)
String sqlName, // always present
AccessorResolution.Resolved accessor // nullable; present on the fetcher path
) {
return switch (parent) {
case GraphitronType.JooqTableRecordType jtrt when column != null && jtrt.table() != null
-> new TableColumnRef(jtrt.table(), column);
case GraphitronType.JooqTableRecordType ignored -> new ColumnByName(sqlName);
case GraphitronType.JooqRecordType ignored -> new ColumnByName(sqlName);
case GraphitronType.JavaRecordType jrt -> accessor != null
? new ResolvedAccessor(accessor, ClassName.bestGuess(jrt.fqClassName()))
: new SyntheticRecordAccessor(ClassName.bestGuess(jrt.fqClassName()), toCamelCase(sqlName));
case GraphitronType.PojoResultType.Backed b -> accessor != null
? new ResolvedAccessor(accessor, ClassName.bestGuess(b.fqClassName()))
: new SyntheticGetter(ClassName.bestGuess(b.fqClassName()),
"get" + capitalize(toCamelCase(sqlName)));
case GraphitronType.PojoResultType.NoBacking ignored -> new PropertyByName(sqlName);
};
}
}
The switch is the only instanceof ResultType over the column-read
predicate after the lift. The exhaustiveness check is what keeps the
sites in lockstep when a sixth variant is added.
Callsite migration
-
FetcherEmitter.propertyOrRecordValue(FetcherEmitter.java:802) becomes a thin formatter: callshapeOf(resultType, column, columnName, accessor), switch on the result, wrap each shape into the existing fetcher-registration shape (ColumnFetcher<>(…)for the jOOQ arms,PropertyDataFetcher.fetching(…)forPropertyByName, lambda overenv.getSource()for the accessor arms). The accessor-arm formatter retains `methodCallExpr’s three injection forms (zero-arg, full-environment, per-argument) verbatim. -
GeneratorUtils.buildFkRowKey(GeneratorUtils.java:210) becomes: callshapeOf(resultType, col, col.sqlName(), null)per FK column, switch on the result rejectingPropertyByNamewith anIllegalStateException(NoBacking is excluded upstream, so this arm is structurally unreachable), and emit$T) env.getSource(.<expr>where<expr>is the per-shape access call.
Classifier-emitter narrowing contract
The buildFkRowKey callsite relies on the classifier guarantee that
PojoResultType.NoBacking never reaches this emission path
(deriveFkRecordParentSource rejects it at FieldBuilder.java:4184).
The dispatcher’s exhaustive switch admits PropertyByName; the
row-key formatter rejects it. The structural target is a type-system
lift on the PojoResultType carrier (drop the NoBacking arm at the
emit-path’s input, or thread a narrower sub-taxonomy that excludes it
so the rejection becomes a compile-time impossibility) rather than a
runtime guard; until that lift, the existing pipeline-tier tests pin
the producer-side rejection, and a javadoc {@link} from
buildFkRowKey’s rejection arm to `deriveFkRecordParentSource makes
the producer-consumer link navigable.
Helper hygiene
Place RecordColumnReads and ColumnReadShape in the existing
generators package next to their two consumers. The dispatcher is
package-private; the shape’s permits are package-private records.
Both formatters live as private static methods in their respective
emitter classes ; no new shared formatter class.
backingClassOf (GeneratorUtils.java:339) and
SourceRowDirectiveResolver.parentBackingClass are explicitly not
folded into the dispatcher. They answer a different question and the
audit above documents why; collapsing them into RecordColumnReads
would couple two unrelated predicates onto the same surface and force
column-read sites to handle a "no backing class" reject path they
already don’t need.
Tests
R180 is a refactor with no SDL-visible behaviour change. The signals that it landed cleanly:
-
Existing pipeline tier on
FetcherEmitterand `GeneratorUtils’s callsites continues to pass (carrier-field role coverage, FK splitQuery dispatch, record-parent key extraction). -
mvn -f graphitron-rewrite/pom.xml install -Plocal-dbstays green end-to-end (build-fixtures → test → compile-spec → execute-spec).
Per rewrite-design-principles.adoc, no per-dispatcher unit test
asserting CodeBlock string equality on .get(Tables.X.COL) etc.;
that pattern is banned at every tier. The pipeline tier already
verifies the four callsites' emission shapes by compiling generated
code against the sakila catalog.
Validator mirror
R180 introduces no new classifier branch (the dispatcher is a
mechanical lift of switches that already exist). The validator’s
existing coverage of @record parent shapes is unchanged; no new
validator rule is required.
Non-goals (follow-up Backlog items)
-
Carry resolved accessors through
SourceKey. TheSyntheticGetterandSyntheticRecordAccessorarms encode an unverified "name follows convention" assumption. The right fix is to pre-resolve a backing-class accessor handle at classification time (mirroring whatChildField.PropertyField.accessor()already does) and have the row-key path carry it throughSourceKey. R180 surfaces the asymmetry at the type level (two arms vs. one) so it is visible; a separate Backlog item should propose the lift. -
Collapse
backingClassOfandparentBackingClass. These are near-duplicates of each other but a different predicate from column-read. They are candidates for their own helper lift; out of scope here.
Roadmap entries
None to add; the follow-up items above will be filed as their own Backlog stubs after R180 lands.