From d325c38d48ea8fba05b15451feadcd5f614999e0 Mon Sep 17 00:00:00 2001 From: Greg MacWilliam Date: Wed, 14 Feb 2024 11:38:12 -0500 Subject: [PATCH 1/4] complexity cost for v3. --- lib/graphql/analysis/ast/query_complexity.rb | 55 ++++++---- .../analysis/ast/query_complexity_spec.rb | 103 +++++++++++++++++- 2 files changed, 132 insertions(+), 26 deletions(-) diff --git a/lib/graphql/analysis/ast/query_complexity.rb b/lib/graphql/analysis/ast/query_complexity.rb index 5c55e2dabf9..83ccf52f04f 100644 --- a/lib/graphql/analysis/ast/query_complexity.rb +++ b/lib/graphql/analysis/ast/query_complexity.rb @@ -44,6 +44,10 @@ def initialize(parent_type, field_definition, query, response_path) def own_complexity(child_complexity) @field_definition.calculate_complexity(query: @query, nodes: @nodes, child_complexity: child_complexity) end + + def composite? + !empty? + end end def on_enter_field(node, parent, visitor) @@ -145,35 +149,38 @@ def merged_max_complexity(query, inner_selections) # Add up the total cost for each unique field name's coalesced selections unique_field_keys.each_key.reduce(0) do |total, field_key| - composite_scopes = nil - field_cost = 0 - - # Collect composite selection scopes for further aggregation, - # leaf selections report their costs directly. - inner_selections.each do |inner_selection| - child_scope = inner_selection[field_key] - next unless child_scope - - # Empty child scopes are leaf nodes with zero child complexity. - if child_scope.empty? - field_cost = child_scope.own_complexity(0) - field_complexity(child_scope, max_complexity: field_cost, child_complexity: nil) + # Collect all child scopes for this field key; + # all keys come with at least one scope. + child_scopes = inner_selections.filter_map { _1[field_key] } + + # Compute maximum possible cost of child selections; + # composites merge their maximums, while leaf scopes are always zero. + # FieldsWillMerge validation assures all scopes are uniformly composite or leaf. + maximum_children_cost = if child_scopes.any?(&:composite?) + merged_max_complexity_for_scopes(query, child_scopes) + else + 0 + end + + # Identify the maximum cost and scope among possibilities + maximum_cost = 0 + maximum_scope = child_scopes.reduce(child_scopes.last) do |max_scope, possible_scope| + scope_cost = possible_scope.own_complexity(maximum_children_cost) + if scope_cost > maximum_cost + maximum_cost = scope_cost + possible_scope else - composite_scopes ||= [] - composite_scopes << child_scope + max_scope end end - if composite_scopes - child_complexity = merged_max_complexity_for_scopes(query, composite_scopes) - - # This is the last composite scope visited; assume it's representative (for backwards compatibility). - # Note: it would be more correct to score each composite scope and use the maximum possibility. - field_cost = composite_scopes.last.own_complexity(child_complexity) - field_complexity(composite_scopes.last, max_complexity: field_cost, child_complexity: child_complexity) - end + field_complexity( + maximum_scope, + max_complexity: maximum_cost, + child_complexity: maximum_children_cost, + ) - total + field_cost + total + maximum_cost end end end diff --git a/spec/graphql/analysis/ast/query_complexity_spec.rb b/spec/graphql/analysis/ast/query_complexity_spec.rb index ca5c66ad885..817c4d1b78c 100644 --- a/spec/graphql/analysis/ast/query_complexity_spec.rb +++ b/spec/graphql/analysis/ast/query_complexity_spec.rb @@ -642,10 +642,109 @@ def field_complexity(scoped_type_complexity, max_complexity:, child_complexity:) field_complexities = reduce_result.first assert_equal({ - ['cheese', 'id'] => { max_complexity: 1, child_complexity: nil }, - ['cheese', 'flavor'] => { max_complexity: 1, child_complexity: nil }, + ['cheese', 'id'] => { max_complexity: 1, child_complexity: 0 }, + ['cheese', 'flavor'] => { max_complexity: 1, child_complexity: 0 }, ['cheese'] => { max_complexity: 3, child_complexity: 2 }, }, field_complexities) end end + + describe "maximum of possible scopes regardless of selection order" do + class MaxOfPossibleScopes < GraphQL::Schema + class Cheese < GraphQL::Schema::Object + field :kind, String + end + + module Producer + include GraphQL::Schema::Interface + field :cheese, Cheese, complexity: 5 + field :name, String, complexity: 5 + end + + class Farm < GraphQL::Schema::Object + implements Producer + field :cheese, Cheese, complexity: 10 + field :name, String, complexity: 10 + end + + class Entity < GraphQL::Schema::Union + possible_types Farm + end + + class Query < GraphQL::Schema::Object + field :entity, Entity + end + + def self.resolve_type + Farm + end + + def self.cost(query_string) + GraphQL::Analysis::AST.analyze_query( + GraphQL::Query.new(self, query_string), + [GraphQL::Analysis::AST::QueryComplexity], + ).first + end + + query(Query) + orphan_types(Producer) + end + + it "uses maximum of merged composite fields, regardless of selection order" do + a = MaxOfPossibleScopes.cost(%| + { + entity { + ...on Producer { cheese { kind } } + ...on Farm { cheese { kind } } + } + } + |) + + b = MaxOfPossibleScopes.cost(%| + { + entity { + ...on Farm { cheese { kind } } + ...on Producer { cheese { kind } } + } + } + |) + + assert_equal 0, a - b + end + + it "uses maximum of merged leaf fields, regardless of selection order" do + a = MaxOfPossibleScopes.cost(%| + { + entity { + ...on Producer { name } + ...on Farm { name } + } + } + |) + + b = MaxOfPossibleScopes.cost(%| + { + entity { + ...on Farm { name } + ...on Producer { name } + } + } + |) + + assert_equal 0, a - b + end + + it "invalid mismatched scope types will still compute without error" do + cost = MaxOfPossibleScopes.cost(%| + { + entity { + ...on Farm { cheese { kind } } + ...on Producer { cheese: name } + } + } + |) + + assert_equal 12, cost + end + end end From 94cf6c405d26de0aacc0d5986e2c2ed592c0a653 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Fri, 11 Apr 2025 12:09:27 -0400 Subject: [PATCH 2/4] Move file to new location --- lib/graphql/analysis/ast/query_complexity.rb | 189 ------------------- lib/graphql/analysis/query_complexity.rb | 187 ++++++++++++++++++ 2 files changed, 187 insertions(+), 189 deletions(-) delete mode 100644 lib/graphql/analysis/ast/query_complexity.rb create mode 100644 lib/graphql/analysis/query_complexity.rb diff --git a/lib/graphql/analysis/ast/query_complexity.rb b/lib/graphql/analysis/ast/query_complexity.rb deleted file mode 100644 index 83ccf52f04f..00000000000 --- a/lib/graphql/analysis/ast/query_complexity.rb +++ /dev/null @@ -1,189 +0,0 @@ -# frozen_string_literal: true -module GraphQL - module Analysis - # Calculate the complexity of a query, using {Field#complexity} values. - module AST - class QueryComplexity < Analyzer - # State for the query complexity calculation: - # - `complexities_on_type` holds complexity scores for each type - def initialize(query) - super - @complexities_on_type_by_query = {} - end - - # Overide this method to use the complexity result - def result - max_possible_complexity - end - - # ScopedTypeComplexity models a tree of GraphQL types mapped to inner selections, ie: - # Hash> - class ScopedTypeComplexity < Hash - # A proc for defaulting empty namespace requests as a new scope hash. - DEFAULT_PROC = ->(h, k) { h[k] = {} } - - attr_reader :field_definition, :response_path, :query - - # @param parent_type [Class] The owner of `field_definition` - # @param field_definition [GraphQL::Field, GraphQL::Schema::Field] Used for getting the `.complexity` configuration - # @param query [GraphQL::Query] Used for `query.possible_types` - # @param response_path [Array] The path to the response key for the field - # @return [Hash>] - def initialize(parent_type, field_definition, query, response_path) - super(&DEFAULT_PROC) - @parent_type = parent_type - @field_definition = field_definition - @query = query - @response_path = response_path - @nodes = [] - end - - # @return [Array] - attr_reader :nodes - - def own_complexity(child_complexity) - @field_definition.calculate_complexity(query: @query, nodes: @nodes, child_complexity: child_complexity) - end - - def composite? - !empty? - end - end - - def on_enter_field(node, parent, visitor) - # We don't want to visit fragment definitions, - # we'll visit them when we hit the spreads instead - return if visitor.visiting_fragment_definition? - return if visitor.skipping? - parent_type = visitor.parent_type_definition - field_key = node.alias || node.name - - # Find or create a complexity scope stack for this query. - scopes_stack = @complexities_on_type_by_query[visitor.query] ||= [ScopedTypeComplexity.new(nil, nil, query, visitor.response_path)] - - # Find or create the complexity costing node for this field. - scope = scopes_stack.last[parent_type][field_key] ||= ScopedTypeComplexity.new(parent_type, visitor.field_definition, visitor.query, visitor.response_path) - scope.nodes.push(node) - scopes_stack.push(scope) - end - - def on_leave_field(node, parent, visitor) - # We don't want to visit fragment definitions, - # we'll visit them when we hit the spreads instead - return if visitor.visiting_fragment_definition? - return if visitor.skipping? - scopes_stack = @complexities_on_type_by_query[visitor.query] - scopes_stack.pop - end - - private - - # @return [Integer] - def max_possible_complexity - @complexities_on_type_by_query.reduce(0) do |total, (query, scopes_stack)| - total + merged_max_complexity_for_scopes(query, [scopes_stack.first]) - end - end - - # @param query [GraphQL::Query] Used for `query.possible_types` - # @param scopes [Array] Array of scoped type complexities - # @return [Integer] - def merged_max_complexity_for_scopes(query, scopes) - # Aggregate a set of all possible scope types encountered (scope keys). - # Use a hash, but ignore the values; it's just a fast way to work with the keys. - possible_scope_types = scopes.each_with_object({}) do |scope, memo| - memo.merge!(scope) - end - - # Expand abstract scope types into their concrete implementations; - # overlapping abstracts coalesce through their intersecting types. - possible_scope_types.keys.each do |possible_scope_type| - next unless possible_scope_type.kind.abstract? - - query.possible_types(possible_scope_type).each do |impl_type| - possible_scope_types[impl_type] ||= true - end - possible_scope_types.delete(possible_scope_type) - end - - # Aggregate the lexical selections that may apply to each possible type, - # and then return the maximum cost among possible typed selections. - possible_scope_types.each_key.reduce(0) do |max, possible_scope_type| - # Collect inner selections from all scopes that intersect with this possible type. - all_inner_selections = scopes.each_with_object([]) do |scope, memo| - scope.each do |scope_type, inner_selections| - memo << inner_selections if types_intersect?(query, scope_type, possible_scope_type) - end - end - - # Find the maximum complexity for the scope type among possible lexical branches. - complexity = merged_max_complexity(query, all_inner_selections) - complexity > max ? complexity : max - end - end - - def types_intersect?(query, a, b) - return true if a == b - - a_types = query.possible_types(a) - query.possible_types(b).any? { |t| a_types.include?(t) } - end - - # A hook which is called whenever a field's max complexity is calculated. - # Override this method to capture individual field complexity details. - # - # @param scoped_type_complexity [ScopedTypeComplexity] - # @param max_complexity [Numeric] Field's maximum complexity including child complexity - # @param child_complexity [Numeric, nil] Field's child complexity - def field_complexity(scoped_type_complexity, max_complexity:, child_complexity: nil) - end - - # @param inner_selections [Array>] Field selections for a scope - # @return [Integer] Total complexity value for all these selections in the parent scope - def merged_max_complexity(query, inner_selections) - # Aggregate a set of all unique field selection keys across all scopes. - # Use a hash, but ignore the values; it's just a fast way to work with the keys. - unique_field_keys = inner_selections.each_with_object({}) do |inner_selection, memo| - memo.merge!(inner_selection) - end - - # Add up the total cost for each unique field name's coalesced selections - unique_field_keys.each_key.reduce(0) do |total, field_key| - # Collect all child scopes for this field key; - # all keys come with at least one scope. - child_scopes = inner_selections.filter_map { _1[field_key] } - - # Compute maximum possible cost of child selections; - # composites merge their maximums, while leaf scopes are always zero. - # FieldsWillMerge validation assures all scopes are uniformly composite or leaf. - maximum_children_cost = if child_scopes.any?(&:composite?) - merged_max_complexity_for_scopes(query, child_scopes) - else - 0 - end - - # Identify the maximum cost and scope among possibilities - maximum_cost = 0 - maximum_scope = child_scopes.reduce(child_scopes.last) do |max_scope, possible_scope| - scope_cost = possible_scope.own_complexity(maximum_children_cost) - if scope_cost > maximum_cost - maximum_cost = scope_cost - possible_scope - else - max_scope - end - end - - field_complexity( - maximum_scope, - max_complexity: maximum_cost, - child_complexity: maximum_children_cost, - ) - - total + maximum_cost - end - end - end - end - end -end diff --git a/lib/graphql/analysis/query_complexity.rb b/lib/graphql/analysis/query_complexity.rb new file mode 100644 index 00000000000..93030e3e5d2 --- /dev/null +++ b/lib/graphql/analysis/query_complexity.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true +module GraphQL + module Analysis + # Calculate the complexity of a query, using {Field#complexity} values. + class QueryComplexity < Analyzer + # State for the query complexity calculation: + # - `complexities_on_type` holds complexity scores for each type + def initialize(query) + super + @complexities_on_type_by_query = {} + end + + # Overide this method to use the complexity result + def result + max_possible_complexity + end + + # ScopedTypeComplexity models a tree of GraphQL types mapped to inner selections, ie: + # Hash> + class ScopedTypeComplexity < Hash + # A proc for defaulting empty namespace requests as a new scope hash. + DEFAULT_PROC = ->(h, k) { h[k] = {} } + + attr_reader :field_definition, :response_path, :query + + # @param parent_type [Class] The owner of `field_definition` + # @param field_definition [GraphQL::Field, GraphQL::Schema::Field] Used for getting the `.complexity` configuration + # @param query [GraphQL::Query] Used for `query.possible_types` + # @param response_path [Array] The path to the response key for the field + # @return [Hash>] + def initialize(parent_type, field_definition, query, response_path) + super(&DEFAULT_PROC) + @parent_type = parent_type + @field_definition = field_definition + @query = query + @response_path = response_path + @nodes = [] + end + + # @return [Array] + attr_reader :nodes + + def own_complexity(child_complexity) + @field_definition.calculate_complexity(query: @query, nodes: @nodes, child_complexity: child_complexity) + end + + def composite? + !empty? + end + end + + def on_enter_field(node, parent, visitor) + # We don't want to visit fragment definitions, + # we'll visit them when we hit the spreads instead + return if visitor.visiting_fragment_definition? + return if visitor.skipping? + parent_type = visitor.parent_type_definition + field_key = node.alias || node.name + + # Find or create a complexity scope stack for this query. + scopes_stack = @complexities_on_type_by_query[visitor.query] ||= [ScopedTypeComplexity.new(nil, nil, query, visitor.response_path)] + + # Find or create the complexity costing node for this field. + scope = scopes_stack.last[parent_type][field_key] ||= ScopedTypeComplexity.new(parent_type, visitor.field_definition, visitor.query, visitor.response_path) + scope.nodes.push(node) + scopes_stack.push(scope) + end + + def on_leave_field(node, parent, visitor) + # We don't want to visit fragment definitions, + # we'll visit them when we hit the spreads instead + return if visitor.visiting_fragment_definition? + return if visitor.skipping? + scopes_stack = @complexities_on_type_by_query[visitor.query] + scopes_stack.pop + end + + private + + # @return [Integer] + def max_possible_complexity + @complexities_on_type_by_query.reduce(0) do |total, (query, scopes_stack)| + total + merged_max_complexity_for_scopes(query, [scopes_stack.first]) + end + end + + # @param query [GraphQL::Query] Used for `query.possible_types` + # @param scopes [Array] Array of scoped type complexities + # @return [Integer] + def merged_max_complexity_for_scopes(query, scopes) + # Aggregate a set of all possible scope types encountered (scope keys). + # Use a hash, but ignore the values; it's just a fast way to work with the keys. + possible_scope_types = scopes.each_with_object({}) do |scope, memo| + memo.merge!(scope) + end + + # Expand abstract scope types into their concrete implementations; + # overlapping abstracts coalesce through their intersecting types. + possible_scope_types.keys.each do |possible_scope_type| + next unless possible_scope_type.kind.abstract? + + query.possible_types(possible_scope_type).each do |impl_type| + possible_scope_types[impl_type] ||= true + end + possible_scope_types.delete(possible_scope_type) + end + + # Aggregate the lexical selections that may apply to each possible type, + # and then return the maximum cost among possible typed selections. + possible_scope_types.each_key.reduce(0) do |max, possible_scope_type| + # Collect inner selections from all scopes that intersect with this possible type. + all_inner_selections = scopes.each_with_object([]) do |scope, memo| + scope.each do |scope_type, inner_selections| + memo << inner_selections if types_intersect?(query, scope_type, possible_scope_type) + end + end + + # Find the maximum complexity for the scope type among possible lexical branches. + complexity = merged_max_complexity(query, all_inner_selections) + complexity > max ? complexity : max + end + end + + def types_intersect?(query, a, b) + return true if a == b + + a_types = query.possible_types(a) + query.possible_types(b).any? { |t| a_types.include?(t) } + end + + # A hook which is called whenever a field's max complexity is calculated. + # Override this method to capture individual field complexity details. + # + # @param scoped_type_complexity [ScopedTypeComplexity] + # @param max_complexity [Numeric] Field's maximum complexity including child complexity + # @param child_complexity [Numeric, nil] Field's child complexity + def field_complexity(scoped_type_complexity, max_complexity:, child_complexity: nil) + end + + # @param inner_selections [Array>] Field selections for a scope + # @return [Integer] Total complexity value for all these selections in the parent scope + def merged_max_complexity(query, inner_selections) + # Aggregate a set of all unique field selection keys across all scopes. + # Use a hash, but ignore the values; it's just a fast way to work with the keys. + unique_field_keys = inner_selections.each_with_object({}) do |inner_selection, memo| + memo.merge!(inner_selection) + end + + # Add up the total cost for each unique field name's coalesced selections + unique_field_keys.each_key.reduce(0) do |total, field_key| + # Collect all child scopes for this field key; + # all keys come with at least one scope. + child_scopes = inner_selections.filter_map { _1[field_key] } + + # Compute maximum possible cost of child selections; + # composites merge their maximums, while leaf scopes are always zero. + # FieldsWillMerge validation assures all scopes are uniformly composite or leaf. + maximum_children_cost = if child_scopes.any?(&:composite?) + merged_max_complexity_for_scopes(query, child_scopes) + else + 0 + end + + # Identify the maximum cost and scope among possibilities + maximum_cost = 0 + maximum_scope = child_scopes.reduce(child_scopes.last) do |max_scope, possible_scope| + scope_cost = possible_scope.own_complexity(maximum_children_cost) + if scope_cost > maximum_cost + maximum_cost = scope_cost + possible_scope + else + max_scope + end + end + + field_complexity( + maximum_scope, + max_complexity: maximum_cost, + child_complexity: maximum_children_cost, + ) + + total + maximum_cost + end + end + end + end +end From 8ffe6a199b586ce482907ca8cb9d8b7ecc92634c Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Fri, 11 Apr 2025 15:31:07 -0400 Subject: [PATCH 3/4] Start sketching out a compatibility system --- lib/graphql/analysis/query_complexity.rb | 88 +++++++++++++++++++++-- lib/graphql/execution/multiplex.rb | 5 ++ lib/graphql/query.rb | 8 +-- lib/graphql/schema.rb | 91 ++++++++++++++++++++++++ 4 files changed, 178 insertions(+), 14 deletions(-) diff --git a/lib/graphql/analysis/query_complexity.rb b/lib/graphql/analysis/query_complexity.rb index 238674653eb..38ca7bb86c4 100644 --- a/lib/graphql/analysis/query_complexity.rb +++ b/lib/graphql/analysis/query_complexity.rb @@ -11,9 +11,34 @@ def initialize(query) @complexities_on_type_by_query = {} end - # Overide this method to use the complexity result + # Override this method to use the complexity result def result - max_possible_complexity + case subject.schema.complexity_cost_calculation_mode_for(subject.context) + when :future + max_possible_complexity + when :legacy + max_possible_complexity(mode: :legacy) + when :compare + future_complexity = max_possible_complexity + legacy_complexity = max_possible_complexity(mode: legacy) + if future_complexity != legacy_complexity + subject.schema.legacy_complexity_cost_calculation_mismatch(subject, future_complexity, legacy_complexity) + else + future_complexity + end + when nil + subject.logger.warn <<~GRAPHQL + 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 + + To opt into the future behavior, configure your schema with: + + complexity_cost_calculation_mode(:future) # or `:legacy`, `:compare` + + GRAPHQL + max_possible_complexity + else + 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}" + end end # ScopedTypeComplexity models a tree of GraphQL types mapped to inner selections, ie: @@ -81,16 +106,17 @@ def on_leave_field(node, parent, visitor) private # @return [Integer] - def max_possible_complexity + def max_possible_complexity(mode: :future) @complexities_on_type_by_query.reduce(0) do |total, (query, scopes_stack)| - total + merged_max_complexity_for_scopes(query, [scopes_stack.first]) + total + merged_max_complexity_for_scopes(query, [scopes_stack.first], mode) end end # @param query [GraphQL::Query] Used for `query.possible_types` # @param scopes [Array] Array of scoped type complexities + # @param mode [:future, :legacy] # @return [Integer] - def merged_max_complexity_for_scopes(query, scopes) + def merged_max_complexity_for_scopes(query, scopes, mode) # Aggregate a set of all possible scope types encountered (scope keys). # Use a hash, but ignore the values; it's just a fast way to work with the keys. possible_scope_types = scopes.each_with_object({}) do |scope, memo| @@ -119,7 +145,14 @@ def merged_max_complexity_for_scopes(query, scopes) end # Find the maximum complexity for the scope type among possible lexical branches. - complexity = merged_max_complexity(query, all_inner_selections) + complexity = case mode + when :legacy + legacy_merged_max_complexity(query, all_inner_selections) + when :future + merged_max_complexity(query, all_inner_selections) + else + raise ArgumentError, "Expected :legacy or :future, not: #{mode.inspect}" + end complexity > max ? complexity : max end end @@ -158,7 +191,7 @@ def merged_max_complexity(query, inner_selections) # composites merge their maximums, while leaf scopes are always zero. # FieldsWillMerge validation assures all scopes are uniformly composite or leaf. maximum_children_cost = if child_scopes.any?(&:composite?) - merged_max_complexity_for_scopes(query, child_scopes) + merged_max_complexity_for_scopes(query, child_scopes, :future) else 0 end @@ -184,6 +217,47 @@ def merged_max_complexity(query, inner_selections) total + maximum_cost end end + + def legacy_merged_max_complexity(query, inner_selections) + # Aggregate a set of all unique field selection keys across all scopes. + # Use a hash, but ignore the values; it's just a fast way to work with the keys. + unique_field_keys = inner_selections.each_with_object({}) do |inner_selection, memo| + memo.merge!(inner_selection) + end + + # Add up the total cost for each unique field name's coalesced selections + unique_field_keys.each_key.reduce(0) do |total, field_key| + composite_scopes = nil + field_cost = 0 + + # Collect composite selection scopes for further aggregation, + # leaf selections report their costs directly. + inner_selections.each do |inner_selection| + child_scope = inner_selection[field_key] + next unless child_scope + + # Empty child scopes are leaf nodes with zero child complexity. + if child_scope.empty? + field_cost = child_scope.own_complexity(0) + field_complexity(child_scope, max_complexity: field_cost, child_complexity: nil) + else + composite_scopes ||= [] + composite_scopes << child_scope + end + end + + if composite_scopes + child_complexity = merged_max_complexity_for_scopes(query, composite_scopes, :legacy) + + # This is the last composite scope visited; assume it's representative (for backwards compatibility). + # Note: it would be more correct to score each composite scope and use the maximum possibility. + field_cost = composite_scopes.last.own_complexity(child_complexity) + field_complexity(composite_scopes.last, max_complexity: field_cost, child_complexity: child_complexity) + end + + total + field_cost + end + end end end end diff --git a/lib/graphql/execution/multiplex.rb b/lib/graphql/execution/multiplex.rb index b4b627160e3..a29dcc77956 100644 --- a/lib/graphql/execution/multiplex.rb +++ b/lib/graphql/execution/multiplex.rb @@ -36,6 +36,11 @@ def initialize(schema:, queries:, context:, max_complexity:) @tracers = schema.tracers + (context[:tracers] || []) @max_complexity = max_complexity @current_trace = context[:trace] ||= schema.new_trace(multiplex: self) + @logger = nil + end + + def logger + @logger ||= @schema.logger_for(context) end end end diff --git a/lib/graphql/query.rb b/lib/graphql/query.rb index 35e0f3067d9..df27ea31283 100644 --- a/lib/graphql/query.rb +++ b/lib/graphql/query.rb @@ -172,13 +172,7 @@ def initialize(schema, query_string = nil, query: nil, document: nil, context: n @result_values = nil @executed = false - @logger = if context && context[:logger] == false - Logger.new(IO::NULL) - elsif context && (l = context[:logger]) - l - else - schema.default_logger - end + @logger = schema.logger_for(context) end # If a document was provided to `GraphQL::Schema#execute` instead of the raw query string, we will need to get it from the document diff --git a/lib/graphql/schema.rb b/lib/graphql/schema.rb index c34d821b232..d19d0cc4f5c 100644 --- a/lib/graphql/schema.rb +++ b/lib/graphql/schema.rb @@ -1066,6 +1066,18 @@ def default_logger(new_default_logger = NOT_CONFIGURED) end end + # @param context [GraphQL::Query::Context, nil] + # @return [Logger] A logger to use for this context configuration, falling back to {.default_logger} + def logger_for(context) + if context && context[:logger] == false + Logger.new(IO::NULL) + elsif context && (l = context[:logger]) + l + else + default_logger + end + end + # @param new_context_class [Class] A subclass to use when executing queries def context_class(new_context_class = nil) if new_context_class @@ -1735,6 +1747,85 @@ def legacy_invalid_return_type_conflicts(query, type1, type2, node1, node2) raise "Implement #{self}.legacy_invalid_return_type_conflicts to handle this invalid selection" end + # The legacy complexity implementation included several bugs: + # + # - In some cases, it used the lexically _last_ field to determine a cost, instead of calculating the maximum among selections + # - In some cases, it called field complexity hooks repeatedly (when it should have only called them once) + # + # The future implementation may produce higher total complexity scores, so it's not active by default yet. You can opt into + # the future default behavior by configuring `:future` here. Or, you can choose a mode for each query with {.complexity_cost_calculation_mode_for}. + # + # The legacy mode is currently maintained alongside the future one, but it will be removed in a future GraphQL-Ruby version. + # + # If you choose `:compare`, you must also implement {.legacy_complexity_cost_calculation_mismatch} to handle the input somehow. + # + # @example Opting into the future calculation mode + # complexity_cost_calculation_mode(:future) + # + # @example Choosing the legacy mode (which will work until that mode is removed...) + # complexity_cost_calculation_mode(:legacy) + # + # @example Run both modes for every query, call {.legacy_complexity_cost_calculation_mismatch} when they don't match: + # complexity_cost_calculation_mode(:compare) + def complexity_cost_calculation_mode(new_mode = NOT_CONFIGURED) + if NOT_CONFIGURED.equal?(new_mode) + @complexity_cost_calculation_mode + else + @complexity_cost_calculation_mode = new_mode + end + end + + # Implement this method to produce a per-query complexity cost calculation mode. (Technically, it's per-multiplex.) + # + # This is a way to check the compatibility of queries coming to your API without adding overhead of running `:compare` + # for every query. You could sample traffic, turn it off/on with feature flags, or anything else. + # + # @example Sampling traffic + # def self.complexity_cost_calculation_mode_for(_context) + # if rand < 0.1 # 10% of the time + # :compare + # else + # :legacy + # end + # end + # + # @example Using a feature flag to manage future mode + # def complexity_cost_calculation_mode_for(context) + # current_user = context[:current_user] + # if Flipper.enabled?(:future_complexity_cost, current_user) + # :future + # elsif rand < 0.5 # 50% + # :compare + # else + # :legacy + # end + # end + # + # @param context [Hash] The context for the currently-running {Execution::Multiplex} (which contains one or more queries) + # @return [:future] Use the new calculation algorithm -- may be higher than `:legacy` + # @return [:legacy] Use the legacy calculation algorithm, warts and all + # @return [:compare] Run both algorithms and call {.legacy_complexity_cost_calculation_mismatch} if they don't match + def complexity_cost_calculation_mode_for(multiplex_context) + complexity_cost_calculation_mode + end + + # Implement this method in your schema to handle mismatches when `:compare` is used. + # + # @example Logging the mismatch + # def self.legacy_cost_calculation_mismatch(multiplex, future_cost, legacy_cost) + # client_id = multiplex.context[:api_client].id + # operation_names = multiplex.queries.map { |q| q.selected_operation_name || "anonymous" }.join(", ") + # Stats.increment(:complexity_mismatch, tags: { client: client_id, ops: operation_names }) + # legacy_cost + # end + # @param multiplex [GraphQL::Execution::Multiplex] + # @param future_complexity_cost [Integer] + # @param legacy_complexity_cost [Integer] + # @return [Integer] the cost to use for this query (probably one of `future_complexity_cost` or `legacy_complexity_cost`) + def legacy_complexity_cost_calculation_mismatch(multiplex, future_complexity_cost, legacy_complexity_cost) + 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" + end + private def add_trace_options_for(mode, new_options) From 99d1d7c3a0b8ff1788d17fb7609865369b0595c6 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Sat, 12 Apr 2025 06:42:42 -0400 Subject: [PATCH 4/4] Add tests for compat --- lib/graphql/analysis/query_complexity.rb | 4 +- lib/graphql/query/context.rb | 1 + lib/graphql/schema.rb | 2 + .../graphql/analysis/query_complexity_spec.rb | 230 ++++++++++++++---- spec/graphql/subscriptions_spec.rb | 2 + 5 files changed, 190 insertions(+), 49 deletions(-) diff --git a/lib/graphql/analysis/query_complexity.rb b/lib/graphql/analysis/query_complexity.rb index 38ca7bb86c4..92f384903fe 100644 --- a/lib/graphql/analysis/query_complexity.rb +++ b/lib/graphql/analysis/query_complexity.rb @@ -20,7 +20,7 @@ def result max_possible_complexity(mode: :legacy) when :compare future_complexity = max_possible_complexity - legacy_complexity = max_possible_complexity(mode: legacy) + legacy_complexity = max_possible_complexity(mode: :legacy) if future_complexity != legacy_complexity subject.schema.legacy_complexity_cost_calculation_mismatch(subject, future_complexity, legacy_complexity) else @@ -30,7 +30,7 @@ def result subject.logger.warn <<~GRAPHQL 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 - To opt into the future behavior, configure your schema with: + To opt into the future behavior, configure your schema (#{subject.schema.name ? subject.schema.name : subject.schema.ancestors}) with: complexity_cost_calculation_mode(:future) # or `:legacy`, `:compare` diff --git a/lib/graphql/query/context.rb b/lib/graphql/query/context.rb index 538a03adaaa..675bd91596f 100644 --- a/lib/graphql/query/context.rb +++ b/lib/graphql/query/context.rb @@ -59,6 +59,7 @@ def initialize(query:, schema: query.schema, values:) @scoped_context = ScopedContext.new(self) end + # Modify this hash to return extensions to client. # @return [Hash] A hash that will be added verbatim to the result hash, as `"extensions" => { ... }` def response_extensions namespace(:__query_result_extensions__) diff --git a/lib/graphql/schema.rb b/lib/graphql/schema.rb index d19d0cc4f5c..941cec0b072 100644 --- a/lib/graphql/schema.rb +++ b/lib/graphql/schema.rb @@ -1818,6 +1818,8 @@ def complexity_cost_calculation_mode_for(multiplex_context) # Stats.increment(:complexity_mismatch, tags: { client: client_id, ops: operation_names }) # legacy_cost # end + # @see Query::Context#add_error Adding an error to the response to notify the client + # @see Query::Context#response_extensions Adding key-value pairs to the response `"extensions" => { ... }` # @param multiplex [GraphQL::Execution::Multiplex] # @param future_complexity_cost [Integer] # @param legacy_complexity_cost [Integer] diff --git a/spec/graphql/analysis/query_complexity_spec.rb b/spec/graphql/analysis/query_complexity_spec.rb index c73f2dc05e4..9c2ba221fd3 100644 --- a/spec/graphql/analysis/query_complexity_spec.rb +++ b/spec/graphql/analysis/query_complexity_spec.rb @@ -2,7 +2,7 @@ require "spec_helper" describe GraphQL::Analysis::QueryComplexity do - let(:schema) { Dummy::Schema } + let(:schema) { Class.new(Dummy::Schema) { complexity_cost_calculation_mode(:future) } } let(:reduce_result) { GraphQL::Analysis.analyze_query(query, [GraphQL::Analysis::QueryComplexity]) } let(:reduce_multiplex_result) { GraphQL::Analysis.analyze_multiplex(multiplex, [GraphQL::Analysis::QueryComplexity]) @@ -249,7 +249,8 @@ end describe "relay types" do - let(:query) { GraphQL::Query.new(StarWars::Schema, query_string) } + let(:schema) { Class.new(StarWars::Schema) { complexity_cost_calculation_mode(:future) } } + let(:query) { GraphQL::Query.new(schema, query_string) } let(:query_string) {%| { rebels { @@ -368,7 +369,8 @@ end describe "Schema-level default_page_size" do - let(:query) { GraphQL::Query.new(StarWars::SchemaWithDefaultPageSize, query_string) } + let(:schema) { Class.new(StarWars::SchemaWithDefaultPageSize) { complexity_cost_calculation_mode(:future) } } + let(:query) { GraphQL::Query.new(schema, query_string) } let(:query_string) {%| { rebels { @@ -464,6 +466,7 @@ def complexity(int_value:) query(Query) orphan_types(DoubleComplexity) + complexity_cost_calculation_mode(:future) module CustomIntrospection class DynamicFields < GraphQL::Introspection::DynamicFields @@ -618,6 +621,7 @@ def resolve query(Query) orphan_types(DoubleComplexity) + complexity_cost_calculation_mode(:future) end let(:query) { GraphQL::Query.new(complexity_schema, query_string) } @@ -753,16 +757,22 @@ class Entity < GraphQL::Schema::Union end class Query < GraphQL::Schema::Object - field :entity, Entity + field :entity, Entity, fallback_value: nil end def self.resolve_type Farm end - def self.cost(query_string) + def self.cost(query_string_or_query) + query = if query_string_or_query.is_a?(String) + GraphQL::Query.new(self, query_string_or_query) + else + query_string_or_query + end + GraphQL::Analysis::AST.analyze_query( - GraphQL::Query.new(self, query_string), + query, [GraphQL::Analysis::AST::QueryComplexity], ).first end @@ -770,61 +780,187 @@ def self.cost(query_string) query(Query) end - it "uses maximum of merged composite fields, regardless of selection order" do - a = MaxOfPossibleScopes.cost(%| - { - entity { - ...on Producer { cheese { kind } } - ...on Farm { cheese { kind } } + describe "in :future mode" do + let(:schema) { Class.new(MaxOfPossibleScopes) { complexity_cost_calculation_mode(:future) }} + it "uses maximum of merged composite fields, regardless of selection order" do + a = schema.cost(%| + { + entity { + ...on Producer { cheese { kind } } + ...on Farm { cheese { kind } } + } } - } - |) + |) - b = MaxOfPossibleScopes.cost(%| - { - entity { - ...on Farm { cheese { kind } } - ...on Producer { cheese { kind } } + b = schema.cost(%| + { + entity { + ...on Farm { cheese { kind } } + ...on Producer { cheese { kind } } + } } - } - |) + |) - assert_equal 0, a - b + assert_equal 0, a - b + end + + it "uses maximum of merged leaf fields, regardless of selection order" do + a = schema.cost(%| + { + entity { + ...on Producer { name } + ...on Farm { name } + } + } + |) + + b = schema.cost(%| + { + entity { + ...on Farm { name } + ...on Producer { name } + } + } + |) + + assert_equal 0, a - b + end end - it "uses maximum of merged leaf fields, regardless of selection order" do - a = MaxOfPossibleScopes.cost(%| - { - entity { - ...on Producer { name } - ...on Farm { name } + describe "in :legacy mode" do + let(:schema) { Class.new(MaxOfPossibleScopes) { complexity_cost_calculation_mode(:legacy) }} + it "uses the last of merged composite fields" do + a = schema.cost(%| + { + entity { + ...on Producer { cheese { kind } } + ...on Farm { cheese { kind } } + } } - } - |) + |) - b = MaxOfPossibleScopes.cost(%| - { - entity { - ...on Farm { name } - ...on Producer { name } + b = schema.cost(%| + { + entity { + ...on Farm { cheese { kind } } + ...on Producer { cheese { kind } } + } } - } - |) + |) + + assert_equal 5, a - b + end - assert_equal 0, a - b + it "uses the last-occurring leaf field" do + a = schema.cost(%| + { + entity { + ...on Producer { name } + ...on Farm { name } + } + } + |) + + b = schema.cost(%| + { + entity { + ...on Farm { name } + ...on Producer { name } + } + } + |) + + assert_equal 5, a - b + end end - it "invalid mismatched scope types will still compute without error" do - cost = MaxOfPossibleScopes.cost(%| - { - entity { - ...on Farm { cheese { kind } } - ...on Producer { cheese: name } + describe "In dynamic mode with :compare" do + let(:schema) { + Class.new(MaxOfPossibleScopes) do + def self.complexity_cost_calculation_mode_for(context) + :compare + end + + def self.legacy_complexity_cost_calculation_mismatch(query, future_cpx, legacy_cpx) + query.context.response_extensions["complexity_warning"] = { + "current" => legacy_cpx, + "future" => future_cpx + } + 1003 + end + end + } + it "calls the handler and uses the returned value" do + query = GraphQL::Query.new(schema, %| + { + entity { + ...on Producer { cheese { kind } } + ...on Farm { cheese { kind } } + } } - } - |) + |) + a = schema.cost(query) + assert_equal 12, a + refute query.result.to_h.key?("extensions") + + queryb = GraphQL::Query.new(schema, %| + { + entity { + ...on Farm { cheese { kind } } + ...on Producer { cheese { kind } } + } + } + |) + b = schema.cost(queryb) + assert_equal 1003, b + assert_equal({"complexity_warning" => {"current" => 7, "future" => 12}}, queryb.result.to_h["extensions"]) + end + + it "calls the custom handler when leaf fields don't match" do + a = schema.cost(%| + { + entity { + ...on Producer { name } + ...on Farm { name } + } + } + |) + assert_equal 11, a + + b = schema.cost(%| + { + entity { + ...on Farm { name } + ...on Producer { name } + } + } + |) + assert_equal 1003, b + end + end + + describe "without a mode setting" do + it "warns, and invalid mismatched scope types will still compute without error" do + cost = nil - assert_equal 12, cost + stdout, _stderr = capture_io do + cost = MaxOfPossibleScopes.cost(%| + { + entity { + ...on Farm { cheese { kind } } + ...on Producer { cheese: name } + } + } + |) + end + + assert_equal 12, cost + assert_includes stdout, "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 + +To opt into the future behavior, configure your schema (MaxOfPossibleScopes) with: + + complexity_cost_calculation_mode(:future) # or `:legacy`, `:compare`" + end end end end diff --git a/spec/graphql/subscriptions_spec.rb b/spec/graphql/subscriptions_spec.rb index 580b64c7314..55c9cbe6cc5 100644 --- a/spec/graphql/subscriptions_spec.rb +++ b/spec/graphql/subscriptions_spec.rb @@ -226,6 +226,7 @@ class Schema < GraphQL::Schema subscription { Subscription } use InMemoryBackend::Subscriptions, extra: 123 max_complexity(InMemoryBackend::MAX_COMPLEXITY) + complexity_cost_calculation_mode(:future) use GraphQL::Schema::Warden if ADD_WARDEN end end @@ -286,6 +287,7 @@ class FromDefinitionInMemoryBackend < InMemoryBackend } Schema = GraphQL::Schema.from_definition(SchemaDefinition, default_resolve: Resolvers, using: {InMemoryBackend::Subscriptions => { extra: 123 }}) Schema.max_complexity(MAX_COMPLEXITY) + Schema.complexity_cost_calculation_mode(:future) # TODO don't hack this (no way to add metadata from IDL parser right now) Schema.get_field("Subscription", "myEvent").subscription_scope = :me end