@reference declares a foreign-key path between the field’s parent table and its target table. The generator threads the path through jOOQ’s catalog and emits a JOIN whenever the field is selected. The reference page covers signature and constraints; this recipe walks the variants the directive surface supports: when you can omit it entirely, multi-hop chains and junction-table traversals, the two ways to pin a hop (by key: or by table:), inline cross-table column references, the input-field position, and the condition: form for relationships the catalog does not declare.
|
Names from inside the generator. This page names types from graphitron’s own classification model. They are accurate today, and worth knowing when a rejection message quotes one back at you, but they are not part of the authoring contract: the classification walk that produces them is being drained, and these names retire with it. What stays stable is the schema you write, the directive reference that describes it, and the diagnostics glossary. |
For the surrounding question of "when does the join fire vs DataLoader-batch instead of inline", see How-to: When to split queries. This recipe assumes the field is inline-joinable (no @splitQuery); the path mechanics are the same either way.
Single-hop FK references
The canonical case: name the FK constraint, the generator joins through it.
type Customer @table(name: "customer") {
address: Address @reference(path: [{key: "customer_address_id_fkey"}])
}
customer_address_id_fkey is the constraint declared in the schema’s DDL. A query selecting customers { address { district } } emits one statement:
SELECT customer.first_name AS "firstName",
address.district AS "district"
FROM customer
LEFT JOIN address ON customer.address_id = address.address_id
The JOIN fires only when the selection set traverses through address; selecting only firstName skips it. This is the projection-narrowing the rewrite applies to every reachable path: nothing is fetched that the request didn’t ask for.
The key: value accepts two forms, both resolving to the same FK:
-
Lowercase SQL constraint name.
"customer_address_id_fkey"matches whatpsql \d customershows. -
Java-constant style.
"CUSTOMER__CUSTOMER_ADDRESS_ID_FKEY"matches the constant generated into jOOQ’sKeysclass.
Either works; pick whichever your team’s style guide prefers. The classifier walks both indices in parallel.
Implicit references: when the catalog disambiguates
When exactly one foreign key exists between two tables, @reference is unnecessary and the generator picks the path automatically:
type Store @table(name: "store") {
customers: [Customer!]! @defaultOrder(primaryKey: true)
}
There is exactly one FK between customer and store (customer.store_id), so the join is implicit. The @table-bound parent type plus the @table-bound return type plus a unique FK between them is enough; the catalog finds the path. This is the recommended shape when it applies; explicit @reference is noise when the catalog can resolve the path.
The generator does not guess when more than one FK exists. The Sakila store ↔ staff pair has two FKs (store.manager_staff_id and staff.store_id), so any field crossing it must declare which one:
type Store @table(name: "store") {
manager: Staff @splitQuery @reference(path: [{key: "store_manager_staff_id_fkey"}])
}
Without @reference here, the build fails at classify time with an ambiguous-reference diagnostic listing both candidate FKs. The fix is always one of two things: name the FK with key:, or use table: if the disambiguation goes the other direction.
Multi-hop chains
@reference accepts a list of hops; the generator chains them into one JOIN sequence.
type Customer @table(name: "customer") {
storeAddress: Address @reference(path: [
{key: "customer_store_id_fkey"},
{key: "store_address_id_fkey"}
])
}
The path is customer.store_id → store.store_id → store.address_id → address.address_id: two FKs and three tables touched in one SQL statement. Each hop’s key: pins one of the FKs; the generator infers the intermediate tables from the FK target metadata.
Multi-hop chains are how junction tables work too. A many-to-many through film_actor is two FKs in sequence:
type Film @table(name: "film") {
actors(actor_id: [Int!] @lookupKey): [Actor!]! @reference(path: [
{key: "film_actor_film_id_fkey"},
{key: "film_actor_actor_id_fkey"}
])
}
The first hop joins film to film_actor via the film_id FK; the second joins film_actor to actor via the actor_id FK. The junction table itself does not appear in the GraphQL schema; it’s purely path infrastructure. The same shape works on connections (actorsConnection: ActorsConnection!) and under @splitQuery (actorsBySplitLookup: …).
The number of hops is unbounded; chains of three, four, or more FKs work the same way. Each hop is independent and resolves against the previous hop’s destination table.
table: as a short form
The reference’s ReferenceElement accepts table: as an alternative to key:. When exactly one FK exists between the source and destination tables for that hop, table: is enough:
type Customer @table(name: "customer") {
district: String @reference(path: [{table: "address"}])
}
This resolves to the same FK as {key: "customer_address_id_fkey"} because customer ↔ address has exactly one FK between them. When the table pair is ambiguous, table: fails:
# Customer ↔ Address has only one FK; this works.
district: String @reference(path: [{table: "address"}])
# Film ↔ Language has only one FK; this works.
languageName: String @field(name: "name") @reference(path: [{table: "language"}])
But two language-pointing FKs (e.g. a hypothetical original_language_id alongside language_id) would make the second example ambiguous, and the build would reject it as an UnclassifiedField with the same ambiguous-reference diagnostic. The table: form is the catalog-driven shortcut; the key: form is the explicit one. Use whichever expresses the intent more clearly:
-
key:when the constraint name is the source of truth (DBA-managed schemas, named-constraint conventions). -
table:when the destination is what’s interesting and the FK choice is unambiguous.
Setting both key: and table: is allowed but redundant; the generator validates they agree.
How table, key, and condition combine
Each ReferenceElement carries three optional fields. They are not independent toggles: which ones you set, and whether the catalog can disambiguate, decides how the hop resolves. The role of condition: in particular flips depending on whether table:/key: accompany it, joining ON predicate when alone, extra constraint when paired.
| Fields set | How the hop resolves |
|---|---|
|
The explicit form. |
|
The catalog-driven shortcut. |
|
Allowed but redundant. The generator validates the two agree and fails if they do not. |
|
Treated as the join |
|
|
none of the three |
Not a valid hop. Each element must pin its hop somehow. (An |
Paths mix the three forms freely, in any order and at any position.
Inline cross-table columns
@reference is most often seen on object-typed fields, but it works on scalar fields too: the parent type pulls a column from a referenced table directly into its own shape, with no intermediate object.
type Film @table(name: "film") {
languageName: String @field(name: "name") @reference(path: [{key: "film_language_id_fkey"}])
}
Film.languageName projects language.name, correlated through film.language_id, directly onto the film type. The classifier produces a ColumnBackedReferenceField; the emitter projects a correlated subquery over the FK path, capped at one row, under the requested alias. No intermediate language object appears; clients see Film { languageName } as a flat scalar.
This is useful when the schema wants to expose a foreign column without the indirection cost of nesting through the linked type. The trade-off: every Film selection that includes languageName adds a subquery to the projection. A field that already nests through language (e.g. Film { language { name } }) is preferred when other language columns are also exposed; the languageName shortcut is for the "expose exactly one column" case.
The shortcut also accepts the multi-hop and table: forms:
type Customer @table(name: "customer") {
storeManagerName: String
@field(name: "first_name")
@reference(path: [
{key: "customer_store_id_fkey"},
{key: "store_manager_staff_id_fkey"}
])
}
Same shape as a multi-hop object reference; the only difference is the parent type’s projection list at SQL time, which gets staff.first_name aliased to storeManagerName.
Filter references
@reference also applies to filter inputs: both INPUT_FIELD_DEFINITION (a field inside a filter: input object) and ARGUMENT_DEFINITION (a direct scalar argument). In both cases the directive lets a filter value resolve through a foreign-key path to a column on a joined table, instead of a column on the field’s own table.
input Input {
district: String! @reference(path: [{table: "address"}])
}
type Customer @table(name: "customer") {
customerId: Int! @field(name: "customer_id")
}
type Query {
query(in: Input!): Customer
}
The query’s return type Customer has @table(name: "customer"), so the input type Input is implicitly bound to the customer table for the duration of this argument site. The @reference(path: [{table: "address"}]) on the district input field resolves the customer→address FK and filters on address.district (a column that does not exist on customer).
Because the terminal column lives on a different table than the row being filtered, the generated condition does not compare a local column. It emits a correlated EXISTS subquery that joins through the path and applies the predicate against the terminal table:
EXISTS (
SELECT 1
FROM address
WHERE address.address_id = customer.address_id -- correlation back to the filtered row
AND address.district = ? -- predicate on the terminal column
)
The same applies to a direct scalar argument:
type Query {
customersByDistrict(
district: String @reference(path: [{table: "address"}])
): [Customer!]!
}
Multi-hop filters
The path may traverse several foreign keys. The EXISTS subquery then joins the whole chain, correlating its first hop back to the filtered row and applying the predicate against the terminal table:
type Query {
citiesByCountryName(
countryName: String @reference(path: [{table: "city"}, {table: "country"}]) @field(name: "country")
): [Address!]!
}
@field(name:) names the terminal column (here country.country) when it differs from the GraphQL field name; without it the field name is used.
Null and empty-list filters contribute no predicate
A reference filter behaves exactly like a local-column filter for absent values: a null scalar argument, or an empty list, contributes no predicate at all (the whole EXISTS term is guarded), so the query is unfiltered rather than matching zero rows. A list-valued reference filter emits IN (…) against the terminal column inside the EXISTS.
Condition hops in a filter path
Every hop form works in a filter path, at any position, mixed freely: {key:}, {table:}, and {condition:}. The EXISTS joins the path hop by hop, and each hop contributes whichever kind of join it declares. A {condition:} hop’s method becomes that join’s ON; at hop 0 it becomes the correlation back to the filtered row, receiving the filtered table and the hop’s own alias:
EXISTS (
SELECT 1
FROM address
WHERE customerToAddress(customer, address) -- the developer's predicate, correlating
AND address.district = ? -- predicate on the terminal column
)
A hop that carries condition: beside a key: or table: is the other case: the predicate is an extra constraint on the FK-joined hop rather than its ON, and it is emitted inside the same EXISTS, ANDed with the correlation. A filter through such a hop matches only rows the author’s predicate admits.
One difference from an output field: a filter site has no return type to read a table from, so a {condition:} hop there always works its target table out from the method’s signature. The second parameter must be a concrete generated jOOQ table class; a Table<?> resolves nothing and the build rejects it, naming the parameter. See The condition: form.
When the relationship a {condition:} hop expresses really is a key equality that simply is not declared in the database, declaring it is the better fix: add a jOOQ <syntheticObjects> <foreignKey> entry and switch the path to {key:}. That makes the relationship a catalog fact once instead of restating the predicate at every filter field, and it unlocks auto-discovery, {table:} paths, multi-hop @nodeId chains and editor completions along with it. The condition: form is for predicates with no key to declare: range overlaps, prefix matches, anything computed.
Filter references are a read-side feature, and condition hops change nothing about that. @lookupKey and the three write rails (insert, update, delete) each accept only a filter whose columns live on the row itself, so any path that leaves the table is refused there, foreign-key and condition hops alike.
The full set of input-field directives (@field, @condition, @reference) interoperate; see How-to: Stacking and overriding conditions for the input-carrier rules and how the override cascade interacts with the per-field directives.
The condition: form
ReferenceElement has a third option: condition: is an ExternalCodeReference to a Java method that returns an org.jooq.Condition to use as the join predicate. Use it for a relationship that is not a declared foreign key and has no key equality to declare: range overlaps, prefix matches, anything computed.
type Category @table(name: "category") {
similar: Category @reference(path: [{condition: {
className: "no.sikt.graphitron.rewrite.test.conditions.CategoryConditions",
method: "sameNamePrefix"
}}])
}
The Java side:
public static Condition sameNamePrefix(Table<?> src, Table<?> tgt) {
return DSL.noCondition();
}
The calling convention is (source, target): the source is the table the path enters the hop from (the field’s own table at hop 0, the previous hop’s alias after that) and the target is the hop’s own alias. The generator emits the returned Condition as that join’s ON, or, at hop 0 of a correlated subquery, as the correlation back to the outer row. Argument order matters and is worth typing concretely, because concrete parameters make a reversed pair a compile error in the generated code rather than silently wrong SQL.
Which table the hop lands on
A {condition:} hop names no table, so the generator has to work one out. It prefers a target the schema declares, and reads the method signature when there is none:
-
If the hop ends the field’s reference chain and the field’s return type carries
@table, that table is the target. The method’s parameters are then free to beTable<?>. -
Otherwise, including every hop in a filter path and every hop that has further hops after it, the target is the class of the method’s second parameter. It must be a concrete generated jOOQ table class;
Table<?>resolves nothing and the build rejects it, naming the parameter and saying what it is being read for.
Either way the parameter types are checked against the tables the generator will actually pass, so a concrete parameter naming the wrong table is a build error rather than a compile error in generated code.
Where condition hops work
Everywhere a @reference path works: object-typed output fields, inline scalar columns, and filter paths on both filter surfaces (see Filter references). Positions mix freely with {key:} and {table:} hops.
Prefer a declared foreign key when one is available or declarable. A {condition:} hop restates its predicate at every field that uses it, and it opts out of what the catalog gives you for free: FK auto-discovery, {table:} short forms, multi-hop @nodeId chains, editor completions.
@splitQuery on a referenced field
Adding @splitQuery switches the field from inline-JOIN dispatch to per-parent DataLoader batching. The @reference path stays the same; only the runtime shape changes.
type Customer @table(name: "customer") {
address: Address @reference(path: [{key: "customer_address_id_fkey"}])
addressSplit: Address @splitQuery @reference(path: [{key: "customer_address_id_fkey"}])
}
address resolves inline as part of the customer query (one SQL statement, JOIN-projected columns).
addressSplit resolves via a DataLoader: the customer query runs first, then a per-request batched address lookup keyed on customer.address_id runs in a second statement, fanned out to all parents that selected addressSplit.
The choice is operational, not semantic; How-to: When to split queries covers when round-trip cost beats fan-out cost (typically: deep paths, many sibling parents, large parent projections). The path itself is identical.
Pitfalls
-
An empty
path:says nothing.@reference(path: [])is legal SDL, and on a filter it is inert rather than an error: with no hops there is nothing to leave the table for, so the column resolves against the field’s own table and the filter is the plain local comparison a directive-less field would have produced. If you meant to reach a joined column, the path needs at least one hop. -
Multi-FK pairs require explicit
key:ortable:. When more than one FK exists between two tables for a given hop, the build fails with an ambiguous-reference diagnostic. Pick one withkey:(preferred when the FK has a stable name) ortable:(preferred when the FK is unique-but-unnamed in your conventions, and the destination disambiguates). -
table:requires unique-FK-between-the-pair. Otherwise the field classifies asUnclassifiedFieldand the build rejects. The diagnostic spells out the candidate FKs; switch tokey:to disambiguate. -
Implicit references work only when one FK exists. The example schema’s
Store.customersworks without@referencebecausecustomer ↔ storehas exactly one FK;Store.managercannot omit@referencebecausestore ↔ staffhas two FKs. -
Inline column references join the linked table.
languageName: String @field(name: "name") @reference(…)adds a JOIN tolanguagewheneverlanguageNameis selected. If the schema also exposesFilm.language { name }and clients tend to select via the nested form, the inline shortcut is redundant cost. -
A
condition:hop needs concrete parameter types wherever no return type declares its target. That is every hop of a filter path and every non-final hop of an output field’s path. ATable<?>second parameter resolves no target table and the build rejects it. See Thecondition:form. -
Prefer a declared foreign key when the relationship is one. If a
{condition:}hop is really a key equality the database does not declare, declare it as a jOOQ synthetic foreign key and switch to{key:}. The predicate form restates itself at every field and forgoes auto-discovery,{table:}short forms,@nodeIdchains and completions. -
Junction tables are path-only, never types. The
film_actorjoin table is two FKs in sequence; it does not appear as a GraphQL type. Adding it as a@table-bound type would be valid (FilmActor exists in the example schema for@nodeIdpurposes), but client-facing many-to-many fields use the path form. -
FK constraint names are catalog-bound. A migration that renames a constraint without updating the schema’s
key:value breaks the build. Thetable:form is more migration-resilient when the FK pair stays unique; thekey:form is more explicit when constraint names are the contract.
See also
-
@referenceis the directive surface this recipe expands. -
@tableestablishes the table binding both endpoints rely on. -
How-to: Polymorphic types for the union/interface variant, modelled through
@discriminate/@discriminator. -
How-to: When to split queries covers the inline-JOIN vs per-parent batch trade-off; the
@referencepath is shared across both. -
How-to: Stacking and overriding conditions covers the input-carrier rules and how input-field references interact with the condition cascade.
-
Tutorial page 4: Joining tables introduces single-hop and multi-hop references in narrative form.