quo 1.0.0.beta2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/.devcontainer/Dockerfile +17 -0
  3. data/.devcontainer/compose.yml +10 -0
  4. data/.devcontainer/devcontainer.json +12 -0
  5. data/Appraisals +4 -12
  6. data/CHANGELOG.md +112 -1
  7. data/CLAUDE.md +19 -0
  8. data/Gemfile +7 -1
  9. data/LICENSE.txt +1 -1
  10. data/README.md +496 -203
  11. data/Rakefile +66 -6
  12. data/UPGRADING.md +216 -0
  13. data/badges/coverage_badge_total.svg +35 -0
  14. data/badges/rubycritic_badge_score.svg +35 -0
  15. data/claude-skill/README.md +100 -0
  16. data/claude-skill/SKILL.md +442 -0
  17. data/claude-skill/references/API_REFERENCE.md +462 -0
  18. data/claude-skill/references/COMPOSITION.md +396 -0
  19. data/claude-skill/references/PAGINATION.md +396 -0
  20. data/claude-skill/references/QUERY_TYPES.md +297 -0
  21. data/claude-skill/references/TRANSFORMERS.md +282 -0
  22. data/context/01-core-architecture.md +247 -0
  23. data/context/02-query-types-implementation.md +355 -0
  24. data/context/03-composition-transformation.md +441 -0
  25. data/context/04-pagination-results.md +485 -0
  26. data/context/05-testing-configuration.md +491 -0
  27. data/context/06-advanced-patterns-examples.md +153 -0
  28. data/gemfiles/rails_8.0.gemfile +10 -5
  29. data/gemfiles/rails_8.1.gemfile +20 -0
  30. data/lib/generators/quo/install/USAGE +21 -0
  31. data/lib/generators/quo/install/install_generator.rb +63 -0
  32. data/lib/quo/collection_backed_query.rb +21 -15
  33. data/lib/quo/collection_results.rb +1 -0
  34. data/lib/quo/composed_collection_backed_query.rb +42 -0
  35. data/lib/quo/composed_instance.rb +144 -0
  36. data/lib/quo/composed_query.rb +43 -178
  37. data/lib/quo/composed_relation_backed_query.rb +42 -0
  38. data/lib/quo/composing/base_strategy.rb +22 -0
  39. data/lib/quo/composing/class_strategy.rb +86 -0
  40. data/lib/quo/composing/class_strategy_registry.rb +31 -0
  41. data/lib/quo/composing/query_classes_strategy.rb +38 -0
  42. data/lib/quo/composing.rb +81 -0
  43. data/lib/quo/engine.rb +1 -0
  44. data/lib/quo/minitest/helpers.rb +14 -24
  45. data/lib/quo/preloadable.rb +1 -0
  46. data/lib/quo/query.rb +22 -5
  47. data/lib/quo/relation_backed_query.rb +24 -18
  48. data/lib/quo/relation_backed_query_specification.rb +44 -25
  49. data/lib/quo/relation_results.rb +1 -0
  50. data/lib/quo/results.rb +31 -2
  51. data/lib/quo/rspec/helpers.rb +15 -26
  52. data/lib/quo/testing/collection_backed_fake.rb +1 -0
  53. data/lib/quo/testing/fake_helpers.rb +30 -0
  54. data/lib/quo/testing/relation_backed_fake.rb +1 -0
  55. data/lib/quo/version.rb +1 -1
  56. data/lib/quo/wrapped_collection_backed_query.rb +21 -0
  57. data/lib/quo/wrapped_relation_backed_query.rb +21 -0
  58. data/lib/quo.rb +8 -0
  59. data/quo.png +0 -0
  60. data/sig/generated/quo/collection_backed_query.rbs +10 -4
  61. data/sig/generated/quo/collection_results.rbs +1 -0
  62. data/sig/generated/quo/composed_collection_backed_query.rbs +25 -0
  63. data/sig/generated/quo/composed_instance.rbs +61 -0
  64. data/sig/generated/quo/composed_query.rbs +23 -56
  65. data/sig/generated/quo/composed_relation_backed_query.rbs +25 -0
  66. data/sig/generated/quo/composing/base_strategy.rbs +16 -0
  67. data/sig/generated/quo/composing/class_strategy.rbs +38 -0
  68. data/sig/generated/quo/composing/class_strategy_registry.rbs +16 -0
  69. data/sig/generated/quo/composing/query_classes_strategy.rbs +24 -0
  70. data/sig/generated/quo/composing.rbs +40 -0
  71. data/sig/generated/quo/engine.rbs +1 -0
  72. data/sig/generated/quo/minitest/helpers.rbs +12 -0
  73. data/sig/generated/quo/preloadable.rbs +1 -0
  74. data/sig/generated/quo/query.rbs +15 -4
  75. data/sig/generated/quo/relation_backed_query.rbs +15 -5
  76. data/sig/generated/quo/relation_backed_query_specification.rbs +47 -39
  77. data/sig/generated/quo/relation_results.rbs +1 -0
  78. data/sig/generated/quo/results.rbs +11 -0
  79. data/sig/generated/quo/rspec/helpers.rbs +12 -0
  80. data/sig/generated/quo/testing/collection_backed_fake.rbs +1 -0
  81. data/sig/generated/quo/testing/fake_helpers.rbs +14 -0
  82. data/sig/generated/quo/testing/relation_backed_fake.rbs +1 -0
  83. data/sig/generated/quo/wrapped_collection_backed_query.rbs +13 -0
  84. data/sig/generated/quo/wrapped_relation_backed_query.rbs +13 -0
  85. data/sig/generated/quo.rbs +1 -0
  86. data/website/.gitignore +6 -0
  87. data/website/.nojekyll +0 -0
  88. data/website/404.html +26 -0
  89. data/website/Gemfile +24 -0
  90. data/website/_config.yml +50 -0
  91. data/website/_data/navigation.yml +8 -0
  92. data/website/_data/sidebar.yml +2 -0
  93. data/website/_data/social_links.yml +3 -0
  94. data/website/_docs/api.md +261 -0
  95. data/website/_docs/get-started.md +289 -0
  96. data/website/assets/quo.png +0 -0
  97. data/website/index.md +141 -0
  98. metadata +70 -13
  99. data/gemfiles/rails_7.0.gemfile +0 -15
  100. data/gemfiles/rails_7.1.gemfile +0 -15
  101. data/gemfiles/rails_7.2.gemfile +0 -15
