Complexity & Depth

GraphQL-Ruby ships with some validations based on query analysis. You can customize them as-needed, too.

Prevent deeply-nested queries

You can also reject queries based on the depth of their nesting. You can define max_depth at schema-level or query-level:

# Schema-level:
class MySchema < GraphQL::Schema
  # ...
  max_depth 15
end

# Query-level, which overrides the schema-level setting:
MySchema.execute(query_string, max_depth: 20)

By default, introspection fields are counted. The default introspection query requires at least max_depth 13. You can also configure your schema not to count introspection fields with max_depth ..., count_introspection_fields: false.

You can use nil to disable the validation:

# This query won't be validated:
MySchema.execute(query_string, max_depth: nil)

To get a feeling for depth of queries in your system, you can extend GraphQL::Analysis::AST::QueryDepth. Hook it up to log out values from each query:

class LogQueryDepth < GraphQL::Analysis::AST::QueryDepth
  def result
    query_depth = super
    message = "[GraphQL Query Depth] #{query_depth} || staff?  #{query.context[:current_user].staff?}"
    Rails.logger.info(message)
  end
end

class MySchema < GraphQL::Schema
  query_analyzer(LogQueryDepth)
end

Prevent complex queries

Fields have a “complexity” value which can be configured in their definition. It can be a constant (numeric) value, or a proc. If no complexity is defined for a field, it will default to a value of 1. It can be defined as a keyword or inside the configuration block. For example:

# Constant complexity:
field :top_score, Integer, null: false, complexity: 10

# Dynamic complexity:
field :top_scorers, [PlayerType], null: false do
  argument :limit, Integer, limit: false, default_value: 5
  complexity ->(ctx, args, child_complexity) {
    if ctx[:current_user].staff?
      # no limit for staff users
      0
    else
      # `child_complexity` is the value for selections
      # which were made on the items of this list.
      #
      # We don't know how many items will be fetched because
      # we haven't run the query yet, but we can estimate by
      # using the `limit` argument which we defined above.
      args[:limit] * child_complexity
    end
  }
end

Then, define your max_complexity at the schema-level:

class MySchema < GraphQL::Schema
  # ...
  max_complexity 100
end

Or, at the query-level, which overrides the schema-level setting:

MySchema.execute(query_string, max_complexity: 100)

Using nil will disable the validation:

# 😧 Anything goes!
MySchema.execute(query_string, max_complexity: nil)

To get a feeling for complexity of queries in your system, you can extend GraphQL::Analysis::AST::QueryComplexity. Hook it up to log out values from each query:

class LogQueryComplexityAnalyzer < GraphQL::Analysis::AST::QueryComplexity
  # Override this method to _do something_ with the calculated complexity value
  def result
    complexity = super
    message = "[GraphQL Query Complexity] #{complexity} | staff? #{query.context[:current_user].staff?}"
    Rails.logger.info(message)
  end
end

class MySchema < GraphQL::Schema
  query_analyzer(LogQueryComplexityAnalyzer)
end

Connection fields

By default, GraphQL-Ruby calculates a complexity value for connection fields by:

For example, this query has complexity 26:

query {
  author {              # +1
    name                # +1
    books(first: 10) {  # +1
      nodes {           # +10 (+1, multiplied by `first:` above)
        title           # +10 (ditto)
      }
      pageInfo {        # +1
        endCursor       # +1
      }
      totalCount        # +1
    }
  }
}

To customize this behavior, implement def calculate_complexity(query:, nodes:, child_complexity:) in your base field class, handling the case where self.connection? is true:

class Types::BaseField < GraphQL::Schema::Field
  def calculate_complexity(query:, nodes:, child_complexity:)
    if connection?
      # Custom connection calculation goes here
    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:

query {
  node(id: "123") { # interface Node
    id
    ...on HasMetafields { # interface HasMetafields
      metafield(key: "a") {
        value
      }
      metafields(first: 10) {
        nodes {
          value
        }
      }
    }
    ...on Product { # implements HasMetafields
      title
      metafield(key: "a") {
        definition {
          description
        }
      }
    }
    ...on PriceList {
      name
      catalog {
        id
      }
    }
  }
}

First, GraphQL Ruby allows field definitions to specify a complexity attribute that provides a complexity score (or a proc that computes a score) for each field. Let’s say that this schema defines a system where:

Given these parameters, we get an itemized scoring distribution of:

query {
  node(id: "123") { # 1, composite
    id # 0, leaf
    ...on HasMetafields {
      metafield(key: "a") { # 1, composite
        value # 0, leaf
      }
      metafields(first: 10) { # 1 * 10, connection
        nodes { # 1, composite
          value # 0, leaf
        }
      }
    }
    ...on Product {
      title # 0, leaf
      metafield(key: "a") { # 1, composite
        definition { # 1, composite
          description # 0, leaf
        }
      }
    }
    ...on PriceList {
      name # 0, leaf
      catalog { # 1, composite
        id # 0, leaf
      }
    }
  }
}

However, we cannot naively tally these itemized scores without over-costing the query. Consider:

To reconcile these possibilities, the complexity algorithm breaks the selection down into a tree of types mapped to possible selections, across which lexical selections can be coalesced and deduplicated (pseudocode):

{
  Schema::Query => {
    "node" => {
      Schema::Node => {
        "id" => nil,
      },
      Schema::HasMetafields => {
        "metafield" => {
          Schema::Metafield => {
            "value" => nil,
          },
        },
        "metafields" => {
          Schema::Metafield => {
            "nodes" => { ... },
          },
        },
      },
      Schema::Product => {
        "title" => nil,
        "metafield" => {
          Schema::Metafield => {
            "definition" => { ... },
          },
        },
      },
      Schema::PriceList => {
        "name" => nil,
        "catalog" => {
          Schema::Catalog => {
            "id" => nil,
          },
        },
      },
    },
  },
}

This aggregation provides a new perspective on the scoring where possible typed selections have costs rather than individual fields. In this normalized view, Product acquires the HasMetafields interface costs, and ignores a duplicated path. Ultimately the maximum of possible typed costs is used, making this query cost 12:

query {
  node(id: "123") { # max(11, 12, 1) = 12
    id
    ...on HasMetafields { # 1 + 10 = 11
      metafield(key: "a") { # 1
        value
      }
      metafields(first: 10) { # 10
        nodes {
          value
        }
      }
    }
    ...on Product { # 1 + 11 from HasMetafields = 12
      title
      metafield(key: "a") { # duplicated in HasMetafields
        definition { # 1
          description
        }
      }
    }
    ...on PriceList { # 1 = 1
      name
      catalog { # 1
        id
      }
    }
  }
}