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.
- 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/README.md +379 -75
- 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
data/README.md
CHANGED
|
@@ -1,17 +1,94 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="quo.png" alt="Quo" width="160" height="160" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# Quo: Query Objects for ActiveRecord & Collections
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
Quo helps you organize database and collection queries into reusable, composable, and testable objects.
|
|
11
|
+
|
|
12
|
+
## Quick Example
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
# Define query objects to encapsulate query logic
|
|
16
|
+
class RecentPostsQuery < Quo::RelationBackedQuery
|
|
17
|
+
# Type-safe properties with defaults
|
|
18
|
+
prop :days_ago, Integer, default: -> { 7 }
|
|
19
|
+
|
|
20
|
+
def query
|
|
21
|
+
Post.where(Post.arel_table[:created_at].gt(days_ago.days.ago))
|
|
22
|
+
.order(created_at: :desc)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Use queries with pagination
|
|
27
|
+
posts_query = RecentPostsQuery.new(days_ago: 30, page: 1, page_size: 10)
|
|
28
|
+
page1 = posts_query.results
|
|
29
|
+
# => Returns first 10 posts from the last 30 days
|
|
30
|
+
|
|
31
|
+
# Navigate between pages
|
|
32
|
+
page2_query = posts_query.next_page_query
|
|
33
|
+
page2 = page2_query.results
|
|
34
|
+
# => Returns next 10 posts
|
|
35
|
+
|
|
36
|
+
class CommentNotSpamQuery < Quo::RelationBackedQuery
|
|
37
|
+
prop :spam_score_threshold, _Float(0..1.0)
|
|
38
|
+
|
|
39
|
+
def query
|
|
40
|
+
comments = Comment.arel_table
|
|
41
|
+
Comment.where(
|
|
42
|
+
comments[:spam_score].eq(nil).or(comments[:spam_score].lt(spam_score_threshold))
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Get recent posts (last 10 days) which have comments that are not Spam
|
|
48
|
+
posts_last_10_days = RecentPostsQuery.new(days_ago: 10).joins(:comments)
|
|
49
|
+
|
|
50
|
+
# Compose your queries
|
|
51
|
+
query = posts_last_10_days + CommentNotSpamQuery.new(spam_score_threshold: 0.5)
|
|
52
|
+
|
|
53
|
+
# Transform results
|
|
54
|
+
transformed_query = query.transform { |post| PostPresenter.new(post) }
|
|
55
|
+
|
|
56
|
+
# Work with result sets
|
|
57
|
+
transformed_query.results.each do |presenter|
|
|
58
|
+
puts presenter.formatted_title
|
|
59
|
+
end
|
|
60
|
+
```
|
|
2
61
|
|
|
3
|
-
Quo helps you organize database queries into reusable, composable, and testable objects.
|
|
4
62
|
|
|
5
63
|
## Core Features
|
|
6
64
|
|
|
7
|
-
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
*
|
|
14
|
-
*
|
|
65
|
+
### Collections
|
|
66
|
+
* Query objects can wrap either an ActiveRecord relation (`RelationBackedQuery`) or any Enumerable collection (`CollectionBackedQuery`)
|
|
67
|
+
* Built-in pagination that works with both database queries and enumerable collections
|
|
68
|
+
* Flexible interface for creating custom queries or wrapping existing queries
|
|
69
|
+
|
|
70
|
+
### Configurable
|
|
71
|
+
* Type-safe properties with optional default values using the Literal gem
|
|
72
|
+
* Each query is (kinda) "immutable" - operations return new query instances, mutation is actively frowned upon
|
|
73
|
+
* Configure your own base classes, default page sizes, and more
|
|
74
|
+
|
|
75
|
+
### Composition and Transformation
|
|
76
|
+
* Combine queries using the `+` operator (alias for `compose` method)
|
|
77
|
+
* Mix and match relation-backed and collection-backed queries
|
|
78
|
+
* Join queries with explicit join conditions using the `joins` parameter
|
|
79
|
+
* Transform results consistently using the `transform` method
|
|
80
|
+
|
|
81
|
+
### Fluent API
|
|
82
|
+
* Chain methods that mirror ActiveRecord's query interface (where, order, limit, etc.)
|
|
83
|
+
* Access utility methods that work on both relation and collection queries (exists?, empty?, etc.)
|
|
84
|
+
* Navigation helpers for pagination (next_page_query, previous_page_query)
|
|
85
|
+
|
|
86
|
+
### Query Results
|
|
87
|
+
* Clear separation between query definition and execution with `Results` objects
|
|
88
|
+
* Automatic application of transformations across all result methods
|
|
89
|
+
* Consistent interface regardless of the underlying query type
|
|
90
|
+
* Support for common methods: each, map, first/last, count, exists?, group_by, and more
|
|
91
|
+
|
|
15
92
|
|
|
16
93
|
## Core Concepts
|
|
17
94
|
|
|
@@ -67,20 +144,53 @@ end
|
|
|
67
144
|
admins = CachedUsers.new(role: "admin").results
|
|
68
145
|
```
|
|
69
146
|
|
|
70
|
-
## Quick Queries with Wrap
|
|
147
|
+
## Quick Queries with Wrap and to_collection
|
|
148
|
+
|
|
149
|
+
### Creating Query Objects with Wrap
|
|
71
150
|
|
|
72
|
-
Create query objects without subclassing:
|
|
151
|
+
Create query objects on the fly without subclassing using the `wrap` class method:
|
|
73
152
|
|
|
74
153
|
```ruby
|
|
75
|
-
# Relation-backed
|
|
154
|
+
# Relation-backed query from an ActiveRecord relation
|
|
76
155
|
users_query = Quo::RelationBackedQuery.wrap(User.active).new
|
|
77
156
|
active_users = users_query.results
|
|
78
157
|
|
|
79
|
-
#
|
|
158
|
+
# Relation-backed query with a block
|
|
159
|
+
posts_query = Quo::RelationBackedQuery.wrap(props: {tag: String}) do
|
|
160
|
+
Post.where(published: true).where("title LIKE ?", "%#{tag}%")
|
|
161
|
+
end
|
|
162
|
+
tagged_posts = posts_query.new(tag: "ruby").results
|
|
163
|
+
|
|
164
|
+
# Collection-backed query from an array
|
|
80
165
|
items_query = Quo::CollectionBackedQuery.wrap([1, 2, 3]).new
|
|
81
166
|
items = items_query.results
|
|
167
|
+
|
|
168
|
+
# Collection-backed query with properties and a block
|
|
169
|
+
filtered_query = Quo::CollectionBackedQuery.wrap(props: {min: Integer}) do
|
|
170
|
+
[1, 2, 3, 4, 5].select { |n| n >= min }
|
|
171
|
+
end
|
|
172
|
+
result = filtered_query.new(min: 3).results # [3, 4, 5]
|
|
82
173
|
```
|
|
83
174
|
|
|
175
|
+
### Converting Between Query Types
|
|
176
|
+
|
|
177
|
+
Convert a relation-backed query to a collection-backed query using `to_collection`:
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
# Start with a relation-backed query
|
|
181
|
+
relation_query = UsersByState.new(state: "California")
|
|
182
|
+
|
|
183
|
+
# Convert to a collection-backed query (executes the query)
|
|
184
|
+
collection_query = relation_query.to_collection
|
|
185
|
+
collection_query.collection? # => true
|
|
186
|
+
collection_query.relation? # => false
|
|
187
|
+
|
|
188
|
+
# You can optionally specify a total count (useful for pagination)
|
|
189
|
+
collection_query = relation_query.to_collection(total_count: 100)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
This is useful when you want to convert an ActiveRecord relation to an enumerable collection while preserving the query interface.
|
|
193
|
+
|
|
84
194
|
## Type-Safe Properties
|
|
85
195
|
|
|
86
196
|
Quo uses the `Literal` gem for typed properties:
|
|
@@ -89,7 +199,7 @@ Quo uses the `Literal` gem for typed properties:
|
|
|
89
199
|
class UsersByState < Quo::RelationBackedQuery
|
|
90
200
|
prop :state, String
|
|
91
201
|
prop :minimum_age, Integer, default: -> { 18 }
|
|
92
|
-
prop :active_only,
|
|
202
|
+
prop :active_only, _Boolean, default: -> { true }
|
|
93
203
|
|
|
94
204
|
def query
|
|
95
205
|
scope = User.where(state: state)
|
|
@@ -102,30 +212,6 @@ end
|
|
|
102
212
|
query = UsersByState.new(state: "California", minimum_age: 21)
|
|
103
213
|
```
|
|
104
214
|
|
|
105
|
-
## Fluent API for Building Queries
|
|
106
|
-
|
|
107
|
-
```ruby
|
|
108
|
-
query = UsersByState.new(state: "California")
|
|
109
|
-
.order(created_at: :desc)
|
|
110
|
-
.includes(:profile)
|
|
111
|
-
.limit(10)
|
|
112
|
-
.where(verified: true)
|
|
113
|
-
|
|
114
|
-
users = query.results
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
Available methods include:
|
|
118
|
-
* `where`
|
|
119
|
-
* `order`
|
|
120
|
-
* `limit`
|
|
121
|
-
* `includes`
|
|
122
|
-
* `preload`
|
|
123
|
-
* `left_outer_joins`
|
|
124
|
-
* `joins`
|
|
125
|
-
* `group`
|
|
126
|
-
|
|
127
|
-
Each method returns a new query instance without modifying the original.
|
|
128
|
-
|
|
129
215
|
## Pagination
|
|
130
216
|
|
|
131
217
|
```ruby
|
|
@@ -135,17 +221,17 @@ query = UsersByState.new(
|
|
|
135
221
|
page_size: 20
|
|
136
222
|
)
|
|
137
223
|
|
|
138
|
-
# Get paginated results
|
|
224
|
+
# Get paginated results for page 2 with 20 items
|
|
139
225
|
users = query.results
|
|
140
226
|
|
|
141
|
-
# Navigation
|
|
227
|
+
# Navigation to next and previous pages creates new queries
|
|
142
228
|
next_page = query.next_page_query
|
|
143
229
|
prev_page = query.previous_page_query
|
|
144
230
|
```
|
|
145
231
|
|
|
146
232
|
## Composing Queries
|
|
147
233
|
|
|
148
|
-
|
|
234
|
+
Quo provides extensive query composition capabilities, letting you combine multiple query objects:
|
|
149
235
|
|
|
150
236
|
```ruby
|
|
151
237
|
class ActiveUsers < Quo::RelationBackedQuery
|
|
@@ -160,38 +246,73 @@ class PremiumUsers < Quo::RelationBackedQuery
|
|
|
160
246
|
end
|
|
161
247
|
end
|
|
162
248
|
|
|
163
|
-
# Compose queries
|
|
249
|
+
# Compose queries using the + operator
|
|
164
250
|
active_premium = ActiveUsers.new + PremiumUsers.new
|
|
165
251
|
users = active_premium.results
|
|
166
252
|
```
|
|
167
253
|
|
|
168
|
-
You can compose queries
|
|
169
|
-
* `
|
|
170
|
-
* `
|
|
171
|
-
* `
|
|
254
|
+
You can compose queries in several ways:
|
|
255
|
+
* At the class level: `ActiveUsers.compose(PremiumUsers)` or `ActiveUsers + PremiumUsers`
|
|
256
|
+
* At the instance level: `active_query.merge(premium_query)` or `active_query + premium_query`
|
|
257
|
+
* With joins: `active_query.merge(premium_query, joins: :some_association)`
|
|
172
258
|
|
|
173
|
-
|
|
259
|
+
Quo handles different composition scenarios automatically:
|
|
260
|
+
* Relation + Relation: Uses ActiveRecord's merge capabilities
|
|
261
|
+
* Relation + Collection: Combines the results of both
|
|
262
|
+
* Collection + Collection: Concatenates the collections
|
|
263
|
+
|
|
264
|
+
For example, to compose query objects with proper joins:
|
|
174
265
|
|
|
175
266
|
```ruby
|
|
176
|
-
|
|
267
|
+
# Query for posts
|
|
268
|
+
class PostsQuery < Quo::RelationBackedQuery
|
|
177
269
|
def query
|
|
178
|
-
|
|
270
|
+
Post.where(published: true)
|
|
179
271
|
end
|
|
180
272
|
end
|
|
181
273
|
|
|
182
|
-
|
|
274
|
+
# Query for authors
|
|
275
|
+
class AuthorsQuery < Quo::RelationBackedQuery
|
|
183
276
|
def query
|
|
184
|
-
|
|
277
|
+
Author.where(active: true)
|
|
185
278
|
end
|
|
186
279
|
end
|
|
187
280
|
|
|
188
|
-
# Compose with a
|
|
189
|
-
|
|
281
|
+
# Compose with a joins parameter to specify the relationship
|
|
282
|
+
composed_query = PostsQuery.new.merge(AuthorsQuery.new, joins: :author)
|
|
283
|
+
# You can also use this equivalent form:
|
|
284
|
+
# composed_query = PostsQuery.new.joins(:author) + AuthorsQuery.new
|
|
190
285
|
|
|
191
|
-
#
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
286
|
+
# Returns published posts by active authors
|
|
287
|
+
results = composed_query.results
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
## Utility Methods
|
|
292
|
+
|
|
293
|
+
Quo query objects provide several utility methods to help you work with them:
|
|
294
|
+
|
|
295
|
+
```ruby
|
|
296
|
+
query = UsersByState.new(state: "California")
|
|
297
|
+
|
|
298
|
+
# Check query type
|
|
299
|
+
query.relation? # => true if backed by an ActiveRecord relation
|
|
300
|
+
query.collection? # => true if backed by a collection
|
|
301
|
+
|
|
302
|
+
# Check pagination status
|
|
303
|
+
query.paged? # => true if pagination is enabled (page is set)
|
|
304
|
+
|
|
305
|
+
# Check transformation status
|
|
306
|
+
query.transform? # => true if a transformer is set
|
|
307
|
+
|
|
308
|
+
# Get the raw underlying query without pagination
|
|
309
|
+
raw_query = query.unwrap_unpaginated # => The ActiveRecord relation or collection
|
|
310
|
+
|
|
311
|
+
# Get the configured query with pagination
|
|
312
|
+
configured_query = query.unwrap # => The query with pagination applied
|
|
313
|
+
|
|
314
|
+
# For RelationBackedQuery, get SQL representation
|
|
315
|
+
puts query.to_sql # => "SELECT users.* FROM users WHERE users.state = 'California'"
|
|
195
316
|
```
|
|
196
317
|
|
|
197
318
|
## Transforming Results
|
|
@@ -204,42 +325,190 @@ query = UsersByState.new(state: "California")
|
|
|
204
325
|
presenters = query.results.to_a # Array of UserPresenter objects
|
|
205
326
|
```
|
|
206
327
|
|
|
207
|
-
##
|
|
328
|
+
## Working with Results Objects
|
|
329
|
+
|
|
330
|
+
When you call `.results` on a query object, you get a `Results` object that wraps the underlying collection and ensures consistent application of transformations.
|
|
331
|
+
|
|
332
|
+
```ruby
|
|
333
|
+
# Create a query with a transformer
|
|
334
|
+
users_query = UsersByState.new(state: "California")
|
|
335
|
+
.transform { |user| UserPresenter.new(user) }
|
|
336
|
+
|
|
337
|
+
# Get results - transformations are applied consistently
|
|
338
|
+
results = users_query.results
|
|
339
|
+
|
|
340
|
+
# Existence checks
|
|
341
|
+
results.exists? # => true/false
|
|
342
|
+
results.empty? # => false/true
|
|
343
|
+
|
|
344
|
+
# Count methods
|
|
345
|
+
results.count # Total count of results (ignoring pagination)
|
|
346
|
+
results.total_count # Same as count
|
|
347
|
+
results.size # Same as count
|
|
348
|
+
results.page_count # Count of items on current page (respects pagination)
|
|
349
|
+
results.page_size # Same as page_count
|
|
350
|
+
|
|
351
|
+
# Enumerable methods - all respect transformations
|
|
352
|
+
results.each { |presenter| puts presenter.formatted_name }
|
|
353
|
+
results.map { |presenter| presenter.email }
|
|
354
|
+
results.select { |presenter| presenter.active? }
|
|
355
|
+
results.reject { |presenter| presenter.inactive? }
|
|
356
|
+
results.first # Returns the first transformed item
|
|
357
|
+
results.last # Returns the last transformed item
|
|
358
|
+
results.first(3) # Returns the first 3 transformed items
|
|
359
|
+
results.to_a # Returns all transformed items as an array
|
|
360
|
+
|
|
361
|
+
# ActiveRecord extensions (for RelationResults)
|
|
362
|
+
results.find(123) # Find by id and transform
|
|
363
|
+
results.find_by(email: "user@example.com") # Find by attributes and transform
|
|
364
|
+
results.where(active: true) # Returns a new Results with the condition applied
|
|
365
|
+
|
|
366
|
+
# Methods are delegated to the underlying collection
|
|
367
|
+
# and transformations are applied consistently
|
|
368
|
+
results.group_by(&:role) # Groups transformed objects by role
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
Quo provides two types of Results objects:
|
|
372
|
+
- `RelationResults` - For ActiveRecord-based queries, delegates to the underlying relation
|
|
373
|
+
- `CollectionResults` - For collection-based queries, delegates to the enumerable collection
|
|
374
|
+
|
|
375
|
+
## Fluent API for Building Queries
|
|
376
|
+
|
|
377
|
+
Quo implements a fluent API that mirrors ActiveRecord's query interface, allowing you to chain methods that build up your query:
|
|
378
|
+
|
|
379
|
+
```ruby
|
|
380
|
+
# Start with a base query
|
|
381
|
+
query = UsersByState.new(state: "California")
|
|
382
|
+
|
|
383
|
+
# Chain method calls to build your query
|
|
384
|
+
refined_query = query
|
|
385
|
+
.order(created_at: :desc) # Order results
|
|
386
|
+
.includes(:profile, :posts) # Eager load associations
|
|
387
|
+
.joins(:posts) # Join with posts
|
|
388
|
+
.where(verified: true) # Add conditions
|
|
389
|
+
.limit(10) # Limit results
|
|
390
|
+
.group("users.role") # Group results
|
|
391
|
+
|
|
392
|
+
# Original query remains unchanged
|
|
393
|
+
original_results = query.results
|
|
394
|
+
refined_results = refined_query.results
|
|
395
|
+
|
|
396
|
+
# You can further refine as needed
|
|
397
|
+
admin_query = refined_query.where(role: "admin")
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
Available methods for relation-backed queries include:
|
|
401
|
+
* `where` - Add conditions to the query
|
|
402
|
+
* `not` - Negate conditions
|
|
403
|
+
* `or` - Add OR conditions
|
|
404
|
+
* `order` - Set the order of results
|
|
405
|
+
* `reorder` - Replace existing order
|
|
406
|
+
* `limit` - Limit the number of results
|
|
407
|
+
* `offset` - Set an offset for results
|
|
408
|
+
* `includes` - Eager load associations
|
|
409
|
+
* `preload` - Preload associations
|
|
410
|
+
* `eager_load` - Eager load with LEFT OUTER JOIN
|
|
411
|
+
* `joins` - Add inner joins
|
|
412
|
+
* `left_outer_joins` - Add left outer joins
|
|
413
|
+
* `group` - Group results
|
|
414
|
+
* `select` - Specify columns to select
|
|
415
|
+
* `distinct` - Return distinct results
|
|
416
|
+
|
|
417
|
+
Each method returns a new query instance without modifying the original, ensuring queries are immutable and can be safely composed.
|
|
418
|
+
|
|
419
|
+
## Association Preloading in Collection-Backed Queries
|
|
420
|
+
|
|
421
|
+
When working with enumerable collections of ActiveRecord models, you can still preload associations to avoid N+1 queries. This is particularly useful when you have collections that don't come directly from the database but still need efficient association loading.
|
|
422
|
+
|
|
423
|
+
Include the `Quo::Preloadable` module in your collection-backed query and use the `includes` or `preload` methods:
|
|
208
424
|
|
|
209
425
|
```ruby
|
|
210
|
-
class
|
|
426
|
+
class FirstAndLastUsers < Quo::CollectionBackedQuery
|
|
211
427
|
include Quo::Preloadable
|
|
212
428
|
|
|
429
|
+
def collection
|
|
430
|
+
[User.first, User.last] # These users come from separate queries
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# Preload the profiles and posts for both users in a single efficient query
|
|
435
|
+
query = FirstAndLastUsers.new.includes(:profile, :posts)
|
|
436
|
+
|
|
437
|
+
# Check that the association is loaded
|
|
438
|
+
query.results.first.profile.loaded? # => true
|
|
439
|
+
query.results.last.posts.loaded? # => true
|
|
440
|
+
|
|
441
|
+
# Access the preloaded associations without triggering additional queries
|
|
442
|
+
query.results.each do |user|
|
|
443
|
+
puts "#{user.name} has #{user.posts.size} posts"
|
|
444
|
+
end
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
The `Preloadable` module overrides the `query` method to apply ActiveRecord's preloader to your collection.
|
|
448
|
+
|
|
449
|
+
### Composing with Joins
|
|
450
|
+
|
|
451
|
+
```ruby
|
|
452
|
+
class ProductsQuery < Quo::RelationBackedQuery
|
|
213
453
|
def query
|
|
214
|
-
|
|
454
|
+
Product.where(active: true)
|
|
215
455
|
end
|
|
456
|
+
end
|
|
216
457
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
records: collection,
|
|
221
|
-
associations: [:profile, :orders]
|
|
222
|
-
).call
|
|
223
|
-
|
|
224
|
-
collection
|
|
458
|
+
class CategoriesQuery < Quo::RelationBackedQuery
|
|
459
|
+
def query
|
|
460
|
+
Category.where(featured: true)
|
|
225
461
|
end
|
|
226
462
|
end
|
|
463
|
+
|
|
464
|
+
# Compose with a join
|
|
465
|
+
products = ProductsQuery.new.merge(CategoriesQuery.new, joins: :category)
|
|
466
|
+
|
|
467
|
+
# Equivalent to:
|
|
468
|
+
# Product.joins(:category)
|
|
469
|
+
# .where(products: { active: true })
|
|
470
|
+
# .where(categories: { featured: true })
|
|
227
471
|
```
|
|
228
472
|
|
|
229
473
|
## Testing Helpers
|
|
230
474
|
|
|
475
|
+
Quo provides testing helpers for both Minitest and RSpec to make your query objects easy to test in isolation.
|
|
476
|
+
|
|
231
477
|
### Minitest
|
|
232
478
|
|
|
479
|
+
The `Quo::Minitest::Helpers` module includes the `fake_query` method that lets you mock query results without hitting the database:
|
|
480
|
+
|
|
233
481
|
```ruby
|
|
234
482
|
class UserQueryTest < ActiveSupport::TestCase
|
|
235
483
|
include Quo::Minitest::Helpers
|
|
236
484
|
|
|
237
485
|
test "filters users by state" do
|
|
486
|
+
# Create test data
|
|
238
487
|
users = [User.new(name: "Alice"), User.new(name: "Bob")]
|
|
239
488
|
|
|
489
|
+
# Mock the query results within the block
|
|
240
490
|
fake_query(UsersByState, results: users) do
|
|
491
|
+
# Any instance of UsersByState created inside this block
|
|
492
|
+
# will return the mocked results regardless of query parameters
|
|
241
493
|
result = UsersByState.new(state: "California").results.to_a
|
|
242
494
|
assert_equal users, result
|
|
495
|
+
|
|
496
|
+
# You can create multiple instances with different parameters
|
|
497
|
+
other_result = UsersByState.new(state: "New York").results.to_a
|
|
498
|
+
assert_equal users, other_result
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# After the block, normal behavior resumes
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
test "works with pagination" do
|
|
505
|
+
users = (1..10).map { |i| User.new(name: "User #{i}") }
|
|
506
|
+
|
|
507
|
+
fake_query(UsersByState, results: users) do
|
|
508
|
+
# Pagination still works with fake query results
|
|
509
|
+
paginated = UsersByState.new(state: "California", page: 1, page_size: 5).results
|
|
510
|
+
assert_equal 5, paginated.page_count
|
|
511
|
+
assert_equal 10, paginated.total_count
|
|
243
512
|
end
|
|
244
513
|
end
|
|
245
514
|
end
|
|
@@ -247,9 +516,11 @@ end
|
|
|
247
516
|
|
|
248
517
|
### RSpec
|
|
249
518
|
|
|
519
|
+
The same functionality is available for RSpec through the `Quo::Rspec::Helpers` module:
|
|
520
|
+
|
|
250
521
|
```ruby
|
|
251
522
|
RSpec.describe UsersByState do
|
|
252
|
-
include Quo::
|
|
523
|
+
include Quo::Rspec::Helpers
|
|
253
524
|
|
|
254
525
|
it "filters users by state" do
|
|
255
526
|
users = [User.new(name: "Alice"), User.new(name: "Bob")]
|
|
@@ -257,6 +528,26 @@ RSpec.describe UsersByState do
|
|
|
257
528
|
fake_query(UsersByState, results: users) do
|
|
258
529
|
result = UsersByState.new(state: "California").results.to_a
|
|
259
530
|
expect(result).to eq(users)
|
|
531
|
+
|
|
532
|
+
# Test that transformations still work
|
|
533
|
+
transformed = UsersByState.new(state: "California")
|
|
534
|
+
.transform { |user| user.name.upcase }
|
|
535
|
+
.results
|
|
536
|
+
|
|
537
|
+
expect(transformed.first).to eq("ALICE")
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
it "can be nested for testing composed queries" do
|
|
542
|
+
users = [User.new(name: "Alice", active: true)]
|
|
543
|
+
premium_users = [User.new(name: "Bob", subscription: "premium")]
|
|
544
|
+
|
|
545
|
+
# Nested fake_query calls for testing composition
|
|
546
|
+
fake_query(ActiveUsers, results: users) do
|
|
547
|
+
fake_query(PremiumUsers, results: premium_users) do
|
|
548
|
+
composed = ActiveUsers.new + PremiumUsers.new
|
|
549
|
+
expect(composed.results.count).to eq(2)
|
|
550
|
+
end
|
|
260
551
|
end
|
|
261
552
|
end
|
|
262
553
|
end
|
|
@@ -307,18 +598,31 @@ $ bundle install
|
|
|
307
598
|
|
|
308
599
|
## Configuration
|
|
309
600
|
|
|
601
|
+
Quo provides several configuration options to customize its behavior to your needs. Configure these in an initializer:
|
|
602
|
+
|
|
310
603
|
```ruby
|
|
311
604
|
# config/initializers/quo.rb
|
|
312
|
-
Quo
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
605
|
+
module Quo
|
|
606
|
+
# Set the default number of items per page (default: 20)
|
|
607
|
+
self.default_page_size = 25
|
|
608
|
+
|
|
609
|
+
# Set the maximum allowed page size to prevent excessive resource usage (default: 200)
|
|
610
|
+
self.max_page_size = 100
|
|
611
|
+
|
|
612
|
+
# Set custom base classes for your queries
|
|
613
|
+
# These must be string names of constantizable classes that inherit from
|
|
614
|
+
# Quo::RelationBackedQuery and Quo::CollectionBackedQuery respectively
|
|
615
|
+
self.relation_backed_query_base_class = "ApplicationQuery"
|
|
616
|
+
self.collection_backed_query_base_class = "ApplicationCollectionQuery"
|
|
617
|
+
end
|
|
316
618
|
```
|
|
317
619
|
|
|
620
|
+
Using custom base classes lets you add functionality that's shared across all your query objects in your application.
|
|
621
|
+
|
|
318
622
|
## Requirements
|
|
319
623
|
|
|
320
624
|
- Ruby 3.1+
|
|
321
|
-
- Rails 7.0+
|
|
625
|
+
- Rails 7.0+, 8.0+
|
|
322
626
|
|
|
323
627
|
## Development
|
|
324
628
|
|
data/Rakefile
CHANGED
|
@@ -1,14 +1,74 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "bundler/gem_tasks"
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
t.libs << "test"
|
|
8
|
-
t.libs << "lib"
|
|
9
|
-
t.test_files = FileList["test/**/*_test.rb"]
|
|
4
|
+
desc "Run tests"
|
|
5
|
+
task :test do
|
|
6
|
+
sh "bin/test"
|
|
10
7
|
end
|
|
11
8
|
|
|
12
9
|
require "standard/rake"
|
|
13
10
|
|
|
14
11
|
task default: %i[test standard]
|
|
12
|
+
|
|
13
|
+
# Add RubyCritic task with badge generation
|
|
14
|
+
begin
|
|
15
|
+
require "rubycritic_small_badge"
|
|
16
|
+
require "rubycritic/rake_task"
|
|
17
|
+
|
|
18
|
+
RubyCriticSmallBadge.configure do |config|
|
|
19
|
+
config.minimum_score = 90
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
RubyCritic::RakeTask.new do |task|
|
|
23
|
+
task.paths = FileList["lib/**/*.rb"]
|
|
24
|
+
|
|
25
|
+
task.options = %(--custom-format RubyCriticSmallBadge::Report
|
|
26
|
+
--minimum-score #{RubyCriticSmallBadge.config.minimum_score}
|
|
27
|
+
--coverage-path coverage/.resultset.json
|
|
28
|
+
--no-browser)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
desc "Run tests with coverage and then RubyCritic"
|
|
32
|
+
task rubycritic_with_coverage: [:coverage, :rubycritic]
|
|
33
|
+
rescue LoadError
|
|
34
|
+
desc "Run RubyCritic (not available)"
|
|
35
|
+
task :rubycritic do
|
|
36
|
+
puts "RubyCritic is not available"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
desc "Run code coverage"
|
|
41
|
+
task :coverage do
|
|
42
|
+
ENV["COVERAGE"] = "1"
|
|
43
|
+
Rake::Task["test"].invoke
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
namespace :website do
|
|
47
|
+
desc "Build the documentation website"
|
|
48
|
+
task :build do
|
|
49
|
+
Dir.chdir("website") do
|
|
50
|
+
puts "Building documentation website..."
|
|
51
|
+
system "bundle install"
|
|
52
|
+
system "bundle exec jekyll build"
|
|
53
|
+
puts "Website built in website/_site/"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
desc "Serve the documentation website locally"
|
|
58
|
+
task :serve do
|
|
59
|
+
Dir.chdir("website") do
|
|
60
|
+
puts "Starting local documentation server..."
|
|
61
|
+
puts "View the website at http://localhost:4000/"
|
|
62
|
+
system "bundle install"
|
|
63
|
+
system "bundle exec jekyll serve"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
desc "Clean the documentation website build"
|
|
68
|
+
task :clean do
|
|
69
|
+
Dir.chdir("website") do
|
|
70
|
+
puts "Cleaning website build..."
|
|
71
|
+
system "bundle exec jekyll clean"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|