Concept explainer · R97 · theme: classification-model
Consumer-derived input tables
An input type never has to declare which database table it maps to. The consuming field's return type already answers that, so @table on an input is a copy of a fact graphitron can read for free. This page is the intuition for why the directive is redundant, and the deeper reason the input has nothing to classify at all.
@table on input types). It leans on the fact-based classification model from R333; you do not need R333 first, this page rebuilds the piece it uses.
@table on the input restates what the field's signature already fixes; the honest model is that an input supplies data to a field's facts and is not itself a thing that gets classified.
The problem, concretely
Take a Sakila filter query. The field returns Film, and Film is bound to the film table:
type Query {
films(filter: FilmConditionInput!): [Film!]!
}
type Film @table(name: "film") { ... }
input FilmConditionInput @table(name: "film") {
title: String
releaseYear: Int
}
The input carries @table(name: "film") so graphitron knows title resolves to film.title and releaseYear to film.release_year. But look at where that name comes from: the field films returns [Film!]!, and Film is @table(name: "film"). The input's directive and the return type's directive say the identical word, film. One of them is redundant, and it is the one on the input.
The input announces its own table. Graphitron classifies FilmConditionInput as a TableInputType bound to film, in isolation, before it knows who consumes it.
input FilmConditionInput
@table(name: "film") // says "film"
The input declares only field names. The table comes from films' return type, resolved at the call site against Film's @table.
input FilmConditionInput { // no @table
title: String
}
// films(): [Film] already says "film"
This holds for every fixture pattern in Sakila: INSERT/UPDATE/DELETE mutations whose field returns a @table type, filter and condition inputs, lookup-key inputs like FilmActorKey. In each the input's @table names a table the consuming field already fixes. R97 removes the directive and reads the table off the consumer instead.
Quiz If you delete @table(name: "film") from FilmConditionInput, how does graphitron know title means film.title?
Film is @table(name: "film")), and that resolution happens registry-free at the edge. The input's fields resolve against that table. A catalog-wide name match would be ambiguous the moment two tables share a column name; consumer-derivation is exact because there is exactly one consuming field per call site.The deeper reason: the input is not an entity
The redundancy argument is the easy half. The load-bearing half comes from the fact-based model (R333): a schema coordinate lowers to a graph of output-field facts, a condition, a write target, a projection. The input does not appear in that graph as a node. It only supplies the data those facts consume.
So the question "what table is this input bound to?" is asking the wrong thing to answer it. The input does not answer that; the consuming field's fact does. The condition fact binds the input's values to columns. The mutation's write-target fact derives the table. "Bound table" is a property of the fact, read through the input, not a property stored on the input.
Classify FilmConditionInput on its own, with no consumer in view. Produce a global verdict: TableInputType or PojoInputType.
A query-only input that is neither a table nor a backing class falls out as PojoInputType with fqClassName=null: a contextless artifact. That null verdict is the tell that this altitude is wrong.
There is no global verdict to reach. Per consuming field, the input's fields resolve against that field's target. A declared input is always reachable from some consuming field, directly or nested inside another input, so the null-class case cannot arise.
Quiz If the input has nothing to classify, why did graphitron ever produce a TableInputType verdict with a table on it?
The boundary: when the input does become an entity
"The input is only data" is true for exactly the era this item covers. There is one use case where modelling the input as a first-class entity is warranted, and it is already filed: Jakarta validation (R92, R98).
The distinction is clean. A constraint, a DB CHECK lifted to @Pattern/@Min, an SDL-declared rule, a Jakarta annotation, is a fact about the input itself: it is true no matter which field consumes the input. That cannot be reduced to an output-field fact, so the input earns its own node in the model. Until then, the input is data for output facts, and consumer-derived resolution is the whole story.
@lookupKey lookup. Green tests did not save it; the altitude was wrong for the general case.
Reuse across consumers: the case that exposes the difference
What happens when one input is used by two fields? Say FilmConditionInput filters both Query.films and Query.filmsByGenre, both returning [Film]. Both consumer-derived resolutions land on film, so nothing differs. The interesting case is when they disagree.
findReturnTablesForInput collects every consuming field's table. On more than one distinct table it bails to non-table (the > 1 demotion). So an input reused across two tables silently classifies non-table everywhere, and a genuine cross-table misconfiguration miscompiles quietly.
Each call site resolves the input against its own consumer's table. Two consumers on two tables just get two resolutions. A field whose table lacks a column the input names fails at classify time, naming the actual consumer's table, and surfaces as UnclassifiedField. The silent demotion becomes a precise error.
Quiz An input is reused by a query returning film and another returning actor. Under the old global aggregate, what does the developer see?
> 1 bail means "more than one table, so give up and call it non-table" everywhere the input is used. No error is raised. Consumer-derived resolution flips this: each call site is resolved independently, so a real mismatch (a column the consumer's table does not have) is a named rejection instead of a quiet demotion.When convention cannot tie-break: argMapping grouping
Consumer-derivation is convention: the return-type table plus by-name column matching. Convention runs out when an input fans out, its fields scatter across multiple service-method parameters, or multiple jOOQ records. Rather than a new directive, R97 extends the existing argMapping escape valve with a grouping form.
argMapping (today)@service(service: {
method: "filmsByPath",
argMapping: "filmIds: input.ids"
})
RHS is a path into the input; LHS is one service-method param. One source, one target.
argMapping: """
order: { orderNumber: input.orderNumber,
customerId: input.customerId },
shipTo: { street: input.street,
city: input.city }
"""
Two groups fill two service params (OrderRecord order, AddressRecord shipTo) from one input. Multi-source to one target each.
This subsumes the @param directive proposed in JIRA GG-376: the group's left-hand side is GG-376's name, the target type is derived from the service method's parameter type (not declared), and the group entries are GG-376's fields. No new directive; the fan-out is expressed on the directive that already carries mappings.
Quiz Why extend argMapping with grouping instead of adding the @param directive GG-376 proposed?
@table on input, alongside R94/R96's @record work) on the grounds that their information is available through introspection or argMapping. Introducing @param to solve fan-out would reintroduce exactly the kind of directive the item argues against. Extending argMapping keeps the "convention, then one escape valve" shape intact.What still leans on @table, and how each unwinds
The directive is not gone yet; several consumers still read it, and R97 is phased so each is made consumer-derived before the declaration is removed. The mutation write-target verbs have been landing one at a time.
- Arg-level
@lookupKey(composite lookup likeFilmActorKey) runs only on theTableInputTypearm today. R97 re-derives it: resolve the plain input's fields against the consumer's table and build the same lookup carrier. - UPDATE write target is the last mutation verb hard-requiring
@tableon its input; DELETE (R457) and INSERT (R515) already went field-relative. UPDATE inherits the same precedence ladder: return-derived table, then@mutation(table:), then the@tablebridge. - The global machinery,
findReturnTablesForInputand theisUsedWithOverrideConditionrouting gate, retires once nothing needs the auto-promotion. - The LSP/catalog seams lose their data source when every input goes plain; the final phase decides affirmatively whether per-consumer resolution feeds the input-type hover, or the input-field coordinates are dropped and R337 owns re-surfacing them.
The @table-on-input deprecation is already announced (R332 shipped the warning). Depth on how arguments resolve against a table lives in the argument-resolution reference; the classifier's mental model is in the classifier mental model; the discipline of turning an unbuildable shape into a named rejection is typed rejection.
Where it stands (as of 2026-07-24)
- landedSubstrate this item builds on: query-side plain-input resolution is field-relative (R205, R215, R330); the mutation write-target verbs went field-relative one at a time, DELETE (R457), the grounding substrate (R514), INSERT (R515); the sibling directive retirements shipped,
@recordreflection-driven binding (R96), the input-records carrier (R94), and the@table-on-input deprecation warning (R332). - in progressR97: the residual. Route arg-level
@lookupKeythrough the consumer-derived table; retirefindReturnTablesForInputand theisUsedWithOverrideConditiongate; migrate UPDATE's write target off the input's@table(Phase 2b, carved out like the other verbs when picked up); narrow the@tabledirective scope toOBJECT | INTERFACEand decide the LSP projection story affirmatively. TheargMappinggrouping (Phase 1) is orthogonal and splits into its own item when scheduled. - boundaryR92 (Spec) and R98 (Backlog): where the deferral ends and the input earns a first-class entity again, on the strength of constraints that are facts about the input itself. R97 covers the input-as-data era up to this point.
- follow-onR337 (Backlog): the nested-grouping projection surfacing residual, revived narrowly if R97 lands but leaves the input-field LSP coordinates on the
PojoInput(null)label.