data/README.md CHANGED
@@ -1,335 +1,628 @@
1
- # 'Quo' query objects for ActiveRecord
1
+ <p align="center">
2
+ <img src="quo.png" alt="Quo" width="160" height="160" />
3
+ </p>
2
4
 
3
- > Note: these docs are for pre-V1 and need updating. I'm working on it!
5
+ # Quo: Query Objects for ActiveRecord & Collections
4
6
 
5
- Quo query objects can help you abstract ActiveRecord DB queries into reusable and composable objects with a chainable
6
- interface.
7
+ ![Coverage](badges/coverage_badge_total.svg)
8
+ ![RubyCritic](badges/rubycritic_badge_score.svg)
7
9
 
8
- The query object can also abstract over any array-like collection meaning that it is possible for example to cache the
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
- The core implementation provides the following functionality:
12
+ ## Quick Example
12
13
 
13
- * wrap around an underlying ActiveRecord or array-like collection
14
- * optionally provides paging behaviour to ActiveRecord based queries
15
- * provides a number of utility methods that operate on the underlying collection (eg `exists?`)
16
- * provides a `+` (`compose`) method which merges two query object instances (see section below for details!)
17
- * can specify a mapping or transform method to `transform` to perform on results
18
- * acts as a callable which executes the underlying query with `.first`
19
- * can return an `Enumerable` of results
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
- `Quo::Query` subclasses are the builders, they retain configuration of the queries, and prepare the underlying query or data collections. Query objects then return `Quo::Results` which take the built queries and then take action on them, such as to fetch data or to count records.
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
- ## Creating a Quo query object
36
+ class CommentNotSpamQuery < Quo::RelationBackedQuery
37
+ prop :spam_score_threshold, _Float(0..1.0)
25
38
 
