quo 1.0.0.beta2 → 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 (101) 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/LICENSE.txt +1 -1
  10. data/README.md +496 -203
  11. data/Rakefile +66 -6
  12. data/UPGRADING.md +216 -0
  13. data/badges/coverage_badge_total.svg +35 -0
  14. data/badges/rubycritic_badge_score.svg +35 -0
  15. data/claude-skill/README.md +100 -0
  16. data/claude-skill/SKILL.md +442 -0
  17. data/claude-skill/references/API_REFERENCE.md +462 -0
  18. data/claude-skill/references/COMPOSITION.md +396 -0
  19. data/claude-skill/references/PAGINATION.md +396 -0
  20. data/claude-skill/references/QUERY_TYPES.md +297 -0
  21. data/claude-skill/references/TRANSFORMERS.md +282 -0
  22. data/context/01-core-architecture.md +247 -0
  23. data/context/02-query-types-implementation.md +355 -0
  24. data/context/03-composition-transformation.md +441 -0
  25. data/context/04-pagination-results.md +485 -0
  26. data/context/05-testing-configuration.md +491 -0
  27. data/context/06-advanced-patterns-examples.md +153 -0
  28. data/gemfiles/rails_8.0.gemfile +10 -5
  29. data/gemfiles/rails_8.1.gemfile +20 -0
  30. data/lib/generators/quo/install/USAGE +21 -0
  31. data/lib/generators/quo/install/install_generator.rb +63 -0
  32. data/lib/quo/collection_backed_query.rb +21 -15
  33. data/lib/quo/collection_results.rb +1 -0
  34. data/lib/quo/composed_collection_backed_query.rb +42 -0
  35. data/lib/quo/composed_instance.rb +144 -0
  36. data/lib/quo/composed_query.rb +43 -178
  37. data/lib/quo/composed_relation_backed_query.rb +42 -0
  38. data/lib/quo/composing/base_strategy.rb +22 -0
  39. data/lib/quo/composing/class_strategy.rb +86 -0
  40. data/lib/quo/composing/class_strategy_registry.rb +31 -0
  41. data/lib/quo/composing/query_classes_strategy.rb +38 -0
  42. data/lib/quo/composing.rb +81 -0
  43. data/lib/quo/engine.rb +1 -0
  44. data/lib/quo/minitest/helpers.rb +14 -24
  45. data/lib/quo/preloadable.rb +1 -0
  46. data/lib/quo/query.rb +22 -5
  47. data/lib/quo/relation_backed_query.rb +24 -18
  48. data/lib/quo/relation_backed_query_specification.rb +44 -25
  49. data/lib/quo/relation_results.rb +1 -0
  50. data/lib/quo/results.rb +31 -2
  51. data/lib/quo/rspec/helpers.rb +15 -26
  52. data/lib/quo/testing/collection_backed_fake.rb +1 -0
  53. data/lib/quo/testing/fake_helpers.rb +30 -0
  54. data/lib/quo/testing/relation_backed_fake.rb +1 -0
  55. data/lib/quo/version.rb +1 -1
  56. data/lib/quo/wrapped_collection_backed_query.rb +21 -0
  57. data/lib/quo/wrapped_relation_backed_query.rb +21 -0
  58. data/lib/quo.rb +8 -0
  59. data/quo.png +0 -0
  60. data/sig/generated/quo/collection_backed_query.rbs +10 -4
  61. data/sig/generated/quo/collection_results.rbs +1 -0
  62. data/sig/generated/quo/composed_collection_backed_query.rbs +25 -0
  63. data/sig/generated/quo/composed_instance.rbs +61 -0
  64. data/sig/generated/quo/composed_query.rbs +23 -56
  65. data/sig/generated/quo/composed_relation_backed_query.rbs +25 -0
  66. data/sig/generated/quo/composing/base_strategy.rbs +16 -0
  67. data/sig/generated/quo/composing/class_strategy.rbs +38 -0
  68. data/sig/generated/quo/composing/class_strategy_registry.rbs +16 -0
  69. data/sig/generated/quo/composing/query_classes_strategy.rbs +24 -0
  70. data/sig/generated/quo/composing.rbs +40 -0
  71. data/sig/generated/quo/engine.rbs +1 -0
  72. data/sig/generated/quo/minitest/helpers.rbs +12 -0
  73. data/sig/generated/quo/preloadable.rbs +1 -0
  74. data/sig/generated/quo/query.rbs +15 -4
  75. data/sig/generated/quo/relation_backed_query.rbs +15 -5
  76. data/sig/generated/quo/relation_backed_query_specification.rbs +47 -39
  77. data/sig/generated/quo/relation_results.rbs +1 -0
  78. data/sig/generated/quo/results.rbs +11 -0
  79. data/sig/generated/quo/rspec/helpers.rbs +12 -0
  80. data/sig/generated/quo/testing/collection_backed_fake.rbs +1 -0
  81. data/sig/generated/quo/testing/fake_helpers.rbs +14 -0
  82. data/sig/generated/quo/testing/relation_backed_fake.rbs +1 -0
  83. data/sig/generated/quo/wrapped_collection_backed_query.rbs +13 -0
  84. data/sig/generated/quo/wrapped_relation_backed_query.rbs +13 -0
  85. data/sig/generated/quo.rbs +1 -0
  86. data/website/.gitignore +6 -0
  87. data/website/.nojekyll +0 -0
  88. data/website/404.html +26 -0
  89. data/website/Gemfile +24 -0
  90. data/website/_config.yml +50 -0
  91. data/website/_data/navigation.yml +8 -0
  92. data/website/_data/sidebar.yml +2 -0
  93. data/website/_data/social_links.yml +3 -0
  94. data/website/_docs/api.md +261 -0
  95. data/website/_docs/get-started.md +289 -0
  96. data/website/assets/quo.png +0 -0
  97. data/website/index.md +141 -0
  98. metadata +70 -13
  99. data/gemfiles/rails_7.0.gemfile +0 -15
  100. data/gemfiles/rails_7.1.gemfile +0 -15
  101. data/gemfiles/rails_7.2.gemfile +0 -15
