Marks a mutation field as fully generated and picks the SQL shape (INSERT, UPDATE, DELETE, UPSERT). The write runs inside a transaction with a primary-key-only RETURNING; a @table-typed response is then read back by a follow-up SELECT keyed on those primary keys, so the response selection set never widens the write statement itself.

SDL signature

directive @mutation(typeName: MutationType!, multiRow: Boolean = false, table: String) on FIELD_DEFINITION

enum MutationType { UPDATE, DELETE, INSERT, UPSERT }

Parameters

Name Type Default Description

typeName

MutationType!

(required)

The SQL shape to emit. INSERT writes a row; UPDATE modifies the row identified by the input’s primary-key-or-unique-key coverage; DELETE removes the row(s) identified the same way; UPSERT emits INSERT … ON CONFLICT … DO UPDATE.

multiRow

Boolean

false

On DELETE, opts into a broadcast delete when the input covers no primary key or unique key (the filter may match more than one row). Rejected on INSERT (no WHERE clause), UPDATE (no broadcast shape; cover a key), and UPSERT.

table

String

(none)

Names the SQL table to write to, on the consuming field. It gives a write that carries no table on its return side a field-relative write target: a bare ID / Boolean / count return names no table, so @mutation(table: "film") names it directly (the field-level analogue of @service(argMapping:)). A DELETE always names its table this way; on INSERT and UPDATE it is the fallback for a return that names no table, and a field whose return does name the table (a @table return, or a payload’s @table-element data field) needs no table:. Wired for DELETE, INSERT, and UPDATE; supplying it on UPSERT is rejected.

Input shape

A @mutation field takes exactly one input argument. Its fields bind to columns of the write target’s table (@field(name:), or the SDL name by default). The input type carries no table directive of its own (@table on an input is a deprecated location and is ignored): for INSERT and UPDATE the write target is derived from the field’s return (see Naming the INSERT and UPDATE write target), and for DELETE it is named on the field with @mutation(table:) (see Naming the DELETE write target).

An input field may also be a nested input object: a grouping shape whose own fields map onto columns of the same table. The grouping carries no DML semantics; the leaves flatten onto the outer write target as if declared at the top level, and absent-vs-null is honored independently at every nesting layer (an absent or null group skips its whole subtree; a present group descends per leaf). The tutorial’s Grouping fields with nested input types walks a worked example. A nested input introducing a second DML target is a separate compound-mutation shape and is not admitted here.

Canonical example

The tutorial’s film mutations cover the two most common shapes. Neither input carries a directive; the write target is derived from the Film return type (Film carries @table(name: "film")):

input FilmCreateInput {
    title:      String! @field(name: "title")
    languageId: Int!    @field(name: "language_id")
}

input FilmUpdateInput {
    filmId: Int!    @field(name: "film_id")
    title:  String! @field(name: "title")
}

type Mutation {
    createFilm(in: FilmCreateInput!): Film @mutation(typeName: INSERT)
    updateFilm(in: FilmUpdateInput!): Film @mutation(typeName: UPDATE)
}

The INSERT shape produces a two-step exchange. The write commits inside a transaction with a primary-key-only RETURNING:

INSERT INTO film (title, language_id)
     VALUES (?, ?)
  RETURNING film.film_id

and the response selection set is then read back by a follow-up SELECT keyed on the returned primary key:

SELECT film.film_id AS "filmId", film.title AS "title"
  FROM film
 WHERE film.film_id = ?

The follow-up SELECT narrows to the response selection set: a mutation that asks for { filmId title } retrieves only those columns. Because the write has already committed when the read-back runs, an error during the response SELECT surfaces as a field error and cannot undo the write.

For a bulk mutation (list input, [Film!]! return), the follow-up SELECT joins a VALUES (idx, pk) table over the returned keys and orders by idx. The payload list contains one entry per written row, in the order the rows were written, mirroring @lookupKey's ordering contract ("returns a list of results in the same order").

UPDATE identifies the target row by which input columns cover a primary key or unique key of the write-target table (inferred from the jOOQ catalog, no directive needed): the matched key’s columns become the WHERE clause and the remaining input fields populate the SET clause. UPSERT adds ON CONFLICT keyed on the matched key’s columns.

