The @condition directive hands one piece of a generated WHERE clause off to a developer-supplied static method. Use it when the predicate isn’t a plain column = ? (range filters, regex, date arithmetic, vendor-specific operators, multi-column boolean shapes) or when the column you want to filter on isn’t reachable through @field. This recipe walks through writing the Java method, attaching the directive at each of the three SDL sites (argument, field, input field), and rebinding parameter names when GraphQL and Java conventions diverge. For the override cascade rules (stacking, suppression, the legacy-divergence trap), read How-to: Stacking and overriding conditions after this recipe.
The recipe assumes you’ve read How-to: Wire external Java code for the class-nameability and FQCN mechanics shared across all directives that point at Java code.
Write the Java method
Every condition method has the same shape: a static method on a public class, returning a jOOQ Condition, taking the surrounding table first and the GraphQL argument values after. That first parameter may be the wildcard Table<?>, which accepts any table, or the concrete generated jOOQ class of the table the directive sits over, which keeps the typed column handles; both are supported everywhere, and Filter a multitable interface or union below is where the choice starts to matter. The example fixture filters films by primary key:
package no.sikt.graphitron.rewrite.test.conditions;
import no.sikt.graphitron.rewrite.test.jooq.tables.Film;
import org.jooq.Condition;
import org.jooq.Table;
import org.jooq.impl.DSL;
public final class InputFieldConditionFixtures {
private InputFieldConditionFixtures() {}
public static Condition filmIdCondition(Table<?> table, String filmId) {
if (filmId == null) {
return DSL.noCondition();
}
return table.field(Film.FILM.FILM_ID).eq(Integer.parseInt(filmId));
}
}
Three things matter:
-
The first parameter is the surrounding table. The rewrite passes the aliased table the field’s fetcher is selecting from, and the generated call site is typed from that coordinate, so declaring the parameter as
Table<?>or as the coordinate’s own generated class are both accepted. Either way, resolve column references through the parameter,table.field(Film.FILM.FILM_ID)on the wildcard form ortable.FILM_IDon the concrete one, rather than throughFilm.FILM.FILM_IDdirectly: the alias-bearing handle keeps the predicate anchored in the caller’sFROMand lets jOOQ render theWHEREagainst the correct alias. -
The remaining parameters bind by name. The Java parameter named
filmIdreceives the GraphQL argument namedfilmId. Names must agree; the rewrite reads them through reflection. Compile the Java module with-parametersso the parameter names survive into the bytecode (the example fixtures'pom.xmlcarries<parameters>true</parameters>for this reason). -
Return
DSL.noCondition()to opt out. When the GraphQL argument is null or absent, return the no-op so the predicate is structurally present but contributes nothing to theWHEREclause. This is the contract that lets optional filter inputs degrade cleanly to "unconstrained".
Declare the carrying artifact in your consumer module’s own <dependencies>; the generated resolvers call the class directly, and the generator rejects a schema naming a class the module does not declare. Make the class nameable covers the rule in full, including the migration off the withdrawn <plugin><dependencies> route.
Attach the directive at one of three sites
@condition is valid on FIELD_DEFINITION, ARGUMENT_DEFINITION, and INPUT_FIELD_DEFINITION. The placement determines what the Java method sees:
| Site | What the method sees | When to use |
|---|---|---|
Argument |
the table + the one argument’s value |
The predicate is local to one argument; other arguments are unaffected. |
Field |
the table + every direct argument |
The predicate spans multiple arguments (conjunctive filter, multi-column boolean shape). |
Input field |
the table + the input’s leaf scalar |
The predicate filters by a value carried inside an input type. |
Argument-level: filter by one input
type Query {
citiesByName(
cityNames: [String!] @field(name: "CITY")
@condition(condition: {
className: "com.example.CityConditions",
method: "cityMethod"
})
): [City]
}
The Java method takes (City table, List<String> cityNames). The predicate is AND-ed with the implicit CITY IN (?, ?, …) that @field(name: "CITY") would otherwise emit on its own. Argument-level conditions only see their own argument; the surrounding field’s other arguments are invisible.
Field-level: filter spanning every argument
type Query {
cities(
countryId: String! @field(name: "COUNTRY_ID"),
cityNames: [String!] @field(name: "CITY")
): [City] @condition(condition: {
className: "com.example.CityConditions",
method: "cityMethod"
})
}
The Java method’s signature is (City table, String countryId, List<String> cityNames), every direct argument is delivered. Reach for field-level placement when the predicate is conjunctive across arguments or when the explicit logic depends on more than one input value.
Input-field-level: filter on an input’s leaf scalar
The example schema’s filmsWithInputFieldCondition query exercises this at runtime. The query takes a filter input whose filmId field carries @condition (the input’s fields resolve against film, the query’s return-type table):
type Query {
filmsWithInputFieldCondition(filter: FilmConditionInput): [Film!]!
}
input FilmConditionInput {
filmId: ID! @field(name: "film_id")
@condition(condition: {
className: "no.sikt.graphitron.rewrite.test.conditions.InputFieldConditionFixtures",
method: "filmIdCondition"
})
}
The Java method’s signature is (Film table, String filmId). The rewrite extracts filter.filmId from the GraphQL argument map at request time and passes it as the second parameter. A two-level nested input (NestedFilmInput’s `inner.filmId in the same fixture set) walks the path ["inner", "filmId"] to reach the leaf and binds the same way. The execution-tier test inputFieldCondition_tableInput_filtersByFilmId in GraphQLQueryTest runs the resulting query and asserts that {filmId: "1"} returns exactly one row.
Pick a site
The default is whichever site is closest to where the value is. An argument carrying a single GraphQL argument: argument-level. A field whose predicate involves several arguments: field-level. A value buried inside a structured input: input-field-level. Argument-level and field-level conditions can coexist on the same field; input-field-level conditions stack with both. How-to: Stacking and overriding conditions covers the override flag and the divergence rules when implicit and explicit predicates disagree.
Rebind argument names with argMapping
When the GraphQL argument name and the Java parameter name diverge, use argMapping: to rebind. The syntax is "javaParam: path", comma-separated; the Java side comes first because that is what the reflected method signature reads. The right-hand side may also walk into nested input fields, described once in Binding a parameter to a nested input field:
type Query {
cities(cityNames: [String!]
@condition(condition: {
className: "com.example.CityConditions",
method: "argConditionRenamed",
argMapping: "city: cityNames"
})): [City]
}
The Java method argConditionRenamed(Table<?> table, String city) receives the GraphQL argument cityNames as its city parameter. Whitespace around : and , is tolerated, multi-line text-block input is accepted for long mappings, and an empty argMapping: is identity. argMapping: is supported on @condition and @service, the directives whose Java targets receive GraphQL arguments. It is structurally inert on @externalField, @enum, and @sourceRow; applying it on those is rejected at parse time.
Field-level placement mirrors the argument-level shape, argMapping: rebinds every parameter that needs a different Java name:
type Query {
cities(cityNames: [String!] @field(name: "CITY"),
countryId: String! @field(name: "COUNTRY_ID")):
[City] @condition(condition: {
className: "com.example.CityConditions",
method: "fieldConditionRenamed",
argMapping: "city: cityNames, country: countryId"
})
}
The Java method fieldConditionRenamed(Table<?> table, String city, String country) receives both GraphQL arguments under their renamed Java identifiers. Unmentioned Java parameters bind to a GraphQL argument of the same name, so partial mappings are valid.
Filter a multitable interface or union
A field returning an interface or union whose implementations sit on different tables is fetched one branch at a time: the rewrite emits one SELECT per participant and calls your @condition method once per branch, against that branch’s own aliased table. Three ways to write the method, and they differ in what the compiler checks for you.
One declaration per participant. Write one method per participant table, all sharing the name the directive references, each differing only in its table parameter:
public static Condition occupantNamePrefix(Customer customer, String firstName) {
return customer.FIRST_NAME.like(firstName + "%");
}
public static Condition occupantNamePrefix(Staff ansatt, String firstName) {
return ansatt.FIRST_NAME.like(firstName + "%");
}
Same-named declarations are accepted as one @condition target when they agree on the binding shape: the same parameter count, the same static-ness, the same return type, the same throws clause, and, position by position, each position either a jOOQ Table parameter in every declaration or identical in name and declared type in every declaration. Only the table parameters may differ, and they may differ in name as well as in type, so naming each slot after its own table is fine. The generated call site is typed from the coordinate, so your compiler performs the selection and each body gets the participant’s generated typed column handles, which is what the wildcard form gives up.
A participant with no applicable declaration fails your compile, at the branch whose call site has no method to bind. That is the intended guard for partial coverage: add the missing participant’s declaration, or widen one to Table<?>.
One declaration taking Table<?>. One method serves every branch:
public static Condition occupantNamePrefix(Table<?> table, String firstName) {
return table.field(DSL.name("first_name"), String.class).like(firstName + "%");
}
The column is resolved by name, so the participants must share it. Two failure modes to plan for. table.field(Film.FILM.TITLE) is typed but returns null on a branch whose table has no such column, and an unguarded .eq(…) on that null throws at request time, not at build time; return DSL.noCondition() for a branch you mean to leave unconstrained. And instanceof narrowing recovers the whole concrete table when you want one branch handled differently:
public static Condition occupantNamePrefix(Table<?> table, String firstName) {
if (table instanceof Customer customer) {
return customer.FIRST_NAME.like(firstName + "%");
}
return DSL.noCondition();
}
A mixed set. A Table<?> declaration beside concrete ones is legal, and it is the case to think twice about. Your compiler’s most-specific rule picks the concrete declaration where one applies and the Table<?> one everywhere else, so the fallback serves every branch no concrete declaration covers. That trades the compile-time partial-coverage guard above for runtime fallback behaviour: a set covering two of three participants plus a fallback compiles clean and silently serves the third from the fallback, which is exactly where a mistyped participant table would otherwise have been caught. Nothing at build time flags it. You opt into that by writing the fallback declaration, so write one when you mean "and everything else", not to quiet a compile error.
Declarations of one name that disagree on anything outside their table parameters are rejected at build time, naming the axis they disagree on and the signatures the generator read.
Pull request-context values via contextArguments
When the predicate depends on values from the request context (current user, tenant ID, correlation ID), name them via contextArguments:. The rewrite appends one Java parameter per listed key, after the table and the GraphQL-argument parameters:
type Query {
citiesForCurrentUser: [City]
@condition(
condition: {className: "com.example.CityConditions", method: "tenantScoped"},
contextArguments: ["tenantId"]
)
}
tenantScoped(City table, UUID tenantId) runs at request time with tenantId pulled from the runtime GraphQLContext. Wire the value into the context map at servlet bootstrap; the rewrite reads each named key with the runtime value’s declared Java type. How-to: Tenant scoping covers the supplying side, and the runtime API reference documents the GraphitronContext interface that brokers the per-request values.
Two consequences worth pinning:
-
Compile-time wiring guard. A typo in
contextArgumentssurfaces at compile time as a typed-but-unfamiliar parameter onGraphitron.newExecutionInput(…)’s signature: the factory reflects the SDL’s `contextArguments:list verbatim, so a misspelled name produces a factory parameter under the misspelled name. The error you see is the consumer-side compile failure (or, if you passnullto fill that slot, anNullPointerExceptionat the factory call from the per-slotObjects.requireNonNull), not a silentnullat request time. -
contextArguments:andargMapping:compose. A single method may take(table, GraphQL args mapped through argMapping, context args in declared order)and the rewrite assembles the call site accordingly.
Constraints
-
condition.className:andcondition.method:are both required for a meaningful directive. A@conditionwhose reference resolves to no method surfaces with the diagnosticfield '<name>' @condition: …orargument '<name>' @condition: …. The class must be nameable per Make the class nameable: this module, a reactor sibling once declared, or a dependency this module declares itself. -
The first Java parameter is always the surrounding table, declared either as
Table<?>or as that table’s own generated jOOQ class. Resolve column references through the parameter rather than the static jOOQ-table handle, the alias-bearing accessor keeps the predicate anchored to the calling fetcher’sFROMclause. A concrete parameter typed on a table the coordinate does not pass is rejected at build time where the generator knows the table, and fails your compile where it does not. -
Parameter names bind GraphQL argument names. Compile the carrying module with
<parameters>true</parameters>so the names survive reflection. Diverging names are reconciled byargMapping:, not by reordering the method. -
DSL.noCondition()is the explicit no-op; return it for null or absent inputs. Returningnullis undefined and currently throws at the predicate-folding site. -
Argument-level placement sees only that argument; sibling arguments are invisible. Field-level placement sees every direct argument, including input-typed ones (the input arrives as the carrier type the input is bound to). Input-field-level placement walks the GraphQL argument map by path and delivers the leaf scalar.
-
argMapping:is supported on@condition. It is not supported on@externalField,@enum, or@sourceRow. -
contextArguments:keys flow throughGraphQLContextunmodified; the runtime is responsible for placing each key. Typos surface asnullat request time. -
The override cascade and the divergence-from-legacy semantics live in How-to: Stacking and overriding conditions. Read it before composing
@conditionwithoverride: trueacross nested inputs.
See also
-
@conditionis the directive’s reference page; the parameter list, theExternalCodeReferenceshape, and the per-site rejection messages live there. -
How-to: Stacking and overriding conditions covers the override flag, the implicit-vs-explicit cascade, the input-carrier rules for nested inputs, and the regression-fenced divergence from the legacy generator’s "outer owns everything" semantics.
-
How-to: Wire external Java code covers class nameability, the FQCN requirement, and the
argMapping:syntax that@conditionshares with@service. -
@field(name: …)supplies the implicit equality predicate that@conditioneither stacks with or replaces. -
How-to: Handle services covers the alternative when the whole fetcher is custom code,
@conditionis for adjusting a single predicate inside an otherwise generated query. -
A
@lookupKeyargument is exempt from the implicit-predicate path;@conditionon a lookup-key arg runs alongside theVALUES + JOINlookup but does not interact with it. The exemption is the key argument’s, not the coordinate’s: non-key arguments on a lookup field take the implicit predicate as usual, and it composes in theWHEREbeside the lookup join.