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 |
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 |
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 |
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 incrementInt
,Int64
,Float
,Date
andTimestamp
data typesdec
to decrementInt
,Int64
,Float
,Date
andTimestamp
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 listsremove
to remove all items, if present, from list types, except Vector listsappend
to append item(s) to list types, except Vector listsprepend
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 id
s 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 theresponse
binding:- In
@check
directives, through theexpr:
argument - With server values, using
field_expr
syntax
- In
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:
- Call an
_insert
mutation forMovie
- Store the returned key of the created movie
- Then, call a second
_insert
mutation to create theMovieMetadata
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 theid
(key) field, are accessed later inresponse.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
@check
s 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:
- Generating mutations for your apps using AI assistance tools
- Authorizing your mutations per the authorization guide
- Calling mutations from your client code for web, iOS, Android and Flutter.
- Performing bulk data operations with mutations