You wrote a schema pattern. What does Graphitron generate from it, and where does each piece land? This page answers that, following the chain the generator actually runs: the schema becomes facts, the facts resolve into a verdict per coordinate, the verdicts join into command rows, and each row renders into a generated unit. Worked examples carry the bulk of the answer; every block in them is rendered from a live fixture, not transcribed.
The chain
Four stages, each reading only the stage before it.
flowchart LR
SDL[GraphQL schema] --> CAP[capture]
CAP --> F[("facts<br/>graphitron_ / graphql_")]
F --> DER[derivation]
DER --> V[("verdicts<br/>intent_ views")]
V --> PLAN[planning]
PLAN --> C[("command rows")]
C --> REN[render]
REN --> OUT[generated units]
Capture transcribes the schema into the fact store: one relation per thing the SDL states, in the
author’s vocabulary (graphql_) and in Graphitron’s (graphitron_). Capture decides nothing; it
records. Derivation asks the questions whose answers are verdicts, as SQL views over those facts
(intent_). Planning joins verdicts into command rows, one row per thing that will be generated.
Render folds each row into an emitted unit, a class or a method, and folds nothing else.
The depth lives elsewhere and this page does not restate it: stage order and what is still transitional in Pipeline overview, the modelling discipline every relation obeys in The fact model and its gentler sibling Naming the row, and per-relation detail with the sentence each relation’s own comment states in the generated schema reference.
One transitional note, so nothing here reads as more settled than it is. A classification walk
(GraphitronSchemaBuilder and the sealed GraphitronSchema model) still runs alongside capture and
still feeds parts of planning. It is a surface being drained, not a place to extend: new facts land
in the store, and consumers re-source onto the store one at a time. Sections of this page that are
still written in the walk’s vocabulary say so on the section.
How a coordinate gets its verdict
This is the page’s closed vocabulary. A coordinate is a (type, field) pair, or a type on its own;
its verdict is not one decision but the answer to several independent questions asked of the facts.
Each question is a relation, each relation states its own rule in its own comment, and the sections
below name the relation rather than restating the rule. Follow a name into
the generated schema reference for its columns and its full comment.
| Question | The relation that answers it |
|---|---|
What did the author claim, on a field? |
|
What did the author claim, on a type? |
|
Which catalog table does a type bind? |
|
What does the catalog match on its own? |
|
So what won? |
|
What contradicts? |
|
Two properties of this layer are worth stating outright, because they are what make it the vocabulary to learn rather than one of several.
Masking is a join, not a guard. A structural classifier never checks whether a directive was written; `intent_resolved_field_claim’s anti-join does the masking, at the coordinate grain. Any authored claim at a coordinate masks every structural reading there, presence arms included, because a directive whose decode declined still diverted the walk.
A conflict is a fact, not a failure. intent_authored_claim_conflict is total over the authored
claims. Whether a violated coordinate fails the build is the consumer’s question: the build-error
surface joins the emitted type domain, while the editor’s diagnostic arm reads the rows ungated,
because a type no field reaches is precisely where an author most needs the signal. What that does
to generation is Typed rejection's subject, and
When nothing is generated below.
What one generated thing is
A command row is one thing that will be generated. Planning mints the rows; render folds each into an emitted unit and folds nothing else. The relations below are the whole command tier.
| Relation | Package | What one row is |
|---|---|---|
|
|
The condition command relation of one generation run. |
|
|
The fetcher edge relation of one generation run: one row per coordinate of the covered non-launcher families whose emitted fetcher methods reference other generated units (see |
|
|
Every |
|
|
The launcher command relation of one generation run: one row per migrated root SELECT coordinate, keyed by the coordinate alone. |
|
|
The projection command relation: one row per projection unit, keyed by the unit’s address (type name for anchor units, |
|
|
The routine-write command relation of one generation run: one row per |
|
|
The type-keyed command relation: every per-type unit this run emits, one row per |
Each row’s sentence above is the first sentence of that relation’s own javadoc, rendered at build rather than transcribed here, so a relation added or renamed in the code shows up in this table or fails the render.
Unit names are minted in one place. Every UnitRef and UnitMethodRef comes from the naming
schemes in GeneratedUnits and from nowhere else, which PackageImportDirectionTest enforces by
pinning the minting site. That single mint is what makes an emitted name in a worked example below
trustworthy: it is the name the run produced, not a name a doc author reconstructed.
Closure is asserted, and the gaps are disclosed rather than implied. LauncherRelationClosureTest
joins the relation the run actually rendered from against the emit walk and against the classified
model, in both directions: every coordinate the covered families claim has exactly one row, every
row’s (owner, method) names a method the run declared, and no two rows claim the same method. The
covered families come from the producer’s own declared membership, never a hand-maintained
restatement. Two absences are pinned as absences, so they read as decisions:
-
the batched polymorphic pair is emitted and uncommitted, its rows methods named through the same
GeneratedUnitsscheme with no row behind them; -
an encoded-DML return arm carries no reentry.
The relations' own populations disclose their transitional edges the same way: LauncherRelation
covers migrated root SELECT coordinates, and FetcherEdgeRelation the covered non-launcher
families. Both are widening as consumers re-source onto the store.
Derived tables
Two kinds of VALUES(…) derived tables built by Graphitron when batching:
-
Derived source table ; built from parent source records. Contains the FK-relevant columns from the parent: the parent’s PK/unique-key columns when the FK is on the child side, or the FK columns themselves when the FK is on the parent side. Used for
@splitQuerytable fields, user-provided returns (@service), and mutation read-backs. -
Derived target table ; built from
@lookupKeyargument values (fromSelectedField.getArguments()). Each argument value (or list element) is one row. Identical for every source in a batch ; all N parents in a batch share the same request arguments, so M (the number of lookup rows) is constant for the entire batch. Base result count is exactly N × M.
@condition on lookup fields is allowed. The condition method, however, must preserve the N × M positional contract: each (source, target) pair produced by the derived-table cross join is either kept in full or dropped in full, and no additional rows may be introduced. In practice this means the condition should be a predicate over the pair of rows, not a filter that can change the per-parent result cardinality non-uniformly. Violating the contract desynchronises batch dispatch ; the client receives rows that cannot be reattached to their source. The contract is a developer responsibility, not a build-time check.
Conditions
| Kind | Purpose | Source |
|---|---|---|
Reference condition |
How two tables are joined within a scope |
|
Filter condition |
Narrows the result set of the current scope |
|
Lookup condition |
Filters the (source × target) row pairs produced by a lookup’s derived target table. Must preserve the N × M positional contract ; see Derived tables above. |
|
Batch-shaping directives
Two directives change the shape of the batch rather than what a coordinate classifies as.
@splitQuery on a field whose parent is bound to a table forces a new SQL statement, dispatched
through a DataLoader keyed by the parent’s key columns. On a field whose parent is class-backed it is
redundant, because the handoff into that parent already opened a new statement; that case is a build
warning, not an error.
@lookupKey turns the field’s argument values into the derived target table above. It blocks
pagination, which is what preserves the N × M result invariant, and it does not by itself open or
close a statement. A field can carry both directives; the lookup rides along on whatever statement
@splitQuery opened.
Type Classification
|
Transitional. This section describes the sealed type leaves the classification walk produces, a surface being drained rather than one to extend. What it states is accurate today. For the architecture the walk drains into, see Pipeline overview. |
The worked examples in this section are not written here. Each one is an include:: of a fragment
rendered from the classification test corpus (graphitron/src/test/resources/corpus, one GraphQL
document per example): the SDL shown is the live fixture the classifier runs against, projected
through a documented query by the query-as-view renderer with the test-only assertion directives
stripped, and the table beside it states what a real capture and a real generation run make of those
coordinates. CorpusFragmentTest compares every fragment against what the corpus renders now, so a
displayed example is always one the pipeline actually produces the stated outcome for, and this page
holds no expectation of its own: what it authors is the prose, the teaching order, and which examples
to show. The reference table that follows enumerates the verdicts not yet migrated to a worked
example.
@table over an object type
A type carrying @table(name:) classifies as TableType, the pivot for SQL generation, unless a
nodehood or discrimination trigger takes precedence: @node, @discriminate, or implements Node
over a table whose jOOQ class publishes NODE_TYPE_ID / NODE_KEY_COLUMNS. A root field
returning a TableType enters a new query scope rooted in that table, and a scalar child whose name
matches a column projects inline.
type Query {
"A single film, fetched by primary key."
film: Film
}
type Film @table(name: "film") {
"The film's display title."
title: String
}
| Coordinate | Verdict | Emitted |
|---|---|---|
|
|
|
|
no claiming directive |
|
Film classifies as TableType (full SQL generation: queries, joins, projections); Query.film
enters the scope; Film.title projects an inline column. The root type itself classifies as
RootType, which is a fetchers class and nothing more: a root type binds no table, so it opens no
query scope of its own and only wires the fields hanging off it. Asserted by the catalog corpus
example via @classifiedType(as: TableType) on Film and @classifiedType(as: RootType) on Query.
The field descriptions are written on the coordinates themselves, in the corpus document, so the
sentence teaching a coordinate lives where the coordinate does.
@node over a table
A @table type that also carries nodehood classifies as NodeType rather than TableType: either
@node(keyColumns:) written on it, or implements Node over a table whose jOOQ class publishes
NODE_TYPE_ID and NODE_KEY_COLUMNS. Nodehood is a stronger claim than table-binding, so it
takes precedence. The @nodeId field is where it shows: the coordinate carries an authored NODE_ID
claim, and the emitted fetcher encodes the key columns into a Relay global id rather than projecting
a column.
type Query {
film: Film
}
type Film implements Node @table(name: "film") @node(keyColumns: ["film_id"]) {
"The Relay global id, encoded from the node key columns."
id: ID! @nodeId
}
| Coordinate | Verdict | Emitted |
|---|---|---|
|
|
|
|
no claiming directive |
|
Note the tier: NODE_ID is authored, where Film.title in the first example was inferred. The
directive is what makes the difference, and it is the anti-join in intent_resolved_field_claim that
records it: an authored claim at a coordinate masks any structural reading there. Asserted by the
node-type corpus example via @classifiedType(as: NodeType).
Global ids over a composite key
@nodeId encodes a type’s key columns into one opaque id. The key does not have to be a single
column, and the id can be produced from a table other than the one that defines the type: written
with typeName:, @nodeId names the type whose key shape to encode, and reads the columns for it
from wherever the coordinate sits.
type Query {
filmActor: FilmActor
filmActorNote: FilmActorNote
}
type FilmActor implements Node @table(name: "film_actor") @node {
"A global id over a two-column key."
id: ID! @nodeId
}
type FilmActorNote @table(name: "film_actor_note") {
note: String @field(name: "note_txt")
"The same key, encoded from another table that names the target type."
filmActorId: ID @nodeId(typeName: "FilmActor")
}
| Coordinate | Verdict | Emitted |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
no claiming directive |
|
|
no claiming directive |
|
Both @nodeId coordinates claim NODE_ID and both emit, with or without typeName:. The
difference is which key shape gets encoded, not which verdict is reached. Asserted by the
composite-node-key corpus example.
An object type with no binding
An object type carrying no @table and reached from no producer is a grouping type: it has no table
of its own, and its fields resolve against whatever scope its parent opened. It generates a fetchers
class, because its fields still need wiring, but it opens no query.
type Query {
film: Film
}
type Film @table(name: "film") {
"A grouping type with no table of its own."
details: FilmDetails
}
type FilmDetails {
title: String
description: String
}
| Coordinate | Verdict | Emitted |
|---|---|---|
|
no claiming directive |
|
|
no claiming directive |
|
|
no claiming directive |
|
|
no claiming directive |
|
FilmDetails.title and FilmDetails.description read columns of film, the table its parent bound,
which is what "nested under the parent’s scope" means concretely. They fold into the parent’s SELECT
and are projected inline, so the grouping object itself needs no query of its own: Film.details is
wired as a passthrough that hands the parent’s source object straight down. The parameter lists in
the block are the tell. A coordinate with a read to launch takes a DataFetchingEnvironment; one
that is only a value read off the row already in hand takes an Object. Film.details returns an
object and still takes an Object, which is what makes it a passthrough rather than a fetch.
Asserted by the nesting corpus example via @classifiedType(as: NestingType).
@scalarType over a scalar
A scalar declaration carrying @scalarType(scalar:) names a public static final GraphQLScalarType
constant, and that constant is the whole binding:
type Query {
payment: Payment
"The same reflection, one use over: the resolved Java type is the service parameter's."
price(
amount: Money): String @service(service: {className : "no.sikt.graphitron.rewrite.TestServiceStub", method : "wireMoney"})
}
type Payment @table(name: "payment") {
"A library-supplied scalar over a real numeric column."
amount: BigDecimal
}
scalar BigDecimal @scalarType(scalar: "graphql.scalars.ExtendedScalars.GraphQLBigDecimal")
scalar Money @scalarType(scalar: "no.sikt.graphitron.rewrite.scalarfixture.ScalarConstants.MONEY")
| Coordinate | Verdict | Emitted |
|---|---|---|
|
|
|
|
no claiming directive |
|
|
|
|
Both scalars classify as ScalarType, and neither generates anything of its own. What the verdict
buys is a registration and a Java type. The constant is registered on the synthesized schema through
.additionalType(…), and graphitron reflects on its Coercing<I, O> to recover the Java type the
scalar stands for.
The two coordinates show why that recovered type matters, because they are the two places it is
consumed. Payment.amount is an ordinary column read whose projection needs a Field<X>, and X is
what the reflection resolved. Query.price takes the scalar as a @service parameter, and the same
resolution is what lets the argument bind to the method’s parameter type. Nothing about either
coordinate’s own verdict changes: amount is the same TABLE_COLUMN a String column would be, and
price is the same SERVICE any other service root is.
The spec’s own built-ins (Int, Float, String, Boolean, ID) register the same way, but their
Java types come from a closed table rather than from reflection, because the spec binds those names.
Writing @scalarType on one of them is a hard validation error rather than an override. A non-spec
scalar with no @scalarType is the other error: it classifies as UnclassifiedType and the build
points at @scalarType(scalar:) as the single fix, never falling back to Object.
Asserted by the scalar-type corpus example via @classifiedType(as: ScalarType).
A Java class on either side of the boundary
The types so far took their verdict from a directive the author wrote. A type that carries no binding
directive can still get one from Java, by sitting at the boundary where a @service method hands a
value out or takes one in. Nothing is declared; the verdict is read off the method’s signature by
reflection, and which leaf it lands on is simply what kind of Java thing the reflected class is.
Three kinds, and the same three on both sides. Take the result side first: the type is what a producer returns.
type Query {
"A plain Java class: the field resolves against a readable accessor."
pojo: PojoBacked @service(service: {className : "no.sikt.graphitron.codereferences.dummyreferences.DummyService", method : "makeDummyRecord"})
"A Java record: the field resolves against a record component."
javaRecord: JavaRecordBacked @service(service: {className : "no.sikt.graphitron.codereferences.dummyreferences.DummyService", method : "makeTestRecordDto"})
"A jOOQ TableRecord: the field resolves against a column of the record's table."
jooqRecord: JooqTableRecordBacked @service(service: {className : "no.sikt.graphitron.rewrite.TestServiceStub", method : "getFilm"})
}
type PojoBacked {
id: ID
}
type JavaRecordBacked {
name: String
}
type JooqTableRecordBacked {
title: String
}
| Coordinate | Verdict | Emitted |
|---|---|---|
|
no claiming directive |
|
|
no claiming directive |
|
|
no claiming directive |
|
|
|
|
|
|
|
|
|
|
The three result types land on PojoResultType.Backed, JavaRecordType, and JooqTableRecordType,
and the fourth ResultType leaf, JooqRecordType, is what a table-less jOOQ Record reflects to. All
four generate the same thing: a *Fetchers class and nothing else. A backed type owns no SQL scope,
because graphitron did not build the value, it received it.
What the leaf does decide is how a field on such a type resolves. Every one of them classifies as a
plain field read, which is why all three child rows above say "no claiming directive": no directive
claims them, and the resolution is structural. But the structure differs by leaf. PojoBacked.id
resolves against a readable accessor, JavaRecordBacked.name against a record component, and
JooqTableRecordBacked.title against a column of the record’s own table. A field naming something the
class does not expose is a build error at that coordinate, not a runtime null.
The input side is the mirror image: the type is what a consumer takes.
type Query {
"A plain Java class: each input field populates a JavaBean setter."
pojo(
in: PojoBackedInput): String @service(service: {className : "no.sikt.graphitron.codereferences.dummyreferences.DummyService", method : "consumeDummyRecord"})
"A Java record: every component must bind, so the input declares them all."
javaRecord(
in: JavaRecordBackedInput): String @service(service: {className : "no.sikt.graphitron.codereferences.dummyreferences.DummyService", method : "consumeTestRecordDto"})
"A jOOQ TableRecord: each input field binds to a column of the record's table."
jooqRecord(
in: JooqTableRecordBackedInput): String @service(service: {className : "no.sikt.graphitron.codereferences.dummyreferences.DummyService", method : "consumeFilmRecord"})
}
input PojoBackedInput {
id: ID
}
input JavaRecordBackedInput {
name: String
value: Int
}
input JooqTableRecordBackedInput {
title: String
}
| Coordinate | Verdict | Emitted |
|---|---|---|
|
|
|
|
|
|
|
|
|
Only the three roots appear, because an input type has no fields to resolve; it has fields to
populate. The input leaves mirror the result ones (PojoInputType, JavaRecordInputType,
JooqRecordInputType, JooqTableRecordInputType), and again none of them generates a class. What each
one generates is the population code inside the root’s own fetcher, so the service body receives its
declared parameter type rather than a Map.
The direction of the obligation flips here, and that is the part worth reading twice. On the result
side an SDL field must find something on the class; a class member the schema ignores is simply not
exposed. On the input side a Java record inverts it: the canonical constructor needs every component,
so the input type must declare a field for each one. That is why JavaRecordBackedInput carries both
name and value while JavaRecordBacked carries only name. A plain class does not invert it,
because JavaBean population fills what it is given and leaves the rest at its default.
PojoInputType carries a second population it does not share with the others: an input type that no
@service parameter ever reflects still classifies as PojoInputType, unbound. That input has no
backing class at all, and its fields resolve per usage against each consuming field’s table, so the
same input reused on two different tables resolves differently at each one.
Asserted by the result-backing and input-backing corpus examples.
@error over an object type
Every type so far takes its values from something the generator can go and read: a table, a service
return, a component of a record. An @error type is the one that does not. Its values come from a
Java exception thrown while some other field was being resolved, and handlers: is where the author
says which exceptions those are.
It also does not reach the schema on its own. It arrives through an errors: slot on a @service
payload, and that slot is what wires the try/catch: the carrier’s error channel records which
@error types this call can produce, so the generated fetcher knows what to map a caught throwable
into.
type Query {
"The @error type reaches the schema through a payload's errors slot."
sak: SakPayload @service(service: {className : "no.sikt.graphitron.rewrite.TestServiceStub", method : "runSak"})
}
type SakPayload {
data: String
errors: [ExtraFieldError]
}
type ExtraFieldError @error(handlers: [{handler : GENERIC, className : "java.lang.IllegalArgumentException"}]) {
path: [String!]!
message: String!
severity: Severity!
}
enum Severity {
LOW
HIGH
}
| Coordinate | Verdict | Emitted |
|---|---|---|
|
no claiming directive |
|
|
no claiming directive |
|
|
no claiming directive |
no method of its own |
|
|
|
|
no claiming directive |
|
|
no claiming directive |
|
path and message are required on every @error type, and graphitron supplies both values itself.
That is why each gets a generated method taking the DataFetchingEnvironment rather than a source
object: path synthesises from the execution context’s path, the SDL position the error fired at
(a validation-derived source routes through GraphQLError.getPath() instead, so the per-element
paths survive), and message resolves at build time to the handler’s description: when the author
wrote one and to the source’s getMessage() when they did not.
Any field beyond those two is the author’s. severity is one, and its row reads no method of its
own because nothing needs generating for it: the schema’s registration wires a
PropertyDataFetcher straight onto the accessor, reading getSeverity() off whatever was caught.
The accessor name is the field’s own unless @field(name:) names a different one. Graphitron does
check at classify time that every extra field has an accessor to read from on every class in the
channel, but that check fires on the carrier and not on the @error type, so an extra field never
moves the type’s verdict away from ErrorType.
The payload’s data field is a plain passthrough over the record the service returned, the
reflection-derived backing of the previous section. errors is not. It is the
error channel’s own coordinate, and the method in its row is where a caught throwable becomes a list
the wire can carry.
What that method looks like depends on how the carrier ships the errors, which the classifier
resolves once, with the parent’s channel in scope, and which has three answers. Here the errors ride
an error-list arm of the value the producer returned, so the emitted method takes the source and
unwraps that arm, resolving to null on the success arm to honour the field’s nullability; that is the
(Object) signature in the block above. A carrier that ships them out of band instead, on the
result’s local context, emits a method taking the DataFetchingEnvironment and reading them back
from there. A plain class-backed parent whose errors-shaped field is a slot the developer
populated emits no method at all: the registration wires a PropertyDataFetcher onto the property,
the way severity is wired above. Only that third form is a passthrough, and a producer-bound
payload like this one does not take it.
The rest of the handlers: option set (SQL-state and vendor-code matching, validation fan-out,
matches: message filters) is in docs/manual/reference/directives/error.adoc; the classification
verdict does not vary with any of it.
Asserted by the error-type corpus example.
Reference table
What is left here is the surface a worked example cannot show. The corpus renders patterns that classify and generate, so a deprecation warning and two rejections have no example to be pulled into; they stay stated rather than demonstrated.
| Classification Trigger on Type | GraphitronType Variant |
Generator Output |
|---|---|---|
Input type with |
Whatever the same input without the directive classifies as |
No generation decision of its own: the directive is ignored, |
Unresolvable directives |
|
Validation error ; build fails |
Conflicting type directives ( |
Arm-order winner |
Validation error from the store-backed conflict detection ( |
Intermediate sealed interfaces (not shown in the table ; grouping nodes in the hierarchy):
- TableBackedType ; groups TableType, NodeType, TableInterfaceType. Builders switch on this to detect table-mapped types.
- ResultType and InputType group the reflection-derived leaves, four each, worked through under
A Java class on either side of the boundary.
Field Classification
|
Transitional. This section describes the sealed field leaves the classification walk produces and the generator output each drives, a surface being drained rather than one to extend. What it states is accurate today. For the architecture the walk drains into, see Pipeline overview. |
A field’s classification factors into three asserted axes plus a derived layer. The worked examples below
render the dimensional form from the corpus; the leaf-name tables that follow are a curated reference,
enumerating each sealed variant and the generator output it drives. The corpus, not these tables, is the
coverage source of truth: VariantCoverageTest guarantees every output-field leaf is demonstrated by a
corpus fixture, so a variant absent from a table below is still tested, just not featured here in prose.
A field is an edge: it arrives into a source, performs an operation, and projects a target.
The three asserted axes are:
-
source, the field’s arrival endpoint, a wrapper around a shape. The wrapper arm is position and the legality gate:Root(permittingQuery/Mutation) is a root field,OnlyChild/Childis a nested field (one or many source objects arriving). Write operations are legal only onRoot.Mutation,NodeResolveonly onRoot.Query,Nestonly on a nested source. The nested arms wrap aSourceShape(Table/Record), the catalog-vs-Java polarity of what arrives atenv.getSource(). -
operation, the verb the field performs: reads (Fetch,Paginate,Lookup,NodeResolve,Nest, and the modeled-but-unpopulatedEntityResolve/Count/Facet), writes (Insert,Upsert,Update,Delete, and the unpopulatedUpdateMatching/DeleteMatching), and the developerServiceCall(the read-vs-write split it once carried is now read off thesourceroot). -
target, the field’s projection endpoint, a wrapper (Single/List) around aTargetShape. The shape carries build-vs-consume:Table/Columnare catalog shapes graphitron builds the SQL for;Record/Fieldare domain shapes graphitron consumes without having built;Connection,Interface,Unionare the container and polymorphic shapes. `Table:Column-
Record:Field` (mirror : reflect). A Relay connection is
Single(Connection(…)), its windowed-read verb thePaginateoperation.
The derived layer is computed from those three plus the field’s slots and schema position, never
asserted: fetch-related (a Fetch reaching a related entity over a join-path), re-fetch (a
service/DML producer yielding a Table shape, forcing a re-projection), new-query (@splitQuery /
polymorphic / record-handoff opening a fresh keyed query), and polarity (mutating-or-not, from the
source root and the write operations). The governing principle is assert what nothing else carries;
derive what another axis or slot already forces. The subsections below sound out the derived layer and
each axis in turn.
The derived layer: same verdict, different mechanism
Two fields can share an identical (source, operation, target) verdict yet emit different SQL, because
the fetcher/loader mechanism is derived, not asserted. The cleanest illustration is the new-query
derivation: a @table child reachable by a foreign key from a @table parent inlines as a correlated
subquery folded into the parent’s SELECT, while adding @splitQuery opens a new keyed query dispatched
through a DataLoader. Both classify identically (source = Child(Table), operation = Fetch,
target = Single(Table)); only the derived new-query layer differs, forced by the @splitQuery slot.
type Query {
city: City
}
type City @table(name: "city") {
country: Country
countrySplit: Country @splitQuery
}
type Country @table(name: "country") {
name: String @field(name: "country")
}
| Coordinate | Verdict | Emitted |
|---|---|---|
|
no claiming directive |
|
|
no claiming directive |
|
|
|
|
|
no claiming directive |
|
City.country and City.countrySplit return the same Country over the same city → country foreign
key and carry the same Child / Fetch / Table verdict; @splitQuery flips only the derived
new-query layer, not an asserted axis. Asserted by the child-table corpus example.
The two mechanisms are worth naming, because every @table child on the page is one or the other.
Inline means a DSL.multiset correlated subquery projected into the parent’s $project block, and at
single cardinality that subquery adds a .limit(1) and a read-side unwrap lambda to turn the one-row
list back into an object. Split means an async DataLoader fetcher plus a rows*() method that takes
the parent keys as a batch.
The record-handoff boundary
@splitQuery is not the only trigger for the new-query derivation. A @table child reached by a foreign
key inlines under a @table parent, but the same FK-reached child re-queries under a record-backed
parent, because the record handoff has already opened a new DataLoader-backed scope that the correlated
subquery cannot fold back into. The parent’s table-ness, not a directive, forces the derived re-query
here; the asserted verdict is unchanged.
type Query {
film: Film
}
type Film @table(name: "film") {
language: Language @reference(path: [{key : "film_language_id_fkey"}])
details: FilmDetails
}
type Language @table(name: "language") {
name: String
}
type FilmDetails {
language: Language @reference(path: [{key : "film_language_id_fkey"}])
}
| Coordinate | Verdict | Emitted |
|---|---|---|
|
no claiming directive |
|
|
no claiming directive |
|
|
no claiming directive |
|
|
|
|
|
no claiming directive |
|
Film.language (TableField) and FilmDetails.language (a record-sourced BatchedTableField) return the same Language
over the same film_language_id_fkey foreign key and carry the same Fetch / Table operation and
target; they differ only on the source shape (Child(Table) vs Child(Record)), and the record handoff
forces a derived keyed re-query under FilmDetails, not a different asserted axis. That re-query is
what the emitted names above are: FilmDetails.language gets an async DataLoader fetcher and a
rows*() method, keyed on the foreign-key columns lifted off the parent’s jOOQ TableRecord, where
Film.language folds into the parent’s own SELECT. Asserted by the record-table corpus example.
target shape: build-vs-consume (Column vs. Field)
The target shape records what domain object the value is, and with it whether graphitron builds the
SQL (catalog: Table / Column) or consumes a value it did not build (domain: Record / Field).
Two scalar flavors hinge on the parent’s table-ness: a scalar under a @table parent projects a Column
(a real database column graphitron projects), while under a record-backed parent (a plain object with no
@table, here produced as a service method’s return type) a scalar projects a Field (a POJO property
read off a record graphitron only reflects). A nested non-table object under a record parent is the object
flavor of the same Field shape. All three are Fetch; the parent’s table-ness moves the source shape
(Table vs Record) and with it the target shape across the build-vs-consume line.
type Query {
film: Film
}
type Film @table(name: "film") {
title: String
details: FilmDetails
}
type FilmDetails {
stats: FilmStats
}
type FilmStats {
count: Int
}
| Coordinate | Verdict |
|---|---|
|
no claiming directive |
|
|
|
no claiming directive |
|
no claiming directive |
|
no claiming directive |
| This pattern classifies but does not generate: the build rejects it, so there are no emitted names to show. The verdicts above are what the store derives either way. See Typed rejection for what a rejection carries. |
Film.title classifies with target shape Column (title is a column of the film table graphitron
builds the projection for). FilmStats.count, a scalar under the record-backed FilmStats, classifies
with target shape Field (a property graphitron reflects). FilmDetails.stats, a nested non-table object
under the record-backed FilmDetails, also classifies with target shape Field (its object flavor). All
hold operation = Fetch; the two record-backed cases also carry source = Child(Record). Neither
record-backed case emits SQL of its own: both are wiring values, and what the generator has to decide
for each is only where on the backing object the value is, carried as the leaf’s sealed
ValueLocator arm (a typed column, a resolved accessor, a by-name read, or graphql-java’s default
property read). Asserted by the mapping corpus example.
Polymorphic fields: interfaces, unions, and Relay nodes
A field returning an interface, a union, or a Relay Node is catalog-bound over the participant
types: classification resolves the polymorphic type to its participant @table rows and projects each
branch from the catalog. The target shape is therefore Interface / Union (the projection lands on
participant table rows), with the participant set carried as a derived slot rather than as a distinct
shape value. The operation is Fetch for interface and union fields (root or child) and NodeResolve
for the Relay node / nodes roots; the new keyed query a plain-interface or union field opens
(InterfaceField / UnionField, and the polymorphic roots QueryInterfaceField / QueryUnionField /
QueryNodeField / QueryNodesField) is a derived new-query, not an asserted axis. The one structural
difference is a @table+@discriminate interface child (TableInterfaceField /
BatchedTableInterfaceField): it is foreign-key-correlatable from the parent, but the discriminated
re-projection is still its own statement rather than a fold into the parent’s SELECT, so what the
cardinality decides is how many statements, not whether there is one. It follows the same rule as the
multi-table pair above, with the participant conjunct holding structurally (the parse boundary rejects a
non-table implementor of a discriminated interface): list cardinality batches through a DataLoader,
single cardinality fetches per parent. Either way its asserted Child / Fetch / Table verdict is the
same.
type Query {
customer: Customer
}
type Customer @table(name: "customer") {
address: Named
}
interface Named {
name: String
}
| Coordinate | Verdict | Emitted |
|---|---|---|
|
no claiming directive |
|
|
no claiming directive |
no method of its own |
|
no claiming directive |
|
Customer.address returns the plain interface Named (implemented by the @table type Address),
so it classifies with source = Child, operation = Fetch, target shape Interface (InterfaceField):
a derived new keyed query projects the participant table, with Address recorded as the participant slot.
Named itself classifies as InterfaceType, the verdict for an interface carrying no @table of its
own, and generates a fetchers class. Asserted by the interface corpus example via
@classifiedType(as: InterfaceType).
A union child follows the same dimensional rule. The selection descends into each participant through an
inline fragment (… on Film, … on Actor), and classification resolves the union to its participant
@table rows:
type Query {
filmActor: FilmActor
}
type FilmActor @table(name: "film_actor") {
related: FilmOrActor
}
type Film @table(name: "film") {
title: String
}
type Actor @table(name: "actor") {
firstName: String @field(name: "FIRST_NAME")
}
union FilmOrActor = Film | Actor
| Coordinate | Verdict | Emitted |
|---|---|---|
|
|
|
|
|
|
|
no claiming directive |
|
|
no claiming directive |
|
FilmActor.related returns the union FilmOrActor, so it classifies with source = Child,
operation = Fetch, target shape Union (UnionField): a derived new keyed query projects whichever
participant table a row resolves to, with Film and Actor recorded as the participant slots.
FilmOrActor itself classifies as UnionType and generates a fetchers class, the same shape
InterfaceType gets: a union binds no table, and its participants carry the catalog binding. Asserted
by the union corpus example via @classifiedType(as: UnionType).
The @table+@discriminate interface child is a further leaf on this same polymorphic rule, and the
one where cardinality forks the delivery; it gets its own worked example under
One table, many participants below. The Relay Node root
(QueryNodeField, operation = NodeResolve, target shape Interface) is another, worked through
under Any root that returns Node.
A discriminated interface may also be a joined-table (class-table) inheritance: each concrete type
declares its own detail @table distinct from the discriminated base, with its inherited (base-resident)
fields carrying a @reference back to the base and its own columns living on the detail table. Such a
participant classifies as a ParticipantRef.JoinedTableBound carrying the resolved child-to-parent hop
(rather than a single-table TableBound); its inherited field is a ColumnBackedReferenceField resolved on
the base and its own field a plain ColumnBackedField on the detail table. The interface fetcher selects from
the base and emits a discriminator-gated LEFT JOIN to each participant’s detail table, projecting the
shared fields off the base and each participant’s detail-exclusive columns off its detail alias; the same
concrete type is independently queryable on its own, resolving its inherited fields through the parent
reference. The child-to-parent join must be PK=FK (the detail table’s foreign-key columns to the base are
its own primary key, single-column or composite), which keeps the base-to-detail join single-valued.
Asserted by the joined-table-interface corpus example.
@asConnection on a discriminated interface root moves the coordinate along the operations and target
axes exactly as it does on a plain table root: the member set gains Paginate, and the target becomes
Single of shape Connection. Nothing about the source axis changes, so the launch stays the
discriminated composition and the page is cut over it.
That composition may be paginated because every join it emits is proven single-valued: the
base-to-detail join is the PK=FK edge above, and a participant scalar reached one @reference hop off
the base is projected as a capped correlated subselect, not joined in. LIMIT slices rows, so the
property that makes a page correct is that one row is one entity; both facts above are what establish
it, and totalCount counts the base under the same predicate the page ran under.
type Query {
"A page of parties, each routed to its concrete type by the discriminator."
parties: [Party!]! @asConnection
}
interface Party @table(name: "party") @discriminate(on: "party_kind") {
displayName: String! @field(name: "display_name")
}
type Individual implements Party @table(name: "party_individual") @discriminator(value: "INDIVIDUAL") {
birthDate: String @field(name: "birth_date")
}
| Coordinate | Verdict | Emitted |
|---|---|---|
|
|
|
|
|
no method of its own |
|
no claiming directive |
|
|
no claiming directive |
|
|
no claiming directive |
|
Asserted by the paginated-joined-table-interface corpus example.
Where a participant’s projection alias comes from
A single-table discriminated interface runs one statement, and every participant’s projection folds into its one select list. Two participants can therefore project two different values under one name, which makes the SELECT alias load-bearing: the fold collects terms into a set that treats two aliased terms with the same alias as the same term, so a name minting one alias in both arms can carry only one of the two projections. The alias namespace answers that by naming an owner.
-
A field a participant declares itself is owned by that participant type. Two participants declaring the same field name over different
@referencepaths are two distinct terms, and each type’s data fetcher reads its own. -
A field the interface declares is owned by the interface, so every participant’s arm mints the identical alias. That is deliberate: the agreeing case is one shared term for every implementer, exactly as before.
The split shows up in the author-facing promise, and where the field is declared decides which half applies:
| Divergence | What happens |
|---|---|
Two participants declare an interface field differently (different resolved path, condition or arguments) |
Build error, a deferred rejection naming both declarations. One shared alias cannot carry two projections, and the generator does not qualify interface-declared names per participant yet. |
A query selects an interface-declared field with different arguments per type |
Runtime client error. Every arm merges every occurrence of a shared name, so one arm would have to serve one selection’s arguments to the other type’s rows. |
A query selects a participant-declared field with different arguments or sub-selections per type |
Resolved per type. Each arm sees only its own occurrences, so nothing is merged across types and there is nothing to disagree about. |
A participant that also participates in a joined-table interface is the one shape left out: its
inherited references would be projected off the base under the unqualified alias, which its qualified
read cannot address, so that coordinate is a deferred rejection rather than a silent miss. No schema
reaches it today: a single-hop @reference off a discriminated base is claimed as a cross-table
participant field, which projects under its own fixed <TypeName>_<fieldName> alias and so never
enters the result-key namespace at all. The rejection is a backstop against a future change that
moves such a coordinate back into that namespace, not a shape to write a schema against.
One table, many participants
An interface carrying @table plus @discriminate(on:) is a single table read through a column that
says which participant each row is. The interface’s own fields resolve against that table, and the
participants add nothing but a discriminator value. Both cardinalities are below, because this is the
one polymorphic child where cardinality decides something.
type Query {
inventory: Inventory
language: Language
}
type Language @table(name: "language") {
"Discriminated participants, read from one table."
mediaList: [MediaItem!]! @reference(path: [{key : "film_language_id_fkey"}])
}
interface MediaItem @table(name: "film") @discriminate(on: "text_rating") {
title: String
}
type Inventory @table(name: "inventory") {
"The same interface at single cardinality."
media: MediaItem
}
| Coordinate | Verdict | Emitted |
|---|---|---|
|
no claiming directive |
|
|
no claiming directive |
|
|
|
no method of its own |
|
no claiming directive |
|
|
no claiming directive |
|
MediaItem.title shows something the emitted column is there to make visible: a coordinate can carry
a verdict and still emit no method of its own. It is projected by the enclosing fetcher’s
discriminated read, not wired separately. A blank in that column is a fact about where the work
happens, not a gap.
The two child coordinates carry the same asserted Child / Fetch / Table verdict and differ only
below it. Language.mediaList is a list, so it batches: a DataLoader fetcher and a rows*() method,
with the discriminated re-projection running over the batch’s parent-input VALUES anchor.
Inventory.media is single, so it fetches per parent row, one re-projection each. The batch key is
the foreign-key hop’s source side rather than the parent’s primary key, because one foreign key
correlates the whole participant set here instead of each participant holding its own. What
cardinality decides is how many statements, not whether there is one; either way the re-projection
is its own statement rather than a fold into the parent’s SELECT, which is what separates this child
from a plain @table child. The @reference on the list side is not part of that difference: it is
there because film holds two foreign keys to language and one of them has to be named.
Asserted by the table-interface corpus example via @classifiedType(as: TableInterfaceType).
A reference on one participant only
Participants of a discriminated interface may reach past the shared table. A @reference on one of
them is read for that participant’s rows alone, gated by the discriminator, and it does get a method,
because it is not part of the shared projection.
type Query {
content: Content
}
interface Content @table(name: "content") @discriminate(on: "CONTENT_TYPE") {
contentId: Int! @field(name: "CONTENT_ID")
}
type FilmContent implements Content @table(name: "content") @discriminator(value: "FILM") {
"Reached over a foreign key from one participant only."
rating: String @reference(path: [{key : "content_film_id_fkey"}]) @field(name: "RATING")
}
| Coordinate | Verdict | Emitted |
|---|---|---|
|
|
no method of its own |
|
|
|
|
no claiming directive |
|
Both coordinates carry the same verdict at the same tier, and only one of them emits: the shared
interface field folds into the discriminated read, the participant-local reference does not. Asserted
by the participant-reference corpus example.
Reading the generator-output column
The "`*Fetchers` Generates" column in the tables below names which of four emission paths a variant
drives. The four are an exhaustive, disjoint partition of every field leaf, enforced by
GeneratorCoverageTest.everyGraphitronFieldLeafHasAKnownDispatchStatus, so a new leaf cannot be added
without declaring its path:
-
Fetcher method ; a real method on the generated
Fetchersclass: a synchronous field fetcher, or an asynchronous DataLoader fetcher paired with arows()batch method (called out as DataLoader-backed where it matters). These are theIMPLEMENTED_LEAVES. -
Inline projection ; no per-field method ; the value is projected straight into the enclosing projection unit’s SELECT by the generated
$projectmethod (ProjectionUnitRenderer). This bucket is derived from the projection producer’s membership declaration (ProjectionCommands.CONTRIBUTION_MINTING_LEAVESminus the dual-arm kinds). -
Wiring value ; no generated method either ; the field is registered as a
DataFetcher/ColumnFetchervalue throughFetcherEmitter/FetcherRegistrationsEmitter. These areIMPLEMENTED_LEAVESleaves with an empty dispatch arm. -
Deferred stub ; a throwing stub method standing in for generation that is not built yet, paired with a build-time validator rejection. The set is empty today (
STUBBED_VARIANTS): the last stub retired when the rooted-at-parent NodeId reference deferral moved to the validator, which rejects theNodeIdEncodeKeyscompaction onColumnBackedReferenceFieldat every arity ahead of generation.
Looking a root up by keys the caller supplies
@lookupKey marks an argument whose values are the keys to look up. It is the only claim on this page
written on an argument rather than on the field, and the claim still lands on the field: the
coordinate reads LOOKUP_KEY, authored.
type Query {
filmActor: FilmActor
"Films fetched by a caller-supplied list of ids, one row back per id."
filmById(
film_id: [ID] @lookupKey): [Film]!
}
type Film @table(name: "film") {
filmId: Int! @field(name: "film_id")
}
type FilmActor @table(name: "film_actor") {
actors(actor_id: [Int!]! @lookupKey): [Actor!]!
}
type Actor @table(name: "actor") {
firstName: String @field(name: "first_name")
}
| Coordinate | Verdict | Emitted |
|---|---|---|
|
|
|
|
|
|
|
no claiming directive |
|
|
no claiming directive |
|
|
|
|
The contract on the root is positional: one output element per key passed, with null at the position
of a key that matched nothing. That is what forces the return type’s elements to be nullable, and a
[Film!]! there is an author error whose message says so. Film.filmId inside the looked-up rows is
an ordinary inferred column read, unaffected by how its rows were reached.
The root’s read is synchronous. There is no parent set to batch over, so Query.filmById is a thin
fetcher over a lookupFilmById rows method that runs the statement and returns its rows directly. A
rows method is not by itself a sign of batching; it is what makes the read callable from outside the
fetcher, so entity resolution can reach the same statement.
FilmActor.actors carries the same directive one level down and reads no claiming directive.
Position is what decides. On a root there is nothing but the argument to say where the rows come from,
so @lookupKey claims the coordinate. On a child the return type has already said it, and the argument
only keys the correlated subquery the child was going to be either way, so the child keeps whatever
verdict its return type gives it. The axis that does move is the operation: the child is
Child / Lookup / Table rather than Child / Fetch / Table, and what it emits is the ordinary
inline shape, a DSL.multiset correlated subquery whose keyset arrives as a VALUES join over the
argument’s values. Asserted by the lookup corpus example.
Batching the child lookup
@splitQuery does to a lookup-keyed child what it does to a plain one, and it is worth seeing on this
shape because two directives are now in play on the same field.
type Query {
store: Store
}
type Store @table(name: "store") {
customers(customer_id: ID! @lookupKey): [Customer!]! @splitQuery
}
type Customer @table(name: "customer") {
firstName: String @field(name: "FIRST_NAME")
}
| Coordinate | Verdict | Emitted |
|---|---|---|
|
|
|
|
no claiming directive |
|
|
no claiming directive |
|
Store.customers classifies exactly as FilmActor.actors above does: same unclaimed coordinate, same
Child / Lookup / Table verdict. @splitQuery replaces the inline correlated subquery with an
async DataLoader fetcher and a rows*() method keyed on the lookup values, which is the same
substitution the derived layer makes on a child with no lookup at all.
The two directives are independent axes, so the four combinations of them are the four spellings a
@table child has: neither (inline subquery), @splitQuery alone (batched by the parent’s foreign
key), @lookupKey alone (inline, keyed by the argument), and both (batched, keyed by the argument).
Only @lookupKey moves an asserted axis. Asserted by the split-lookup corpus example.
The one spelling that paginates
Exactly one of those four spellings can carry @asConnection.
type Query {
country: Country
}
type Country @table(name: "country") {
"A batched child that also paginates: @splitQuery opened the statement the page needs."
cities: [City!]! @splitQuery @asConnection @defaultOrder(primaryKey: true)
}
type City @table(name: "city") {
city: String
}
| Coordinate | Verdict | Emitted |
|---|---|---|
|
|
|
|
no claiming directive |
|
|
no claiming directive |
|
|
no claiming directive |
|
|
no claiming directive |
|
A page needs a statement of its own to put a window on, and @splitQuery is what opens one, so it is a
precondition here rather than a companion directive. The other three spellings are refused, for two
different reasons. The inline child is refused because its correlated subquery is folded into the
parent’s SELECT and there is nothing to window: @asConnection without @splitQuery is rejected
outright. Either lookup-keyed spelling is refused because a page would break the positional
correspondence @lookupKey establishes, and that holds whether or not @splitQuery is also present.
CountryCitiesConnection and CountryCitiesEdge are synthesised for the coordinate and named after it,
exactly as on a paginated root; being batched changes where the rows come
from, not how the wrapper types are made.
The two directives contribute one VALUES derived table each, so what a spelling builds follows from
what it carries: @splitQuery a derived source table of parent rows, @lookupKey a
derived target table of argument values. The batched lookup carries both and its base result count is
N × M; a root lookup has no parent side, so it returns exactly M. Asserted by the paginated-child
corpus example.
Query Fields
A root field’s position is an axis value, not a separate classification: source = Query is what
makes it a root, and the return type decides the rest exactly as it does on a child. The default case
is the plain @table return of the @table type example, which emits a full
fetcher: the condition call, the orderBy build, and the inline DSL chain
(dsl.select(Type.$project(…)).from(table)…) that opens the query scope. The polymorphic returns
are the polymorphic section above, where a
root and a child differ only in where they arrive: a multi-table interface or union root emits per-
participant polymorphic projection through a derived new keyed query, and a @table+@discriminate
interface root is the discriminated composition shown there under @asConnection.
What is left is the @service pair, the failure row, and the Relay node roots, which get a worked
example of their own at the end of this section:
| Schema Pattern | QueryField Variant |
*Fetchers Generates |
|---|---|---|
|
|
Async DataLoader fetcher + |
|
|
Fetcher method |
Anything else |
|
Validation error ; build fails |
The federation _entities field is not modelled as a QueryField permit; it is resolved by federation-graphql-java-support directly and dispatched through the generated EntityFetcherDispatch runtime helper. The corresponding entity-resolution metadata is carried in GraphitronSchema.entitiesByType.
A @service field’s input parameter may itself be a generated jOOQ TableRecord (singular or List<…>), distinct from the field’s return type. When the parameter’s SDL input type classifies as JooqTableRecordInputType, the call site binds it on the column axis rather than instantiating a Java bean: each plain input field names a column through @field(name:), and an optional @nodeId field decodes the record’s scalar key. The parameter is materialised by a generated create<Record> (singular) / create<Record>List (list) helper on the *Fetchers class (the CallSiteExtraction.JooqRecord binding), which loads the columns through record.fromArray(…, Tables.<T>.<col>…) and decodes the identity through NodeIdEncoder.decodeValues. The binding is coordinate-agnostic: it applies identically whether the @service field is a root field or a @table-parent child field, sharing one helper.
|
On that same column axis, several plain input fields may name one column, which is the standard GraphQL rename-deprecation shape: add the new field, keep the old one marked @deprecated until its removal date, and point both at the column. InputBeanResolver admits the overlap when all but at most one of the colliding fields carry native @deprecated, and merges the group into a single CallSiteExtraction.ColumnBinding carrying ordered read paths rather than several writers on one column. Precedence is the live (non-deprecated) path first, then the deprecated paths in reverse declaration order; the emitted load tries them in that order and takes the first containsKey-present one, so a client sending both names gets the live value, a client sending only the old name still writes, and a client sending neither leaves the column changed=false. Two live fields on one column stay an author error (JooqRecordInputError.LiveColumnCollision). This is the only write path that admits the pattern: the INSERT and UPDATE mutation paths reject an all-plain column overlap on their own mechanisms (a SET map holding one value per column, and a bulk VALUES join that cannot name one derived column twice).
|
Any root that returns Node
A root field whose element type is the Node interface is a node fetcher. Its name has nothing to
do with it:
type Query {
"Relay's own entry point: one global id at a time."
node(
id: ID!): Node
"The list form of the same signature."
nodes(
ids: [ID!]!): [Node]
"Not named `node`, and classified as one anyway: the signature is the trigger."
internalFilmNode(
id: ID): Node
}
interface Node {
id: ID!
}
type Film implements Node @table(name: "film") @node {
title: String
}
| Coordinate | Verdict | Emitted |
|---|---|---|
|
|
|
|
no claiming directive |
no method of its own |
|
no claiming directive |
|
|
no claiming directive |
|
|
no claiming directive |
|
All three roots classify the same way: source = Query, operation = NodeResolve, target shape
Interface. Recognition is by signature, never by field name, and internalFilmNode is in the example
to make that testable rather than merely stated. A federation subgraph commonly publishes an extra
by-id entry point under its own name; name-based dispatch would classify that as a plain interface
root and give it a keyed query over the participants instead of node resolution.
Cardinality is the one axis that changes the delivery. The single-valued roots read the id argument
and return the row synchronously. The list root fans its ids out into DataLoaders keyed by the
execution path, so ids arriving at the same path batch into one read, and returns a future. Both go
through one generated dispatcher, which peels the type prefix off each global id and resolves the row
through the same entity dispatch federation _entities uses; a null, malformed, or unknown-prefix id
comes back as null rather than an error, which is what the Relay spec asks for. That dispatcher is
emitted only when the schema classifies at least one NodeType.
Film carries @node and its id carries @nodeId, which is what makes it reachable by global id at
all; that declaration is the @node example above. Asserted by the relay-node
corpus example.
Mutation Fields
A mutation whose @mutation(typeName:) writes the catalog and returns a @table type classifies with
source = Mutation, the write verb as its operation (Insert / Update / Delete), and
target shape Table. The write produces the affected row, then a follow-up SELECT re-projects it
through the catalog; that read-back is the derived re-fetch (a write producer yielding a Table shape),
not a separate asserted axis. The mutation’s input object is part of the rendered closure, so the excerpt
shows the plain input the write consumes; the @table return names the write target:
type Mutation {
createFilm(in: FilmInput!): Film @mutation(typeName: INSERT)
}
type Film @table(name: "film") {
title: String
}
input FilmInput {
title: String
}
| Coordinate | Verdict | Emitted |
|---|---|---|
|
|
|
|
|
|
Mutation.createFilm inserts a film row from FilmInput and projects the inserted row as the @table
type Film: source = Mutation, operation = Insert, target shape Table (MutationField.DmlTableField carrying an Insert write arm),
the read-back being the derived re-fetch. Asserted by the dml corpus example.
The other two write verbs differ from INSERT in what they can hand back, and the pair below is
that difference alone: same claim, same tier, one returning the written row and one returning an id.
type Mutation {
"Writes the row, then projects it back through the catalog."
updateFilm(
in: FilmUpdateInput!): Film @mutation(typeName: UPDATE)
"Cannot project a row that is gone; hands back the deleted row's id."
deleteFilm(
in: FilmKeyInput!): ID @mutation(typeName: DELETE, table: "film")
}
type Film implements Node @table(name: "film") @node {
title: String
}
input FilmUpdateInput {
filmId: Int! @field(name: "film_id")
title: String
}
input FilmKeyInput {
filmId: Int! @field(name: "film_id")
}
| Coordinate | Verdict | Emitted |
|---|---|---|
|
|
|
|
|
|
|
|
|
UPDATE behaves like INSERT: it writes, then re-projects the affected row, so its target shape is
Table and the return type names the write target. DELETE cannot do that, because after the write
there is no row left to project; RETURNING carries the primary key and nothing else, so the return
tops out at an encoded ID and the target shape is Column. That is also why deleteFilm has to
say @mutation(table: "film"): an ID return names no type, so the write target has nowhere else to
come from. Both coordinates carry the same MUTATION claim at the same tier, which is the point:
the verb is a slot of the claim, not a claim of its own. Asserted by the mutation-roots corpus
example, which also pins the multiRow broadcast form of DELETE.
Returning a payload wrapper instead of the row
A mutation may hand back a carrier rather than the written row: a plain object with one data field
naming what was written, optionally beside an errors-shaped field. The carrier binds no table, so the
write’s affected rows reach it as a record and the coordinate’s target shape is Record instead of
Table. The claim and the verb are unchanged; what moves is where the read-back lives.
type Mutation {
updateFilmPayload(in: FilmUpdateInput!): FilmUpdatePayload @mutation(typeName: UPDATE)
}
type FilmUpdatePayload {
"The data field owns the read-back, a new keyed query off the written record's keys."
film: Film
}
type Film @table(name: "film") {
title: String
}
input FilmUpdateInput {
filmId: Int! @field(name: "film_id")
title: String
}
| Coordinate | Verdict | Emitted |
|---|---|---|
|
|
|
|
no claiming directive |
|
|
|
|
On a direct @table return the write and the re-projection are one coordinate’s business, the derived
re-fetch above. A carrier splits them into two. The mutation emits a two-step fetcher: the per-kind DML
chain runs inside transactionResult with a PK-only RETURNING, and the data field owns the follow-up
SELECT, issued outside the transaction as its own keyed query off the returned keys. That is why
the carrier’s film field carries a nested verdict of its own rather than continuing the write’s: it
arrives as OnlyChild on a Record source shape (the RETURNING record, not a catalog row), and
projects Single of shape Table. Its operation members are the reentry into the catalog and the
projection itself.
Bulk input takes the same shape with a list-shaped data field: one transactionResult runs the per-row
DML and accumulates a typed result in input order, and the write target is derived from that
@table-element data field rather than from the carrier. DELETE is the one verb whose carrier cannot
hold a @table data field, for the reason deleteFilm above cannot project one; its only admissible
data field is an encoded id read off the deleted row’s RETURNING record. Asserted by the
dml-payloads corpus example, which also pins both bulk forms, and by dml-delete-payload for the
DELETE carrier.
A mutation whose producer is a Java method
@mutation is not the only way onto a mutation root. @service puts a Java method behind the
coordinate instead of a DML write, and the claim it lands is SERVICE, not MUTATION: no verb, no
write target, nothing for @mutation(table:) to name. What the return type still decides is the
target shape, and the pair below is that difference alone.
type Mutation {
"A @table return: the service's rows are re-queried through the catalog."
importFilm: Film @service(service: {className : "no.sikt.graphitron.rewrite.TestServiceStub", method : "runFilm"})
"A non-table return: what the service produced is the answer."
summariseFilm: FilmDetails @service(service: {className : "no.sikt.graphitron.rewrite.TestServiceStub", method : "runDetails"})
}
type Film @table(name: "film") {
title: String
}
type FilmDetails {
title: String
}
| Coordinate | Verdict | Emitted |
|---|---|---|
|
|
|
|
no claiming directive |
|
|
|
|
|
|
|
Both roots are source = Mutation, operation = ServiceCall. importFilm returns Film, a @table
type, so the service’s values have to become catalog rows again: target shape Table, and the
coordinate opens a keyed re-query the way the child-coordinate @service pair below does, which is
why Film.title reads a column of film rather than a property of whatever the method returned.
summariseFilm returns a type that binds no table, so there is nothing to look up and the service’s
own value is the answer: target shape Record. That is the same table-vs-non-table split
service-table-child and service-scalar-child draw on a child coordinate, one rung up; the parent
being a mutation root moves the source axis and nothing else.
A root carries one of the two claims, never both; the conflict row below says what happens when an
author writes both. Asserted by the mutation-service corpus example.
Four of the five rows left below are rejections, which the corpus does not render: it demonstrates patterns that classify and generate, so a build failure has no worked example to be pulled into. The first row is the exception, a live pattern still stated rather than shown.
| Schema Pattern | MutationField Variant |
*Fetchers Generates |
|---|---|---|
|
|
As the direct- |
|
|
Validation error ; build fails. |
|
|
Rejected at classification. UPSERT is recognised and deliberately not generated: the write arm has no producer today, so the coordinate defers rather than erroring. |
Neither |
|
Validation error ; build fails |
Both |
Arm-order winner |
Validation error from the store-backed conflict detection ( |
The direct-return DmlTableField permit is guaranteed never to carry a class-backed (record-shaped) return: every DML mutation whose return type reflects to a record carrier routes through the DML-carrier permits (MutationDmlRecordField / MutationBulkDmlRecordField) via BuildContext.scanStructuralDmlPayload. The narrowness is enforced structurally by the routing in FieldBuilder.classifyMutationField, not by an Invariant the validator restates.
When the value comes from somewhere other than the catalog
Three directives take a coordinate away from the catalog and put a producer behind it. All three
land as an AUTHORED claim, and the claim name is what the outcome block shows.
@service names a Java method. What the generator does with its return depends on whether that
return is a table type or not, and the pair below isolates exactly that: same directive, same
parent, one returning @table rows and one returning a scalar.
type Query {
language: Language
}
type Language @table(name: "language") {
name: String
"Films for this language, produced by a service and re-queried as table rows."
filmsViaService: [Film!]! @service(service: {className : "no.sikt.graphitron.rewrite.generators.TestFilmService", method : "getFilmsMapped"})
}
type Film @table(name: "film") {
title: String
}
| Coordinate | Verdict | Emitted |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
no claiming directive |
|
The service returns values, and Film is a table type, so the values have to become table rows
again: the coordinate opens a new keyed query, batched through a DataLoader, and Film.title reads
a column of film inside it. Asserted by the service-table-child corpus example.
Change the return to a scalar and the re-query disappears, because there is nothing to look up:
type Query {
language: Language
}
type Language @table(name: "language") {
name: String
"A scalar a service produces; no SQL of its own."
rank: Int @service(service: {className : "no.sikt.graphitron.rewrite.generators.TestFilmService", method : "getRankMapped"})
}
| Coordinate | Verdict | Emitted |
|---|---|---|
|
|
|
|
|
|
|
no claiming directive |
|
Same verdict, same tier, and a fetcher method that hands back what the service returned. Asserted by
the service-scalar-child corpus example.
Neither of those two verdicts is a statement about the parent. Move the same pair onto a parent that
is not a table at all, a plain object whose backing class the generator reflects off its own
@service producer, and the verdicts do not move:
type Query {
aggregated: Aggregated @service(service: {className : "no.sikt.graphitron.codereferences.dummyreferences.DummyService", method : "makeLanguageKeyed"})
}
type Aggregated {
"A scalar the service produces off the parent's backing record."
rank: Int @service(service: {className : "no.sikt.graphitron.rewrite.generators.TestFilmService", method : "getRankMappedByRecord"})
"Table rows the service produces, keyed on the record the parent hands down."
filmsViaService: [Film!]! @service(service: {className : "no.sikt.graphitron.rewrite.generators.TestFilmService", method : "getFilmsMappedByRecord"})
}
type Film @table(name: "film") {
title: String
}
| Coordinate | Verdict | Emitted |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
The parent here is class-backed: no @table, no @record, its backing class reflected off
makeLanguageKeyed’s return type. Its two children carry the same `SERVICE claim and the same two
verdicts the @table-parented pair above carries, and filmsViaService still batches its Film rows
through a DataLoader, keying the batch on the record the parent hands down rather than on a parent
table row. What the parent’s shape moves is the source axis, Child(Record) instead of
Child(Table), and the key the batch is built from with it. It does not move the claim, the target,
or which leaf the coordinate lands on: both service leaves are minted under either parent, so no leaf
name answers what arrives at getSource(). Asserted by the service-child-class-backed-parent corpus
example.
The last pair separates two things that both look like "this column is not where you would expect".
@reference keeps the value in the catalog and changes which table it is read from, so the claim
stays TABLE_COLUMN and stays inferred: the name still matched, just at the far end of a named
foreign key. @externalField takes the value out of the catalog entirely and claims the coordinate.
type Query {
film: Film
}
type Film @table(name: "film") {
"Reached over a named foreign key."
languageName: String @field(name: "name") @reference(path: [{key : "film_language_id_fkey"}])
"Computed in Java, not read from a column."
computedRating: String @externalField(reference: {className : "no.sikt.graphitron.rewrite.TestExternalFieldStub", method : "rating"})
}
| Coordinate | Verdict | Emitted |
|---|---|---|
|
|
|
|
|
|
|
no claiming directive |
|
This is the clearest reading of what the tier column means. @reference is written on
languageName and the tier is still inferred, because @reference is not a claiming directive:
it moves the site the name resolves against, and intent_column_match_claim then matches there. Only
@externalField claims, so only computedRating reads authored. Asserted by the
reference-and-computed corpus example.
Reading a database routine
@routine points a field at a table-valued function instead of a table. The return type still binds
a @table, because the function’s result set has a shape, and its children still resolve as columns
of it. argMapping: binds the field’s GraphQL arguments to the function’s parameters by name.
type Query {
"A table-valued database routine, read like a table."
tilganger(
env: String!
serviceId: String!
feideId: String!): [Tilgang!]! @routine(name: "tilganger_for_feidebruker_med_fs_fiktivt_fnr", argMapping: "pEnv: env, pServiceId: serviceId, pFeideId: feideId") @defaultOrder(fields: [{name : "organisasjonskode"}, {name : "rollekode"}])
}
type Tilgang @table(name: "tilganger_for_feidebruker_med_fs_fiktivt_fnr") {
organisasjonskode: Int
rollekode: String
}
| Coordinate | Verdict | Emitted |
|---|---|---|
|
|
|
|
|
|
|
|
|
ROUTINE is one of the claims intent_authored_field_claim carries, so the routine coordinate is
authored while its children stay inferred column matches: from the children’s point of view
nothing is unusual, they are reading a relation that happens to be a function result. Asserted by the
routine-table-valued-read corpus example.
Pagination, and facets over the same filter
@asConnection rewrites a list field into a Relay connection. The connection, edge and page-info
types do not exist in the schema the author wrote; they are synthesised for the coordinate and named
after it. @asFacet on an input field adds facet counts computed over the same filter.
type Query {
"A paginated read whose filter input also yields facet counts."
films(
filter: FilmFilter): [Film!]! @asConnection @defaultOrder(primaryKey: true)
}
type Film @table(name: "film") {
title: String
}
input FilmFilter {
title: [String!] @field(name: "title") @asFacet
}
| Coordinate | Verdict | Emitted |
|---|---|---|
|
|
|
|
no claiming directive |
|
|
no claiming directive |
|
|
no claiming directive |
|
The coordinates named QueryFilmsConnection and QueryFilmsEdge are the synthesised types, and they
emit fetchers of their own: that is what makes them visible here at all, since nothing in the authored
SDL mentions them. Neither carries a claim, because @asConnection shapes the read rather than
claiming the coordinate. Asserted by the faceted-connection corpus example, whose @synthesises
assertion pins the full mint set including the facets and page-info types this query does not select.
Page-info is the one wrapper in that mint set that is not per-coordinate. A schema has exactly one
PageInfo, landing on PageInfoType, however many connections it carries: the classifier synthesises
it once when some connection exists and the author did not declare it, and reuses the author’s own
declaration when they did, which is what a hand-written or federated schema relies on. The reused
half is asserted by the connection corpus example, whose SDL declares the whole Relay triad
outright.
Turning rows into fields
@pivot reads a table whose rows are the values you want as fields, and turns one row per key into
one field per key. The vocabulary argument names the enum whose values map to the key column, so the
target type’s fields and the pivot’s rows line up by name.
type Query {
film: Film
}
type Film @table(name: "film") {
"One row of translations, pivoted out of a per-language table."
titleTexts: TranslatedTexts @reference(path: [{table : "film_translation"}]) @pivot(on: "lang_code", value: "title_txt", vocabulary: "Sprak")
}
type TranslatedTexts {
nn: String
nb: String
}
| Coordinate | Verdict | Emitted |
|---|---|---|
|
no claiming directive |
|
|
no claiming directive |
|
|
no claiming directive |
|
|
no claiming directive |
|
No coordinate here carries a claim: @pivot and @reference are both shaping directives rather than
claiming ones, and TranslatedTexts is a grouping type whose fields are filled from the pivoted
aggregate rather than matched against a column. The nn and nb fields get their key values from the
Sprak enum’s own @field(name:) renames. Asserted by the pivot corpus example.
Child Fields (on @table parent)
Scalar / Enum return type
A scalar child of a @table parent takes its value from a column, and both spellings of that are
worked through above: the column matched by name in the @table type example, and the
column renamed by @field(name:) in the derived layer example. Either way the
coordinate’s verdict is TABLE_COLUMN, and the outcome block beside each shows the emitted accessor.
What is left is the one spelling the build refuses:
| Schema Pattern | ChildField Variant |
*Fetchers Generates |
|---|---|---|
|
|
Nothing yet. A rooted-at-parent NodeId reference is rejected at validate time as deferred, at every arity, because no emitter brings the target’s key columns into scope. The rejection is keyed on the variant class itself ( |
Object return type
Every return type a child can have is worked through above, so none of them is repeated here. The four
spellings of a plain @table child are the derived layer example and the
child lookup pair; the polymorphic return types are
Polymorphic fields, with the discriminated interface child’s cardinality fork
under One table, many participants; the plain object with no @table
is the unbound object type example. What is left is the three rows the build refuses:
| Schema Pattern | ChildField Variant |
*Fetchers Generates |
|---|---|---|
Conflicting directives |
Arm-order winner |
Validation error from the store-backed conflict detection ( |
|
|
Validation error; the directive is no longer supported ( |
|
|
Validation error; the directive is no longer supported ( |
Child Fields (on a class-backed parent)
The parent here is a type whose backing class is reflection-derived from its producer (a @service
return, or a parent-accessor chain), not declared via a directive.
A class-backed parent is not a separate classification either. It moves the source axis to
Child(Record) and, with it, the key the child’s query is built from; it does not move the claim or
the target. The worked examples above cover most of what that means: scalars and nested non-table
objects under such a parent in the target shape example, an FK-reached @table
child in the record-handoff example, and both @service forms in the
class-backed service pair, and the errors: slot in the
@error example. What is left is the two forms no example shows, one of which does
not currently generate:
| Schema Pattern | ChildField Variant |
*Fetchers Generates |
|---|---|---|
Return |
|
Nothing. Single cardinality is rejected at validate time (single-cardinality |
Return |
|
Async DataLoader fetcher + |
Input Fields (resolved against the consuming field’s table)
| Schema Pattern | InputField Variant |
Used for |
|---|---|---|
|
|
Maps input argument to a table column |
|
|
Maps input argument to a FK column |
|
|
Decodes a NodeId into the consumer table’s key columns |
|
|
Decodes a NodeId into FK columns on the consumer’s own table; the predicate compares those columns directly |
|
|
Decodes a NodeId whose value no column of the consumer’s table holds, so the filter compares the target’s key columns inside a correlated |
Nested input object |
|
Expands nested input fields inline against the same table |
InputField is a separate top-level sub-hierarchy of GraphitronField, alongside RootField and ChildField. It classifies input-object fields against the consuming field’s resolved table: the return-type table on the filter and arg-level @lookupKey paths, the write-target table on the DML paths.
*\* UnclassifiedField is a direct permit of GraphitronField; it is not nested under QueryField, MutationField, or ChildField. It is listed in the tables above for completeness, but structurally it sits at the top level of the sealed hierarchy. TableTargetField is an intermediate sealed sub-interface of ChildField grouping all 5 SQL-generating child field variants (TableField, BatchedTableField, TableInterfaceField, BatchedTableInterfaceField, ServiceTableField). The batched leaf is source-gated, and a @lookupKey correspondence rides on its fetch sibling as the seal’s LookupResolution arm rather than as a leaf of its own.
Classification without a directive
Most coordinates on this page carry a directive. Some carry none and still classify, and the verdict
layer says so in one column rather than as a separate rule: intent_column_match_claim claims
TABLE_COLUMN for a field whose name resolves against the table its site navigates to, with no
directive involved, and intent_resolved_field_claim.tier reads INFERRED where such a reading
survived the anti-join against the authored claims.
Every outcome block above shows this happening. Film.title under the first example
is TABLE_COLUMN, inferred: nothing was written on it, and the catalog matched the name.
The return type does not change that. A field whose GraphQL type is an enum is still a column read; the enum-ness lives in the conversion between the column’s value and the GraphQL value, not in the classification.
type Query {
film: Film
}
type Film @table(name: "film") {
"A GraphQL enum return, still a plain column read."
rating: Rating
}
enum Rating {
G
PG
PG13
R
NC17
}
| Coordinate | Verdict | Emitted |
|---|---|---|
|
|
|
|
no claiming directive |
|
Film.rating lands on the same verdict as Film.title, at the same tier, and emits the same shape of
fetcher method. Rating itself classifies as EnumType and generates nothing: its values become the
strings or ints the column holds at bind time, which is the whole of what the enum contributes.
Asserted by the enum-column corpus example.
A field returning a *Connection type is the same story one level out: @asConnection shapes the
read and claims nothing, so neither the field nor the synthesised connection types carry a claim. See
Pagination, and facets over the same filter.
When nothing is generated
A pattern the generator refuses still classifies. Capture records the same facts, derivation resolves the same verdict, and then planning mints no command row: the run produces diagnostic rows instead, and render has nothing to fold.
That is why a worked example can show a verdict and no emitted names, and why the one on this page
that does, under target shape, is not a gap in the tables. Its outcome block
states the absence, because "generates nothing" is an answer to this page’s question, not a missing
row.
The diagnostics themselves are a closed vocabulary and a small one. rejection_validation_error
carries one row per rejection, keyed by the graph and the emit order of the walk’s error stream, with
a kind column whose CHECK constraint holds the three-way fork:
kind |
What it means |
|---|---|
|
The schema says something Graphitron understands and refuses. The author can fix it; the message names what and, where a name was close, what was probably meant. |
|
The schema is not well-formed enough to classify against. |
|
A recognised combination with no live producer. The coordinate is understood and deliberately not generated, which is different from being wrong. |
The taxonomy behind those rows, why rejection is a returned value rather than a thrown exception, and how a coordinate carries its own refusal, is Typed rejection. What an author sees for each is the manual’s diagnostics glossary.
Where the code lives
Six top-level packages under no.sikt.graphitron. The map states ownership: which package a change
belongs in, not what each package will become.
| Package | What it owns |
|---|---|
|
Capture. Visitors that transcribe a schema into store rows, one per thing the SDL states. Decides nothing. |
|
The command vocabulary: the row types planning mints and render reads, plus the catalog and
argument value types they carry. |
|
Planning. The command producers, the relations they mint into, |
|
The render shell. One fold per command row into emitted source, and nothing that decides. |
|
The classification walk and the sealed model it produces, plus the generators still driven from
that model. Transitional: this is the surface being drained. A change here should be asked
whether it belongs in one of the four above instead. Its |
|
Build-time renderers that emit documentation fragments, such as the command-relation table above. Never on a consumer’s classpath. |
Every relation, every fact and every verdict has its rule stated once, in its own comment in the store’s DDL, and rendered browsable at the generated schema reference. That is the reference to reach for when this page names a relation and you want its columns.
See also:
-
Pipeline overview ; what runs when, and what is still transitional
-
The fact model ; the discipline every relation obeys
-
Typed rejection ; the refusal taxonomy behind the diagnostic rows
-
Runtime Extension Points ; the emitted classes a consumer plugs into
-
Graphitron Development Principles ; the axioms and the named enforcement behind each