From 9ffd3d47dda284380f21a97c58e1081b02ee8f92 Mon Sep 17 00:00:00 2001 From: Minghe Huang Date: Sat, 22 Mar 2025 20:47:15 +0100 Subject: [PATCH] feat(mergeAST): add mergeAST utilility --- src/utilities/__tests__/mergeAST-test.ts | 182 ++++++++++++++++++++ src/utilities/mergeAST.ts | 204 +++++++++++++++++++++++ 2 files changed, 386 insertions(+) create mode 100644 src/utilities/__tests__/mergeAST-test.ts create mode 100644 src/utilities/mergeAST.ts diff --git a/src/utilities/__tests__/mergeAST-test.ts b/src/utilities/__tests__/mergeAST-test.ts new file mode 100644 index 0000000000..729d14a792 --- /dev/null +++ b/src/utilities/__tests__/mergeAST-test.ts @@ -0,0 +1,182 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { parse } from '../../language/parser'; +import { print } from '../../language/printer'; + +import { GraphQLInt, GraphQLObjectType, GraphQLString } from '../../type/definition'; +import { GraphQLSchema } from '../../type/schema'; + +import { mergeAST } from '../mergeAST'; + +const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Test', + fields: { + id: { + type: GraphQLInt, + }, + name: { + type: GraphQLString, + }, + }, + }), +}); + +describe.only('mergeAST', () => { + it('does not modify query with no fragments', () => { + const query = ` + query Test { + id + } + `; + const expected = stripWhitespace(` + query Test { + id + } + `); + expect(parseMergeAndPrint(query)).to.equal(expected); + expect(parseMergeAndPrint(query, schema)).to.equal(expected); + }); + + it('does inline simple nested fragment', () => { + const query = /* GraphQL */ ` + query Test { + ...Fragment1 + } + + fragment Fragment1 on Test { + id + } + `; + const expected = stripWhitespace(/* GraphQL */ ` + query Test { + ... on Test { + id + } + } + `); + const expectedWithSchema = stripWhitespace(/* GraphQL */ ` + query Test { + id + } + `); + expect(parseMergeAndPrint(query)).to.equal(expected); + expect(parseMergeAndPrint(query, schema)).to.equal(expectedWithSchema); + }); + + it('does inline triple nested fragment', () => { + const query = ` + query Test { + ...Fragment1 + } + + fragment Fragment1 on Test { + ...Fragment2 + } + + fragment Fragment2 on Test { + ...Fragment3 + } + + fragment Fragment3 on Test { + id + } + `; + const expected = stripWhitespace(` + query Test { + ... on Test { + ... on Test { + ... on Test { + id + } + } + } + } + `); + const expectedWithSchema = stripWhitespace(/* GraphQL */ ` + query Test { + id + } + `); + expect(parseMergeAndPrint(query)).to.equal(expected); + expect(parseMergeAndPrint(query, schema)).to.equal(expectedWithSchema); + }); + + it('does inline multiple fragments', () => { + const query = ` + query Test { + ...Fragment1 + ...Fragment2 + ...Fragment3 + } + + fragment Fragment1 on Test { + id + } + + fragment Fragment2 on Test { + id + } + + fragment Fragment3 on Test { + id + } + `; + const expected = stripWhitespace(` + query Test { + ... on Test { + id + } + ... on Test { + id + } + ... on Test { + id + } + } + `); + const expectedWithSchema = stripWhitespace(` + query Test { + id + } + `); + expect(parseMergeAndPrint(query)).to.equal(expected); + expect(parseMergeAndPrint(query, schema)).to.equal(expectedWithSchema); + }); + + it('removes duplicate fragment spreads', () => { + const query = ` + query Test { + ...Fragment1 + ...Fragment1 + } + + fragment Fragment1 on Test { + id + } + `; + const expected = stripWhitespace(` + query Test { + ... on Test { + id + } + } + `); + const expectedWithSchema = stripWhitespace(/* GraphQL */ ` + query Test { + id + } + `); + expect(parseMergeAndPrint(query)).to.equal(expected); + expect(parseMergeAndPrint(query, schema)).to.equal(expectedWithSchema); + }); +}); + +function parseMergeAndPrint(query: string, maybeSchema?: GraphQLSchema) { + return stripWhitespace(print(mergeAST(parse(query), maybeSchema))); +} + +function stripWhitespace(str: string) { + return str.replace(/\s/g, ''); +} diff --git a/src/utilities/mergeAST.ts b/src/utilities/mergeAST.ts new file mode 100644 index 0000000000..173de7e9d7 --- /dev/null +++ b/src/utilities/mergeAST.ts @@ -0,0 +1,204 @@ +import type { Maybe } from '../jsutils/Maybe'; + +import type { + DocumentNode, + FieldNode, + FragmentDefinitionNode, + SelectionNode, +} from '../language/ast'; +import { Kind } from '../language/kinds'; +import type { ASTVisitor } from '../language/visitor'; +import { visit } from '../language/visitor'; + +import type { GraphQLOutputType } from '../type/definition'; +import { getNamedType, isNamedType } from '../type/definition'; +import type { GraphQLSchema } from '../type/schema'; + +import { TypeInfo, visitWithTypeInfo } from './TypeInfo'; + +export function mergeAST( + documentAST: DocumentNode, + schema?: GraphQLSchema | null, +): DocumentNode { + // If we're given the schema, we can simplify even further by resolving object + // types vs unions/interfaces + const typeInfo = schema ? new TypeInfo(schema) : null; + + const fragmentDefinitions: { + [key: string]: FragmentDefinitionNode | undefined; + } = Object.create(null); + + for (const definition of documentAST.definitions) { + if (definition.kind === Kind.FRAGMENT_DEFINITION) { + fragmentDefinitions[definition.name.value] = + definition; + } + } + + const flattenVisitors: ASTVisitor = { + SelectionSet(node: any) { + const selectionSetType: Maybe = + typeInfo?.getParentType(); + let { selections } = node; + + selections = selectionSetType + ? inlineRelevantFragmentSpreads( + fragmentDefinitions, + selections, + selectionSetType, + ) + : inlineRelevantFragmentSpreads( + fragmentDefinitions, + selections, + ); + + return { + ...node, + selections, + }; + }, + FragmentDefinition() { + return null; + }, + }; + + const flattenedAST = visit( + documentAST, + typeInfo + ? visitWithTypeInfo(typeInfo, flattenVisitors) + : flattenVisitors, + ); + + const deduplicateVisitors: ASTVisitor = { + SelectionSet(node: any) { + let { selections } = node; + + selections = uniqueBy(selections, (selection) => + selection.alias + ? selection.alias.value + : selection.name.value, + ); + + return { + ...node, + selections, + }; + }, + FragmentDefinition() { + return null; + }, + }; + + return visit(flattenedAST, deduplicateVisitors); +} + +function inlineRelevantFragmentSpreads( + fragmentDefinitions: { + [key: string]: FragmentDefinitionNode | undefined; + }, + selections: ReadonlyArray, + selectionSetType?: GraphQLOutputType, +): ReadonlyArray { + // const selectionSetTypeName = selectionSetType + // ? getNamedType(selectionSetType).name + // : null; + + let selectionSetTypeName = null; + if (selectionSetType) { + const typ = getNamedType(selectionSetType); + if (isNamedType(typ)) { + selectionSetTypeName = typ.name; + } + } + + const outputSelections = []; + const seenSpreads: Array = []; + for (let selection of selections) { + if (selection.kind === 'FragmentSpread') { + const fragmentName = selection.name.value; + if ( + !selection.directives || + selection.directives.length === 0 + ) { + if (seenSpreads.includes(fragmentName)) { + /* It's a duplicate - skip it! */ + continue; + } else { + seenSpreads.push(fragmentName); + } + } + const fragmentDefinition = + fragmentDefinitions[selection.name.value]; + if (fragmentDefinition) { + const { typeCondition, directives, selectionSet } = + fragmentDefinition; + selection = { + kind: Kind.INLINE_FRAGMENT, + typeCondition, + directives, + selectionSet, + }; + } + } + if ( + selection.kind === Kind.INLINE_FRAGMENT && + // Cannot inline if there are directives + (!selection.directives || + selection.directives?.length === 0) + ) { + const fragmentTypeName = selection.typeCondition + ? selection.typeCondition.name.value + : null; + if ( + !fragmentTypeName || + fragmentTypeName === selectionSetTypeName + ) { + outputSelections.push( + ...inlineRelevantFragmentSpreads( + fragmentDefinitions, + selection.selectionSet.selections, + selectionSetType, + ), + ); + continue; + } + } + outputSelections.push(selection); + } + return outputSelections; +} + +function uniqueBy( + array: ReadonlyArray, + iteratee: (item: FieldNode) => T, +) { + const FilteredMap = new Map(); + const result: Array = []; + for (const item of array) { + if (item.kind === 'Field') { + const uniqueValue = iteratee(item); + const existing = FilteredMap.get(uniqueValue); + if (item.directives?.length) { + // Cannot inline fields with directives (yet) + const itemClone = { ...item }; + result.push(itemClone); + } else if ( + existing?.selectionSet && + item.selectionSet + ) { + // Merge the selection sets + existing.selectionSet.selections = [ + ...existing.selectionSet.selections, + ...item.selectionSet.selections, + ]; + } else if (!existing) { + const itemClone = { ...item }; + FilteredMap.set(uniqueValue, itemClone); + result.push(itemClone); + } + } else { + result.push(item); + } + } + return result; +}