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
@@ -0,0 +1,462 @@
1
+ # API Reference
2
+
3
+ > **Targets Quo `~> 2.0`.**
4
+
5
+ ## Query class methods
6
+
7
+ ### `.wrap(query = nil, props: {}, &block)`
8
+
9
+ Create a query class without defining a full subclass.
10
+
11
+ ```ruby
12
+ # Wrap a relation
13
+ RecentComments = Quo::RelationBackedQuery.wrap(Comment.where("created_at > ?", 1.day.ago))
14
+ RecentComments.new.results
15
+
16
+ # Wrap with typed props inside a block
17
+ CommentsByAuthor = Quo::RelationBackedQuery.wrap(props: {author_id: Integer}) do
18
+ Comment.joins(:post).where(posts: {author_id: author_id})
19
+ end
20
+ CommentsByAuthor.new(author_id: 1).results
21
+
22
+ # Wrap a collection
23
+ CachedAuthors = Quo::CollectionBackedQuery.wrap do
24
+ Rails.cache.fetch("authors") { Author.all.to_a }
25
+ end
26
+ ```
27
+
28
+ **Returns:** Query class.
29
+
30
+ **Performance note:** `wrap` allocates a new class on each call. Treat it
31
+ as type-definition: assign the result to a constant. Don't call
32
+ `Quo::RelationBackedQuery.wrap(rel).new` inside a method that runs many
33
+ times per request.
34
+
35
+ ---
36
+
37
+ ### `.compose(right, joins: nil)` (alias `+`)
38
+
39
+ Compose two query classes. Returns a new class that, when instantiated,
40
+ runs both underlying queries merged together.
41
+
42
+ ```ruby
43
+ ComposedClass = ActiveCommentsQuery.compose(NonSpamCommentsQuery)
44
+ # Equivalent to: ComposedClass = ActiveCommentsQuery + NonSpamCommentsQuery
45
+ ComposedClass.new(score: 0.5).results
46
+ ```
47
+
48
+ **Parameters:**
49
+ - `right` — Query class to compose with
50
+ - `joins:` — optional join argument (Symbol/Hash/Array) for the AR merge
51
+
52
+ **Returns:** Composed query class.
53
+
54
+ See `references/COMPOSITION.md` for class-vs-instance composition guidance.
55
+
56
+ ---
57
+
58
+ ## Query instance methods
59
+
60
+ ### `#initialize(**props)`
61
+
62
+ ```ruby
63
+ query = CommentsByAuthorQuery.new(
64
+ author_id: 1,
65
+ since: 1.day.ago,
66
+ page: 1,
67
+ page_size: 25
68
+ )
69
+ ```
70
+
71
+ **Parameters:** keyword arguments matching the query's `prop` declarations,
72
+ plus `page` and `page_size`.
73
+
74
+ **Raises:** `Literal::TypeError` on type mismatch or missing required props.
75
+
76
+ ---
77
+
78
+ ### `#query` (RelationBackedQuery — must implement)
79
+
80
+ Return an `ActiveRecord::Relation` (or another `Quo::Query`).
81
+
82
+ ```ruby
83
+ def query
84
+ Comment.where(read: false).order(:created_at)
85
+ end
86
+ ```
87
+
88
+ ---
89
+
90
+ ### `#collection` (CollectionBackedQuery — must implement)
91
+
92
+ Return an `Enumerable`.
93
+
94
+ ```ruby
95
+ def collection
96
+ items.select { |i| i.score > 0.5 }
97
+ end
98
+ ```
99
+
100
+ ---
101
+
102
+ ### `#results`
103
+
104
+ Run the query and return a `Quo::Results`.
105
+
106
+ ```ruby
107
+ results = query.results
108
+ results.each { |row| ... }
109
+ results.count
110
+ ```
111
+
112
+ **Returns:** `Quo::Results`.
113
+
114
+ ---
115
+
116
+ ### `#copy(**overrides)`
117
+
118
+ Return a new query instance with overridden props. Doesn't mutate `self`.
119
+
120
+ ```ruby
121
+ page_2 = query.copy(page: 2)
122
+ larger = query.copy(page_size: 100)
123
+ ```
124
+
125
+ **Returns:** new query instance of the same class.
126
+
127
+ ---
128
+
129
+ ### `#merge(right, joins: nil)` (alias `+` for instances)
130
+
131
+ Compose two query instances. Returns a value-shaped query, no class
132
+ allocation.
133
+
134
+ ```ruby
135
+ left = CommentsByAuthorQuery.new(author_id: 1)
136
+ right = UnreadCommentsQuery.new
137
+
138
+ merged = left.merge(right)
139
+ # Or: merged = left + right
140
+ ```
141
+
142
+ **Parameters:**
143
+ - `right` — query instance, AR relation, or enumerable
144
+ - `joins:` — optional join argument (Symbol/Hash/Array) for the AR merge
145
+
146
+ **Returns:** new composed query instance.
147
+
148
+ ---
149
+
150
+ ### `#transform(&block)`
151
+
152
+ Attach a transformer that runs on each row of `results`.
153
+
154
+ ```ruby
155
+ query = CommentsByAuthorQuery.new(author_id: 1)
156
+ .transform { |c| CommentPresenter.new(c) }
157
+
158
+ query.results.first # => CommentPresenter
159
+ ```
160
+
161
+ **Returns:** new query instance with the transformer attached.
162
+
163
+ ---
164
+
165
+ ### `#next_page_query` / `#previous_page_query`
166
+
167
+ Return new query instances at adjacent pages. Both require a non-nil
168
+ `page` and raise `NoMethodError` otherwise (they compute `page + 1` /
169
+ `page - 1`).
170
+
171
+ ```ruby
172
+ query = CommentsByAuthorQuery.new(author_id: 1, page: 1, page_size: 25)
173
+ query.next_page_query.page # => 2
174
+ query.copy(page: 5).previous_page_query.page # => 4
175
+ ```
176
+
177
+ ---
178
+
179
+ ### `#offset`
180
+
181
+ Computed: `(page - 1) * page_size`.
182
+
183
+ ```ruby
184
+ query = CommentsByAuthorQuery.new(author_id: 1, page: 3, page_size: 25)
185
+ query.offset # => 50
186
+ ```
187
+
188
+ ---
189
+
190
+ ### `#unwrap` / `#unwrap_unpaginated`
191
+
192
+ ```ruby
193
+ query = CommentsByAuthorQuery.new(author_id: 1, page: 2, page_size: 25)
194
+
195
+ query.unwrap # AR::Relation w/ LIMIT 25 OFFSET 25
196
+ query.unwrap_unpaginated # AR::Relation, no LIMIT/OFFSET
197
+ ```
198
+
199
+ For `CollectionBackedQuery`, `#unwrap` returns a paginated array slice
200
+ and `#unwrap_unpaginated` returns the full enumerable.
201
+
202
+ ---
203
+
204
+ ### `#to_sql` (RelationBackedQuery only)
205
+
206
+ ```ruby
207
+ CommentsByAuthorQuery.new(author_id: 1).to_sql
208
+ # => "SELECT ... FROM comments INNER JOIN posts ..."
209
+ ```
210
+
211
+ ---
212
+
213
+ ### `#to_collection`
214
+
215
+ Materialise a `RelationBackedQuery` into a `CollectionBackedQuery`.
216
+
217
+ ```ruby
218
+ relation_q = CommentsByAuthorQuery.new(author_id: 1)
219
+ collection_q = relation_q.to_collection
220
+ collection_q.collection? # => true
221
+ ```
222
+
223
+ ---
224
+
225
+ ### Predicates
226
+
227
+ ```ruby
228
+ query.relation? # backed by AR relation?
229
+ query.collection? # backed by enumerable?
230
+ query.paged? # pagination enabled?
231
+ query.transform? # transformer attached?
232
+ ```
233
+
234
+ ---
235
+
236
+ ### Property accessors
237
+
238
+ `#page`, `#page_size`, plus accessors for any `prop` you declared.
239
+
240
+ ```ruby
241
+ query = CommentsByAuthorQuery.new(author_id: 1, page: 3, page_size: 50)
242
+ query.page # => 3
243
+ query.page_size # => 50
244
+ query.author_id # => 1
245
+ ```
246
+
247
+ ---
248
+
249
+ ## Fluent spec API (RelationBackedQuery only)
250
+
251
+ `Quo::RelationBackedQuery` routes spec-style method calls through
252
+ `method_missing` to a `Quo::RelationBackedQuerySpecification`. Each call
253
+ returns a new query instance with the spec updated; chains compose.
254
+
255
+ ```ruby
256
+ q = UnreadCommentsQuery.new
257
+ .where(read: false)
258
+ .order(created_at: :desc)
259
+ .joins(:post)
260
+ .includes(:author)
261
+ .limit(10)
262
+ .distinct
263
+ ```
264
+
265
+ Available methods (mirror their `ActiveRecord::Relation` counterparts):
266
+
267
+ - `#where(conditions)`
268
+ - `#order(order_clause)`
269
+ - `#reorder(order_clause)`
270
+ - `#group(*columns)`
271
+ - `#limit(value)`
272
+ - `#offset(value)`
273
+ - `#select(*fields)`
274
+ - `#joins(*tables)` — accepts multiple table args (Symbol, Hash, Array)
275
+ - `#left_outer_joins(*tables)`
276
+ - `#includes(*associations)`
277
+ - `#preload(*associations)`
278
+ - `#eager_load(*associations)`
279
+ - `#distinct(enabled = true)`
280
+ - `#extending(*modules)`
281
+ - `#unscope(*args)`
282
+
283
+ Each returns a new query; the underlying `_specification` is built up
284
+ immutably.
285
+
286
+ ### `#with(options = {})`
287
+
288
+ Merge multiple spec options at once via a hash.
289
+
290
+ ```ruby
291
+ q.with(
292
+ where: {read: false},
293
+ order: {created_at: :desc},
294
+ limit: 10
295
+ )
296
+ ```
297
+
298
+ ### `#with_specification(specification)`
299
+
300
+ Replace the entire spec on a copy of the query.
301
+
302
+ ```ruby
303
+ spec = Quo::RelationBackedQuerySpecification.new(limit: 5)
304
+ q.with_specification(spec)
305
+ ```
306
+
307
+ Specs added on a composed instance apply to the merged relation at
308
+ unwrap time, on top of any specs on the individual operands.
309
+
310
+ ---
311
+
312
+ ## Quo::Results methods
313
+
314
+ ### Counts
315
+
316
+ | Method | Returns |
317
+ |---|---|
318
+ | `#count` | total rows (across all pages) |
319
+ | `#page_count` | rows in the current page |
320
+ | `#empty?` | true when there are no rows |
321
+ | `#exists?` | true when there's at least one row |
322
+
323
+ ### Enumerable
324
+
325
+ `Quo::Results` includes `Enumerable` and delegates `#each`, `#map`,
326
+ `#select`, `#reject`, `#first`, `#last`, `#find`, `#group_by`, `#to_a`.
327
+ If a transformer is set, each yielded row passes through it.
328
+
329
+ ```ruby
330
+ results.each { |c| ... }
331
+ results.map(&:body)
332
+ results.select { |c| c.read? }
333
+ results.group_by(&:read)
334
+ results.to_a
335
+ ```
336
+
337
+ ### `#transform?`
338
+
339
+ Boolean — was a transformer attached to the query?
340
+
341
+ ---
342
+
343
+ ## Property type reference
344
+
345
+ Quo uses [Literal](https://github.com/joeldrapper/literal). Its helper
346
+ methods on Quo classes are prefixed with underscore.
347
+
348
+ ### Primitives
349
+
350
+ ```ruby
351
+ prop :name, String
352
+ prop :count, Integer
353
+ prop :price, Float
354
+ prop :enabled, _Boolean # NB: _Boolean, not Boolean
355
+ prop :data, Hash
356
+ prop :items, Array
357
+ ```
358
+
359
+ ### Custom classes
360
+
361
+ ```ruby
362
+ prop :author, Author
363
+ prop :post, Post
364
+ ```
365
+
366
+ ### Arrays / Nilable / Unions
367
+
368
+ ```ruby
369
+ prop :tags, _Array(String)
370
+ prop :ids, _Array(Integer)
371
+ prop :since, _Nilable(Time)
372
+ prop :id_or_slug, _Union(String, Integer)
373
+ ```
374
+
375
+ ### Defaults
376
+
377
+ Use a lambda for any non-frozen default to avoid shared mutable state.
378
+
379
+ ```ruby
380
+ prop :tags, _Array(String), default: -> { [] }
381
+ prop :since, Time, default: -> { 1.day.ago }
382
+ prop :page_size, Integer, default: -> { 20 }
383
+ prop :status, String, default: "pending".freeze
384
+ ```
385
+
386
+ ---
387
+
388
+ ## Configuration
389
+
390
+ ```ruby
391
+ # config/initializers/quo.rb
392
+ Quo.default_page_size = 25
393
+ Quo.max_page_size = 200
394
+ Quo.relation_backed_query_base_class = "ApplicationRelationQuery"
395
+ Quo.collection_backed_query_base_class = "ApplicationCollectionQuery"
396
+ ```
397
+
398
+ The base class options let you set application-level defaults (e.g. a
399
+ `hello` method shared by every relation-backed query) by subclassing the
400
+ base classes once and pointing Quo at the subclass.
401
+
402
+ ---
403
+
404
+ ## Errors
405
+
406
+ ### `Literal::TypeError`
407
+
408
+ Raised at `#initialize` when a prop value violates its declared type.
409
+
410
+ ```ruby
411
+ class StrictQuery < Quo::RelationBackedQuery
412
+ prop :author_id, Integer
413
+ end
414
+
415
+ StrictQuery.new(author_id: "1")
416
+ # => Literal::TypeError: author_id is "1", expected Integer
417
+
418
+ StrictQuery.new
419
+ # => Literal::TypeError: author_id is nil, expected Integer
420
+ ```
421
+
422
+ ### `ArgumentError`
423
+
424
+ Raised by `Quo::Composing.composer` / `Quo::Composing.merge_instances`
425
+ if the operands aren't a valid combination.
426
+
427
+ ---
428
+
429
+ ## End-to-end example
430
+
431
+ ```ruby
432
+ class CommentsByAuthorQuery < Quo::RelationBackedQuery
433
+ prop :author_id, Integer
434
+ prop :since, _Nilable(Time)
435
+ prop :include_read, _Boolean, default: -> { true }
436
+
437
+ def query
438
+ scope = Comment
439
+ .joins(:post)
440
+ .where(posts: {author_id: author_id})
441
+ .order(created_at: :desc)
442
+ scope = scope.where("comments.created_at > ?", since) if since
443
+ scope = scope.where(read: false) unless include_read
444
+ scope
445
+ end
446
+ end
447
+
448
+ # In a controller
449
+ def index
450
+ query = CommentsByAuthorQuery
451
+ .new(
452
+ author_id: params[:author_id].to_i,
453
+ since: params[:since] && Time.zone.parse(params[:since]),
454
+ include_read: params[:include_read] != "false",
455
+ page: params[:page] || 1,
456
+ page_size: 25,
457
+ )
458
+ .transform { |c| CommentPresenter.new(c, viewer: current_user) }
459
+
460
+ render :index, locals: {comments: query.results, paginator: query}
461
+ end
462
+ ```