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.
# Gemfile
gem "graphql-constant_queries", group: :testConfigure 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"
endPer-schema options (exceptions_path, context, spec_pattern) override the
globals. A type reachable from several schemas belongs to the first registered.
-
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 adeclares has_constant_queriesexample that fails until it does. -
C. Every field is checked.
has_constant_queriesgenerates 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
resolvingblock gets its own example, so its lets apply only to that check andrspec file:LINEreruns it. Every other check shares one example,resolving with default objects, which fails with every problem it found.
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 }.
- 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 liketotalCount. - 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_objectsreturnsnobjects. - 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.
A flat YAML list of Type.field keys whose checks don't pass yet:
- Types::Document.pageDataA 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.
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.
MIT.