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,485 @@
1
+ # Pagination and Results
2
+
3
+ This document covers Quo's pagination system and the Results objects that execute queries and provide access to data.
4
+
5
+ ## Pagination System
6
+
7
+ ### Core Pagination Properties
8
+
9
+ Every Quo query has built-in pagination support via two properties:
10
+
11
+ ```ruby
12
+ class Query < Literal::Struct
13
+ # Current page number (nil means no pagination)
14
+ prop :page, _Nilable(Integer), &COERCE_TO_INT
15
+
16
+ # Items per page (defaults to Quo.default_page_size)
17
+ prop :page_size, _Nilable(Integer),
18
+ default: -> { Quo.default_page_size || 20 },
19
+ &COERCE_TO_INT
20
+ end
21
+ ```
22
+
23
+ ### Pagination Implementation
24
+
25
+ #### For RelationBackedQuery
26
+
27
+ ```ruby
28
+ def configured_query
29
+ q = underlying_query
30
+ return q unless paged? # paged? returns true if page is set
31
+
32
+ q.offset(offset).limit(sanitised_page_size)
33
+ end
34
+
35
+ def offset
36
+ per_page = sanitised_page_size
37
+ page_with_default = page&.positive? ? page : 1
38
+ per_page * (page_with_default - 1)
39
+ end
40
+ ```
41
+
42
+ SQL translation:
43
+ - Page 1, size 20: `LIMIT 20 OFFSET 0`
44
+ - Page 2, size 20: `LIMIT 20 OFFSET 20`
45
+ - Page 3, size 10: `LIMIT 10 OFFSET 20`
46
+
47
+ #### For CollectionBackedQuery
48
+
49
+ ```ruby
50
+ def configured_query
51
+ q = underlying_query
52
+ return q unless paged?
53
+
54
+ if q.respond_to?(:[])
55
+ q[offset, sanitised_page_size] # Array slicing
56
+ else
57
+ q # Non-sliceable collections return unchanged
58
+ end
59
+ end
60
+ ```
61
+
62
+ ### Page Size Sanitization
63
+
64
+ ```ruby
65
+ def sanitised_page_size
66
+ if page_size&.positive?
67
+ given_size = page_size.to_i
68
+ max_page_size = Quo.max_page_size || 200
69
+
70
+ # Enforce maximum to prevent resource abuse
71
+ given_size > max_page_size ? max_page_size : given_size
72
+ else
73
+ Quo.default_page_size || 20
74
+ end
75
+ end
76
+ ```
77
+
78
+ ### Navigation Methods
79
+
80
+ ```ruby
81
+ query = UsersQuery.new(page: 2, page_size: 20)
82
+
83
+ # Get next page query (immutable - returns new instance)
84
+ next_page = query.next_page_query
85
+ next_page.page # => 3
86
+
87
+ # Get previous page query
88
+ prev_page = query.previous_page_query
89
+ prev_page.page # => 1
90
+
91
+ # Previous page never goes below 1
92
+ first_page = UsersQuery.new(page: 1)
93
+ prev = first_page.previous_page_query
94
+ prev.page # => 1 (not 0)
95
+ ```
96
+
97
+ ### Pagination Examples
98
+
99
+ ```ruby
100
+ # Basic pagination
101
+ users = UsersQuery.new(page: 1, page_size: 25).results
102
+
103
+ # Iterate through pages
104
+ query = UsersQuery.new(page: 1, page_size: 100)
105
+ all_users = []
106
+
107
+ loop do
108
+ results = query.results
109
+ all_users.concat(results.to_a)
110
+
111
+ break if results.to_a.size < query.page_size
112
+ query = query.next_page_query
113
+ end
114
+
115
+ # Pagination with other options
116
+ query = UsersQuery.new(
117
+ state: "CA",
118
+ page: 3,
119
+ page_size: 50
120
+ ).order(:created_at)
121
+ ```
122
+
123
+ ## Results Objects
124
+
125
+ ### Results Base Class
126
+
127
+ ```ruby
128
+ module Quo
129
+ class Results
130
+ def initialize(query, transformer: nil, **options)
131
+ @query = query
132
+ @transformer = transformer
133
+ @configured_query = query.unwrap
134
+ end
135
+
136
+ # Counting methods
137
+ def count # Total count (ignores pagination)
138
+ def total_count # Alias for count
139
+ def size # Alias for count
140
+ def page_count # Count on current page only
141
+ def page_size # Alias for page_count
142
+
143
+ # Existence methods
144
+ def exists?
145
+ def empty?
146
+
147
+ # Enumerable interface
148
+ def each(&block)
149
+ def map(&block)
150
+ def first(limit = nil)
151
+ def last(limit = nil)
152
+
153
+ # Delegation with transformation
154
+ def method_missing(method, *args, **kwargs, &block)
155
+ end
156
+ end
157
+ ```
158
+
159
+ ### RelationResults
160
+
161
+ Specialized for ActiveRecord relations:
162
+
163
+ ```ruby
164
+ class RelationResults < Results
165
+ delegate :model, :klass, to: :@query
166
+
167
+ def count
168
+ # Efficient SQL count
169
+ @unpaginated_relation.count
170
+ end
171
+
172
+ def total_count
173
+ # For compatibility
174
+ count
175
+ end
176
+
177
+ def page_count
178
+ # Only counts current page
179
+ @configured_query.count
180
+ end
181
+
182
+ def exists?
183
+ @configured_query.exists?
184
+ end
185
+
186
+ def find(id)
187
+ result = @configured_query.find(id)
188
+ transform? ? @transformer.call(result) : result
189
+ end
190
+
191
+ def find_by(conditions)
192
+ result = @configured_query.find_by(conditions)
193
+ return nil unless result
194
+ transform? ? @transformer.call(result) : result
195
+ end
196
+
197
+ def where(conditions)
198
+ # Returns new Results with additional conditions
199
+ self.class.new(
200
+ @query.copy,
201
+ configured_query: @configured_query.where(conditions),
202
+ transformer: @transformer
203
+ )
204
+ end
205
+ end
206
+ ```
207
+
208
+ ### CollectionResults
209
+
210
+ Specialized for enumerable collections:
211
+
212
+ ```ruby
213
+ class CollectionResults < Results
214
+ def initialize(query, transformer: nil, total_count: nil)
215
+ super(query, transformer: transformer)
216
+ @total_count = total_count
217
+ end
218
+
219
+ def count
220
+ # Counts full collection or uses provided total
221
+ @total_count || @query.unwrap_unpaginated.count
222
+ end
223
+
224
+ def page_count
225
+ # Counts items on current page
226
+ @configured_query.count
227
+ end
228
+
229
+ def exists?
230
+ !@configured_query.empty?
231
+ end
232
+
233
+ def to_a
234
+ arr = @configured_query.to_a
235
+ transform? ? arr.map.with_index { |x, i| @transformer.call(x, i) } : arr
236
+ end
237
+ end
238
+ ```
239
+
240
+ ### Working with Results
241
+
242
+ ```ruby
243
+ # Get results
244
+ query = UsersQuery.new(state: "CA", page: 1, page_size: 20)
245
+ results = query.results
246
+
247
+ # Counting
248
+ results.count # Total users in CA (ignores pagination)
249
+ results.page_count # Users on current page (max 20)
250
+ results.total_count # Same as count
251
+
252
+ # Existence checks
253
+ if results.exists?
254
+ puts "Found #{results.count} users"
255
+ else
256
+ puts "No users found"
257
+ end
258
+
259
+ # Enumeration
260
+ results.each do |user|
261
+ puts user.name
262
+ end
263
+
264
+ # Get specific items
265
+ first_user = results.first
266
+ last_user = results.last
267
+ first_five = results.first(5)
268
+
269
+ # Map/Select/Reject with transformation
270
+ emails = results.map(&:email)
271
+ active = results.select(&:active?)
272
+
273
+ # For RelationResults - ActiveRecord methods
274
+ user = results.find(123)
275
+ admin = results.find_by(role: "admin")
276
+ californians = results.where(state: "CA")
277
+ ```
278
+
279
+ ## Transformation in Results
280
+
281
+ ### How Transformation Works
282
+
283
+ ```ruby
284
+ # Set transformer on query
285
+ query = UsersQuery.new.transform { |user| UserPresenter.new(user) }
286
+ results = query.results
287
+
288
+ # All methods return transformed objects
289
+ results.first # => UserPresenter instance
290
+ results.to_a # => Array of UserPresenter instances
291
+ results.map(&:name) # => Calls name on UserPresenter, not User
292
+ ```
293
+
294
+ ### Transformation Implementation
295
+
296
+ ```ruby
297
+ # In Results base class
298
+ def transform_results(results)
299
+ return results unless transform?
300
+
301
+ if results.is_a?(Enumerable)
302
+ results.map.with_index { |item, i| @transformer.call(item, i) }
303
+ else
304
+ @transformer.call(results)
305
+ end
306
+ end
307
+
308
+ # Method missing handles transformation
309
+ def method_missing(method, *args, **kwargs, &block)
310
+ if block
311
+ @configured_query.send(method, *args, **kwargs) do |*block_args|
312
+ x = block_args.first
313
+ transformed = transform? ? @transformer.call(x) : x
314
+ block.call(transformed, *(block_args[1..] || []))
315
+ end
316
+ else
317
+ raw = @configured_query.send(method, *args, **kwargs)
318
+ transform_results(raw)
319
+ end
320
+ end
321
+ ```
322
+
323
+ ### Special Case: group_by
324
+
325
+ ```ruby
326
+ # group_by has special handling to transform both keys and values
327
+ query = UsersQuery.new.transform { |u| UserPresenter.new(u) }
328
+
329
+ grouped = query.results.group_by(&:role)
330
+ # Returns: { "admin" => [UserPresenter, ...], "user" => [UserPresenter, ...] }
331
+
332
+ # Custom grouping
333
+ grouped = query.results.group_by { |presenter| presenter.created_at.year }
334
+ # Groups presenters by year
335
+ ```
336
+
337
+ ## Pagination Patterns
338
+
339
+ ### API Pagination
340
+
341
+ ```ruby
342
+ class UsersController < ApplicationController
343
+ def index
344
+ query = UsersQuery.new(
345
+ page: params[:page]&.to_i || 1,
346
+ page_size: params[:per_page]&.to_i || 25
347
+ )
348
+
349
+ results = query.results
350
+
351
+ render json: {
352
+ users: results.to_a,
353
+ pagination: {
354
+ current_page: query.page,
355
+ per_page: query.page_size,
356
+ total_count: results.total_count,
357
+ total_pages: (results.total_count.to_f / query.page_size).ceil,
358
+ has_next: results.page_count == query.page_size,
359
+ has_previous: query.page > 1
360
+ }
361
+ }
362
+ end
363
+ end
364
+ ```
365
+
366
+ ### Cursor-Based Pagination
367
+
368
+ ```ruby
369
+ class CursorPaginatedQuery < Quo::RelationBackedQuery
370
+ prop :cursor, _Nilable(String)
371
+ prop :limit, Integer, default: -> { 20 }
372
+
373
+ def query
374
+ scope = User.order(:id)
375
+
376
+ if cursor
377
+ decoded_id = Base64.decode64(cursor).to_i
378
+ scope = scope.where("id > ?", decoded_id)
379
+ end
380
+
381
+ scope.limit(limit + 1) # Fetch one extra to check for more
382
+ end
383
+
384
+ def results_with_cursor
385
+ items = results.to_a
386
+ has_more = items.size > limit
387
+ items = items[0...limit] if has_more
388
+
389
+ next_cursor = if has_more && items.any?
390
+ Base64.encode64(items.last.id.to_s).strip
391
+ end
392
+
393
+ {
394
+ data: items,
395
+ next_cursor: next_cursor,
396
+ has_more: has_more
397
+ }
398
+ end
399
+ end
400
+ ```
401
+
402
+ ### Infinite Scroll
403
+
404
+ ```ruby
405
+ class InfiniteScrollQuery < Quo::RelationBackedQuery
406
+ prop :last_id, _Nilable(Integer)
407
+ prop :batch_size, Integer, default: -> { 50 }
408
+
409
+ def query
410
+ scope = Post.order(created_at: :desc)
411
+ scope = scope.where("id < ?", last_id) if last_id
412
+ scope.limit(batch_size)
413
+ end
414
+ end
415
+
416
+ # Frontend makes requests:
417
+ # GET /posts?last_id=123&batch_size=50
418
+ ```
419
+
420
+ ## Performance Considerations
421
+
422
+ ### Count Performance
423
+
424
+ ```ruby
425
+ # For RelationResults - uses SQL COUNT
426
+ results.count # SELECT COUNT(*) FROM users WHERE ...
427
+
428
+ # For CollectionResults - counts in memory
429
+ results.count # Calls .count on the array
430
+
431
+ # Optimization: Pass total_count when converting
432
+ relation_query = UsersQuery.new
433
+ total = relation_query.results.count # Get count via SQL
434
+
435
+ collection_query = relation_query.to_collection(total_count: total)
436
+ collection_query.results.count # Uses cached total, no counting needed
437
+ ```
438
+
439
+ ### Large Result Sets
440
+
441
+ ```ruby
442
+ # Bad: Loads everything into memory
443
+ users = UsersQuery.new.results.to_a # Could be millions!
444
+
445
+ # Good: Process in batches
446
+ query = UsersQuery.new(page: 1, page_size: 1000)
447
+
448
+ loop do
449
+ results = query.results
450
+
451
+ results.each do |user|
452
+ # Process user
453
+ end
454
+
455
+ break if results.page_count < query.page_size
456
+ query = query.next_page_query
457
+ end
458
+
459
+ # Better: Use ActiveRecord's find_each for relations
460
+ UsersQuery.new.unwrap.find_each(batch_size: 1000) do |user|
461
+ # Process user
462
+ end
463
+ ```
464
+
465
+ ### Pagination Edge Cases
466
+
467
+ ```ruby
468
+ # Empty results
469
+ query = UsersQuery.new(page: 999, page_size: 20)
470
+ results = query.results
471
+ results.count # => 0 (total count)
472
+ results.page_count # => 0 (current page)
473
+ results.exists? # => false
474
+
475
+ # Single page
476
+ query = UsersQuery.new(page: 1, page_size: 1000)
477
+ results = query.results # If total users < 1000
478
+ next_page = query.next_page_query.results
479
+ next_page.empty? # => true
480
+
481
+ # No pagination
482
+ query = UsersQuery.new # No page set
483
+ query.paged? # => false
484
+ results = query.results # Returns all results
485
+ ```