Skip to content

Commit cbcac61

Browse files
author
Robert Mosolgo
authored
Merge pull request #1824 from xuorig/fields-will-merge-ast
Fields will merge to AST visitor
2 parents 52ddbbf + 345ecd4 commit cbcac61

File tree

4 files changed

+444
-43
lines changed

4 files changed

+444
-43
lines changed

lib/graphql/static_validation/rules/fields_will_merge.rb

Lines changed: 335 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,355 @@
1-
# frozen_string_literal: true
1+
# frozen_string_literal: true
22
module GraphQL
33
module StaticValidation
44
module FieldsWillMerge
5-
# Special handling for fields without arguments
5+
# Validates that a selection set is valid if all fields (including spreading any
6+
# fragments) either correspond to distinct response names or can be merged
7+
# without ambiguity.
8+
#
9+
# Original Algorithm: https://github.com/graphql/graphql-js/blob/master/src/validation/rules/OverlappingFieldsCanBeMerged.js
610
NO_ARGS = {}.freeze
11+
Field = Struct.new(:node, :definition, :parents)
12+
FragmentSpread = Struct.new(:name, :parents)
713

814
def initialize(*)
915
super
16+
@visited_fragments = {}
17+
@compared_fragments = {}
18+
end
19+
20+
def on_operation_definition(node, _parent)
21+
conflicts_within_selection_set(node, type_definition)
22+
super
23+
end
24+
25+
def on_field(node, _parent)
26+
conflicts_within_selection_set(node, type_definition)
27+
super
28+
end
29+
30+
private
31+
32+
def conflicts_within_selection_set(node, parent_type)
33+
return if parent_type.nil?
34+
35+
fields, fragment_spreads = fields_and_fragments_from_selection(node, parents: [parent_type])
36+
37+
# (A) Find find all conflicts "within" the fields of this selection set.
38+
find_conflicts_within(fields)
39+
40+
fragment_spreads.each_with_index do |fragment_spread, i|
41+
are_mutually_exclusive = mutually_exclusive?(
42+
fragment_spread.parents,
43+
[parent_type]
44+
)
45+
46+
# (B) Then find conflicts between these fields and those represented by
47+
# each spread fragment name found.
48+
find_conflicts_between_fields_and_fragment(
49+
fragment_spread,
50+
fields,
51+
mutually_exclusive: are_mutually_exclusive,
52+
)
53+
54+
# (C) Then compare this fragment with all other fragments found in this
55+
# selection set to collect conflicts between fragments spread together.
56+
# This compares each item in the list of fragment names to every other
57+
# item in that same list (except for itself).
58+
fragment_spreads[i + 1..-1].each do |fragment_spread2|
59+
are_mutually_exclusive = mutually_exclusive?(
60+
fragment_spread.parents,
61+
fragment_spread2.parents
62+
)
63+
64+
find_conflicts_between_fragments(
65+
fragment_spread,
66+
fragment_spread2,
67+
mutually_exclusive: are_mutually_exclusive,
68+
)
69+
end
70+
end
71+
end
72+
73+
def find_conflicts_between_fragments(fragment_spread1, fragment_spread2, mutually_exclusive:)
74+
fragment_name1 = fragment_spread1.name
75+
fragment_name2 = fragment_spread2.name
76+
return if fragment_name1 == fragment_name2
77+
78+
cache_key = compared_fragments_key(
79+
fragment_name1,
80+
fragment_name2,
81+
mutually_exclusive,
82+
)
83+
if @compared_fragments.key?(cache_key)
84+
return
85+
else
86+
@compared_fragments[cache_key] = true
87+
end
88+
89+
fragment1 = context.fragments[fragment_name1]
90+
fragment2 = context.fragments[fragment_name2]
1091

11-
context.each_irep_node do |node|
12-
if node.ast_nodes.size > 1
13-
defn_names = Set.new(node.ast_nodes.map(&:name))
92+
return if fragment1.nil? || fragment2.nil?
1493

