Implement Data Connect mutations

Firebase Data Connect lets you create connectors for your PostgreSQL instances managed with Google Cloud SQL. These connectors are combinations of a queries and mutations for using your data from your schema.

The Get started guide introduced a movie review app schema for PostgreSQL.

That guide also introduced both deployable and ad hoc administrative operations, including mutations.

  • Deployable mutations are those you implement to call from client apps in a connector, with API endpoints you define. Data Connect integrates authentication and authorization into these mutations, and generates client SDKs based on your API.
  • Ad hoc administrative mutations are run from privileged environments to populate and manage tables. You can create and execute them in the Firebase console, from privileged environments using the Firebase Admin SDK, and in local development environments using our Data Connect VS Code extension.

This guide takes a deeper look at deployable mutations.

Features of Data Connect mutations

Data Connect lets you to perform basic mutations in the all the ways you'd expect given a PostgreSQL database:

  • Perform CRUD operations
  • Manage multi-step operations with transactions

But with Data Connect's extensions to GraphQL, you can implement advanced mutations for faster, more efficient apps:

  • Use key scalars returned by many operations to simplify repeated operations on records
  • Use server values to populate data with operations provided by the server
  • Perform queries in the course of a multi-step mutation operations to look up data, saving lines of code and round trips to the server.

Use generated fields to implement mutations

Your Data Connect operations will extend a set of fields automatically generated Data Connect based on the types and type relationships in your schema. These fields are generated by local tooling whenever you edit your schema.

You can use generated fields to implement mutations, from creating, updating, and deleting individual records in single tables, to more complex multi-table updates.

Assume your schema contains a Movie type and an associated Actor type. Data Connect generates movie_insert, movie_update, movie_delete fields, and more.

Mutation with the
movie_insert field

The movie_insert field represents a mutation to create a single record in the Movie table.

Use this field to create a single movie.

mutation CreateMovie($data: Movie_Data!) {
  movie_insert(data: $data) { key }
}

Mutation with the
movie_update field

The movie_update field represents a mutation to update a single record in the Movie table.

Use this field to update a single movie by its key.

mutation UpdateMovie($myKey: Movie_Key!, $data: Movie_Data!) {
  movie_update(key: $myKey, data: $data) { key }
}

Mutation with the
movie_delete field

The movie_delete field represents a mutation to delete a single record in the Movie table.

Use this field to delete a single movie by its key.

  mutation DeleteMovie($myKey: Movie_Key!) {
    movie_delete(key: $myKey) { key }
  }

Essential elements of a mutation

Data Connect mutations are GraphQL mutations with Data Connect extensions. Just as with a regular GraphQL mutation, you can define an operation name and a list of GraphQL variables.

Data Connect extends GraphQL queries with customized directives like @auth and @transaction.

So the following mutation has:

  • A mutation type definition
  • A SignUp operation (mutation) name
  • A single variable $username operation argument
  • A single directive, @auth
  • A single field user_insert.
mutation SignUp($username: String!) @auth(level: USER) {
  user_insert(data: {
    id_expr: "auth.uid"
    username: $username
  })
}

Every mutation argument requires a type declaration, a built-in like String, or a custom, schema-defined type like Movie.

Write basic mutations

You can start writing mutations to create, update and delete individual records from your database.

Create

Let's do basic creates.

# Create a movie based on user input
mutation CreateMovie($title: String!, $releaseYear: Int!, $genre: String!, $rating: Int!) {
  movie_insert(data: {
    title: $title
    releaseYear: $releaseYear
    genre: $genre
    rating: $rating
  })
}

# Create a movie with default values
mutation CreateMovie2 {
  movie_insert(data: {
    title: "Sherlock Holmes"
    releaseYear: 2009
    genre: "Mystery"
    rating: 5
  })
}

Or an upsert.

