diff --git a/lib/graphql/analysis/query_complexity.rb b/lib/graphql/analysis/query_complexity.rb index 6d59b3dd5d..92f384903f 100644 --- a/lib/graphql/analysis/query_complexity.rb +++ b/lib/graphql/analysis/query_complexity.rb @@ -13,7 +13,32 @@ def initialize(query) # 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 (#{subject.schema.name ? subject.schema.name : subject.schema.ancestors}) 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: @@ -44,6 +69,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) @@ -77,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| @@ -115,14 +145,20 @@ 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 def types_intersect?(query, a, b) return true if a == b - a_types = query.types.possible_types(a) query.types.possible_types(b).any? { |t| a_types.include?(t) } end @@ -145,6 +181,50 @@ def merged_max_complexity(query, inner_selections) 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, :future) + 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 + + 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 @@ -167,7 +247,7 @@ def merged_max_complexity(query, inner_selections) end if composite_scopes - child_complexity = merged_max_complexity_for_scopes(query, 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. diff --git a/lib/graphql/execution/multiplex.rb b/lib/graphql/execution/multiplex.rb index b4b627160e..a29dcc7795 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 35e0f3067d..df27ea3128 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/query/context.rb b/lib/graphql/query/context.rb index 538a03adaa..675bd91596 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 c34d821b23..941cec0b07 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,87 @@ 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 + # @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] + # @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) diff --git a/spec/graphql/analysis/query_complexity_spec.rb b/spec/graphql/analysis/query_complexity_spec.rb index 931329a1e9..9c2ba221fd 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) } @@ -723,10 +727,240 @@ 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, fallback_value: nil + end + + def self.resolve_type + Farm + end + + 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( + query, + [GraphQL::Analysis::AST::QueryComplexity], + ).first + end + + query(Query) + end + + 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 = schema.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 = 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 + + 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 = schema.cost(%| + { + entity { + ...on Farm { cheese { kind } } + ...on Producer { cheese { kind } } + } + } + |) + + assert_equal 5, a - b + end + + 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 + + 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 + + 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 580b64c731..55c9cbe6cc 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