A filter input that decodes an opaque @nodeId of one type and applies it as a predicate against rows of a different type is the bread-and-butter shape for cross-table filtering. @reference declares the FK path, and every foreign-key chain the path grammar can state is emittable on the read side. What the chain decides is which predicate you get: a single-table predicate over the parent’s own columns, or a correlated EXISTS that walks the chain.
One question decides the predicate
Ask it per key column: after walking the chain, is that column of `T’s key observable on the parent’s own row?
With a single direct FK the answer is decided by where that FK lands on the node type’s table. When its target-side columns are the node type’s key columns, the decoded key sits on the parent’s own row at the FK’s source columns, so the predicate is WHERE parent.fk_columns IN (decoded_keys): no JOIN, no subquery, one column tuple and one IN clause. When the FK targets some other unique column of that table instead, the parent’s row carries that column’s value and not the key, so no parent column holds the decoded value and the filter binds the node’s key on the node’s own table inside the same correlated EXISTS the chains below produce. One direct foreign key is therefore not a promise of a single-table predicate; the landing is.
Across multiple hops the answer depends on whether each hop carries the previous hop’s arrival forward. Walk the chain from the first hop: each hop keeps the column it departed the parent from and replaces the column it arrived at, so a hop that departs from a column the previous hop did not arrive at carries nothing further. When every key column is still being carried at the end, the chain has the same single-table shape a direct FK has, and this is the identity-carrying lift: the terminal tuple lifts back to a tuple on the parent’s own row, positionally aligned with `T’s NodeType keys. Chain length is then purely a classifier-time concept and the runtime touches one table.
When a key column is carried nowhere, no parent column holds the decoded value, so the predicate binds T’s key on `T’s own table inside a correlated `EXISTS that joins the chain back to the parent row. Both worked examples below are real, and neither is a fallback for the other.
Three shapes reach the EXISTS, and it is worth seeing that they are one case. One translates: a hop departs from a renamed or alternate-key column the previous hop never arrived at, or a single direct FK targets a unique column that is not the node’s key. One is a junction: the parent reaches an intermediate table against a foreign key’s direction, so the pairing lives in the junction row and not in either endpoint’s columns. One is a reverse hop: the only foreign key connecting the two tables is declared on the node type’s own table, so the parent’s row holds no column pointing at the node at all. Nothing is renamed in the latter two, which is why the question is about the landing and not about column names.
Worked example: an identity-carrying 2-hop chain
Three tables on a (k1, k2) identity-carrying chain. level_a is the NodeType target; level_c is the parent shape we filter:
CREATE TABLE level_a (
k1 varchar(20) NOT NULL,
k2 varchar(20) NOT NULL,
PRIMARY KEY (k1, k2)
);
CREATE TABLE level_b (
s varchar(20) NOT NULL,
k1 varchar(20) NOT NULL,
k2 varchar(20) NOT NULL,
PRIMARY KEY (s, k1, k2),
FOREIGN KEY (k1, k2) REFERENCES level_a (k1, k2)
);
CREATE TABLE level_c (
c varchar(20) NOT NULL,
s varchar(20) NOT NULL,
k1 varchar(20) NOT NULL,
k2 varchar(20) NOT NULL,
PRIMARY KEY (c, s, k1, k2),
FOREIGN KEY (s, k1, k2) REFERENCES level_b (s, k1, k2)
);
Both FKs preserve the next FK’s source-side columns positionally by SQL name:
-
level_c.(s, k1, k2) → level_b.(s, k1, k2)carries(s, k1, k2)tolevel_b. -
level_b.(k1, k2) → level_a.(k1, k2)consumes(k1, k2)of those, which were carried in.
So level_a’s identity `(k1, k2) is observable on a level_c row at columns (k1, k2) directly.
The schema declaration:
type LevelA implements Node @table(name: "level_a") @node {
id: ID!
}
input LevelCFilterInput {
levelAIds: [ID!] @nodeId(typeName: "LevelA") @reference(path: [
{key: "level_c_level_b_fk"},
{key: "level_b_level_a_fk"}
])
}
extend type Query {
levelCs(filter: LevelCFilterInput): [LevelC!]!
}
The classifier walks the chain, lands each key column, and stores the resulting tuple on the carrier. The emitter reads that tuple directly. The generated SQL is … WHERE row(level_c.k1, level_c.k2) IN ((decoded_k1, decoded_k2), …) over a single-table FROM clause.
Worked example: a junction chain
A junction table pairs two tables and holds no identity of its own. Sakila’s film_category is the shape:
CREATE TABLE film_category (
film_id int NOT NULL REFERENCES film (film_id),
category_id int NOT NULL REFERENCES category (category_id),
PRIMARY KEY (film_id, category_id)
);
Filtering films by a Category node id names both foreign keys, in the order the chain walks them. The first is traversed against its direction, from film to the junction:
type Category implements Node @table(name: "category") @node {
id: ID!
}
input FilmFilterInput {
categoryIds: [ID!] @nodeId(typeName: "Category") @reference(path: [
{key: "film_category_film_id_fkey"},
{key: "film_category_category_id_fkey"}
])
}
extend type Query {
films(filter: FilmFilterInput): [Film!]!
}
The first hop departs film.film_id and arrives on film_category.film_id; the second departs film_category.category_id, which is not where the first arrived, so nothing carries forward. Category’s key column lands on no `film column, which is correct: a film row holds no category, only the junction row holds the pairing. The predicate binds category.category_id inside a correlated EXISTS that joins film_category back to the film row.
A junction is many-to-many, so a film in two of the requested categories matches through two junction rows. EXISTS is what keeps that from multiplying the film: it asks whether at least one row exists, so each matching parent comes back exactly once, and a null foreign-key column fails the correlation rather than duplicating or dropping a row.
Auto-discovery is single-hop and directional
The fallback that lets you omit @reference looks for exactly one foreign key that is declared on the parent’s own table and references the node type’s table. Two things it does not look for, each with its own build message.
It does not search past one hop. Multi-hop chains are declared explicitly, one { key: … } element per hop, and disambiguation among A → ? → C paths is the author’s responsibility. This pins the multi-hop opt-in to the SDL surface where the chain is visible at review time.
It does not search the other direction. A foreign key declared on the node type’s own table is not discovered, even when it is the only one connecting the two tables. That is the reverse hop, and naming the constraint is all it takes:
type Customer implements Node @table(name: "customer") @node {
id: ID!
}
extend type Query {
# customer.address_id -> address.address_id. The foreign key is on customer, so
# reaching Customer from address traverses it backwards.
addresses(customerIds: [ID!] @nodeId(typeName: "Customer") @reference(path: [
{key: "customer_address_id_fkey"}
])): [Address!]!
}
An address row holds no customer_id, so this binds remotely exactly as a junction chain does, with one hop instead of two. It is non-unique the same way: two customers can share an address, and the EXISTS is what brings that address back once rather than twice.
The direction is where this differs from the {table:} shortcut in @reference join paths, which resolves a hop from either endpoint and so needs only that one foreign key connects the pair. A @nodeId leaf’s auto-discovery starts from the parent’s row, because a single-table predicate is what it is trying to find, and only a foreign key leaving that row can produce one.
Where auto-discovery declines, the build message says which of the three cases you are in, because the remedy differs:
-
Several foreign keys leave the parent’s table for the node type’s table. The message names every candidate and spells one of them as the
@referencethat picks it. This is the disambiguation case. -
None leaves it, but one is declared on the node type’s table. The message names that constraint, which is the reverse hop above. Nothing here is ambiguous, so nothing needs disambiguating; the path just has to be stated.
-
No foreign key connects the two tables at all. Auto-discovery is single-hop, so the remedy is a chain through the tables in between, or a corrected
@nodeId(typeName:)when the node type named is not the one meant.
Where the chain is refused
A chain that binds remotely still reads and filters. Two things refuse it.
A write, or a @lookupKey. An INSERT, an UPDATE, a DELETE key or a @lookupKey join needs the value on the row it is writing, and a remotely-bound @nodeId supplies a key of the target row instead. All four say so with the same sentence, that the shape "needs a key-to-FK-column subquery, which is not implemented". Expose the decoded key columns explicitly with @field on those coordinates, or keep the chain on the read side where the EXISTS serves it.
A { condition: … } step, at any coordinate:
must be a foreign key
A { condition: … } step appeared inside a multi-hop @nodeId @reference path. Every step in a multi-hop chain must join via a foreign key; condition-only steps are rejected with:
@reference path on @nodeId leaf '<leafName>': step <i> is a condition step; every step in a
multi-hop @nodeId path must be a foreign key (use { key: ... } at every position).
What to change: replace the { condition: … } with a { key: <fk-name> } step. If the predicate that the condition: was expressing genuinely belongs in the chain, lift it to the surrounding query’s @condition arm rather than embedding it in the @nodeId filter path.
Related
-
How-to: Join with references covers the directive surface for non-
@nodeId@referencepaths. -
How-to: Global object IDs covers the wire format for
@nodeIdend-to-end. -
How-to: Add custom conditions is the right home for predicates that join with non-FK semantics.