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 @table(name: "film") {
    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:

  • @table(name: "film") on the input type. This binds FilmCreateInput’s fields to columns on the `film table; without it, the generator would not know which table to insert into.

  • @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 use RETURNING to 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:

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

The RETURNING clause is what makes mutations cheap in Graphitron: PostgreSQL hands the inserted row back in the same statement, with the columns the GraphQL selection set asked for. There is no follow-up SELECT to fetch what was just written.

If your mutation selects more fields, the RETURNING projection grows. If your mutation also selects through a @reference, the resolver wraps the INSERT in a WITH clause and joins from the inserted row outwards, keeping the round trip count to one.

Other mutation shapes

The example schema also has updateFilm and upsertFilm:

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

UPDATE requires an input field marked with @lookupKey to identify the row to update; UPSERT adds an ON CONFLICT clause. Both follow the same pattern as INSERT: the directive picks the SQL shape, the input’s @table binds the target, and the RETURNING projection narrows to the response selection set. Sample FilmUpdateInput:

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

The @lookupKey on filmId tells the generator "this field identifies the row, not a value to set". 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 with no @table directive: a plain grouping type whose own fields map onto columns of the same table as the surrounding @table input. This tidies a large input into named clusters on the client side without introducing a second table. Suppose a schema like:

input EndreOrganisasjonInput @table(name: "ORGANISASJON") {
  id: ID! @nodeId(typeName: "URegOrganisasjon")
  originalnavn: String! @field(name: "NAVN_ORIGINAL")
  lokalisering: LokaliseringInput!
}

input LokaliseringInput {
  landkode: String!
  bynavn: String
  regionnavn: String
}

type Mutation {
  endreOrganisasjon(input: [EndreOrganisasjonInput!]!): EndreOrganisasjonPayload
    @mutation(typeName: UPDATE)
}

LokaliseringInput has no @table, so its fields are 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 UPDATE writes flat columns on ORGANISASJON exactly as if the nested leaves had been declared at the top level of EndreOrganisasjonInput.

  • @field(name:) on a nested leaf targets the outer table. The resolution context is the surrounding @table, so a nested leaf names a column of that table (here, of ORGANISASJON); 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 (lokalisering not 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 null

    the column is written NULL

    leaf 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 (a nested input that is itself @table-backed) is a different, compound-mutation shape and is not covered here.

You have just learned

How @mutation(typeName: …​) flips a resolver into an INSERT, UPDATE, or UPSERT; how @table and @field on an input type wire the operation to a target table and 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.