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,355 @@
1
+ # Query Types and Implementation
2
+
3
+ This document details the two primary query types in Quo and their implementation patterns.
4
+
5
+ ## RelationBackedQuery Deep Dive
6
+
7
+ ### Core Implementation
8
+
9
+ ```ruby
10
+ class RelationBackedQuery < Query
11
+ # Specification pattern for storing query options
12
+ prop :_specification, _Nilable(Quo::RelationBackedQuerySpecification),
13
+ default: -> { RelationBackedQuerySpecification.blank },
14
+ writer: false
15
+
16
+ # Must return an ActiveRecord::Relation or another Quo::Query
17
+ def query
18
+ raise NotImplementedError
19
+ end
20
+
21
+ # Returns RelationResults for execution
22
+ def results
23
+ Quo::RelationResults.new(self, transformer: transformer)
24
+ end
25
+
26
+ # Fluent API implementation
27
+ def method_missing(method_name, *args, **kwargs, &block)
28
+ spec = @_specification || RelationBackedQuerySpecification.blank
29
+
30
+ if spec.respond_to?(method_name)
31
+ updated_spec = spec.method(method_name).call(*args, **kwargs, &block)
32
+ return with_specification(updated_spec)
33
+ end
34
+
35
+ super
36
+ end
37
+ end
38
+ ```
39
+
40
+ ### Practical Example
41
+
42
+ ```ruby
43
+ class PublishedPostsQuery < Quo::RelationBackedQuery
44
+ # Type-safe properties
45
+ prop :author_id, _Nilable(Integer)
46
+ prop :since_date, _Nilable(Date), default: -> { 30.days.ago.to_date }
47
+ prop :category, _Nilable(String)
48
+
49
+ def query
50
+ posts = Post.published
51
+ posts = posts.where(author_id: author_id) if author_id
52
+ posts = posts.where("published_at >= ?", since_date) if since_date
53
+ posts = posts.joins(:categories).where(categories: { name: category }) if category
54
+ posts
55
+ end
56
+ end
57
+
58
+ # Usage with fluent API
59
+ query = PublishedPostsQuery.new(author_id: 123)
60
+ .order(published_at: :desc)
61
+ .includes(:author, :comments)
62
+ .limit(10)
63
+
64
+ results = query.results
65
+ ```
66
+
67
+ ### Query Specification Pattern
68
+
69
+ The specification pattern separates query construction from storage:
70
+
71
+ ```ruby
72
+ # Building a complex query
73
+ spec = RelationBackedQuerySpecification.blank
74
+ .where(active: true)
75
+ .where("created_at > ?", 1.week.ago)
76
+ .order(created_at: :desc)
77
+ .includes(:profile)
78
+ .limit(10)
79
+
80
+ # Apply to any relation
81
+ spec.apply_to(User.all) # => Returns configured relation
82
+ ```
83
+
84
+ ### Wrap Factory Method
85
+
86
+ Create query objects on the fly without defining a class:
87
+
88
+ ```ruby
89
+ # Simple wrap
90
+ ActiveUsersQuery = Quo::RelationBackedQuery.wrap(User.where(active: true))
91
+
92
+ # With properties
93
+ FilteredUsersQuery = Quo::RelationBackedQuery.wrap(
94
+ props: {
95
+ role: String,
96
+ min_age: _Nilable(Integer)
97
+ }
98
+ ) do
99
+ scope = User.all
100
+ scope = scope.where(role: role) if role
101
+ scope = scope.where("age >= ?", min_age) if min_age
102
+ scope
103
+ end
104
+
105
+ # Usage
106
+ query = FilteredUsersQuery.new(role: "admin", min_age: 18)
107
+ ```
108
+
109
+ ### SQL Generation
110
+
111
+ RelationBackedQuery provides SQL introspection:
112
+
113
+ ```ruby
114
+ query = UsersByStateQuery.new(state: "CA")
115
+ puts query.to_sql
116
+ # => "SELECT users.* FROM users WHERE users.state = 'CA'"
117
+ ```
118
+
119
+ ## CollectionBackedQuery Deep Dive
120
+
121
+ ### Core Implementation
122
+
123
+ ```ruby
124
+ class CollectionBackedQuery < Query
125
+ # Optional total count for pagination
126
+ prop :total_count, _Nilable(Integer), reader: false
127
+
128
+ # Must return an Enumerable
129
+ def collection
130
+ raise NotImplementedError
131
+ end
132
+
133
+ # Default implementation delegates to collection
134
+ def query
135
+ collection
136
+ end
137
+
138
+ # Returns CollectionResults for execution
139
+ def results
140
+ Quo::CollectionResults.new(self, transformer: transformer, total_count: @total_count)
141
+ end
142
+
143
+ # Pagination support
144
+ def configured_query
145
+ q = underlying_query
146
+ return q unless paged?
147
+
148
+ if q.respond_to?(:[])
149
+ q[offset, sanitised_page_size] # Array-like slicing
150
+ else
151
+ q # Non-sliceable collections
152
+ end
153
+ end
154
+ end
155
+ ```
156
+
157
+ ### Practical Examples
158
+
159
+ #### Working with Cached Data
160
+
161
+ ```ruby
162
+ class CachedProductsQuery < Quo::CollectionBackedQuery
163
+ prop :min_price, _Nilable(Float)
164
+ prop :category, _Nilable(String)
165
+
166
+ def collection
167
+ @products ||= Rails.cache.fetch("all_products", expires_in: 1.hour) do
168
+ Product.includes(:variants, :images).to_a
169
+ end
170
+
171
+ filtered = @products
172
+ filtered = filtered.select { |p| p.price >= min_price } if min_price
173
+ filtered = filtered.select { |p| p.category == category } if category
174
+ filtered
175
+ end
176
+ end
177
+ ```
178
+
179
+ #### Working with External APIs
180
+
181
+ ```ruby
182
+ class GitHubRepositoriesQuery < Quo::CollectionBackedQuery
183
+ prop :username, String
184
+ prop :language, _Nilable(String)
185
+
186
+ def collection
187
+ repos = fetch_github_repos(username)
188
+ repos = repos.select { |r| r["language"] == language } if language
189
+ repos
190
+ end
191
+
192
+ private
193
+
194
+ def fetch_github_repos(username)
195
+ response = Net::HTTP.get_response(
196
+ URI("https://api.github.com/users/#{username}/repos")
197
+ )
198
+ JSON.parse(response.body)
199
+ end
200
+ end
201
+ ```
202
+
203
+ #### Working with Non-Database Models
204
+
205
+ ```ruby
206
+ class FileSystemQuery < Quo::CollectionBackedQuery
207
+ prop :directory, String, default: -> { "." }
208
+ prop :extension, _Nilable(String)
209
+
210
+ def collection
211
+ files = Dir.glob(File.join(directory, "**/*"))
212
+ files = files.select { |f| f.end_with?(extension) } if extension
213
+ files.map { |path| FileInfo.new(path) }
214
+ end
215
+ end
216
+
217
+ FileInfo = Struct.new(:path) do
218
+ def name
219
+ File.basename(path)
220
+ end
221
+
222
+ def size
223
+ File.size(path)
224
+ end
225
+ end
226
+ ```
227
+
228
+ ### Preloadable Module
229
+
230
+ Enable association preloading for collections of ActiveRecord models:
231
+
232
+ ```ruby
233
+ class SpecialUsersQuery < Quo::CollectionBackedQuery
234
+ include Quo::Preloadable
235
+
236
+ def collection
237
+ # These come from different sources
238
+ [
239
+ User.find_by(email: "admin@example.com"),
240
+ User.find_by(role: "superuser"),
241
+ User.where(vip: true).first
242
+ ].compact
243
+ end
244
+ end
245
+
246
+ # Preload associations efficiently
247
+ query = SpecialUsersQuery.new.includes(:profile, :posts)
248
+ query.results.each do |user|
249
+ # No N+1 queries!
250
+ puts "#{user.name} has #{user.posts.count} posts"
251
+ end
252
+ ```
253
+
254
+ ### Wrap Factory Method
255
+
256
+ ```ruby
257
+ # Simple collection wrap
258
+ NumbersQuery = Quo::CollectionBackedQuery.wrap([1, 2, 3, 4, 5])
259
+
260
+ # With filtering logic
261
+ FilteredNumbersQuery = Quo::CollectionBackedQuery.wrap(
262
+ props: { min: Integer }
263
+ ) do
264
+ (1..100).select { |n| n >= min }
265
+ end
266
+
267
+ # Usage
268
+ query = FilteredNumbersQuery.new(min: 50, page: 1, page_size: 10)
269
+ results = query.results
270
+ ```
271
+
272
+ ## Conversion Between Types
273
+
274
+ ### to_collection Method
275
+
276
+ Convert a RelationBackedQuery to a CollectionBackedQuery:
277
+
278
+ ```ruby
279
+ # Start with a relation query
280
+ relation_query = UsersByStateQuery.new(state: "NY")
281
+
282
+ # Convert to collection (executes the query)
283
+ collection_query = relation_query.to_collection
284
+
285
+ # Can specify total count for accurate pagination
286
+ collection_query = relation_query.to_collection(total_count: 1000)
287
+
288
+ # Now it behaves as a collection
289
+ collection_query.collection? # => true
290
+ collection_query.relation? # => false
291
+ ```
292
+
293
+ This is useful for:
294
+ - Caching query results
295
+ - Working with results in memory
296
+ - Applying Ruby-based filtering to SQL results
297
+
298
+ ## Query Method Contracts
299
+
300
+ ### Required Methods
301
+
302
+ Both query types must implement:
303
+
304
+ 1. **query** - Returns the data source (relation or collection)
305
+ 2. **validated_query** - Validates and returns the query
306
+ 3. **underlying_query** - Returns the query without pagination
307
+ 4. **configured_query** - Returns the query with pagination applied
308
+
309
+ ### Type Checking
310
+
311
+ ```ruby
312
+ # RelationBackedQuery validates type
313
+ def validated_query
314
+ query.tap do |q|
315
+ raise ArgumentError, "#query must return an ActiveRecord Relation or a Quo::Query instance"
316
+ unless query.nil? || q.is_a?(::ActiveRecord::Relation) || q.is_a?(Quo::Query)
317
+ end
318
+ end
319
+
320
+ # CollectionBackedQuery has no validation
321
+ def validated_query
322
+ query # Any enumerable is valid
323
+ end
324
+ ```
325
+
326
+ ## Performance Considerations
327
+
328
+ ### RelationBackedQuery
329
+
330
+ - Queries are lazy - SQL only executes when results are accessed
331
+ - Supports database-level optimizations (indexes, joins)
332
+ - Efficient counting via SQL COUNT
333
+ - Memory efficient for large datasets
334
+
335
+ ### CollectionBackedQuery
336
+
337
+ - Data must fit in memory
338
+ - Filtering happens in Ruby (potentially slower)
339
+ - Flexible - works with any data source
340
+ - Good for cached data or small datasets
341
+
342
+ ## Choosing the Right Type
343
+
344
+ Use **RelationBackedQuery** when:
345
+ - Working directly with ActiveRecord models
346
+ - Need database-level filtering and joins
347
+ - Working with large datasets
348
+ - Need SQL-level performance
349
+
350
+ Use **CollectionBackedQuery** when:
351
+ - Working with cached data
352
+ - Data comes from external APIs
353
+ - Need Ruby-level transformations
354
+ - Working with non-AR objects
355
+ - Dataset fits comfortably in memory