Most input fields fall wholly on one side of that boundary, but a @nodeId reference to another table need not: it expands into the foreign key’s child columns on the write target, and those can include columns the matched key also covers. Such a field partitions per column rather than being rejected. Its out-of-key columns become SET writes; its in-key columns are the row’s identity, so they contribute to the WHERE clause where no other field supplies them, and are otherwise only checked. Where both the reference and another field decode a value for the same column, the two are compared before any DML runs and a mismatch fails the call with an error naming both input fields, writing nothing. The foreign key forces them equal for well-formed input, so this only catches a caller who sent a reference belonging to a different row.

A reference in that shape must be non-null (ID!), and the build rejects the nullable spelling. Its in-key half is row identity and is therefore never written, so an explicit null would clear only the rest of the foreign key and leave a half-populated tuple that PostgreSQL’s default MATCH SIMPLE accepts. Such a reference can be re-pointed within the same key value; it cannot be cleared. The requirement applies only where the reference actually straddles: the same field is fine as ID wherever the matched key does not intersect the foreign key.

A field carrying the row’s own columns is still rejected when it straddles, because writing only some of a row’s key columns would move the row rather than update it.

DELETE identifies rows the same way (primary-key-or-unique-key coverage), but has no SET clause: every admitted input column is a WHERE filter, and the deleted row’s primary key is returned through RETURNING. Extra input columns beyond the matched key add further AND`ed predicates (they narrow the match, they don’t move to a `SET side). When the input covers no primary key or unique key, the DELETE is rejected at build time so a non-unique filter cannot silently broadcast; set multiRow: true on the @mutation directive to opt into a deliberate broadcast (non-key) delete that may remove more than one row.

Naming the INSERT and UPDATE write target

An INSERT or UPDATE usually names its write target on the return side: the written row’s type is the natural return. Graphitron derives the write target by this precedence:

  1. The return’s own @table (preferred). A direct @table return (createFilm(…​): Film, Film carrying @table), or a carrier payload whose single data field is a @table-element (createFilms(…​): FilmsPayload, FilmsPayload { films: [Film!] }). The input’s fields resolve against the return’s table.

  2. @mutation(table:) on the field. For the encoded-ID / scalar-return field whose return names no table (createFilm(in: FilmInput!): ID @mutation(typeName: INSERT, table: "film")).

Where the return names the table, a @mutation(table:) also present must name the same table (the RETURNING projection reads from the write target, so a disagreement cannot emit a coherent statement); a mismatch is rejected at build time. A field that names its table by neither rung is rejected, with the message leading with the preferred return-derived fix.

input FilmInput {              # no directive: the write target is derived from the return
    title: String! @field(name: "title")
}
type FilmsPayload { films: [Film!] }   # Film is @table(name: "film")

type Mutation {
    createFilms(in: [FilmInput!]!): FilmsPayload @mutation(typeName: INSERT)
}

Naming the DELETE write target

DELETE cannot use the return-derived rung: it commonly returns a bare ID (the encoded primary key of the deleted row), a Boolean, or a delete count, and it can never return the deleted row’s @table type, because the row is gone after the statement runs. So a DELETE names its write target on the consuming field with @mutation(table:):

input FilmDeleteInput {
    filmId: Int! @field(name: "film_id")
}

type Mutation {
    deleteFilm(in: FilmDeleteInput!): ID @mutation(typeName: DELETE, table: "film")
}

The input type carries no directive; its fields resolve against the table named on the field. A DELETE that does not name its table with @mutation(table:) is rejected at build time, with the message steering you to the argument. (An input type still carrying a legacy @table builds with a warning; the directive is ignored, so it does not name the table either. See the deprecated location.)

Payload-returning DELETE

@mutation(typeName: DELETE) supports two payload-carrier shapes. Both echo information about the rows the DML actually removed; neither projects non-primary-key columns from the deleted rows (the row is gone before the response can read it).

The simplest payload returns the encoded NodeId of each deleted row:

type SlettRegelverksamlingPayload {
    deletedIds: [ID!]   # implicit @nodeId; encoder resolves against the write-target table's @node
}

extend type Mutation {
    slettRegelverksamling(input: [RegelverksamlingDeleteInput!]!): SlettRegelverksamlingPayload
      @mutation(typeName: DELETE)
}

