graphql 2.6.3 → 2.6.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/lib/generators/graphql/field_extractor.rb +17 -1
  3. data/lib/generators/graphql/templates/schema.erb +2 -1
  4. data/lib/graphql/analysis/query_complexity.rb +2 -2
  5. data/lib/graphql/dashboard/application_controller.rb +5 -1
  6. data/lib/graphql/dashboard.rb +3 -0
  7. data/lib/graphql/dataloader/async_dataloader.rb +333 -68
  8. data/lib/graphql/dataloader/source.rb +32 -19
  9. data/lib/graphql/dataloader.rb +52 -36
  10. data/lib/graphql/execution/field_resolve_step.rb +79 -19
  11. data/lib/graphql/execution/finalize.rb +7 -8
  12. data/lib/graphql/execution/interpreter/runtime.rb +1 -8
  13. data/lib/graphql/execution/interpreter.rb +9 -1
  14. data/lib/graphql/execution/prepare_object_step.rb +8 -4
  15. data/lib/graphql/execution/runner.rb +17 -5
  16. data/lib/graphql/execution/selections_step.rb +10 -6
  17. data/lib/graphql/execution_error.rb +4 -0
  18. data/lib/graphql/float_decoding_error.rb +13 -0
  19. data/lib/graphql/float_encoding_error.rb +28 -0
  20. data/lib/graphql/language/block_string.rb +6 -11
  21. data/lib/graphql/language/cache.rb +84 -14
  22. data/lib/graphql/language/lexer.rb +15 -9
  23. data/lib/graphql/language/nodes.rb +2 -1
  24. data/lib/graphql/language.rb +3 -3
  25. data/lib/graphql/pagination/relation_connection.rb +10 -1
  26. data/lib/graphql/query/variable_validation_error.rb +16 -1
  27. data/lib/graphql/query/variables.rb +1 -1
  28. data/lib/graphql/railtie.rb +2 -1
  29. data/lib/graphql/schema/build_from_definition.rb +11 -3
  30. data/lib/graphql/schema/directive.rb +3 -0
  31. data/lib/graphql/schema/input_object.rb +2 -2
  32. data/lib/graphql/schema/resolver.rb +1 -1
  33. data/lib/graphql/schema/wrapper.rb +4 -0
  34. data/lib/graphql/schema.rb +5 -5
  35. data/lib/graphql/static_validation/rules/fields_will_merge.rb +66 -29
  36. data/lib/graphql/subscriptions/serialize.rb +10 -4
  37. data/lib/graphql/types/float.rb +18 -4
  38. data/lib/graphql/version.rb +1 -1
  39. data/lib/graphql.rb +2 -0
  40. metadata +4 -58
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+ module GraphQL
3
+ # This error is raised when `Types::Float` is given a non-finite input value.
4
+ class FloatDecodingError < GraphQL::RuntimeTypeError
5
+ # The value which couldn't be decoded
6
+ attr_reader :float_value
7
+
8
+ def initialize(value)
9
+ @float_value = value
10
+ super("Float is not finite: #{value.inspect}.")
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+ module GraphQL
3
+ # This error is raised when `Types::Float` is asked to return a non-finite value.
4
+ class FloatEncodingError < GraphQL::RuntimeTypeError
5
+ # The value which couldn't be encoded
6
+ attr_reader :float_value
7
+
8
+ # @return [GraphQL::Schema::Field] The field that returned a non-finite float
9
+ attr_reader :field
10
+
11
+ # @return [Array<String, Integer>] Where the field appeared in the GraphQL response
12
+ attr_reader :path
13
+
14
+ def initialize(value, context:)
15
+ @float_value = value
16
+ @field = context[:current_field]
17
+ @path = context[:current_path]
18
+ message = "Float is not finite: #{value.inspect}".dup
19
+ if @path
20
+ message << " @ #{@path.join(".")}"
21
+ end
22
+ if @field
23
+ message << " (#{@field.path})"
24
+ end
25
+ super("#{message}.")
26
+ end
27
+ end
28
+ end
@@ -21,17 +21,12 @@ module GraphQL
21
21
  next
22
22
  end
23
23
  line_length = line.size
24
- line_indent = if line.match?(/\A [^ ]/)
25
- 2
26
- elsif line.match?(/\A [^ ]/)
27
- 4
28
- elsif line.match?(/\A[^ ]/)
29
- 0
30
- else
31
- line[/\A */].size
24
+ leading = 0
25
+ while leading < line_length && line.getbyte(leading) == 32
26
+ leading += 1
32
27
  end
