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 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

  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.

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.