@table anchors a GraphQL type to a database table; @field points a slot at a specific column or string. Together they carry every type-to-storage mapping in the rewrite. This recipe walks through the four directive sites of @field (object field, argument, input field, enum value), shows the two enum-mapping shapes (text-column and jOOQ-enum), and covers the case-sensitivity and re-binding rules that catch teams new to the directive pair.
When to spell @table and when to omit it
@table without name: looks up the jOOQ table by the GraphQL type name, case-insensitively. So type Customer resolves to the customer table without configuration. Spell name: explicitly when:
-
The GraphQL type name and the SQL identifier diverge (renamed type, namespace prefix, plural/singular mismatch). Example:
type Person @table(name: "customer")keeps the schema-side rename intact while binding to the existing table. -
The SQL identifier carries casing or punctuation the GraphQL type can’t replicate (uppercase, underscores, or quoted identifiers in the jOOQ catalog).
-
Two GraphQL types bind to the same table (e.g.
CustomerandCustomerSummary); both need explicit@table(name: "customer")because one is going to lose the case-insensitive default.
The name: value must match the jOOQ-generated identifier exactly (typically uppercase for PostgreSQL when jOOQ’s default casing is in effect). The build fails with an unclassified-type error if the catalog has no matching Table class.
Tables in non-default schemas
When the consumer’s jOOQ codegen straddles two or more SQL schemas (public, archive, an external integration namespace), @table(name:) accepts a qualified "schema.table" form to scope the lookup. Unqualified names still work in the common case where the table name is unique across every schema in the catalog:
type Widget @table(name: "widget") { # unique across schemas
id: ID! @field(name: "WIDGET_ID")
}
type ArchivedEvent @table(name: "archive.event") { # qualified
id: ID! @field(name: "EVENT_ID")
}
type CurrentEvent @table(name: "public.event") { # qualified
id: ID! @field(name: "EVENT_ID")
}
When the bare table name appears in two or more schemas, an unqualified lookup fails the build with @table(name: 'event') is ambiguous: defined in schemas [archive, public]; qualify as 'archive.event', 'public.event' (the catalog returns empty rather than picking whichever schema iterated first; the rejection names the colliding schemas and the qualified forms inline). The fix is to pick the right schema and qualify; the message tells you both options. Single-schema consumers see no change; only collision sites are forced to qualify, so adding a non-default schema to a previously single-schema project only forces edits where a name actually collides.
A genuinely missing name (the value isn’t in any schema) takes a different path: the standard table 'X' could not be resolved in the jOOQ catalog rejection with a Levenshtein-ranked candidate hint pointing at near matches. The two messages distinguish "this column was renamed" from "this column lives somewhere; pick which one".
The same syntax applies on input types and interfaces; @table(name: "schema.table") is valid wherever bare @table(name:) was. Schema and table halves of the qualified value are both case-insensitive (consistent with the unqualified form).
Field-to-column with @field
The most common shape: bind every scalar to its column.
type Customer @table(name: "customer") {
customerId: Int! @field(name: "CUSTOMER_ID")
firstName: String! @field(name: "FIRST_NAME")
lastName: String! @field(name: "LAST_NAME")
email: String @field(name: "EMAIL")
}
The selection set drives projection: a query asking for firstName lastName produces SELECT customer.first_name AS "firstName", customer.last_name AS "lastName", not SELECT *. Adding columns to the type only widens the projection when clients ask for them.
Four practical rules:
-
Names match case-insensitively. When GraphQL
firstNameand columnFIRST_NAMEalready match (lowercased and underscore-normalised), the directive is technically redundant. The example schema applies it explicitly anyway because mismatches creep in over time and explicit binding keeps the drift detectable in code review. -
Mismatch fails the build.
@field(name: "FIRST_NAMEZ")(typo) fails resolution against the catalog, with a candidate hint pointing atFIRST_NAME. -
Nested
@tablere-binds. A nested type with its own@tablere-anchors column lookup; an@reference-annotated field hops into the joined table for the duration of that field’s subquery. InsideCustomer,address: Address @reference(…)resolvesAddress’s columns against the `addresstable, notcustomer. -
Don’t use
@fieldon relations.@fieldis for column-bound slots only. Relations (address: Address,films: [Film!]!) use@referenceor auto-discovered FK paths instead.
Argument-to-column: avoid silent column collisions
When a Query field exposes a filter argument whose name doesn’t match the column, @field pins the binding:
type Query {
customers(active: Boolean @field(name: "ACTIVEBOOL")): [Customer!]!
}
Without the directive, the generator would look up a column called active on the customer table. Sakila’s customer table happens to have one (an unused integer flag), so the filter would silently bind to the wrong column. @field(name: "ACTIVEBOOL") makes the binding explicit and the build will fail loudly if ACTIVEBOOL ever disappears from the catalog.
This is the canonical case where @field is not redundant: GraphQL conventions favour short, intuitive argument names (active, name, id) while databases often keep longer, more specific column names (ACTIVEBOOL, FIRST_NAME, CUSTOMER_ID). The directive is what makes the schema readable on both sides without coupling them.
The argument can also drive a typed-enum filter (see "Enum values" below) or a @lookupKey batch lookup; @field works the same way in either case.
Input-field-to-column: mutations and filters
@field on INPUT_FIELD_DEFINITION plays the same role inside an input type used by a mutation, condition, or upsert:
input FilmCreateInput @table(name: "film") {
title: String! @field(name: "title")
languageId: Int! @field(name: "language_id")
}
The INSERT writes title and language_id; the GraphQL camelCase never reaches SQL. The @table directive on the input type tells the generator which table the write targets; without it, the input would not classify as a TableInputType and the mutation would not wire up.
Three things that surface here and not on output types:
-
Required input scalars are write-time required. A
title: String!field on a@tableinput means the column must be supplied on every call; nullability of the underlying column does not relax the GraphQL contract. -
Input types don’t compose like output types. You cannot reuse
Customer(an output type) as an input. DefineCustomerCreateInputseparately and bind it with its own@table. -
Filter inputs follow the same rule. A
FilmConditionInput @table(name: "film")bound to afilmsByCondition(filter: FilmConditionInput)query uses@fieldon each input field to point its@condition-driven match at the right column.
Enum values: text-column and jOOQ-enum
GraphQL enums map to two distinct database shapes, and @field on ENUM_VALUE carries the right thing for each.
Text-column enum. The column is text, varchar, or another string type, and the DB stores literal values (some of which can’t be GraphQL identifiers). Use @field(name:) on each value where the storage diverges:
"""Text-column enum: values map to database strings via @field(name:)."""
enum TextRating {
G
PG
PG_13 @field(name: "PG-13")
R
NC_17 @field(name: "NC-17")
}
GraphQL doesn’t allow - in enum values, but the database stores "PG-13" and "NC-17". @field per value provides the GraphQL-safe identifier-to-storage mapping. The generator emits a static *_MAP lookup table and uses it to translate GraphQL values to DB strings on the way down (filter arguments, mutation input) and back to GraphQL on the way up (selection projections). Values without @field default to their own name, so G, PG, and R map to "G", "PG", and "R" without further configuration.
jOOQ-typed enum. The column is a database-native ENUM type (PostgreSQL CREATE TYPE) for which jOOQ generates a Java enum class. GraphQL enum values must match the Java constants exactly:
enum MpaaRating {
G
PG
PG_13
R
NC_17
}
The generator validates every GraphQL value against the jOOQ-generated Java enum’s Enum.values() and rejects mismatches at build time with a candidate-hint diagnostic. There is no @field per value on the jOOQ-enum case because the Java enum names are the contract; renaming a GraphQL value to a non-matching name fails the build.
The two cases coexist on the same database. The Sakila example schema’s Film.rating is the jOOQ-enum form (mpaa_rating is a Postgres ENUM); Film.textRating is the text-column form on a sibling TEXT_RATING column that stores the same values as plain strings.
When the column is a jOOQ enum but the GraphQL side carries @field on values, the build fails. When the column is text but the GraphQL enum has no @field overrides, the values default to their own names; that’s only correct if the DB happens to store the GraphQL identifiers verbatim.
Re-binding and the table-context cascade
Column lookup resolves against a current table context. The cascade rules:
-
The containing GraphQL type’s
@table, if it has one. -
A nested type’s own
@tablere-binds inside that type. -
An
@reference-annotated field hops the context into the joined table for the duration of its subquery. -
An argument or input field on a query/mutation field with no
@tableresolves against the field’s return-type table, not the parent’s.
Two consequences worth highlighting:
-
Nested types don’t inherit
@tablefrom their parent.AddressinsideCustomerre-binds to its own table;@fieldinsideAddressresolves againstaddress, notcustomer. The@referenceoncustomer.addresscarries the FK path so the join is correct. -
Arguments resolve against the return type.
customers(active: Boolean @field(name: "ACTIVEBOOL")): [Customer!]!resolvesACTIVEBOOLagainst thecustomertable (the return type) even though the field lives onQuery. This is why argument-side@fieldworks without a@tableon the argument itself.
Constraints and pitfalls
-
@field(name:)is required (graphql-java rejects no-arg@fieldat parse time). -
The target identifier must resolve in the surrounding `@table’s jOOQ catalog; mismatches fail the build with a candidate hint.
-
@fieldis for column-bound slots only. Relations and nested types use@referenceor auto-discovered FK paths. -
GraphQL slot name and target identifier match case-insensitively (lowercased + underscore-normalised). The directive is redundant when names match; spell it out anyway to surface drift.
-
Input types must declare their own
@table; inheriting from an output type’s binding is not supported. -
Text-column enum values default to their own name when
@fieldis omitted; that is only correct when the DB stores the GraphQL identifier verbatim. -
jOOQ-enum values must match the Java enum constants 1:1;
@fieldper value is rejected on the jOOQ-enum case. -
The argument-side default (column lookup against the field’s return-type table) is what makes argument
@fieldwork without a@tableon the argument itself; it also means the same argument bound to two different return types resolves against two different tables.
See also
-
@tableestablishes the table context. -
@fieldis the per-slot binding directive. -
@referencehops between two@table-bound types. -
@lookupKeycomposes with@fieldon argument-side batch lookups. -
@conditioncomposes with@fieldon input filter fields. -
Tutorial page 2: The starter schema introduces
@tableand@fieldtogether.