Repository: urql-graphql/urql
Pull request #3023 (2023-03-09 15:16:34)

feat(populate): add way to limit depth and skip certain types from that depth-count
## Summary

This introduces a `maxDepth` and `skipType` (naming suggestion welcome) option to the `populateExchange`, this is mainly meant to avoid having long running applications have infinitely deep selection-sets due to seeing a lot of queries and a root-type being mutated.



Patch:
diff --git a/.changeset/stupid-cherries-join.md b/.changeset/stupid-cherries-join.md
new file mode 100644
index 00000000..72bae355
--- /dev/null
+++ b/.changeset/stupid-cherries-join.md
@@ -0,0 +1,7 @@
+---
+'@urql/exchange-populate': minor
+---
+
+Introduce `maxDepth` and `skipType` into the `populateExchange`, these options allow you to specify
+the maximum depth a mutation should be populated as well as which types should not be counted towards
+this depth.
diff --git a/exchanges/populate/src/populateExchange.ts b/exchanges/populate/src/populateExchange.ts
index 080311ba..dc48d2db 100644
--- a/exchanges/populate/src/populateExchange.ts
+++ b/exchanges/populate/src/populateExchange.ts
@@ -20,8 +20,14 @@ import { Exchange, Operation, stringifyVariables } from '@urql/core';
 import { getName, GraphQLFlatType, unwrapType } from './helpers/node';
 import { traverse } from './helpers/traverse';
 
+interface Options {
+  skipType?: RegExp;
+  maxDepth?: number;
+}
+
 interface PopulateExchangeOpts {
   schema: IntrospectionQuery;
+  options?: Options;
 }
 
 const makeDict = (): any => Object.create(null);
@@ -39,11 +45,37 @@ interface FieldUsage {
 }
 
 type FragmentMap<T extends string = string> = Record<T, FragmentDefinitionNode>;
