diff --git a/guides/queries/complexity_and_depth.md b/guides/queries/complexity_and_depth.md index b3294be8e8..5ac22b9ced 100644 --- a/guides/queries/complexity_and_depth.md +++ b/guides/queries/complexity_and_depth.md @@ -163,6 +163,26 @@ class Types::BaseField < GraphQL::Schema::Field end ``` +## Caching complexity + +Calculating complexity can be slow in larger monolithic applications. To help with this, QueryComplexity provides a digest that can be used for caching: + +```ruby +class CachedMaxQueryComplexity < GraphQL::Analyzer::MaxQueryComplexity + # You should use persistent cache store for production such as Rails.cache or Redis.new + NAIVE_CACHE = {} + + def max_possible_complexity + # Dynamic complexities such as lambda complexity or complexity_for method are impossible to cache + if @complexity_digest.key?(query) + NAIVE_CACHE.fetch(Digest::SHA256.hexdigest(@complexity_digest[query].sort_by(&:first).to_json)) { super } + else + super + end + end +end +``` + ## How complexity scoring works GraphQL Ruby's complexity scoring algorithm is biased towards selection fairness. While highly accurate, its results are not always intuitive. Here's an example query performed on the [Shopify Admin API](https://shopify.dev/docs/api/admin-graphql): diff --git a/lib/graphql/analysis/query_complexity.rb b/lib/graphql/analysis/query_complexity.rb index 985c1f5863..3d55e9d1b6 100644 --- a/lib/graphql/analysis/query_complexity.rb +++ b/lib/graphql/analysis/query_complexity.rb @@ -11,6 +11,8 @@ def initialize(query) @complexities_on_type_by_query = {} @intersect_cache = Hash.new { |h, k| h[k] = {}.compare_by_identity }.compare_by_identity @possible_types_cache = {}.compare_by_identity + @uncacheable = [] + @complexity_digest = Hash.new { |h, k| h[k] = Set.new } end # Override this method to use the complexity result @@ -85,14 +87,58 @@ def on_enter_field(node, parent, visitor) return if @skip_introspection_fields && visitor.field_definition.introspection? parent_type = visitor.parent_type_definition field_key = node.alias || node.name + field_definition = visitor.field_definition # 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 = scopes_stack.last[parent_type][field_key] ||= ScopedTypeComplexity.new(parent_type, field_definition, visitor.query, visitor.response_path) scope.nodes.push(node) scopes_stack.push(scope) + + # We already know we can't cache this query. + if @uncacheable.include?(visitor.query) + return + end + + # If the field definition is dynamically computed, we can't cache the complexity. + if field_definition.complexity.is_a?(Proc) || field_definition.respond_to?(:complexity_for) + @uncacheable << visitor.query + @complexity_digest.delete(visitor.query) + + return + end + + # Add the field definition to the complexity fields hash. + complexity_digest = [field_definition.name, field_definition.complexity] + + # The page size affects the complexity. + if field_definition.connection? + arguments = visitor.arguments_for(node, field_definition) + return unless arguments.is_a?(GraphQL::Execution::Interpreter::Arguments) # FIXME: Are errors by definition not cacheable? + max_possible_page_size = nil + + if arguments[:first] + max_possible_page_size = arguments[:first] + end + + if arguments[:last] && (max_possible_page_size.nil? || arguments[:last] > max_possible_page_size) + max_possible_page_size = arguments[:last] + end + + if max_possible_page_size.nil? + max_possible_page_size = field_definition.default_page_size || visitor.query.schema.default_page_size || field_definition.max_page_size || visitor.query.schema.default_max_page_size + end + + if max_possible_page_size.nil? + raise GraphQL::Error, "Can't calculate complexity for #{field_definition.path}, no `first:`, `last:`, `default_page_size`, `max_page_size` or `default_max_page_size`" + end + + complexity_digest << max_possible_page_size + end + + @complexity_digest[visitor.query] << complexity_digest end def on_leave_field(node, parent, visitor)