@@ -0,0 +1,442 @@
1
+ ---
2
+ name: quo
3
+ description: Build composable, type-safe query objects using the Quo gem. Use when creating ActiveRecord or collection queries, composing queries, paginating, or transforming results.
4
+ ---
5
+
6
+ # Quo Query Objects
7
+
8
+ > **Targets Quo `~> 2.0`.** This skill matches the API of the 2.0 line.
9
+ > If you upgrade Quo, re-run the install generator to refresh the skill:
10
+ > `bin/rails generate quo:install --force`.
11
+ >
12
+ > If you're upgrading from 1.x, see `UPGRADING.md` in the gem for the
13
+ > two intentional behavioural changes (prop fan-out semantics and
14
+ > pagination inheritance).
15
+
16
+ ## Overview
17
+
18
+ Quo is a Ruby gem that encapsulates database and collection queries into
19
+ reusable, composable, testable objects. It provides type-safe properties
20
+ (via the [Literal](https://github.com/joeldrapper/literal) gem), built-in
21
+ pagination, and a fluent API similar to ActiveRecord.
22
+
23
+ ### Core components
24
+
25
+ 1. **Query objects** — define and configure queries with typed properties
26
+ 2. **Results objects** — execute queries and provide access to paginated data
27
+ 3. **Composition** — combine queries using `+` (aliased to `compose` for classes, `merge` for instances)
28
+
29
+ ### When to use Quo
30
+
31
+ **Use Quo for:**
32
+ - Complex queries that are reused across the app
33
+ - Queries with configurable, typed parameters
34
+ - Queries that need pagination
35
+ - Composing reusable query fragments
36
+ - Encapsulating collection filtering logic
37
+
38
+ **Avoid Quo for:**
39
+ - Simple one-off queries — use ActiveRecord directly
40
+ - Queries with no parameters worth typing
41
+ - Logic that lives in exactly one place
42
+
43
+ **Anti-pattern:** a Quo class whose body is a single `where(...)`. That's
44
+ just AR with extra ceremony. If a filter is one line and has no parameters
45
+ worth typing, keep it inline (`Comment.where(read: true)`) rather than
46
+ wrapping in a Quo class.
47
+
48
+ ## Composition: class-level vs instance-level
49
+
50
+ This is the most important distinction in Quo and the most commonly misused.
51
+ There are **two** composition modes. They look identical (`+`) but they do
52
+ different work and have very different costs. Pick deliberately.
53
+
54
+ ### Class composition — defining a new named query type
55
+
56
+ Use this when you want to **define a new, reusable query type** in terms
57
+ of existing ones. It runs once at code-load time. `+` between two classes
58
+ returns a new Class.
59
+
60
+ ```ruby
61
+ # As a constant — the composition runs once when the file is loaded.
62
+ RecentNonSpamComments = RecentCommentsQuery + NonSpamCommentsQuery
63
+
64
+ # As a superclass — when you want to add props or methods on top.
65
+ class RecentNonSpamComments < (RecentCommentsQuery + NonSpamCommentsQuery)
66
+ prop :author_id, _Nilable(Integer)
67
+ end
68
+
69
+ # Then per-request, you instantiate it like any Quo class.
70
+ RecentNonSpamComments.new(since: 1.day.ago, score: 0.5).results
71
+ ```
72
+
73
+ ### Instance composition — combining queries at a call site
74
+
75
+ Use this when you want to **merge concrete query instances** per request,
76
+ each with its own props. `+` between two instances returns a value-shaped
77
+ query — cheap to do inside a hot path, render loop, or controller.
78
+
79
+ ```ruby
80
+ def call
81
+ (RecentCommentsQuery.new(since: 1.day.ago) +
82
+ NonSpamCommentsQuery.new(score: 0.5))
83
+ .results
84
+ end
85
+ ```
86
+
87
+ ### Anti-pattern: class composition at the call site
88
+
89
+ Don't reach for class composition when you actually want instance
90
+ composition. This pattern looks ergonomic but allocates a fresh anonymous
91
+ class on every call:
92
+
93
+ ```ruby
94
+ # Wrong — allocates a new class every request, just to instantiate it once.
95
+ (RecentCommentsQuery + NonSpamCommentsQuery)
96
+ .new(since: 1.day.ago, score: 0.5)
97
+ .results
98
+
99
+ # Right — instance composition, no class allocation.
100
+ (RecentCommentsQuery.new(since: 1.day.ago) +
101
+ NonSpamCommentsQuery.new(score: 0.5))
102
+ .results
103
+ ```
104
+
105
+ The "wrong" version also relies on prop fan-out: the framework figures out
106
+ that `since:` belongs to the left query and `score:` to the right.
107
+ Class-level call-site composition makes that magic *necessary*; instance
108
+ composition makes it unnecessary because each leaf gets its own props at
109
+ construction.
110
+
111
+ ### Quick decision guide
112
+
113
+ | You want to… | Use |
114
+ |---|---|
115
+ | Define a new query type from existing ones, once | `Q1 + Q2` (class) |
116
+ | Combine instances at a call site with specific props | `q1 + q2` (instance) |
117
+ | Add props/methods on top of a composition | `class Foo < (Q1 + Q2); ... end` |
118
+ | Conditionally add a filter at runtime | `q + maybe_filter` (instance) |
119
+
120
+ For more depth: see `references/COMPOSITION.md`.
121
+
122
+ ## Quick reference: query types
123
+
124
+ ### RelationBackedQuery (ActiveRecord queries)
125
+
126
+ ```ruby
127
+ class RecentCommentsQuery < Quo::RelationBackedQuery
128
+ prop :since, Time, default: -> { 1.week.ago }
129
+ prop :status, _Nilable(String)
130
+
131
+ def query
132
+ scope = Comment.where("created_at > ?", since).order(created_at: :desc)
133
+ scope = scope.where(status: status) if status
134
+ scope
135
+ end
136
+ end
137
+
138
+ # Usage
139
+ query = RecentCommentsQuery.new(since: 7.days.ago, page: 1, page_size: 25)
140
+ results = query.results
141
+
142
+ results.each { |comment| puts comment.body }
143
+ results.count # total across all pages
144
+ results.page_count # rows in current page
145
+ ```
146
+
147
+ ### CollectionBackedQuery (in-memory collections)
148
+
149
+ ```ruby
150
+ class TopRatedCommentsQuery < Quo::CollectionBackedQuery
151
+ prop :comments, _Array(Comment)
152
+ prop :min_score, Float, default: -> { 0.5 }
153
+
154
+ def collection
155
+ comments.select { |c| c.spam_score && c.spam_score < min_score }
156
+ end
157
+ end
158
+
159
+ query = TopRatedCommentsQuery.new(comments: Comment.all.to_a, min_score: 0.3)
160
+ filtered = query.results
161
+ ```
162
+
163
+ **Detail:** [references/QUERY_TYPES.md](references/QUERY_TYPES.md)
164
+
165
+ ## Quick reference: type-safe properties
166
+
167
+ Quo uses Literal for runtime type validation. Declare props with `prop`:
168
+
169
+ ```ruby
170
+ class CommentsByAuthorQuery < Quo::RelationBackedQuery
171
+ # Required
172
+ prop :author_id, Integer
173
+
174
+ # Optional with default
175
+ prop :limit, Integer, default: -> { 50 }
176
+ prop :include_unread, _Boolean, default: -> { true }
177
+
178
+ # Nilable (allows nil explicitly)
179
+ prop :since, _Nilable(Time)
180
+
181
+ # Arrays / unions
182
+ prop :tags, _Array(String), default: -> { [] }
183
+ prop :id_or_slug, _Union(String, Integer)
184
+
185
+ def query
186
+ scope = Comment.joins(:post).where(posts: {author_id: author_id})
187
+ scope = scope.where("comments.created_at > ?", since) if since
188
+ scope = scope.where(read: false) unless include_unread
189
+ scope = scope.where(tag: tags) if tags.any?
190
+ scope.limit(limit)
191
+ end
192
+ end
193
+ ```
194
+
195
+ Common patterns:
196
+
197
+ ```ruby
198
+ prop :name, String # primitive
199
+ prop :enabled, _Boolean # boolean
200
+ prop :tags, _Array(String) # typed array
201
+ prop :since, _Nilable(Time) # nilable
202
+ prop :status, _Union(String, Symbol) # union
203
+ prop :author, Author # AR model class
204
+ ```
205
+
206
+ ## Quick reference: pagination
207
+
208
+ ```ruby
209
+ # Page 1 of 25 per page
210
+ query = CommentsByAuthorQuery.new(author_id: 1, page: 1, page_size: 25)
211
+ results = query.results
212
+
213
+ # Navigation — return new query objects, don't mutate
214
+ next_query = query.next_page_query
215
+ prev_query = query.previous_page_query
216
+
217
+ # Counts
218
+ results.count # total across all pages
219
+ results.page_count # rows in current page
220
+
221
+ # Inspect
222
+ query.paged? # true
223
+
224
+ # All rows, no pagination
225
+ query.unwrap_unpaginated.to_a
226
+ ```
227
+
228
+ **Detail:** [references/PAGINATION.md](references/PAGINATION.md)
229
+
230
+ ## Quick reference: fluent spec API
231
+
232
+ `RelationBackedQuery` instances forward AR-style spec methods through
233
+ `method_missing` to an immutable `Quo::RelationBackedQuerySpecification`.
234
+ Each call returns a new query with the spec updated; chains compose.
235
+
236
+ ```ruby
237
+ q = CommentsByAuthorQuery.new(author_id: 1)
238
+ .where(read: false)
239
+ .order(created_at: :desc)
240
+ .joins(:post)
241
+ .includes(:author)
242
+ .limit(10)
243
+ .distinct
244
+ ```
245
+
246
+ Supported: `where`, `order`, `reorder`, `group`, `limit`, `offset`,
247
+ `select`, `joins`, `left_outer_joins`, `includes`, `preload`, `eager_load`,
248
+ `distinct`, `extending`, `unscope`. All mirror their AR::Relation
249
+ counterparts.
250
+
251
+ Specs added to a composed query are applied to the merged relation at
252
+ unwrap time, on top of any specs on the individual operands.
253
+
254
+ **Detail:** [references/API_REFERENCE.md](references/API_REFERENCE.md)
255
+
256
+ ## Quick reference: result transformations
257
+
258
+ Attach a transformer to wrap each row as it comes out of the query:
259
+
260
+ ```ruby
261
+ query = CommentsByAuthorQuery.new(author_id: 1)
262
+ .transform { |comment| CommentPresenter.new(comment) }
263
+
264
+ query.results.each do |presenter|
265
+ puts presenter.formatted_body
266
+ end
267
+
268
+ # Transformers apply to every Enumerable method
269
+ query.results.map(&:formatted_body)
270
+ query.results.group_by(&:status)
271
+ ```
272
+
273
+ Pass extra context via the surrounding scope:
274
+
275
+ ```ruby
276
+ viewer = current_user
277
+ query = CommentsByAuthorQuery.new(author_id: 1)
278
+ .transform { |comment| CommentPresenter.new(comment, viewer: viewer) }
279
+ ```
280
+
281
+ **Detail:** [references/TRANSFORMERS.md](references/TRANSFORMERS.md)
282
+
283
+ ## Quick reference: `wrap` vs `from`
284
+
285
+ Two APIs for adopting a bare relation or enumerable into Quo, with
286
+ different shapes and costs. Pick by use case:
287
+
288
+ ### `.from` — value form (use at call sites)
289
+
290
+ `Quo::RelationBackedQuery.from(rel)` returns a **Quo::Query instance**
291
+ that wraps the relation. No class allocation per call. Use this when
292
+ you want a Quo query value at a call site (operations, controllers,
293
+ hot loops):
294
+
295
+ ```ruby
296
+ # In an operation / controller
297
+ Quo::RelationBackedQuery.from(Comment.where(read: false)).results
298
+
299
+ # Composes naturally with v2 instance composition
300
+ (Quo::RelationBackedQuery.from(rel) + UnreadCommentsQuery.new).results
301
+
302
+ # Same for collections
303
+ Quo::CollectionBackedQuery.from(cached_array).results
304
+ ```
305
+
306
+ ### `wrap` — class form (use for type definition)
307
+
308
+ `Quo::RelationBackedQuery.wrap(...)` returns a **Class**. Useful when
309
+ you want to define a named query type once at code-load time, possibly
310
+ parameterised via the block form:
311
+
312
+ ```ruby
313
+ # As a constant — evaluated once when the file loads.
314
+ ActiveCommentsQuery = Quo::RelationBackedQuery.wrap(Comment.where(read: false))
315
+ ActiveCommentsQuery.new.results
316
+
317
+ # Block form with typed props — the block captures over `self` so props
318
+ # are accessible.
319
+ RecentCommentsForAuthor = Quo::RelationBackedQuery.wrap(props: {author_id: Integer}) do
320
+ Comment.joins(:post).where(posts: {author_id: author_id})
321
+ end
322
+ RecentCommentsForAuthor.new(author_id: 1).results
323
+
324
+ # Wrap a cached value as a collection query
325
+ CachedAuthors = Quo::CollectionBackedQuery.wrap do
326
+ Rails.cache.fetch("all_authors") { Author.all.to_a }
327
+ end
328
+ ```
329
+
330
+ > **Anti-pattern:** `Quo::RelationBackedQuery.wrap(rel).new` at a call
331
+ > site. `wrap` allocates a new anonymous class on every call only to
332
+ > instantiate it once and discard the class — the same waste pattern
333
+ > v2 removed from instance composition. Use `.from(rel)` for the
334
+ > value form, or hoist the `wrap(...)` to a constant if you want the
335
+ > class form.
336
+
337
+ ## Quick reference: type conversion
338
+
339
+ ```ruby
340
+ # RelationBackedQuery → CollectionBackedQuery
341
+ relation_query = CommentsByAuthorQuery.new(author_id: 1)
342
+ collection_query = relation_query.to_collection
343
+
344
+ collection_query.collection? # => true
345
+ collection_query.relation? # => false
346
+ ```
347
+
348
+ ## Quick reference: utility methods
349
+
350
+ ```ruby
351
+ query = CommentsByAuthorQuery.new(author_id: 1, page: 1)
352
+
353
+ query.relation? # backed by an AR relation?
354
+ query.collection? # backed by a collection?
355
+ query.paged? # pagination enabled?
356
+ query.transform? # transformer attached?
357
+ query.unwrap # paginated AR::Relation
358
+ query.unwrap_unpaginated # full unpaginated AR::Relation
359
+ query.to_sql # SQL string (RelationBackedQuery only)
360
+
361
+ results = query.results
362
+ results.exists? # any rows?
363
+ results.empty? # zero rows?
364
+ results.count # total across all pages
365
+ results.page_count # rows in current page
366
+ results.first / .last
367
+ results.map { ... }
368
+ results.group_by { ... }
369
+ ```
370
+
371
+ ## Common patterns
372
+
373
+ ### Conditional filter composition
374
+
375
+ ```ruby
376
+ def list_comments(filters = {})
377
+ query = CommentsByAuthorQuery.new(author_id: filters[:author_id])
378
+ query += UnreadCommentsQuery.new if filters[:unread]
379
+ query += NonSpamCommentsQuery.new(score: 0.5) if filters[:hide_spam]
380
+ query.results
381
+ end
382
+ ```
383
+
384
+ (Each `+` here is **instance composition** — cheap, value-level.)
385
+
386
+ ### Presenters via transformers in a controller
387
+
388
+ ```ruby
389
+ class CommentsController < ApplicationController
390
+ def index
391
+ query = CommentsByAuthorQuery
392
+ .new(author_id: params[:author_id], page: params[:page])
393
+ .transform { |c| CommentPresenter.new(c, viewer: current_user) }
394
+
395
+ render :index, locals: {comments: query.results, paginator: query}
396
+ end
397
+ end
398
+ ```
399
+
400
+ ### Testing a query object
401
+
402
+ ```ruby
403
+ class CommentsByAuthorQueryTest < ActiveSupport::TestCase
404
+ setup do
405
+ @author = Author.create!(name: "Ada")
406
+ @post = Post.create!(title: "Hi", author: @author)
407
+ @target = Comment.create!(post: @post, body: "ok", read: false)
408
+ Comment.create!(post: @post, body: "spam", read: true)
409
+ end
410
+
411
+ test "returns comments for the given author" do
412
+ results = CommentsByAuthorQuery.new(author_id: @author.id).results
413
+ assert_includes results, @target
414
+ end
415
+
416
+ test "pagination returns page-sized chunks" do
417
+ 20.times { |i| Comment.create!(post: @post, body: "x#{i}") }
418
+
419
+ query = CommentsByAuthorQuery.new(author_id: @author.id, page: 1, page_size: 10)
420
+ assert_equal 10, query.results.page_count
421
+
422
+ next_page = query.next_page_query
423
+ assert_equal 2, next_page.page
424
+ end
425
+ end
426
+ ```
427
+
428
+ ## Reference files
429
+
430
+ | File | Read when you need… |
431
+ |---|---|
432
+ | [QUERY_TYPES.md](references/QUERY_TYPES.md) | Detail on RelationBackedQuery vs CollectionBackedQuery, conversion, internals |
433
+ | [COMPOSITION.md](references/COMPOSITION.md) | Composition modes, merge strategies, joins, conditional building |
434
+ | [PAGINATION.md](references/PAGINATION.md) | Page navigation, counts, unpaginated access |
435
+ | [TRANSFORMERS.md](references/TRANSFORMERS.md) | Result transformation, presenter patterns, scope of context capture |
436
+ | [API_REFERENCE.md](references/API_REFERENCE.md) | Method-by-method reference for queries and results |
437
+
438
+ ## External resources
439
+
440
+ - Documentation site: <https://quo-gem.diaconou.com/>
441
+ - Source: <https://github.com/stevegeek/quo>
442
+ - Literal (type system): <https://github.com/joeldrapper/literal>