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.
- 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/LICENSE.txt +1 -1
- data/README.md +496 -203
- 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
|
+
# 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.
|