Skip to content

Commit 1697428

Browse files
Test Userclaude
andcommitted
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 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 49450d8 commit 1697428

File tree

4 files changed

+604
-0
lines changed

4 files changed

+604
-0
lines changed

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,8 @@ export {
452452
coerceInputValue,
453453
// Concatenates multiple AST together.
454454
concatAST,
455+
// Merges multiple AST documents, combining selection sets and deduplicating fields.
456+
mergeAST,
455457
// Separates an AST into an AST per Operation.
456458
separateOperations,
457459
// Strips characters that are not significant to the validity or execution of a GraphQL document.
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
import { expect } from 'chai';
2+
import { describe, it } from 'mocha';
3+
4+
import { dedent } from '../../__testUtils__/dedent';
5+
6+
import { parse } from '../../language/parser';
7+
import { print } from '../../language/printer';
8+
9+
import { mergeAST } from '../mergeAST';
10+
11+
describe('mergeAST', () => {
12+
it('merges two simple queries', () => {
13+
const docA = parse('{ a, b }');
14+
const docB = parse('{ c, d }');
15+
16+
expect(print(mergeAST([docA, docB]))).to.equal(dedent`
17+
{
18+
a
19+
b
20+
c
21+
d
22+
}
23+
`);
24+
});
25+
26+
it('deduplicates identical fields', () => {
27+
const docA = parse('{ a, b }');
28+
const docB = parse('{ b, c }');
29+
30+
expect(print(mergeAST([docA, docB]))).to.equal(dedent`
31+
{
32+
a
33+
b
34+
c
35+
}
36+
`);
37+
});
38+
39+
it('recursively merges nested selection sets', () => {
40+
const docA = parse('{ user { name } }');
41+
const docB = parse('{ user { email } }');
42+
43+
expect(print(mergeAST([docA, docB]))).to.equal(dedent`
44+
{
45+
user {
46+
name
47+
email
48+
}
49+
}
50+
`);
51+
});
52+
53+
it('deeply merges nested selection sets', () => {
54+
const docA = parse('{ user { profile { name } } }');
55+
const docB = parse('{ user { profile { avatar } } }');
56+
57+
expect(print(mergeAST([docA, docB]))).to.equal(dedent`
58+
{
59+
user {
60+
profile {
61+
name
62+
avatar
63+
}
64+
}
65+
}
66+
`);
67+
});
68+
69+
it('does not merge fields with different arguments', () => {
70+
const docA = parse('{ user(id: 1) { name } }');
71+
const docB = parse('{ user(id: 2) { name } }');
72+
73+
expect(print(mergeAST([docA, docB]))).to.equal(dedent`
74+
{
75+
user(id: 1) {
76+
name
77+
}
78+
user(id: 2) {
79+
name
80+
}
81+
}
82+
`);
83+
});
84+
85+
it('merges fields with same arguments', () => {
86+
const docA = parse('{ user(id: 1) { name } }');
87+
const docB = parse('{ user(id: 1) { email } }');
88+
89+
expect(print(mergeAST([docA, docB]))).to.equal(dedent`
90+
{
91+
user(id: 1) {
92+
name
93+
email
94+
}
95+
}
96+
`);
97+
});
98+
99+
it('handles aliased fields', () => {
100+
const docA = parse('{ myUser: user { name } }');
101+
const docB = parse('{ myUser: user { email } }');
102+
103+
expect(print(mergeAST([docA, docB]))).to.equal(dedent`
104+
{
105+
myUser: user {
106+
name
107+
email
108+
}
109+
}
110+
`);
111+
});
112+
113+
it('does not merge different aliases for the same field', () => {
114+
const docA = parse('{ a: user { name } }');
115+
const docB = parse('{ b: user { name } }');
116+
117+
expect(print(mergeAST([docA, docB]))).to.equal(dedent`
118+
{
119+
a: user {
120+
name
121+
}
122+
b: user {
123+
name
124+
}
125+
}
126+
`);
127+
});
128+
129+
it('merges named operations of the same type', () => {
130+
const docA = parse('query GetUser { user { name } }');
131+
const docB = parse('query GetUser { user { email } }');
132+
133+
expect(print(mergeAST([docA, docB]))).to.equal(dedent`
134+
query GetUser {
135+
user {
136+
name
137+
email
138+
}
139+
}
140+
`);
141+
});
142+
143+
it('keeps separate operations with different names', () => {
144+
const docA = parse('query GetUser { user { name } }');
145+
const docB = parse('query GetPosts { posts { title } }');
146+
147+
const result = print(mergeAST([docA, docB]));
148+
expect(result).to.equal(dedent`
149+
query GetUser {
150+
user {
151+
name
152+
}
153+
}
154+
155+
query GetPosts {
156+
posts {
157+
title
158+
}
159+
}
160+
`);
161+
});
162+
163+
it('keeps separate operations with different types', () => {
164+
const docA = parse('query { user { name } }');
165+
const docB = parse('mutation { createUser { id } }');
166+
167+
const result = print(mergeAST([docA, docB]));
168+
expect(result).to.equal(dedent`
169+
{
170+
user {
171+
name
172+
}
173+
}
174+
175+
mutation {
176+
createUser {
177+
id
178+
}
179+
}
180+
`);
181+
});
182+
183+
it('merges variable definitions and deduplicates', () => {
184+
const docA = parse('query GetUser($id: ID!) { user(id: $id) { name } }');
185+
const docB = parse(
186+
'query GetUser($id: ID!, $includeEmail: Boolean!) { user(id: $id) { email @include(if: $includeEmail) } }',
187+
);
188+
189+
const result = print(mergeAST([docA, docB]));
190+
expect(result).to.equal(dedent`
191+
query GetUser($id: ID!, $includeEmail: Boolean!) {
192+
user(id: $id) {
193+
name
194+
email @include(if: $includeEmail)
195+
}
196+
}
197+
`);
198+
});
199+
200+
it('deduplicates fragment definitions', () => {
201+
const docA = parse(`
202+
{ ...UserFields }
203+
fragment UserFields on User { name }
204+
`);
205+
const docB = parse(`
206+
{ ...UserFields }
207+
fragment UserFields on User { name }
208+
`);
209+
210+
const result = print(mergeAST([docA, docB]));
211+
expect(result).to.equal(dedent`
212+
{
213+
...UserFields
214+
}
215+
216+
fragment UserFields on User {
217+
name
218+
}
219+
`);
220+
});
221+
222+
it('merges inline fragments with the same type condition', () => {
223+
const docA = parse('{ ... on User { name } }');
224+
const docB = parse('{ ... on User { email } }');
225+
226+
expect(print(mergeAST([docA, docB]))).to.equal(dedent`
227+
{
228+
... on User {
229+
name
230+
email
231+
}
232+
}
233+
`);
234+
});
235+
236+
it('keeps separate inline fragments with different type conditions', () => {
237+
const docA = parse('{ ... on User { name } }');
238+
const docB = parse('{ ... on Post { title } }');
239+
240+
expect(print(mergeAST([docA, docB]))).to.equal(dedent`
241+
{
242+
... on User {
243+
name
244+
}
245+
... on Post {
246+
title
247+
}
248+
}
249+
`);
250+
});
251+
252+
it('handles merging more than two documents', () => {
253+
const docA = parse('{ a }');
254+
const docB = parse('{ b }');
255+
const docC = parse('{ c }');
256+
257+
expect(print(mergeAST([docA, docB, docC]))).to.equal(dedent`
258+
{
259+
a
260+
b
261+
c
262+
}
263+
`);
264+
});
265+
266+
it('returns empty document for empty array', () => {
267+
const result = mergeAST([]);
268+
expect(result.definitions).to.deep.equal([]);
269+
});
270+
271+
it('returns equivalent document for single document', () => {
272+
const doc = parse('{ a, b }');
273+
expect(print(mergeAST([doc]))).to.equal(print(doc));
274+
});
275+
276+
it('handles the use case from the issue: adding required fields', () => {
277+
// Client queries avgRating, resolver needs ratings { stars } to compute it
278+
const clientQuery = parse(`
279+
{
280+
home {
281+
avgRating
282+
address
283+
}
284+
}
285+
`);
286+
287+
const requiredFields = parse(`
288+
{
289+
home {
290+
ratings {
291+
stars
292+
}
293+
}
294+
}
295+
`);
296+
297+
expect(print(mergeAST([clientQuery, requiredFields]))).to.equal(dedent`
298+
{
299+
home {
300+
avgRating
301+
address
302+
ratings {
303+
stars
304+
}
305+
}
306+
}
307+
`);
308+
});
309+
310+
it('deduplicates fragment spreads', () => {
311+
const docA = parse('{ ...Frag, a }');
312+
const docB = parse('{ ...Frag, b }');
313+
314+
expect(print(mergeAST([docA, docB]))).to.equal(dedent`
315+
{
316+
...Frag
317+
a
318+
b
319+
}
320+
`);
321+
});
322+
});

src/utilities/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ export { coerceInputValue } from './coerceInputValue';
7676
// Concatenates multiple AST together.
7777
export { concatAST } from './concatAST';
7878

79+
// Merges multiple AST documents, combining selection sets and deduplicating fields.
80+
export { mergeAST } from './mergeAST';
81+
7982
// Separates an AST into an AST per Operation.
8083
export { separateOperations } from './separateOperations';
8184

0 commit comments

Comments
 (0)