-
-/** An exchange for auto-populating mutations with a required response body. */
+const SKIP_COUNT_TYPE = /^PageInfo|(Connection|Edge)$/;
+
+/** Creates an `Exchange` handing automatic mutation selection-set population based on the
+ * query selection-sets seen.
+ *
+ *
+ * @remarks
+ * The `populateExchange` will create an exchange that monitors queries and
+ * extracts fields and types so it knows what is currently observed by your
+ * application.
+ * When a mutation comes in with the `@populate` directive it will fill the
+ * selection-set based on these prior queries.
+ *
+ * This Exchange can ease up the transition from documentCache to graphCache
+ *
+ * @example
+ * ```ts
+ * populateExchange({ schema, options: { maxDepth: 3, skipType: /Todo/ }})
+ *
+ * const query = gql`
+ *   mutation { addTodo @popualte }
+ * `
+ * ```
+ */
 export const populateExchange = ({
   schema: ogSchema,
+  options,
 }: PopulateExchangeOpts): Exchange => ({ forward }) => {
+  const maxDepth = (options && options.maxDepth) || 2;
+  const skipType = (options && options.skipType) || SKIP_COUNT_TYPE;
+
   const schema = buildClientSchema(ogSchema);
   /** List of operation keys that have already been parsed. */
   const parsedOperations = new Set<number>();
@@ -100,7 +132,10 @@ export const populateExchange = ({
         const visited = new Set();
         const populateSelections = (
           type: GraphQLFlatType,
-          selections: Array<FieldNode | InlineFragmentNode | FragmentSpreadNode>
+          selections: Array<
+            FieldNode | InlineFragmentNode | FragmentSpreadNode
+          >,
+          depth: number
         ) => {
           let possibleTypes: readonly string[] = [];
           let isAbstract = false;
@@ -195,12 +230,17 @@ export const populateExchange = ({
                 typeSelections.push(field);
               } else if (
                 value.type instanceof GraphQLObjectType &&
-                !visited.has(value.type.name)
+                !visited.has(value.type.name) &&
+                depth < maxDepth
               ) {
                 visited.add(value.type.name);
                 const fieldSelections: Array<FieldNode> = [];
 
-                populateSelections(value.type, fieldSelections);
+                populateSelections(
+                  value.type,
+                  fieldSelections,
+                  skipType.test(value.type.name) ? depth : depth + 1
+                );
 
                 const args = value.args
                   ? Object.keys(value.args).map(k => {
@@ -242,7 +282,7 @@ export const populateExchange = ({
         const selections: Array<
           FieldNode | InlineFragmentNode | FragmentSpreadNode
         > = node.selectionSet ? [...node.selectionSet.selections] : [];
-        populateSelections(type, selections);
+        populateSelections(type, selections, 0);
 
         return {
           ...node,


Test patch:
diff --git a/exchanges/persisted-fetch/src/__snapshots__/persistedFetchExchange.test.ts.snap b/exchanges/persisted-fetch/src/__snapshots__/persistedFetchExchange.test.ts.snap
index 325c7c63..f0001ae3 100644
--- a/exchanges/persisted-fetch/src/__snapshots__/persistedFetchExchange.test.ts.snap
+++ b/exchanges/persisted-fetch/src/__snapshots__/persistedFetchExchange.test.ts.snap
@@ -1,4 +1,4 @@
-// Vitest Snapshot v1
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
 
 exports[`accepts successful persisted query responses 1`] = `"{\\"operationName\\":\\"getUser\\",\\"variables\\":{\\"name\\":\\"Clara\\"},\\"extensions\\":{\\"persistedQuery\\":{\\"version\\":1,\\"sha256Hash\\":\\"b4228e10e04c59def248546d305b710309c1b297423b38eb64f989a89a398cd8\\"}}}"`;
 
diff --git a/exchanges/populate/src/populateExchange.test.ts b/exchanges/populate/src/populateExchange.test.ts
index a0b87673..9db9bb81 100644
--- a/exchanges/populate/src/populateExchange.test.ts
+++ b/exchanges/populate/src/populateExchange.test.ts
@@ -79,10 +79,16 @@ const schemaDef = `
     store: OnlineStore
   }
 
+  type Company {
+    id: String
+    employees: [User]
+  }
+
   type Query {
     todos: [Todo!]
     users: [User!]!
     products: [Product]!
+    company: Company
   }
 
   type Mutation {
@@ -90,6 +96,7 @@ const schemaDef = `
     removeTodo: [Node]
     updateTodo: [UnionType]
     addProduct: Product
+    removeCompany: Company
   }
 `;
 
@@ -825,3 +832,92 @@ describe('nested fragment', () => {
   `);
   });
 });
+
+describe('respects max-depth', () => {
+  const queryOp = makeOperation(
+    'query',
+    {
+      key: 1234,
+      variables: undefined,
+      query: gql`
+        query {
+          company {
+            id
+            employees {
+              id
+              todos {
+                id
+              }
+            }
+          }
+        }
+      `,
+    },
+    context
+  );
+
+  const mutationOp = makeOperation(
+    'mutation',
+    {
+      key: 5678,
+      variables: undefined,
+      query: gql`
+        mutation MyMutation {
+          removeCompany @populate
+        }
+      `,
+    },
+    context
+  );
+
+  describe('mutation query', () => {
+    it('matches snapshot', async () => {
+      const response = pipe<Operation, any, Operation[]>(
+        fromArray([queryOp, mutationOp]),
+        populateExchange({ schema, options: { maxDepth: 1 } })(exchangeArgs),
+        toArray
+      );
+
+      expect(print(response[1].query)).toMatchInlineSnapshot(`
+        "mutation MyMutation {
+          removeCompany {
+            __typename
+            id
+            employees {
+              __typename
+              id
+            }
+          }
+        }"
+      `);
+    });
+
+    it('respects skip syntax', async () => {
+      const response = pipe<Operation, any, Operation[]>(
+        fromArray([queryOp, mutationOp]),
+        populateExchange({
+          schema,
+          options: { maxDepth: 1, skipType: /User/ },
+        })(exchangeArgs),
+        toArray
+      );
+
+      expect(print(response[1].query)).toMatchInlineSnapshot(`
+        "mutation MyMutation {
+          removeCompany {
+            __typename
+            id
+            employees {
+              __typename
+              id
+              todos {
+                __typename
+                id
+              }
+            }
+          }
+        }"
+      `);
+    });
+  });
+});
diff --git a/packages/react-urql/src/hooks/__snapshots__/useMutation.test.tsx.snap b/packages/react-urql/src/hooks/__snapshots__/useMutation.test.tsx.snap
index f79419cb..d40f6110 100644
--- a/packages/react-urql/src/hooks/__snapshots__/useMutation.test.tsx.snap
+++ b/packages/react-urql/src/hooks/__snapshots__/useMutation.test.tsx.snap
@@ -1,4 +1,4 @@
-// Vitest Snapshot v1
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
 
 exports[`on initial useEffect > initialises default state 1`] = `
 {
diff --git a/packages/react-urql/src/hooks/__snapshots__/useQuery.test.tsx.snap b/packages/react-urql/src/hooks/__snapshots__/useQuery.test.tsx.snap
index 6e38c0ca..442ec102 100644
--- a/packages/react-urql/src/hooks/__snapshots__/useQuery.test.tsx.snap
+++ b/packages/react-urql/src/hooks/__snapshots__/useQuery.test.tsx.snap
@@ -1,4 +1,4 @@
-// Vitest Snapshot v1
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
 
 exports[`on initial useEffect > initialises default state 1`] = `
 {
diff --git a/packages/react-urql/src/hooks/__snapshots__/useSubscription.test.tsx.snap b/packages/react-urql/src/hooks/__snapshots__/useSubscription.test.tsx.snap
index 6e38c0ca..442ec102 100644
--- a/packages/react-urql/src/hooks/__snapshots__/useSubscription.test.tsx.snap
+++ b/packages/react-urql/src/hooks/__snapshots__/useSubscription.test.tsx.snap
@@ -1,4 +1,4 @@
-// Vitest Snapshot v1
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
 
 exports[`on initial useEffect > initialises default state 1`] = `
 {