From a29673bec785845859aadbfb66bc3b0e8101aafb Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Sun, 22 Feb 2026 18:29:32 -0800 Subject: [PATCH 1/2] Add mergeAST utility for merging multiple AST documents Implements a `mergeAST` function that merges multiple DocumentNodes by combining selection sets of operations with matching names and types, recursively deduplicating fields with the same response name and arguments, and deduplicating fragment definitions. This addresses the long-standing need (issue #1428) for a way to dynamically merge GraphQL queries, such as when resolvers need to ensure additional fields are present in requests to backend services. Closes #1428 --- src/index.ts | 2 + src/utilities/__tests__/mergeAST-test.ts | 322 +++++++++++++++++++++++ src/utilities/index.ts | 3 + src/utilities/mergeAST.ts | 277 +++++++++++++++++++ 4 files changed, 604 insertions(+) create mode 100644 src/utilities/__tests__/mergeAST-test.ts create mode 100644 src/utilities/mergeAST.ts diff --git a/src/index.ts b/src/index.ts index 54ab38437f..2ea8faee2b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -452,6 +452,8 @@ export { coerceInputValue, // Concatenates multiple AST together. concatAST, + // Merges multiple AST documents, combining selection sets and deduplicating fields. + mergeAST, // Separates an AST into an AST per Operation. separateOperations, // Strips characters that are not significant to the validity or execution of a GraphQL document. diff --git a/src/utilities/__tests__/mergeAST-test.ts b/src/utilities/__tests__/mergeAST-test.ts new file mode 100644 index 0000000000..5af1354f5a --- /dev/null +++ b/src/utilities/__tests__/mergeAST-test.ts @@ -0,0 +1,322 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { dedent } from '../../__testUtils__/dedent'; + +import { parse } from '../../language/parser'; +import { print } from '../../language/printer'; + +import { mergeAST } from '../mergeAST'; + +describe('mergeAST', () => { + it('merges two simple queries', () => { + const docA = parse('{ a, b }'); + const docB = parse('{ c, d }'); + + expect(print(mergeAST([docA, docB]))).to.equal(dedent` + { + a + b + c + d + } + `); + }); + + it('deduplicates identical fields', () => { + const docA = parse('{ a, b }'); + const docB = parse('{ b, c }'); + + expect(print(mergeAST([docA, docB]))).to.equal(dedent` + { + a + b + c + } + `); + }); + + it('recursively merges nested selection sets', () => { + const docA = parse('{ user { name } }'); + const docB = parse('{ user { email } }'); + + expect(print(mergeAST([docA, docB]))).to.equal(dedent` + { + user { + name + email + } + } + `); + }); + + it('deeply merges nested selection sets', () => { + const docA = parse('{ user { profile { name } } }'); + const docB = parse('{ user { profile { avatar } } }'); + + expect(print(mergeAST([docA, docB]))).to.equal(dedent` + { + user { + profile { + name + avatar + } + } + } + `); + }); + + it('does not merge fields with different arguments', () => { + const docA = parse('{ user(id: 1) { name } }'); + const docB = parse('{ user(id: 2) { name } }'); + + expect(print(mergeAST([docA, docB]))).to.equal(dedent` + { + user(id: 1) { + name + } + user(id: 2) { + name + } + } + `); + }); + + it('merges fields with same arguments', () => { + const docA = parse('{ user(id: 1) { name } }'); + const docB = parse('{ user(id: 1) { email } }'); + + expect(print(mergeAST([docA, docB]))).to.equal(dedent` + { + user(id: 1) { + name + email + } + } + `); + }); + + it('handles aliased fields', () => { + const docA = parse('{ myUser: user { name } }'); + const docB = parse('{ myUser: user { email } }'); + + expect(print(mergeAST([docA, docB]))).to.equal(dedent` + { + myUser: user { + name + email + } + } + `); + }); + + it('does not merge different aliases for the same field', () => { + const docA = parse('{ a: user { name } }'); + const docB = parse('{ b: user { name } }'); + + expect(print(mergeAST([docA, docB]))).to.equal(dedent` + { + a: user { + name + } + b: user { + name + } + } + `); + }); + + it('merges named operations of the same type', () => { + const docA = parse('query GetUser { user { name } }'); + const docB = parse('query GetUser { user { email } }'); + + expect(print(mergeAST([docA, docB]))).to.equal(dedent` + query GetUser { + user { + name + email + } + } + `); + }); + + it('keeps separate operations with different names', () => { + const docA = parse('query GetUser { user { name } }'); + const docB = parse('query GetPosts { posts { title } }'); + + const result = print(mergeAST([docA, docB])); + expect(result).to.equal(dedent` + query GetUser { + user { + name + } + } + + query GetPosts { + posts { + title + } + } + `); + }); + + it('keeps separate operations with different types', () => { + const docA = parse('query { user { name } }'); + const docB = parse('mutation { createUser { id } }'); + + const result = print(mergeAST([docA, docB])); + expect(result).to.equal(dedent` + { + user { + name + } + } + + mutation { + createUser { + id + } + } + `); + }); + + it('merges variable definitions and deduplicates', () => { + const docA = parse('query GetUser($id: ID!) { user(id: $id) { name } }'); + const docB = parse( + 'query GetUser($id: ID!, $includeEmail: Boolean!) { user(id: $id) { email @include(if: $includeEmail) } }', + ); + + const result = print(mergeAST([docA, docB])); + expect(result).to.equal(dedent` + query GetUser($id: ID!, $includeEmail: Boolean!) { + user(id: $id) { + name + email @include(if: $includeEmail) + } + } + `); + }); + + it('deduplicates fragment definitions', () => { + const docA = parse(` + { ...UserFields } + fragment UserFields on User { name } + `); + const docB = parse(` + { ...UserFields } + fragment UserFields on User { name } + `); + + const result = print(mergeAST([docA, docB])); + expect(result).to.equal(dedent` + { + ...UserFields + } + + fragment UserFields on User { + name + } + `); + }); + + it('merges inline fragments with the same type condition', () => { + const docA = parse('{ ... on User { name } }'); + const docB = parse('{ ... on User { email } }'); + + expect(print(mergeAST([docA, docB]))).to.equal(dedent` + { + ... on User { + name + email + } + } + `); + }); + + it('keeps separate inline fragments with different type conditions', () => { + const docA = parse('{ ... on User { name } }'); + const docB = parse('{ ... on Post { title } }'); + + expect(print(mergeAST([docA, docB]))).to.equal(dedent` + { + ... on User { + name + } + ... on Post { + title + } + } + `); + }); + + it('handles merging more than two documents', () => { + const docA = parse('{ a }'); + const docB = parse('{ b }'); + const docC = parse('{ c }'); + + expect(print(mergeAST([docA, docB, docC]))).to.equal(dedent` + { + a + b + c + } + `); + }); + + it('returns empty document for empty array', () => { + const result = mergeAST([]); + expect(result.definitions).to.deep.equal([]); + }); + + it('returns equivalent document for single document', () => { + const doc = parse('{ a, b }'); + expect(print(mergeAST([doc]))).to.equal(print(doc)); + }); + + it('handles the use case from the issue: adding required fields', () => { + // Client queries avgRating, resolver needs ratings { stars } to compute it + const clientQuery = parse(` + { + home { + avgRating + address + } + } + `); + + const requiredFields = parse(` + { + home { + ratings { + stars + } + } + } + `); + + expect(print(mergeAST([clientQuery, requiredFields]))).to.equal(dedent` + { + home { + avgRating + address + ratings { + stars + } + } + } + `); + }); + + it('deduplicates fragment spreads', () => { + const docA = parse('{ ...Frag, a }'); + const docB = parse('{ ...Frag, b }'); + + expect(print(mergeAST([docA, docB]))).to.equal(dedent` + { + ...Frag + a + b + } + `); + }); +}); diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 90f08fc225..e2e1dfce48 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -76,6 +76,9 @@ export { coerceInputValue } from './coerceInputValue'; // Concatenates multiple AST together. export { concatAST } from './concatAST'; +// Merges multiple AST documents, combining selection sets and deduplicating fields. +export { mergeAST } from './mergeAST'; + // Separates an AST into an AST per Operation. export { separateOperations } from './separateOperations'; diff --git a/src/utilities/mergeAST.ts b/src/utilities/mergeAST.ts new file mode 100644 index 0000000000..f90ac69180 --- /dev/null +++ b/src/utilities/mergeAST.ts @@ -0,0 +1,277 @@ +import type { + DocumentNode, + DefinitionNode, + FieldNode, + FragmentDefinitionNode, + InlineFragmentNode, + OperationDefinitionNode, + SelectionNode, + SelectionSetNode, +} from '../language/ast'; +import { Kind } from '../language/kinds'; +import { print } from '../language/printer'; + +/** + * Provided a collection of ASTs, merge their definitions together, + * combining selection sets of operations with the same name and type. + * + * This is useful for dynamically constructing queries by merging + * selections from multiple sources, such as when building queries + * that need to include fields required by resolvers. + * + * Fields are considered identical when they have the same response name + * (alias or field name) and the same arguments. When two identical fields + * both have selection sets, their selections are recursively merged. + * + * Fragment definitions with the same name are deduplicated (the first + * occurrence is kept). + * + * Operations are matched by name and operation type (query/mutation/subscription). + * Unnamed operations are matched by operation type alone. + */ +export function mergeAST( + documents: ReadonlyArray, +): DocumentNode { + const operationMap = new Map(); + const fragmentMap = new Map(); + const otherDefinitions: DefinitionNode[] = []; + + for (const doc of documents) { + for (const definition of doc.definitions) { + if (definition.kind === Kind.OPERATION_DEFINITION) { + const key = operationKey(definition); + const existing = operationMap.get(key); + if (existing) { + operationMap.set(key, mergeOperations(existing, definition)); + } else { + operationMap.set(key, definition); + } + } else if (definition.kind === Kind.FRAGMENT_DEFINITION) { + const name = definition.name.value; + if (!fragmentMap.has(name)) { + fragmentMap.set(name, definition); + } + } else { + otherDefinitions.push(definition); + } + } + } + + const definitions: Array = [ + ...operationMap.values(), + ...fragmentMap.values(), + ...otherDefinitions, + ]; + + return { kind: Kind.DOCUMENT, definitions }; +} + +/** + * Generate a unique key for an operation based on its type and name. + */ +function operationKey(operation: OperationDefinitionNode): string { + const name = operation.name?.value ?? ''; + return `${operation.operation}:${name}`; +} + +/** + * Merge two operations by combining their selection sets and variable definitions. + */ +function mergeOperations( + a: OperationDefinitionNode, + b: OperationDefinitionNode, +): OperationDefinitionNode { + const mergedSelectionSet = mergeSelectionSets( + a.selectionSet, + b.selectionSet, + ); + const mergedVariableDefinitions = mergeVariableDefinitions( + a.variableDefinitions ?? [], + b.variableDefinitions ?? [], + ); + const mergedDirectives = mergeDirectives( + a.directives ?? [], + b.directives ?? [], + ); + + return { + ...a, + selectionSet: mergedSelectionSet, + ...(mergedVariableDefinitions.length > 0 + ? { variableDefinitions: mergedVariableDefinitions } + : {}), + ...(mergedDirectives.length > 0 ? { directives: mergedDirectives } : {}), + }; +} + +/** + * Merge two selection sets by combining their selections and deduplicating + * fields with the same response name and arguments. + */ +function mergeSelectionSets( + a: SelectionSetNode, + b: SelectionSetNode, +): SelectionSetNode { + const merged = mergeSelections([...a.selections, ...b.selections]); + return { + kind: Kind.SELECTION_SET, + selections: merged, + }; +} + +/** + * Merge an array of selections, deduplicating fields that have the same + * response name and arguments by recursively merging their selection sets. + */ +function mergeSelections( + selections: ReadonlyArray, +): ReadonlyArray { + const fieldMap = new Map(); + const inlineFragmentMap = new Map(); + const result: SelectionNode[] = []; + + for (const selection of selections) { + if (selection.kind === Kind.FIELD) { + const key = fieldKey(selection); + const existing = fieldMap.get(key); + if (existing) { + fieldMap.set(key, mergeFieldNodes(existing, selection)); + // Update the entry in result array + const idx = result.indexOf(existing); + result[idx] = fieldMap.get(key)!; + } else { + fieldMap.set(key, selection); + result.push(selection); + } + } else if (selection.kind === Kind.INLINE_FRAGMENT) { + const key = inlineFragmentKey(selection); + const existing = inlineFragmentMap.get(key); + if (existing) { + const merged = mergeInlineFragments(existing, selection); + inlineFragmentMap.set(key, merged); + const idx = result.indexOf(existing); + result[idx] = merged; + } else { + inlineFragmentMap.set(key, selection); + result.push(selection); + } + } else { + // FragmentSpread - deduplicate by name and directives + const key = print(selection); + if (!result.some((s) => s.kind === Kind.FRAGMENT_SPREAD && print(s) === key)) { + result.push(selection); + } + } + } + + return result; +} + +/** + * Generate a key for a field based on its response name (alias or field name) + * and its arguments, so that fields with different arguments are not merged. + */ +function fieldKey(field: FieldNode): string { + const responseName = field.alias?.value ?? field.name.value; + const args = field.arguments?.length + ? '(' + + field.arguments + .map((arg) => `${arg.name.value}: ${print(arg.value)}`) + .sort() + .join(', ') + + ')' + : ''; + return `${responseName}${args}`; +} + +/** + * Generate a key for an inline fragment based on its type condition and directives. + */ +function inlineFragmentKey(fragment: InlineFragmentNode): string { + const typeName = fragment.typeCondition?.name.value ?? ''; + const directives = fragment.directives?.length + ? fragment.directives.map((d) => print(d)).sort().join(' ') + : ''; + return `${typeName}:${directives}`; +} + +/** + * Merge two field nodes. If both have selection sets, recursively merge them. + * If only one has a selection set, use that one. Directives are combined. + */ +function mergeFieldNodes(a: FieldNode, b: FieldNode): FieldNode { + if (a.selectionSet && b.selectionSet) { + const mergedDirectives = mergeDirectives( + a.directives ?? [], + b.directives ?? [], + ); + return { + ...a, + selectionSet: mergeSelectionSets(a.selectionSet, b.selectionSet), + ...(mergedDirectives.length > 0 + ? { directives: mergedDirectives } + : {}), + }; + } + + if (b.selectionSet) { + return { ...b }; + } + + return { ...a }; +} + +/** + * Merge two inline fragments with the same type condition by merging + * their selection sets. + */ +function mergeInlineFragments( + a: InlineFragmentNode, + b: InlineFragmentNode, +): InlineFragmentNode { + return { + ...a, + selectionSet: mergeSelectionSets(a.selectionSet, b.selectionSet), + }; +} + +/** + * Merge variable definitions, deduplicating by variable name. + * The first occurrence of each variable is kept. + */ +function mergeVariableDefinitions( + a: ReadonlyArray<{ readonly variable: { readonly name: { readonly value: string } } }>, + b: ReadonlyArray<{ readonly variable: { readonly name: { readonly value: string } } }>, +): ReadonlyArray { + const seen = new Set(); + const result: Array = []; + for (const varDef of [...a, ...b]) { + const name = varDef.variable.name.value; + if (!seen.has(name)) { + seen.add(name); + result.push(varDef); + } + } + return result; +} + +/** + * Merge directive arrays, deduplicating by their printed representation. + */ +function mergeDirectives< + T extends { readonly kind: string }, +>( + a: ReadonlyArray, + b: ReadonlyArray, +): ReadonlyArray { + const seen = new Set(); + const result: T[] = []; + for (const directive of [...a, ...b]) { + const key = print(directive as unknown as Parameters[0]); + if (!seen.has(key)) { + seen.add(key); + result.push(directive); + } + } + return result; +} From 866226a00e44f4226e629444da955674c6793250 Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Wed, 11 Mar 2026 21:34:27 -0700 Subject: [PATCH 2/2] add depth guard to mergeAST to prevent DOS Adds a maxDepth option (default: 20) to mergeAST that limits recursion depth when merging nested selection sets. This addresses the concern raised about mergeAST being a potential denial-of-service vector when used on unvalidated or adversarial input. The depth is tracked through the recursive mergeSelectionSets path and an error is thrown if the limit is exceeded. The default of 20 is high enough for any realistic query while still protecting against malicious nesting. --- src/index.ts | 1 + src/utilities/__tests__/mergeAST-test.ts | 93 +++++++++++++++++++++++ src/utilities/index.ts | 1 + src/utilities/mergeAST.ts | 96 ++++++++++++++++++++---- 4 files changed, 176 insertions(+), 15 deletions(-) diff --git a/src/index.ts b/src/index.ts index 2ea8faee2b..bdadde2731 100644 --- a/src/index.ts +++ b/src/index.ts @@ -505,4 +505,5 @@ export type { TypedQueryDocumentNode, // Schema Coordinates ResolvedSchemaElement, + MergeASTOptions, } from './utilities/index'; diff --git a/src/utilities/__tests__/mergeAST-test.ts b/src/utilities/__tests__/mergeAST-test.ts index 5af1354f5a..fc14e9ef1f 100644 --- a/src/utilities/__tests__/mergeAST-test.ts +++ b/src/utilities/__tests__/mergeAST-test.ts @@ -320,3 +320,96 @@ describe('mergeAST', () => { `); }); }); + +describe('mergeAST depth guard', () => { + it('allows merging within the default depth limit', () => { + const docA = parse('{ a { b { c { d { e } } } } }'); + const docB = parse('{ a { b { c { d { f } } } } }'); + + expect(print(mergeAST([docA, docB]))).to.equal(dedent` + { + a { + b { + c { + d { + e + f + } + } + } + } + } + `); + }); + + it('throws when depth exceeds maxDepth', () => { + // Build a deeply nested query: { a { a { a { ... } } } } + const buildDeep = (depth: number): string => { + let q = '{ '; + for (let i = 0; i < depth; i++) { + q += 'a { '; + } + q += 'leaf '; + for (let i = 0; i < depth; i++) { + q += '} '; + } + q += '}'; + return q; + }; + + const docA = parse(buildDeep(5)); + const docB = parse(buildDeep(5)); + + // Should succeed with a high enough limit + expect(() => mergeAST([docA, docB], { maxDepth: 10 })).to.not.throw(); + + // Should throw when limit is too low + expect(() => mergeAST([docA, docB], { maxDepth: 3 })).to.throw( + 'mergeAST: maximum depth of 3 exceeded', + ); + }); + + it('throws with custom maxDepth option', () => { + const docA = parse('{ a { b { c { d } } } }'); + const docB = parse('{ a { b { c { e } } } }'); + + expect(() => mergeAST([docA, docB], { maxDepth: 2 })).to.throw( + 'mergeAST: maximum depth of 2 exceeded', + ); + }); + + it('respects depth limit on inline fragments', () => { + const docA = parse('{ ... on Query { a { ... on A { b { c } } } } }'); + const docB = parse('{ ... on Query { a { ... on A { b { d } } } } }'); + + expect(() => mergeAST([docA, docB], { maxDepth: 2 })).to.throw( + 'mergeAST: maximum depth of 2 exceeded', + ); + + // Should succeed with sufficient depth + expect(() => mergeAST([docA, docB], { maxDepth: 10 })).to.not.throw(); + }); + + it('uses default maxDepth of 20 when no option is provided', () => { + // Build a query nested 21 levels deep + const buildDeep = (depth: number): string => { + let q = '{ '; + for (let i = 0; i < depth; i++) { + q += 'a { '; + } + q += 'leaf '; + for (let i = 0; i < depth; i++) { + q += '} '; + } + q += '}'; + return q; + }; + + const docA = parse(buildDeep(21)); + const docB = parse(buildDeep(21)); + + expect(() => mergeAST([docA, docB])).to.throw( + 'mergeAST: maximum depth of 20 exceeded', + ); + }); +}); diff --git a/src/utilities/index.ts b/src/utilities/index.ts index e2e1dfce48..19b93b7577 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -78,6 +78,7 @@ export { concatAST } from './concatAST'; // Merges multiple AST documents, combining selection sets and deduplicating fields. export { mergeAST } from './mergeAST'; +export type { MergeASTOptions } from './mergeAST'; // Separates an AST into an AST per Operation. export { separateOperations } from './separateOperations'; diff --git a/src/utilities/mergeAST.ts b/src/utilities/mergeAST.ts index f90ac69180..4e24d1adb8 100644 --- a/src/utilities/mergeAST.ts +++ b/src/utilities/mergeAST.ts @@ -11,6 +11,20 @@ import type { import { Kind } from '../language/kinds'; import { print } from '../language/printer'; +const DEFAULT_MAX_DEPTH = 20; + +/** + * Options for controlling the merge behavior. + */ +export interface MergeASTOptions { + /** + * Maximum nesting depth allowed when recursively merging selection sets. + * Prevents denial-of-service from deeply nested or adversarial input. + * Defaults to 20. + */ + maxDepth?: number; +} + /** * Provided a collection of ASTs, merge their definitions together, * combining selection sets of operations with the same name and type. @@ -28,10 +42,16 @@ import { print } from '../language/printer'; * * Operations are matched by name and operation type (query/mutation/subscription). * Unnamed operations are matched by operation type alone. + * + * A `maxDepth` option (default: 20) limits the recursion depth when merging + * nested selection sets, guarding against denial-of-service from deeply + * nested or adversarial documents. */ export function mergeAST( documents: ReadonlyArray, + options?: MergeASTOptions, ): DocumentNode { + const maxDepth = options?.maxDepth ?? DEFAULT_MAX_DEPTH; const operationMap = new Map(); const fragmentMap = new Map(); const otherDefinitions: DefinitionNode[] = []; @@ -42,7 +62,7 @@ export function mergeAST( const key = operationKey(definition); const existing = operationMap.get(key); if (existing) { - operationMap.set(key, mergeOperations(existing, definition)); + operationMap.set(key, mergeOperations(existing, definition, maxDepth)); } else { operationMap.set(key, definition); } @@ -80,10 +100,13 @@ function operationKey(operation: OperationDefinitionNode): string { function mergeOperations( a: OperationDefinitionNode, b: OperationDefinitionNode, + maxDepth: number, ): OperationDefinitionNode { const mergedSelectionSet = mergeSelectionSets( a.selectionSet, b.selectionSet, + 1, + maxDepth, ); const mergedVariableDefinitions = mergeVariableDefinitions( a.variableDefinitions ?? [], @@ -111,8 +134,21 @@ function mergeOperations( function mergeSelectionSets( a: SelectionSetNode, b: SelectionSetNode, + depth: number, + maxDepth: number, ): SelectionSetNode { - const merged = mergeSelections([...a.selections, ...b.selections]); + if (depth > maxDepth) { + throw new Error( + `mergeAST: maximum depth of ${maxDepth} exceeded. ` + + 'This limit prevents denial-of-service from deeply nested documents. ' + + 'Consider increasing the maxDepth option if this depth is expected.', + ); + } + const merged = mergeSelections( + [...a.selections, ...b.selections], + depth, + maxDepth, + ); return { kind: Kind.SELECTION_SET, selections: merged, @@ -125,6 +161,8 @@ function mergeSelectionSets( */ function mergeSelections( selections: ReadonlyArray, + depth: number, + maxDepth: number, ): ReadonlyArray { const fieldMap = new Map(); const inlineFragmentMap = new Map(); @@ -135,7 +173,7 @@ function mergeSelections( const key = fieldKey(selection); const existing = fieldMap.get(key); if (existing) { - fieldMap.set(key, mergeFieldNodes(existing, selection)); + fieldMap.set(key, mergeFieldNodes(existing, selection, depth, maxDepth)); // Update the entry in result array const idx = result.indexOf(existing); result[idx] = fieldMap.get(key)!; @@ -147,7 +185,12 @@ function mergeSelections( const key = inlineFragmentKey(selection); const existing = inlineFragmentMap.get(key); if (existing) { - const merged = mergeInlineFragments(existing, selection); + const merged = mergeInlineFragments( + existing, + selection, + depth, + maxDepth, + ); inlineFragmentMap.set(key, merged); const idx = result.indexOf(existing); result[idx] = merged; @@ -158,7 +201,11 @@ function mergeSelections( } else { // FragmentSpread - deduplicate by name and directives const key = print(selection); - if (!result.some((s) => s.kind === Kind.FRAGMENT_SPREAD && print(s) === key)) { + if ( + !result.some( + (s) => s.kind === Kind.FRAGMENT_SPREAD && print(s) === key, + ) + ) { result.push(selection); } } @@ -199,7 +246,12 @@ function inlineFragmentKey(fragment: InlineFragmentNode): string { * Merge two field nodes. If both have selection sets, recursively merge them. * If only one has a selection set, use that one. Directives are combined. */ -function mergeFieldNodes(a: FieldNode, b: FieldNode): FieldNode { +function mergeFieldNodes( + a: FieldNode, + b: FieldNode, + depth: number, + maxDepth: number, +): FieldNode { if (a.selectionSet && b.selectionSet) { const mergedDirectives = mergeDirectives( a.directives ?? [], @@ -207,7 +259,12 @@ function mergeFieldNodes(a: FieldNode, b: FieldNode): FieldNode { ); return { ...a, - selectionSet: mergeSelectionSets(a.selectionSet, b.selectionSet), + selectionSet: mergeSelectionSets( + a.selectionSet, + b.selectionSet, + depth + 1, + maxDepth, + ), ...(mergedDirectives.length > 0 ? { directives: mergedDirectives } : {}), @@ -228,10 +285,17 @@ function mergeFieldNodes(a: FieldNode, b: FieldNode): FieldNode { function mergeInlineFragments( a: InlineFragmentNode, b: InlineFragmentNode, + depth: number, + maxDepth: number, ): InlineFragmentNode { return { ...a, - selectionSet: mergeSelectionSets(a.selectionSet, b.selectionSet), + selectionSet: mergeSelectionSets( + a.selectionSet, + b.selectionSet, + depth + 1, + maxDepth, + ), }; } @@ -240,11 +304,15 @@ function mergeInlineFragments( * The first occurrence of each variable is kept. */ function mergeVariableDefinitions( - a: ReadonlyArray<{ readonly variable: { readonly name: { readonly value: string } } }>, - b: ReadonlyArray<{ readonly variable: { readonly name: { readonly value: string } } }>, -): ReadonlyArray { + a: ReadonlyArray<{ + readonly variable: { readonly name: { readonly value: string } }; + }>, + b: ReadonlyArray<{ + readonly variable: { readonly name: { readonly value: string } }; + }>, +): ReadonlyArray<(typeof a)[number]> { const seen = new Set(); - const result: Array = []; + const result: Array<(typeof a)[number]> = []; for (const varDef of [...a, ...b]) { const name = varDef.variable.name.value; if (!seen.has(name)) { @@ -258,9 +326,7 @@ function mergeVariableDefinitions( /** * Merge directive arrays, deduplicating by their printed representation. */ -function mergeDirectives< - T extends { readonly kind: string }, ->( +function mergeDirectives( a: ReadonlyArray, b: ReadonlyArray, ): ReadonlyArray {