26
- The query object must inherit from `Quo::Query` and provide an implementation for the `query` method.
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
- The `query` method must return either:
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
- - an `ActiveRecord::Relation`
31
- - an Enumerable (like a 'collection backed' query)
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
- Remember that the query object should be useful in composition with other query objects. Thus it should not directly
35
- specify things that are not directly needed to fetch the right data for the given context.
53
+ # Transform results
54
+ transformed_query = query.transform { |post| PostPresenter.new(post) }
36
55
 
37
- For example the ordering of the results is mostly something that is specified when the query object is used, not as
38
- part of the query itself (as then it would always enforce the ordering on other queries it was composed with).
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
- If any parameters are need in `query`, these are provided when instantiating the query object using the `options` hash.
63
+ ## Core Features
43
64
 
44
- It is also possible to pass special configuration options to the constructor options hash.
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
- Specifically when the underlying collection is a ActiveRecord relation then:
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
- * `order`: the `order` condition for the relation (eg `:desc`)
49
- * `includes`: the `includes` condition for the relation (eg `account: {user: :profile}`)
50
- * `group`: the `group` condition for the relation
51
- * `page`: the current page number to fetch
52
- * `page_size`: the number of elements to fetch in the page
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
- Note that the above options have no bearing on the query if it is backed by an array-like collection and that some
55
- options can be configured using the following methods.
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
- ## Configuring queries
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
- * limit
62
- * order
63
- * group
64
- * includes
65
- * left_outer_joins
66
- * preload
67
- * joins
93
+ ## Core Concepts
68
94
 
69
- Note that these return a new Quo Query and do not mutate the original instance.
95
+ Query objects encapsulate query logic in dedicated classes, making complex queries more manageable and reusable.
70
96
 
71
- ## Composition of queries (merging or combining them)
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
- Quo query objects are composeability. In `ActiveRecord::Relation` this is acheived using `merge`
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
- Composing can be done with either
103
+ ### Relation-Backed Queries
78
104
 
79
- - `Quo::Query.compose(left, right)`
80
- - or `left.compose(right)`
81
- - or more simply with `left + right`
105
+ For queries based on ActiveRecord relations:
82
106
 
83
- The composition methods also accept an optional parameter to pass to ActiveRecord relation merges for the `joins`.
84
- This allows you to compose together Query objects which return relations which are of different models but still merge
85
- them correctly with the appropriate joins. Note with the alias you cant neatly specify optional parameters for joins
86
- on relations.
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
- Note that the compose process creates a new query object instance, which is a instance of a `Quo::ComposedQuery`.
119
+ # Create and use the query
120
+ query = RecentActiveUsers.new(days_ago: 7)
121
+ results = query.results
89
122
 
90
- Consider the following cases:
123
+ # Work with results
124
+ results.each { |user| puts user.email }
125
+ puts "Found #{results.count} users"
126
+ ```
91
127
 
92
- 1. compose two query objects which return `ActiveRecord::Relation`s
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
- In case (1) the compose process uses `ActiveRecords::Relation`'s `merge` method to create another query object
97
- wrapped around a new 'composed' `ActiveRecords::Relation`.
130
+ For queries based on any Enumerable collection:
98
131
 
99
- In case (2) the query object with a `ActiveRecords::Relation` inside is executed, and the result is then concatenated
100
- to the array-like with `+`
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
- In case (3) the values contained are concatenated with `+`
143
+ # Use the query
144
+ admins = CachedUsers.new(role: "admin").results
145
+ ```
103
146
 
104
- *Note that*
147
+ ## Quick Queries with Wrap and to_collection
105
148
 