# Movie upsert using combination of variables and literals
mutation UpsertMovie($title: String!) {
  movie_upsert(data: {
    title: $title
    releaseYear: 2009
    genre: "Mystery"
    rating: 5
    genre: "Mystery/Thriller"
  })
}

Perform updates

Here are updates. Producers and directors certainly hope that those average ratings are on trend.

The movie_update field contains an expected id argument to identify a record and a data field you can use to set values in this update.

mutation UpdateMovie(
  $id: UUID!,
  $genre: String!,
  $rating: Int!,
  $description: String!
) {
  movie_update(id: $id,
    data: {
      genre: $genre
      rating: $rating
      description: $description
    })
}

To perform multiple updates, use the movie_updateMany field.

# Multiple updates (increase all ratings of a genre)
mutation IncreaseRatingForGenre($genre: String!, $rating: Int!) {
  movie_updateMany(
    where: { genre: { eq: $genre } },
    data:
      {
        rating: $rating
      })
}

Use increment, decrement, append, and prepend operations with _update

While in _update and _updateMany mutations you can explicitly set values in data:, it often makes more sense to apply an operator like increment to update values.

To modify the earlier update example, assume you want to increment the rating of a particular movie. You can use rating_update syntax with the inc operator.

mutation UpdateMovie(
  $id: UUID!,
  $ratingIncrement: Int!
) {
  movie_update(id: $id, data: {
    rating_update: {
      inc: $ratingIncrement
    }
  })
}

Data Connect supports the following operators for field updates:

  • inc to increment Int, Int64, Float, Date and Timestamp data types
  • dec to decrement Int, Int64, Float, Date and Timestamp data types

For lists, you can also update with individual values or lists of values using:

  • add to append item(s) if they are not already present to list types, except Vector lists
  • remove to remove all items, if present, from list types, except Vector lists
  • append to append item(s) to list types, except Vector lists
  • prepend to prepend item(s) to list types, except Vector lists

Perform deletes

You can of course delete movie data. Film preservationists will certainly want the physical films to be maintained for as long as possible.

# Delete by key
mutation DeleteMovie($id: UUID!) {
  movie_delete(id: $id)
}

Here you can use _deleteMany.

# Multiple deletes
mutation DeleteUnpopularMovies($minRating: Int!) {
  movie_deleteMany(where: { rating: { le: $minRating } })
}

Write mutations on relations

Observe how to use the implicit _upsert mutation on a relation.

# Create or update a one to one relation
mutation MovieMetadataUpsert($movieId: UUID!, $director: String!) {
  movieMetadata_upsert(
    data: { movie: { id: $movieId }, director: $director }
  )
}

Design schemas for efficient mutations

Data Connect provides two important features that allow you to write more efficient mutations and save round-trip operations.

Key scalars are concise object identifiers that Data Connect automatically assembles from key fields in your schemas. Key scalars are about efficiency, allowing you to find in a single call information about the identity and structure of your data. They are especially useful when you want to perform sequential actions on new records and need a unique identifier to pass to upcoming operations, and also when you want to access relational keys to perform additional more complex operations.

Using server values, you can effectively let the server dynamically populate fields in your tables using stored or readily-computable values according to particular server-side CEL expressions in the expr argument. For example, you can define a field with a timestamp applied when the field is accessed using the time stored in an operation request, updatedAt: Timestamp! @default(expr: "request.time").

Write advanced mutations: let Data Connect supply values using field_expr syntax

As discussed in key scalars and server values, you can design your schema so that the server populates values for common fields like ids and dates in response to client requests.

In addition, you can make use of data, such as user IDs, sent in Data Connect request objects from client apps.

When you implement mutations, use field_expr syntax to trigger server-generated updates or access data from requests. For example, to pass the authorization uid stored in a request to an _upsert operation, pass "auth.uid" in the userId_expr field.

# Add a movie to the user's favorites list
mutation AddFavoritedMovie($movieId: UUID!) @auth(level: USER) {
  favorite_movie_upsert(data: { userId_expr: "auth.uid", movieId: $movieId })
}

