Splits the annotated field’s resolution out of the surrounding parent SELECT into a separate, batched query. The rewrite emits a DataLoader-backed fetcher: per request, sibling parents' keys are gathered, one batched SELECT runs against the child table with a VALUES-keyed join, and rows scatter back to their original parent positions.
|
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. |
@splitQuery is the directive that decouples query depth from query breadth. Without it, child references inline into the parent SELECT (one round-trip, joined SQL). With it, each @splitQuery boundary becomes a deferred DataLoader call (more round-trips, narrower SQL, batched across parents). It composes with @reference, @lookupKey, @asConnection, and @service.
SDL signature
directive @splitQuery on FIELD_DEFINITION
The directive takes no arguments. The cardinality (single vs list), the FK direction (parent-holds-FK vs child-holds-FK), and the path’s hop count are all read from the surrounding @reference / catalog inference; the rewrite picks the appropriate DataLoader shape from those signals.
Canonical examples
List child, child-holds-FK (@splitQuery keyed on parent’s PK). The DataLoader’s batch is parent_id IN (…), distributing rows to parents by FK:
type Language @table(name: "language") {
films: [Film!]! @splitQuery @reference(path: [{key: "film_language_id_fkey"}])
}
Single-cardinality, parent-holds-FK (@splitQuery keyed on parent’s FK column). At least one parent may carry NULL in the FK; the rewrite short-circuits those to null without dispatching to the DataLoader:
type Customer @table(name: "customer") {
addressSplit: Address @splitQuery @reference(path: [{key: "customer_address_id_fkey"}])
}
type Store @table(name: "store") {
manager: Staff @splitQuery @reference(path: [{key: "store_manager_staff_id_fkey"}])
}
Store.manager traverses a nullable FK; the §4 null-FK short-circuit returns null without a round-trip when the column is NULL. The @reference is required even though Customer.address_id would auto-infer, because store and staff have two FKs between them and inference would be ambiguous.
Multi-hop list with @lookupKey (a Table-sourced BatchedTableField carrying the keyed lookup resolution: flat SELECT + VALUES+ON keyset + per-parent scatter):
type Film @table(name: "film") {
actorsBySplitLookup(actor_id: [Int!] @lookupKey): [Actor!]! @splitQuery @reference(path: [
{key: "film_actor_film_id_fkey"},
{key: "film_actor_actor_id_fkey"}
])
}
Self-referencing FK (split is available for fan-out reduction; not structurally required since the inline emitter handles self-joins via parent-prefixed alias names):
type Film @table(name: "film") {
sequel: Film @splitQuery @reference(path: [{key: "FILM__SEQUEL_FKEY"}])
}
Non-root @service (resolver-style fields without an FK chain back to the parent’s table need an explicit split):
type City @table(name: "city") {
filmsFromCity: [Film!]! @splitQuery @service(service: {className: "com.example.MockService"})
}
Combined with @asConnection (per-parent paginated child connection: ROW_NUMBER() OVER (PARTITION BY parentInput.idx) envelope gives each parent its own slice):
type Film @table(name: "film") {
actorsConnection(first: Int, after: String): ActorsConnection!
@splitQuery
@reference(path: [
{key: "film_actor_film_id_fkey"},
{key: "film_actor_actor_id_fkey"}
])
@defaultOrder(primaryKey: true)
}
Constraints
-
Single-cardinality
@splitQueryis restricted to a single-hop@referencepath. Multi-hop single-cardinality is rejected at build time with the offending path’s hop count in the message. -
List-cardinality
@splitQuerysupports any hop count, including junction-table chains (@reference(path: […, …])). -
Implicit on
@table-typed fields of a class-backed parent reachable from@service: re-entry queries are emitted automatically. Explicit@splitQueryon those fields is redundant but not rejected. (classifyChildFieldOnResultTypenever inspects@splitQueryon class-backed-parent table-bound fields.) -
Rejected at build time (
[deferred]) on a field whose return type is a plain-object nesting type (a directiveless object projected off the parent’s own table row). The batched launcher the split implies is not generated, and accepting the directive would silently ignore it. This is the line between the positionally inert cases on this page: where the fetch is already separate (root fields, argument-bearing fields, class-backed parents) the directive is redundant and at most warned about; where honoring it would require a delivery that does not exist (a nesting field) it is unimplemented and rejected. Remove the directive to keep the inline projection;@splitQueryon table-backed fields inside the nesting type, and on@pivotfields, is supported as usual. -
Redundant but not rejected on a field returning a
@table+@discriminateinterface (a single-table discriminated interface). This sits on the redundant and at most warned about side of the line the nesting bullet above draws, and for the stronger of the two reasons: the discriminated re-projection is always its own statement, so the directive’s own meaning (stop projecting this child into its parent’s SELECT) is satisfied before it is applied, and the delivery an author reaching for it actually wants is already the default. List and connection cardinality batch the re-projection through a DataLoader on their own, one statement for the whole batch of parents (the connection form windows that statement into per-parent pages); single cardinality fetches per parent, which no directive changes. Writing@splitQueryhere yields a lint warning (splitquery-redundant-on-discriminated-interface-child) offering to delete it, and the directive is ignored. -
Self-referencing FKs work inline in the rewrite. the inline projection arm prefixes alias names with the parent alias’s runtime name so recursive subselects don’t collide;
Category.parentandCategory.childrenin the example schema cover this end-to-end.@splitQueryis available on self-FKs for the same fan-out-reduction reasons as any other reference, but is not required. -
Required when
@serviceis used on a non-root field (the service fetches the row independently of the parent’s SELECT; the split is the contract). -
Composes with
@asConnection: the per-parent partition envelope is added to the connection’s pagination shape. -
Composes with
@lookupKey: the lookup key narrows the per-parent batch (each parent’s child list filtered by the caller-provided keys). -
On root (
Query/Mutation) fields and on fields with arguments,@splitQueryis irrelevant; those always have explicit data fetchers. The directive is silently ignored there. -
Nullable FKs on parent-holds-FK single-cardinality fields short-circuit to
nullbefore dispatching to the DataLoader (saves a round-trip when no parents have a non-null FK value).
See also
-
@referencesupplies the FK chain;@splitQuerydecides whether to inline or batch it. -
@asConnectionadds per-parent Relay pagination on top of a split fetch. -
@lookupKeynarrows the per-parent batch to caller-provided keys. -
Class-backed parents reachable from
@serviceget implicit@splitQueryon@table-typed children. -
How-to: When to split queries covers the round-trip-vs-fan-out trade-off, self-FK rules, and DataLoader caching behaviour.
-
LSP inlay hints and classification hover marks the separately fetched fields in the editor, for every reason a split happens, so a type’s round-trip cost can be read off the schema.