The carrier field’s element type must be ID (single DELETE) or [ID!] (bulk DELETE). The list wrapper must be list-of-non-null; [ID] (list-of-nullable) is rejected, because every element of a successful DELETE response is the encoded PK of an actually-deleted row, the slot cannot be null. The encoder is recognised implicitly when the mutation’s write-target table backs a @node type. If the write-target table is not @node-backed, the carrier is rejected with the same diagnostic as the bare-ID DELETE path; register the table’s SDL type as @node first. To pin the encoder explicitly (recommended when grep-ability matters), attach @nodeId(typeName: "…​") to the carrier field; the directive’s encoder must resolve to the same table as the mutation’s write target. An @nodeId whose encoder resolves to a different table is rejected: you would be returning IDs of a different entity than the one the DML acted on.

The response contains exactly the IDs of rows the DML actually removed.

Projecting the deleted row’s primary key onto an SDL type

The carrier may also return the @table-backed SDL type, but only when every non-nullable field on the type resolves to a primary-key column:

type Regelverksamling @table(name: "regelverksamling") @node {
    id: ID!                              # PK; admits
    navn: String                         # non-PK, nullable; admits, runtime returns null
    beskrivelse: String!                 # non-PK, non-nullable; REJECTS the carrier
}

The classifier inspects every field on the element type and rejects the carrier if any non-nullable field maps to a non-PK column. Nullable non-PK fields admit and always resolve to null at runtime; this is by design — after a DELETE there is no row left to read those columns from. If your SDL type carries non-nullable non-PK fields, prefer the [ID!] shape above, or define a dedicated DeletedRegelverksamling SDL type whose non-nullable fields are PK-only.

@service-resolved fields are not admitted on the element type, nullable or not. The service would receive a PK-only row at runtime and any non-PK source parameter would silently produce null. Use the [ID!] shape and resolve service-backed data on the deleted entity through a sibling lookup if needed. FK-traversing reference fields (@reference paths to a joined target table) are also rejected on DELETE carriers, since the join cannot run after the row is gone.

Single vs bulk

Both shapes work with single and bulk DELETE; cardinality follows the carrier field’s wrapper (ID/Foo for single, [ID!]/[Foo!] for bulk).

What you can’t return

  • Arbitrary non-PK columns of the deleted row. The row is gone; RETURNING is narrowed to primary-key columns. If you need the full pre-delete state, snapshot it in your application code before issuing the mutation.

  • A bare class-backed payload type. Use a NodeId echo or a @table projection.

Constraints

  • INSERT and UPDATE derive their write target from the return (a @table return, or a payload’s @table-element data field), falling back to @mutation(table:) for an encoded-ID / scalar return (see Naming the INSERT and UPDATE write target). DELETE names its write target with @mutation(table: "…") on the field (it cannot derive the table from its return type, since the row is gone after the statement). A field with no write-target source is rejected at build time, with the message naming the applicable fix. @table on the input type is a deprecated location, ignored rather than consulted, so it is never a write-target source.

  • UPDATE and DELETE require the input columns to cover a primary key or unique key of the write-target table (inferred from the catalog, no directive needed); otherwise the build fails so a non-unique filter cannot silently broadcast. DELETE can opt out with multiRow: true for a deliberate broadcast; UPDATE has no broadcast shape (cover a key). @lookupKey on a mutation input field is no longer supported (it identified rows in the pre-catalog model); remove it.

  • UPDATE requires at least one input field outside the matched key (otherwise the SET clause is empty). DELETE has no such requirement — a key-only DELETE is the canonical single-row delete. UPSERT is exempt on the SET side: an upsert with no SET-clause fields is INSERT … ON CONFLICT DO NOTHING, which is a legitimate shape.

  • @mutation and @service are mutually exclusive on the same field. Pick one: @mutation for fully-generated DB operations, @service for handing the operation to custom Java.

  • Optional input fields participate in the operation only when the client provides them; absent optional fields are omitted from the column list (INSERT) or the SET clause (UPDATE/UPSERT).

See also

  • Tutorial page 5: A first mutation walks the INSERT/UPDATE flow against a running database.

  • @lookupKey is a Query-side lookup-key marker on ARGUMENT_DEFINITION; it no longer participates in mutation row identification (that is catalog-derived).

  • @table binds the object types whose returns carry a write target; on input types it is a deprecated location and is ignored.

  • @service for custom-Java mutations.