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.classifyArguments returns List<ArgumentRef> (sealed, three top-level arms plus two intermediate sealed sub-groupers): ScalarArg.{ColumnBackedArg | ColumnBackedReferenceArg | UnboundArg}, InputTypeArg.{TableInputArg | PlainInputArg}, plus the top-level OrderByArg, PaginationArgRef, UnclassifiedArg. The column-backed scalar arms carry one or more columns (arity is a column count read off isComposite(), 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.

  • contextArguments flow through ServiceCatalog.reflectTableMethod into trailing ParamSource.Context parameters 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 the LookupRows input-rows helper.

  • the sealed LookupResolution (None | Keyed) carried total on the table-read leaves pairs with @lookupKey.

  • TypeBuilder.buildInputType classifies 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 (InputFieldResolver on the filter path, TypeBuilder.resolveInputFields on the arg-level @lookupKey path), so an input reused across consumers resolves per-consumer. The override axis is threaded field-relative there too, as classifyArgument’s `enclosingOverride.

  • TableInputArg is 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 is GraphQLDirectiveContainer-generic so GraphQLInputObjectField works 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; see withListedInputConditions fixture: customerString(table, input.getId())).

  • On reflection failure, the error is appended and Optional.empty() is returned, mirroring the buildArgCondition error 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

  1. Override is threaded field-relative, not gated whole-type. There is no whole-type routing gate: override is a per-field validation modifier ("the consumer owns this predicate, skip column-coverage on it"), threaded to the call site as classifyArgument’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 @condition override flag. The per-call-site classifier handles input-field column resolution directly against the consuming field’s table with the existing catalog.findColumn + @field(name:) path. A field whose own @condition(override: true) owns the predicate classifies as InputField.ConditionOwnedField whether or not a column also resolves; a genuine column-miss without one is InputField.UnboundField, admitted at consumption under an enclosing override cascade.

  2. GraphitronSchemaValidator. No new structural validation: graphql-java enforces on INPUT_FIELD_DEFINITION placement at schema-parse time. Reflection errors surface through the per-call-site classifier’s errors list (the same UnclassifiedArg fallback 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 ExecuteListener capturing 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

  • readConditionDirective home: BuildContext. Rejected alternatives: a new ConditionDirectives utility; keeping it in FieldBuilder and duplicating a minimal copy in TypeBuilder. BuildContext already houses DIR_CONDITION, ARG_OVERRIDE, argBoolean, and argStringList; co-locating directive-parsing helpers there is consistent and every caller already has a ctx handle.

  • Projection access to InputField list: carried on the argument record, not looked up from a registry. TableInputArg and PlainInputArg each hold List<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 of List<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 NestingField conditions: single arg named after the SDL field. Matches the reflection shape ServiceCatalog.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 through ArgConditionRef without 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, return Optional.empty(), leave the rest of the field classifying cleanly. Promoting the whole input type to UnclassifiedType was 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.

  • ArgCallEmitter shape for nested input-field extraction: new sealed variant CallSiteExtraction.NestedInputField(outerArgName, path). Rejected alternatives: (B) an optional outerArgPath field on CallParam that every extraction variant checks (couples every variant to the nested case); © projection pre-lifts a Object <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 with NestingField chains). The sealed hierarchy is already the right place for extraction-shape variations (Direct, EnumValueOf, ContextArg, JooqConvert); NestedInputField fits the same pattern, makes the nested case explicit at every emitter switch, and composes cleanly with NestingField recursion in projection.

Out of Scope

  • Mutations. Input-type arguments for DML use a different mapping. Mutations get their own plan.

  • NodeIdField with @condition. Node-id input fields decode through NodeIdStrategy rather than direct column binding, so input-field-level @condition would compose with the encoded-id path differently than with plain column fields. Promote to its own backlog item if a real schema surfaces this.