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 Table<?> + arg-value parameters. 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.
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.