Reference document for how the rewrite classifies GraphQL arguments and
projects them into filters, lookup mappings, order-by specs, and pagination
specs. Covers @condition at all three legal positions
(FIELD_DEFINITION, ARGUMENT_DEFINITION, INPUT_FIELD_DEFINITION) and the
override-propagation semantics that tie them together.
Pipeline shape
-
FieldBuilder.classifyArgumentsreturnsList<ArgumentRef>(sealed, three top-level arms plus two intermediate sealed sub-groupers):ScalarArg.{ColumnBackedArg | ColumnBackedReferenceArg | UnboundArg},InputTypeArg.{TableInputArg | PlainInputArg}, plus the top-levelOrderByArg,PaginationArgRef,UnclassifiedArg. The column-backed scalar arms carry one or more columns (arity is a column count read offisComposite(), not a leaf dimension) with local or FK-resolved bindings; the sealed sub-groupers let projections pattern-switch on shape axis without enumerating leaves. -
Projection helpers consume that list:
projectFilters,projectOrderBySpec,projectPaginationSpec,projectForLookup. -
contextArgumentsflow throughServiceCatalog.reflectTableMethodinto trailingParamSource.Contextparameters on the generated method calls. -
ArgConditionRef(ConditionFilter filter, boolean override)carries the reusable "condition + override flag" pair at every level (field, arg, input-field). -
TableInputArg.fieldBindings: List<InputColumnBinding>carries the@lookupKey-only bindings; composite-key lookups are wired end-to-end via theLookupRowsinput-rows helper. -
the sealed
LookupResolution(None|Keyed) carried total on the table-read leaves pairs with@lookupKey. -
TypeBuilder.buildInputTypeclassifies every input on the plain path, including one carrying@table(a deprecated directive location, accepted and ignored; a post-classification pass emits the per-usage advisory carrying the per-verb migration guidance). An input carries no table of its own; its fields resolve against the consuming field’s target table at the call site (InputFieldResolveron the filter path,TypeBuilder.resolveInputFieldson the arg-level@lookupKeypath), so an input reused across consumers resolves per-consumer. Theoverrideaxis is threaded field-relative there too, asclassifyArgument’s `enclosingOverride. -
TableInputArgis the carrier for an input argument whose fields resolved to a concrete table; it is built field-relatively (from the consuming field’s table), never from a table declared on the input type.
Scope
@condition is legal at three positions per directives.graphqls:
FIELD_DEFINITION, ARGUMENT_DEFINITION, INPUT_FIELD_DEFINITION. Each
@condition-carrying field inside an input type contributes its own
predicate when the input is used at a call site. Nested input-field
conditions compose. Outer-level overrides propagate downward.
The input-field position applies to every input type: an input’s fields
resolve against the enclosing consuming field’s target table, per call
site. A divergence-scan of alf’s production schema
(alf/graphitron-rewrite:graphitron-rewrite/generator-schema.graphql,
not committed to trunk) counted 62 inputs carrying inner
@condition, 3 of them under an outer field-level
@condition(override: true) (Query.emner, Query.emnerV2,
Query.studenter), alongside 63 call sites relying on implicit column
conditions instead: an un-annotated
ColumnBackedField / ColumnBackedReferenceField contributes
a BodyParam with NestedInputField extraction to the same
GeneratedConditionFilter the explicit conditions land in.
Design
Data model
Three InputField variants
(graphitron/…/model/InputField.java) carry
Optional<ArgConditionRef> condition:
-
InputField.ColumnBackedField -
InputField.ColumnBackedReferenceField -
InputField.NestingField
The variants are source-agnostic: input fields classify at
argument-classify time against the enclosing consuming field’s target
table, whichever path (filter, arg-level @lookupKey, DML write target)
supplied that table. The variant doesn’t need to know which path produced
it; the carrying argument record (TableInputArg or PlainInputArg)
remembers that.
NodeIdField is intentionally excluded; see Out of Scope. ArgConditionRef
is reused verbatim; its override flag is the input-field-level override
(matching legacy semantics: override: true on an input field replaces that
field’s implicit condition with the explicit method).
Classification: per call site, against the consumer’s table
Input fields classify per call site, because the resolution table is a fact of the consuming field, not of the input type: the same input used at N call sites classifies N times, once per resolved table. Classification is cheap; reclassification is simpler than caching, and a per-site cache would complicate invalidation without a measured need. A field that resolves on no column of a consumer’s table is a classify-time rejection naming that consumer’s table.
Shared per-field classifier. BuildContext.classifyInputField(field,
parentTypeName, tableRef, expandingTypes, errors) → InputFieldResolution
hosts the column / @reference / nesting decision tree. Every caller
supplies the call site’s resolved table: the filter path passes the query
field’s return-type table (rt), the DML paths pass the write-target
table. The shared classifier means NestingField semantics stay identical
across all resolution paths: a nested input resolves against the same
table as its parent via the existing recursive call.
Condition helper. BuildContext.buildInputFieldCondition(GraphQLInputObjectField
field, String inputFieldName, List<String> errors) → Optional<ArgConditionRef>
mirrors FieldBuilder.buildArgCondition:
-
Directive parsing is delegated to
BuildContext.readConditionDirective, which isGraphQLDirectiveContainer-generic soGraphQLInputObjectFieldworks without modification. -
Reflection via
ServiceCatalog.reflectTableMethod(className, method, Set.of(inputFieldName), Set.copyOf(contextArguments)). The method’s primary argument is the single input-field value, named after the SDL field name (matches legacy; seewithListedInputConditionsfixture:customerString(table, input.getId())). -
On reflection failure, the error is appended and
Optional.empty()is returned, mirroring thebuildArgConditionerror contract.
The helper is agnostic to which path supplied the resolution table; every
caller uses the same shape. classifyInputField, buildInputFieldCondition,
and readConditionDirective all live in BuildContext; callers in
TypeBuilder and FieldBuilder reach them via ctx.
Projection: threading conditions to the call site
FieldBuilder.projectFilters handles outer-arg-level @condition on both
TableInputArg and PlainInputArg and then walks each input’s classified
InputField records via walkInputFieldConditions, appending every
present condition. The walking logic is identical across both carriers;
differences live only in the carrying record.
Both variants carry a classified field list. TableInputArg and
PlainInputArg each carry List<InputField> fields populated at
classify time. TableInputArg.fieldBindings is @lookupKey-only and
insufficient on its own, since condition-carrying fields aren’t
necessarily lookup keys.
The alternative was to read the field list out of a registry at projection
time. Rejected: re-couples projection to builder context, breaks the
invariant that projection is a pure function of List<ArgumentRef> (no
builder state, no registry lookups). And since input fields classify per
call site, there is no whole-type registry entry to read from anyway, so
the carry-on-the-record shape is the only coherent option.
Override propagation
Three directive levels can co-exist at one call site:
-
Field:
fieldDef @condition -
Argument:
arg @condition -
Input field:
inputField @condition
Nesting adds a fourth tier: an input type contains an input field whose type is
itself another input type, which has its own fields. Each nested level can
carry its own @condition.
Propagation rule (downward inheritance). override: true at any enclosing
level (parent-field ⊇ arg ⊇ nesting-field) suppresses every nested implicit
condition (jOOQ table.COLUMN.eq(input.getField())). Explicit @condition
methods are never suppressed by ancestor overrides; they’re independent
declarations by the schema author, and a level’s own override flag affects
only that level’s implicit condition.
This rule is not the one the retired graphitron-parent generator implemented, and the
divergence was deliberate. What that generator did, and why the rewrite departs from it, is
Argument resolution: the legacy divergence.
Truth table (per input-field, per call site)
"Any enclosing override" = parent-field-level OR arg-level OR any
intermediate nesting-field’s override: true.
| Any enclosing override | Input field @condition |
Implicit condition | Explicit method |
|---|---|---|---|
No |
Absent |
Emitted |
n/a |
No |
Present (no override) |
Emitted |
Emitted |
No |
Present (override:true) |
Suppressed |
Emitted |
Yes |
Absent |
Suppressed |
n/a |
Yes |
Present (no override) |
Suppressed |
Emitted |
Yes |
Present (override:true) |
Suppressed |
Emitted |
Enforced by the symmetric-implicit-predicate-emission pipeline test
(plainInput_resolvedColumnWithoutCondition_emitsImplicitBodyParam),
which pins implicit-condition emission for consumer-resolved input fields.
"Emitted" in the explicit-method column means the method call lands in the
List<WhereFilter> returned by projectFilters; downstream emitters AND
all present filters together (see §Emission). The earlier column label
"Replaces" was inherited from column-arg vocabulary and is misleading here,
since rows 5-6 have no implicit condition left to replace.
Six rows, not nine: the previous draft’s "outer override: false`" row is
indistinguishable from "outer absent" since `false is the directive default.
Confirmed against BuildContext.argBoolean (which defaults ARG_OVERRIDE to
false) and the SDL declaration in directives.graphqls (override: Boolean
= false).
Emission: no new emitters
projectFilters output is List<WhereFilter>; each ConditionFilter is
already a callable reference carrying a table slot + arg-value parameters (the
slot’s declared type is the author’s, Table<?> or the coordinate’s own
generated class; the emitter substitutes a coordinate-typed expression). The
downstream emitters (the lookup rows core LookupRows,
SplitRowsMethodEmitter) already AND-in each ConditionFilter without
knowing its provenance. Input-field conditions land alongside field-level
and arg-level conditions in the same filter list.
List-typed inputs (composite-key lookups). The LookupRows input-rows helper emits
VALUES+JOIN rows; per-row condition evaluation already reads fields via
input.get(i).get<FieldName>(). Input-field conditions piggyback on the same
loop; projection just hands them as additional filters. Verify round-trip
count with an execution test (see §Test strategy).
Nested input types. InputField.NestingField resolves its
own fields against the parent’s table. A condition on the nesting field is
reflected with the nesting field’s SDL name as the sole arg (same shape as a
scalar input field’s condition); projection walks NestingField.fields
recursively to pick up inner conditions, threading a boolean enclosingOverride
accumulator: any level’s override: true flips it to true for all
descendants. No new emitter shape.
Validator
-
Override is threaded field-relative, not gated whole-type. There is no whole-type routing gate:
overrideis a per-field validation modifier ("the consumer owns this predicate, skip column-coverage on it"), threaded to the call site asclassifyArgument’s `enclosingOverride(the enclosing field- or argument-level@condition(override: true), ORed with the consuming argument’s own). Every classified input field carries its own@conditionoverride flag. The per-call-site classifier handles input-field column resolution directly against the consuming field’s table with the existingcatalog.findColumn+@field(name:)path. A field whose own@condition(override: true)owns the predicate classifies asInputField.ConditionOwnedFieldwhether or not a column also resolves; a genuine column-miss without one isInputField.UnboundField, admitted at consumption under an enclosing override cascade. -
GraphitronSchemaValidator. No new structural validation: graphql-java enforceson INPUT_FIELD_DEFINITIONplacement at schema-parse time. Reflection errors surface through the per-call-site classifier’serrorslist (the sameUnclassifiedArgfallback already used for other classify-time failures).
Runtime: nested input-field arg extraction
When a @condition method sits on an input field, the runtime values
passed to it are not reachable as top-level arguments. The
CallSiteExtraction.NestedInputField(String outerArgName, List<String> path)
variant records the path from the outer argument down to the leaf value.
FieldBuilder.walkInputFieldConditions threads (outerArgName, pathPrefix)
through the recursion; when a condition is found, rewrapForNested
replaces each ParamSource.Arg param’s extraction with
NestedInputField(outerArgName, prefix + [fieldName]).
At code-gen time, ArgCallEmitter.buildArgExtraction turns that into a
null-safe nested instanceof Map<?, ?> ternary chain that traverses from
the top-level argument Map down to the leaf value. The chain short-circuits
to null at any level whose value is absent or is not a Map, so a
@condition method always receives either the concrete leaf value or
null; reflecting it with a Map or a wrong-shaped value is not possible.
WireMapChain.of is the single home for that descent, and it returns a
primary expression: the chain is parenthesised at the of boundary, so a
caller may splice it into any operand slot without knowing the precedence of
the operator it lands in. That guarantee is what the descent’s consumers rely
on rather than each wrapping for itself. The generated DML decode locals put
it in an instanceof pattern test and the value-agreement preamble in a
== null comparison, both of which bind tighter than ?:; an unparenthesised
chain reparses there with its own : null arm bound to the operator, which
javac rejects at the consumer. TypeFetcherGenerator.nestedContainsKeyExpr
carries the same guarantee for the presence test, whose deeper arm is an &&
chain. The wrap sits at the boundary rather than inside the recursion so that
no arm added later can drop it; one pair suffices at any path depth, because
the recursive arms already parenthesise their own result and descend into
binding.get(key), so exactly one bare conditional expression ever escapes.
Compile-tier coverage is the endorsementNestedOverlap mutation family in
graphitron-sakila-example, which reaches both operand slots through a nested
@nodeId leaf on a generated write.
Test assertions
Follows docs/architecture/principles/development-principles.adoc: no body-string assertions on
emitted method bodies. Execution tests assert, for each case:
-
JDBC round-trip count matches expectation (catches spurious extra queries).
-
Returned row IDs match the hand-authored expected set.
-
WHERE-clause shape via a jOOQ
ExecuteListenercapturing the generated SQL: compare structural tokens (column references, operator positions, AND/OR tree shape), not literal strings.
Pipeline tests (GraphitronSchemaBuilderTest) assert on the classifier
output directly (List<InputField>, List<WhereFilter>), not on emitted
code.
Design decisions & rationale
-
readConditionDirectivehome:BuildContext. Rejected alternatives: a newConditionDirectivesutility; keeping it inFieldBuilderand duplicating a minimal copy inTypeBuilder.BuildContextalready housesDIR_CONDITION,ARG_OVERRIDE,argBoolean, andargStringList; co-locating directive-parsing helpers there is consistent and every caller already has actxhandle. -
Projection access to
InputFieldlist: carried on the argument record, not looked up from a registry.TableInputArgandPlainInputArgeach holdList<InputField> fields, populated at classify time. Registry lookup at projection time was rejected: it re-couples projection to builder context and breaks the invariant that projection is a pure function ofList<ArgumentRef>. There is no whole-type registry entry anyway (input fields classify per call site against the consuming field’s table), so the carry-on-record shape is the only coherent one. -
Condition-method signature for
NestingFieldconditions: single arg named after the SDL field. Matches the reflection shapeServiceCatalog.reflectTableMethod(className, method, Set.of(fieldName), …)already used by scalar input-field conditions. Per-leaf parameterization was rejected as speculative: no legacy fixture or alf call site requires it, and it would change the reflection key from a single field name to an ordered tuple that does not round-trip throughArgConditionRefwithout schema changes. If a method needs inner values, it traverses the passed object. -
Reflection-failure behaviour: per-arg, not per-type. Input-field condition reflection mirrors
buildArgCondition: append the error, returnOptional.empty(), leave the rest of the field classifying cleanly. Promoting the whole input type toUnclassifiedTypewas rejected on blast-radius grounds: a reflection failure is a caller-fixable error, not a schema-structural one, so it should not invalidate the input type’s other fields. -
ArgCallEmittershape for nested input-field extraction: new sealed variantCallSiteExtraction.NestedInputField(outerArgName, path). Rejected alternatives: (B) an optionalouterArgPathfield onCallParamthat every extraction variant checks (couples every variant to the nested case); © projection pre-lifts aObject <slot> = env.getArgument(outerArg) instanceof Map m ? m.get(field) : null;local at the top of the fetcher body and references it (complicates projection with a new emission slot and doesn’t compose withNestingFieldchains). The sealed hierarchy is already the right place for extraction-shape variations (Direct,EnumValueOf,ContextArg,JooqConvert);NestedInputFieldfits the same pattern, makes the nested case explicit at every emitter switch, and composes cleanly withNestingFieldrecursion in projection.
Out of Scope
-
Mutations. Input-type arguments for DML use a different mapping. Mutations get their own plan.
-
NodeIdFieldwith@condition. Node-id input fields decode throughNodeIdStrategyrather than direct column binding, so input-field-level@conditionwould compose with the encoded-id path differently than with plain column fields. Promote to its own backlog item if a real schema surfaces this.