@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. Customer and CustomerSummary); 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 firstName and column FIRST_NAME already 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 at FIRST_NAME.

  • Nested @table re-binds. A nested type with its own @table re-anchors column lookup; an @reference-annotated field hops into the joined table for the duration of that field’s subquery. Inside Customer, address: Address @reference(…​) resolves Address’s columns against the `address table, not customer.

  • Don’t use @field on relations. @field is for column-bound slots only. Relations (address: Address, films: [Film!]!) use @reference or 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 @table input 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. Define CustomerCreateInput separately and bind it with its own @table.

  • Filter inputs follow the same rule. A FilmConditionInput @table(name: "film") bound to a filmsByCondition(filter: FilmConditionInput) query uses @field on 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:

  1. The containing GraphQL type’s @table, if it has one.

  2. A nested type’s own @table re-binds inside that type.

  3. An @reference-annotated field hops the context into the joined table for the duration of its subquery.

  4. An argument or input field on a query/mutation field with no @table resolves against the field’s return-type table, not the parent’s.

Two consequences worth highlighting:

  • Nested types don’t inherit @table from their parent. Address inside Customer re-binds to its own table; @field inside Address resolves against address, not customer. The @reference on customer.address carries the FK path so the join is correct.

  • Arguments resolve against the return type. customers(active: Boolean @field(name: "ACTIVEBOOL")): [Customer!]! resolves ACTIVEBOOL against the customer table (the return type) even though the field lives on Query. This is why argument-side @field works without a @table on the argument itself.

Constraints and pitfalls

  • @field(name:) is required (graphql-java rejects no-arg @field at parse time).

  • The target identifier must resolve in the surrounding `@table’s jOOQ catalog; mismatches fail the build with a candidate hint.

  • @field is for column-bound slots only. Relations and nested types use @reference or 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 @field is omitted; that is only correct when the DB stores the GraphQL identifier verbatim.

  • jOOQ-enum values must match the Java enum constants 1:1; @field per 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 @field work without a @table on the argument itself; it also means the same argument bound to two different return types resolves against two different tables.

See also