Skip to content

Commit 7278b7c

Browse files
committed
Merge pull request #231 from graphql/fast-cycle-detection
[Validation] perf improvements for fragment cycle detection
2 parents a738c6d + 4cf2190 commit 7278b7c

File tree

2 files changed

+117
-69
lines changed

2 files changed

+117
-69
lines changed

src/validation/__tests__/NoFragmentCycles.js

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -136,24 +136,29 @@ describe('Validate: No circular fragment spreads', () => {
136136
fragment fragX on Dog { ...fragY }
137137
fragment fragY on Dog { ...fragZ }
138138
fragment fragZ on Dog { ...fragO }
139-
fragment fragO on Dog { ...fragA, ...fragX }
139+
fragment fragO on Dog { ...fragP }
140+
fragment fragP on Dog { ...fragA, ...fragX }
140141
`, [
141-
{ message: cycleErrorMessage('fragA', [ 'fragB', 'fragC', 'fragO' ]),
142+
{ message:
143+
cycleErrorMessage('fragA', [ 'fragB', 'fragC', 'fragO', 'fragP' ]),
142144
locations: [
143145
{ line: 2, column: 31 },
144146
{ line: 3, column: 31 },
145147
{ line: 4, column: 31 },
146-
{ line: 8, column: 31 } ] },
147-
{ message: cycleErrorMessage('fragX', [ 'fragY', 'fragZ', 'fragO' ]),
148+
{ line: 8, column: 31 },
149+
{ line: 9, column: 31 } ] },
150+
{ message:
151+
cycleErrorMessage('fragO', [ 'fragP', 'fragX', 'fragY', 'fragZ' ]),
148152
locations: [
153+
{ line: 8, column: 31 },
154+
{ line: 9, column: 41 },
149155
{ line: 5, column: 31 },
150156
{ line: 6, column: 31 },
151-
{ line: 7, column: 31 },
152-
{ line: 8, column: 41 } ] }
157+
{ line: 7, column: 31 } ] }
153158
]);
154159
});
155160

156-
it('no spreading itself deeply two paths -- new rule', () => {
161+
it('no spreading itself deeply two paths', () => {
157162
expectFailsRule(NoFragmentCycles, `
158163
fragment fragA on Dog { ...fragB, ...fragC }
159164
fragment fragB on Dog { ...fragA }
@@ -166,4 +171,35 @@ describe('Validate: No circular fragment spreads', () => {
166171
]);
167172
});
168173

174+
it('no spreading itself deeply two paths -- alt traverse order', () => {
175+
expectFailsRule(NoFragmentCycles, `
176+
fragment fragA on Dog { ...fragC }
177+
fragment fragB on Dog { ...fragC }
178+
fragment fragC on Dog { ...fragA, ...fragB }
179+
`, [
180+
{ message: cycleErrorMessage('fragA', [ 'fragC' ]),
181+
locations: [ { line: 2, column: 31 }, { line: 4, column: 31 } ] },
182+
{ message: cycleErrorMessage('fragC', [ 'fragB' ]),
183+
locations: [ { line: 4, column: 41 }, { line: 3, column: 31 } ] }
184+
]);
185+
});
186+
187+
it('no spreading itself deeply and immediately', () => {
188+
expectFailsRule(NoFragmentCycles, `
189+
fragment fragA on Dog { ...fragB }
190+
fragment fragB on Dog { ...fragB, ...fragC }
191+
fragment fragC on Dog { ...fragA, ...fragB }
192+
`, [
193+
{ message: cycleErrorMessage('fragB', []),
194+
locations: [ { line: 3, column: 31 } ] },
195+
{ message: cycleErrorMessage('fragA', [ 'fragB', 'fragC' ]),
196+
locations: [
197+
{ line: 2, column: 31 },
198+
{ line: 3, column: 41 },
199+
{ line: 4, column: 31 } ] },
200+
{ message: cycleErrorMessage('fragB', [ 'fragC' ]),
201+
locations: [ { line: 3, column: 41 }, { line: 4, column: 41 } ] }
202+
]);
203+
});
204+
169205
});

src/validation/rules/NoFragmentCycles.js

Lines changed: 74 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,12 @@
1010

1111
import type { ValidationContext } from '../index';
1212
import { GraphQLError } from '../../error';
13-
import type { SelectionSet, FragmentSpread } from '../../language/ast';
14-
import { FRAGMENT_DEFINITION } from '../../language/kinds';
15-
import { visit } from '../../language/visitor';
13+
import { FRAGMENT_SPREAD } from '../../language/kinds';
14+
import type {
15+
SelectionSet,
16+
FragmentSpread,
17+
FragmentDefinition
18+
} from '../../language/ast';
1619

1720