15-
# Check for more than one GraphQL::Field backing this node:
16-
if defn_names.size > 1
17-
defn_names = defn_names.sort.join(" or ")
18-
msg = "Field '#{node.name}' has a field conflict: #{defn_names}?"
19-
context.errors << GraphQL::StaticValidation::Message.new(msg, nodes: node.ast_nodes.to_a)
94+
fragment_type1 = context.schema.types[fragment1.type.name]
95+
fragment_type2 = context.schema.types[fragment2.type.name]
96+
97+
return if fragment_type1.nil? || fragment_type2.nil?
98+
99+
fragment_fields1, fragment_spreads1 = fields_and_fragments_from_selection(
100+
fragment1,
101+
parents: [*fragment_spread1.parents, fragment_type1]
102+
)
103+
fragment_fields2, fragment_spreads2 = fields_and_fragments_from_selection(
104+
fragment2,
105+
parents: [*fragment_spread2.parents, fragment_type2]
106+
)
107+
108+
# (F) First, find all conflicts between these two collections of fields
109+
# (not including any nested fragments).
110+
find_conflicts_between(
111+
fragment_fields1,
112+
fragment_fields2,
113+
mutually_exclusive: mutually_exclusive,
114+
)
115+
116+
# (G) Then collect conflicts between the first fragment and any nested
117+
# fragments spread in the second fragment.
118+
fragment_spreads2.each do |fragment_spread|
119+
find_conflicts_between_fragments(
120+
fragment_spread1,
121+
fragment_spread,
122+
mutually_exclusive: mutually_exclusive,
123+
)
124+
end
125+
126+
# (G) Then collect conflicts between the first fragment and any nested
127+
# fragments spread in the second fragment.
128+
fragment_spreads1.each do |fragment_spread|
129+
find_conflicts_between_fragments(
130+
fragment_spread2,
131+
fragment_spread,
132+
mutually_exclusive: mutually_exclusive,
133+
)
134+
end
135+
end
136+
137+
def find_conflicts_between_fields_and_fragment(fragment_spread, fields, mutually_exclusive:)
138+
fragment_name = fragment_spread.name
139+
return if @visited_fragments.key?(fragment_name)
140+
@visited_fragments[fragment_name] = true
141+
142+
fragment = context.fragments[fragment_name]
143+
return if fragment.nil?
144+
145+
fragment_type = context.schema.types[fragment.type.name]
146+
return if fragment_type.nil?
147+
148+
fragment_fields, fragment_spreads = fields_and_fragments_from_selection(fragment, parents: [*fragment_spread.parents, fragment_type])
149+
150+
# (D) First find any conflicts between the provided collection of fields
151+
# and the collection of fields represented by the given fragment.
152+
find_conflicts_between(
153+
fields,
154+
fragment_fields,
155+
mutually_exclusive: mutually_exclusive,
156+
)
157+
158+
# (E) Then collect any conflicts between the provided collection of fields
159+
# and any fragment names found in the given fragment.
160+
fragment_spreads.each do |fragment_spread|
161+
find_conflicts_between_fields_and_fragment(
162+
fragment_spread,
163+
fields,
164+
mutually_exclusive: mutually_exclusive,
165+
)
166+
end
167+
end
168+
169+
def find_conflicts_within(response_keys)
170+
response_keys.each do |key, fields|
171+
next if fields.size < 2
172+
# find conflicts within nodes
173+
for i in 0..fields.size - 1
174+
for j in i + 1..fields.size - 1
175+
find_conflict(key, fields[i], fields[j])
20176
end
177+
end
178+
end
179+
end
21180

22-
# Check for incompatible / non-identical arguments on this node:
23-
args = node.ast_nodes.map do |n|
24-
if n.arguments.any?
25-
n.arguments.reduce({}) do |memo, a|
26-
arg_value = a.value
27-
memo[a.name] = case arg_value
28-
when GraphQL::Language::Nodes::AbstractNode
29-
arg_value.to_query_string
30-
else
31-
GraphQL::Language.serialize(arg_value)
32-
end
33-
memo
34-
end
35-
else
36-
NO_ARGS
181+
def find_conflict(response_key, field1, field2, mutually_exclusive: false)
182+
node1 = field1.node
183+
node2 = field2.node
184+
185+
are_mutually_exclusive = mutually_exclusive ||
186+
mutually_exclusive?(field1.parents, field2.parents)
187+
188+
if !are_mutually_exclusive
189+
if node1.name != node2.name
190+
errored_nodes = [node1.name, node2.name].sort.join(" or ")
191+
msg = "Field '#{response_key}' has a field conflict: #{errored_nodes}?"
192+
context.errors << GraphQL::StaticValidation::Message.new(msg, nodes: [node1, node2])
193+
end
194+
195+
args = possible_arguments(node1, node2)
196+
if args.size > 1
197+
msg = "Field '#{response_key}' has an argument conflict: #{args.map { |arg| GraphQL::Language.serialize(arg) }.join(" or ")}?"
198+
context.errors << GraphQL::StaticValidation::Message.new(msg, nodes: [node1, node2])
199+
end
200+
end
201+
202+
find_conflicts_between_sub_selection_sets(
203+
field1,
204+
field2,
205+
mutually_exclusive: are_mutually_exclusive,
206+
)
207+
end
208+
209+
def find_conflicts_between_sub_selection_sets(field1, field2, mutually_exclusive:)
210+
return if field1.definition.nil? || field2.definition.nil?
211+
212+
return_type1 = field1.definition.type.unwrap
213+
return_type2 = field2.definition.type.unwrap
214+
parents1 = [*field1.parents, return_type1]
215+
parents2 = [*field2.parents, return_type2]
216+
217+
fields, fragment_spreads = fields_and_fragments_from_selection(
218+
field1.node,
219+
parents: parents1
220+
)
221+
222+
fields2, fragment_spreads2 = fields_and_fragments_from_selection(
223+
field2.node,
224+
parents: parents2
225+
)
226+
227+
# (H) First, collect all conflicts between these two collections of field.
228+
find_conflicts_between(fields, fields2, mutually_exclusive: mutually_exclusive)
229+
230+
# (I) Then collect conflicts between the first collection of fields and
231+
# those referenced by each fragment name associated with the second.
232+
fragment_spreads2.each do |fragment_spread|
233+
find_conflicts_between_fields_and_fragment(
234+
fragment_spread,
235+
fields,
236+
mutually_exclusive: mutually_exclusive,
237+
)
238+
end
239+
240+
# (I) Then collect conflicts between the second collection of fields and
241+
# those referenced by each fragment name associated with the first.
242+
fragment_spreads.each do |fragment_spread|
243+
find_conflicts_between_fields_and_fragment(
244+
fragment_spread,
245+
fields2,
246+
mutually_exclusive: mutually_exclusive,
247+
)
248+
end
249+
250+
# (J) Also collect conflicts between any fragment names by the first and
251+
# fragment names by the second. This compares each item in the first set of
252+
# names to each item in the second set of names.
253+
fragment_spreads.each do |frag1|
254+
fragment_spreads2.each do |frag2|
255+
find_conflicts_between_fragments(
256+
frag1,
257+
frag2,
258+
mutually_exclusive: mutually_exclusive
259+
)
260+
end
261+
end
262+
end
263+
264+
def find_conflicts_between(response_keys, response_keys2, mutually_exclusive:)
265+
response_keys.each do |key, fields|
266+
fields2 = response_keys2[key]
267+
if fields2
268+
fields.each do |field|
269+
fields2.each do |field2|
270+
find_conflict(
271+
key,
272+
field,
273+
field2,
274+
mutually_exclusive: mutually_exclusive,
275+
)
37276
end
38277
end
39-
args.uniq!
278+
end
279+
end
280+
end
40281

