Skip to content

Commit 9ffd3d4

Browse files
committed
feat(mergeAST): add mergeAST utilility
1 parent 6b253e7 commit 9ffd3d4

File tree

2 files changed

+386
-0
lines changed

2 files changed

+386
-0
lines changed
+182
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import { expect } from 'chai';
2+
import { describe, it } from 'mocha';
3+
4+
import { parse } from '../../language/parser';
5+
import { print } from '../../language/printer';
6+
7+
import { GraphQLInt, GraphQLObjectType, GraphQLString } from '../../type/definition';
8+
import { GraphQLSchema } from '../../type/schema';
9+
10+
import { mergeAST } from '../mergeAST';
11+
12+
const schema = new GraphQLSchema({
13+
query: new GraphQLObjectType({
14+
name: 'Test',
15+
fields: {
16+
id: {
17+
type: GraphQLInt,
18+
},
19+
name: {
20+
type: GraphQLString,
21+
},
22+
},
23+
}),
24+
});
25+
26+
describe.only('mergeAST', () => {
27+
it('does not modify query with no fragments', () => {
28+
const query = `
29+
query Test {
30+
id
31+
}
32+
`;
33+
const expected = stripWhitespace(`
34+
query Test {
35+
id
36+
}
37+
`);
38+
expect(parseMergeAndPrint(query)).to.equal(expected);
39+
expect(parseMergeAndPrint(query, schema)).to.equal(expected);
40+
});
41+
42+
it('does inline simple nested fragment', () => {
43+
const query = /* GraphQL */ `
44+
query Test {
45+
...Fragment1
46+
}
47+
48+
fragment Fragment1 on Test {
49+
id
50+
}
51+
`;
52+
const expected = stripWhitespace(/* GraphQL */ `
53+
query Test {
54+
... on Test {
55+
id
56+
}
57+
}
58+
`);
59+
const expectedWithSchema = stripWhitespace(/* GraphQL */ `
60+
query Test {
61+
id
62+
}
63+
`);
64+
expect(parseMergeAndPrint(query)).to.equal(expected);
65+
expect(parseMergeAndPrint(query, schema)).to.equal(expectedWithSchema);
66+
});
67+
68+
it('does inline triple nested fragment', () => {
69+
const query = `
70+
query Test {
71+
...Fragment1
72+
}
73+
74+
fragment Fragment1 on Test {
75+
...Fragment2
76+
}
77+
78+
fragment Fragment2 on Test {
79+
...Fragment3
80+
}
81+
82+
fragment Fragment3 on Test {
83+
id
84+
}
85+
`;
86+
const expected = stripWhitespace(`
87+
query Test {
88+
... on Test {
89+
... on Test {
90+
... on Test {
91+
id
92+
}
93+
}
94+
}
95+
}
96+
`);
97+
const expectedWithSchema = stripWhitespace(/* GraphQL */ `
98+
query Test {
99+
id
100+
}
101+
`);
102+
expect(parseMergeAndPrint(query)).to.equal(expected);
103+
expect(parseMergeAndPrint(query, schema)).to.equal(expectedWithSchema);
104+
});
105+
106+
it('does inline multiple fragments', () => {
107+
const query = `
108+
query Test {
109+
...Fragment1
110+
...Fragment2
111+
...Fragment3
112+
}
113+
114+
fragment Fragment1 on Test {
115+
id
116+
}
117+
118+
fragment Fragment2 on Test {
119+
id
120+
}
121+
122+
fragment Fragment3 on Test {
123+
id
124+
}
125+
`;
126+
const expected = stripWhitespace(`
127+
query Test {
128+
... on Test {
129+
id
130+
}
131+
... on Test {
132+
id
133+
}
134+
... on Test {
135+
id
136+
}
137+
}
138+
`);
139+
const expectedWithSchema = stripWhitespace(`
140+
query Test {
141+
id
142+
}
143+
`);
144+
expect(parseMergeAndPrint(query)).to.equal(expected);
145+
expect(parseMergeAndPrint(query, schema)).to.equal(expectedWithSchema);
146+
});
147+
148+
it('removes duplicate fragment spreads', () => {
149+
const query = `
150+
query Test {
151+
...Fragment1
152+
...Fragment1
153+
}
154+
155+
fragment Fragment1 on Test {
156+
id
157+
}
158+
`;
159+
const expected = stripWhitespace(`
160+
query Test {
161+
... on Test {
162+
id
163+
}
164+
}
165+
`);
166+
const expectedWithSchema = stripWhitespace(/* GraphQL */ `
167+
query Test {
168+
id
169+
}
170+
`);
171+
expect(parseMergeAndPrint(query)).to.equal(expected);
172+
expect(parseMergeAndPrint(query, schema)).to.equal(expectedWithSchema);
173+
});
174+
});
175+
176+
function parseMergeAndPrint(query: string, maybeSchema?: GraphQLSchema) {
177+
return stripWhitespace(print(mergeAST(parse(query), maybeSchema)));
178+
}
179+
180+
function stripWhitespace(str: string) {
181+
return str.replace(/\s/g, '');
182+
}

