Skip to content

About

Prove every graphql-ruby field issues a constant number of SQL statements: auto-generated RSpec checks per field that fail on N+1s.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

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.

About

Prove every graphql-ruby field issues a constant number of SQL statements: auto-generated RSpec checks per field that fail on N+1s.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages