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.
Files changed (100) hide show
  1. checksums.yaml +4 -4
  2. data/.devcontainer/Dockerfile +17 -0
  3. data/.devcontainer/compose.yml +10 -0
  4. data/.devcontainer/devcontainer.json +12 -0
  5. data/Appraisals +4 -12
  6. data/CHANGELOG.md +112 -1
  7. data/CLAUDE.md +19 -0
  8. data/Gemfile +7 -1
  9. data/README.md +379 -75
  10. data/Rakefile +66 -6
  11. data/UPGRADING.md +216 -0
  12. data/badges/coverage_badge_total.svg +35 -0
  13. data/badges/rubycritic_badge_score.svg +35 -0
  14. data/claude-skill/README.md +100 -0
  15. data/claude-skill/SKILL.md +442 -0
  16. data/claude-skill/references/API_REFERENCE.md +462 -0
  17. data/claude-skill/references/COMPOSITION.md +396 -0
  18. data/claude-skill/references/PAGINATION.md +396 -0
  19. data/claude-skill/references/QUERY_TYPES.md +297 -0
  20. data/claude-skill/references/TRANSFORMERS.md +282 -0
  21. data/context/01-core-architecture.md +247 -0
  22. data/context/02-query-types-implementation.md +355 -0
  23. data/context/03-composition-transformation.md +441 -0
  24. data/context/04-pagination-results.md +485 -0
  25. data/context/05-testing-configuration.md +491 -0
  26. data/context/06-advanced-patterns-examples.md +153 -0
  27. data/gemfiles/rails_8.0.gemfile +10 -5
  28. data/gemfiles/rails_8.1.gemfile +20 -0
  29. data/lib/generators/quo/install/USAGE +21 -0
  30. data/lib/generators/quo/install/install_generator.rb +63 -0
  31. data/lib/quo/collection_backed_query.rb +21 -15
  32. data/lib/quo/collection_results.rb +1 -0
  33. data/lib/quo/composed_collection_backed_query.rb +42 -0
  34. data/lib/quo/composed_instance.rb +144 -0
  35. data/lib/quo/composed_query.rb +43 -178
  36. data/lib/quo/composed_relation_backed_query.rb +42 -0
  37. data/lib/quo/composing/base_strategy.rb +22 -0
  38. data/lib/quo/composing/class_strategy.rb +86 -0
  39. data/lib/quo/composing/class_strategy_registry.rb +31 -0
  40. data/lib/quo/composing/query_classes_strategy.rb +38 -0
  41. data/lib/quo/composing.rb +81 -0
  42. data/lib/quo/engine.rb +1 -0
  43. data/lib/quo/minitest/helpers.rb +14 -24
  44. data/lib/quo/preloadable.rb +1 -0
  45. data/lib/quo/query.rb +22 -5
  46. data/lib/quo/relation_backed_query.rb +24 -18
  47. data/lib/quo/relation_backed_query_specification.rb +44 -25
  48. data/lib/quo/relation_results.rb +1 -0
  49. data/lib/quo/results.rb +31 -2
  50. data/lib/quo/rspec/helpers.rb +15 -26
  51. data/lib/quo/testing/collection_backed_fake.rb +1 -0
  52. data/lib/quo/testing/fake_helpers.rb +30 -0
  53. data/lib/quo/testing/relation_backed_fake.rb +1 -0
  54. data/lib/quo/version.rb +1 -1
  55. data/lib/quo/wrapped_collection_backed_query.rb +21 -0
  56. data/lib/quo/wrapped_relation_backed_query.rb +21 -0
  57. data/lib/quo.rb +8 -0
  58. data/quo.png +0 -0
  59. data/sig/generated/quo/collection_backed_query.rbs +10 -4
  60. data/sig/generated/quo/collection_results.rbs +1 -0
  61. data/sig/generated/quo/composed_collection_backed_query.rbs +25 -0
  62. data/sig/generated/quo/composed_instance.rbs +61 -0
  63. data/sig/generated/quo/composed_query.rbs +23 -56
  64. data/sig/generated/quo/composed_relation_backed_query.rbs +25 -0
  65. data/sig/generated/quo/composing/base_strategy.rbs +16 -0
  66. data/sig/generated/quo/composing/class_strategy.rbs +38 -0
  67. data/sig/generated/quo/composing/class_strategy_registry.rbs +16 -0
  68. data/sig/generated/quo/composing/query_classes_strategy.rbs +24 -0
  69. data/sig/generated/quo/composing.rbs +40 -0
  70. data/sig/generated/quo/engine.rbs +1 -0
  71. data/sig/generated/quo/minitest/helpers.rbs +12 -0
  72. data/sig/generated/quo/preloadable.rbs +1 -0
  73. data/sig/generated/quo/query.rbs +15 -4
  74. data/sig/generated/quo/relation_backed_query.rbs +15 -5
  75. data/sig/generated/quo/relation_backed_query_specification.rbs +47 -39
  76. data/sig/generated/quo/relation_results.rbs +1 -0
  77. data/sig/generated/quo/results.rbs +11 -0
  78. data/sig/generated/quo/rspec/helpers.rbs +12 -0
  79. data/sig/generated/quo/testing/collection_backed_fake.rbs +1 -0
  80. data/sig/generated/quo/testing/fake_helpers.rbs +14 -0
  81. data/sig/generated/quo/testing/relation_backed_fake.rbs +1 -0
  82. data/sig/generated/quo/wrapped_collection_backed_query.rbs +13 -0
  83. data/sig/generated/quo/wrapped_relation_backed_query.rbs +13 -0
  84. data/sig/generated/quo.rbs +1 -0
  85. data/website/.gitignore +6 -0
  86. data/website/.nojekyll +0 -0
  87. data/website/404.html +26 -0
  88. data/website/Gemfile +24 -0
  89. data/website/_config.yml +50 -0
  90. data/website/_data/navigation.yml +8 -0
  91. data/website/_data/sidebar.yml +2 -0
  92. data/website/_data/social_links.yml +3 -0
  93. data/website/_docs/api.md +261 -0
  94. data/website/_docs/get-started.md +289 -0
  95. data/website/assets/quo.png +0 -0
  96. data/website/index.md +141 -0
  97. metadata +70 -13
  98. data/gemfiles/rails_7.0.gemfile +0 -15
  99. data/gemfiles/rails_7.1.gemfile +0 -15
  100. 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` |