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

Names from inside the generator. This page names types from graphitron’s own classification model. They are accurate today, and worth knowing when a rejection message quotes one back at you, but they are not part of the authoring contract: the classification walk that produces them is being drained, and these names retire with it. What stays stable is the schema you write, the directive reference that describes it, and the diagnostics glossary.

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 input-typed arguments) every implicit column-equality predicate the input’s column-bound fields 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 an input type, 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.

Filter 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 filter input (its fields resolve against film, the query’s return-type table):

type Query {
    filmsOuterOverrideFilterInput(filter: FilmConditionInput): [Film!]!
        @condition(
            condition: {className: "...InputFieldConditionFixtures", method: "outerOverrideMethod"},
            override: true
        )
}

input FilmConditionInput {
    filmId: Int @field(name: "FILM_ID")
        @condition(condition: {
            className: "...InputFieldConditionFixtures",
            method: "filmIdCondition"
        })
}

Three things happen at request time:

  1. outerOverrideMethod(filmTable) runs (the outer explicit method, returning film_id >= 2 in the fixture).

  2. The implicit filmTable.FILM_ID = ? that the rewrite would normally derive from filter.filmId is suppressed.

  3. The inner filmIdCondition(filmTable, filmId) runs anyway (the inner explicit method, returning film_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: filmsOuterOverrideFilterInput (above) is paired with the filmsOuterOverridePlainInput fixture (the same shape against a second filter input) 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.

Filter inputs resolve against the consuming field’s table

A filter input needs no directive of its own (@table on an input is a deprecated location; the classifier ignores it and warns). Its fields resolve against the consuming field’s table: the query’s return-type table, per call site. Three consequences:

  • Implicit predicates come for free. An un-annotated input field that resolves to a column (via @field(name:) or the SDL-name default) contributes an implicit column = ? predicate, exactly like a scalar argument. @condition is for the predicates the equality decomposition can’t express, not a prerequisite for filtering.

  • Reuse resolves per-consumer. The same input consumed by two fields returning different tables classifies once per call site, each against that consumer’s table. A field that resolves on no column of a consumer’s table fails the build with a diagnostic naming that consumer’s table.

  • The cascade is uniform. override: propagation, implicit-predicate suppression, and explicit-condition preservation behave identically for every input argument; there is no separate "plain input" rule.

The second regression-fence fixture exercises the same override shape on a second input: 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 four carriers in the rewrite. For class-backed carriers, the backing class is reflected from the @service / @condition method parameter the input flows into:

  • Class-backed by a Java record (JavaRecordInputType): the producer’s parameter is a record class; the rewrite binds GraphQL fields to its components.

  • Class-backed by a jOOQ record (JooqRecordInputType) or a jOOQ TableRecord (JooqTableRecordInputType): the reflected carrier is a jOOQ-generated row type.

  • Plain (PojoInputType): the default when the input is not reflected to a typed class. The carrier is Map<String, Object> at runtime; column binding resolves against each consuming field’s table. Most @condition fixtures sit here.

The non-obvious composition rules around @condition:

  • jOOQ records can’t contain other records. 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 @condition one: there is no jOOQ class that carries a nested record.

  • Java-record carriers cascade upward. If a leaf input is class-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. a jOOQ-record outer with a Java-record inner) does not compose: the outer carrier 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).

  • Plain PojoInputType works for scalar paths at any depth. The runtime Map<String, Object> traversal handles nested plain inputs whose leaves are scalars, @condition-annotated or not. The example schema’s NestedFilmInput outer / InnerFilmInput inner / filmId @condition leaf composition exercises this; the multi-segment path ["inner", "filmId"] is extracted at runtime via NestedInputField.

The conservative default: leave filter inputs carrier-free (the Map-traversal handles the descent, and implicit predicates resolve against the consuming field’s table). Only reach for a class-backed carrier (a service or condition method whose parameter is a typed Java class) when the method wants 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 the VALUES input-rows helper (LookupRows), 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 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 contextArguments surfaces as a null parameter at request time, not at build time. Test the wiring (or log on null in the condition method).

  • contextArguments: and argMapping: 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 @condition is AND-ed with the implicit column = ? predicates. Reach for override: only when the explicit method takes over completely.

  • Argument-level override: true is local. Suppresses just that argument’s implicit predicate, nothing else.

  • Field-level override: true cascades through input arguments. Suppresses implicit predicates on direct args and inside input-typed arguments. Inner @condition annotations are preserved.

  • Inner explicit @condition always runs. The rewrite never drops a @condition annotation regardless of how many override: true layers sit above it. Migrators from the legacy generator should audit fields where outer override: true and inner @condition coexist; legacy semantics dropped the inner ones.

  • Input fields resolve against the consuming field’s table. Column-resolved fields contribute implicit predicates; a field that resolves on no column of a consumer’s table fails the build naming that table.

  • jOOQ records cannot contain other records. Inputs class-backed by a jOOQ record 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 class-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 as null at request time.

  • @lookupKey args bypass the implicit-predicate path. override: has no effect on lookup-key dispatch; explicit @condition on a lookup-key arg runs alongside the VALUES + JOIN.

See also

  • @condition is the directive’s reference page.

  • @field supplies the implicit predicate that @condition either stacks with or replaces.

  • The producer’s reflected parameter type determines the input carrier the condition method reads from; a carrier-free input runs through the Map-based path.

  • A @lookupKey argument is exempt from the implicit-predicate path. Its non-key siblings on the same field are not: they cascade exactly as they would anywhere else.

  • @service is 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-field column binding that @condition builds on.