src/utilities/mergeAST.ts

+204
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import type { Maybe } from '../jsutils/Maybe';
2+
3+
import type {
4+
DocumentNode,
5+
FieldNode,
6+
FragmentDefinitionNode,
7+
SelectionNode,
8+
} from '../language/ast';
9+
import { Kind } from '../language/kinds';
10+
import type { ASTVisitor } from '../language/visitor';
11+
import { visit } from '../language/visitor';
12+
13+
import type { GraphQLOutputType } from '../type/definition';
14+
import { getNamedType, isNamedType } from '../type/definition';
15+
import type { GraphQLSchema } from '../type/schema';
16+
17+
import { TypeInfo, visitWithTypeInfo } from './TypeInfo';
18+
19+
export function mergeAST(
20+
documentAST: DocumentNode,
21+
schema?: GraphQLSchema | null,
22+
): DocumentNode {
23+
// If we're given the schema, we can simplify even further by resolving object
24+
// types vs unions/interfaces
25+
const typeInfo = schema ? new TypeInfo(schema) : null;
26+
27+
const fragmentDefinitions: {
28+
[key: string]: FragmentDefinitionNode | undefined;
29+
} = Object.create(null);
30+
31+
for (const definition of documentAST.definitions) {
32+
if (definition.kind === Kind.FRAGMENT_DEFINITION) {
33+
fragmentDefinitions[definition.name.value] =
34+
definition;
35+
}
36+
}
37+
38+
const flattenVisitors: ASTVisitor = {
39+
SelectionSet(node: any) {
40+
const selectionSetType: Maybe<GraphQLOutputType> =
41+
typeInfo?.getParentType();
42+
let { selections } = node;
43+
44+
selections = selectionSetType
45+
? inlineRelevantFragmentSpreads(
46+
fragmentDefinitions,
47+
selections,
48+
selectionSetType,
49+
)
50+
: inlineRelevantFragmentSpreads(
51+
fragmentDefinitions,
52+
selections,
53+
);
54+
55+
return {
56+
...node,
57+
selections,
58+
};
59+
},
60+
FragmentDefinition() {
61+
return null;
62+
},
63+
};
64+
65+
const flattenedAST = visit(
66+
documentAST,
67+
typeInfo
68+
? visitWithTypeInfo(typeInfo, flattenVisitors)
69+
: flattenVisitors,
70+
);
71+
72+
const deduplicateVisitors: ASTVisitor = {
73+
SelectionSet(node: any) {
74+
let { selections } = node;
75+
76+
selections = uniqueBy(selections, (selection) =>
77+
selection.alias
78+
? selection.alias.value
79+
: selection.name.value,
80+
);
81+
82+
return {
83+
...node,
84+
selections,
85+
};
86+
},
87+
FragmentDefinition() {
88+
return null;
89+
},
90+
};
91+
92+
return visit(flattenedAST, deduplicateVisitors);
93+
}
94+
95+
function inlineRelevantFragmentSpreads(
96+
fragmentDefinitions: {
97+
[key: string]: FragmentDefinitionNode | undefined;
98+
},
99+
selections: ReadonlyArray<SelectionNode>,
100+
selectionSetType?: GraphQLOutputType,
101+
): ReadonlyArray<SelectionNode> {
102+
// const selectionSetTypeName = selectionSetType
103+
// ? getNamedType(selectionSetType).name
104+
// : null;
105+
106+
let selectionSetTypeName = null;
107+
if (selectionSetType) {
108+
const typ = getNamedType(selectionSetType);
109+
if (isNamedType(typ)) {
110+
selectionSetTypeName = typ.name;
111+
}
112+
}
113+
114+
const outputSelections = [];
115+
const seenSpreads: Array<string> = [];
116+
for (let selection of selections) {
117+
if (selection.kind === 'FragmentSpread') {
118+
const fragmentName = selection.name.value;
119+
if (
120+
!selection.directives ||
121+
selection.directives.length === 0
122+
) {
123+
if (seenSpreads.includes(fragmentName)) {
124+
/* It's a duplicate - skip it! */
125+
continue;
126+
} else {
127+
seenSpreads.push(fragmentName);
128+
}
129+
}
130+
const fragmentDefinition =
131+
fragmentDefinitions[selection.name.value];
132+
if (fragmentDefinition) {
133+
const { typeCondition, directives, selectionSet } =
134+
fragmentDefinition;
135+
selection = {
136+
kind: Kind.INLINE_FRAGMENT,
137+
typeCondition,
138+
directives,
139+
selectionSet,
140+
};
141+
}
142+
}
143+
if (
144+
selection.kind === Kind.INLINE_FRAGMENT &&
145+
// Cannot inline if there are directives
146+
(!selection.directives ||
147+
selection.directives?.length === 0)
148+
) {
149+
const fragmentTypeName = selection.typeCondition
150+
? selection.typeCondition.name.value
151+
: null;
152+
if (
153+
!fragmentTypeName ||
154+
fragmentTypeName === selectionSetTypeName
155+
) {
156+
outputSelections.push(
157+
...inlineRelevantFragmentSpreads(
158+
fragmentDefinitions,
159+
selection.selectionSet.selections,
160+
selectionSetType,
161+
),
162+
);
163+
continue;
164+
}
165+
}
166+
outputSelections.push(selection);
167+
}
168+
return outputSelections;
169+
}
170+
171+
function uniqueBy<T>(
172+
array: ReadonlyArray<SelectionNode>,
173+
iteratee: (item: FieldNode) => T,
174+
) {
175+
const FilteredMap = new Map<T, FieldNode>();
176+
const result: Array<SelectionNode> = [];
177+
for (const item of array) {
178+
if (item.kind === 'Field') {
179+
const uniqueValue = iteratee(item);
180+
const existing = FilteredMap.get(uniqueValue);
181+
if (item.directives?.length) {
182+
// Cannot inline fields with directives (yet)
183+
const itemClone = { ...item };
184+
result.push(itemClone);
185+
} else if (
186+
existing?.selectionSet &&
187+
item.selectionSet
188+
) {
189+
// Merge the selection sets
190+
existing.selectionSet.selections = [
191+
...existing.selectionSet.selections,
192+
...item.selectionSet.selections,
193+
];
194+
} else if (!existing) {
195+
const itemClone = { ...item };
196+
FilteredMap.set(uniqueValue, itemClone);
197+
result.push(itemClone);
198+
}
199+
} else {
200+
result.push(item);
201+
}
202+
}
203+
return result;
204+
}

0 commit comments

Comments
 (0)