graphql 2.5.2 → 2.5.3

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cff992b51cd3efae382a362bc5708e8dce5060efb48cf1944560262a59276283
4
- data.tar.gz: 206fd93c0504f9072f8af62a18557a9e0380664b1f8836acd38426ee44e9eaf7
3
+ metadata.gz: d2e4ec1acc9810683bb2628dc5403c74d729742612655f1be275267a89b107b3
4
+ data.tar.gz: fb092108f803095aea29b325859fc810feaa8e5fdb6c873c431f9824428fbc15
5
5
  SHA512:
6
- metadata.gz: 293369e23e4baee72a07481b6a6015be003e2557469ab3b30e1ee82d79bfaf6004218a5fe0a85033de8e92b3ae475508bf445e5906ec2a81c4df53c2b25add49
7
- data.tar.gz: fafe652c92d2655ad39a16fb61252b33b27eee4e10476a5cc40ab7fd3c1e81a56f9e67b81f6a9ba1da67ba76c7112e756973a236287c634bae9cf66fb7ed3e3e
6
+ metadata.gz: 603e3da13be680884399546074dae65f44635382e13311758e237f7973bbdd96f4a1a46f6b17476af77d88177f5e82f3c76b227633e2e15a9c909365a818f97e
7
+ data.tar.gz: d631c5a9e1c91fb815d98e1981641405c344b589c6e823ae5609b25fd819a2d5dc9032f366c94a3da69c56a48aa7d8e9ee082cbfa217807e6cbf313d16cbd598
@@ -13,7 +13,32 @@ module GraphQL
13
13
 
14
14
  # Override this method to use the complexity result
15
15
  def result