1821
export function cycleErrorMessage(
@@ -24,83 +27,92 @@ export function cycleErrorMessage(
2427
}
2528

2629
export function NoFragmentCycles(context: ValidationContext): any {
30+
var errors = [];
2731

28-
// Gather all the fragment spreads ASTs for each fragment definition.
29-
// Importantly this does not include inline fragments.
30-
var definitions = context.getDocument().definitions;
31-
var spreadsInFragment = definitions.reduce((map, node) => {
32-
if (node.kind === FRAGMENT_DEFINITION) {
33-
map[node.name.value] = gatherSpreads(node);
34-
}
35-
return map;
36-
}, {});
32+
// Tracks already visited fragments to maintain O(N) and to ensure that cycles
33+
// are not redundantly reported.
34+
var visitedFrags = Object.create(null);
35+
36+
// Array of AST nodes used to produce meaningful errors
37+
var spreadPath = [];
3738

38-
// Tracks spreads known to lead to cycles to ensure that cycles are not
39-
// redundantly reported.
40-
var knownToLeadToCycle = new Set();
39+
// Position in the spread path
40+
var spreadPathIndexByName = Object.create(null);
4141

4242
return {
43+
Document: {
44+
leave() {
45+
if (errors.length) {
46+
return errors;
47+
}
48+
}
49+
},
50+
OperationDefinition: () => false,
4351
FragmentDefinition(node) {
44-
var errors = [];
45-
var initialName = node.name.value;
52+
if (!visitedFrags[node.name.value]) {
53+
detectCycleRecursive(node);
54+
}
55+
return false;
56+
},
57+
};
4658

47-
// Array of AST nodes used to produce meaningful errors
48-
var spreadPath = [];
59+
// This does a straight-forward DFS to find cycles.
60+
// It does not terminate when a cycle was found but continues to explore
61+
// the graph to find all possible cycles.
62+
function detectCycleRecursive(fragment: FragmentDefinition) {
63+
const fragmentName = fragment.name.value;
64+
visitedFrags[fragmentName] = true;
4965

50-
// This does a straight-forward DFS to find cycles.
51-
// It does not terminate when a cycle was found but continues to explore
52-
// the graph to find all possible cycles.
53-
function detectCycleRecursive(fragmentName) {
54-
var spreadNodes = spreadsInFragment[fragmentName];
55-
if (spreadNodes) {
56-
for (var i = 0; i < spreadNodes.length; ++i) {
57-
var spreadNode = spreadNodes[i];
58-
if (knownToLeadToCycle.has(spreadNode)) {
59-
continue;
60-
}
61-
if (spreadNode.name.value === initialName) {
62-
var cyclePath = spreadPath.concat(spreadNode);
63-
cyclePath.forEach(spread => knownToLeadToCycle.add(spread));
64-
errors.push(new GraphQLError(
65-
cycleErrorMessage(
66-
initialName,
67-
spreadPath.map(s => s.name.value)
68-
),
69-
cyclePath
70-
));
71-
continue;
72-
}
73-
if (spreadPath.some(spread => spread === spreadNode)) {
74-
continue;
75-
}
66+
const spreadNodes = [];
67+
gatherSpreads(spreadNodes, fragment.selectionSet);
68+
if (spreadNodes.length === 0) {
69+
return;
70+
}
71+
72+
spreadPathIndexByName[fragmentName] = spreadPath.length;
7673

77-
spreadPath.push(spreadNode);
78-
detectCycleRecursive(spreadNode.name.value);
79-
spreadPath.pop();
74+
for (let i = 0; i < spreadNodes.length; i++) {
75+
const spreadNode = spreadNodes[i];
76+
const spreadName = spreadNode.name.value;
77+
const cycleIndex = spreadPathIndexByName[spreadName];
78+
79+
if (cycleIndex === undefined) {
80+
spreadPath.push(spreadNode);
81+
if (!visitedFrags[spreadName]) {
82+
const spreadFragment = context.getFragment(spreadName);
83+
if (spreadFragment) {
84+
detectCycleRecursive(spreadFragment);
8085
}
8186
}
87+
spreadPath.pop();
88+
} else {
89+
const cyclePath = spreadPath.slice(cycleIndex);
90+
errors.push(new GraphQLError(
91+
cycleErrorMessage(
92+
spreadName,
93+
cyclePath.map(s => s.name.value)
94+
),
95+
cyclePath.concat(spreadNode)
96+
));
8297
}
98+
}
8399

84-
detectCycleRecursive(initialName);
85-
86-
if (errors.length > 0) {
87-
return errors;
88-
}
89-
},
90-
};
100+
spreadPathIndexByName[fragmentName] = undefined;
101+
}
91102
}
92103

93104
/**
94105
* Given an operation or fragment AST node, gather all the
95106
* named spreads defined within the scope of the fragment
96107
* or operation
97108
*/
98-
function gatherSpreads(node: SelectionSet): Array<FragmentSpread> {
99-
var spreadNodes = [];
100-
visit(node, {
101-
FragmentSpread(spread) {
102-
spreadNodes.push(spread);
109+
function gatherSpreads(spreads: Array<FragmentSpread>, node: SelectionSet) {
110+
for (let i = 0; i < node.selections.length; i++) {
111+
const selection = node.selections[i];
112+
if (selection.kind === FRAGMENT_SPREAD) {
113+
spreads.push(selection);
114+
} else if (selection.selectionSet) {
115+
gatherSpreads(spreads, selection.selectionSet);
103116
}
104-
});
105-
return spreadNodes;
117+
}
106118
}

0 commit comments

Comments
 (0)