33
- if line_indent < line_length && (common_indent.nil? || line_indent < common_indent)
34
- common_indent = line_indent
28
+ if leading < line_length && (common_indent.nil? || leading < common_indent)
29
+ common_indent = leading
35
30
  end
36
31
  end
37
32
 
@@ -41,7 +36,7 @@ module GraphQL
41
36
  if idx == 0
42
37
  next
43
38
  else
44
- line.slice!(0, common_indent)
39
+ line[0, common_indent] = ""
45
40
  end
46
41
  end
47
42
  end
@@ -2,6 +2,9 @@
2
2
 
3
3
  require 'graphql/version'
4
4
  require 'digest/sha2'
5
+ require 'openssl'
6
+ require 'securerandom'
7
+ require 'tempfile'
5
8
 
6
9
  module GraphQL
7
10
  module Language
@@ -9,7 +12,11 @@ module GraphQL
9
12
  #
10
13
  # With Rails, parser caching may enabled by setting `config.graphql.parser_cache = true` in your Rails application.
11
14
  #
12
- # The cache may be manually built by assigning `GraphQL::Language::Parser.cache = GraphQL::Language::Cache.new("some_dir")`.
15
+ # The cache may be manually built by assigning `GraphQL::Language::Parser.cache = GraphQL::Language::Cache.new(Pathname.new("some_dir"), secret: ENV.fetch("GRAPHQL_CACHE_SECRET"))`.
16
+ # The `secret` should be a stable value stored outside of the cache directory.
17
+ # When it isn't provided, a process-local secret is generated and cache entries
18
+ # are rebuilt after the process restarts. Pass `secret: nil` to disable cache
19
+ # signing. This should only be used when the cache directory is trusted.
13
20
  # This will create a directory (`tmp/cache/graphql` by default) that stores a cache of parsed files.
14
21
  #
15
22
  # Much like [bootsnap](https://github.com/Shopify/bootsnap), the parser cache needs to be cleaned up manually.
@@ -18,32 +25,95 @@ module GraphQL
18
25
  #
19
26
  # @see GraphQL::Railtie for simple Rails integration
20
27
  class Cache
21
- def initialize(path)
28
+ # @param path [Pathname] The directory where cache entries are stored.
29
+ # @param secret [String, nil] A stable secret for verifying cache entries. When omitted,
30
+ # a process-local secret is generated. Pass `nil` to disable cache signing.
31
+ def initialize(path, secret: SecureRandom.random_bytes(32))
22
32
  @path = path
33
+ @secret = secret
23
34
  end
24
35
 
25
36
  DIGEST = Digest::SHA256.new << GraphQL::VERSION
37
+ HMAC_SIZE = OpenSSL::Digest::SHA256.new.digest_length
38
+ InvalidCache = Class.new(StandardError)
39
+ private_constant :InvalidCache
26
40
 
27
41
  def fetch(filename)
28
- hash = DIGEST.dup << filename
42
+ cache_key = cache_key_for(filename)
43
+ return yield unless cache_key
44
+
45
+ cache_path = @path.join(cache_key)
46
+
47
+ begin
48
+ return load_cache(cache_path, cache_key) if cache_path.file?
49
+ rescue InvalidCache, SystemCallError
50
+ # Rebuild caches created by older versions or with an invalid signature.
51
+ end
52
+
53
+ payload = yield
29
54
  begin
30
- hash << File.mtime(filename).to_i.to_s
55
+ write_cache(cache_path, cache_key, payload)
31
56
  rescue SystemCallError
32
- return yield
57
+ # Parser caching is best-effort; return the parsed payload if the cache cannot be written.
33
58
  end
34
- cache_path = @path.join(hash.to_s)
59
+ payload
60
+ end
61
+
62
+ private
63
+
64
+ def cache_key_for(filename)
65
+ content_digest = Digest::SHA256.file(filename).hexdigest
66
+ (DIGEST.dup << filename << content_digest).to_s
67
+ rescue SystemCallError
68
+ nil
69
+ end
70
+
71
+ def load_cache(cache_path, cache_key)
72
+ cache_data = cache_path.binread
73
+ return Marshal.load(cache_data) unless @secret
74
+
75
+ signature = cache_data.byteslice(0, HMAC_SIZE)
76
+ payload = cache_data.byteslice(HMAC_SIZE..-1)
77
+ raise InvalidCache unless signature && payload
35
78
 
