quo 1.0.0.beta1 → 2.0.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 +4 -4
- data/.devcontainer/Dockerfile +17 -0
- data/.devcontainer/compose.yml +10 -0
- data/.devcontainer/devcontainer.json +12 -0
- data/Appraisals +4 -12
- data/CHANGELOG.md +112 -1
- data/CLAUDE.md +19 -0
- data/Gemfile +7 -1
- data/README.md +379 -75
- data/Rakefile +66 -6
- data/UPGRADING.md +216 -0
- data/badges/coverage_badge_total.svg +35 -0
- data/badges/rubycritic_badge_score.svg +35 -0
- data/claude-skill/README.md +100 -0
- data/claude-skill/SKILL.md +442 -0
- data/claude-skill/references/API_REFERENCE.md +462 -0
- data/claude-skill/references/COMPOSITION.md +396 -0
- data/claude-skill/references/PAGINATION.md +396 -0
- data/claude-skill/references/QUERY_TYPES.md +297 -0
- data/claude-skill/references/TRANSFORMERS.md +282 -0
- data/context/01-core-architecture.md +247 -0
- data/context/02-query-types-implementation.md +355 -0
- data/context/03-composition-transformation.md +441 -0
- data/context/04-pagination-results.md +485 -0
- data/context/05-testing-configuration.md +491 -0
- data/context/06-advanced-patterns-examples.md +153 -0
- data/gemfiles/rails_8.0.gemfile +10 -5
- data/gemfiles/rails_8.1.gemfile +20 -0
- data/lib/generators/quo/install/USAGE +21 -0
- data/lib/generators/quo/install/install_generator.rb +63 -0
- data/lib/quo/collection_backed_query.rb +21 -15
- data/lib/quo/collection_results.rb +1 -0
- data/lib/quo/composed_collection_backed_query.rb +42 -0
- data/lib/quo/composed_instance.rb +144 -0
- data/lib/quo/composed_query.rb +43 -178
- data/lib/quo/composed_relation_backed_query.rb +42 -0
- data/lib/quo/composing/base_strategy.rb +22 -0
- data/lib/quo/composing/class_strategy.rb +86 -0
- data/lib/quo/composing/class_strategy_registry.rb +31 -0
- data/lib/quo/composing/query_classes_strategy.rb +38 -0
- data/lib/quo/composing.rb +81 -0
- data/lib/quo/engine.rb +1 -0
- data/lib/quo/minitest/helpers.rb +14 -24
- data/lib/quo/preloadable.rb +1 -0
- data/lib/quo/query.rb +22 -5
- data/lib/quo/relation_backed_query.rb +24 -18
- data/lib/quo/relation_backed_query_specification.rb +44 -25
- data/lib/quo/relation_results.rb +1 -0
- data/lib/quo/results.rb +31 -2
- data/lib/quo/rspec/helpers.rb +15 -26
- data/lib/quo/testing/collection_backed_fake.rb +1 -0
- data/lib/quo/testing/fake_helpers.rb +30 -0
- data/lib/quo/testing/relation_backed_fake.rb +1 -0
- data/lib/quo/version.rb +1 -1
- data/lib/quo/wrapped_collection_backed_query.rb +21 -0
- data/lib/quo/wrapped_relation_backed_query.rb +21 -0
- data/lib/quo.rb +8 -0
- data/quo.png +0 -0
- data/sig/generated/quo/collection_backed_query.rbs +10 -4
- data/sig/generated/quo/collection_results.rbs +1 -0
- data/sig/generated/quo/composed_collection_backed_query.rbs +25 -0
- data/sig/generated/quo/composed_instance.rbs +61 -0
- data/sig/generated/quo/composed_query.rbs +23 -56
- data/sig/generated/quo/composed_relation_backed_query.rbs +25 -0
- data/sig/generated/quo/composing/base_strategy.rbs +16 -0
- data/sig/generated/quo/composing/class_strategy.rbs +38 -0
- data/sig/generated/quo/composing/class_strategy_registry.rbs +16 -0
- data/sig/generated/quo/composing/query_classes_strategy.rbs +24 -0
- data/sig/generated/quo/composing.rbs +40 -0
- data/sig/generated/quo/engine.rbs +1 -0
- data/sig/generated/quo/minitest/helpers.rbs +12 -0
- data/sig/generated/quo/preloadable.rbs +1 -0
- data/sig/generated/quo/query.rbs +15 -4
- data/sig/generated/quo/relation_backed_query.rbs +15 -5
- data/sig/generated/quo/relation_backed_query_specification.rbs +47 -39
- data/sig/generated/quo/relation_results.rbs +1 -0
- data/sig/generated/quo/results.rbs +11 -0
- data/sig/generated/quo/rspec/helpers.rbs +12 -0
- data/sig/generated/quo/testing/collection_backed_fake.rbs +1 -0
- data/sig/generated/quo/testing/fake_helpers.rbs +14 -0
- data/sig/generated/quo/testing/relation_backed_fake.rbs +1 -0
- data/sig/generated/quo/wrapped_collection_backed_query.rbs +13 -0
- data/sig/generated/quo/wrapped_relation_backed_query.rbs +13 -0
- data/sig/generated/quo.rbs +1 -0
- data/website/.gitignore +6 -0
- data/website/.nojekyll +0 -0
- data/website/404.html +26 -0
- data/website/Gemfile +24 -0
- data/website/_config.yml +50 -0
- data/website/_data/navigation.yml +8 -0
- data/website/_data/sidebar.yml +2 -0
- data/website/_data/social_links.yml +3 -0
- data/website/_docs/api.md +261 -0
- data/website/_docs/get-started.md +289 -0
- data/website/assets/quo.png +0 -0
- data/website/index.md +141 -0
- metadata +70 -13
- data/gemfiles/rails_7.0.gemfile +0 -15
- data/gemfiles/rails_7.1.gemfile +0 -15
- data/gemfiles/rails_7.2.gemfile +0 -15
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
# Pagination Reference
|
|
2
|
+
|
|
3
|
+
> **Targets Quo `~> 2.0`.**
|
|
4
|
+
|
|
5
|
+
Quo provides built-in pagination for both `RelationBackedQuery` and
|
|
6
|
+
`CollectionBackedQuery`. Pagination is consistent across query types and
|
|
7
|
+
integrates with composition and transformers without surprise.
|
|
8
|
+
|
|
9
|
+
## Page parameters
|
|
10
|
+
|
|
11
|
+
Every Quo query accepts two pagination kwargs:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
query = CommentsByAuthorQuery.new(
|
|
15
|
+
author_id: 1,
|
|
16
|
+
page: 2, # 1-indexed
|
|
17
|
+
page_size: 25
|
|
18
|
+
)
|
|
19
|
+
results = query.results
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### Defaults
|
|
23
|
+
|
|
24
|
+
`page` has no default — it stays `nil` unless you pass it, and a query
|
|
25
|
+
with `nil` page is unpaginated. `page_size` defaults to
|
|
26
|
+
`Quo.default_page_size` (20).
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
query = CommentsByAuthorQuery.new(author_id: 1)
|
|
30
|
+
query.page # => nil
|
|
31
|
+
query.page_size # => 20 (or whatever Quo.default_page_size is)
|
|
32
|
+
query.paged? # => false
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
You can configure the default page size globally:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
# config/initializers/quo.rb
|
|
39
|
+
Quo.default_page_size = 25
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
There's also a hard cap (`Quo.max_page_size`, default 200) that protects
|
|
43
|
+
against runaway page sizes from untrusted input.
|
|
44
|
+
|
|
45
|
+
### Checking pagination status
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
query = CommentsByAuthorQuery.new(author_id: 1, page: 1)
|
|
49
|
+
query.paged? # => true
|
|
50
|
+
|
|
51
|
+
unpaginated = CommentsByAuthorQuery.new(author_id: 1, page: nil)
|
|
52
|
+
unpaginated.paged? # => false
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Working with results
|
|
56
|
+
|
|
57
|
+
### Counts
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
query = CommentsByAuthorQuery.new(author_id: 1, page: 2, page_size: 25)
|
|
61
|
+
results = query.results
|
|
62
|
+
|
|
63
|
+
results.count # total across all pages
|
|
64
|
+
results.page_count # rows in current page
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Existence
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
results.empty?
|
|
71
|
+
results.exists?
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Iteration
|
|
75
|
+
|
|
76
|
+
Standard `Enumerable` works:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
results.each { |c| ... }
|
|
80
|
+
results.map(&:body)
|
|
81
|
+
results.select { |c| c.read? }
|
|
82
|
+
results.first
|
|
83
|
+
results.last
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Page navigation
|
|
87
|
+
|
|
88
|
+
`#next_page_query` / `#previous_page_query` return new query objects;
|
|
89
|
+
they don't mutate. They require a non-nil `page` — calling them on an
|
|
90
|
+
unpaginated query raises `NoMethodError` (it computes `page + 1` on
|
|
91
|
+
`nil`). If you might be on an unpaginated query, use `.copy(page: 2)`
|
|
92
|
+
instead.
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
query = CommentsByAuthorQuery.new(author_id: 1, page: 1, page_size: 25)
|
|
96
|
+
|
|
97
|
+
next_query = query.next_page_query
|
|
98
|
+
next_query.page # => 2
|
|
99
|
+
|
|
100
|
+
prev_query = query.copy(page: 5).previous_page_query
|
|
101
|
+
prev_query.page # => 4
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Jumping to a specific page
|
|
105
|
+
|
|
106
|
+
`#copy` lets you change any prop, including `page`:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
query = CommentsByAuthorQuery.new(author_id: 1, page: 1, page_size: 25)
|
|
110
|
+
page_5 = query.copy(page: 5)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Offset
|
|
114
|
+
|
|
115
|
+
Calculated from page and page_size:
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
query = CommentsByAuthorQuery.new(author_id: 1, page: 3, page_size: 25)
|
|
119
|
+
query.offset # => 50 (page 3 starts at row 51, 0-indexed offset 50)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Total page count
|
|
123
|
+
|
|
124
|
+
There's no built-in `total_pages` — derive it from `results.count`:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
results = query.results
|
|
128
|
+
total_pages = (results.count.to_f / query.page_size).ceil
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### "Has next page?"
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
def has_next_page?(query)
|
|
135
|
+
query.results.count > query.page * query.page_size
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def has_previous_page?(query)
|
|
139
|
+
query.page > 1
|
|
140
|
+
end
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Unpaginated access
|
|
144
|
+
|
|
145
|
+
### Disable pagination explicitly
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
query = CommentsByAuthorQuery.new(author_id: 1, page: nil)
|
|
149
|
+
query.paged? # => false
|
|
150
|
+
all = query.results.to_a
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Unwrap
|
|
154
|
+
|
|
155
|
+
`#unwrap` returns the underlying relation/collection with paging applied
|
|
156
|
+
if `paged?`. `#unwrap_unpaginated` gives you the full thing regardless.
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
query = CommentsByAuthorQuery.new(author_id: 1, page: 2, page_size: 25)
|
|
160
|
+
|
|
161
|
+
paginated_rel = query.unwrap # AR::Relation w/ LIMIT 25 OFFSET 25
|
|
162
|
+
unpaginated_rel = query.unwrap_unpaginated # AR::Relation, no LIMIT/OFFSET
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
For collections you get the array slice or the full array.
|
|
166
|
+
|
|
167
|
+
### Use cases for unpaginated
|
|
168
|
+
|
|
169
|
+
- Exporting all rows to CSV
|
|
170
|
+
- Aggregating across all rows
|
|
171
|
+
- Caching the full materialised result
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
query = CommentsByAuthorQuery.new(author_id: 1)
|
|
175
|
+
|
|
176
|
+
CSV.generate do |csv|
|
|
177
|
+
query.unwrap_unpaginated.find_each(batch_size: 1_000) do |comment|
|
|
178
|
+
csv << [comment.id, comment.body]
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
For very large result sets, use `find_each` (or `find_in_batches`)
|
|
184
|
+
on the AR relation rather than materialising.
|
|
185
|
+
|
|
186
|
+
## Pagination + composition
|
|
187
|
+
|
|
188
|
+
### Composed queries inherit pagination as a coupled pair
|
|
189
|
+
|
|
190
|
+
Pagination inherits *as a unit* — whichever operand has a non-nil
|
|
191
|
+
`page` contributes both its `page` and its `page_size`. An operand
|
|
192
|
+
with only `page_size` set is *not* paginated (every Quo::Query has a
|
|
193
|
+
default `page_size`), so its `page_size` doesn't propagate.
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
base = CommentsByAuthorQuery.new(author_id: 1, page: 2, page_size: 25)
|
|
197
|
+
filter = UnreadCommentsQuery.new
|
|
198
|
+
|
|
199
|
+
composed = base + filter
|
|
200
|
+
composed.page # => 2
|
|
201
|
+
composed.page_size # => 25
|
|
202
|
+
composed.paged? # => true
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Right wins when both operands are paginated
|
|
206
|
+
|
|
207
|
+
```ruby
|
|
208
|
+
left = CommentsByAuthorQuery.new(author_id: 1, page: 1, page_size: 10)
|
|
209
|
+
right = UnreadCommentsQuery.new(page: 3, page_size: 50)
|
|
210
|
+
|
|
211
|
+
composed = left + right
|
|
212
|
+
composed.page # => 3
|
|
213
|
+
composed.page_size # => 50
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
If neither operand is paginated, the composed isn't either:
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
left = CommentsByAuthorQuery.new(author_id: 1, page_size: 10) # no page set
|
|
220
|
+
right = UnreadCommentsQuery.new(page_size: 20) # no page set
|
|
221
|
+
|
|
222
|
+
composed = left + right
|
|
223
|
+
composed.paged? # => false
|
|
224
|
+
composed.page # => nil
|
|
225
|
+
composed.page_size # => 20 (the default — neither operand's page_size propagates without a page)
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Setting pagination after composition
|
|
229
|
+
|
|
230
|
+
In practice, set pagination on the outermost composition, not on
|
|
231
|
+
operands:
|
|
232
|
+
|
|
233
|
+
```ruby
|
|
234
|
+
composed = CommentsByAuthorQuery.new(author_id: 1) + UnreadCommentsQuery.new
|
|
235
|
+
paginated = composed.copy(page: 1, page_size: 50)
|
|
236
|
+
paginated.results
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Pagination + transformers
|
|
240
|
+
|
|
241
|
+
Transformers carry through pagination unchanged.
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
query = CommentsByAuthorQuery.new(author_id: 1, page: 1, page_size: 25)
|
|
245
|
+
.transform { |c| CommentPresenter.new(c) }
|
|
246
|
+
|
|
247
|
+
results = query.results
|
|
248
|
+
results.first # => CommentPresenter
|
|
249
|
+
results.page_count # => 25
|
|
250
|
+
results.count # => total across all pages
|
|
251
|
+
|
|
252
|
+
next_results = query.next_page_query.results
|
|
253
|
+
next_results.first # => CommentPresenter (transformer preserved)
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
## Controller patterns
|
|
257
|
+
|
|
258
|
+
### Basic pagination
|
|
259
|
+
|
|
260
|
+
```ruby
|
|
261
|
+
class CommentsController < ApplicationController
|
|
262
|
+
def index
|
|
263
|
+
@query = CommentsByAuthorQuery.new(
|
|
264
|
+
author_id: params[:author_id],
|
|
265
|
+
page: params[:page] || 1,
|
|
266
|
+
page_size: 25
|
|
267
|
+
)
|
|
268
|
+
@comments = @query.results
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### Caps on user-supplied page size
|
|
274
|
+
|
|
275
|
+
```ruby
|
|
276
|
+
def safe_page_size
|
|
277
|
+
raw = params[:per_page]&.to_i || 20
|
|
278
|
+
[raw, 100].min
|
|
279
|
+
end
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
(Quo enforces its own `max_page_size` cap globally — this gives you a
|
|
283
|
+
per-endpoint cap on top.)
|
|
284
|
+
|
|
285
|
+
### Pagination metadata for an API response
|
|
286
|
+
|
|
287
|
+
```ruby
|
|
288
|
+
def index
|
|
289
|
+
query = CommentsByAuthorQuery.new(
|
|
290
|
+
author_id: current_user.id,
|
|
291
|
+
page: params[:page] || 1,
|
|
292
|
+
page_size: safe_page_size
|
|
293
|
+
)
|
|
294
|
+
results = query.results
|
|
295
|
+
|
|
296
|
+
render json: {
|
|
297
|
+
data: results.map { |c| {id: c.id, body: c.body} },
|
|
298
|
+
meta: {
|
|
299
|
+
current_page: query.page,
|
|
300
|
+
per_page: query.page_size,
|
|
301
|
+
total_count: results.count,
|
|
302
|
+
total_pages: (results.count.to_f / query.page_size).ceil,
|
|
303
|
+
has_next: results.count > query.page * query.page_size,
|
|
304
|
+
has_prev: query.page > 1,
|
|
305
|
+
},
|
|
306
|
+
}
|
|
307
|
+
end
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
## Performance notes
|
|
311
|
+
|
|
312
|
+
### RelationBackedQuery is efficient
|
|
313
|
+
|
|
314
|
+
```ruby
|
|
315
|
+
query = CommentsByAuthorQuery.new(author_id: 1, page: 1, page_size: 25)
|
|
316
|
+
query.results
|
|
317
|
+
# SELECT comments.* FROM comments JOIN posts ... LIMIT 25 OFFSET 0
|
|
318
|
+
# Only 25 rows materialised.
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
A separate count query is issued when you call `results.count`.
|
|
322
|
+
|
|
323
|
+
### CollectionBackedQuery loads everything
|
|
324
|
+
|
|
325
|
+
In-memory pagination slices an already-fully-materialised collection.
|
|
326
|
+
That's appropriate for small/cached data, not for large datasets.
|
|
327
|
+
|
|
328
|
+
### Avoid N+1 in a paginated query
|
|
329
|
+
|
|
330
|
+
Eager-load associations the page will use:
|
|
331
|
+
|
|
332
|
+
```ruby
|
|
333
|
+
class CommentsByAuthorQuery < Quo::RelationBackedQuery
|
|
334
|
+
prop :author_id, Integer
|
|
335
|
+
|
|
336
|
+
def query
|
|
337
|
+
Comment
|
|
338
|
+
.joins(:post)
|
|
339
|
+
.includes(:post) # avoid N+1 on `comment.post`
|
|
340
|
+
.where(posts: {author_id: author_id})
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
### Batch processing
|
|
346
|
+
|
|
347
|
+
For full-dataset processing, skip pagination and use AR's batch API on
|
|
348
|
+
the unpaginated relation:
|
|
349
|
+
|
|
350
|
+
```ruby
|
|
351
|
+
CommentsByAuthorQuery.new(author_id: 1)
|
|
352
|
+
.unwrap_unpaginated
|
|
353
|
+
.find_each(batch_size: 1_000) do |comment|
|
|
354
|
+
ProcessCommentJob.perform_later(comment.id)
|
|
355
|
+
end
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
## Testing pagination
|
|
359
|
+
|
|
360
|
+
```ruby
|
|
361
|
+
class CommentsByAuthorQueryPaginationTest < ActiveSupport::TestCase
|
|
362
|
+
setup do
|
|
363
|
+
@author = Author.create!(name: "Ada")
|
|
364
|
+
@post = Post.create!(title: "Hi", author: @author)
|
|
365
|
+
75.times { |i| Comment.create!(post: @post, body: "c#{i}") }
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
test "paginates correctly" do
|
|
369
|
+
query = CommentsByAuthorQuery.new(author_id: @author.id, page: 1, page_size: 25)
|
|
370
|
+
results = query.results
|
|
371
|
+
|
|
372
|
+
assert_equal 25, results.page_count
|
|
373
|
+
assert_equal 75, results.count
|
|
374
|
+
assert query.paged?
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
test "next_page_query returns next page" do
|
|
378
|
+
query = CommentsByAuthorQuery.new(author_id: @author.id, page: 1, page_size: 25)
|
|
379
|
+
assert_equal 2, query.next_page_query.page
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
test "previous_page_query returns previous page" do
|
|
383
|
+
query = CommentsByAuthorQuery.new(author_id: @author.id, page: 3, page_size: 25)
|
|
384
|
+
assert_equal 2, query.previous_page_query.page
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
test "unpaginated returns everything" do
|
|
388
|
+
query = CommentsByAuthorQuery.new(author_id: @author.id, page: nil)
|
|
389
|
+
results = query.results
|
|
390
|
+
|
|
391
|
+
assert_equal 75, results.count
|
|
392
|
+
assert_equal 75, results.page_count
|
|
393
|
+
refute query.paged?
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
```
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
# Query Types Reference
|
|
2
|
+
|
|
3
|
+
> **Targets Quo `~> 2.0`.**
|
|
4
|
+
|
|
5
|
+
Quo provides two primary query types:
|
|
6
|
+
|
|
7
|
+
- **`Quo::RelationBackedQuery`** — wraps an `ActiveRecord::Relation`
|
|
8
|
+
- **`Quo::CollectionBackedQuery`** — wraps any `Enumerable`
|
|
9
|
+
|
|
10
|
+
Both share the same outer surface: typed properties via `prop`,
|
|
11
|
+
pagination, composition with `+`, transformers, and a `Quo::Results`
|
|
12
|
+
return value from `#results`.
|
|
13
|
+
|
|
14
|
+
## RelationBackedQuery
|
|
15
|
+
|
|
16
|
+
### When to use
|
|
17
|
+
|
|
18
|
+
For database queries — anything you'd write in ActiveRecord. This is the
|
|
19
|
+
common case.
|
|
20
|
+
|
|
21
|
+
### Structure
|
|
22
|
+
|
|
23
|
+
Subclass and implement `#query`. The method must return an
|
|
24
|
+
`ActiveRecord::Relation` (not a materialised array).
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
class CommentsByAuthorQuery < Quo::RelationBackedQuery
|
|
28
|
+
prop :author_id, Integer
|
|
29
|
+
prop :since, _Nilable(Time)
|
|
30
|
+
prop :limit, Integer, default: -> { 50 }
|
|
31
|
+
|
|
32
|
+
def query
|
|
33
|
+
scope = Comment
|
|
34
|
+
.joins(:post)
|
|
35
|
+
.where(posts: {author_id: author_id})
|
|
36
|
+
.order(created_at: :desc)
|
|
37
|
+
scope = scope.where("comments.created_at > ?", since) if since
|
|
38
|
+
scope.limit(limit)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
query = CommentsByAuthorQuery.new(author_id: 1, since: 1.day.ago, page: 1, page_size: 25)
|
|
43
|
+
results = query.results
|
|
44
|
+
results.each { |comment| puts comment.body }
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### `#query` must return a Relation
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
# Right
|
|
51
|
+
def query
|
|
52
|
+
Comment.where(read: false).order(:created_at)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Wrong — array, not relation
|
|
56
|
+
def query
|
|
57
|
+
Comment.where(read: false).to_a
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`#query` is allowed to return another `Quo::Query` instance — Quo will
|
|
62
|
+
unwrap it. That makes it natural to compose inside a query class:
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
class PopularRecentCommentsQuery < Quo::RelationBackedQuery
|
|
66
|
+
prop :since, Time, default: -> { 1.day.ago }
|
|
67
|
+
|
|
68
|
+
def query
|
|
69
|
+
UnreadCommentsQuery.new + RecentCommentsQuery.new(since: since)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Lazy evaluation
|
|
75
|
+
|
|
76
|
+
Construction never hits the database. The query runs when you ask for
|
|
77
|
+
results.
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
query = CommentsByAuthorQuery.new(author_id: 1)
|
|
81
|
+
# No SQL yet.
|
|
82
|
+
|
|
83
|
+
query.results.each { |c| ... }
|
|
84
|
+
# SQL runs here.
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Utility methods
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
query = CommentsByAuthorQuery.new(author_id: 1)
|
|
91
|
+
|
|
92
|
+
query.to_sql # => SQL string
|
|
93
|
+
query.unwrap_unpaginated # => ActiveRecord::Relation (no LIMIT/OFFSET)
|
|
94
|
+
query.unwrap # => ActiveRecord::Relation (with paging applied if any)
|
|
95
|
+
|
|
96
|
+
query.relation? # => true
|
|
97
|
+
query.collection? # => false
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## CollectionBackedQuery
|
|
101
|
+
|
|
102
|
+
### When to use
|
|
103
|
+
|
|
104
|
+
For in-memory enumerables — cached data, API responses, pre-loaded
|
|
105
|
+
arrays. Avoid for large datasets you'd be loading from the database
|
|
106
|
+
anyway; use `RelationBackedQuery` and let the database do the work.
|
|
107
|
+
|
|
108
|
+
### Structure
|
|
109
|
+
|
|
110
|
+
Subclass and implement `#collection`. The method must return an
|
|
111
|
+
`Enumerable` (typically an Array).
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
class TopRatedCommentsQuery < Quo::CollectionBackedQuery
|
|
115
|
+
prop :comments, _Array(Comment)
|
|
116
|
+
prop :max_spam_score, Float, default: -> { 0.5 }
|
|
117
|
+
|
|
118
|
+
def collection
|
|
119
|
+
comments
|
|
120
|
+
.select { |c| c.spam_score && c.spam_score < max_spam_score }
|
|
121
|
+
.sort_by { |c| c.spam_score || 0 }
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
query = TopRatedCommentsQuery.new(comments: Comment.all.to_a, max_spam_score: 0.3)
|
|
126
|
+
query.results.each { |c| puts c.body }
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### `#collection` must return Enumerable
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
# Right — Array
|
|
133
|
+
def collection
|
|
134
|
+
items.select { |i| i.active? }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Right — Set
|
|
138
|
+
def collection
|
|
139
|
+
Set.new(items)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Wrong — single item
|
|
143
|
+
def collection
|
|
144
|
+
items.first
|
|
145
|
+
end
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Pagination happens in memory
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
query = TopRatedCommentsQuery.new(comments: huge_array, page: 2, page_size: 10)
|
|
152
|
+
query.results
|
|
153
|
+
# All `huge_array` lives in memory; pagination slices it.
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
For large data sets, prefer a `RelationBackedQuery` so the database can
|
|
157
|
+
limit before returning rows.
|
|
158
|
+
|
|
159
|
+
### Utility methods
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
query = TopRatedCommentsQuery.new(comments: comments)
|
|
163
|
+
|
|
164
|
+
query.unwrap_unpaginated # => Array (full)
|
|
165
|
+
query.unwrap # => Array (paginated slice if paged?)
|
|
166
|
+
|
|
167
|
+
query.relation? # => false
|
|
168
|
+
query.collection? # => true
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Converting between types
|
|
172
|
+
|
|
173
|
+
`#to_collection` materialises a `RelationBackedQuery` into a
|
|
174
|
+
`CollectionBackedQuery`. Useful for caching the materialised array.
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
relation_query = CommentsByAuthorQuery.new(author_id: 1)
|
|
178
|
+
collection_query = relation_query.to_collection
|
|
179
|
+
|
|
180
|
+
collection_query.relation? # => false
|
|
181
|
+
collection_query.collection? # => true
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
A common cache pattern:
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
class CachedRecentCommentsQuery < Quo::CollectionBackedQuery
|
|
188
|
+
prop :author_id, Integer
|
|
189
|
+
prop :ttl, ActiveSupport::Duration, default: -> { 5.minutes }
|
|
190
|
+
|
|
191
|
+
def collection
|
|
192
|
+
Rails.cache.fetch("comments:author:#{author_id}", expires_in: ttl) do
|
|
193
|
+
CommentsByAuthorQuery.new(author_id: author_id).results.to_a
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Property types reference
|
|
200
|
+
|
|
201
|
+
Quo uses [Literal](https://github.com/joeldrapper/literal) for type
|
|
202
|
+
validation. Literal's helper methods on Quo classes are prefixed with
|
|
203
|
+
underscore (e.g. `_Nilable`, `_Array`, `_Union`, `_Boolean`).
|
|
204
|
+
|
|
205
|
+
### Primitives
|
|
206
|
+
|
|
207
|
+
```ruby
|
|
208
|
+
prop :name, String
|
|
209
|
+
prop :count, Integer
|
|
210
|
+
prop :price, Float
|
|
211
|
+
prop :enabled, _Boolean # NB: _Boolean, not Boolean
|
|
212
|
+
prop :data, Hash
|
|
213
|
+
prop :items, Array
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Custom classes
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
prop :author, Author
|
|
220
|
+
prop :post, Post
|
|
221
|
+
prop :comment, Comment
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Arrays
|
|
225
|
+
|
|
226
|
+
```ruby
|
|
227
|
+
prop :tags, _Array(String)
|
|
228
|
+
prop :ids, _Array(Integer)
|
|
229
|
+
prop :authors, _Array(Author)
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### Nilable
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
prop :since, _Nilable(Time)
|
|
236
|
+
prop :status, _Nilable(String)
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### Unions
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
prop :id_or_slug, _Union(String, Integer)
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Defaults
|
|
246
|
+
|
|
247
|
+
```ruby
|
|
248
|
+
# Use a lambda for any non-frozen default — avoids shared mutable state.
|
|
249
|
+
prop :tags, _Array(String), default: -> { [] }
|
|
250
|
+
prop :since, Time, default: -> { 1.day.ago }
|
|
251
|
+
prop :page_size, Integer, default: -> { 20 }
|
|
252
|
+
|
|
253
|
+
# Frozen literals are also OK:
|
|
254
|
+
prop :status, String, default: "pending".freeze
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
### Boolean caveat
|
|
258
|
+
|
|
259
|
+
Use `_Boolean` (Literal helper), not bare `Boolean`:
|
|
260
|
+
|
|
261
|
+
```ruby
|
|
262
|
+
prop :enabled, _Boolean # right
|
|
263
|
+
prop :enabled, Boolean # wrong — there's no top-level Boolean class
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
## Property validation
|
|
267
|
+
|
|
268
|
+
Properties validate at construction. Wrong types and missing required
|
|
269
|
+
values raise `Literal::TypeError`.
|
|
270
|
+
|
|
271
|
+
```ruby
|
|
272
|
+
class StrictQuery < Quo::RelationBackedQuery
|
|
273
|
+
prop :author_id, Integer
|
|
274
|
+
prop :limit, Integer, default: -> { 50 }
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
StrictQuery.new(author_id: 1) # OK
|
|
278
|
+
StrictQuery.new(author_id: "1") # raises — wrong type
|
|
279
|
+
StrictQuery.new(limit: 10) # raises — missing :author_id
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
## Method requirements summary
|
|
283
|
+
|
|
284
|
+
| Type | Implement | Returns |
|
|
285
|
+
|---|---|---|
|
|
286
|
+
| `Quo::RelationBackedQuery` | `#query` | `ActiveRecord::Relation` (or another `Quo::Query`) |
|
|
287
|
+
| `Quo::CollectionBackedQuery` | `#collection` | `Enumerable` |
|
|
288
|
+
|
|
289
|
+
## Choosing between types
|
|
290
|
+
|
|
291
|
+
| Use case | Pick |
|
|
292
|
+
|---|---|
|
|
293
|
+
| Database query that pages and filters | `RelationBackedQuery` |
|
|
294
|
+
| Need raw SQL access (`#to_sql`) | `RelationBackedQuery` |
|
|
295
|
+
| Cached array, API response, in-memory filter | `CollectionBackedQuery` |
|
|
296
|
+
| Caching the result of a relation query | `RelationBackedQuery` → `to_collection` |
|
|
297
|
+
| Composing with `.where`/`.joins`/`.order` | `RelationBackedQuery` |
|