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,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>
|