36
- if cache_path.exist?
37
- Marshal.load(cache_path.read)
79
+ expected_signature = signature_for(cache_key, payload)
80
+ unless secure_compare(signature, expected_signature)
81
+ raise InvalidCache
82
+ end
83
+ Marshal.load(payload)
84
+ end
85
+
86
+ def write_cache(cache_path, cache_key, payload)
87
+ @path.mkpath
88
+ serialized_payload = Marshal.dump(payload)
89
+ cache_data = if @secret
90
+ signature_for(cache_key, serialized_payload) + serialized_payload
38
91
  else
39
- payload = yield
40
- tmp_path = "#{cache_path}.#{rand}"
92
+ serialized_payload
93
+ end
94
+
95
+ Tempfile.create(['graphql-cache-', '.tmp'], @path.to_s) do |tempfile|
96
+ tempfile.binmode
97
+ tempfile.write(cache_data)
98
+ tempfile.flush
99
+ tempfile.fsync
100
+ tempfile.close
101
+ File.rename(tempfile.path, cache_path.to_s)
102
+ end
103
+ end
104
+
105
+ def signature_for(cache_key, payload)
106
+ OpenSSL::HMAC.digest('SHA256', @secret, cache_key + payload)
107
+ end
108
+
109
+ def secure_compare(left, right)
110
+ return false unless left.bytesize == right.bytesize
41
111
 
42
- @path.mkpath
43
- File.binwrite(tmp_path, Marshal.dump(payload))
44
- File.rename(tmp_path, cache_path.to_s)
45
- payload
112
+ result = 0
113
+ left.bytes.each_with_index do |byte, index|
114
+ result |= byte ^ right.getbyte(index)
46
115
  end
116
+ result.zero?
47
117
  end
48
118
  end
49
119
  end
@@ -9,6 +9,9 @@ module GraphQL
9
9
  end
10
10
  @string = graphql_str
11
11
  @filename = filename
12
+ if !@string.valid_encoding?
13
+ raise_parse_error("Parse error on bad Unicode escape sequence", nil, nil)
14
+ end
12
15
  @scanner = StringScanner.new(graphql_str)
13
16
  @pos = nil
14
17
  @max_tokens = max_tokens || Float::INFINITY
@@ -110,10 +113,6 @@ module GraphQL
110
113
  @scanner.pos += 1
111
114
  :UNKNOWN_CHAR
112
115
  end
113
- rescue ArgumentError => err
114
- if err.message == "invalid byte sequence in UTF-8"
115
- raise_parse_error("Parse error on bad Unicode escape sequence", nil, nil)
116
- end
117
116
  end
118
117
 
119
118
  def token_value
@@ -147,7 +146,7 @@ module GraphQL
147
146
  "\\r" => "\r",
148
147
  "\\t" => "\t",
149
148
  }