# Remove a movie from the user's favorites list
mutation DeleteFavoritedMovie($movieId: UUID!) @auth(level: USER) {
  favorite_movie_delete(key: { userId_expr: "auth.uid", movieId: $movieId })
}

Or, in a familiar to-do list app, when creating a new to-do list, you could pass id_expr to instruct the server to auto-generate a UUID for the list.

mutation CreateTodoListWithFirstItem(
  $listName: String!
) @transaction {
  # Step 1
  todoList_insert(data: {
    id_expr: "uuidV4()", # <-- auto-generated. Or a column-level @default on `type TodoList` will also work
    name: $listName,
  })
}

For more information, see the _Expr scalars in the scalars reference.

Write advanced mutations: multi-step operations

There are many situations in which you might want to include multiple write fields (like inserts) in one mutation. You might also want to read your database during execution of a mutation to lookup and verify existing data before performing, for example, inserts or updates. These options save round trip operations and hence costs.

Data Connect lets you perform multi-step logic in your mutations by supporting:

  • Multiple write fields

  • Multiple read fields in your mutations (using the query field keyword).

  • The @transaction directive, which provides transaction support familiar from relational databases.

  • The @check directive, which lets you evaluate the contents of reads using CEL expressions, and based on the results of such evaluation:

    • Proceed with creates, updates and deletes defined by a mutation
    • Proceed to return the results of a query field
    • Use returned messages to perform appropriate logic in your client code
  • The @redact directive, which lets you omit query field results from wire protocol results.

  • The CEL response binding, which stores the accumulated results of all mutations and queries performed in a complex, multi-step operation. You can access the response binding:

    • In @check directives, through the expr: argument
    • With server values, using field_expr syntax

The @transaction directive

Support for multi-step mutations includes error handling using transactions.

The @transaction directive enforces that a mutation - with either a single write field (for example, _insert or _update) or with multiple write fields - always run in a database transaction.

  • Mutations without @transaction execute each root field one after another in sequence. The operation surfaces any errors as partial field errors, but not the impacts of the subsequent executions.

  • Mutations with @transaction are guaranteed to either fully succeed or fully fail. If any of the fields within the transaction fails, the entire transaction is rolled back.

The @check and @redact directives

The @check directive verifies that specified fields are present in query results. A Common Expression Language (CEL) expression is used to test field values. The default behavior of the directive is to check for and reject nodes whose value is null or [] (empty lists).

The @redact directive redacts a part of the response from the client. Redacted fields are still evaluated for side effects (including data changes and @check) and the results are still available to later steps in CEL expressions.

Use @check, @check(message:) and @redact

A major use for @check and @redact is looking up related data to decide whether certain operations should be authorized, using the lookup in logic but hiding it from clients. Your query can return useful messages for correct handling in client code.

For illustration, the following query field checks whether a requestor has an appropriate "admin" role to view users who can edit a movie.

query GetMovieEditors($movieId: UUID!) @auth(level: USER) {
  moviePermission(key: { movieId: $movieId, userId_expr: "auth.uid" }) @redact {
    role @check(expr: "this == 'admin'", message: "You must be an admin to view all editors of a movie.")
  }
  moviePermissions(where: { movieId: { eq: $movieId }, role: { eq: "editor" } }) {
    user {
      id
      username
    }
  }
}

To learn more about @check and @redact directives in authorization checks, refer to the discussion of authorization data lookup.

Use @check to validate keys

Some mutation fields, such as _update, may no-op if a record with a specified key does not exist. Similarly, lookups may return null or an empty list. These are not considered errors and therefore won't trigger rollbacks.

To guard against this result, test whether keys can be found using the @check directive.

# Delete by key, error if not found
mutation MustDeleteMovie($id: UUID!) @transaction {
  movie_delete(id: $id) @check(expr: "this != null", message: "Movie not found, therefore nothing is deleted")
}

Use the response binding to chain multi-step mutations