16
- max_possible_complexity
16
+ case subject.schema.complexity_cost_calculation_mode_for(subject.context)
17
+ when :future
18
+ max_possible_complexity
19
+ when :legacy
20
+ max_possible_complexity(mode: :legacy)
21
+ when :compare
22
+ future_complexity = max_possible_complexity
23
+ legacy_complexity = max_possible_complexity(mode: :legacy)
24
+ if future_complexity != legacy_complexity
25
+ subject.schema.legacy_complexity_cost_calculation_mismatch(subject, future_complexity, legacy_complexity)
26
+ else
27
+ future_complexity
28
+ end
29
+ when nil
30
+ subject.logger.warn <<~GRAPHQL
31
+ GraphQL-Ruby's complexity cost system is getting some "breaking fixes" in a future version. See the migration notes at https://graphql-ruby.org/api-docs/#{GraphQL::VERSION}/Schema.html#complexity_cost_cacluation_mode-class_method
32
+
33
+ To opt into the future behavior, configure your schema (#{subject.schema.name ? subject.schema.name : subject.schema.ancestors}) with:
34
+
35
+ complexity_cost_calculation_mode(:future) # or `:legacy`, `:compare`
36
+
37
+ GRAPHQL
38
+ max_possible_complexity
39
+ else
40
+ raise ArgumentError, "Expected `:future`, `:legacy`, `:compare`, or `nil` from `#{query.schema}.complexity_cost_calculation_mode_for` but got: #{query.schema.complexity_cost_calculation_mode.inspect}"
41
+ end
17
42
  end
18
43
 
19
44
  # ScopedTypeComplexity models a tree of GraphQL types mapped to inner selections, ie:
@@ -44,6 +69,10 @@ module GraphQL
44
69
  def own_complexity(child_complexity)
45
70
  @field_definition.calculate_complexity(query: @query, nodes: @nodes, child_complexity: child_complexity)
46
71
  end
72
+
73
+ def composite?
74
+ !empty?
75
+ end
47
76
  end
48
77
 
49
78
  def on_enter_field(node, parent, visitor)
@@ -77,16 +106,17 @@ module GraphQL
77
106
  private
78
107
 
79
108
  # @return [Integer]
80
- def max_possible_complexity
109
+ def max_possible_complexity(mode: :future)
81
110
  @complexities_on_type_by_query.reduce(0) do |total, (query, scopes_stack)|
82
- total + merged_max_complexity_for_scopes(query, [scopes_stack.first])
111
+ total + merged_max_complexity_for_scopes(query, [scopes_stack.first], mode)
83
112
  end
84
113
  end
85
114
 
86
115
  # @param query [GraphQL::Query] Used for `query.possible_types`
87
116
  # @param scopes [Array<ScopedTypeComplexity>] Array of scoped type complexities
117
+ # @param mode [:future, :legacy]
88
118
  # @return [Integer]
89
- def merged_max_complexity_for_scopes(query, scopes)
119
+ def merged_max_complexity_for_scopes(query, scopes, mode)
90
120
  # Aggregate a set of all possible scope types encountered (scope keys).
91
121
  # Use a hash, but ignore the values; it's just a fast way to work with the keys.
92
122
  possible_scope_types = scopes.each_with_object({}) do |scope, memo|
@@ -115,14 +145,20 @@ module GraphQL
115
145
  end
116
146
 
117
147
  # Find the maximum complexity for the scope type among possible lexical branches.
118
- complexity = merged_max_complexity(query, all_inner_selections)
148
+ complexity = case mode
149
+ when :legacy
150
+ legacy_merged_max_complexity(query, all_inner_selections)
151
+ when :future
152
+ merged_max_complexity(query, all_inner_selections)
153
+ else
154
+ raise ArgumentError, "Expected :legacy or :future, not: #{mode.inspect}"
155
+ end
119
156
  complexity > max ? complexity : max
120
157
  end
121
158
  end
122
159
 
123
160
  def types_intersect?(query, a, b)
124
161
  return true if a == b
125
-
126
162
  a_types = query.types.possible_types(a)
127
163
  query.types.possible_types(b).any? { |t| a_types.include?(t) }
128
164
  end
@@ -145,6 +181,50 @@ module GraphQL
145
181
  memo.merge!(inner_selection)
146
182
  end
147
183
 
184
+ # Add up the total cost for each unique field name's coalesced selections
185
+ unique_field_keys.each_key.reduce(0) do |total, field_key|
186
+ # Collect all child scopes for this field key;
187
+ # all keys come with at least one scope.
188
+ child_scopes = inner_selections.filter_map { _1[field_key] }
189
+
190
+ # Compute maximum possible cost of child selections;
191
+ # composites merge their maximums, while leaf scopes are always zero.
192
+ # FieldsWillMerge validation assures all scopes are uniformly composite or leaf.
193
+ maximum_children_cost = if child_scopes.any?(&:composite?)
194
+ merged_max_complexity_for_scopes(query, child_scopes, :future)
195
+ else
196
+ 0
197
+ end
198
+
199
+ # Identify the maximum cost and scope among possibilities
200
+ maximum_cost = 0
201
+ maximum_scope = child_scopes.reduce(child_scopes.last) do |max_scope, possible_scope|
202
+ scope_cost = possible_scope.own_complexity(maximum_children_cost)
203
+ if scope_cost > maximum_cost
204
+ maximum_cost = scope_cost
205
+ possible_scope
206
+ else
207
+ max_scope
208
+ end
209
+ end
210
+
211
+ field_complexity(
212
+ maximum_scope,
213
+ max_complexity: maximum_cost,
214
+ child_complexity: maximum_children_cost,
215
+ )
216
+
217
+ total + maximum_cost
218
+ end
219
+ end
220
+
221
+ def legacy_merged_max_complexity(query, inner_selections)
222
+ # Aggregate a set of all unique field selection keys across all scopes.
223
+ # Use a hash, but ignore the values; it's just a fast way to work with the keys.
224
+ unique_field_keys = inner_selections.each_with_object({}) do |inner_selection, memo|
225
+ memo.merge!(inner_selection)
226
+ end
227
+
148
228
  # Add up the total cost for each unique field name's coalesced selections
149
229
  unique_field_keys.each_key.reduce(0) do |total, field_key|
150
230
  composite_scopes = nil
@@ -167,7 +247,7 @@ module GraphQL
167
247
  end
168
248
 
169
249
  if composite_scopes
170
- child_complexity = merged_max_complexity_for_scopes(query, composite_scopes)
250
+ child_complexity = merged_max_complexity_for_scopes(query, composite_scopes, :legacy)
171
251
 
172
252
  # This is the last composite scope visited; assume it's representative (for backwards compatibility).
173
253
  # Note: it would be more correct to score each composite scope and use the maximum possibility.
@@ -22,7 +22,7 @@ module GraphQL
22
22
  # ]
23
23
  #
24
24
  module Current
25
- # @return [String, nil] Comma-joined operation names for the currently-running {Multiplex}. `nil` if all operations are anonymous.
25
+ # @return [String, nil] Comma-joined operation names for the currently-running {Execution::Multiplex}. `nil` if all operations are anonymous.
26
26
  def self.operation_name
27
27
  if (m = Fiber[:__graphql_current_multiplex])
28
28
  m.context[:__graphql_current_operation_name] ||= begin
data/lib/graphql/dig.rb CHANGED
@@ -5,7 +5,8 @@ module GraphQL
5
5
  # so we can use some of the magic in Schema::InputObject and Interpreter::Arguments
6
6
  # to handle stringified/symbolized keys.
7
7
  #
8
- # @param args [Array<[String, Symbol>] Retrieves the value object corresponding to the each key objects repeatedly
8
+ # @param own_key [String, Symbol] A key to retrieve
9
+ # @param rest_keys [Array<[String, Symbol>] Retrieves the value object corresponding to the each key objects repeatedly
9
10
  # @return [Object]
10
11
  def dig(own_key, *rest_keys)
11
12
  val = self[own_key]
@@ -475,9 +475,10 @@ module GraphQL
475
475
  if is_non_null
476
476
  set_result(selection_result, result_name, nil, false, is_non_null) do
477
477
  # When this comes from a list item, use the parent object:
478
- parent_type = selection_result.is_a?(GraphQLResultArray) ? selection_result.graphql_parent.graphql_result_type : selection_result.graphql_result_type
478
+ is_from_array = selection_result.is_a?(GraphQLResultArray)
479
+ parent_type = is_from_array ? selection_result.graphql_parent.graphql_result_type : selection_result.graphql_result_type
479
480
  # This block is called if `result_name` is not dead. (Maybe a previous invalid nil caused it be marked dead.)
480
- err = parent_type::InvalidNullError.new(parent_type, field, ast_node)
481
+ err = parent_type::InvalidNullError.new(parent_type, field, ast_node, is_from_array: is_from_array)
481
482
  schema.type_error(err, context)
482
483
  end
483
484
  else
@@ -36,6 +36,11 @@ module GraphQL
36
36
  @tracers = schema.tracers + (context[:tracers] || [])
37
37
  @max_complexity = max_complexity
38
38
  @current_trace = context[:trace] ||= schema.new_trace(multiplex: self)
39
+ @logger = nil
40
+ end
41
+
42
+ def logger
43
+ @logger ||= @schema.logger_for(context)
39
44
  end
40
45
  end
41
46
  end
@@ -12,11 +12,24 @@ module GraphQL
12
12
  # @return [GraphQL::Language::Nodes::Field] the field where the error occurred
13
13
  attr_reader :ast_node
14
14
 
15
- def initialize(parent_type, field, ast_node)
15
+ # @return [Boolean] indicates an array result caused the error
16
+ attr_reader :is_from_array
17
+
18
+ def initialize(parent_type, field, ast_node, is_from_array: false)
16
19
  @parent_type = parent_type
17
20
  @field = field
18
21
  @ast_node = ast_node
19
- super("Cannot return null for non-nullable field #{@parent_type.graphql_name}.#{@field.graphql_name}")
22
+ @is_from_array = is_from_array
23
+
24
+ # For List elements, identify the non-null error is for an
25
+ # element and the required element type so it's not ambiguous
26
+ # whether it was caused by a null instead of the list or a
27
+ # null element.
28
+ if @is_from_array
29
+ super("Cannot return null for non-nullable element of type '#{@field.type.of_type.of_type.to_type_signature}' for #{@parent_type.graphql_name}.#{@field.graphql_name}")
30
+ else
31
+ super("Cannot return null for non-nullable field #{@parent_type.graphql_name}.#{@field.graphql_name}")
32
+ end
20
33
  end
21
34
 
22
35
  class << self
@@ -59,6 +59,7 @@ module GraphQL
59
59
  @scoped_context = ScopedContext.new(self)
60
60
  end
61
61
 
62
+ # Modify this hash to return extensions to client.
62
63
  # @return [Hash] A hash that will be added verbatim to the result hash, as `"extensions" => { ... }`
63
64
  def response_extensions
64
65
  namespace(:__query_result_extensions__)
data/lib/graphql/query.rb CHANGED
@@ -50,7 +50,7 @@ module GraphQL
50
50
  # @return [GraphQL::StaticValidation::Validator] if present, the query will validate with these rules.
51
51
  attr_reader :static_validator
52
52
 
53
- # @param new_validate [GraphQL::StaticValidation::Validator] if present, the query will validate with these rules. This can't be reasssigned after validation.
53
+ # @param new_validator [GraphQL::StaticValidation::Validator] if present, the query will validate with these rules. This can't be reasssigned after validation.
54
54
  def static_validator=(new_validator)
55
55
  if defined?(@validation_pipeline) && @validation_pipeline && @validation_pipeline.has_validated?
56
56
  raise ArgumentError, "Can't reassign Query#static_validator= after validation has run, remove this assignment."
@@ -172,13 +172,7 @@ module GraphQL
172
172
  @result_values = nil
173
173
  @executed = false
174
174
 
175
- @logger = if context && context[:logger] == false
176
- Logger.new(IO::NULL)
177
- elsif context && (l = context[:logger])
178
- l
179
- else
180
- schema.default_logger
181
- end
175
+ @logger = schema.logger_for(context)
182
176
  end
183
177
 
184
178
  # If a document was provided to `GraphQL::Schema#execute` instead of the raw query string, we will need to get it from the document
@@ -288,7 +282,7 @@ module GraphQL
288
282
  # @param ast_node [GraphQL::Language::Nodes::AbstractNode]
289
283
  # @param definition [GraphQL::Schema::Field]
290
284
  # @param parent_object [GraphQL::Schema::Object]
291
- # @return Hash{Symbol => Object}
285
+ # @return [Hash{Symbol => Object}]
292
286
  def arguments_for(ast_node, definition, parent_object: nil)
293
287
  arguments_cache.fetch(ast_node, definition, parent_object)
294
288
  end
@@ -3,6 +3,7 @@ module GraphQL
3
3
  class Schema
4
4
  module AlwaysVisible
5
5
  def self.use(schema, **opts)
6
+ schema.use(GraphQL::Schema::Visibility, profiles: { nil => {} })
6
7
  schema.extend(self)
7
8
  end
8
9
 
@@ -4,7 +4,7 @@ module GraphQL
4
4
  class Schema
5
5
  # Represents a list type in the schema.
6
6
  # Wraps a {Schema::Member} as a list type.
7
- # @see {Schema::Member::TypeSystemHelpers#to_list_type}
7
+ # @see Schema::Member::TypeSystemHelpers#to_list_type Create a list type from another GraphQL type
8
8
  class List < GraphQL::Schema::Wrapper
9
9
  include Schema::Member::ValidatesInput
10
10
 
@@ -3,6 +3,8 @@
3
3
  module GraphQL
4
4
  class Schema
5
5
  class Member
6
+ # @api public
7
+ # Shared methods for working with {Dataloader} inside GraphQL runtime objects.
6
8
  module HasDataloader
7
9
  # @return [GraphQL::Dataloader] The dataloader for the currently-running query
8
10
  def dataloader
@@ -37,7 +39,7 @@ module GraphQL
37
39
  source.load(find_by_value)
38
40
  end
39
41
 
40
- # Look up an associated record using a Rails association.
42
+ # Look up an associated record using a Rails association (via {Dataloader::ActiveRecordAssociationSource})
41
43
  # @param association_name [Symbol] A `belongs_to` or `has_one` association. (If a `has_many` association is named here, it will be selected without pagination.)
42
44
  # @param record [ActiveRecord::Base] The object that the association belongs to.
43
45
  # @param scope [ActiveRecord::Relation] A scope to look up the associated record in
@@ -45,7 +47,7 @@ module GraphQL
45
47
  # @example Looking up a belongs_to on the current object
46
48
  # dataload_association(:parent) # Equivalent to `object.parent`, but dataloaded
47
49
  # @example Looking up an associated record on some other object
48
- # dataload_association(:post, comment) # Equivalent to `comment.post`, but dataloaded
50
+ # dataload_association(comment, :post) # Equivalent to `comment.post`, but dataloaded
49
51
  def dataload_association(record = object, association_name, scope: nil)
50
52
  source = if scope
51
53
  dataloader.with(Dataloader::ActiveRecordAssociationSource, association_name, scope)
@@ -281,9 +281,11 @@ module GraphQL
281
281
 
282
282
  def non_duplicate_items(definitions, visibility_cache)
283
283
  non_dups = []
284
+ names = Set.new
284
285
  definitions.each do |defn|
285
286
  if visibility_cache[defn]
286
- if (dup_defn = non_dups.find { |d| d.graphql_name == defn.graphql_name })
287
+ if !names.add?(defn.graphql_name)
288
+ dup_defn = non_dups.find { |d| d.graphql_name == defn.graphql_name }
287
289
  raise_duplicate_definition(dup_defn, defn)
288
290
  end
289
291
  non_dups << defn
@@ -148,23 +148,22 @@ module GraphQL
148
148
 
149
149
  attr_reader :cached_profiles
150
150
 
151
- def profile_for(context, visibility_profile = context[:visibility_profile])
151
+ def profile_for(context)
152
152
  if !@profiles.empty?
153
- if visibility_profile.nil?
154
- if @dynamic
155
- if context.is_a?(Query::NullContext)
156
- top_level_profile
157
- else
158
- @schema.visibility_profile_class.new(context: context, schema: @schema)
159
- end
160
- elsif !@profiles.empty?
161
- raise ArgumentError, "#{@schema} expects a visibility profile, but `visibility_profile:` wasn't passed. Provide a `visibility_profile:` value or add `dynamic: true` to your visibility configuration."
162
- end
163
- elsif !@profiles.include?(visibility_profile)
164
- raise ArgumentError, "`#{visibility_profile.inspect}` isn't allowed for `visibility_profile:` (must be one of #{@profiles.keys.map(&:inspect).join(", ")}). Or, add `#{visibility_profile.inspect}` to the list of profiles in the schema definition."
165
- else
153
+ visibility_profile = context[:visibility_profile]
154
+ if @profiles.include?(visibility_profile)
166
155
  profile_ctx = @profiles[visibility_profile]
167
156
  @cached_profiles[visibility_profile] ||= @schema.visibility_profile_class.new(name: visibility_profile, context: profile_ctx, schema: @schema)
157
+ elsif @dynamic
158
+ if context.is_a?(Query::NullContext)
159
+ top_level_profile
160
+ else
161
+ @schema.visibility_profile_class.new(context: context, schema: @schema)
162
+ end
163
+ elsif !context.key?(:visibility_profile)
164
+ raise ArgumentError, "#{@schema} expects a visibility profile, but `visibility_profile:` wasn't passed. Provide a `visibility_profile:` value or add `dynamic: true` to your visibility configuration."
165
+ else
166
+ raise ArgumentError, "`#{visibility_profile.inspect}` isn't allowed for `visibility_profile:` (must be one of #{@profiles.keys.map(&:inspect).join(", ")}). Or, add `#{visibility_profile.inspect}` to the list of profiles in the schema definition."
168
167
  end
169
168
  elsif context.is_a?(Query::NullContext)
170
169
  top_level_profile
@@ -60,7 +60,7 @@ module GraphQL
60
60
  # Any undiscoverable types may be provided with the `types` configuration.
61
61
  #
62
62
  # Schemas can restrict large incoming queries with `max_depth` and `max_complexity` configurations.
63
- # (These configurations can be overridden by specific calls to {Schema#execute})
63
+ # (These configurations can be overridden by specific calls to {Schema.execute})
64
64
  #
65
65
  # @example defining a schema
66
66
  # class MySchema < GraphQL::Schema
@@ -249,7 +249,7 @@ module GraphQL
249
249
 
250
250
 
251
251
  # Returns the JSON response of {Introspection::INTROSPECTION_QUERY}.
252
- # @see {#as_json}
252
+ # @see #as_json Return a Hash representation of the schema
253
253
  # @return [String]
254
254
  def to_json(**args)
255
255
  JSON.pretty_generate(as_json(**args))
@@ -257,8 +257,6 @@ module GraphQL
257
257
 
258
258
  # Return the Hash response of {Introspection::INTROSPECTION_QUERY}.
259
259
  # @param context [Hash]
260
- # @param only [<#call(member, ctx)>]
261
- # @param except [<#call(member, ctx)>]
262
260
  # @param include_deprecated_args [Boolean] If true, deprecated arguments will be included in the JSON response
263
261
  # @param include_schema_description [Boolean] If true, the schema's description will be queried and included in the response
264
262
  # @param include_is_repeatable [Boolean] If true, `isRepeatable: true|false` will be included with the schema's directives
@@ -1066,6 +1064,18 @@ module GraphQL
1066
1064
  end
1067
1065
  end
1068
1066
 
1067
+ # @param context [GraphQL::Query::Context, nil]
1068
+ # @return [Logger] A logger to use for this context configuration, falling back to {.default_logger}
1069
+ def logger_for(context)
1070
+ if context && context[:logger] == false
1071
+ Logger.new(IO::NULL)
1072
+ elsif context && (l = context[:logger])
1073
+ l
1074
+ else
1075
+ default_logger
1076
+ end
1077
+ end
1078
+
1069
1079
  # @param new_context_class [Class<GraphQL::Query::Context>] A subclass to use when executing queries
1070
1080
  def context_class(new_context_class = nil)
1071
1081
  if new_context_class
@@ -1165,7 +1175,7 @@ module GraphQL
1165
1175
  # GraphQL-Ruby calls this method during execution when it needs the application to determine the type to use for an object.
1166
1176
  #
1167
1177
  # Usually, this object was returned from a field whose return type is an {GraphQL::Schema::Interface} or a {GraphQL::Schema::Union}.
1168
- # But this method is called in other cases, too -- for example, when {GraphQL::Schema::Argument.loads} cases an object to be directly loaded from the database.
1178
+ # But this method is called in other cases, too -- for example, when {GraphQL::Schema::Argument#loads} cases an object to be directly loaded from the database.
1169
1179
  #
1170
1180
  # @example Returning a GraphQL type based on the object's class name
1171
1181
  # class MySchema < GraphQL::Schema
@@ -1220,7 +1230,7 @@ module GraphQL
1220
1230
 
1221
1231
  # Return a stable ID string for `object` so that it can be refetched later, using {.object_from_id}.
1222
1232
  #
1223
- # {GlobalID}(https://github.com/rails/globalid) and {SQIDs}(https://sqids.org/ruby) can both be used to create IDs.
1233
+ # [GlobalID](https://github.com/rails/globalid) and [SQIDs](https://sqids.org/ruby) can both be used to create IDs.
1224
1234
  #
1225
1235
  # @example Using Rails's GlobalID to generate IDs
1226
1236
  # def self.id_from_object(application_object, graphql_type, context)
@@ -1297,13 +1307,13 @@ module GraphQL
1297
1307
  # @return [void]
1298
1308
  # @raise [GraphQL::ExecutionError] to return this error to the client
1299
1309
  # @raise [GraphQL::Error] to crash the query and raise a developer-facing error
1300
- def type_error(type_error, ctx)
1310
+ def type_error(type_error, context)
1301
1311
  case type_error
1302
1312
  when GraphQL::InvalidNullError
1303
1313
  execution_error = GraphQL::ExecutionError.new(type_error.message, ast_node: type_error.ast_node)
1304
- execution_error.path = ctx[:current_path]
1314
+ execution_error.path = context[:current_path]
1305
1315
 
1306
- ctx.errors << execution_error
1316
+ context.errors << execution_error
1307
1317
  when GraphQL::UnresolvedTypeError, GraphQL::StringEncodingError, GraphQL::IntegerEncodingError
1308
1318
  raise type_error
1309
1319
  when GraphQL::IntegerDecodingError
@@ -1311,7 +1321,7 @@ module GraphQL
1311
1321
  end
1312
1322
  end
1313
1323
 
1314
- # A function to call when {#execute} receives an invalid query string
1324
+ # A function to call when {.execute} receives an invalid query string
1315
1325
  #
1316
1326
  # The default is to add the error to `context.errors`
1317
1327
  # @param parse_err [GraphQL::ParseError] The error encountered during parsing
@@ -1567,7 +1577,8 @@ module GraphQL
1567
1577
  # @see {Query#initialize} for query keyword arguments
1568
1578
  # @see {Execution::Multiplex#run_all} for multiplex keyword arguments
1569
1579
  # @param queries [Array<Hash>] Keyword arguments for each query
1570
- # @param context [Hash] Multiplex-level context
1580
+ # @option kwargs [Hash] :context ({}) Multiplex-level context
1581
+ # @option kwargs [nil, Integer] :max_complexity (nil)
1571
1582
  # @return [Array<GraphQL::Query::Result>] One result for each query in the input
1572
1583
  def multiplex(queries, **kwargs)
1573
1584
  GraphQL::Execution::Interpreter.run_all(self, queries, **kwargs)
@@ -1632,7 +1643,7 @@ module GraphQL
1632
1643
  end
1633
1644
  end
1634
1645
 
1635
- # @return [Symbol, nil] The method name to lazily resolve `obj`, or nil if `obj`'s class wasn't registered with {#lazy_resolve}.
1646
+ # @return [Symbol, nil] The method name to lazily resolve `obj`, or nil if `obj`'s class wasn't registered with {.lazy_resolve}.
1636
1647
  def lazy_method_name(obj)
1637
1648
  lazy_methods.get(obj)
1638
1649
  end
@@ -1670,6 +1681,158 @@ module GraphQL
1670
1681
  end
1671
1682
  end
1672
1683
 
1684
+
1685
+ # This setting controls how GraphQL-Ruby handles empty selections on Union types.
1686
+ #
1687
+ # To opt into future, spec-compliant behavior where these selections are rejected, set this to `false`.
1688
+ #
1689
+ # If you need to support previous, non-spec behavior which allowed selecting union fields
1690
+ # but *not* selecting any fields on that union, set this to `true` to continue allowing that behavior.
1691
+ #
1692
+ # If this is `true`, then {.legacy_invalid_empty_selections_on_union} will be called with {Query} objects
1693
+ # with that kind of selections. You must implement that method
1694
+ # @param new_value [Boolean]
1695
+ # @return [true, false, nil]
1696
+ def allow_legacy_invalid_empty_selections_on_union(new_value = NOT_CONFIGURED)
1697
+ if NOT_CONFIGURED.equal?(new_value)
1698
+ @allow_legacy_invalid_empty_selections_on_union
1699
+ else
1700
+ @allow_legacy_invalid_empty_selections_on_union = new_value
1701
+ end
1702
+ end
1703
+
1704
+ # This method is called during validation when a previously-allowed, but non-spec
1705
+ # query is encountered where a union field has no child selections on it.
1706
+ #
1707
+ # You should implement this method to log the violation so that you can contact clients
1708
+ # and notify them about changing their queries. Then return a suitable value to
1709
+ # tell GraphQL-Ruby how to continue.
1710
+ # @param query [GraphQL::Query]
1711
+ # @return [:return_validation_error] Let GraphQL-Ruby return the (new) normal validation error for this query
1712
+ # @return [String] A validation error to return for this query
1713
+ # @return [nil] Don't send the client an error, continue the legacy behavior (allow this query to execute)
1714
+ def legacy_invalid_empty_selections_on_union(query)
1715
+ raise "Implement `def self.legacy_invalid_empty_selections_on_union(query)` to handle this scenario"
1716
+ end
1717
+
1718
+ # This setting controls how GraphQL-Ruby handles overlapping selections on scalar types when the types
1719
+ # don't match.
1720
+ #
1721
+ # When set to `false`, GraphQL-Ruby will reject those queries with a validation error (as per the GraphQL spec).
1722
+ #
1723
+ # When set to `true`, GraphQL-Ruby will call {.legacy_invalid_return_type_conflicts} when the scenario is encountered.
1724
+ #
1725
+ # @param new_value [Boolean] `true` permits the legacy behavior, `false` rejects it.
1726
+ # @return [true, false, nil]
1727
+ def allow_legacy_invalid_return_type_conflicts(new_value = NOT_CONFIGURED)
1728
+ if NOT_CONFIGURED.equal?(new_value)
1729
+ @allow_legacy_invalid_return_type_conflicts
1730
+ else
1731
+ @allow_legacy_invalid_return_type_conflicts = new_value
1732
+ end
1733
+ end
1734
+
1735
+ # This method is called when the query contains fields which don't contain matching scalar types.
1736
+ # This was previously allowed by GraphQL-Ruby but it's a violation of the GraphQL spec.
1737
+ #
1738
+ # You should implement this method to log the violation so that you observe usage of these fields.
1739
+ # Fixing this scenario might mean adding new fields, and telling clients to use those fields.
1740
+ # (Changing the field return type would be a breaking change, but if it works for your client use cases,
1741
+ # that might work, too.)
1742
+ #
1743
+ # @param query [GraphQL::Query]
1744
+ # @param type1 [Module] A GraphQL type definition
1745
+ # @param type2 [Module] A GraphQL type definition
1746
+ # @param node1 [GraphQL::Language::Nodes::Field] This node is recognized as conflicting. You might call `.line` and `.col` for custom error reporting.
1747
+ # @param node2 [GraphQL::Language::Nodes::Field] The other node recognized as conflicting.
1748
+ # @return [:return_validation_error] Let GraphQL-Ruby return the (new) normal validation error for this query
1749
+ # @return [String] A validation error to return for this query
1750
+ # @return [nil] Don't send the client an error, continue the legacy behavior (allow this query to execute)
1751
+ def legacy_invalid_return_type_conflicts(query, type1, type2, node1, node2)
1752
+ raise "Implement #{self}.legacy_invalid_return_type_conflicts to handle this invalid selection"
1753
+ end
1754
+
1755
+ # The legacy complexity implementation included several bugs:
1756
+ #
1757
+ # - In some cases, it used the lexically _last_ field to determine a cost, instead of calculating the maximum among selections
1758
+ # - In some cases, it called field complexity hooks repeatedly (when it should have only called them once)
1759
+ #
1760
+ # The future implementation may produce higher total complexity scores, so it's not active by default yet. You can opt into
1761
+ # the future default behavior by configuring `:future` here. Or, you can choose a mode for each query with {.complexity_cost_calculation_mode_for}.
1762
+ #
1763
+ # The legacy mode is currently maintained alongside the future one, but it will be removed in a future GraphQL-Ruby version.
1764
+ #
1765
+ # If you choose `:compare`, you must also implement {.legacy_complexity_cost_calculation_mismatch} to handle the input somehow.
1766
+ #
1767
+ # @example Opting into the future calculation mode
1768
+ # complexity_cost_calculation_mode(:future)
1769
+ #
1770
+ # @example Choosing the legacy mode (which will work until that mode is removed...)
1771
+ # complexity_cost_calculation_mode(:legacy)
1772
+ #
1773
+ # @example Run both modes for every query, call {.legacy_complexity_cost_calculation_mismatch} when they don't match:
1774
+ # complexity_cost_calculation_mode(:compare)
1775
+ def complexity_cost_calculation_mode(new_mode = NOT_CONFIGURED)
1776
+ if NOT_CONFIGURED.equal?(new_mode)
1777
+ @complexity_cost_calculation_mode
1778
+ else
1779
+ @complexity_cost_calculation_mode = new_mode
1780
+ end
1781
+ end
1782
+
1783
+ # Implement this method to produce a per-query complexity cost calculation mode. (Technically, it's per-multiplex.)
1784
+ #
1785
+ # This is a way to check the compatibility of queries coming to your API without adding overhead of running `:compare`
1786
+ # for every query. You could sample traffic, turn it off/on with feature flags, or anything else.
1787
+ #
1788
+ # @example Sampling traffic
1789
+ # def self.complexity_cost_calculation_mode_for(_context)
1790
+ # if rand < 0.1 # 10% of the time
1791
+ # :compare
1792
+ # else
1793
+ # :legacy
1794
+ # end
1795
+ # end
1796
+ #
1797
+ # @example Using a feature flag to manage future mode
1798
+ # def complexity_cost_calculation_mode_for(context)
1799
+ # current_user = context[:current_user]
1800
+ # if Flipper.enabled?(:future_complexity_cost, current_user)
1801
+ # :future
1802
+ # elsif rand < 0.5 # 50%
1803
+ # :compare
1804
+ # else
1805
+ # :legacy
1806
+ # end
1807
+ # end
1808
+ #
1809
+ # @param multiplex_context [Hash] The context for the currently-running {Execution::Multiplex} (which contains one or more queries)
1810
+ # @return [:future] Use the new calculation algorithm -- may be higher than `:legacy`
1811
+ # @return [:legacy] Use the legacy calculation algorithm, warts and all
1812
+ # @return [:compare] Run both algorithms and call {.legacy_complexity_cost_calculation_mismatch} if they don't match
1813
+ def complexity_cost_calculation_mode_for(multiplex_context)
1814
+ complexity_cost_calculation_mode
1815
+ end
1816
+
1817
+ # Implement this method in your schema to handle mismatches when `:compare` is used.
1818
+ #
1819
+ # @example Logging the mismatch
1820
+ # def self.legacy_cost_calculation_mismatch(multiplex, future_cost, legacy_cost)
1821
+ # client_id = multiplex.context[:api_client].id
1822
+ # operation_names = multiplex.queries.map { |q| q.selected_operation_name || "anonymous" }.join(", ")
1823
+ # Stats.increment(:complexity_mismatch, tags: { client: client_id, ops: operation_names })
1824
+ # legacy_cost
1825
+ # end
1826
+ # @see Query::Context#add_error Adding an error to the response to notify the client
1827
+ # @see Query::Context#response_extensions Adding key-value pairs to the response `"extensions" => { ... }`
1828
+ # @param multiplex [GraphQL::Execution::Multiplex]
1829
+ # @param future_complexity_cost [Integer]
1830
+ # @param legacy_complexity_cost [Integer]
1831
+ # @return [Integer] the cost to use for this query (probably one of `future_complexity_cost` or `legacy_complexity_cost`)
1832
+ def legacy_complexity_cost_calculation_mismatch(multiplex, future_complexity_cost, legacy_complexity_cost)
1833
+ raise "Implement #{self}.legacy_complexity_cost(multiplex, future_complexity_cost, legacy_complexity_cost) to handle this mismatch (#{future_complexity_cost} vs. #{legacy_complexity_cost}) and return a value to use"
1834
+ end
1835
+
1673
1836
  private
1674
1837
 
1675
1838
  def add_trace_options_for(mode, new_options)
@@ -25,22 +25,56 @@ module GraphQL
25
25
  def validate_field_selections(ast_node, resolved_type)
26
26
  msg = if resolved_type.nil?
27
27
  nil
28
- elsif !ast_node.selections.empty? && resolved_type.kind.leaf?
29
- selection_strs = ast_node.selections.map do |n|
30
- case n
31
- when GraphQL::Language::Nodes::InlineFragment
32
- "\"... on #{n.type.name} { ... }\""
33
- when GraphQL::Language::Nodes::Field
34
- "\"#{n.name}\""
35
- when GraphQL::Language::Nodes::FragmentSpread
36
- "\"#{n.name}\""
28
+ elsif resolved_type.kind.leaf?
29
+ if !ast_node.selections.empty?
30
+ selection_strs = ast_node.selections.map do |n|
31
+ case n
32
+ when GraphQL::Language::Nodes::InlineFragment
33
+ "\"... on #{n.type.name} { ... }\""
34
+ when GraphQL::Language::Nodes::Field
35
+ "\"#{n.name}\""
36
+ when GraphQL::Language::Nodes::FragmentSpread
37
+ "\"#{n.name}\""
38
+ else
39
+ raise "Invariant: unexpected selection node: #{n}"
40
+ end
41
+ end
42
+ "Selections can't be made on #{resolved_type.kind.name.sub("_", " ").downcase}s (%{node_name} returns #{resolved_type.graphql_name} but has selections [#{selection_strs.join(", ")}])"
43
+ else
44
+ nil
45
+ end
46
+ elsif ast_node.selections.empty?
47
+ return_validation_error = true
48
+ legacy_invalid_empty_selection_result = nil
49
+ if !resolved_type.kind.fields?
50
+ case @schema.allow_legacy_invalid_empty_selections_on_union
51
+ when true
52
+ legacy_invalid_empty_selection_result = @schema.legacy_invalid_empty_selections_on_union(@context.query)
53
+ case legacy_invalid_empty_selection_result
54
+ when :return_validation_error
55
+ # keep `return_validation_error = true`
56
+ when String
57
+ return_validation_error = false
58
+ # the string is returned below
59
+ when nil
60
+ # No error:
61
+ return_validation_error = false
62
+ legacy_invalid_empty_selection_result = nil
63
+ else
64
+ raise GraphQL::InvariantError, "Unexpected return value from legacy_invalid_empty_selections_on_union, must be `:return_validation_error`, String, or nil (got: #{legacy_invalid_empty_selection_result.inspect})"
65
+ end
66
+ when false
67
+ # pass -- error below
37
68
  else
38
- raise "Invariant: unexpected selection node: #{n}"
69
+ return_validation_error = false
70
+ @context.query.logger.warn("Unions require selections but #{ast_node.alias || ast_node.name} (#{resolved_type.graphql_name}) doesn't have any. This will fail with a validation error on a future GraphQL-Ruby version. More info: https://graphql-ruby.org/api-doc/#{GraphQL::VERSION}/GraphQL/Schema.html#allow_legacy_invalid_empty_selections_on_union-class_method")
39
71
  end
40
72
  end
41
- "Selections can't be made on #{resolved_type.kind.name.sub("_", " ").downcase}s (%{node_name} returns #{resolved_type.graphql_name} but has selections [#{selection_strs.join(", ")}])"
42
- elsif resolved_type.kind.fields? && ast_node.selections.empty?
43
- "Field must have selections (%{node_name} returns #{resolved_type.graphql_name} but has no selections. Did you mean '#{ast_node.name} { ... }'?)"
73
+ if return_validation_error
74
+ "Field must have selections (%{node_name} returns #{resolved_type.graphql_name} but has no selections. Did you mean '#{ast_node.name} { ... }'?)"
75
+ else
76
+ legacy_invalid_empty_selection_result
77
+ end
44
78
  else
45
79
  nil
46
80
  end
@@ -33,26 +33,19 @@ module GraphQL
33
33
 
34
34
  private
35
35
 
36
- def field_conflicts
37
- @field_conflicts ||= Hash.new do |errors, field|
38
- errors[field] = GraphQL::StaticValidation::FieldsWillMergeError.new(kind: :field, field_name: field)
39
- end
40
- end
41
-
42
- def arg_conflicts
43
- @arg_conflicts ||= Hash.new do |errors, field|
44
- errors[field] = GraphQL::StaticValidation::FieldsWillMergeError.new(kind: :argument, field_name: field)
36
+ def conflicts
37
+ @conflicts ||= Hash.new do |h, error_type|
38
+ h[error_type] = Hash.new do |h2, field_name|
39
+ h2[field_name] = GraphQL::StaticValidation::FieldsWillMergeError.new(kind: error_type, field_name: field_name)
40
+ end
45
41
  end
46
42
  end
47
43
 
48
44
  def setting_errors
49
- @field_conflicts = nil
50
- @arg_conflicts = nil
51
-
45
+ @conflicts = nil
52
46
  yield
53
47
  # don't initialize these if they weren't initialized in the block:
54
- @field_conflicts && @field_conflicts.each_value { |error| add_error(error) }
55
- @arg_conflicts && @arg_conflicts.each_value { |error| add_error(error) }
48
+ @conflicts&.each_value { |error_type| error_type.each_value { |error| add_error(error) } }
56
49
  end
57
50
 
58
51
  def conflicts_within_selection_set(node, parent_type)
@@ -222,7 +215,7 @@ module GraphQL
222
215
 
223
216
  if !are_mutually_exclusive
224
217
  if node1.name != node2.name
225
- conflict = field_conflicts[response_key]
218
+ conflict = conflicts[:field][response_key]
226
219
 
227
220
  conflict.add_conflict(node1, node1.name)
228
221
  conflict.add_conflict(node2, node2.name)
@@ -231,7 +224,7 @@ module GraphQL
231
224
  end
232
225
 
233
226
  if !same_arguments?(node1, node2)
234
- conflict = arg_conflicts[response_key]
227
+ conflict = conflicts[:argument][response_key]
235
228
 
236
229
  conflict.add_conflict(node1, GraphQL::Language.serialize(serialize_field_args(node1)))
237
230
  conflict.add_conflict(node2, GraphQL::Language.serialize(serialize_field_args(node2)))
@@ -240,6 +233,49 @@ module GraphQL
240
233
  end
241
234
  end
242
235
 
236
+ if !conflicts[:field].key?(response_key) &&
237
+ (t1 = field1.definition&.type) &&
238
+ (t2 = field2.definition&.type) &&
239
+ return_types_conflict?(t1, t2)
240
+
241
+ return_error = nil
242
+ message_override = nil
243
+ case @schema.allow_legacy_invalid_return_type_conflicts
244
+ when false
245
+ return_error = true
246
+ when true
247
+ legacy_handling = @schema.legacy_invalid_return_type_conflicts(@context.query, t1, t2, node1, node2)
248
+ case legacy_handling
249
+ when nil
250
+ return_error = false
251
+ when :return_validation_error
252
+ return_error = true
253
+ when String
254
+ return_error = true
255
+ message_override = legacy_handling
256
+ else
257
+ raise GraphQL::Error, "#{@schema}.legacy_invalid_scalar_conflicts returned unexpected value: #{legacy_handling.inspect}. Expected `nil`, String, or `:return_validation_error`."
258
+ end
259
+ else
260
+ return_error = false
261
+ @context.query.logger.warn <<~WARN
262
+ GraphQL-Ruby encountered mismatched types in this query: `#{t1.to_type_signature}` (at #{node1.line}:#{node1.col}) vs. `#{t2.to_type_signature}` (at #{node2.line}:#{node2.col}).
263
+ This will return an error in future GraphQL-Ruby versions, as per the GraphQL specification
264
+ Learn about migrating here: https://graphql-ruby.org/api-doc/#{GraphQL::VERSION}/GraphQL/Schema.html#allow_legacy_invalid_return_type_conflicts-class_method
265
+ WARN
266
+ end
267
+
268
+ if return_error
269
+ conflict = conflicts[:return_type][response_key]
270
+ if message_override
271
+ conflict.message = message_override
272
+ end
273
+ conflict.add_conflict(node1, "`#{t1.to_type_signature}`")
274
+ conflict.add_conflict(node2, "`#{t2.to_type_signature}`")
275
+ @conflict_count += 1
276
+ end
277
+ end
278
+
243
279
  find_conflicts_between_sub_selection_sets(
244
280
  field1,
245
281
  field2,
@@ -247,6 +283,32 @@ module GraphQL
247
283
  )
248
284
  end
249
285
 
286
+ def return_types_conflict?(type1, type2)
287
+ if type1.list?
288
+ if type2.list?
289
+ return_types_conflict?(type1.of_type, type2.of_type)
290
+ else
291
+ true
292
+ end
293
+ elsif type2.list?
294
+ true
295
+ elsif type1.non_null?
296
+ if type2.non_null?
297
+ return_types_conflict?(type1.of_type, type2.of_type)
298
+ else
299
+ true
300
+ end
301
+ elsif type2.non_null?
302
+ true
303
+ elsif type1.kind.leaf? && type2.kind.leaf?
304
+ type1 != type2
305
+ else
306
+ # One or more of these are composite types,
307
+ # their selections will be validated later on.
308
+ false
309
+ end
310
+ end
311
+
250
312
  def find_conflicts_between_sub_selection_sets(field1, field2, mutually_exclusive:)
251
313
  return if field1.definition.nil? ||
252
314
  field2.definition.nil? ||
@@ -14,9 +14,11 @@ module GraphQL
14
14
  end
15
15
 
16
16
  def message
17
- "Field '#{field_name}' has #{kind == :argument ? 'an' : 'a'} #{kind} conflict: #{conflicts}?"
17
+ @message || "Field '#{field_name}' has #{kind == :argument ? 'an' : 'a'} #{kind} conflict: #{conflicts}?"
18
18
  end
19
19
 
20
+ attr_writer :message
21
+
20
22
  def path
21
23
  []
22
24
  end
@@ -26,7 +28,13 @@ module GraphQL
26
28
  end
27
29
 
28
30
  def add_conflict(node, conflict_str)
29
- return if nodes.include?(node)
31
+ # Can't use `.include?` here because AST nodes implement `#==`
32
+ # based on string value, not including location. But sometimes,
33
+ # identical nodes conflict because of their differing return types.
34
+ if nodes.any? { |n| n == node && n.line == node.line && n.col == node.col }
35
+ # already have an error for this node
36
+ return
37
+ end
30
38
 
31
39
  @nodes << node
32
40
  @conflicts << conflict_str
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module GraphQL
3
- VERSION = "2.5.2"
3
+ VERSION = "2.5.3"
4
4
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graphql
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.5.2
4
+ version: 2.5.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Robert Mosolgo
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-04-08 00:00:00.000000000 Z
10
+ date: 2025-04-14 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: base64