@condition is the escape hatch for SQL predicates the catalog can’t infer: regex, ranges, date arithmetic, vendor-specific shapes, multi-column boolean expressions. By default the explicit predicate is AND-ed with the implicit column = ? predicates the rewrite would have emitted on its own; override: true flips that to "explicit only". The override: flag interacts with field-level vs argument-level placement, with @table-bound vs plain inputs, and with how nested input types pass values down. This recipe walks through the cascade end-to-end, names the input-carrier choices that make nested @condition flow correctly, and pins the rewrite’s intentional divergence from the legacy generator’s "outer owns everything" semantics.
For the directive’s signature, parameter list, and canonical examples, see @condition. This recipe assumes you’ve read it.
The default: stack, don’t replace
Without override: true, every @condition is additive. The rewrite folds the developer-supplied Condition into the WHERE clause alongside whatever it would otherwise emit:
type Query {
citiesByName(
cityNames: [String!] @field(name: "CITY")
@condition(condition: {className: "com.example.CityConditions", method: "cityMethod"})
): [City]
}
Two predicates make it into SQL: the implicit CITY IN (?, ?, …) from @field(name: "CITY") plus a list-typed argument, and the explicit cityMethod(table, cityNames) predicate. The two are AND-ed. This is usually what you want: the explicit method runs additional filtering on the same column, e.g. case-insensitive matching, normalised whitespace, locale-aware comparison.
Field-level @condition (without override:) is the same shape, just at a different scope: the explicit predicate is AND-ed with the implicit predicates for every direct argument and (for @table-bound input arguments) every implicit column-equality predicate the input would otherwise contribute.
Argument-level override: true: scoped to one argument
The narrow override:
type Query {
cities(countryId: String! @field(name: "COUNTRY_ID")
@condition(condition: {...}, override: true)): [City]
}
override: true on the argument suppresses that argument’s implicit column = ? and lets the explicit condition method own the predicate alone. Other arguments on the same field are unaffected: their implicit predicates still fire, their explicit @condition annotations (if any) still fire on top.
The implementation key is ScalarArg.suppressedByFieldOverride() || argCondition.override() in FieldBuilder.projectFilters: only the argument’s own BodyParam is skipped; sibling arguments build their own BodyParam independently.
Field-level override: true: scoped to all direct arguments
The wide override:
type Query {
cities(
countryId: String! @field(name: "COUNTRY_ID"),
cityNames: [String!] @field(name: "CITY")
): [City] @condition(condition: {...}, override: true)
}
Field-level override: true suppresses the implicit predicates for every direct argument and, when an argument is a @table-bound input, every implicit column-equality predicate inside the input. The explicit method runs alone (in this example, alongside whatever inner explicit @condition annotations might be on the input’s fields, but those are already covered by argument-level conditions; on a flat scalar arg list, there’s just the field’s own method).
This is the right shape when the developer’s condition method takes over completely: a multi-column boolean expression that the implicit equality decomposition would interfere with.
@table input + outer override: true: the cascade rule
The non-obvious case. An outer field-level @condition(override: true) on a query whose argument is a @table-bound input:
type Query {
filmsOuterOverrideTableInput(filter: FilmConditionInput): [Film!]!
@condition(
condition: {className: "...InputFieldConditionFixtures", method: "outerOverrideMethod"},
override: true
)
}
input FilmConditionInput @table(name: "film") {
filmId: Int @field(name: "FILM_ID")
@condition(condition: {
className: "...InputFieldConditionFixtures",
method: "filmIdCondition"
})
}
Three things happen at request time:
-
outerOverrideMethod(filmTable)runs (the outer explicit method, returningfilm_id >= 2in the fixture). -
The implicit
filmTable.FILM_ID = ?that the rewrite would normally derive fromfilter.filmIdis suppressed. -
The inner
filmIdCondition(filmTable, filmId)runs anyway (the inner explicit method, returningfilm_id = ?for the supplied value).
Net SQL WHERE: (film_id >= 2) AND (film_id = 1). With the fixture data (filmId: "1"), no rows match. This is the rewrite’s intended behavior: override: true suppresses implicit predicates, not explicit ones. Inner @condition annotations are preserved through any number of override layers above.
The implementation key is walkInputFieldConditions in FieldBuilder: every case arm adds the cf.condition() filter to out unconditionally, while the implicit BodyParam is gated on !enclosingOverride && cf.condition().isEmpty(). The two paths are independent.
The same shape applies recursively to nested input types: a NestingField propagates enclosingOverride = enclosingOverride || (this nesting’s @condition has override:true) to its children. Implicit predicates anywhere below an override layer are suppressed; explicit predicates anywhere below are preserved.
Divergence from legacy semantics
The legacy generator interpreted override: true as "outer owns everything": the inner explicit @condition annotations were dropped, and the outer method ran alone. The rewrite preserves the inner explicit conditions deliberately. The Sakila example schema has a regression-fence around this: filmsOuterOverrideTableInput (above) is paired with the filmsOuterOverridePlainInput fixture and a comment in the schema that says, verbatim:
A regression to legacy semantics breaks this test by name.
The reason for the divergence: under the legacy reading, an inner @condition was meaningless once any ancestor used override: true, which made schema review brittle (an outer override meant inner annotations were silently inert). Under the rewrite’s reading, every @condition always fires; only the rewrite’s own implicit predicates are negotiable. This makes the directive composable and the schema readable: each @condition you write produces SQL.
If you’ve been migrating from the legacy generator and you have an outer @condition(override: true) whose inner fields also carry @condition, audit them. The legacy generator silently dropped the inner ones; the rewrite runs them. You may need to add override: true to the inner annotation, or remove the inner annotation entirely if it was redundant with the outer method.
Plain (non-@table) inputs and implicit predicates
When the argument is a plain input (no @table on the input type), the rewrite does not synthesise implicit column = ? predicates from the input’s fields. Only explicit @condition annotations contribute. This is PlainInputArg’s job in `FieldBuilder.projectFilters:
case ArgumentRef.InputTypeArg.PlainInputArg pia -> {
pia.argCondition().ifPresent(ac -> argConditions.add(ac.filter()));
walkInputFieldConditions(pia.fields(), pia.name(), List.of(),
false, Set.of(), null, argConditions); // null implicitBodyParams
}
The null argument means no implicit BodyParam ever materialises. The walker still descends and collects every explicit @condition it finds, but no implicit predicates contribute. This is why @condition on a plain input field is the only way to filter through that field; the implicit-equality fallback is unavailable.
The same fixture pattern mirrors the @table case: filmsOuterOverridePlainInput(filter: PlainFilmIdInput) with outer @condition(override: true) and an inner @condition on filter.filmId. Both methods fire; outer-only semantics would fail the same regression fence.
Nested-input record conventions
The @condition reference page lists the carrier rules in two lines; here’s the operational shape, with the carriers spelled out.
GraphQL input types classify into one of five carriers in the rewrite:
-
@table-bound (TableInputType): Graphitron owns the column projection and DML. Most@conditionfixtures sit here. -
@record-bound to a Java record (JavaRecordInputType): the developer supplies a record class; the rewrite binds GraphQL fields to its components. -
@record-bound to a jOOQ record (JooqRecordInputType) or a jOOQTableRecord(JooqTableRecordInputType): the carrier is a jOOQ-generated row type. -
Plain (
PojoInputType): the fallback when no@tableand no@recordapply, or when@recordis present without aclassName:. The carrier isMap<String, Object>at runtime.
The non-obvious composition rules around @condition:
-
jOOQ records can’t contain other records. A
@tableinput or a jOOQ-record input can have a leaf-level@condition, but it cannot nest a child input that requires its own jOOQ-record carrier. That’s a carrier-shape limitation, not a@conditionone: there is no jOOQ class that carries a nested record. -
Java-record carriers cascade upward. If a leaf input is
@record-backed by a Java record, every input above it in the nesting chain must also be Java-record-backed. The rewrite needs a stable Java type to hold the nested record. Mixing carriers (e.g.@tableouter with@recordinner) does not compose: the outer@tableinput has no slot for the inner record. -
Listed nested inputs require a Java record carrier.
[InnerInput]inside an outer input means the outer carrier must be a Java record (so the list element has a stable Java type to deserialise into). A@tableouter with[InnerInput]inside is rejected at classify time. -
Plain
PojoInputTypeworks for shallow scalar paths. The runtimeMap<String, Object>traversal handles any depth of plain inputs whose leaves are scalars and@condition-annotated. The example schema’sNestedFilmInput @tableouter /InnerFilmInputplain inner /filmId @conditionleaf composition exercises this; the multi-segment path["inner", "filmId"]is extracted at runtime viaNestedInputField.
The conservative default: when in doubt, use @table on the outermost input (so the rewrite can drive implicit predicates) and plain inputs on inner nesting layers (so the rewrite’s Map-traversal handles the descent). Only reach for @record when a service method’s input shape demands a typed Java carrier, or when listed nested inputs are involved.
Lookup-key arguments are exempt
A @condition does not interfere with @lookupKey: lookup-key arguments are consumed upstream by LookupValuesJoinEmitter, not by the WHERE-clause projection. The implementation key is in FieldBuilder.projectFilters:
if (!autoSuppressed && !ca.isLookupKey()) {
bodyParams.add(...);
}
Lookup-key args bypass the BodyParam path entirely. The lookupBoundNames set passed into walkInputFieldConditions skips them on the input-side too, so an input field that backs a lookup key never emits an implicit predicate even when the input is @table-bound and enclosingOverride is false.
If you @condition a lookup-key arg, the explicit predicate runs alongside the VALUES + JOIN lookup path. override: true on a lookup-key arg has no effect on the lookup itself (it’s not a WHERE-clause predicate to suppress); it only affects the implicit column = ? that would otherwise pair with a non-lookup-key arg of the same shape.
contextArguments: flow
Values pulled from GraphQLContext arrive as additional Java parameters on the reflected method, after the table and the input parameters:
type Query {
citiesForCurrentUser: [City]
@condition(
condition: {className: "...", method: "tenantScoped"},
contextArguments: ["tenantId"]
)
}
tenantScoped(City table, UUID tenantId) runs at request time. Two consequences:
-
No build-time validation that the context key is wired. A typo in
contextArgumentssurfaces as anullparameter at request time, not at build time. Test the wiring (or log on null in the condition method). -
contextArguments:andargMapping:are independent.argMapping:rebinds GraphQL argument names to differently-named Java parameters;contextArguments:adds parameters from the request context. They compose: a method can take(table, mapped GraphQL args, context args)in that order.
The supplying side of the context-argument story (tenant scoping, request-id propagation, current-user filtering) lives in How-to: Tenant scoping and the runtime-side GraphitronContext interface documented in the runtime API reference.
Constraints and pitfalls
-
Default is additive. Without
override: true, an explicit@conditionisAND-ed with the implicitcolumn = ?predicates. Reach foroverride:only when the explicit method takes over completely. -
Argument-level
override: trueis local. Suppresses just that argument’s implicit predicate, nothing else. -
Field-level
override: truecascades through@tableinputs. Suppresses implicit predicates on direct args and inside@table-bound input arguments. Inner@conditionannotations are preserved. -
Inner explicit
@conditionalways runs. The rewrite never drops a@conditionannotation regardless of how manyoverride: truelayers sit above it. Migrators from the legacy generator should audit fields where outeroverride: trueand inner@conditioncoexist; legacy semantics dropped the inner ones. -
Plain inputs have no implicit predicates. Only explicit
@conditionannotations contribute. Without one, a plain input field is silently a no-op on the SQL side. -
jOOQ records cannot contain other records.
@record-backed-by-jOOQ inputs can have leaf `@condition`s but cannot nest a sibling jOOQ-record input. -
Java-record carriers cascade upward. If any input in a nesting chain is
@record-backed by a Java record, every input above it must also be Java-record-backed. -
Listed nested inputs (
[InnerInput]) require a Java record carrier on the outer input. -
contextArguments:is unvalidated at build time. Typos surface asnullat request time. -
@lookupKeyargs bypass the implicit-predicate path.override:has no effect on lookup-key dispatch; explicit@conditionon a lookup-key arg runs alongside theVALUES + JOIN.
See also
-
@conditionis the directive’s reference page. -
@fieldsupplies the implicit predicate that@conditioneither stacks with or replaces. -
@record/@tabledetermine the input carrier the condition method reads from. -
@lookupKeyis exempt from the implicit-predicate path. -
@serviceis the alternative when an entire fetcher is custom code. -
How-to: Wire external Java code covers the FQCN requirement, classpath setup, and
argMapping:syntax shared with@condition. -
How-to: Map types to tables covers the input-type binding side that
@conditionbuilds on.