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?

intent_authored_field_claim. One arm pair per claiming directive (@service, @externalField, @nodeId, @lookupKey, @routine, @mutation): a decoded arm reading the semantic relation, and a presence arm falling back to the raw application where the decode declined.

What did the author claim, on a type?

intent_authored_type_claim. @table and @error, decoded arm plus presence fallback each, with the root type names masked out.

Which catalog table does a type bind?

intent_bound_table, resolving @table’s reference through `intent_spelled_table: the name argument as written, or the type name where the argument was omitted.

What does the catalog match on its own?

intent_column_match_claim, the structural classifier: a field whose name resolves against the table its site navigates to claims TABLE_COLUMN, with no directive involved.

So what won?

intent_resolved_field_claim. The authored rows unioned with the inferred rows at coordinates the authored relation does not cover. Its tier column says which: AUTHORED, or INFERRED for a structural reading that survived the anti-join.

What contradicts?

intent_authored_claim_conflict: one row per coordinate two or more distinct classifiers claim, at both grains. That is the whole predicate; the relation carries no population filter, so each consumer applies the population its own question needs.

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

ConditionRelation

no.sikt.graphitron.plan

The condition command relation of one generation run.

FetcherEdgeRelation

no.sikt.graphitron.plan

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 FetcherEdgeCommands for the declared families).

KeyProjectionRelation

no.sikt.graphitron.command

Every argMapping binding in the graph that projects a node type’s key column, keyed by the coordinate the argMapping sits on and the path as written.

LauncherRelation

no.sikt.graphitron.plan

The launcher command relation of one generation run: one row per migrated root SELECT coordinate, keyed by the coordinate alone.

ProjectionRelation

no.sikt.graphitron.plan

The projection command relation: one row per projection unit, keyed by the unit’s address (type name for anchor units, (anchor, typeName) for nested units, coordinate for pivot units — the key is structural in the minted UnitRef).

RoutineWriteRelation

no.sikt.graphitron.plan

The routine-write command relation of one generation run: one row per @routine-writing mutation coordinate, keyed by the coordinate alone.

TypeUnitRelation

no.sikt.graphitron.plan

The type-keyed command relation: every per-type unit this run emits, one row per (typeName, arm) key (enforced here; two rows of one kind for one type is a producer bug, not a merge).

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 GeneratedUnits scheme 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 @splitQuery table fields, user-provided returns (@service), and mutation read-backs.

  • Derived target table ; built from @lookupKey argument values (from SelectedField.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

@reference directive, carried on the hop’s On axis: FK key → On.ColumnPairs; condition method → On.Predicate

Filter condition

Narrows the result set of the current scope

@condition directive, arguments, cursor

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.

@condition directive on a field with @lookupKey

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
}
Table 1. What the pipeline makes of it
Coordinate Verdict Emitted

Film.title

TABLE_COLUMN, inferred

fetchers.FilmFetchers#title(Object)

Query.film

no claiming directive

fetchers.QueryFetchers#film(DataFetchingEnvironment)

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
}
Table 2. What the pipeline makes of it
Coordinate Verdict Emitted

Film.id

NODE_ID, authored

fetchers.FilmFetchers#id(Object)

Query.film

no claiming directive

fetchers.QueryFetchers#film(DataFetchingEnvironment)

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")
}
Table 3. What the pipeline makes of it
Coordinate Verdict Emitted

FilmActor.id

NODE_ID, authored

fetchers.FilmActorFetchers#id(Object)

FilmActorNote.filmActorId

NODE_ID, authored

fetchers.FilmActorNoteFetchers#filmActorId(Object)

FilmActorNote.note

TABLE_COLUMN, inferred

fetchers.FilmActorNoteFetchers#note(Object)

Query.filmActor

no claiming directive

fetchers.QueryFetchers#filmActor(DataFetchingEnvironment)

Query.filmActorNote

no claiming directive

fetchers.QueryFetchers#filmActorNote(DataFetchingEnvironment)

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
}
Table 4. What the pipeline makes of it
Coordinate Verdict Emitted

Film.details

no claiming directive

fetchers.FilmFetchers#details(Object)

FilmDetails.description

no claiming directive

fetchers.FilmDetailsFetchers#description(Object)

FilmDetails.title

no claiming directive

fetchers.FilmDetailsFetchers#title(Object)

Query.film

no claiming directive

fetchers.QueryFetchers#film(DataFetchingEnvironment)

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")
Table 5. What the pipeline makes of it
Coordinate Verdict Emitted

Payment.amount

TABLE_COLUMN, inferred

fetchers.PaymentFetchers#amount(Object)

Query.payment

no claiming directive

fetchers.QueryFetchers#payment(DataFetchingEnvironment)

Query.price

SERVICE, authored

fetchers.QueryFetchers#price(DataFetchingEnvironment)

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
}
Table 6. What the pipeline makes of it
Coordinate Verdict Emitted

JavaRecordBacked.name

no claiming directive

fetchers.JavaRecordBackedFetchers#name(Object)

JooqTableRecordBacked.title

no claiming directive

fetchers.JooqTableRecordBackedFetchers#title(Object)

PojoBacked.id

no claiming directive

fetchers.PojoBackedFetchers#id(Object)

Query.javaRecord

SERVICE, authored

fetchers.QueryFetchers#javaRecord(DataFetchingEnvironment)

Query.jooqRecord

SERVICE, authored

fetchers.QueryFetchers#jooqRecord(DataFetchingEnvironment)

Query.pojo

SERVICE, authored

fetchers.QueryFetchers#pojo(DataFetchingEnvironment)

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
}
Table 7. What the pipeline makes of it
Coordinate Verdict Emitted

Query.javaRecord

SERVICE, authored

fetchers.QueryFetchers#javaRecord(DataFetchingEnvironment)

Query.jooqRecord

SERVICE, authored

fetchers.QueryFetchers#jooqRecord(DataFetchingEnvironment)

Query.pojo

SERVICE, authored

fetchers.QueryFetchers#pojo(DataFetchingEnvironment)

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
}
Table 8. What the pipeline makes of it
Coordinate Verdict Emitted

ExtraFieldError.message

no claiming directive

fetchers.ExtraFieldErrorFetchers#message(DataFetchingEnvironment)

ExtraFieldError.path

no claiming directive

fetchers.ExtraFieldErrorFetchers#path(DataFetchingEnvironment)

ExtraFieldError.severity

no claiming directive

no method of its own

Query.sak

SERVICE, authored

fetchers.QueryFetchers#sak(DataFetchingEnvironment)

SakPayload.data

no claiming directive

fetchers.SakPayloadFetchers#data(Object)

SakPayload.errors

no claiming directive

fetchers.SakPayloadFetchers#errors(Object)

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 @table (deprecated location; see docs/manual/reference/directives/table.adoc)

Whatever the same input without the directive classifies as

No generation decision of its own: the directive is ignored, name: is never read, and no reader consults it (the input-axis binding arm and the three nesting guards that once did were all deleted, so inertness is uniform). A non-fatal per-usage build warning announces the deprecation and carries the per-verb migration guidance (drop the directive for filters and lookup args; @mutation(table:) for DELETE and encoded-ID / scalar returns; return-derivation for INSERT/UPDATE). A future release rejects the location.

Unresolvable directives

UnclassifiedType

Validation error ; build fails

Conflicting type directives (@table with @error)

Arm-order winner

Validation error from the store-backed conflict detection (AuthoredClaimConflicts) ; build fails

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 (permitting Query / Mutation) is a root field, OnlyChild / Child is a nested field (one or many source objects arriving). Write operations are legal only on Root.Mutation, NodeResolve only on Root.Query, Nest only on a nested source. The nested arms wrap a SourceShape (Table / Record), the catalog-vs-Java polarity of what arrives at env.getSource().

  • operation, the verb the field performs: reads (Fetch, Paginate, Lookup, NodeResolve, Nest, and the modeled-but-unpopulated EntityResolve / Count / Facet), writes (Insert, Upsert, Update, Delete, and the unpopulated UpdateMatching / DeleteMatching), and the developer ServiceCall (the read-vs-write split it once carried is now read off the source root).

  • target, the field’s projection endpoint, a wrapper (Single / List) around a TargetShape. The shape carries build-vs-consume: Table / Column are catalog shapes graphitron builds the SQL for; Record / Field are domain shapes graphitron consumes without having built; Connection,

    Interface, Union are the container and polymorphic shapes. `Table:Column

    Record:Field` (mirror : reflect). A Relay connection is Single(Connection(…​)), its windowed-read verb the Paginate operation.

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")
}
Table 9. What the pipeline makes of it
Coordinate Verdict Emitted

City.country

no claiming directive

fetchers.CityFetchers#country(DataFetchingEnvironment)

City.countrySplit

no claiming directive

fetchers.CityFetchers#countrySplit(DataFetchingEnvironment)

Country.name

TABLE_COLUMN, inferred

fetchers.CountryFetchers#name(Object)

Query.city

no claiming directive

fetchers.QueryFetchers#city(DataFetchingEnvironment)

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"}])
}
Table 10. What the pipeline makes of it
Coordinate Verdict Emitted

Film.details

no claiming directive

fetchers.FilmFetchers#details(Object)

Film.language

no claiming directive

fetchers.FilmFetchers#language(DataFetchingEnvironment)

FilmDetails.language

no claiming directive

fetchers.FilmDetailsFetchers#language(DataFetchingEnvironment)

Language.name

TABLE_COLUMN, inferred

fetchers.LanguageFetchers#name(Object)

Query.film

no claiming directive

fetchers.QueryFetchers#film(DataFetchingEnvironment)

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
}
Table 11. What the pipeline makes of it
Coordinate Verdict

Film.details

no claiming directive

Film.title

TABLE_COLUMN, inferred

FilmDetails.stats

no claiming directive

FilmStats.count

no claiming directive

Query.film

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
}
Table 12. What the pipeline makes of it
Coordinate Verdict Emitted

Customer.address

no claiming directive

fetchers.CustomerFetchers#address(DataFetchingEnvironment)

Named.name

no claiming directive

no method of its own

Query.customer

no claiming directive

fetchers.QueryFetchers#customer(DataFetchingEnvironment)

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
Table 13. What the pipeline makes of it
Coordinate Verdict Emitted

Actor.firstName

TABLE_COLUMN, inferred

fetchers.ActorFetchers#firstName(Object)

Film.title

TABLE_COLUMN, inferred

fetchers.FilmFetchers#title(Object)

FilmActor.related

no claiming directive

fetchers.FilmActorFetchers#related(DataFetchingEnvironment)

Query.filmActor

no claiming directive

fetchers.QueryFetchers#filmActor(DataFetchingEnvironment)

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")
}
Table 14. What the pipeline makes of it
Coordinate Verdict Emitted

Individual.birthDate

TABLE_COLUMN, inferred

fetchers.IndividualFetchers#birthDate(Object)

Party.displayName

TABLE_COLUMN, inferred

no method of its own

Query.parties

no claiming directive

fetchers.QueryFetchers#parties(DataFetchingEnvironment)

QueryPartiesConnection.edges

no claiming directive

fetchers.QueryPartiesConnectionFetchers#edges(DataFetchingEnvironment)

QueryPartiesEdge.node

no claiming directive

fetchers.QueryPartiesEdgeFetchers#node(DataFetchingEnvironment)

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 @reference paths 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
}
Table 15. What the pipeline makes of it
Coordinate Verdict Emitted

Inventory.media

no claiming directive

fetchers.InventoryFetchers#media(DataFetchingEnvironment)

Language.mediaList

no claiming directive

fetchers.LanguageFetchers#mediaList(DataFetchingEnvironment)

MediaItem.title

TABLE_COLUMN, inferred

no method of its own

Query.inventory

no claiming directive

fetchers.QueryFetchers#inventory(DataFetchingEnvironment)

Query.language

no claiming directive

fetchers.QueryFetchers#language(DataFetchingEnvironment)

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")
}
Table 16. What the pipeline makes of it
Coordinate Verdict Emitted

Content.contentId

TABLE_COLUMN, inferred

no method of its own

FilmContent.rating

TABLE_COLUMN, inferred

fetchers.FilmContentFetchers#rating(Object)

Query.content

no claiming directive

fetchers.QueryFetchers#content(DataFetchingEnvironment)

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 Fetchers class: a synchronous field fetcher, or an asynchronous DataLoader fetcher paired with a rows() batch method (called out as DataLoader-backed where it matters). These are the IMPLEMENTED_LEAVES.

  • Inline projection ; no per-field method ; the value is projected straight into the enclosing projection unit’s SELECT by the generated $project method (ProjectionUnitRenderer). This bucket is derived from the projection producer’s membership declaration (ProjectionCommands.CONTRIBUTION_MINTING_LEAVES minus the dual-arm kinds).

  • Wiring value ; no generated method either ; the field is registered as a DataFetcher / ColumnFetcher value through FetcherEmitter / FetcherRegistrationsEmitter. These are IMPLEMENTED_LEAVES leaves 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 the NodeIdEncodeKeys compaction on ColumnBackedReferenceField at 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")
}
Table 17. What the pipeline makes of it
Coordinate Verdict Emitted

Actor.firstName

TABLE_COLUMN, inferred

fetchers.ActorFetchers#firstName(Object)

Film.filmId

TABLE_COLUMN, inferred

fetchers.FilmFetchers#filmId(Object)

FilmActor.actors

no claiming directive

fetchers.FilmActorFetchers#actors(DataFetchingEnvironment)

Query.filmActor

no claiming directive

fetchers.QueryFetchers#filmActor(DataFetchingEnvironment)

Query.filmById

LOOKUP_KEY, authored

fetchers.QueryFetchers#filmById(DataFetchingEnvironment)

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")
}
Table 18. What the pipeline makes of it
Coordinate Verdict Emitted

Customer.firstName

TABLE_COLUMN, inferred

fetchers.CustomerFetchers#firstName(Object)

Query.store

no claiming directive

fetchers.QueryFetchers#store(DataFetchingEnvironment)

Store.customers

no claiming directive

fetchers.StoreFetchers#customers(DataFetchingEnvironment)

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
}
Table 19. What the pipeline makes of it
Coordinate Verdict Emitted

City.city

TABLE_COLUMN, inferred

fetchers.CityFetchers#city(Object)

Country.cities

no claiming directive

fetchers.CountryFetchers#cities(DataFetchingEnvironment)

CountryCitiesConnection.edges

no claiming directive

fetchers.CountryCitiesConnectionFetchers#edges(DataFetchingEnvironment)

CountryCitiesEdge.node

no claiming directive

fetchers.CountryCitiesEdgeFetchers#node(DataFetchingEnvironment)

Query.country

no claiming directive

fetchers.QueryFetchers#country(DataFetchingEnvironment)

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

@service, return @table type

QueryServiceTableField

Async DataLoader fetcher + rows*() method

@service, return non-table type

QueryServiceRecordField

Fetcher method

Anything else

UnclassifiedField**

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
}
Table 20. What the pipeline makes of it
Coordinate Verdict Emitted

Film.title

TABLE_COLUMN, inferred

fetchers.FilmFetchers#title(Object)

Node.id

no claiming directive

no method of its own

Query.internalFilmNode

no claiming directive

fetchers.QueryFetchers#internalFilmNode(DataFetchingEnvironment)

Query.node

no claiming directive

fetchers.QueryFetchers#node(DataFetchingEnvironment)

Query.nodes

no claiming directive

fetchers.QueryFetchers#nodes(DataFetchingEnvironment)

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
}
Table 21. What the pipeline makes of it
Coordinate Verdict Emitted

Film.title

TABLE_COLUMN, inferred

fetchers.FilmFetchers#title(Object)

Mutation.createFilm

MUTATION, authored

fetchers.MutationFetchers#createFilm(DataFetchingEnvironment)

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")
}
Table 22. What the pipeline makes of it
Coordinate Verdict Emitted

Film.title

TABLE_COLUMN, inferred

fetchers.FilmFetchers#title(Object)

Mutation.deleteFilm

MUTATION, authored

fetchers.MutationFetchers#deleteFilm(DataFetchingEnvironment)

Mutation.updateFilm

MUTATION, authored

fetchers.MutationFetchers#updateFilm(DataFetchingEnvironment)

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
}
Table 23. What the pipeline makes of it
Coordinate Verdict Emitted

Film.title

TABLE_COLUMN, inferred

fetchers.FilmFetchers#title(Object)

FilmUpdatePayload.film

no claiming directive

fetchers.FilmUpdatePayloadFetchers#film(DataFetchingEnvironment)

Mutation.updateFilmPayload

MUTATION, authored

fetchers.MutationFetchers#updateFilmPayload(DataFetchingEnvironment)

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
}
Table 24. What the pipeline makes of it
Coordinate Verdict Emitted

Film.title

TABLE_COLUMN, inferred

fetchers.FilmFetchers#title(Object)

FilmDetails.title

no claiming directive

fetchers.FilmDetailsFetchers#title(Object)

Mutation.importFilm

SERVICE, authored

fetchers.MutationFetchers#importFilm(DataFetchingEnvironment)

Mutation.summariseFilm

SERVICE, authored

fetchers.MutationFetchers#summariseFilm(DataFetchingEnvironment)

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

@mutation(typeName: INSERT, table: "…"), returning ID

DmlTableField (Insert write arm)

As the direct-@table INSERT above; an encoded-ID return names no table, so @mutation(table:) names it on the field. Byte-identical carrier across the two rungs

@mutation(typeName: UPSERT, table: "…") (table: on a verb that does not read it)

UnclassifiedField (typed MutationTableArgError.UnsupportedVerb)

Validation error ; build fails. table: is wired for DELETE, INSERT, and UPDATE only

@mutation(typeName: UPSERT), returning ID or a @table type

UnclassifiedField (typed deferral; the Upsert write arm has no live producer)

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 @service nor @mutation

UnclassifiedField**

Validation error ; build fails

Both @service and @mutation

Arm-order winner

Validation error from the store-backed conflict detection (AuthoredClaimConflicts) ; build fails. The LSP/MCP projection reports FieldClassification.Conflicted carrying both claims' decoded slot facts (verb, table, method)

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
}
Table 25. What the pipeline makes of it
Coordinate Verdict Emitted

Film.title

TABLE_COLUMN, inferred

fetchers.FilmFetchers#title(Object)

Language.filmsViaService

SERVICE, authored

fetchers.LanguageFetchers#filmsViaService(DataFetchingEnvironment)

Language.name

TABLE_COLUMN, inferred

fetchers.LanguageFetchers#name(Object)

Query.language

no claiming directive

fetchers.QueryFetchers#language(DataFetchingEnvironment)

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"})
}
Table 26. What the pipeline makes of it
Coordinate Verdict Emitted

Language.name

TABLE_COLUMN, inferred

fetchers.LanguageFetchers#name(Object)

Language.rank

SERVICE, authored

fetchers.LanguageFetchers#rank(DataFetchingEnvironment)

Query.language

no claiming directive

fetchers.QueryFetchers#language(DataFetchingEnvironment)

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
}
Table 27. What the pipeline makes of it
Coordinate Verdict Emitted

Aggregated.filmsViaService

SERVICE, authored

fetchers.AggregatedFetchers#filmsViaService(DataFetchingEnvironment)

Aggregated.rank

SERVICE, authored

fetchers.AggregatedFetchers#rank(DataFetchingEnvironment)

Film.title

TABLE_COLUMN, inferred

fetchers.FilmFetchers#title(Object)

Query.aggregated

SERVICE, authored

fetchers.QueryFetchers#aggregated(DataFetchingEnvironment)

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"})
}
Table 28. What the pipeline makes of it
Coordinate Verdict Emitted

Film.computedRating

EXTERNAL_FIELD, authored

fetchers.FilmFetchers#computedRating(DataFetchingEnvironment)

Film.languageName

TABLE_COLUMN, inferred

fetchers.FilmFetchers#languageName(DataFetchingEnvironment)

Query.film

no claiming directive

fetchers.QueryFetchers#film(DataFetchingEnvironment)

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
}
Table 29. What the pipeline makes of it
Coordinate Verdict Emitted

Query.tilganger

ROUTINE, authored

fetchers.QueryFetchers#tilganger(DataFetchingEnvironment)

Tilgang.organisasjonskode

TABLE_COLUMN, inferred

fetchers.TilgangFetchers#organisasjonskode(Object)

Tilgang.rollekode

TABLE_COLUMN, inferred

fetchers.TilgangFetchers#rollekode(Object)

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
}
Table 30. What the pipeline makes of it
Coordinate Verdict Emitted

Film.title

TABLE_COLUMN, inferred

fetchers.FilmFetchers#title(Object)

Query.films

no claiming directive

fetchers.QueryFetchers#films(DataFetchingEnvironment)

QueryFilmsConnection.edges

no claiming directive

fetchers.QueryFilmsConnectionFetchers#edges(DataFetchingEnvironment)

QueryFilmsEdge.node

no claiming directive

fetchers.QueryFilmsEdgeFetchers#node(DataFetchingEnvironment)

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
}
Table 31. What the pipeline makes of it
Coordinate Verdict Emitted

Film.titleTexts

no claiming directive

fetchers.FilmFetchers#titleTexts(DataFetchingEnvironment)

Query.film

no claiming directive

fetchers.QueryFetchers#film(DataFetchingEnvironment)

TranslatedTexts.nb

no claiming directive

fetchers.TranslatedTextsFetchers#nb(Object)

TranslatedTexts.nn

no claiming directive

fetchers.TranslatedTextsFetchers#nn(Object)

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

@nodeId(typeName:), non-FK-mirror target

ColumnBackedReferenceField (compaction = NodeIdEncodeKeys; arity = the target key’s column count)

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 (Rejection.StubKey.VariantClass), so what the deferral names is the shape that has to grow an emitter

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 (AuthoredClaimConflicts); build fails. The LSP/MCP projection reports FieldClassification.Conflicted carrying the claims

@notGenerated

UnclassifiedField**

Validation error; the directive is no longer supported (INVALID_SCHEMA rejection). Remove it; fields must be fully described by the schema.

@multitableReference

UnclassifiedField**

Validation error; the directive is no longer supported (INVALID_SCHEMA rejection). Remove it; the rewrite generates multi-table interface dispatch from @discriminate / @discriminator.

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 @table with @lookupKey (catalog FK from parent’s jOOQ TableRecord)

BatchedTableField (Record-sourced, KeyLift.FkColumns, keyed lookup arm)

Nothing. Single cardinality is rejected at validate time (single-cardinality @lookupKey is not supported); the list spelling passes validation and reaches no emission, because the batched-lookup cell has no producer. Pinned as the corpus’s one recorded launcher-production gap by the record-method example

Return @table with @sourceRow (parent is Backed or JavaRecordType; no catalog FK required)

BatchedTableField (Record-sourced, KeyLift.Lifter; keyed lookup arm when @lookupKey is present)

Async DataLoader fetcher + rows*() method; the developer-supplied static lifter populates the batched VALUES rows

Input Fields (resolved against the consuming field’s table)

Schema Pattern InputField Variant Used for

@field(name:) or matching column name

InputField.ColumnBackedField (arity 1)

Maps input argument to a table column

@reference on input scalar

InputField.ColumnBackedReferenceField (arity 1)

Maps input argument to a FK column

@nodeId on input scalar

InputField.ColumnBackedField (NodeIdDecodeKeys extraction; arity = the node key’s column count)

Decodes a NodeId into the consumer table’s key columns

@nodeId(typeName:) on input scalar, FK targets the target’s key columns

InputField.ColumnBackedReferenceField (NodeIdDecodeKeys extraction, FilterBinding.Local; arity = the target key’s column count)

Decodes a NodeId into FK columns on the consumer’s own table; the predicate compares those columns directly

@nodeId(typeName:) on input scalar, FK targets other columns

InputField.ColumnBackedReferenceField (NodeIdDecodeKeys extraction, FilterBinding.Remote)

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 EXISTS over the FK. Read-side only: writing such a carrier, or using it as a @lookupKey column, is rejected at validate time (it would need a subquery translating the key back into an FK-column value)

Nested input object

InputField.NestingField

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
}
Table 32. What the pipeline makes of it
Coordinate Verdict Emitted

Film.rating

TABLE_COLUMN, inferred

fetchers.FilmFetchers#rating(Object)

Query.film

no claiming directive

fetchers.QueryFetchers#film(DataFetchingEnvironment)

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

AUTHOR_ERROR

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.

INVALID_SCHEMA

The schema is not well-formed enough to classify against.

DEFERRED

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

facts

Capture. Visitors that transcribe a schema into store rows, one per thing the SDL states. Decides nothing.

command

The command vocabulary: the row types planning mints and render reads, plus the catalog and argument value types they carry. KeyProjectionRelation lives here rather than in plan, because it is keyed by the coordinate an argMapping sits on rather than by a generation run.

plan

Planning. The command producers, the relations they mint into, EmitPlan that runs them, and GeneratedUnits, the single site every generated unit name is minted from.

render

The render shell. One fold per command row into emitted source, and nothing that decides.

rewrite

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 derive subpackage is recorded as misnamed, with the split filed as its own work.

docs

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: