A solution of performing Deep Mutations from inside a frontend application

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:

  1. A deep mutation is basically a nested mutation. Dgraph would handle this by default by adding new nodes instead of updating them.
  2. 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.
  3. 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:

  1. Are we running a mutation (in our case updatePredicate)? Thus the query string must contain update.
  2. Are we having UIDs for the nested nodes we wish to update? If not, we can continue with the default mutation.
  3. 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:

  1. run root mutation with @auth rules β†’ if it fails, return an error
  2. loop through child mutations and return all possible fields on node root level with expand(_all_)
  3. check which fields should be updated and match those fields to the appropriate predicate
  4. 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.

4 Likes

Thanks for sharing. We will see what we can add to the product to simplify this use case, either at GraphQL directive and execution level or as helper function in lambda to help implement those mutations. GraphQL spec is not helping that much on this topic. We have the discuss posts and issues on this subject and will work on that after the @auth work.
Just for clarity what is supported out-of-the-box is explained at Deep Mutations - GraphQL, you can

  • add a nested object and a relationship from the parent,
  • remove a relationship using remove as in ``updateAuthor(input: {filter: {id: β€œ0x3ab7bd2”}, remove: {posts: {postID: β€œ0x3ab7bd1”}}}),
  • adding a relationship from a parent to an already existing entity
1 Like

No problem, pleasure to contribute! :pray:

I think the reason why nested or deep mutations are not really a thing in the GraphQL specs is because of the lack of definition what or when such a mutations takes place. Like I’ve mentioned in my post, our definition scope defines a nested mutation by adding the UID of the specific node. This is obviously already a restriction since GraphQL does not automatically request an id field to identify a specific node but in my opinion it makes the most sense.

Although I’m not 100% confident with the existing source and thus only have a limited understanding of how mutations are handled by Dgraph, I think something similar to what I have proposed should not be too hard to implement. Also @auth rules should not be too much of a problem since Dgraph already handles nested @auth for add mutations.

Would be great if I could be kept in the loop regarding this issue. I’m very interested on what you guys are having in mind. :raised_hands: