graphql_declarative
Declarative filtering, sorting, cursor pagination and preloading for graphql-ruby resolvers.
In a large GraphQL API, every list endpoint re-implements the same four concerns by hand. Four hand-written concerns per endpoint, across a hundred endpoints, is where N+1s and pagination bugs come from — each endpoint is a fresh chance to get it wrong.
Two of those bugs are not tedium. They are correctness failures that most implementations ship, and both are invisible in a test suite with static fixtures.
Before
class Resolvers::Courses < GraphQL::Schema::Resolver
type Types::Course.connection_type, null: false
argument :title_contains, String, required: false
argument :enrollment_status, String, required: false
argument :sort_by, String, required: false
def resolve(title_contains: nil, enrollment_status: nil, sort_by: nil, first: 25, after: nil)
scope = Course.where(school_id: context[:school_id])
scope = scope.where("title LIKE ?", "%#{title_contains}%") if title_contains
scope = scope.joins(:enrollments)
.where(enrollments: {status: enrollment_status}) if enrollment_status
scope = scope.order(sort_by || :id)
scope = scope.offset(Base64.decode64(after).to_i) if after
scope.limit(first).includes(:author)
end
end
Four bugs: the joins corrupts pagination, the offset cursor breaks under
concurrent writes, order(sort_by) interpolates user input, and includes(:author)
is hardcoded so any new association in the query reintroduces an N+1.
After
class Types::CourseFilter < GraphqlDeclarative::FilterInput
filter :title, :string, ops: [:contains]
filter :enrollment_status, :string, through: :enrollments, column: :status
end
class Resolvers::Courses < GraphqlDeclarative::Resolver
type Types::Course.connection_type, null: false
filterable_by Types::CourseFilter
sortable_by :title, :created_at
paginate default_page_size: 25, max_page_size: 100
preload_from_selection
def base_scope
Course.where(school_id: context[:school_id])
end
end
No resolve. You declare what is allowed; the gem builds the query.
Correctness 1: association filters must not join the paginated relation
joins multiplies rows. A course with 3 matching enrollments occupies 3 rows, so
LIMIT 25 returns fewer than 25 distinct courses, and a cursor taken from the
last row points into the middle of a duplicate run — page 2 skips records that
were never shown. DISTINCT fixes the count but breaks ORDER BY on a joined
column and still cannot produce a stable cursor.
Four courses with 3, 1, 2 and 1 active enrollments, page size 2:
| page 1 | page 2 | count |
|
|---|---|---|---|
joins |
[A, A] |
skips records | 7 |
| this gem | [A, B] |
[C, D] |
4 |
through: filters are resolved into an id subquery, so the paginated relation is
never joined:
SELECT "courses".* FROM "courses"
WHERE "courses"."published" = TRUE
AND "courses"."id" IN (SELECT "courses"."id" FROM "courses"
INNER JOIN "enrollments" ON "enrollments"."course_id" = "courses"."id"
WHERE "enrollments"."status" = 'active')
AND ("courses"."title" > 'B' OR ("courses"."title" = 'B' AND "courses"."id" > 3))
ORDER BY "courses"."title" ASC, "courses"."id" ASC
LIMIT 3
Proof: spec/pagination_through_associations_spec.rb.
Three specs assert the contract; three more assert the broken joins behaviour on
purpose, so the bug stays demonstrable and cannot silently return.
Correctness 2: cursors must be keys, not offsets
graphql-ruby's GraphQL::Pagination::RelationConnection#cursor_for encodes
offset.to_s (relation_connection.rb:47).
If a row is inserted or deleted before the current page while a client is
paginating, every later cursor points one place off — records repeat or vanish.
This gem encodes the tuple (sort_value, id) and seeks:
sort_col > :v OR (sort_col = :v AND id > :i)
Row-value comparison is deliberately avoided; SQLite and MySQL support varies.
Proof: spec/stability_spec.rb — paginate, insert a row
before the current page, then fetch page 2. The keyset walk returns each record
exactly once; the offset walk repeats one.
Preloading
preload_from_selection walks the query's selection set and preloads the
associations the query actually asked for, so the preload list cannot drift from
the query the way a hardcoded includes does.
Page of 25, from 2,000 courses / 200 authors / 6,000 enrollments, selecting
author { name } and enrollments { status }:
| queries | time | |
|---|---|---|
| no preloading | 51 | 5.3 ms |
| preload derived from the selection set | 3 | 2.0 ms |
Reproduce with ruby bench/query_count.rb.
Safety
Column, table and association identifiers come only from filter declarations
and the sortable_by whitelist — never from user input. Values are always bind
parameters, LIKE metacharacters are escaped with an explicit ESCAPE clause,
and there is no Arel.sql on an interpolated string anywhere in the gem.
sortable_by rejects anything not declared, so sortBy: "title; DROP TABLE courses"
raises rather than reaching SQL.
Installation
gem "graphql_declarative"
Requires Ruby >= 3.0, graphql ~> 2.0, ActiveRecord >= 6.1. Tested against PostgreSQL, MySQL and SQLite.
API
FilterInput
filter :title, :string, ops: [:eq, :contains, :starts_with, :ends_with, :in]
filter :created_at, :datetime, ops: [:gte, :lte]
filter :author_name, :string, through: :author, column: :name
:eq generates the bare argument name (title:); every other op suffixes it
(title_contains:, created_at_gte:). through: names an association;
column: defaults to the filter name.
Multiple filters on the same association intersect within one subquery — one child row must satisfy all predicates for that association.
Resolver
| Declaration | Adds |
|---|---|
filterable_by FilterClass |
filter: argument |
sortable_by :a, :b |
sort_by: and sort_direction: enums |
paginate default_page_size:, max_page_size: |
first: and after: |
preload author: :profile |
static preload, always applied |
preload_from_selection |
preload derived from the query |
Pipeline order, which is load-bearing:
base_scope -> Filter -> Sort -> cursor seek -> LIMIT n+1 -> Preloader -> Connection
Preload runs after LIMIT — preloading before the page is bounded loads the
whole filtered set. Sort runs before the seek, because the seek predicate is
built from the sort column.
Limitations in v0.1.0
Stated plainly, because each one is a place this gem will not protect you:
- Forward pagination only.
last:/before:raise a clear error rather than being silently ignored. - Sortable columns must be
NOT NULL. ANULLsort value never compares true, so rows carrying it would vanish mid-pagination. Declaring a nullable column raises — but at first use, not at boot, so a misdeclared resolver ships green and fails on the first request that selects it. - Single-column primary keys only. Composite keys raise.
- Top-level
ANDonly. No nestedAND/OR/NOTfilter trees. - No
totalCount. It is an unbounded second query. - Cursors do not record the sort they were issued under. Changing
sort_bymid-pagination gives wrong results rather than an error. - A malformed cursor id casts silently. A garbage id may yield a valid-looking page instead of a "malformed cursor" error.
- The id subquery is built from the model, not from
base_scope. In a multi-tenant app the subquery scans across tenants before the outer query intersects it back down. Correct, but not free. - No polymorphic associations, STI-aware filtering, custom scalar filters, or Rails generators.
Development
bin/setup
bundle exec rspec
bundle exec standardrb
ruby bench/query_count.rb
SPEC.md is the design document: component contracts, the pipeline invariants,
and a decision log recording why each tradeoff was made.
License
MIT