GraphQL gives you two ways to spell "this field returns one of several object types": an interface whose implementers each implements it, and a union that lists member types. Graphitron supports both and projects each onto one of two layouts. Single-table polymorphism: every subtype shares one jOOQ table, and a discriminator column says which row belongs to which subtype. Multi-table polymorphism: each subtype has its own table, and the generator stitches results together with a two-stage UNION ALL fetcher. Pick by where the rows actually live; the directives follow from that choice.
Choose the layout
Single-table fits when the rows literally share a table and a column tells you the subtype. The classic shape is an entity_type discriminator with subtype-specific columns nullable on the rows that don’t use them. One SELECT against the shared table covers every subtype; row-mapping branches at the discriminator value.
Multi-table fits when subtypes are stored in different tables (heterogeneous shape, separate PKs, separate indexes). There is no shared discriminator column to pivot on; the generator emits a stage-1 narrow UNION ALL projecting (typename, pk, sort__) across the participants and a stage-2 per-typename batched lookup that hydrates each branch’s full row.
The two layouts are not mutually exclusive across a schema: a single GraphQL schema can carry single-table interfaces (e.g. Content) and multi-table interfaces (e.g. Searchable) side by side. Each interface or union picks its own layout independently of the others.
Single-table: @discriminate + @discriminator
The interface carries @table (the shared table) and @discriminate(on:) (the column that selects the subtype). Each implementer carries the same @table and @discriminator(value:) pinned to the column’s literal value:
interface Content @table(name: "content") @discriminate(on: "CONTENT_TYPE") {
contentId: Int! @field(name: "CONTENT_ID")
title: String! @field(name: "TITLE")
}
type FilmContent implements Content @table(name: "content") @discriminator(value: "FILM") {
contentId: Int! @field(name: "CONTENT_ID")
title: String! @field(name: "TITLE")
length: Int @field(name: "LENGTH")
rating: MpaaRating @reference(path: [{key: "content_film_id_fkey"}]) @field(name: "RATING")
}
type ShortContent implements Content @table(name: "content") @discriminator(value: "SHORT") {
contentId: Int! @field(name: "CONTENT_ID")
title: String! @field(name: "TITLE")
description: String @field(name: "SHORT_DESCRIPTION")
}
A query selecting … on FilmContent { length } and … on ShortContent { description } produces one SELECT content.content_id, content.title, content.length, content.short_description, content.content_type FROM content. The row mapper inspects content_type per row and dispatches to the matching type. Subtype-specific fields are read from the unified projection as nullable; the row mapper ignores them on the wrong branch.
Subtype-only fields can still pull from joined tables. FilmContent.rating reaches into film via content_film_id_fkey; the generator emits a conditional LEFT JOIN gated by content_type = 'FILM', so non-FILM rows never trigger the join.
Multi-table: omit @discriminate
When the implementers live on different tables, drop @discriminate from the interface (or use a union, which never carries one). The interface itself stays unbound; it carries no @table, because there is no shared backing storage:
interface Searchable {
name: String!
}
type Film implements Searchable @table(name: "film") {
name: String! @field(name: "TITLE")
# ...
}
type Actor implements Searchable @table(name: "actor") {
name: String! @field(name: "FIRST_NAME")
# ...
}
union Document = Film | Actor
The interface field name is synthetic on the GraphQL side; each implementer remaps its own meaningful column to it via @field(name:). The union form Document = Film | Actor produces the same fetcher path as the interface form; the generator’s MultiTablePolymorphicEmitter is shared between them.
At request time the rewrite emits a two-stage fetcher. Stage 1 is a narrow UNION ALL projecting (typename, pk, sort) per branch; stage 2 dispatches per typename to a batched lookup that hydrates the full row. The records carry typename so the GraphQL TypeResolver routes per row without a discriminator column.
Polymorphic child fields
A polymorphic interface or union can be the return type of a child field, not just a root query. Two shapes are common.
Auto-discovered per-branch FK back to the parent. If each implementer has exactly one FK back to the parent table, the rewrite infers the per-branch WHERE clause without any @reference on the child field:
type Address @table(name: "address") {
addressId: Int! @field(name: "ADDRESS_ID")
occupants: [AddressOccupant!]!
}
union AddressOccupant = Customer | Staff
Both customer.address_id and staff.address_id FK back to address.address_id, so the multi-table polymorphic emitter projects per-branch WHERE customer.address_id = parent.address_id and WHERE staff.address_id = parent.address_id in stage 1’s narrow UNION ALL. No directive is needed on Address.occupants.
FK chain to a single-table interface. If the field returns a single-table polymorphic interface reached through one FK chain, use a regular @reference:
type Film @table(name: "film") {
filmContent: Content @reference(path: [{key: "content_film_id_fkey"}])
}
content.film_id FKs back to film.film_id; the per-parent fetcher conditions on the FK and projects CONTENT_TYPE so the row mapper still routes per row.
Pagination across polymorphism
Both layouts compose with @asConnection. For multi-table polymorphism the connection emitter wraps the per-branch UNION ALL in a derived table so .seek/.limit and cursor encoding apply uniformly across the union; per-typename stage 2 additionally projects sort so cursor decoding can read it back per edge. Composite-PK participants project DSL.jsonbArray(k1, k2) as the synthetic sort column, typed as JSONB so PostgreSQL’s lexicographic ordering reproduces the multi-column ordering.
For single-table polymorphism, pagination works the same way as for non-polymorphic @table fields: the discriminator column is just another projected column, and the row mapper still branches per row. The connection’s order-by and cursor contract are unchanged.
Discriminator-value pitfalls
Single-table layouts hinge on the literal database value matching @discriminator(value:) verbatim. Common gotchas:
-
Case sensitivity.
value: "FILM"does not match a stored'film'. Match the column’s storage exactly. -
Whitespace. Trailing spaces on
CHAR(N)columns are part of the stored value. Either trim at write time or include the padding in thevalue:. -
Duplicate values. Two implementers with the same
value:collapse to one branch; the second is unreachable. The build does not currently reject this, so audit for it. -
Unknown values. A row whose discriminator column carries a value no implementer claims fails to map and surfaces as a runtime error. Either backfill the column or add an implementer.
-
Mixing tables. Every implementer must declare the interface’s
@table. An implementer on a different table is rejected at classify time; for cross-table layouts, omit@discriminateand use the multi-table fetcher instead. -
Missing column.
@discriminate(on:)must name a real column on the shared table. The build fails to resolve if it does not exist.
Constraints
-
The single-table layout requires every implementer to share the interface’s
@table. Mixing tables breaks the contract. -
The multi-table layout requires that each implementer be
@table-bound; the interface or union itself is not. -
Subtype-specific fields on a single-table interface may pull from joined tables via
@reference; the generator emits a conditionalLEFT JOINgated by the discriminator value. -
Auto-discovered per-branch FK paths (the
AddressOccupantshape) are the supported multi-table-child idiom. The legacy@multitableReferencedirective is rejected; see@multitableReference. -
For composite-PK multi-table polymorphism with
@asConnection, all participating tables must share the same composite-PK shape; the polymorphic emitter projects a single typedsortcolumn across the branches.
See also
-
@discriminatedeclares the interface or union’s discriminator column. -
@discriminatorpins each implementer to a value. -
@referencefor cross-table joins on subtype-specific fields and for FK-chain access to single-table polymorphic interfaces. -
@asConnectionfor cursor pagination across either layout.