41-
if args.length > 1
42-
msg = "Field '#{node.name}' has an argument conflict: #{args.map{ |arg| GraphQL::Language.serialize(arg) }.join(" or ")}?"
43-
context.errors << GraphQL::StaticValidation::Message.new(msg, nodes: node.ast_nodes.to_a)
282+
def fields_and_fragments_from_selection(node, parents:)
283+
fields, fragment_spreads = find_fields_and_fragments(node.selections, parents: parents)
284+
response_keys = fields.group_by { |f| f.node.alias || f.node.name }
285+
[response_keys, fragment_spreads]
286+
end
287+
288+
def find_fields_and_fragments(selections, parents:, fields: [], fragment_spreads: [])
289+
selections.each do |node|
290+
case node
291+
when GraphQL::Language::Nodes::Field
292+
definition = context.schema.get_field(parents.last, node.name)
293+
fields << Field.new(node, definition, parents)
294+
when GraphQL::Language::Nodes::InlineFragment
295+
fragment_type = node.type ? context.schema.types[node.type.name] : parents.last
296+
find_fields_and_fragments(node.selections, parents: [*parents, fragment_type], fields: fields, fragment_spreads: fragment_spreads) if fragment_type
297+
when GraphQL::Language::Nodes::FragmentSpread
298+
fragment_spreads << FragmentSpread.new(node.name, parents)
299+
end
300+
end
301+
302+
[fields, fragment_spreads]
303+
end
304+
305+
def possible_arguments(field1, field2)
306+
# Check for incompatible / non-identical arguments on this node:
307+
[field1, field2].map do |n|
308+
if n.arguments.any?
309+
n.arguments.reduce({}) do |memo, a|
310+
arg_value = a.value
311+
memo[a.name] = case arg_value
312+
when GraphQL::Language::Nodes::AbstractNode
313+
arg_value.to_query_string
314+
else
315+
GraphQL::Language.serialize(arg_value)
316+
end
317+
memo
44318
end
319+
else
320+
NO_ARGS
321+
end
322+
end.uniq
323+
end
324+
325+
def compared_fragments_key(frag1, frag2, exclusive)
326+
# Cache key to not compare two fragments more than once.
327+
# The key includes both fragment names sorted (this way we
328+
# avoid computing "A vs B" and "B vs A"). It also includes
329+
# "exclusive" since the result may change depending on the parent_type
330+
"#{[frag1, frag2].sort.join('-')}-#{exclusive}"
331+
end
332+
333+
# Given two list of parents, find out if they are mutually exclusive
334+
def mutually_exclusive?(parents1, parents2)
335+
i = 0
336+
j = 0
337+
338+
while i <= parents1.size - 1 && j <= parents2.size - 1 do
339+
type1 = parents1[i]
340+
type2 = parents2[j]
341+
342+
# If the types we're comparing are both different object types,
343+
# they have to be mutually exclusive.
344+
if type1 != type2 && type1.kind.object? && type2.kind.object?
345+
return true
45346
end
347+
348+
i = i + 1 if i <= parents1.size - 1
349+
j = j + 1 if j <= parents2.size - 1
46350
end
351+
352+
false
47353
end
48354
end
49355
end

0 commit comments

Comments
 (0)