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.
Files changed (100) 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/README.md +379 -75
  10. data/Rakefile +66 -6
  11. data/UPGRADING.md +216 -0
  12. data/badges/coverage_badge_total.svg +35 -0
  13. data/badges/rubycritic_badge_score.svg +35 -0
  14. data/claude-skill/README.md +100 -0
  15. data/claude-skill/SKILL.md +442 -0
  16. data/claude-skill/references/API_REFERENCE.md +462 -0
  17. data/claude-skill/references/COMPOSITION.md +396 -0
  18. data/claude-skill/references/PAGINATION.md +396 -0
  19. data/claude-skill/references/QUERY_TYPES.md +297 -0
  20. data/claude-skill/references/TRANSFORMERS.md +282 -0
  21. data/context/01-core-architecture.md +247 -0
  22. data/context/02-query-types-implementation.md +355 -0
  23. data/context/03-composition-transformation.md +441 -0
  24. data/context/04-pagination-results.md +485 -0
  25. data/context/05-testing-configuration.md +491 -0
  26. data/context/06-advanced-patterns-examples.md +153 -0
  27. data/gemfiles/rails_8.0.gemfile +10 -5
  28. data/gemfiles/rails_8.1.gemfile +20 -0
  29. data/lib/generators/quo/install/USAGE +21 -0
  30. data/lib/generators/quo/install/install_generator.rb +63 -0
  31. data/lib/quo/collection_backed_query.rb +21 -15
  32. data/lib/quo/collection_results.rb +1 -0
  33. data/lib/quo/composed_collection_backed_query.rb +42 -0
  34. data/lib/quo/composed_instance.rb +144 -0
  35. data/lib/quo/composed_query.rb +43 -178
  36. data/lib/quo/composed_relation_backed_query.rb +42 -0
  37. data/lib/quo/composing/base_strategy.rb +22 -0
  38. data/lib/quo/composing/class_strategy.rb +86 -0
  39. data/lib/quo/composing/class_strategy_registry.rb +31 -0
  40. data/lib/quo/composing/query_classes_strategy.rb +38 -0
  41. data/lib/quo/composing.rb +81 -0
  42. data/lib/quo/engine.rb +1 -0
  43. data/lib/quo/minitest/helpers.rb +14 -24
  44. data/lib/quo/preloadable.rb +1 -0
  45. data/lib/quo/query.rb +22 -5
  46. data/lib/quo/relation_backed_query.rb +24 -18
  47. data/lib/quo/relation_backed_query_specification.rb +44 -25
  48. data/lib/quo/relation_results.rb +1 -0
  49. data/lib/quo/results.rb +31 -2
  50. data/lib/quo/rspec/helpers.rb +15 -26
  51. data/lib/quo/testing/collection_backed_fake.rb +1 -0
  52. data/lib/quo/testing/fake_helpers.rb +30 -0
  53. data/lib/quo/testing/relation_backed_fake.rb +1 -0
  54. data/lib/quo/version.rb +1 -1
  55. data/lib/quo/wrapped_collection_backed_query.rb +21 -0
  56. data/lib/quo/wrapped_relation_backed_query.rb +21 -0
  57. data/lib/quo.rb +8 -0
  58. data/quo.png +0 -0
  59. data/sig/generated/quo/collection_backed_query.rbs +10 -4
  60. data/sig/generated/quo/collection_results.rbs +1 -0
  61. data/sig/generated/quo/composed_collection_backed_query.rbs +25 -0
  62. data/sig/generated/quo/composed_instance.rbs +61 -0
  63. data/sig/generated/quo/composed_query.rbs +23 -56
  64. data/sig/generated/quo/composed_relation_backed_query.rbs +25 -0
  65. data/sig/generated/quo/composing/base_strategy.rbs +16 -0
  66. data/sig/generated/quo/composing/class_strategy.rbs +38 -0
  67. data/sig/generated/quo/composing/class_strategy_registry.rbs +16 -0
  68. data/sig/generated/quo/composing/query_classes_strategy.rbs +24 -0
  69. data/sig/generated/quo/composing.rbs +40 -0
  70. data/sig/generated/quo/engine.rbs +1 -0
  71. data/sig/generated/quo/minitest/helpers.rbs +12 -0
  72. data/sig/generated/quo/preloadable.rbs +1 -0
  73. data/sig/generated/quo/query.rbs +15 -4
  74. data/sig/generated/quo/relation_backed_query.rbs +15 -5
  75. data/sig/generated/quo/relation_backed_query_specification.rbs +47 -39
  76. data/sig/generated/quo/relation_results.rbs +1 -0
  77. data/sig/generated/quo/results.rbs +11 -0
  78. data/sig/generated/quo/rspec/helpers.rbs +12 -0
  79. data/sig/generated/quo/testing/collection_backed_fake.rbs +1 -0
  80. data/sig/generated/quo/testing/fake_helpers.rbs +14 -0
  81. data/sig/generated/quo/testing/relation_backed_fake.rbs +1 -0
  82. data/sig/generated/quo/wrapped_collection_backed_query.rbs +13 -0
  83. data/sig/generated/quo/wrapped_relation_backed_query.rbs +13 -0
  84. data/sig/generated/quo.rbs +1 -0
  85. data/website/.gitignore +6 -0
  86. data/website/.nojekyll +0 -0
  87. data/website/404.html +26 -0
  88. data/website/Gemfile +24 -0
  89. data/website/_config.yml +50 -0
  90. data/website/_data/navigation.yml +8 -0
  91. data/website/_data/sidebar.yml +2 -0
  92. data/website/_data/social_links.yml +3 -0
  93. data/website/_docs/api.md +261 -0
  94. data/website/_docs/get-started.md +289 -0
  95. data/website/assets/quo.png +0 -0
  96. data/website/index.md +141 -0
  97. metadata +70 -13
  98. data/gemfiles/rails_7.0.gemfile +0 -15
  99. data/gemfiles/rails_7.1.gemfile +0 -15
  100. data/gemfiles/rails_7.2.gemfile +0 -15
