# 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 in an IRep node def initialize(query) super @complexities_on_type_by_query = {} end # Overide this method to use the complexity result def result max_possible_complexity end class ScopedTypeComplexity # A single proc for {#scoped_children} hashes. Use this to avoid repeated allocations, # since the lexical binding isn't important. HASH_CHILDREN = ->(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 def initialize(parent_type, field_definition, query, response_path) @parent_type = parent_type @field_definition = field_definition @query = query @response_path = response_path @scoped_children = nil @nodes = [] end # @return [Array] attr_reader :nodes # Returns true if this field has no selections, ie, it's a scalar. # We need a quick way to check whether we should continue traversing. def terminal? @scoped_children.nil? end # This value is only calculated when asked for to avoid needless hash allocations. # Also, if it's never asked for, we determine that this scope complexity # is a scalar field ({#terminal?}). # @return [Hash ScopedTypeComplexity>] def scoped_children @scoped_children ||= Hash.new(&HASH_CHILDREN) end def own_complexity(child_complexity) @field_definition.calculate_complexity(query: @query, nodes: @nodes, child_complexity: child_complexity) 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 the complexity calculation for this field -- # if we're re-entering a selection, we'll already have one. # Otherwise, make a new one and store it. # # `node` and `visitor.field_definition` may appear from a cache, # but I think that's ok. If the arguments _didn't_ match, # then the query would have been rejected as invalid. complexities_on_type = @complexities_on_type_by_query[visitor.query] ||= [ScopedTypeComplexity.new(nil, nil, query, visitor.response_path)] complexity = complexities_on_type.last.scoped_children[parent_type][field_key] ||= ScopedTypeComplexity.new(parent_type, visitor.field_definition, visitor.query, visitor.response_path) complexity.nodes.push(node) # Push it on the stack. complexities_on_type.push(complexity) 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? complexities_on_type = @complexities_on_type_by_query[visitor.query] complexities_on_type.pop end private # @return [Integer] def max_possible_complexity @complexities_on_type_by_query.reduce(0) do |total, (query, complexities_on_type)| root_complexity = complexities_on_type.last # Use this entry point to calculate the total complexity total_complexity_for_query = merged_max_complexity_for_scopes(query, [root_complexity.scoped_children]) total + total_complexity_for_query end end # @param query [GraphQL::Query] Used for `query.possible_types` # @param scoped_children_hashes [Array] Array of scoped children hashes # @return [Integer] def merged_max_complexity_for_scopes(query, scoped_children_hashes) # Figure out what scopes are possible here. # Use a hash, but ignore the values; it's just a fast way to work with the keys. all_scopes = {} scoped_children_hashes.each do |h| all_scopes.merge!(h) end # If an abstract scope is present, but _all_ of its concrete types # are also in the list, remove it from the list of scopes to check, # because every possible type is covered by a concrete type. # (That is, there are no remainder types to check.) prev_keys = all_scopes.keys prev_keys.each do |scope| next unless scope.kind.abstract? missing_concrete_types = query.possible_types(scope).select { |t| !all_scopes.key?(t) } # This concrete type is possible _only_ as a member of the abstract type. # So, attribute to it the complexity which belongs to the abstract type. missing_concrete_types.each do |concrete_scope| all_scopes[concrete_scope] = all_scopes[scope] end all_scopes.delete(scope) end # This will hold `{ type => int }` pairs, one for each possible branch complexity_by_scope = {} # For each scope, # find the lexical selections that might apply to it, # and gather them together into an array. # Then, treat the set of selection hashes # as a set and calculate the complexity for them as a unit all_scopes.each do |scope, _| # These will be the selections on `scope` children_for_scope = [] scoped_children_hashes.each do |sc_h| sc_h.each do |inner_scope, children_hash| if applies_to?(query, scope, inner_scope) children_for_scope << children_hash end end end # Calculate the complexity for `scope`, merging all # possible lexical branches. complexity_value = merged_max_complexity(query, children_for_scope) complexity_by_scope[scope] = complexity_value end # Return the max complexity among all scopes complexity_by_scope.each_value.max end def applies_to?(query, left_scope, right_scope) if left_scope == right_scope # This can happen when several branches are being analyzed together true else # Check if these two scopes have _any_ types in common. possible_right_types = query.possible_types(right_scope) possible_left_types = query.possible_types(left_scope) !(possible_right_types & possible_left_types).empty? end 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 children_for_scope [Array] An array of `scoped_children[scope]` hashes # (`{field_key => complexity}`) # @return [Integer] Complexity value for all these selections in the current scope def merged_max_complexity(query, children_for_scope) all_keys = [] children_for_scope.each do |c| all_keys.concat(c.keys) end all_keys.uniq! complexity_for_keys = {} all_keys.each do |child_key| scoped_children_for_key = nil complexity_for_key = nil children_for_scope.each do |children_hash| next unless children_hash.key?(child_key) complexity_for_key = children_hash[child_key] if complexity_for_key.terminal? # Assume that all terminals would return the same complexity # Since it's a terminal, its child complexity is zero. complexity = complexity_for_key.own_complexity(0) complexity_for_keys[child_key] = complexity field_complexity(complexity_for_key, max_complexity: complexity, child_complexity: nil) else scoped_children_for_key ||= [] scoped_children_for_key << complexity_for_key.scoped_children end end next unless scoped_children_for_key child_complexity = merged_max_complexity_for_scopes(query, scoped_children_for_key) # This is the _last_ one we visited; assume it's representative. max_complexity = complexity_for_key.own_complexity(child_complexity) field_complexity(complexity_for_key, max_complexity: max_complexity, child_complexity: child_complexity) complexity_for_keys[child_key] = max_complexity end # Calculate the child complexity by summing the complexity of all selections complexity_for_keys.each_value.inject(0, &:+) end end end end end