The project is in a healthy, maintained state
Generates a check for every field of every object type in a graphql-ruby schema that fails when the field's ActiveRecord statement count grows with the number of objects it resolves across.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

 Project Readme

graphql-constant_queries

Prove, in tests, that every field of every graphql-ruby object type issues the same number of ActiveRecord SQL statements whether it resolves across 2 objects or 3. A field whose count grows with the object count has an N+1, and it fails in the spec of the type that causes it.

Setup

# Gemfile
gem "graphql-constant_queries", group: :test

Configure it after the app boots but before spec files load, e.g. in spec/support/constant_queries.rb required from rails_helper.rb (or wherever the app is loaded):

require "graphql/constant_queries/rspec"

GraphQL::ConstantQueries.configure do |c|
  c.source_root = Rails.root                   # default: Dir.pwd
  c.context = -> { {current_user: user} }      # evaluated in each example; default: {}
  c.schema MySchema, exceptions_path: "spec/graphql/my_schema_constant_queries.yml"
end

Per-schema options (exceptions_path, context, spec_pattern) override the globals. A type reachable from several schemas belongs to the first registered.

The three checks

  • A. Every type has a spec. In the schema's spec:

    RSpec.describe MySchema do
      has_specs_for_all_types except: /Error\z/
    end

    except: (a Regexp or an array of them, matched against type names) only relaxes this check: an excluded type that has a spec still gets B.

  • B. Every type spec declares has_constant_queries. The spec at a type's conventional path gets a declares has_constant_queries example that fails until it does.

  • C. Every field is checked. has_constant_queries generates a check per field:

    RSpec.describe Types::Document do
      has_constant_queries do
        let(:user) { create(:user, :admin) }                  # context for every check
        default_objects { |n| create_list(:document, n, user: user) }  # required
    
        resolving :deleted_by_user do                          # customize a field's check
          objects { |n| create_list(:document, n, :deleted, user: user) }
        end
        resolving :page_data, page: 1                          # add a check with arguments
        resolving :page_data, page: -> { create(:page).number }  # evaluated in the example
        resolving :owner_permissions do
          let!(:owner) { create(:user) }                       # applies to this check only
          objects { |n| create_list(:document, n, user: owner) }
        end
      end
    end

    Keywords are the field's Ruby argument names. A resolving block gets its own example, so its lets apply only to that check and rspec file:LINE reruns it. Every other check shares one example, resolving with default objects, which fails with every problem it found.

Spec paths

spec_pattern defaults to spec/%{source}_spec.rb, where %{source} is the type's file relative to source_root/app without .rb (app/graphql/types/foo.rb → spec/graphql/types/foo_spec.rb). A type defined in a file named for something else, like a generated wrapper, gets the path autoloading expects: its underscored name under the same top-level directory. %{name} is the underscored class name. spec_pattern may also be a callable ->(type) { path }.

Writing good checks

  • Single objects need data. A field returning one object must be non-null on every object, because a nil foreign key skips the load and proves nothing.
  • Vary the records resolvers walk. Repeated loads of one record hit the query cache, so objects sharing a parent hide per-object loads. Give each object its own parent.
  • Lists and connections may be empty. Their load still runs per object. Connections select nodes { __typename } plus argument-free counters like totalCount.
  • Hidden objects fail. A null object means the authorizer hid it and the field never resolved.
  • Hidden fields aren't checked. A field not visible in the check's context is left out; an example with no visible fields is skipped.
  • Empty builds prove nothing. Make sure default_objects returns n objects.
  • Single objects are checked one level deep. A single-object field selects only __typename, so the returned object's authorizer runs but its fields don't.
  • Runs happen under the ActiveRecord query cache, as in a real request. Each run reloads its objects and the records in context, and each check runs in a savepoint, so nothing one run loads or writes carries into the next. Objects that aren't persisted records are rebuilt for each check.

Exceptions file

A flat YAML list of Type.field keys whose checks don't pass yet:

- Types::Document.pageData

A key covers every check of the field. Once all of them pass, the type's group fails until you remove the key. Runs filtered to some of a field's checks never report it. Every key must name a real field of a type in that schema, or the file fails to load. The list only shrinks; don't add keys for new code.

What it proves

Objects reach the type through a test-only subclass of the real schema whose only root field is objects: [Type], so every type is checked as a list even if clients only reach it one at a time. That proves each field batches across several objects, which also covers aliased siblings. It doesn't cover:

  • root fields (query, mutation, subscription), generated mutation payloads, connections, and edges;
  • behavior that depends on what an ancestor field preloaded or put in context, except as far as a spec's lets set the same context.

License

MIT.