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
@@ -0,0 +1,441 @@
1
+ # Query Composition and Transformation
2
+
3
+ This document explains Quo's powerful composition system and transformation capabilities.
4
+
5
+ ## Composition Overview
6
+
7
+ Quo implements a sophisticated composition system that allows queries to be combined using the `+` operator or `compose` method. The composition strategy is automatically selected based on the types being composed.
8
+
9
+ ## Composition Architecture
10
+
11
+ ### Strategy Pattern Implementation
12
+
13
+ ```ruby
14
+ module Quo
15
+ module Composing
16
+ # Main entry points
17
+ def self.composer(chosen_superclass, left_query_class, right_query_class, joins: nil, left_spec: nil, right_spec: nil)
18
+ registry = ClassStrategyRegistry.new
19
+ strategy = registry.find_strategy(left_query_class, right_query_class)
20
+ strategy.compose(chosen_superclass, left_query_class, right_query_class, joins: joins, left_spec: left_spec, right_spec: right_spec)
21
+ end
22
+
23
+ def self.merge_instances(left_instance, right_instance, joins: nil)
24
+ registry = InstanceStrategyRegistry.new
25
+ strategy = registry.find_strategy(left_instance, right_instance)
26
+ strategy.compose(left_instance, right_instance, joins: joins)
27
+ end
28
+ end
29
+ end
30
+ ```
31
+
32
+ ### Composition Strategies
33
+
34
+ Quo implements different strategies based on what's being composed:
35
+
36
+ 1. **RelationAndRelationStrategy** - Composing two relation-backed queries
37
+ 2. **RelationAndQueryStrategy** - Composing a relation with any query
38
+ 3. **QueryAndRelationStrategy** - Composing any query with a relation
39
+ 4. **QueryAndQueryStrategy** - Composing two arbitrary queries
40
+ 5. **QueryClassesStrategy** - Composing query classes (not instances)
41
+
42
+ ## Class-Level Composition
43
+
44
+ ### Basic Class Composition
45
+
46
+ ```ruby
47
+ # Define base queries
48
+ class ActiveUsersQuery < Quo::RelationBackedQuery
49
+ def query
50
+ User.where(active: true)
51
+ end
52
+ end
53
+
54
+ class PremiumUsersQuery < Quo::RelationBackedQuery
55
+ def query
56
+ User.where(subscription: "premium")
57
+ end
58
+ end
59
+
60
+ # Compose at class level
61
+ ActivePremiumQuery = ActiveUsersQuery + PremiumUsersQuery
62
+
63
+ # Or with explicit method
64
+ ActivePremiumQuery = ActiveUsersQuery.compose(PremiumUsersQuery)
65
+
66
+ # Use the composed class
67
+ query = ActivePremiumQuery.new(page: 1)
68
+ results = query.results # Active AND premium users
69
+ ```
70
+
71
+ ### Composed Query Implementation
72
+
73
+ When queries are composed, Quo creates a new `ComposedQuery` class:
74
+
75
+ ```ruby
76
+ module Quo
77
+ class ComposedQuery < Query
78
+ class << self
79
+ attr_accessor :_left_class, :_right_class, :_joins
80
+ end
81
+
82
+ # Properties from both queries are merged
83
+ # Left query properties take precedence in conflicts
84
+
85
+ def query
86
+ # Combines queries based on their types
87
+ merge_instances(left_query, right_query)
88
+ end
89
+ end
90
+ end
91
+ ```
92
+
93
+ ### Property Inheritance
94
+
95
+ ```ruby
96
+ class FilteredUsersQuery < Quo::RelationBackedQuery
97
+ prop :min_age, Integer
98
+ prop :role, String
99
+
100
+ def query
101
+ User.where("age >= ?", min_age).where(role: role)
102
+ end
103
+ end
104
+
105
+ class LocationUsersQuery < Quo::RelationBackedQuery
106
+ prop :city, String
107
+ prop :state, String
108
+
109
+ def query
110
+ User.where(city: city, state: state)
111
+ end
112
+ end
113
+
114
+ # Composed query has all properties
115
+ ComposedQuery = FilteredUsersQuery + LocationUsersQuery
116
+
117
+ query = ComposedQuery.new(
118
+ min_age: 21,
119
+ role: "admin",
120
+ city: "New York",
121
+ state: "NY"
122
+ )
123
+ ```
124
+
125
+ ## Instance-Level Composition
126
+
127
+ ### Basic Instance Composition
128
+
129
+ ```ruby
130
+ # Create query instances
131
+ active_users = ActiveUsersQuery.new
132
+ premium_users = PremiumUsersQuery.new
133
+
134
+ # Compose instances
135
+ combined = active_users + premium_users
136
+ # Or
137
+ combined = active_users.merge(premium_users)
138
+
139
+ results = combined.results
140
+ ```
141
+
142
+ ### Composition with Joins
143
+
144
+ ```ruby
145
+ # Define queries that need joins
146
+ class PublishedPostsQuery < Quo::RelationBackedQuery
147
+ def query
148
+ Post.where(published: true)
149
+ end
150
+ end
151
+
152
+ class ActiveAuthorsQuery < Quo::RelationBackedQuery
153
+ def query
154
+ Author.where(active: true)
155
+ end
156
+ end
157
+
158
+ # Compose with explicit join
159
+ posts = PublishedPostsQuery.new
160
+ authors = ActiveAuthorsQuery.new
161
+
162
+ # Method 1: Using joins parameter
163
+ combined = posts.compose(authors, joins: :author)
164
+
165
+ # Method 2: Chaining joins before composition
166
+ combined = posts.joins(:author) + authors
167
+
168
+ # Results in: SELECT posts.* FROM posts
169
+ # INNER JOIN authors ON authors.id = posts.author_id
170
+ # WHERE posts.published = true AND authors.active = true
171
+ ```
172
+
173
+ ### Complex Join Examples
174
+
175
+ ```ruby
176
+ # Multiple joins
177
+ posts_with_comments = posts.compose(
178
+ CommentQuery.new,
179
+ joins: [:author, :comments]
180
+ )
181
+
182
+ # Nested joins
183
+ posts_with_nested = posts.compose(
184
+ CategoryQuery.new,
185
+ joins: { author: :profile, comments: :user }
186
+ )
187
+
188
+ # Hash conditions in joins
189
+ posts_with_conditions = posts.compose(
190
+ AuthorQuery.new,
191
+ joins: { author: { profile: :preferences } }
192
+ )
193
+ ```
194
+
195
+ ## Mixed Type Composition
196
+
197
+ ### Relation + Collection
198
+
199
+ ```ruby
200
+ # Start with a relation query
201
+ class BaseUsersQuery < Quo::RelationBackedQuery
202
+ def query
203
+ User.where(deleted_at: nil)
204
+ end
205
+ end
206
+
207
+ # Have a collection of special users
208
+ class SpecialUsersQuery < Quo::CollectionBackedQuery
209
+ def collection
210
+ # From cache, API, etc
211
+ [
212
+ User.new(id: 1, name: "Admin"),
213
+ User.new(id: 2, name: "Support")
214
+ ]
215
+ end
216
+ end
217
+
218
+ # Compose them
219
+ combined = BaseUsersQuery.new + SpecialUsersQuery.new
220
+ # Results include both database users AND special users
221
+ ```
222
+
223
+ ### How Mixed Composition Works
224
+
225
+ When composing different types:
226
+
227
+ 1. If either is a collection, result is collection-backed
228
+ 2. Relations are converted to arrays via `to_a`
229
+ 3. Collections are concatenated
230
+ 4. Duplicates are NOT automatically removed
231
+
232
+ ```ruby
233
+ # Under the hood for Relation + Collection
234
+ def compose_relation_and_collection(relation_query, collection_query)
235
+ relation_results = relation_query.results.to_a
236
+ collection_results = collection_query.results.to_a
237
+
238
+ Quo::CollectionBackedQuery.wrap(relation_results + collection_results)
239
+ end
240
+ ```
241
+
242
+ ## Transformation System
243
+
244
+ ### Basic Transformation
245
+
246
+ ```ruby
247
+ # Transform results after fetching
248
+ users_query = UsersByStateQuery.new(state: "CA")
249
+ .transform { |user| UserPresenter.new(user) }
250
+
251
+ # All result methods return transformed objects
252
+ users_query.results.each do |presenter|
253
+ puts presenter.display_name # Not user.name
254
+ end
255
+
256
+ users_query.results.first # Returns UserPresenter, not User
257
+ users_query.results.map(&:to_json) # Maps over presenters
258
+ ```
259
+
260
+ ### Transformation Implementation
261
+
262
+ ```ruby
263
+ # Inside Quo::Query
264
+ def transform(&block)
265
+ @__transformer = block
266
+ self
267
+ end
268
+
269
+ # Inside Results classes
270
+ def transform_results(results)
271
+ return results unless transform?
272
+
273
+ if results.is_a?(Enumerable)
274
+ results.map.with_index { |item, i| @transformer.call(item, i) }
275
+ else
276
+ @transformer.call(results)
277
+ end
278
+ end
279
+ ```
280
+
281
+ ### Index-Aware Transformation
282
+
283
+ ```ruby
284
+ # Transformer receives optional index
285
+ query.transform do |user, index|
286
+ {
287
+ position: index + 1,
288
+ name: user.name,
289
+ email: user.email
290
+ }
291
+ end
292
+
293
+ # First user gets position: 1, second gets position: 2, etc.
294
+ ```
295
+
296
+ ### Chaining Transformations
297
+
298
+ ```ruby
299
+ # Note: Only last transformation is applied
300
+ users_query
301
+ .transform { |u| UserDecorator.new(u) } # This is overwritten
302
+ .transform { |u| UserPresenter.new(u) } # This is applied
303
+
304
+ # To chain transformations, compose in one block
305
+ users_query.transform do |user|
306
+ decorated = UserDecorator.new(user)
307
+ UserPresenter.new(decorated)
308
+ end
309
+ ```
310
+
311
+ ## Advanced Composition Patterns
312
+
313
+ ### Repository Pattern
314
+
315
+ ```ruby
316
+ class UserRepository
317
+ def active
318
+ @active ||= ActiveUsersQuery
319
+ end
320
+
321
+ def premium
322
+ @premium ||= PremiumUsersQuery
323
+ end
324
+
325
+ def verified
326
+ @verified ||= VerifiedUsersQuery
327
+ end
328
+
329
+ def active_premium_verified
330
+ active + premium + verified
331
+ end
332
+ end
333
+
334
+ repo = UserRepository.new
335
+ users = repo.active_premium_verified.new.results
336
+ ```
337
+
338
+ ### Dynamic Composition
339
+
340
+ ```ruby
341
+ class SearchQuery < Quo::RelationBackedQuery
342
+ prop :filters, Hash, default: -> { {} }
343
+
344
+ def query
345
+ base = User.all
346
+
347
+ # Dynamically compose queries based on filters
348
+ queries_to_compose = []
349
+
350
+ queries_to_compose << ActiveUsersQuery if filters[:active]
351
+ queries_to_compose << PremiumUsersQuery if filters[:premium]
352
+ queries_to_compose << VerifiedUsersQuery if filters[:verified]
353
+
354
+ queries_to_compose.reduce(base) do |combined, query_class|
355
+ combined + query_class.new
356
+ end
357
+ end
358
+ end
359
+ ```
360
+
361
+ ### Conditional Composition
362
+
363
+ ```ruby
364
+ class ConditionalQuery < Quo::RelationBackedQuery
365
+ prop :include_archived, Boolean, default: -> { false }
366
+ prop :user_id, Integer
367
+
368
+ def query
369
+ base = Post.where(user_id: user_id)
370
+
371
+ if include_archived
372
+ base + ArchivedPostsQuery.new(user_id: user_id)
373
+ else
374
+ base
375
+ end
376
+ end
377
+ end
378
+ ```
379
+
380
+ ## Composition Performance
381
+
382
+ ### Efficient Database Queries
383
+
384
+ When composing relation-backed queries:
385
+
386
+ ```ruby
387
+ # Good: Single database query
388
+ active_premium = ActiveUsersQuery.new + PremiumUsersQuery.new
389
+ users = active_premium.results # One query with merged conditions
390
+
391
+ # SQL: SELECT users.* FROM users WHERE active = true AND subscription = 'premium'
392
+ ```
393
+
394
+ ### Memory Considerations
395
+
396
+ When composing with collections:
397
+
398
+ ```ruby
399
+ # Caution: Loads all records into memory
400
+ relation_query = User.where(active: true) # Could be millions
401
+ collection_query = SpecialUsersQuery.new # Just a few
402
+
403
+ combined = relation_query + collection_query
404
+ # This executes relation_query.to_a - loads ALL active users!
405
+ ```
406
+
407
+ ### Best Practices
408
+
409
+ 1. **Compose similar types when possible** - Relation + Relation is most efficient
410
+ 2. **Use joins parameter for associations** - Avoids N+1 queries
411
+ 3. **Transform after composition** - More efficient than transforming each query
412
+ 4. **Be mindful of collection size** - Mixed composition loads relations into memory
413
+ 5. **Consider caching** - For expensive composed queries
414
+
415
+ ## Testing Composed Queries
416
+
417
+ ```ruby
418
+ # Test composition behavior
419
+ class ComposedQueryTest < ActiveSupport::TestCase
420
+ test "combines filters from both queries" do
421
+ left = ActiveUsersQuery.new
422
+ right = PremiumUsersQuery.new
423
+
424
+ composed = left + right
425
+ sql = composed.to_sql
426
+
427
+ assert_includes sql, "active = true"
428
+ assert_includes sql, "subscription = 'premium'"
429
+ end
430
+
431
+ test "transformation applies to composed results" do
432
+ composed = (ActiveUsersQuery.new + PremiumUsersQuery.new)
433
+ .transform { |u| u.name.upcase }
434
+
435
+ fake_query(composed.__class__, results: [User.new(name: "john")]) do
436
+ result = composed.results.first
437
+ assert_equal "JOHN", result
438
+ end
439
+ end
440
+ end
441
+ ```