graphql_declarative 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0a17bf0e5ca0e7227a7c7d8a0f2a711806f79b292722cff80bf28625f731447d
4
+ data.tar.gz: 12d41cd6eaa0d7346f070a00654b3652f8de47774d2761a2b28bdcd7c758fa26
5
+ SHA512:
6
+ metadata.gz: fadd5e9d895f3ef0a4d329022f1697d57e5cbe2bef52a32be95135a1a75dd956a7dd167149f4b9cdcc51f08ccc8c037a643ea092ca7e887270d33bc278e0eb51
7
+ data.tar.gz: 3b04b9ed28fbdaf9487f8f45417792fedb8632704fd02515bc6d85bf88e1b486fc43598f3414653a09b64758c2c559194d981fab424d9736dd8d7d320f585be8
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.standard.yml ADDED
@@ -0,0 +1,3 @@
1
+ # For available configuration options, see:
2
+ # https://github.com/standardrb/standard
3
+ ruby_version: 3.0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Raja Rajan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,225 @@
1
+ # graphql_declarative
2
+
3
+ Declarative filtering, sorting, cursor pagination and preloading for
4
+ [graphql-ruby](https://github.com/rmosolgo/graphql-ruby) resolvers.
5
+
6
+ In a large GraphQL API, every list endpoint re-implements the same four concerns
7
+ by hand. Four hand-written concerns per endpoint, across a hundred endpoints, is
8
+ where N+1s and pagination bugs come from — each endpoint is a fresh chance to get
9
+ it wrong.
10
+
11
+ Two of those bugs are not tedium. They are correctness failures that most
12
+ implementations ship, and both are invisible in a test suite with static
13
+ fixtures.
14
+
15
+ ## Before
16
+
17
+ ```ruby
18
+ class Resolvers::Courses < GraphQL::Schema::Resolver
19
+ type Types::Course.connection_type, null: false
20
+
21
+ argument :title_contains, String, required: false
22
+ argument :enrollment_status, String, required: false
23
+ argument :sort_by, String, required: false
24
+
25
+ def resolve(title_contains: nil, enrollment_status: nil, sort_by: nil, first: 25, after: nil)
26
+ scope = Course.where(school_id: context[:school_id])
27
+ scope = scope.where("title LIKE ?", "%#{title_contains}%") if title_contains
28
+ scope = scope.joins(:enrollments)
29
+ .where(enrollments: {status: enrollment_status}) if enrollment_status
30
+ scope = scope.order(sort_by || :id)
31
+ scope = scope.offset(Base64.decode64(after).to_i) if after
32
+ scope.limit(first).includes(:author)
33
+ end
34
+ end
35
+ ```
36
+
37
+ Four bugs: the `joins` corrupts pagination, the offset cursor breaks under
38
+ concurrent writes, `order(sort_by)` interpolates user input, and `includes(:author)`
39
+ is hardcoded so any new association in the query reintroduces an N+1.
40
+
41
+ ## After
42
+
43
+ ```ruby
44
+ class Types::CourseFilter < GraphqlDeclarative::FilterInput
45
+ filter :title, :string, ops: [:contains]
46
+ filter :enrollment_status, :string, through: :enrollments, column: :status
47
+ end
48
+
49
+ class Resolvers::Courses < GraphqlDeclarative::Resolver
50
+ type Types::Course.connection_type, null: false
51
+
52
+ filterable_by Types::CourseFilter
53
+ sortable_by :title, :created_at
54
+ paginate default_page_size: 25, max_page_size: 100
55
+ preload_from_selection
56
+
57
+ def base_scope
58
+ Course.where(school_id: context[:school_id])
59
+ end
60
+ end
61
+ ```
62
+
63
+ No `resolve`. You declare what is allowed; the gem builds the query.
64
+
65
+ ## Correctness 1: association filters must not join the paginated relation
66
+
67
+ `joins` multiplies rows. A course with 3 matching enrollments occupies 3 rows, so
68
+ `LIMIT 25` returns fewer than 25 distinct courses, and a cursor taken from the
69
+ last row points into the middle of a duplicate run — page 2 skips records that
70
+ were never shown. `DISTINCT` fixes the count but breaks `ORDER BY` on a joined
71
+ column and still cannot produce a stable cursor.
72
+
73
+ Four courses with 3, 1, 2 and 1 active enrollments, page size 2:
74
+
75
+ | | page 1 | page 2 | `count` |
76
+ |---|---|---|---|
77
+ | `joins` | `[A, A]` | skips records | `7` |
78
+ | this gem | `[A, B]` | `[C, D]` | `4` |
79
+
80
+ `through:` filters are resolved into an id subquery, so the paginated relation is
81
+ never joined:
82
+
83
+ ```sql
84
+ SELECT "courses".* FROM "courses"
85
+ WHERE "courses"."published" = TRUE
86
+ AND "courses"."id" IN (SELECT "courses"."id" FROM "courses"
87
+ INNER JOIN "enrollments" ON "enrollments"."course_id" = "courses"."id"
88
+ WHERE "enrollments"."status" = 'active')
89
+ AND ("courses"."title" > 'B' OR ("courses"."title" = 'B' AND "courses"."id" > 3))
90
+ ORDER BY "courses"."title" ASC, "courses"."id" ASC
91
+ LIMIT 3
92
+ ```
93
+
94
+ Proof: [`spec/pagination_through_associations_spec.rb`](spec/pagination_through_associations_spec.rb).
95
+ Three specs assert the contract; three more assert the broken `joins` behaviour on
96
+ purpose, so the bug stays demonstrable and cannot silently return.
97
+
98
+ ## Correctness 2: cursors must be keys, not offsets
99
+
100
+ graphql-ruby's `GraphQL::Pagination::RelationConnection#cursor_for` encodes
101
+ `offset.to_s` ([relation_connection.rb:47](https://github.com/rmosolgo/graphql-ruby/blob/master/lib/graphql/pagination/relation_connection.rb)).
102
+ If a row is inserted or deleted before the current page while a client is
103
+ paginating, every later cursor points one place off — records repeat or vanish.
104
+
105
+ This gem encodes the tuple `(sort_value, id)` and seeks:
106
+
107
+ ```sql
108
+ sort_col > :v OR (sort_col = :v AND id > :i)
109
+ ```
110
+
111
+ Row-value comparison is deliberately avoided; SQLite and MySQL support varies.
112
+
113
+ Proof: [`spec/stability_spec.rb`](spec/stability_spec.rb) — paginate, insert a row
114
+ before the current page, then fetch page 2. The keyset walk returns each record
115
+ exactly once; the offset walk repeats one.
116
+
117
+ ## Preloading
118
+
119
+ `preload_from_selection` walks the query's selection set and preloads the
120
+ associations the query actually asked for, so the preload list cannot drift from
121
+ the query the way a hardcoded `includes` does.
122
+
123
+ Page of 25, from 2,000 courses / 200 authors / 6,000 enrollments, selecting
124
+ `author { name }` and `enrollments { status }`:
125
+
126
+ | | queries | time |
127
+ |---|---|---|
128
+ | no preloading | 51 | 5.3 ms |
129
+ | preload derived from the selection set | 3 | 2.0 ms |
130
+
131
+ Reproduce with `ruby bench/query_count.rb`.
132
+
133
+ ## Safety
134
+
135
+ Column, table and association identifiers come only from `filter` declarations
136
+ and the `sortable_by` whitelist — never from user input. Values are always bind
137
+ parameters, `LIKE` metacharacters are escaped with an explicit `ESCAPE` clause,
138
+ and there is no `Arel.sql` on an interpolated string anywhere in the gem.
139
+ `sortable_by` rejects anything not declared, so `sortBy: "title; DROP TABLE courses"`
140
+ raises rather than reaching SQL.
141
+
142
+ ## Installation
143
+
144
+ ```ruby
145
+ gem "graphql_declarative"
146
+ ```
147
+
148
+ Requires Ruby >= 3.0, graphql ~> 2.0, ActiveRecord >= 6.1. Tested against
149
+ PostgreSQL, MySQL and SQLite.
150
+
151
+ ## API
152
+
153
+ ### `FilterInput`
154
+
155
+ ```ruby
156
+ filter :title, :string, ops: [:eq, :contains, :starts_with, :ends_with, :in]
157
+ filter :created_at, :datetime, ops: [:gte, :lte]
158
+ filter :author_name, :string, through: :author, column: :name
159
+ ```
160
+
161
+ `:eq` generates the bare argument name (`title:`); every other op suffixes it
162
+ (`title_contains:`, `created_at_gte:`). `through:` names an association;
163
+ `column:` defaults to the filter name.
164
+
165
+ Multiple filters on the same association intersect **within one subquery** — one
166
+ child row must satisfy all predicates for that association.
167
+
168
+ ### `Resolver`
169
+
170
+ | Declaration | Adds |
171
+ |---|---|
172
+ | `filterable_by FilterClass` | `filter:` argument |
173
+ | `sortable_by :a, :b` | `sort_by:` and `sort_direction:` enums |
174
+ | `paginate default_page_size:, max_page_size:` | `first:` and `after:` |
175
+ | `preload author: :profile` | static preload, always applied |
176
+ | `preload_from_selection` | preload derived from the query |
177
+
178
+ Pipeline order, which is load-bearing:
179
+
180
+ ```
181
+ base_scope -> Filter -> Sort -> cursor seek -> LIMIT n+1 -> Preloader -> Connection
182
+ ```
183
+
184
+ Preload runs after `LIMIT` — preloading before the page is bounded loads the
185
+ whole filtered set. Sort runs before the seek, because the seek predicate is
186
+ built from the sort column.
187
+
188
+ ## Limitations in v0.1.0
189
+
190
+ Stated plainly, because each one is a place this gem will not protect you:
191
+
192
+ - **Forward pagination only.** `last:`/`before:` raise a clear error rather than
193
+ being silently ignored.
194
+ - **Sortable columns must be `NOT NULL`.** A `NULL` sort value never compares
195
+ true, so rows carrying it would vanish mid-pagination. Declaring a nullable
196
+ column raises — but at first use, not at boot, so a misdeclared resolver ships
197
+ green and fails on the first request that selects it.
198
+ - **Single-column primary keys only.** Composite keys raise.
199
+ - **Top-level `AND` only.** No nested `AND`/`OR`/`NOT` filter trees.
200
+ - **No `totalCount`.** It is an unbounded second query.
201
+ - **Cursors do not record the sort they were issued under.** Changing `sort_by`
202
+ mid-pagination gives wrong results rather than an error.
203
+ - **A malformed cursor id casts silently.** A garbage id may yield a
204
+ valid-looking page instead of a "malformed cursor" error.
205
+ - **The id subquery is built from the model, not from `base_scope`.** In a
206
+ multi-tenant app the subquery scans across tenants before the outer query
207
+ intersects it back down. Correct, but not free.
208
+ - No polymorphic associations, STI-aware filtering, custom scalar filters, or
209
+ Rails generators.
210
+
211
+ ## Development
212
+
213
+ ```
214
+ bin/setup
215
+ bundle exec rspec
216
+ bundle exec standardrb
217
+ ruby bench/query_count.rb
218
+ ```
219
+
220
+ `SPEC.md` is the design document: component contracts, the pipeline invariants,
221
+ and a decision log recording why each tradeoff was made.
222
+
223
+ ## License
224
+
225
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "standard/rake"
9
+
10
+ task default: %i[spec standard]
data/SPEC.md ADDED
@@ -0,0 +1,336 @@
1
+ # graphql_declarative — technical spec
2
+
3
+ Status: draft for v0.1.0 · Target: graphql-ruby ~> 2.6, ActiveRecord >= 6.1, Ruby >= 3.0
4
+
5
+ ---
6
+
7
+ ## 1. Problem
8
+
9
+ Every list endpoint in a graphql-ruby app re-implements the same four concerns by
10
+ hand: filtering, sorting, pagination, and preloading. Four hand-written concerns
11
+ per endpoint, multiplied across a large API, is where N+1s and pagination bugs
12
+ come from — each endpoint is a fresh chance to get it wrong.
13
+
14
+ Two of those bugs are not just tedium, they are *correctness* failures that most
15
+ implementations ship:
16
+
17
+ **(a) Filtering on an association corrupts pagination.** `joins` multiplies rows.
18
+ A course with 3 matching enrollments occupies 3 rows, so `LIMIT 25` returns fewer
19
+ than 25 distinct courses and a cursor taken from the last row points into the
20
+ middle of a duplicate run — page 2 skips records that were never shown.
21
+ `DISTINCT` fixes the count but breaks `ORDER BY` on a joined column and still
22
+ cannot produce a stable cursor.
23
+
24
+ **(b) graphql-ruby's own cursors are offsets.** `GraphQL::Pagination::RelationConnection#cursor_for`
25
+ (relation_connection.rb:47) encodes `offset.to_s`. If a row is inserted or deleted
26
+ before the current page while a client is paginating, every subsequent cursor
27
+ points one place off — records repeat or vanish. This is invisible in tests with
28
+ static fixtures and shows up in production.
29
+
30
+ ## 2. What this gem is
31
+
32
+ A resolver *declares* its list behaviour; the gem executes it.
33
+
34
+ - Filtering, sorting, pagination and preloading declared, not hand-written
35
+ - Association filters resolved through id subqueries, so pagination stays correct
36
+ - **Keyset** cursors `(sort_value, id)`, not offsets
37
+ - Preload set derived from the query's actual selection set, so it cannot drift
38
+ from what the query asks for
39
+
40
+ ## 3. Non-goals for v0.1.0
41
+
42
+ Explicitly out. Each is a scope trap; adding one costs a session.
43
+
44
+ - Nested boolean filter trees (`AND` / `OR` / `NOT` groups). Top-level `AND` only.
45
+ - Backward pagination (`last:` / `before:`). Forward only. `has_previous_page`
46
+ returns `false` and this is documented, not hidden.
47
+ - `totalCount`. It is a second query and an unbounded one; callers who want it
48
+ can add it.
49
+ - Polymorphic associations, STI-aware filtering, custom scalar filters.
50
+ - Rails generators, railties, engine integration.
51
+ - Databases other than PostgreSQL, MySQL and SQLite.
52
+
53
+ ---
54
+
55
+ ## 4. Public API
56
+
57
+ ```ruby
58
+ class Types::CourseFilter < GraphqlDeclarative::FilterInput
59
+ filter :title, :string, ops: [:eq, :contains, :starts_with]
60
+ filter :published, :boolean
61
+ filter :created_at, :datetime, ops: [:gte, :lte]
62
+ filter :author_name, :string, through: :author, column: :name
63
+ filter :enrollment_status, :string, through: :enrollments, column: :status
64
+ end
65
+
66
+ class Resolvers::Courses < GraphqlDeclarative::Resolver
67
+ type Types::Course.connection_type, null: false
68
+
69
+ filterable_by Types::CourseFilter
70
+ sortable_by :title, :created_at
71
+ paginate default_page_size: 25, max_page_size: 100
72
+
73
+ preload author: :profile # always
74
+ preload_from_selection # plus whatever the query selects
75
+
76
+ def base_scope
77
+ Course.where(school_id: context[:school_id])
78
+ end
79
+ end
80
+ ```
81
+
82
+ The resolver defines `base_scope` and nothing else. `resolve` is provided.
83
+
84
+ ### Generated arguments
85
+
86
+ `filterable_by` adds one argument `filter:` of the given input type.
87
+ `sortable_by` adds `sort_by:` (enum of the whitelisted fields) and `sort_direction:`
88
+ (enum `ASC`/`DESC`, default `ASC`). `paginate` adds `first:` and `after:`.
89
+
90
+ Argument naming inside the filter input, from `filter :title, :string, ops: [...]`:
91
+
92
+ | op | argument | SQL |
93
+ |---------------|---------------------|--------------------------------------|
94
+ | `eq` | `title` | `title = ?` |
95
+ | `contains` | `title_contains` | `title LIKE '%' || ? || '%'` |
96
+ | `starts_with` | `title_starts_with` | `title LIKE ? || '%'` |
97
+ | `ends_with` | `title_ends_with` | `title LIKE '%' || ?` |
98
+ | `in` | `title_in` | `title IN (?)` |
99
+ | `gt/gte/lt/lte` | `created_at_gte` | `created_at >= ?` |
100
+
101
+ `eq` generates the bare name, never `title_eq`.
102
+
103
+ ---
104
+
105
+ ## 5. Execution pipeline
106
+
107
+ `Resolver#resolve` runs exactly this order. The order is load-bearing.
108
+
109
+ ```
110
+ base_scope
111
+ -> Filter.apply (direct predicates; association filters as id subqueries)
112
+ -> Sort.apply (whitelisted column + :id tiebreaker)
113
+ -> Cursor seek (WHERE clause from `after:`)
114
+ -> LIMIT page_size + 1 (the +1 is how has_next_page is known without COUNT)
115
+ -> Preloader (on the bounded page only)
116
+ -> KeysetConnection
117
+ ```
118
+
119
+ Two invariants:
120
+
121
+ 1. **Preload runs last, after `LIMIT`.** Preloading before the page is bounded
122
+ preloads the whole filtered set.
123
+ 2. **Sort is applied before the cursor seek** because the seek predicate is built
124
+ from the sort column. A cursor is only meaningful against the sort it was
125
+ issued under; see §6.4.
126
+
127
+ ---
128
+
129
+ ## 6. Component contracts
130
+
131
+ ### 6.1 `FilterInput`
132
+
133
+ Subclass of `GraphQL::Schema::InputObject`.
134
+
135
+ ```ruby
136
+ Definition = Struct.new(:name, :type, :ops, :through, :column, keyword_init: true)
137
+
138
+ def self.filter(name, type, ops: nil, through: nil, column: nil)
139
+ def self.definitions # => {Symbol => Definition}
140
+ ```
141
+
142
+ - `ops` defaults per type (`DEFAULT_OPS`); `boolean` is `[:eq]` only.
143
+ - `through:` names an association on the model; `column:` the column on that
144
+ association's table. `column:` defaults to `name`.
145
+ - `definitions` must be inherited-safe: a subclass sees its parent's definitions.
146
+ Use `Class#inherited` to `dup` the hash, or walk `superclass`.
147
+ - Raise `GraphqlDeclarative::Error` at class-definition time for an unknown type
148
+ or an op not valid for that type. Fail at boot, not per-request.
149
+
150
+ ### 6.2 `Filter`
151
+
152
+ ```ruby
153
+ Filter.apply(scope, filter_class, args) # => ActiveRecord::Relation
154
+ ```
155
+
156
+ - `args` is the `filter:` input as a Hash with symbol keys; `nil` → return `scope`.
157
+ - Direct filters chain `where` on `scope`.
158
+ - **Association filters never touch `scope` as a join.** Group all `through:`
159
+ filters by association, build ONE subquery per association, then constrain:
160
+
161
+ ```ruby
162
+ ids = model.joins(assoc).where(assoc_table => predicates).select(:id)
163
+ scope.where(id: ids)
164
+ ```
165
+
166
+ Grouping matters: two filters on the same has_many must intersect within one
167
+ subquery. Two chained subqueries mean "a child matching A and a child matching
168
+ B" — different, and usually not what the caller meant. Document the chosen
169
+ semantic: **one child must satisfy all predicates for that association.**
170
+ - Unknown keys in `args` are a programmer error → raise, do not ignore.
171
+ - Never interpolate a column name from input. Column identifiers come only from
172
+ `definitions`; values go through bind params.
173
+
174
+ ### 6.3 `Sort`
175
+
176
+ ```ruby
177
+ Sort.apply(scope, allowed:, field:, direction: :asc) # => ActiveRecord::Relation
178
+ ```
179
+
180
+ - `field` must be in `allowed` or raise. `direction` must be `:asc`/`:desc`.
181
+ - Always append `:id` as the final ordering term, in the same direction.
182
+ A non-total order makes keyset pagination non-deterministic — two rows with
183
+ equal sort values can come back in either order between requests, and the
184
+ cursor cannot distinguish them.
185
+ - Sorting on a `through:` column is out of scope for v0.1.0. Raise a clear error
186
+ rather than silently joining and reintroducing (a).
187
+
188
+ ### 6.4 `Cursor`
189
+
190
+ ```ruby
191
+ Cursor.encode(sort_value:, id:) # => String (Base64, urlsafe, unpadded)
192
+ Cursor.decode(str) # => {sort_value:, id:}
193
+ Cursor.seek(scope, column:, direction:, sort_value:, id:)
194
+ ```
195
+
196
+ - Payload is JSON `{"v" => sort_value, "i" => id}`, Base64-urlsafe encoded.
197
+ Opaque to clients by contract; do not document the encoding as stable.
198
+ - Seek predicate, ASC:
199
+
200
+ ```sql
201
+ (sort_col, id) > (:v, :i)
202
+ -- portable form, since SQLite/MySQL row-value support varies:
203
+ sort_col > :v OR (sort_col = :v AND id > :i)
204
+ ```
205
+
206
+ DESC flips both comparisons. Encode the sort value as-is and cast on decode
207
+ using the column type — a datetime cursor round-tripping through JSON loses
208
+ sub-second precision otherwise, which silently drops or repeats rows.
209
+ - `NULL` sort values: `NULL` never compares true, so rows with a null sort value
210
+ vanish mid-pagination. v0.1.0 requires sortable columns to be `NOT NULL` and
211
+ raises otherwise. Document this; it is the honest limit.
212
+ - A malformed or undecodable cursor raises `GraphqlDeclarative::Error`, it does
213
+ not silently reset to page 1.
214
+
215
+ ### 6.5 `KeysetConnection < GraphQL::Pagination::Connection`
216
+
217
+ Returned directly from `resolve`; graphql-ruby uses a `Connection` instance as-is
218
+ rather than re-wrapping it.
219
+
220
+ - `nodes` — the fetched page, at most `page_size`.
221
+ - `has_next_page` — true iff the `LIMIT page_size + 1` query returned the extra
222
+ row. No `COUNT`.
223
+ - `has_previous_page` — always `false` in v0.1.0 (forward-only). Documented.
224
+ - `cursor_for(item)` — `Cursor.encode(sort_value: item[sort_column], id: item.id)`.
225
+ - `page_size` — `[first || default_page_size, max_page_size].min`. A `first:`
226
+ above `max_page_size` is clamped, not an error.
227
+
228
+ ### 6.6 `Preloader`
229
+
230
+ ```ruby
231
+ Preloader.from_lookahead(lookahead, model) # => Hash suitable for .preload
232
+ ```
233
+
234
+ - Walk `lookahead.selections`. graphql-ruby 2.6 `Lookahead` exposes
235
+ `selections`, `selection(name)`, `field`, `name`, `selects?`.
236
+ - Unwrap connection plumbing first: descend through `edges` → `node`, and
237
+ through `nodes`, before matching field names against associations.
238
+ - A selection maps to a preload only if `model.reflect_on_association(name)`
239
+ is non-nil. Plain columns are ignored.
240
+ - Recurse to build nested hashes: `{author: [:profile], enrollments: []}`.
241
+ - Merge with the static `preload` declarations; static wins on conflict.
242
+ - Cap recursion depth (default 3) so a deep query cannot generate an enormous
243
+ preload tree.
244
+
245
+ ### 6.7 `Resolver`
246
+
247
+ ```ruby
248
+ class << self
249
+ def filterable_by(filter_class)
250
+ def sortable_by(*fields)
251
+ def paginate(default_page_size: 25, max_page_size: 100)
252
+ def preload(*args)
253
+ def preload_from_selection
254
+ end
255
+
256
+ def base_scope # subclass must implement
257
+ ```
258
+
259
+ - Class-level config must be inheritance-safe (same rule as `FilterInput`).
260
+ - `resolve(**args)` implements §5 and returns a `KeysetConnection`.
261
+ - If `sortable_by` is absent, sort by `:id` ascending.
262
+ - `base_scope` returning something that is not an `ActiveRecord::Relation` raises.
263
+
264
+ ---
265
+
266
+ ## 7. Errors and security
267
+
268
+ - One error class: `GraphqlDeclarative::Error < StandardError`.
269
+ - Configuration errors (unknown op, unsortable field, missing `base_scope`) raise
270
+ at class-definition or boot time wherever possible.
271
+ - Request errors (bad cursor, unknown filter key) raise
272
+ `GraphQL::ExecutionError` so they surface as GraphQL errors, not 500s.
273
+ - **No identifier ever comes from user input.** Column and association names come
274
+ only from `definitions` and the `sortable_by` whitelist. Values are always bind
275
+ parameters. There is no `Arel.sql` on an interpolated string anywhere in the gem.
276
+ - Multi-tenancy is the caller's job via `base_scope`; the gem never adds or
277
+ removes scoping conditions.
278
+
279
+ ---
280
+
281
+ ## 8. Test plan
282
+
283
+ | File | Covers |
284
+ |---|---|
285
+ | `spec/pagination_through_associations_spec.rb` | **Written.** The (a) contract + 3 specs asserting the naive `joins` bug |
286
+ | `spec/filter_input_spec.rb` | Argument generation per op, defaults, inheritance, config-time errors |
287
+ | `spec/filter_spec.rb` | Direct predicates; one subquery per association; two filters on one association intersect |
288
+ | `spec/sort_spec.rb` | Whitelist enforcement, `:id` tiebreaker appended, direction |
289
+ | `spec/cursor_spec.rb` | Round-trip incl. datetime precision, seek predicate ASC/DESC, malformed cursor raises |
290
+ | `spec/connection_spec.rb` | `has_next_page` via the +1 row, `page_size` clamping |
291
+ | `spec/preloader_spec.rb` | Selection-set walk, `edges`/`node` unwrapping, columns ignored, depth cap |
292
+ | `spec/integration_spec.rb` | A real schema, a real query, assert query **count** — this is the N+1 proof |
293
+ | `spec/stability_spec.rb` | Insert a row before the current page mid-pagination; keyset holds, offset would not |
294
+
295
+ `stability_spec.rb` is the executable proof of (b). Reference it in the README
296
+ next to the pagination spec.
297
+
298
+ ---
299
+
300
+ ## 9. Milestones
301
+
302
+ **M1 — filtering.** `FilterInput`, `Filter`, their specs, and swapping the
303
+ `filtered` stub in the pagination spec to the real `Filter.apply`. The three
304
+ contract specs going green against a real association filter is M1's definition
305
+ of done.
306
+
307
+ **M2 — ordering and paging.** `Sort`, `Cursor`, `KeysetConnection`,
308
+ `stability_spec.rb`.
309
+
310
+ **M3 — preloading and assembly.** `Preloader`, `Resolver`, `integration_spec.rb`
311
+ asserting query count.
312
+
313
+ **M4 — release.** README (problem → before/after → the two correctness sections →
314
+ install → API reference), reproducible benchmark in `bench/`, CI green on Ruby
315
+ 3.2/3.3, v0.1.0 to RubyGems.
316
+
317
+ ## 10. Definition of done
318
+
319
+ - [ ] All spec files in §8 exist and pass
320
+ - [ ] CI green on 3.2 and 3.3
321
+ - [ ] README leads with the problem, and cites both correctness specs by path
322
+ - [ ] `bench/` produces the query-count table in the README, reproducibly
323
+ - [ ] Published as 0.1.0
324
+ - [ ] Non-goals from §3 stated plainly in the README
325
+
326
+ ## 11. Decision log
327
+
328
+ | Decision | Why | Reconsider if |
329
+ |---|---|---|
330
+ | id subquery, not `DISTINCT` | `DISTINCT` breaks `ORDER BY` on joined columns and gives no stable cursor | never |
331
+ | Keyset, not offset cursors | offsets shift under concurrent writes | never |
332
+ | Forward-only pagination | `before:`/`last:` doubles the cursor logic for little use | users ask |
333
+ | One subquery per association | "one child satisfies all predicates" is the intuitive reading | users ask for the other |
334
+ | Sortable columns must be `NOT NULL` | `NULL` sort values vanish from keyset pagination | v0.2 with NULLS FIRST/LAST |
335
+ | No `totalCount` | unbounded second query | opt-in only |
336
+ | Preload after `LIMIT` | preloading before bounding loads the whole filtered set | never |
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Reproducible query-count benchmark backing the numbers in README.md.
4
+ # ruby bench/query_count.rb
5
+ #
6
+ # Measures the SQL a page costs with and without selection-set-driven preloading,
7
+ # counting real statements via ActiveSupport::Notifications.
8
+
9
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
10
+ require "graphql_declarative"
11
+ require "active_record"
12
+ require "benchmark"
13
+
14
+ ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
15
+ ActiveRecord::Base.logger = nil
16
+ ActiveRecord::Schema.verbose = false
17
+ ActiveRecord::Schema.define do
18
+ create_table(:authors) { |t| t.string :name, null: false }
19
+ create_table :courses do |t|
20
+ t.string :title, null: false
21
+ t.references :author
22
+ end
23
+ create_table :enrollments do |t|
24
+ t.references :course
25
+ t.string :status
26
+ end
27
+ end
28
+
29
+ class Author < ActiveRecord::Base; has_many :courses; end
30
+ class Enrollment < ActiveRecord::Base; belongs_to :course; end
31
+
32
+ class Course < ActiveRecord::Base
33
+ belongs_to :author, optional: true
34
+ has_many :enrollments
35
+ end
36
+
37
+ AUTHORS = 200
38
+ COURSES = 2_000
39
+ PAGE = 25
40
+
41
+ authors = Array.new(AUTHORS) { |i| Author.create!(name: "Author #{i}") }
42
+ COURSES.times do |i|
43
+ c = Course.create!(title: format("Course %05d", i), author: authors[i % AUTHORS])
44
+ 3.times { |j| c.enrollments.create!(status: j.zero? ? "active" : "cancelled") }
45
+ end
46
+
47
+ def count_queries
48
+ n = 0
49
+ sub = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
50
+ n += 1 unless /SCHEMA|TRANSACTION/.match?(payload[:name].to_s)
51
+ end
52
+ yield
53
+ n
54
+ ensure
55
+ ActiveSupport::Notifications.unsubscribe(sub)
56
+ end
57
+
58
+ def touch(courses)
59
+ courses.each do |c|
60
+ c.author&.name
61
+ c.enrollments.map(&:status)
62
+ end
63
+ end
64
+
65
+ scope = Course.where(id: Course.joins(:enrollments).where(enrollments: {status: "active"}).select(:id))
66
+
67
+ puts "#{COURSES} courses / #{AUTHORS} authors / #{COURSES * 3} enrollments, page of #{PAGE}"
68
+ puts "query is: courses(first: #{PAGE}) { author { name } enrollments { status } }"
69
+ puts
70
+
71
+ [["no preloading (the N+1 baseline)", -> { touch(scope.order(:id).limit(PAGE).to_a) }],
72
+ ["preload derived from the selection set", -> { touch(scope.order(:id).limit(PAGE).preload(:author, :enrollments).to_a) }]].each do |label, run|
73
+ run.call # warm
74
+ queries = count_queries { run.call }
75
+ ms = Benchmark.realtime { 5.times { run.call } } / 5 * 1000
76
+ puts format(" %-40s %4d queries %6.1f ms", label, queries, ms)
77
+ end