Skip to content

feat(mergeAST): add mergeAST utility #4359

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: 16.x.x
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 182 additions & 0 deletions src/utilities/__tests__/mergeAST-test.ts
Original file line number Diff line number Diff line change
@@ -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, '');
}
204 changes: 204 additions & 0 deletions src/utilities/mergeAST.ts
Original file line number Diff line number Diff line change
@@ -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<GraphQLOutputType> =
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

Check warning on line 78 in src/utilities/mergeAST.ts

View check run for this annotation

Codecov / codecov/patch

src/utilities/mergeAST.ts#L78

Added line #L78 was not covered by tests
: selection.name.value,
);

return {
...node,
selections,
};
},
FragmentDefinition() {
return null;
},

Check warning on line 89 in src/utilities/mergeAST.ts

View check run for this annotation

Codecov / codecov/patch

src/utilities/mergeAST.ts#L88-L89

Added lines #L88 - L89 were not covered by tests
};

return visit(flattenedAST, deduplicateVisitors);
}

function inlineRelevantFragmentSpreads(
fragmentDefinitions: {
[key: string]: FragmentDefinitionNode | undefined;
},
selections: ReadonlyArray<SelectionNode>,
selectionSetType?: GraphQLOutputType,
): ReadonlyArray<SelectionNode> {
// 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<string> = [];
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;

Check warning on line 151 in src/utilities/mergeAST.ts

View check run for this annotation

Codecov / codecov/patch

src/utilities/mergeAST.ts#L150-L151

Added lines #L150 - L151 were not covered by tests
if (
!fragmentTypeName ||
fragmentTypeName === selectionSetTypeName
) {
outputSelections.push(
...inlineRelevantFragmentSpreads(
fragmentDefinitions,
selection.selectionSet.selections,
selectionSetType,
),
);
continue;
}
}
outputSelections.push(selection);
}
return outputSelections;
}

function uniqueBy<T>(
array: ReadonlyArray<SelectionNode>,
iteratee: (item: FieldNode) => T,
) {
const FilteredMap = new Map<T, FieldNode>();
const result: Array<SelectionNode> = [];
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);

Check warning on line 184 in src/utilities/mergeAST.ts

View check run for this annotation

Codecov / codecov/patch

src/utilities/mergeAST.ts#L182-L184

Added lines #L182 - L184 were not covered by tests
} else if (
existing?.selectionSet &&
item.selectionSet

Check warning on line 187 in src/utilities/mergeAST.ts

View check run for this annotation

Codecov / codecov/patch

src/utilities/mergeAST.ts#L187

Added line #L187 was not covered by tests
) {
// Merge the selection sets
existing.selectionSet.selections = [
...existing.selectionSet.selections,
...item.selectionSet.selections,
];

Check warning on line 193 in src/utilities/mergeAST.ts

View check run for this annotation

Codecov / codecov/patch

src/utilities/mergeAST.ts#L189-L193

Added lines #L189 - L193 were not covered by tests
} else if (!existing) {
const itemClone = { ...item };
FilteredMap.set(uniqueValue, itemClone);
result.push(itemClone);
}
} else {
result.push(item);
}
}
return result;
}
Loading