106
- with `left.compose(right)`, `left` must obviously be an instance of a `Quo::Query`, and `right` can be either a
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
- ### Examples
151
+ Create query objects on the fly without subclassing using the `wrap` class method:
111
152
 
112
153
  ```ruby
113
- class CompanyToBeApproved < Quo::RelationBackedQuery
114
- def query
115
- Registration
116
- .left_joins(:approval)
117
- .where(approvals: {completed_at: nil})
118
- end
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
- Registration
124
- .joins(company: :address)
125
- .where(addresses: {state: options[:state]})
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
- query1 = CompanyToBeApproved.new
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
- This effectively executes:
215
+ ## Pagination
138
216
 
139
217
  ```ruby
140
- Registration
141
- .left_joins(:approval)
142
- .joins(company: :address)
143
- .where(approvals: {completed_at: nil})
144
- .where(addresses: {state: options[:state]})
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
- It is also possible to compose with an `ActiveRecord::Relation`. This can be useful in a Query object itself to help
148
- build up the `query` relation. For example:
232
+ ## Composing Queries
233
+
234
+ Quo provides extensive query composition capabilities, letting you combine multiple query objects:
149
235
 
150
236
  ```ruby
151
- class RegistrationToBeApproved < Quo::RelationBackedQuery
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
- done = Registration.where(step: "complete")
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
- # A Relation can be composed directly to a Quo::Query
162
- query = RegistrationToBeApproved.new + Registration.where(blocked: false)
249
+ # Compose queries using the + operator
250
+ active_premium = ActiveUsers.new + PremiumUsers.new
251
+ users = active_premium.results
163
252
  ```
164
253
 
165
- Also you can use joins:
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
- class TagByName < Quo::RelationBackedQuery
267
+ # Query for posts
268
+ class PostsQuery < Quo::RelationBackedQuery
169
269
  def query
170
- Tag.where(name: options[:name])
270
+ Post.where(published: true)
171
271
  end
172
272
  end
173
273
 
174
- class CategoryByName < Quo::RelationBackedQuery
274
+ # Query for authors
275
+ class AuthorsQuery < Quo::RelationBackedQuery
175
276
  def query
176
- Category.where(name: options[:name])
277
+ Author.where(active: true)
177
278
  end
178
279
  end
179
280
 
180
- tags = TagByName.new(name: "Intel")
181
- for_category = CategoryByName.new(name: "CPUs")
182
- tags.compose(for_category, :category) # perform join on tag association `category`
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
- # equivalent to Tag.joins(:category).where(name: "Intel").where(categories: {name: "CPUs"})
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
- ### Quo::ComposedQuery
291
+ ## Utility Methods
190
292
 
