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,396 @@
1
+ # Query Composition Reference
2
+
3
+ > **Targets Quo `~> 2.0`.**
4
+
5
+ Quo lets you combine query objects using `+` (alias of `compose` /
6
+ `merge`). It supports two composition modes that look identical but do
7
+ different work. **Choose deliberately** — picking the wrong one is the
8
+ single most common Quo perf footgun.
9
+
10
+ ## The two composition modes
11
+
12
+ ### 1. Class composition — `SomeClass + OtherClass`
13
+
14
+ When the operands are query **classes**, `+` returns a new Class. This is
15
+ useful for *defining a new named query type* in terms of existing ones.
16
+
17
+ ```ruby
18
+ RecentNonSpamComments = RecentCommentsQuery + NonSpamCommentsQuery
19
+
20
+ # Or as a real subclass when you want to add props/methods on top.
21
+ class RecentNonSpamComments < (RecentCommentsQuery + NonSpamCommentsQuery)
22
+ prop :author_id, _Nilable(Integer)
23
+ end
24
+
25
+ # Then per-request, instantiate and use like any Quo class.
26
+ RecentNonSpamComments.new(since: 1.day.ago, score: 0.5).results
27
+ ```
28
+
29
+ Class composition has real per-call cost (it allocates a new anonymous
30
+ class and re-defines properties on it). Treat it as type-definition, not
31
+ runtime work — assign to a constant or define a class once at file-load
32
+ time.
33
+
34
+ ### 2. Instance composition — `some_instance + other_instance`
35
+
36
+ When the operands are query **instances**, `+` returns a value-shaped
37
+ query that holds both operands. No class is allocated.
38
+
39
+ ```ruby
40
+ def list_comments(filters)
41
+ query = RecentCommentsQuery.new(since: filters[:since])
42
+ query += NonSpamCommentsQuery.new(score: filters[:score]) if filters[:score]
43
+ query.results
44
+ end
45
+ ```
46
+
47
+ Instance composition is cheap. Use it freely inside controllers,
48
+ operations, render loops, anywhere you want to combine concrete query
49
+ values with their own props.
50
+
51
+ ### Anti-pattern: class composition at the call site
52
+
53
+ ```ruby
54
+ # Wrong — allocates a new class on every call to fan props down.
55
+ (RecentCommentsQuery + NonSpamCommentsQuery)
56
+ .new(since: 1.day.ago, score: 0.5)
57
+ .results
58
+
59
+ # Right — instance composition, no class allocation.
60
+ (RecentCommentsQuery.new(since: 1.day.ago) +
61
+ NonSpamCommentsQuery.new(score: 0.5))
62
+ .results
63
+ ```
64
+
65
+ The wrong form also relies on prop fan-out: the framework guesses that
66
+ `since:` belongs to the left class and `score:` to the right. Class
67
+ composition at the call site makes that magic *necessary*; instance
68
+ composition makes it unnecessary because each leaf gets its own props at
69
+ construction.
70
+
71
+ ### Decision guide
72
+
73
+ | You want to… | Use |
74
+ |---|---|
75
+ | Define a new query type from existing ones, once | `Q1 + Q2` (class) |
76
+ | Combine instances at a call site with specific props | `q1 + q2` (instance) |
77
+ | Add props/methods on top of a composition | `class Foo < (Q1 + Q2); ... end` |
78
+ | Conditionally add a filter at runtime | `q + maybe_filter` (instance) |
79
+ | Wrap a bare AR relation in a hot loop | `Quo::RelationBackedQuery.from(rel)` |
80
+
81
+ ## What instance composition returns
82
+
83
+ `some_instance + other_instance` returns a **value**, not a class:
84
+
85
+ - `q1 + q2` where one side is relation-backed →
86
+ `Quo::ComposedRelationBackedQuery`
87
+ - both sides collection-backed → `Quo::ComposedCollectionBackedQuery`
88
+
89
+ Both are real concrete classes with `left`, `right`, `merge_joins` as
90
+ typed Literal props. There's a single class per kind; no anonymous
91
+ class is allocated per composition call.
92
+
93
+ These value-form composed queries are themselves `Quo::Query` instances,
94
+ so they:
95
+
96
+ - accept `.results`, `.unwrap`, `.unwrap_unpaginated`, `.to_sql`, etc.
97
+ - can be paginated (`.copy(page: 2, page_size: 25)`)
98
+ - can have a transformer attached (`.transform { ... }`)
99
+ - can themselves be composed further (`(q1 + q2) + q3`)
100
+ - can have specs applied (`.order(...)`, `.where(...)`, `.joins(...)`, `.distinct`)
101
+ — the spec is applied to the merged relation at unwrap time.
102
+
103
+ ## `#copy` on a composed instance
104
+
105
+ `composed.copy(**overrides)` behaves like `copy` on any Quo::Query —
106
+ return a new instance with some props overridden. Two kinds of override:
107
+
108
+ 1. Overrides for the composed's own props (`left`, `right`, `merge_joins`,
109
+ `_specification`, `page`, `page_size`) go through the standard Literal
110
+ copy.
111
+
112
+ 2. Overrides for **any other prop** are walked into the operand tree:
113
+ each operand that declares the prop is copied with the new value.
114
+ Composed-as-operand recurses. The composed query exposes one logical
115
+ prop for that name — copying with a new value lands on every leaf
116
+ that owns it.
117
+
118
+ ```ruby
119
+ q = Q1.new(score: 0.5) + Q2.new(score: 0.5)
120
+ updated = q.copy(score: 0.9)
121
+ # both operands now have score: 0.9
122
+ ```
123
+
124
+ 3. Unknown prop (declared by no operand) → `ArgumentError`. Same surface
125
+ as a normal `copy(unknown_prop:)` on a leaf.
126
+
127
+ `#copy` on a composed instance is O(tree size) per fan override —
128
+ intended for call-site convenience, not for hot paths.
129
+
130
+ ## Composition behaviour by query type
131
+
132
+ The `+` operator works across both `RelationBackedQuery` and
133
+ `CollectionBackedQuery`. The merge strategy depends on the types being
134
+ combined.
135
+
136
+ ### Relation + Relation
137
+
138
+ Two `RelationBackedQuery` operands → ActiveRecord `merge`.
139
+
140
+ ```ruby
141
+ class PublishedPostsQuery < Quo::RelationBackedQuery
142
+ def query
143
+ Post.where("body IS NOT NULL")
144
+ end
145
+ end
146
+
147
+ class PostsByAuthorQuery < Quo::RelationBackedQuery
148
+ prop :author_id, Integer
149
+ def query
150
+ Post.where(author_id: author_id)
151
+ end
152
+ end
153
+
154
+ published = PublishedPostsQuery.new
155
+ mine = PostsByAuthorQuery.new(author_id: 1)
156
+ (published + mine).results
157
+ # SQL: SELECT "posts".* FROM "posts"
158
+ # WHERE "body" IS NOT NULL AND "posts"."author_id" = 1
159
+ ```
160
+
161
+ Behaviours:
162
+
163
+ - WHERE clauses combine with AND
164
+ - Joins are merged
165
+ - ORDER clauses combine
166
+ - For LIMIT/OFFSET the right operand wins (later overrides earlier)
167
+
168
+ ### Relation + Collection
169
+
170
+ A `RelationBackedQuery` + a `CollectionBackedQuery` runs the relation,
171
+ materialises it to an array, then concatenates with the collection.
172
+
173
+ ```ruby
174
+ db_query = PublishedPostsQuery.new
175
+ mem = Quo::CollectionBackedQuery.wrap([extra_post1, extra_post2]).new
176
+ (db_query + mem).results.to_a
177
+ # Loads all published posts into memory, then appends extras.
178
+ ```
179
+
180
+ **Cost note:** the relation is materialised in full — pagination on the
181
+ relation side is bypassed by composition. Use sparingly with large
182
+ relations.
183
+
184
+ ### Collection + Collection
185
+
186
+ Two `CollectionBackedQuery` operands → array concatenation.
187
+
188
+ ```ruby
189
+ first = Quo::CollectionBackedQuery.wrap([1, 2, 3]).new
190
+ second = Quo::CollectionBackedQuery.wrap([4, 5, 6]).new
191
+ (first + second).results.to_a # => [1, 2, 3, 4, 5, 6]
192
+ ```
193
+
194
+ ## Merge with explicit joins
195
+
196
+ When composing two Relation queries against different tables, pass `joins:`
197
+ so AR knows how to combine them.
198
+
199
+ ```ruby
200
+ class PostsQuery < Quo::RelationBackedQuery
201
+ def query
202
+ Post.where("title IS NOT NULL")
203
+ end
204
+ end
205
+
206
+ class AuthorsQuery < Quo::RelationBackedQuery
207
+ prop :verified, _Boolean, default: -> { true }
208
+ def query
209
+ Author.where(verified: verified) # assumes Author has :verified
210
+ end
211
+ end
212
+
213
+ PostsQuery.new.merge(AuthorsQuery.new, joins: :author).results
214
+ # SQL: SELECT "posts".* FROM "posts"
215
+ # INNER JOIN "authors" ON "authors"."id" = "posts"."author_id"
216
+ # WHERE "title" IS NOT NULL AND "authors"."verified" = true
217
+ ```
218
+
219
+ Multiple joins chain naturally:
220
+
221
+ ```ruby
222
+ posts.merge(authors, joins: :author)
223
+ .merge(comments, joins: :comments)
224
+ ```
225
+
226
+ The `joins:` argument accepts anything `ActiveRecord::Relation#joins`
227
+ accepts: a Symbol, Hash, or Array of either.
228
+
229
+ ## Composition vs direct chaining
230
+
231
+ Composition is for **reusable** query fragments. If a query is one-off,
232
+ just use AR directly inside a single Quo class.
233
+
234
+ ```ruby
235
+ # Composition (good when the parts are reused elsewhere)
236
+ class ActiveCommentsQuery < Quo::RelationBackedQuery
237
+ def query; Comment.unread; end
238
+ end
239
+
240
+ class RecentCommentsQuery < Quo::RelationBackedQuery
241
+ prop :since, Time, default: -> { 1.day.ago }
242
+ def query; Comment.where("created_at > ?", since); end
243
+ end
244
+
245
+ (ActiveCommentsQuery.new + RecentCommentsQuery.new(since: 1.hour.ago)).results
246
+ ```
247
+
248
+ ```ruby
249
+ # Direct chaining (better if this is the only place it's used)
250
+ class ActiveRecentCommentsQuery < Quo::RelationBackedQuery
251
+ prop :since, Time, default: -> { 1.day.ago }
252
+
253
+ def query
254
+ Comment.unread.where("created_at > ?", since)
255
+ end
256
+ end
257
+ ```
258
+
259
+ **Use composition when:**
260
+ - Each fragment is reused in multiple places
261
+ - You compose conditionally (some filters only apply sometimes)
262
+ - Tests benefit from exercising fragments in isolation
263
+
264
+ **Use direct chaining when:**
265
+ - The query is specific to one call site
266
+ - All conditions always apply together
267
+ - Performance matters and you want zero composition overhead
268
+
269
+ ## Conditional composition
270
+
271
+ The instance form is ideal for runtime-conditional filters. Each `+=` is
272
+ cheap and only allocates the merged value.
273
+
274
+ ```ruby
275
+ def comments_query(filters)
276
+ query = AllCommentsQuery.new
277
+ query += UnreadCommentsQuery.new if filters[:unread]
278
+ query += NonSpamCommentsQuery.new(score: 0.5) if filters[:hide_spam]
279
+ query += AuthorFilterQuery.new(author_id: filters[:author_id]) if filters[:author_id]
280
+ query
281
+ end
282
+
283
+ comments_query(unread: true, author_id: 42).results
284
+ ```
285
+
286
+ For class-level conditional definition (e.g. you build a base type at
287
+ load time but want optional layers at construction), prefer giving the
288
+ class a single nilable prop and branching inside `#query`:
289
+
290
+ ```ruby
291
+ class CommentsQuery < Quo::RelationBackedQuery
292
+ prop :author_id, _Nilable(Integer)
293
+ prop :since, _Nilable(Time)
294
+ prop :hide_spam, _Boolean, default: -> { false }
295
+
296
+ def query
297
+ scope = Comment.all
298
+ scope = scope.where(post_id: Post.where(author_id: author_id)) if author_id
299
+ scope = scope.where("created_at > ?", since) if since
300
+ scope = scope.where("spam_score < 0.5 OR spam_score IS NULL") if hide_spam
301
+ scope
302
+ end
303
+ end
304
+ ```
305
+
306
+ Both styles are valid; pick by where the optionality lives (call site vs.
307
+ inside the query type).
308
+
309
+ ## Composition + transformers
310
+
311
+ Transformers and composition compose in either order. The transformer of
312
+ the outer query wins.
313
+
314
+ ```ruby
315
+ # Compose first, transform last
316
+ base = AllCommentsQuery.new
317
+ filtered = base + UnreadCommentsQuery.new
318
+ presented = filtered.transform { |c| CommentPresenter.new(c) }
319
+ presented.results # presenters
320
+ ```
321
+
322
+ ```ruby
323
+ # Transform first, compose later — transformer carries through
324
+ transformed = AllCommentsQuery.new.transform { |c| CommentPresenter.new(c) }
325
+ filtered = transformed + UnreadCommentsQuery.new
326
+ filtered.results # presenters
327
+ ```
328
+
329
+ If both sides have transformers, the *right* one is used for the merged
330
+ result. Mixing transformers across composition is rarely what you want;
331
+ attach the transformer once, on the outermost query.
332
+
333
+ ## Composition immutability
334
+
335
+ Compositions never mutate operands. Each `+` returns a fresh value (or a
336
+ fresh class, in the class-composition case).
337
+
338
+ ```ruby
339
+ base = AllCommentsQuery.new
340
+ filter = UnreadCommentsQuery.new
341
+ composed = base + filter
342
+
343
+ base.equal?(composed) # => false
344
+ filter.equal?(composed) # => false
345
+ # base and filter remain independently usable
346
+ ```
347
+
348
+ ## Testing composed queries
349
+
350
+ Test fragments individually, then test the composition end-to-end with
351
+ representative data.
352
+
353
+ ```ruby
354
+ class CommentCompositionTest < ActiveSupport::TestCase
355
+ setup do
356
+ @author = Author.create!(name: "Ada")
357
+ @post = Post.create!(title: "Hi", author: @author)
358
+ @target = Comment.create!(post: @post, body: "ok", read: false, spam_score: 0.1)
359
+ @spammy = Comment.create!(post: @post, body: "buy", read: false, spam_score: 0.9)
360
+ @read = Comment.create!(post: @post, body: "old", read: true, spam_score: 0.1)
361
+ end
362
+
363
+ test "unread + non_spam returns only unread, non-spammy comments" do
364
+ composed = UnreadCommentsQuery.new + NonSpamCommentsQuery.new(score: 0.5)
365
+
366
+ results = composed.results.to_a
367
+ assert_includes results, @target
368
+ refute_includes results, @spammy
369
+ refute_includes results, @read
370
+ end
371
+
372
+ test "composition leaves operands intact" do
373
+ base = AllCommentsQuery.new
374
+ filter = UnreadCommentsQuery.new
375
+ _composed = base + filter
376
+
377
+ assert base.results.count >= 3
378
+ assert filter.results.count >= 2
379
+ end
380
+ end
381
+ ```
382
+
383
+ ## Performance guidance
384
+
385
+ - **Prefer Relation + Relation.** All work stays in the database.
386
+ - **Avoid Relation + Collection on hot paths.** It materialises the
387
+ full relation in memory.
388
+ - **Hoist class compositions to constants.** Don't use class composition
389
+ per-request; use instance composition there.
390
+ - **Don't `wrap(rel).new` on hot paths.** Like class composition, `wrap`
391
+ allocates a new class. Hoist to a constant if you call it more than
392
+ once. (See `references/QUERY_TYPES.md` on `wrap` for detail.)
393
+ - **Profile if in doubt.** A 10-line Quo composition can hide a
394
+ surprising amount of class allocation if used incorrectly. The
395
+ class/instance distinction is the lever to pull — instance
396
+ composition allocates no new classes per call.