150
- UTF_8 = /\\u(?:([\dAa-f]{4})|\{([\da-f]{4,})\})(?:\\u([\dAa-f]{4}))?/i
149
+ UTF_8 = /\\u(?:([\da-f]{4})|\{([\da-f]+)\})(?:\\u([\da-f]{4}))?/i
151
150
  VALID_STRING = /\A(?:[^\\]|#{ESCAPES}|#{UTF_8})*\z/o
152
151
  ESCAPED = /(?:#{ESCAPES}|#{UTF_8})/o
153
152
 
@@ -163,7 +162,11 @@ module GraphQL
163
162
  if !str.valid_encoding? || !str.match?(VALID_STRING)
164
163
  raise_parse_error("Bad unicode escape in #{str.inspect}")
165
164
  else
166
- Lexer.replace_escaped_characters_in_place(str)
165
+ begin
166
+ Lexer.replace_escaped_characters_in_place(str)
167
+ rescue RangeError
168
+ raise_parse_error("Bad unicode escape in #{str.inspect}")
169
+ end
167
170
 
168
171
  if !str.valid_encoding?
169
172
  raise_parse_error("Bad unicode escape in #{str.inspect}")
@@ -175,11 +178,14 @@ module GraphQL
175
178
  end
176
179
 
177
180
  def line_number
178
- @scanner.string[0..@pos].count("\n") + 1
181
+ @scanner.string.byteslice(0, @pos).b.count("\n".b) + 1
179
182
  end
180
183
 
181
184
  def column_number
182
- @scanner.string[0..@pos].split("\n").last.length
185
+ line_prefix = @scanner.string.byteslice(0, @pos)
186
+ line_prefix = line_prefix.b unless line_prefix.valid_encoding?
187
+ newline_index = line_prefix.rindex("\n")
188
+ newline_index ? line_prefix.length - newline_index : line_prefix.length + 1
183
189
  end
184
190
 
185
191
  def raise_parse_error(message, line = line_number, col = column_number)
@@ -286,7 +292,7 @@ module GraphQL
286
292
  QUOTE = '"'
287
293
  UNICODE_DIGIT = /[0-9A-Za-z]/
288
294
  FOUR_DIGIT_UNICODE = /#{UNICODE_DIGIT}{4}/
289
- N_DIGIT_UNICODE = %r{#{Punctuation::LCURLY}#{UNICODE_DIGIT}{4,}#{Punctuation::RCURLY}}x
295
+ N_DIGIT_UNICODE = %r{#{Punctuation::LCURLY}#{UNICODE_DIGIT}+#{Punctuation::RCURLY}}x
290
296
  UNICODE_ESCAPE = %r{\\u(?:#{FOUR_DIGIT_UNICODE}|#{N_DIGIT_UNICODE})}
291
297
  STRING_ESCAPE = %r{[\\][\\/bfnrt]}
292
298
  BLOCK_QUOTE = '"""'
@@ -304,7 +304,8 @@ module GraphQL
304
304
  children_method_names.map { |m| "#{m}: NO_CHILDREN" } +
305
305
  DEFAULT_INITIALIZE_OPTIONS
306
306
 
307
- assignments = scalar_method_names.map { |m| "@#{m} = #{m}"} +
307
+ # Intern descriptions so identical strings across SDL documents share one frozen object.
308
+ assignments = scalar_method_names.map { |m| m == :description ? "@#{m} = #{m} && -#{m}" : "@#{m} = #{m}" } +
308
309
  children_method_names.map { |m| "@#{m} = #{m}.freeze" }
309
310
 
310
311
  if name.end_with?("Definition") && name != "FragmentDefinition"
@@ -31,11 +31,11 @@ module GraphQL
31
31
 
32
32
  "[#{serialized_array}]"
33
33
  else
34
- JSON.generate(value, quirks_mode: true)
34
+ JSON.generate(value)
35
35
  end
36
36
  rescue JSON::GeneratorError
37
- if Float::INFINITY == value
38
- "Infinity"
37
+ if value.is_a?(Float) && !value.finite?
38
+ value.to_s
39
39
  else
40
40
  raise
41
41
  end
@@ -221,7 +221,16 @@ module GraphQL
221
221
  # returns an array of nodes
222
222
  def load_nodes
223
223
  # Return an array so we can consistently use `.index(node)` on it
224
- @nodes ||= limited_nodes.to_a
224
+ return @nodes if @nodes
225
+ if (@context[:dataloader].is_a?(GraphQL::Dataloader::AsyncDataloader))
226
+ # `AsyncDataloader` may resolve sibling fields (eg, `edges` and `pageInfo`)
227
+ # in separate Fibers, so several callers can get here before `@nodes` is set.
228
+ (@load_lock ||= Mutex.new).synchronize do
229
+ @nodes ||= limited_nodes.to_a
230
+ end
231
+ else
232
+ @nodes = limited_nodes.to_a
233
+ end
225
234
  end
226
235
  end
227
236
  end
@@ -22,7 +22,7 @@ module GraphQL
22
22
  # It is possible there are other extension items in this error, so handle
23
23
  # a one level deep merge explicitly. However beyond that only show the
24
24
  # latest value and problems.
25
- super.merge({ "extensions" => { "value" => value, "problems" => validation_result.problems }}) do |key, oldValue, newValue|
25
+ super.merge({ "extensions" => { "value" => value_for_extensions, "problems" => validation_result.problems }}) do |key, oldValue, newValue|
26
26
  if oldValue.respond_to?(:merge)
27
27
  oldValue.merge(newValue)
28
28
  else
@@ -33,6 +33,21 @@ module GraphQL
33
33
 
34
34
  private
35
35
 
36
+ def value_for_extensions(value = @value)
37
+ case value
38
+ when Array
39
+ value.map { |item| value_for_extensions(item) }
40
+ when Hash
41
+ value.each_with_object({}) do |(key, item), result|
42
+ result[key] = value_for_extensions(item)
43
+ end
44
+ when Float
45
+ value.finite? ? value : value.to_s
46
+ else
47
+ value
48
+ end
49
+ end
50
+
36
51
  def problem_fields
37
52
  @problem_fields ||= @validation_result
38
53
  .problems
@@ -19,7 +19,7 @@ module GraphQL
19
19
  @storage = ast_variables.each_with_object({}) do |ast_variable, memo|
20
20
  if schema.validate_max_errors && schema.validate_max_errors <= @errors.count
21
21
  add_max_errors_reached_message
22
- break
22
+ break memo
23
23
  end
24
24
  # Find the right value for this variable:
25
25
  # - First, use the value provided at runtime
@@ -15,7 +15,8 @@ module GraphQL
15
15
  initializer("graphql.cache") do |app|
16
16
  if config.graphql.parser_cache
17
17
  Language::Parser.cache ||= Language::Cache.new(
18
- app.root.join("tmp/cache/graphql")
18
+ app.root.join("tmp/cache/graphql"),
19
+ secret: app.secret_key_base
19
20
  )
20
21
  end
21
22
  end
@@ -82,6 +82,7 @@ module GraphQL
82
82
  replace_late_bound_types_with_built_in(types)
83
83
 
84
84
  schema_extensions = nil
85
+ definitions_by_name = nil
85
86
  document.definitions.each do |definition|
86
87
  case definition
87
88
  when GraphQL::Language::Nodes::SchemaDefinition, GraphQL::Language::Nodes::DirectiveDefinition
@@ -101,9 +102,16 @@ module GraphQL
101
102
  if prev_type.nil? || prev_type.is_a?(Schema::LateBoundType)
102
103
  if definition.is_a?(GraphQL::Language::Nodes::ObjectTypeDefinition) || definition.is_a?(Language::Nodes::InterfaceTypeDefinition)
103
104
  interface_names = definition.interfaces.map(&:name)
104
- transitive_names = interface_names.map { |n| document.definitions.find { |d| d.respond_to?(:name) && d.name == n }&.interfaces&.map(&:name) }
105
- transitive_names.flatten!
106
- transitive_names.compact!
105
+ if !interface_names.empty?
106
+ definitions_by_name ||= document.definitions.each_with_object({}) do |d, by_name|
107
+ by_name[d.name] ||= d if d.respond_to?(:name)
108
+ end
109
+ transitive_names = interface_names.map { |n| definitions_by_name[n]&.interfaces&.map(&:name) }
110
+ transitive_names.flatten!
111
+ transitive_names.compact!
112
+ else
113
+ transitive_names = interface_names
114
+ end
107
115
  if !(missing_transitive_interfaces = transitive_names - interface_names).empty?
108
116
  raise GraphQL::Schema::InvalidDocumentError, "type #{definition.name} is missing one or more transitive interface names: #{missing_transitive_interfaces.join(", ")}. Add them to the type's `implements` list and try again."
109
117
  end
@@ -168,6 +168,9 @@ module GraphQL
168
168
  # Let validation handle this
169
169
  value
170
170
  end
171
+ elsif arg_defn.default_value?
172
+ value = arg_defn.default_value
173
+ graphql_value = arg_type.coerce_isolated_result(value) unless value.nil?
171
174
  else
172
175
  value = graphql_value = nil
173
176
  end
@@ -172,12 +172,12 @@ module GraphQL
172
172
  types = ctx.types
173
173
 
174
174
  if input.is_a?(Array)
175
- return GraphQL::Query::InputValidationResult.from_problem(INVALID_OBJECT_MESSAGE % { object: JSON.generate(input, quirks_mode: true) })
175
+ return GraphQL::Query::InputValidationResult.from_problem(INVALID_OBJECT_MESSAGE % { object: JSON.generate(input) })
176
176
  end
177
177
 
178
178
  if !(input.respond_to?(:to_h) || input.respond_to?(:to_unsafe_h))
179
179
  # We're not sure it'll act like a hash, so reject it:
180
- return GraphQL::Query::InputValidationResult.from_problem(INVALID_OBJECT_MESSAGE % { object: JSON.generate(input, quirks_mode: true) })
180
+ return GraphQL::Query::InputValidationResult.from_problem(INVALID_OBJECT_MESSAGE % { object: JSON.generate(input) })
181
181
  end
182
182
 
183
183
 
@@ -83,7 +83,7 @@ module GraphQL
83
83
  is_authed, new_return_value = authorized?(**@prepared_arguments)
84
84
  rescue GraphQL::UnauthorizedError => err
85
85
  new_return_value = q.schema.unauthorized_object(err)
86
- is_authed = true # the error was handled
86
+ is_authed = false
87
87
  end
88
88
  end
89
89
 
@@ -25,6 +25,10 @@ module GraphQL
25
25
  def ==(other)
26
26
  self.class == other.class && of_type == other.of_type
27
27
  end
28
+
29
+ def deconstruct_keys(_keys)
30
+ { of_type: of_type }
31
+ end
28
32
  end
29
33
  end
30
34
  end
@@ -665,8 +665,8 @@ module GraphQL
665
665
  inherited_um = find_inherited_value(:union_memberships, EMPTY_HASH).fetch(type.graphql_name, EMPTY_ARRAY)
666
666
  own_um + inherited_um
667
667
  else
668
- joined_um = own_union_memberships.dup
669
- find_inherited_value(:union_memberhips, EMPTY_HASH).each do |k, v|
668
+ joined_um = own_union_memberships.transform_values(&:dup)
669
+ find_inherited_value(:union_memberships, EMPTY_HASH).each do |k, v|
670
670
  um = joined_um[k] ||= []
671
671
  um.concat(v)
672
672
  end
@@ -860,7 +860,7 @@ module GraphQL
860
860
  # @return [Array<GraphQL::StaticValidation::Error >]
861
861
  def validate(string_or_document, rules: nil, context: nil)
862
862
  doc = if string_or_document.is_a?(String)
863
- GraphQL.parse(string_or_document)
863
+ GraphQL.parse(string_or_document, max_tokens: max_query_string_tokens)
864
864
  else
865
865
  string_or_document
866
866
  end
@@ -1338,9 +1338,9 @@ module GraphQL
1338
1338
 
1339
1339
  context.errors << execution_error
1340
1340
  execution_error
1341
- when GraphQL::UnresolvedTypeError, GraphQL::StringEncodingError, GraphQL::IntegerEncodingError
1341
+ when GraphQL::UnresolvedTypeError, GraphQL::StringEncodingError, GraphQL::FloatEncodingError, GraphQL::IntegerEncodingError
1342
1342
  raise type_error
1343
- when GraphQL::IntegerDecodingError
1343
+ when GraphQL::FloatDecodingError, GraphQL::IntegerDecodingError
1344
1344
  nil
1345
1345
  end
1346
1346
  end
@@ -15,6 +15,8 @@ module GraphQL
15
15
  # separately (which leads to exponential recursion through nested fragments),
16
16
  # we flatten all fragment spreads into a single field map and compare within it.
17
17
  NO_ARGS = GraphQL::EmptyObjects::EMPTY_HASH
18
+ EXCLUSIVE_COMPARISON = 1
19
+ NONEXCLUSIVE_COMPARISON = 2
18
20
 
19
21
  class Field
20
22
  attr_reader :node, :definition, :owner_type, :parents
@@ -33,6 +35,10 @@ module GraphQL
33
35
  def unwrapped_return_type
34
36
  @unwrapped_return_type ||= return_type&.unwrap
35
37
  end
38
+
39
+ def comparison_key
40
+ @comparison_key ||= [@node, @parents]
41
+ end
36
42
  end
37
43
 
38
44
  def initialize(*)
@@ -43,6 +49,9 @@ module GraphQL
43
49
  # Track which sub-selection node pairs have been compared to prevent
44
50
  # infinite recursion with cyclic fragments
45
51
  @compared_sub_selections = {}.compare_by_identity
52
+ @compared_field_groups = {}
53
+ @field_group_signatures = {}.compare_by_identity
54
+ @field_selection_signatures = {}.compare_by_identity
46
55
  # Cache mutually_exclusive? results for type pairs
47
56
  @mutually_exclusive_cache = {}.compare_by_identity
48
57
  # Cache collect_fields results for sub-selection comparison
@@ -215,21 +224,7 @@ module GraphQL
215
224
  end
216
225
 
217
226
  if all_same
218
- # All fields share a signature, so they can only conflict on
219
- # sub-selections. Deduplicate by AST node identity — fields from
220
- # the same node always have identical sub-selections.
221
- unique_nodes = fields.uniq { |f| f.node.object_id }
222
- i = 0
223
- while i < unique_nodes.size
224
- j = i + 1
225
- while j < unique_nodes.size
226
- if unique_nodes[i].node.selections.size > 0 || unique_nodes[j].node.selections.size > 0
227
- find_conflict(key, unique_nodes[i], unique_nodes[j])
228
- end
229
- j += 1
230
- end
231
- i += 1
232
- end
227
+ find_conflicts_between_selection_groups(key, fields)
233
228
  else
234
229
  groups = fields.group_by { |f| field_signature(f) }
235
230
  unique_groups = groups.values
@@ -243,22 +238,10 @@ module GraphQL
243
238
  gj += 1
244
239
  end
245
240
 
246
- # Within same group, deduplicate by AST node and compare all
247
- # pairs for sub-selection conflicts
241
+ # Within the same group, fields can only conflict on sub-selections.
248
242
  group = unique_groups[gi]
249
243
  if group.size >= 2
250
- unique_in_group = group.uniq { |f| f.node.object_id }
251
- ui = 0
252
- while ui < unique_in_group.size
253
- uj = ui + 1
254
- while uj < unique_in_group.size
255
- if unique_in_group[ui].node.selections.size > 0 || unique_in_group[uj].node.selections.size > 0
256
- find_conflict(key, unique_in_group[ui], unique_in_group[uj])
257
- end
258
- uj += 1
259
- end
260
- ui += 1
261
- end
244
+ find_conflicts_between_selection_groups(key, group)
262
245
  end
263
246
 
264
247
  gi += 1
@@ -279,6 +262,29 @@ module GraphQL
279
262
  end
280
263
  end
281
264
 
265
+ def find_conflicts_between_selection_groups(response_key, fields)
266
+ fields_by_selection = {}
267
+ fields.each do |field|
268
+ fields_by_selection[field_selection_signature(field)] ||= field
269
+ end
270
+
271
+ representatives = fields_by_selection.values
272
+ i = 0
273
+ while i < representatives.size
274
+ j = i + 1
275
+ while j < representatives.size
276
+ find_conflict(response_key, representatives[i], representatives[j])
277
+ j += 1
278
+ end
279
+ i += 1
280
+ end
281
+ end
282
+
283
+ def field_selection_signature(field)
284
+ node = field.node
285
+ @field_selection_signatures[node] ||= node.selections.map(&:to_query_string)
286
+ end
287
+
282
288
  def fields_same_signature?(f1, f2)
283
289
  n1 = f1.node
284
290
  n2 = f2.node
@@ -458,6 +464,7 @@ module GraphQL
458
464
  response_keys.each do |key, fields|
459
465
  fields2 = response_keys2[key]
460
466
  next unless fields2
467
+ next if field_groups_already_compared?(fields, fields2, mutually_exclusive)
461
468
 
462
469
  fields_arr = fields.is_a?(Field) ? [fields] : fields
463
470
  fields2_arr = fields2.is_a?(Field) ? [fields2] : fields2
@@ -475,6 +482,36 @@ module GraphQL
475
482
  end
476
483
  end
477
484
 
485
+ def field_groups_already_compared?(fields, fields2, mutually_exclusive)
486
+ signature1 = field_group_signature(fields)
487
+ signature2 = field_group_signature(fields2)
488
+ previous_comparisons = @compared_field_groups[signature1]
489
+ comparison_state = previous_comparisons && previous_comparisons[signature2]
490
+
491
+ if mutually_exclusive
492
+ return true if comparison_state
493
+ new_state = EXCLUSIVE_COMPARISON
494
+ else
495
+ return true if comparison_state == NONEXCLUSIVE_COMPARISON
496
+ new_state = NONEXCLUSIVE_COMPARISON
497
+ end
498
+
499
+ previous_comparisons ||= (@compared_field_groups[signature1] = {})
500
+ previous_comparisons[signature2] = new_state
501
+
502
+ reverse_comparisons = @compared_field_groups[signature2] ||= {}
503
+ reverse_comparisons[signature1] = new_state
504
+ false
505
+ end
506
+
507
+ def field_group_signature(fields)
508
+ if fields.is_a?(Field)
509
+ fields.comparison_key
510
+ else
511
+ @field_group_signatures[fields] ||= fields.map(&:comparison_key)
512
+ end
513
+ end
514
+
478
515
  def same_arguments?(field1, field2)
479
516
  arguments1 = field1.arguments
480
517
  arguments2 = field2.arguments