The example schema declares one mutation field for inserting a new film. This page sends that mutation and walks through the directives that turn a GraphQL operation into a SQL INSERT.
The mutation in the schema
From schema.graphqls:
input FilmCreateInput {
title: String! @field(name: "title")
languageId: Int! @field(name: "language_id")
}
type Mutation {
createFilm(in: FilmCreateInput!): Film @mutation(typeName: INSERT)
}
Three pieces are doing the work:
-
The
Filmreturn type.Filmcarries@table(name: "film")(page 2), and the mutation’s write target is derived from its return type, soFilmCreateInput’s fields bind to columns on the `filmtable. The input type itself needs no directive. (A mutation whose return names no table, aDELETEreturning anID, say, names the table on the field instead with@mutation(table: "film").) -
@field(name: "…")on each input field. Same role as on a query type: map the GraphQL field name to a database column. -
@mutation(typeName: INSERT)on the mutation field. This is the directive that flips the resolver from "build a SELECT" to "build an INSERT", and tells it to useRETURNINGto populate the response.
Send the mutation
curl -s -X POST http://localhost:8080/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"mutation { createFilm(in: { title: \"MY FIRST FILM\", languageId: 1 }) { filmId title } }"}'
{
"data": {
"createFilm": {
"filmId": 6,
"title": "MY FIRST FILM"
}
}
}
Run it again and you get filmId: 7, then 8, and so on; the film_id column is a serial, so PostgreSQL hands out the next id on each insert.
What just happened
The generated resolver for Mutation.createFilm produced this shape of SQL, in two steps. First the write, committed inside a transaction, with a RETURNING clause narrowed to the primary key:
INSERT INTO film (title, language_id)
VALUES (?, ?)
RETURNING film.film_id
Then a follow-up SELECT keyed on that primary key reads back the columns the GraphQL selection set asked for:
SELECT film.film_id AS "filmId", film.title AS "title"
FROM film
WHERE film.film_id = ?
Splitting the write from the read-back keeps the write statement independent of the response shape: selecting more fields grows only the follow-up SELECT, and a read error during the response cannot undo the committed insert. For a list-shaped mutation (bulk input, [Film!]! return) the follow-up SELECT joins the returned keys as a VALUES table and orders by their input position, so the payload has one entry per written row, in the order the rows were written.
Other mutation shapes
The example schema also has updateFilm:
type Mutation {
createFilm(in: FilmCreateInput!): Film @mutation(typeName: INSERT)
updateFilm(in: FilmUpdateInput!): Film @mutation(typeName: UPDATE)
}
UPDATE follows the same pattern as INSERT: the directive picks the SQL shape, the Film return derives the write target, and the same two-step write-then-read-back serves the response selection set. Sample FilmUpdateInput:
input FilmUpdateInput {
filmId: Int! @field(name: "film_id")
title: String! @field(name: "title")
}
UPDATE identifies the row to update by key coverage, read from the database catalog with no directive needed: filmId covers the film table’s primary key, so it becomes the WHERE clause, and the remaining fields (title) populate the SET clause. An input that covers no primary or unique key fails the build rather than updating more rows than intended. Try the update against the film you just inserted:
curl -s -X POST http://localhost:8080/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"mutation { updateFilm(in: { filmId: 6, title: \"RENAMED FILM\" }) { filmId title } }"}'
Grouping fields with nested input types
A @mutation input field can itself be an input object: a plain grouping type whose own fields map onto columns of the same write target as the surrounding input. This tidies a large input into named clusters on the client side without introducing a second table. Suppose a schema like:
input EndreOrganisasjonInput {
id: ID! @nodeId(typeName: "URegOrganisasjon")
originalnavn: String! @field(name: "NAVN_ORIGINAL")
lokalisering: LokaliseringInput!
}
input LokaliseringInput {
landkode: String!
bynavn: String
regionnavn: String
}
# URegOrganisasjon is @table(name: "ORGANISASJON"); its presence as the payload's
# data element derives the write target.
type EndreOrganisasjonPayload {
organisasjoner: [URegOrganisasjon!]
}
type Mutation {
endreOrganisasjon(input: [EndreOrganisasjonInput!]!): EndreOrganisasjonPayload
@mutation(typeName: UPDATE)
}
LokaliseringInput is a grouping, not a separate row: landkode, bynavn, and regionnavn are columns of ORGANISASJON, reached on the wire as input.lokalisering.landkode. The mutation call nests the same way:
mutation {
endreOrganisasjon(input: [{ id: "...", originalnavn: "Sikt", lokalisering: { landkode: "NO", bynavn: "Oslo" } }]) {
# ...
}
}
Three things to keep in mind:
-
The grouping has no DML semantics. It is purely a wire-format ergonomics shape. The generated
UPDATEwrites flat columns onORGANISASJONexactly as if the nested leaves had been declared at the top level ofEndreOrganisasjonInput. -
@field(name:)on a nested leaf targets the outer table. The resolution context is the mutation’s write target, so a nested leaf names a column of that table (here, ofORGANISASJON); the SDL-name-defaults-to-column-name fallback resolves against the outer table too. -
Absent-vs-null is honored at every nesting layer, matching the way top-level mutation inputs already behave:
Wire shape Effect outer key absent (
lokaliseringnot sent)no claim about the group; every leaf under it is skipped
outer key present with
null(lokalisering: null)no claim about the group; every leaf under it is skipped
outer key present with a Map
descend; each leaf is decided independently by the rows below
leaf key absent
the column is left untouched (PATCH semantics)
leaf key present with
nullthe column is written
NULLleaf key present with a value
the column is written to that value
So sending lokalisering: { landkode: "NO", bynavn: null } writes landkode = 'NO', bynavn = NULL, and leaves regionnavn untouched.
This works for every DML verb (INSERT, UPDATE, DELETE). A nested grouping that introduces a second table is a different, compound-mutation shape and is not covered here.
You have just learned
How @mutation(typeName: …) flips a resolver into an INSERT or UPDATE; how the field’s return type (or @mutation(table:), for returns that name no table) carries the write target while @field maps input fields to its columns; and how RETURNING keeps the round-trip count to one, even when the response asks for more than the inserted row provides.
Next
Page 6: Going further points at the four how-to recipes most worth reading after this tutorial.