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 +7 -0
- data/.rspec +3 -0
- data/.standard.yml +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +225 -0
- data/Rakefile +10 -0
- data/SPEC.md +336 -0
- data/bench/query_count.rb +77 -0
- data/gemfiles/ar_7.0.gemfile +10 -0
- data/gemfiles/ar_7.1.gemfile +7 -0
- data/gemfiles/ar_7.2.gemfile +7 -0
- data/gemfiles/ar_8.0.gemfile +7 -0
- data/lib/graphql_declarative/cursor.rb +174 -0
- data/lib/graphql_declarative/filter.rb +151 -0
- data/lib/graphql_declarative/filter_input.rb +120 -0
- data/lib/graphql_declarative/keyset_connection.rb +182 -0
- data/lib/graphql_declarative/preloader.rb +158 -0
- data/lib/graphql_declarative/resolver.rb +426 -0
- data/lib/graphql_declarative/sort.rb +196 -0
- data/lib/graphql_declarative/version.rb +5 -0
- data/lib/graphql_declarative.rb +16 -0
- data/sig/graphql_declarative.rbs +4 -0
- metadata +116 -0
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
data/.standard.yml
ADDED
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
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
|