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

|
|
8
|
+

|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
data from a query and reuse it.
|
|
10
|
+
Quo helps you organize database and collection queries into reusable, composable, and testable objects.
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
## Quick Example
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
20
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
|
|
21
30
|
|
|
22
|
-
|
|
31
|
+
# Navigate between pages
|
|
32
|
+
page2_query = posts_query.next_page_query
|
|
33
|
+
page2 = page2_query.results
|
|
34
|
+
# => Returns next 10 posts
|
|
23
35
|
|
|
24
|
-
|
|
36
|
+
class CommentNotSpamQuery < Quo::RelationBackedQuery
|
|
37
|
+
prop :spam_score_threshold, _Float(0..1.0)
|
|
25
38
|
|
|
26
|
-
|
|
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
|
|
27
46
|
|
|
28
|
-
|
|
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)
|
|
29
49
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
- or another `Quo::Query` instance.
|
|
50
|
+
# Compose your queries
|
|
51
|
+
query = posts_last_10_days + CommentNotSpamQuery.new(spam_score_threshold: 0.5)
|
|
33
52
|
|
|
34
|
-
|
|
35
|
-
|
|
53
|
+
# Transform results
|
|
54
|
+
transformed_query = query.transform { |post| PostPresenter.new(post) }
|
|
36
55
|
|
|
37
|
-
|
|
38
|
-
|
|
56
|
+
# Work with result sets
|
|
57
|
+
transformed_query.results.each do |presenter|
|
|
58
|
+
puts presenter.formatted_title
|
|
59
|
+
end
|
|
60
|
+
```
|
|
39
61
|
|
|
40
|
-
## Passing options to queries
|
|
41
62
|
|
|
42
|
-
|
|
63
|
+
## Core Features
|
|
43
64
|
|
|
44
|
-
|
|
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
|
|
45
69
|
|
|
46
|
-
|
|
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
|
|
47
74
|
|
|
48
|
-
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
|
53
80
|
|
|
54
|
-
|
|
55
|
-
|
|
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)
|
|
56
85
|
|
|
57
|
-
|
|
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
|
|
58
91
|
|
|
59
|
-
Note that it is possible to configure a query using chainable methods similar to ActiveRecord:
|
|
60
92
|
|
|
61
|
-
|
|
62
|
-
* order
|
|
63
|
-
* group
|
|
64
|
-
* includes
|
|
65
|
-
* left_outer_joins
|
|
66
|
-
* preload
|
|
67
|
-
* joins
|
|
93
|
+
## Core Concepts
|
|
68
94
|
|
|
69
|
-
|
|
95
|
+
Query objects encapsulate query logic in dedicated classes, making complex queries more manageable and reusable.
|
|
70
96
|
|
|
71
|
-
|
|
97
|
+
Quo provides two main components:
|
|
98
|
+
1. **Query Objects** - Define and configure queries
|
|
99
|
+
2. **Results Objects** - Execute queries and provide access to the results
|
|
72
100
|
|
|
73
|
-
|
|
74
|
-
and so under the hood `Quo::Query` uses that when composing relations. However since Queries can also abstract over
|
|
75
|
-
array-like collections (ie enumerable and define a `+` method) compose also handles concating them together.
|
|
101
|
+
## Creating Query Objects
|
|
76
102
|
|
|
77
|
-
|
|
103
|
+
### Relation-Backed Queries
|
|
78
104
|
|
|
79
|
-
|
|
80
|
-
- or `left.compose(right)`
|
|
81
|
-
- or more simply with `left + right`
|
|
105
|
+
For queries based on ActiveRecord relations:
|
|
82
106
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
107
|
+
```ruby
|
|
108
|
+
class RecentActiveUsers < Quo::RelationBackedQuery
|
|
109
|
+
# Define typed properties
|
|
110
|
+
prop :days_ago, Integer, default: -> { 30 }
|
|
111
|
+
|
|
112
|
+
def query
|
|
113
|
+
User
|
|
114
|
+
.where(active: true)
|
|
115
|
+
.where("created_at > ?", days_ago.days.ago)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
87
118
|
|
|
88
|
-
|
|
119
|
+
# Create and use the query
|
|
120
|
+
query = RecentActiveUsers.new(days_ago: 7)
|
|
121
|
+
results = query.results
|
|
89
122
|
|
|
90
|
-
|
|
123
|
+
# Work with results
|
|
124
|
+
results.each { |user| puts user.email }
|
|
125
|
+
puts "Found #{results.count} users"
|
|
126
|
+
```
|
|
91
127
|
|
|
92
|
-
|
|
93
|
-
2. compose two query objects, one of which returns a `ActiveRecord::Relation`, and the other an array-like
|
|
94
|
-
3. compose two query objects which return array-likes
|
|
128
|
+
### Collection-Backed Queries
|
|
95
129
|
|
|
96
|
-
|
|
97
|
-
wrapped around a new 'composed' `ActiveRecords::Relation`.
|
|
130
|
+
For queries based on any Enumerable collection:
|
|
98
131
|
|
|
99
|
-
|
|
100
|
-
|
|
132
|
+
```ruby
|
|
133
|
+
class CachedUsers < Quo::CollectionBackedQuery
|
|
134
|
+
prop :role, String
|
|
135
|
+
|
|
136
|
+
def collection
|
|
137
|
+
@cached_users ||= Rails.cache.fetch("all_users", expires_in: 1.hour) do
|
|
138
|
+
User.all.to_a
|
|
139
|
+
end.select { |user| user.role == role }
|
|
140
|
+
end
|
|
141
|
+
end
|
|
101
142
|
|
|
102
|
-
|
|
143
|
+
# Use the query
|
|
144
|
+
admins = CachedUsers.new(role: "admin").results
|
|
145
|
+
```
|
|
103
146
|
|
|
104
|
-
|
|
147
|
+
## Quick Queries with Wrap and to_collection
|
|
105
148
|
|
|
106
|
-
|
|
107
|
-
query object or and `ActiveRecord::Relation`. However `Quo::Query.compose(left, right)` also accepts
|
|
108
|
-
`ActiveRecord::Relation`s for left.
|
|
149
|
+
### Creating Query Objects with Wrap
|
|
109
150
|
|
|
110
|
-
|
|
151
|
+
Create query objects on the fly without subclassing using the `wrap` class method:
|
|
111
152
|
|
|
112
153
|
```ruby
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
154
|
+
# Relation-backed query from an ActiveRecord relation
|
|
155
|
+
users_query = Quo::RelationBackedQuery.wrap(User.active).new
|
|
156
|
+
active_users = users_query.results
|
|
157
|
+
|
|
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
|
|
165
|
+
items_query = Quo::CollectionBackedQuery.wrap([1, 2, 3]).new
|
|
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 }
|
|
119
171
|
end
|
|
172
|
+
result = filtered_query.new(min: 3).results # [3, 4, 5]
|
|
173
|
+
```
|
|
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
|
+
|
|
194
|
+
## Type-Safe Properties
|
|
195
|
+
|
|
196
|
+
Quo uses the `Literal` gem for typed properties:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
class UsersByState < Quo::RelationBackedQuery
|
|
200
|
+
prop :state, String
|
|
201
|
+
prop :minimum_age, Integer, default: -> { 18 }
|
|
202
|
+
prop :active_only, _Boolean, default: -> { true }
|
|
120
203
|
|
|
121
|
-
class CompanyInUsState < Quo::RelationBackedQuery
|
|
122
204
|
def query
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
205
|
+
scope = User.where(state: state)
|
|
206
|
+
scope = scope.where("age >= ?", minimum_age) if minimum_age.present?
|
|
207
|
+
scope = scope.where(active: true) if active_only
|
|
208
|
+
scope
|
|
126
209
|
end
|
|
127
210
|
end
|
|
128
211
|
|
|
129
|
-
|
|
130
|
-
query2 = CompanyInUsState.new(state: "California")
|
|
131
|
-
|
|
132
|
-
# Compose
|
|
133
|
-
composed = query1 + query2 # or Quo::Query.compose(query1, query2) or query1.compose(query2)
|
|
134
|
-
composed.first
|
|
212
|
+
query = UsersByState.new(state: "California", minimum_age: 21)
|
|
135
213
|
```
|
|
136
214
|
|
|
137
|
-
|
|
215
|
+
## Pagination
|
|
138
216
|
|
|
139
217
|
```ruby
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
218
|
+
query = UsersByState.new(
|
|
219
|
+
state: "California",
|
|
220
|
+
page: 2,
|
|
221
|
+
page_size: 20
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# Get paginated results for page 2 with 20 items
|
|
225
|
+
users = query.results
|
|
226
|
+
|
|
227
|
+
# Navigation to next and previous pages creates new queries
|
|
228
|
+
next_page = query.next_page_query
|
|
229
|
+
prev_page = query.previous_page_query
|
|
145
230
|
```
|
|
146
231
|
|
|
147
|
-
|
|
148
|
-
|
|
232
|
+
## Composing Queries
|
|
233
|
+
|
|
234
|
+
Quo provides extensive query composition capabilities, letting you combine multiple query objects:
|
|
149
235
|
|
|
150
236
|
```ruby
|
|
151
|
-
class
|
|
237
|
+
class ActiveUsers < Quo::RelationBackedQuery
|
|
238
|
+
def query
|
|
239
|
+
User.where(active: true)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
class PremiumUsers < Quo::RelationBackedQuery
|
|
152
244
|
def query
|
|
153
|
-
|
|
154
|
-
approved = CompanyToBeApproved.new
|
|
155
|
-
# Here we use `.compose` utility method to wrap our Relation in a Query and
|
|
156
|
-
# then compose with the other Query
|
|
157
|
-
Quo::Query.compose(done, approved)
|
|
245
|
+
User.where(subscription_tier: "premium")
|
|
158
246
|
end
|
|
159
247
|
end
|
|
160
248
|
|
|
161
|
-
#
|
|
162
|
-
|
|
249
|
+
# Compose queries using the + operator
|
|
250
|
+
active_premium = ActiveUsers.new + PremiumUsers.new
|
|
251
|
+
users = active_premium.results
|
|
163
252
|
```
|
|
164
253
|
|
|
165
|
-
|
|
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)`
|
|
258
|
+
|
|
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:
|
|
166
265
|
|
|
167
266
|
```ruby
|
|
168
|
-
|
|
267
|
+
# Query for posts
|
|
268
|
+
class PostsQuery < Quo::RelationBackedQuery
|
|
169
269
|
def query
|
|
170
|
-
|
|
270
|
+
Post.where(published: true)
|
|
171
271
|
end
|
|
172
272
|
end
|
|
173
273
|
|
|
174
|
-
|
|
274
|
+
# Query for authors
|
|
275
|
+
class AuthorsQuery < Quo::RelationBackedQuery
|
|
175
276
|
def query
|
|
176
|
-
|
|
277
|
+
Author.where(active: true)
|
|
177
278
|
end
|
|
178
279
|
end
|
|
179
280
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
183
285
|
|
|
184
|
-
#
|
|
286
|
+
# Returns published posts by active authors
|
|
287
|
+
results = composed_query.results
|
|
185
288
|
```
|
|
186
289
|
|
|
187
|
-
Collection backed queries can also be composed (see below sections for more details).
|
|
188
290
|
|
|
189
|
-
|
|
291
|
+
## Utility Methods
|
|
190
292
|
|
|
191
|
-
|
|
192
|
-
composed. These are then used to create a more useful output from `to_s`, so that it is easier to understand what the
|
|
193
|
-
merged query is actually made up of:
|
|
293
|
+
Quo query objects provide several utility methods to help you work with them:
|
|
194
294
|
|
|
195
295
|
```ruby
|
|
196
|
-
|
|
197
|
-
puts q
|
|
198
|
-
# > "Quo::ComposedQuery[FooQuery, BarQuery]"
|
|
199
|
-
```
|
|
296
|
+
query = UsersByState.new(state: "California")
|
|
200
297
|
|
|
201
|
-
|
|
298
|
+
# Check query type
|
|
299
|
+
query.relation? # => true if backed by an ActiveRecord relation
|
|
300
|
+
query.collection? # => true if backed by a collection
|
|
202
301
|
|
|
203
|
-
|
|
302
|
+
# Check pagination status
|
|
303
|
+
query.paged? # => true if pagination is enabled (page is set)
|
|
204
304
|
|
|
205
|
-
|
|
206
|
-
|
|
305
|
+
# Check transformation status
|
|
306
|
+
query.transform? # => true if a transformer is set
|
|
207
307
|
|
|
208
|
-
|
|
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'"
|
|
316
|
+
```
|
|
209
317
|
|
|
210
|
-
|
|
211
|
-
by a collection (ie an enumerable such as an Array). This is useful for encapsulating data that doesn't come from an
|
|
212
|
-
ActiveRecord query or queries that execute immediately. Subclass this and override `collection` to return the data you
|
|
213
|
-
want to encapsulate.
|
|
318
|
+
## Transforming Results
|
|
214
319
|
|
|
215
320
|
```ruby
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
q = MyCollectionBackedQuery.new
|
|
222
|
-
q.collection? # is it a collection under the hood? Yes it is!
|
|
223
|
-
q.count # '3'
|
|
321
|
+
query = UsersByState.new(state: "California")
|
|
322
|
+
.transform { |user| UserPresenter.new(user) }
|
|
323
|
+
|
|
324
|
+
# Results are automatically transformed
|
|
325
|
+
presenters = query.results.to_a # Array of UserPresenter objects
|
|
224
326
|
```
|
|
225
327
|
|
|
226
|
-
|
|
227
|
-
|
|
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.
|
|
228
331
|
|
|
229
332
|
```ruby
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
233
369
|
```
|
|
234
370
|
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
237
376
|
|
|
238
|
-
|
|
377
|
+
Quo implements a fluent API that mirrors ActiveRecord's query interface, allowing you to chain methods that build up your query:
|
|
239
378
|
|
|
240
379
|
```ruby
|
|
241
|
-
|
|
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")
|
|
242
398
|
```
|
|
243
399
|
|
|
244
|
-
|
|
245
|
-
|
|
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.
|
|
246
418
|
|
|
247
|
-
|
|
419
|
+
## Association Preloading in Collection-Backed Queries
|
|
248
420
|
|
|
249
|
-
|
|
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:
|
|
250
424
|
|
|
251
425
|
```ruby
|
|
252
|
-
class
|
|
253
|
-
|
|
254
|
-
|
|
426
|
+
class FirstAndLastUsers < Quo::CollectionBackedQuery
|
|
427
|
+
include Quo::Preloadable
|
|
428
|
+
|
|
429
|
+
def collection
|
|
430
|
+
[User.first, User.last] # These users come from separate queries
|
|
255
431
|
end
|
|
256
432
|
end
|
|
257
433
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
# => 2
|
|
261
|
-
composed.first
|
|
262
|
-
# => #<Tag id: ...>
|
|
434
|
+
# Preload the profiles and posts for both users in a single efficient query
|
|
435
|
+
query = FirstAndLastUsers.new.includes(:profile, :posts)
|
|
263
436
|
|
|
264
|
-
|
|
265
|
-
# =>
|
|
266
|
-
|
|
267
|
-
# => 4
|
|
268
|
-
```
|
|
437
|
+
# Check that the association is loaded
|
|
438
|
+
query.results.first.profile.loaded? # => true
|
|
439
|
+
query.results.last.posts.loaded? # => true
|
|
269
440
|
|
|
270
|
-
|
|
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
|
+
```
|
|
271
446
|
|
|
272
|
-
|
|
273
|
-
`last` and `each`.
|
|
447
|
+
The `Preloadable` module overrides the `query` method to apply ActiveRecord's preloader to your collection.
|
|
274
448
|
|
|
275
|
-
|
|
449
|
+
### Composing with Joins
|
|
276
450
|
|
|
277
451
|
```ruby
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
452
|
+
class ProductsQuery < Quo::RelationBackedQuery
|
|
453
|
+
def query
|
|
454
|
+
Product.where(active: true)
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
class CategoriesQuery < Quo::RelationBackedQuery
|
|
459
|
+
def query
|
|
460
|
+
Category.where(featured: true)
|
|
461
|
+
end
|
|
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 })
|
|
285
471
|
```
|
|
286
472
|
|
|
287
|
-
##
|
|
473
|
+
## Testing Helpers
|
|
288
474
|
|
|
289
|
-
|
|
290
|
-
maybe desirable.
|
|
475
|
+
Quo provides testing helpers for both Minitest and RSpec to make your query objects easy to test in isolation.
|
|
291
476
|
|
|
292
|
-
|
|
477
|
+
### Minitest
|
|
293
478
|
|
|
294
|
-
|
|
295
|
-
The `with` option is passed to the Query object on initialisation and used when setting up the method stub on the
|
|
296
|
-
query class.
|
|
479
|
+
The `Quo::Minitest::Helpers` module includes the `fake_query` method that lets you mock query results without hitting the database:
|
|
297
480
|
|
|
298
|
-
|
|
481
|
+
```ruby
|
|
482
|
+
class UserQueryTest < ActiveSupport::TestCase
|
|
483
|
+
include Quo::Minitest::Helpers
|
|
484
|
+
|
|
485
|
+
test "filters users by state" do
|
|
486
|
+
# Create test data
|
|
487
|
+
users = [User.new(name: "Alice"), User.new(name: "Bob")]
|
|
488
|
+
|
|
489
|
+
# Mock the query results within the block
|
|
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
|
|
493
|
+
result = UsersByState.new(state: "California").results.to_a
|
|
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
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
### RSpec
|
|
518
|
+
|
|
519
|
+
The same functionality is available for RSpec through the `Quo::Rspec::Helpers` module:
|
|
299
520
|
|
|
300
521
|
```ruby
|
|
301
|
-
|
|
302
|
-
|
|
522
|
+
RSpec.describe UsersByState do
|
|
523
|
+
include Quo::Rspec::Helpers
|
|
524
|
+
|
|
525
|
+
it "filters users by state" do
|
|
526
|
+
users = [User.new(name: "Alice"), User.new(name: "Bob")]
|
|
527
|
+
|
|
528
|
+
fake_query(UsersByState, results: users) do
|
|
529
|
+
result = UsersByState.new(state: "California").results.to_a
|
|
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
|
|
551
|
+
end
|
|
552
|
+
end
|
|
553
|
+
end
|
|
303
554
|
```
|
|
304
555
|
|
|
305
|
-
|
|
556
|
+
## Project Organization
|
|
306
557
|
|
|
307
|
-
|
|
308
|
-
important or where you are doing a composition of queries backed by relations!
|
|
558
|
+
Suggested directory structure:
|
|
309
559
|
|
|
310
|
-
|
|
311
|
-
|
|
560
|
+
```
|
|
561
|
+
app/
|
|
562
|
+
queries/
|
|
563
|
+
application_query.rb
|
|
564
|
+
users/
|
|
565
|
+
active_users_query.rb
|
|
566
|
+
by_state_query.rb
|
|
567
|
+
products/
|
|
568
|
+
featured_products_query.rb
|
|
569
|
+
```
|
|
312
570
|
|
|
313
|
-
|
|
571
|
+
Base classes:
|
|
314
572
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
573
|
+
```ruby
|
|
574
|
+
# app/queries/application_query.rb
|
|
575
|
+
class ApplicationQuery < Quo::RelationBackedQuery
|
|
576
|
+
# Common functionality
|
|
577
|
+
end
|
|
318
578
|
|
|
579
|
+
# app/queries/application_collection_query.rb
|
|
580
|
+
class ApplicationCollectionQuery < Quo::CollectionBackedQuery
|
|
581
|
+
# Common functionality
|
|
582
|
+
end
|
|
583
|
+
```
|
|
319
584
|
|
|
320
585
|
## Installation
|
|
321
586
|
|
|
322
|
-
|
|
587
|
+
Add to your Gemfile:
|
|
588
|
+
|
|
589
|
+
```ruby
|
|
590
|
+
gem "quo"
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
Then execute:
|
|
594
|
+
|
|
595
|
+
```
|
|
596
|
+
$ bundle install
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
## Configuration
|
|
323
600
|
|
|
324
|
-
|
|
601
|
+
Quo provides several configuration options to customize its behavior to your needs. Configure these in an initializer:
|
|
325
602
|
|
|
326
|
-
|
|
603
|
+
```ruby
|
|
604
|
+
# config/initializers/quo.rb
|
|
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
|
|
618
|
+
```
|
|
327
619
|
|
|
328
|
-
|
|
620
|
+
Using custom base classes lets you add functionality that's shared across all your query objects in your application.
|
|
329
621
|
|
|
330
|
-
##
|
|
622
|
+
## Requirements
|
|
331
623
|
|
|
332
|
-
|
|
624
|
+
- Ruby 3.1+
|
|
625
|
+
- Rails 7.0+, 8.0+
|
|
333
626
|
|
|
334
627
|
## Development
|
|
335
628
|
|
|
@@ -343,7 +636,7 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/steveg
|
|
|
343
636
|
|
|
344
637
|
## Inspired by `rectify`
|
|
345
638
|
|
|
346
|
-
|
|
639
|
+
This implementation is inspired by the `Rectify` gem: https://github.com/andypike/rectify. Thanks to Andy Pike for the inspiration.
|
|
347
640
|
|
|
348
641
|
## License
|
|
349
642
|
|