191
- The new instance of `Quo::ComposedQuery` from a compose process, retains references to the original entities that were
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
- q = FooQuery.new + BarQuery.new
197
- puts q
198
- # > "Quo::ComposedQuery[FooQuery, BarQuery]"
199
- ```
296
+ query = UsersByState.new(state: "California")
200
297
 
201
- ## Query Objects & Pagination
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
- Specify extra options to enable pagination:
302
+ # Check pagination status
303
+ query.paged? # => true if pagination is enabled (page is set)
204
304
 
205
- * `page`: the current page number to fetch
206
- * `page_size`: the number of elements to fetch in the page
305
+ # Check transformation status
306
+ query.transform? # => true if a transformer is set
207
307
 
208
- ### `Quo::CollectionBackedQuery` & `Quo::CollectionBackedQuery` objects
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
- `Quo::CollectionBackedQuery` is a subclass of `Quo::Query` which can be used to create query objects which are backed
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
- class MyCollectionBackedQuery < Quo::CollectionBackedQuery
217
- def collection
218
- [1, 2, 3]
219
- end
220
- end
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
- Sometimes it is useful to create similar Queries without needing to create a explicit subclass of your own. For this
227
- use `Quo::CollectionBackedQuery`:
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
- q = Quo::CollectionBackedQuery.wrap([1, 2, 3])
231
- q.collection? # true
232
- q.count # '3'
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
- `Quo::CollectionBackedQuery` also uses `total_count` option value as the specified 'total count', useful when the data is
236
- actually just a page of the data and not the total count.
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
- Example of an CollectionBackedQuery used to wrap a page of enumerable data:
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
- Quo::CollectionBackedQuery.wrap(my_data, total_count: 100, page: current_page)
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
- If a loaded query is `compose`d with other Query objects then it will be seen as an array-like, and concatenated to whatever
245
- results are returned from the other queries. An loaded or eager query will force all other queries to be eager loaded.
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
- ### Composition
419
+ ## Association Preloading in Collection-Backed Queries
248
420
 
249
- Examples of composition of eager loaded queries
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 CachedTags < Quo::RelationBackedQuery
253
- def query
254
- @tags ||= Tag.where(active: true).to_a
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
- composed = CachedTags.new(active: false) + [1, 2]
259
- composed.last
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
- Quo::CollectionBackedQuery.new([3, 4]).compose(Quo::CollectionBackedQuery.new([1, 2])).last
265
- # => 2
266
- Quo::Query.compose([1, 2], [3, 4]).last
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
- ## Transforming results
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
- Sometimes you want to specify a block to execute on each result for any method that returns results, such as `first`,
273
- `last` and `each`.
447
+ The `Preloadable` module overrides the `query` method to apply ActiveRecord's preloader to your collection.
274
448
 
275
- This can be specified using the `transform(&block)` instance method. For example:
449
+ ### Composing with Joins
276
450
 
277
451
  ```ruby
278
- TagsQuery.new(
279
- active: [true, false],
280
- page: 1,
281
- page_size: 30,
282
- ).transform { |tag| TagPresenter.new(tag) }
283
- .first
284
- # => #<TagPresenter ...>
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
- ## Tests & stubbing
473
+ ## Testing Helpers
288
474
 
289
- Tests for Query objects themselves should exercise the actual underlying query. But in other code stubbing the query
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
- The spec helper method `stub_query(query_class, {results: ..., with: ...})` can do this for you.
477
+ ### Minitest
293
478
 
294
- It stubs `.new` on the Query object and returns instances of `CollectionBackedQuery` instead with the given `results`.
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
- For example:
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
- stub_query(TagQuery, with: {name: "Something"}, results: [t1, t2])
302
- expect(TagQuery.new(name: "Something").first).to eql t1
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
- *Note that*
556
+ ## Project Organization
306
557
 
307
- This returns an instance of CollectionBackedQuery, so will not work for cases were the actual type of the query instance is
308
- important or where you are doing a composition of queries backed by relations!
558
+ Suggested directory structure:
309
559
 
310
- If `compose` will be used then `Quo::Query.compose` needs to be stubbed. Something might be possible to make this
311
- nicer in future.
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
- ## Other reading
571
+ Base classes:
314
572
 
315
- See:
316
- * [Includes vs preload vs eager_load](http://blog.scoutapp.com/articles/2017/01/24/activerecord-includes-vs-joins-vs-preload-vs-eager_load-when-and-where)
317
- * [Objects on Rails](http://objectsonrails.com/#sec-14)
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
- Install the gem and add to the application's Gemfile by executing:
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
- $ bundle add quo
601
+ Quo provides several configuration options to customize its behavior to your needs. Configure these in an initializer:
325
602
 
326
- If bundler is not being used to manage dependencies, install the gem by executing:
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
- $ gem install quo
620
+ Using custom base classes lets you add functionality that's shared across all your query objects in your application.
329
621
 
330
- ## Usage
622
+ ## Requirements
331
623
 
332
- TODO: Write usage instructions here
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
- Note this implementation is inspired by the `Rectify` gem; https://github.com/andypike/rectify. Thanks to Andy Pike for the inspiration.
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