The project is in a healthy, maintained state
Declare filtering, sorting, cursor pagination, and preloading on a graphql-ruby resolver instead of hand-writing them per endpoint. Pagination stays correct through association filters, and preloading is derived from the query's selection set.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

~> 2.0

Runtime

>= 7.0, < 9.0
~> 2.0
 Project Readme

graphql_declarative

Gem Version CI

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 is not the answer either. It does return a correct page when you sort by a base-table column, but it forces the database to build the whole joined result, dedupe it and sort it before LIMIT can apply - on a large table that is the entire join, every request. It also rejects ORDER BY on a joined column, since the sort expression is not in the DISTINCT set, and fails outright on json columns, which have no equality operator to dedupe on.

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.2, graphql ~> 2.0, ActiveRecord >= 7.0 (< 9.0). Every Ruby/ActiveRecord combination in that range is exercised by CI. 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. A NULL sort 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 AND only. No nested AND/OR/NOT filter trees.
  • No totalCount. It is an unbounded second query.
  • Cursors do not record the sort they were issued under. Changing sort_by mid-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