diff --git a/src/index.ts b/src/index.ts index 54ab38437f..bdadde2731 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. @@ -503,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 new file mode 100644 index 0000000000..fc14e9ef1f --- /dev/null +++ b/src/utilities/__tests__/mergeAST-test.ts @@ -0,0 +1,415 @@ +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 + } + `); + }); +}); + +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 90f08fc225..19b93b7577 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -76,6 +76,10 @@ 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'; +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 new file mode 100644 index 0000000000..4e24d1adb8 --- /dev/null +++ b/src/utilities/mergeAST.ts @@ -0,0 +1,343 @@ +import type { + DocumentNode, + DefinitionNode, + FieldNode, + FragmentDefinitionNode, + InlineFragmentNode, + OperationDefinitionNode, + SelectionNode, + SelectionSetNode, +} from '../language/ast'; +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. + * + * 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. + * + * 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[] = []; + + 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, maxDepth)); + } 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, + maxDepth: number, +): OperationDefinitionNode { + const mergedSelectionSet = mergeSelectionSets( + a.selectionSet, + b.selectionSet, + 1, + maxDepth, + ); + 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, + depth: number, + maxDepth: number, +): SelectionSetNode { + 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, + }; +} + +/** + * 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, + depth: number, + maxDepth: number, +): 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, depth, maxDepth)); + // 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, + depth, + maxDepth, + ); + 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, + depth: number, + maxDepth: number, +): FieldNode { + if (a.selectionSet && b.selectionSet) { + const mergedDirectives = mergeDirectives( + a.directives ?? [], + b.directives ?? [], + ); + return { + ...a, + selectionSet: mergeSelectionSets( + a.selectionSet, + b.selectionSet, + depth + 1, + maxDepth, + ), + ...(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, + depth: number, + maxDepth: number, +): InlineFragmentNode { + return { + ...a, + selectionSet: mergeSelectionSets( + a.selectionSet, + b.selectionSet, + depth + 1, + maxDepth, + ), + }; +} + +/** + * 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<(typeof a)[number]> { + const seen = new Set(); + const result: Array<(typeof a)[number]> = []; + 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( + 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; +}