data/README.md CHANGED
@@ -1,17 +1,94 @@
1
- # Quo: Query Objects for ActiveRecord
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
+ ![Coverage](badges/coverage_badge_total.svg)
8
+ ![RubyCritic](badges/rubycritic_badge_score.svg)
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
- * Wrap around an underlying ActiveRecord relation or array-like collection
8
- * Supports pagination for ActiveRecord-based queries and collections that respond to `[]`
9
- * Support composition with the `+` (`compose`) method to merge multiple query objects
10
- * Allow transforming results with the `transform` method
11
- * Offer utility methods that operate on the underlying collection (eg `exists?`)
12
- * Act as a callable with chainable methods like ActiveRecord
13
- * Provide a clear separation between query definition and execution with enumerable `Results` objects
14
- * Type-safe properties with optional default values
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
- # Collection-backed
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, Boolean, default: -> { true }
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
- Combine multiple queries:
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 using:
169
- * `Quo::Query.compose(left, right)`
170
- * `left.compose(right)`
171
- * `left + right`
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
- ### Composing with Joins
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
- class ProductsQuery < Quo::RelationBackedQuery
267
+ # Query for posts
268
+ class PostsQuery < Quo::RelationBackedQuery
177
269
  def query
178
- Product.where(active: true)
270
+ Post.where(published: true)
179
271
  end
180
272
  end
181
273
 
182
- class CategoriesQuery < Quo::RelationBackedQuery
274
+ # Query for authors
275
+ class AuthorsQuery < Quo::RelationBackedQuery
183
276
  def query
184
- Category.where(featured: true)
277
+ Author.where(active: true)
185
278
  end
186
279
  end
187
280
 
188
- # Compose with a join
189
- products = ProductsQuery.new.compose(CategoriesQuery.new, joins: :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
190
285
 
191
- # Equivalent to:
192
- # Product.joins(:category)
193
- # .where(products: { active: true })
194
- # .where(categories: { featured: true })
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
- ## Custom Association Preloading
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 UsersWithOrders < Quo::RelationBackedQuery
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
- User.all
454
+ Product.where(active: true)
215
455
  end
456
+ end
216
457
 
217
- def preload_associations(collection)
218
- # Custom preloading logic
219
- ActiveRecord::Associations::Preloader.new(
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::RSpec::Helpers
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.default_page_size = 25
313
- Quo.max_page_size = 100
314
- Quo.relation_backed_query_base_class = "ApplicationQuery"
315
- Quo.collection_backed_query_base_class = "ApplicationCollectionQuery"
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
- require "rake/testtask"
5
-
6
- Rake::TestTask.new(:test) do |t|
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