The basic approach to creating related records, for example a new Movie and an associated MovieMetadata entry, is to:

  1. Call an _insert mutation for Movie
  2. Store the returned key of the created movie
  3. Then, call a second _insert mutation to create the MovieMetadata record.

But with Data Connect, you can handle this common case in a single multi-step operation by accessing the results of the first _insert in the second _insert.

Making a successful movie review app is a lot of work. Let's track our to-do list with a new example.

Use response to set fields with server values

In the following to-do list mutation:

  • The response binding represents the partial response object so far, which includes all top-level mutation fields before the current one.
  • The results of the initial todoList_insert operation, which returns the id (key) field, are accessed later in response.todoList_insert.id so we can immediately insert a new to-do item.
mutation CreateTodoListWithFirstItem(
  $listName: String!,
  $itemContent: String!
) @transaction {
  # Sub-step 1:
  todoList_insert(data: {
    id_expr: "uuidV4()", # <-- auto-generated. Or a column-level @default on `type TodoList` will also work
    name: $listName,
  })
  # Sub-step 2:
  todo_insert(data: {
    listId_expr: "response.todoList_insert.id" # <-- Grab the newly generated ID from the partial response so far.
    content: $itemContent,
  })
}

Use response to validate fields using @check

response is available in @check(expr: "...") as well, so you can use it to build even more complicated server-side logic. Combined with query { … } steps in mutations, you can achieve a lot more without any additional client-server roundtrips.

In the following example, note: that @check already has access to response.query because a @check always runs after the step it is attached to.

mutation CreateTodoInNamedList(
  $listName: String!,
  $itemContent: String!
) @transaction {
  # Sub-step 1: Look up List.id by its name
  query
  @check(expr: "response.query.todoLists.size() > 0", message: "No such TodoList with the name!")
  @check(expr: "response.query.todoLists.size() < 2", message: "Ambiguous listName!") {
    todoLists(where: { name: $listName }) {
      id
    }
  }
  # Sub-step 2:
  todo_insert(data: {
    listId_expr: "response.todoLists[0].id" # <-- Now we have the parent list ID to insert to
    content: $itemContent,
  })
}

For more information about the response binding, see the CEL reference.

Understand interrupted operations with @transaction and query @check

Multi-step mutations can encounter errors:

  • Database operations may fail.
  • query @check logic may terminate operations.

Data Connect recommends that you use the @transaction directive with your multi-step mutations. This results in a more consistent database and mutation results that are easier to handle in client code:

  • At the first error or failed @check, the operation will terminate, so there is no need to manage execution of any subsequent fields or evaluation of CEL.
  • Rollbacks are performed in response to database errors or @check logic, yielding a consistent database state.
  • A rollback error is always returned to client code.

There may be some use cases where you choose not to use @transaction: you might opt for eventual consistency if, for example, you need higher throughput, scalability or availability. However, you need to manage your database and your client code to allow for the results:

  • If one field fails due to database operations, subsequent fields will continue to execute. However, failed @checks still terminate the entire operation.
  • Rollbacks are not performed, meaning a mixed database state with some successful updates and some failed updates.
  • Your operations with @check may give more inconsistent results if your @check logic uses the results of reads and/or writes in a previous step.
  • The result returned to client code will contain a more complex mix of success and failure responses to be handled.

Directives for Data Connect mutations

In addition to the directives you use in defining types and tables, Data Connect provides the @auth, @check, @redact and @transaction directives for augmenting the behavior of operations.

Directive Applicable to Description
@auth Queries and mutations Defines the authorization policy for a query or mutation. See the authorization and attestation guide.
@check query fields in multi-step operations Verifies that specified fields are present in query results. A Common Expression Language (CEL) expression is used to test field values. See Multi-step operations.
@redact Queries Redacts a part of the response from the client. See Multi-step operations.
@transaction Mutations Enforces that a mutation always run in a database transaction. See Multi-step operations.

Next steps

You may be interested in: