Since this seems be a widely requested topic, I decided to share our approach to deal with deep mutations.
DISCLAIMER: This is not a perfect solution and certainly comes with some side-effects. However, for us, it covers a huge amount of uses cases.
Prerequisites
We use Dgraph as our backend Graph server in combination with React, Relay and GraphQL as the query language for the frontend. Therefore, this post deals with this specific setup.
Packages:
-
react
β^18.2.0
-
react-relay
β^15.0.0
-
react-relay-network-modern
β^6.2.1
- Dgraph Cloud β
v21.03.0-92-g0c9f60156
Create the middleware
In order to make this work, we first need to create a middleware which will then be part of the Relay Network Layer. We first need to define what the requirements for a deep mutation are:
- A deep mutation is basically a nested mutation. Dgraph would handle this by default by adding new nodes instead of updating them.
- Since we still want to allow for new nodes to be created, we define that a deep mutation only occurs for nested nodes where a UID has been provided. Otherwise we create the node.
- We need to define some rules of which and how GraphQL variables are allowed when defining the mutation in the application.
Moreover, we need to make sure that the Relay requirements (same typename, same return object shape, β¦) are fulfilled when we run a query which is different from the original query. We will cover this a bit later.
Letβs first start with the implementation of the deep mutation middleware within the react-relay-network-modern
scope
import { patchDeepMutation } from "./helpers";
import type { DeepMutationResult } from "schema";
import type { Middleware, MiddlewareNextFn, RelayRequestAny } from "react-relay-network-modern";
// Parameters when using ACL, @auth rules and namespaces
// - `userJWT` --> token which contains the user claim (for @auth rules)
// - `accessJWT` --> token which determines the namespace we want to query against
type DeepMutationMiddlewareParams = {
accessJWT: string;
userJWT: string;
};
/**
* -----------------------------------------------------------
* Deep Mutation Middleware
* -----------------------------------------------------------
* Custom middleware for `react-relay-network-modern`
* See [here](https://github.com/relay-tools/react-relay-network-modern) for further details.
* -----------------------------------------------------------
*/
export const deepMutationMiddleware = (params: DeepMutationMiddlewareParams): Middleware => {
const { accessJWT, userJWT } = params;
return (next: MiddlewareNextFn) => async (req: RelayRequestAny) => {
// Extract the query body and variables which have been sent in the request
const queryBody = {
query: req.getQueryString(),
variables: req.getVariables(),
};
// Check if the requirements for a deep mutation are fulfilled
const deepMutation = patchDeepMutation(queryBody.query, queryBody.variables);
// --> if yes run custom lambda resolver
if (deepMutation) {
const request = await fetch(`/graphql`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Dgraph-AccessToken": accessJWT,
"Codeversity-Web-Auth": userJWT,
},
body: JSON.stringify({
query: `
mutation deepMutationMiddlewareMutation($input: DeepMutationInput!) {
deepMutation(input: $input) {
success
error
}
}
`,
variables: { input: deepMutation },
}),
});
const result = (await request.json()) as { data: { deepMutation: DeepMutationResult } };
if (result.data.deepMutation.error) {
throw new Error(result.data.deepMutation.error);
}
}
const res = await next(req);
return res;
};
};
Deep mutation trigger
To trigger the deep mutation we need to define the patchDeepMutation
function. We check for 3 things:
- Are we running a mutation (in our case
updatePredicate
)? Thus the query string must containupdate
. - Are we having UIDs for the nested nodes we wish to update? If not, we can continue with the default mutation.
- Are we conforming to the query definition?
An implementation could look like this
/**
* -----------------------------------------------------------
* Patch Deep Mutation
* -----------------------------------------------------------
* ...
* -----------------------------------------------------------
*/
export const patchDeepMutation = (query: string, variables: Record<string, any>) => {
// If we are not running a mutation, return
if (!query.startsWith("mutation")) {
return;
}
// If the mutation does not contain the keyword `update`, return
const updateMutation = parseQueryString(query);
if (!updateMutation) {
return;
}
let rootUid: string | undefined = undefined;
let vars: Record<string, any> = {};
// For nested mutations we require that the variables `$id: ID! or [ID!]`
// and `$set: <Type>Patch` are part of the request body (variables).
if ("input" in variables && "set" in variables.input) {
vars = flattenSetObject(variables.input.set);
rootUid = variables.input.filter?.id;
} else if ("set" in variables) {
vars = flattenSetObject(variables.set);
if ("filter" in variables) {
rootUid = variables.filter.id;
} else if ("id" in variables) {
rootUid = variables.id;
} else {
throw new Error(
`Nested mutation '${updateMutation.queryName}' must contain a variable named '$id' for the root mutation. Custom names not allowed when performing nested mutations.`
);
}
} else {
throw new Error(
`Nested mutations must submit a variable named '$id: ID!' and a variable named '$set: <Type>Patch'. $set is missing.`
);
}
// Check if deep mutation substitution is necessary
if (Object.keys(vars).length === 1) {
return;
}
// Semantics check: a field `id` must be provided in variables
if (rootUid === undefined) {
throw new Error(
`Deep mutation '${updateMutation.queryName}' must contain a variable named '$id' for the root mutation. Custom names not allowed when performing deep mutations.`
);
}
return {
set: JSON.stringify({ ...vars, root: { id: rootUid, ...vars.root } }),
rootMutation: updateMutation.rootMutation,
};
};
/**
* -----------------------------------------------------------
* Flatten Set Object
* -----------------------------------------------------------
* Helper function to flatten the variable object.
* -----------------------------------------------------------
*/
const flattenSetObject = (obj: object, flatObject?: Record<string, any>, level?: number, subLevel?: number) => {
let cLevel = level || 0;
let cFlatObject = flatObject || {};
let cObject: Record<string, any> = {};
let cSubLevel = subLevel || 0;
Object.entries(obj).forEach(([key, value]) => {
if (typeof value === "object") {
const keys = Object.keys(value);
if (keys.length === 1 && "id" in value) {
cObject[key] = { id: value.id };
} else if (keys.length === 1) {
flattenSetObject(value[keys[0]], cFlatObject, cLevel + 1);
} else if (keys.length > 1 && "id" in value) {
flattenSetObject(value, cFlatObject, cLevel + 1, cSubLevel);
cSubLevel++;
}
} else {
cObject[key] = value;
}
});
cFlatObject[`${cLevel === 0 ? "root" : `level${cLevel}${cSubLevel > 0 ? `-${cSubLevel}` : ""}`}`] = cObject;
return cFlatObject;
};
/**
* -----------------------------------------------------------
* Parse Query String
* -----------------------------------------------------------
* Parse query string to check if deep mutation is necessary.
* -----------------------------------------------------------
*/
const parseQueryString = (query: string) => {
const cleanQuery = query.replace(/[\n\r\t\s]+/g, " ");
const updateMutation = cleanQuery.match(/update(\w+| [^ ]+|$)/g);
const queryName = cleanQuery.match(/^mutation\s\w+/)![0].split(" ")[1];
// No update mutation --> leave request untouched
if (!updateMutation) {
return;
}
// Is update mutation but developer wants to bypass deep mutation checks
if (cleanQuery.includes("__NO_CHECK__")) {
return;
}
return {
rootMutation: updateMutation[0],
queryName: queryName,
};
};
Create the custom lambda resolver
For creating the custom lambda resolver, we first need to register everything in the GraphQL schema.
type Mutation {
...
# Add deep mutation to mutation type
deepMutation(input: DeepMutationInput!): DeepMutationResult @lambda
}
# Deep mutation input type
input DeepMutationInput {
"""
Name of root mutation.
"""
rootMutation: String!
"""
All nested mutations.
"""
set: String!
}
# GraphQL result type for error handling
type DeepMutationResult {
success: String
error: String
}
Inside the lambda resolver, we now need to run the root mutation and all of its nested mutations separately. Since we want to allow for @auth
rules, we additionally make the assumption that the user which has the right to mutate the root node also has permission to update all of its children which are subject of this deep mutation (since we decided to update these nodes via DQL). You could possibly also update the child nodes via GraphQL and have separate @auth
rules but that requires to have update
resolvers generated for each of the nested nodes. This leads to the following function chaining:
- run root mutation with
@auth
rules β if it fails, return an error - loop through child mutations and return all possible fields on node root level with
expand(_all_)
- check which fields should be updated and match those fields to the appropriate predicate
- return success message to let the middleware now that everything went well
Then the lambda resolver would look like so (NOTE: for the sake of simplicity, types and helper functions have been omitted):
export const deepMutation: DeepMutation = async ({ args, authHeader }) => {
try {
const { rootMutation, set } = args.input;
// Get accessJWT to determine which namespace we are querying against (only if namespaces are used)
// Parse nested mutations
const mutations = JSON.parse(set);
const { root, ...restMut } = mutations;
// Run root mutation first to check if user has appropriate rights
const { id, ...rootSet } = root;
// Get typename of root mutation
const typeName = rootMutation.split("update")[1];
// Run root mutation via GraphQL --> this allows for @auth rules on the root node!
const rootGraphQlMutation = `
mutation Root($id: [ID!], $set: ${typeName}Patch!) {
${rootMutation}(
input: {
filter: { id: $id },
set: $set
}
) { numUids }
}
`;
const updateRoot = await graphQlRequest<
TGraphQlrequest<Record<string, { numUids: number }>, { id: string; set: Json }>
>({
errorPos: "Error when trying to query root.",
query: rootGraphQlMutation,
variables: {
id: id,
set: rootSet,
},
headers: {
"X-Dgraph-AccessToken": accessJWT,
"Codeversity-Web-Auth": authHeader.value,
},
});
if (updateRoot.data[rootMutation]?.numUids === 0) {
throw new Error(`Insufficient user rights to perform deep mutation on '${rootMutation}'.`);
}
// Run nested mutations
for await (const value of Object.values(restMut)) {
const { id, ...set } = value as any;
// Run query on nested node to extract predicate type for fields
const query = await dqlRequest<DeepMutationExpand>({
commitNow: true,
type: "query",
headers: {
"X-Dgraph-AccessToken": accessJWT,
},
variables: {
id: id,
},
query: `{ expand(func: uid($id)) { expand(_all_) } }`,
});
if (!query.data.expand[0]) {
throw new Error("Something went wrong.");
}
// Create map which indexes fieldname <--> predicate relation
const predMap = new Map<string, string>();
Object.keys(query.data.expand[0]).forEach(key => {
const pred = key.split(".");
predMap.set(pred[1], pred[0]);
});
// Rewrite GraphQL set object for nested node to DQL
let dqlSet: Record<string, any> = { uid: id };
Object.entries(set).forEach(([key, value]) => {
const predicate = `${predMap.get(key)}.${key}`;
dqlSet[predicate] = value;
});
// Run mutation for nested node
const mut = await dqlRequest<DeepMutationSet>({
commitNow: true,
type: "mutate",
headers: {
"X-Dgraph-AccessToken": accessJWT,
},
query: {
set: dqlSet,
},
});
if (mut.data.code !== "Success") {
throw new Error(`Could not update nested node with UID '${id}' in '${rootMutation}'.`);
}
}
// Success - return CreateCourseResult object
return {
__typename: "DeepMutationResult",
success: `Deep mutations for '${rootMutation}' successfully executed.`,
};
} catch (error) {
const codeversityError = getErrorForLambda({
error: error,
});
return {
__typename: "DeepMutationResult",
error: codeversityError.msg,
};
}
};
Usage
If everything went well, you should be able to write and run your update
mutations like any other mutation (assuming a type TestNode
in the schema):
mutation TestMutation($id: ID!, $set: TestNodePatch) {
updateTestNode(input. { filter: { id: [$id] }, set: $set }) {
testNode {
id
name
nested {
id
someField
}
}
}
}
with the variables
// The root node UID
const id = "0x1";
// Set parameter
const set = {
name: "I am root",
nested: {
id: "0x2",
someField: "I am nested"
}
}
Wrap up
This is basically it. Obviously it has some downsides
- we have limited the way we are writing
update
mutations in the frontend by restraining it to the use of$id
and$set
. - it is hard to protect nested mutations with appropriate
@auth
rules. - we experience performance issues when there are tons of nested mutations since we need to query, parse and mutate for each and every nested node.
However, we found that it covers at least 80% of our use cases and having a more dogmatic approach on how to write mutations, is actually benefitting the readability.