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,282 @@
1
+ # Result Transformers Reference
2
+
3
+ > **Targets Quo `~> 2.0`.**
4
+
5
+ Transformers apply a function to each row as it comes out of a query.
6
+ Common uses: wrapping rows in presenter/serializer objects, converting
7
+ to DTOs, or applying view-specific logic without modifying the
8
+ underlying query.
9
+
10
+ ## Attaching a transformer
11
+
12
+ `#transform` takes a block and returns a new query that wraps each row
13
+ through the block.
14
+
15
+ ```ruby
16
+ class CommentsByAuthorQuery < Quo::RelationBackedQuery
17
+ prop :author_id, Integer
18
+ def query
19
+ Comment.joins(:post).where(posts: {author_id: author_id})
20
+ end
21
+ end
22
+
23
+ query = CommentsByAuthorQuery.new(author_id: 1)
24
+ .transform { |comment| CommentPresenter.new(comment) }
25
+
26
+ query.results.each do |presenter|
27
+ puts presenter.formatted_body
28
+ end
29
+ ```
30
+
31
+ ### Inspecting
32
+
33
+ ```ruby
34
+ query = CommentsByAuthorQuery.new(author_id: 1)
35
+ query.transform? # => false
36
+
37
+ with_transform = query.transform { |c| CommentPresenter.new(c) }
38
+ with_transform.transform? # => true
39
+ ```
40
+
41
+ ## Patterns
42
+
43
+ ### Presenter wrapping
44
+
45
+ A typical view-layer transformer:
46
+
47
+ ```ruby
48
+ class CommentPresenter
49
+ def initialize(comment, viewer: nil)
50
+ @comment = comment
51
+ @viewer = viewer
52
+ end
53
+
54
+ def formatted_body
55
+ @comment.body.gsub(/\b(http\S+)/, '<a href="\1">\1</a>').html_safe
56
+ end
57
+
58
+ def status_badge
59
+ @comment.read? ? "✓ read" : "● unread"
60
+ end
61
+ end
62
+
63
+ query = CommentsByAuthorQuery.new(author_id: current_user.id)
64
+ .transform { |c| CommentPresenter.new(c, viewer: current_user) }
65
+
66
+ query.results.each do |presenter|
67
+ puts "#{presenter.status_badge} #{presenter.formatted_body}"
68
+ end
69
+ ```
70
+
71
+ ### Serialization for API responses
72
+
73
+ ```ruby
74
+ class CommentSerializer
75
+ def initialize(comment, include_post: false)
76
+ @comment = comment
77
+ @include_post = include_post
78
+ end
79
+
80
+ def as_json
81
+ base = {id: @comment.id, body: @comment.body, read: @comment.read}
82
+ base[:post] = {id: @comment.post.id, title: @comment.post.title} if @include_post
83
+ base
84
+ end
85
+ end
86
+
87
+ query = CommentsByAuthorQuery.new(author_id: params[:author_id])
88
+ .transform { |c| CommentSerializer.new(c, include_post: true) }
89
+
90
+ render json: {data: query.results.map(&:as_json)}
91
+ ```
92
+
93
+ ### DTO conversion
94
+
95
+ ```ruby
96
+ CommentDTO = Data.define(:id, :body, :read_at)
97
+
98
+ query = CommentsByAuthorQuery.new(author_id: 1)
99
+ .transform { |c| CommentDTO.new(id: c.id, body: c.body, read_at: c.updated_at) }
100
+
101
+ query.results.to_a # => [CommentDTO, CommentDTO, ...]
102
+ ```
103
+
104
+ ### Capturing context in the transformer block
105
+
106
+ The block closes over its surrounding scope, so context flows in
107
+ naturally:
108
+
109
+ ```ruby
110
+ viewer = current_user
111
+ locale = I18n.locale
112
+
113
+ query = CommentsByAuthorQuery.new(author_id: 1)
114
+ .transform { |c| CommentPresenter.new(c, viewer: viewer, locale: locale) }
115
+ ```
116
+
117
+ This works because the block is captured at `transform` time and applied
118
+ later when results are read.
119
+
120
+ ## Transformers + pagination
121
+
122
+ Transformers operate on the materialised page; counts are unaffected.
123
+
124
+ ```ruby
125
+ query = CommentsByAuthorQuery.new(author_id: 1, page: 1, page_size: 25)
126
+ .transform { |c| CommentPresenter.new(c) }
127
+
128
+ results = query.results
129
+ results.first # => CommentPresenter
130
+ results.count # => total comments (unwrapped count)
131
+ results.page_count # => 25 presenters in current page
132
+ ```
133
+
134
+ Page navigation preserves the transformer:
135
+
136
+ ```ruby
137
+ next_query = query.next_page_query
138
+ next_query.transform? # => true
139
+ next_query.results.first # => CommentPresenter
140
+ ```
141
+
142
+ ## Transformers + composition
143
+
144
+ ### Compose, then transform
145
+
146
+ The natural order: build the query first, transform last.
147
+
148
+ ```ruby
149
+ base = AllCommentsQuery.new
150
+ filter = UnreadCommentsQuery.new
151
+ (base + filter).transform { |c| CommentPresenter.new(c) }.results
152
+ ```
153
+
154
+ ### Transform, then compose
155
+
156
+ The transformer carries through composition.
157
+
158
+ ```ruby
159
+ transformed = AllCommentsQuery.new.transform { |c| CommentPresenter.new(c) }
160
+ filtered = transformed + UnreadCommentsQuery.new
161
+ filtered.results.first # => CommentPresenter
162
+ ```
163
+
164
+ ### Multiple `transform` calls
165
+
166
+ The most recent one wins. Quo doesn't pipeline transformers — each
167
+ `#transform` replaces any prior one.
168
+
169
+ ```ruby
170
+ query = AllCommentsQuery.new
171
+ .transform { |c| CommentPresenter.new(c) }
172
+ .transform { |c| CommentSerializer.new(c) }
173
+
174
+ query.results.first # => CommentSerializer (the last transformer)
175
+ ```
176
+
177
+ If you want a pipeline, compose the work inside a single block:
178
+
179
+ ```ruby
180
+ query = AllCommentsQuery.new.transform { |c|
181
+ CommentSerializer.new(CommentPresenter.new(c))
182
+ }
183
+ ```
184
+
185
+ ## Method delegation
186
+
187
+ `Quo::Results` delegates `Enumerable` methods to the underlying
188
+ collection, applying the transformer on each yielded row.
189
+
190
+ ```ruby
191
+ results = CommentsByAuthorQuery.new(author_id: 1)
192
+ .transform { |c| CommentPresenter.new(c) }
193
+ .results
194
+
195
+ results.map(&:formatted_body)
196
+ results.select { |p| p.status_badge.include?("unread") }
197
+ results.group_by(&:status_badge)
198
+ results.first
199
+ ```
200
+
201
+ These all return transformed rows. `#count`, `#page_count`, `#empty?`,
202
+ and `#exists?` work on the underlying data — they aren't transformed.
203
+
204
+ ## Performance considerations
205
+
206
+ ### Lazy
207
+
208
+ Transformers run when results are read, not when `#transform` is called.
209
+
210
+ ```ruby
211
+ query = AllCommentsQuery.new.transform { |c|
212
+ puts "transforming #{c.id}"
213
+ CommentPresenter.new(c)
214
+ }
215
+ # Nothing prints yet.
216
+
217
+ query.results.each { |p| ... }
218
+ # Now "transforming N" prints for each row.
219
+ ```
220
+
221
+ ### Keep transformers cheap
222
+
223
+ A transformer that does heavy work per row will dominate query time.
224
+ Prefer a lightweight wrapper that defers expensive work to method
225
+ calls.
226
+
227
+ ```ruby
228
+ # Heavy — runs for every row, even if you only need .id
229
+ .transform { |c| HeavyPresenter.new(c).tap(&:precompute_everything!) }
230
+
231
+ # Light — defers work until needed
232
+ .transform { |c| LazyPresenter.new(c) }
233
+ ```
234
+
235
+ ### Memoize inside the wrapper
236
+
237
+ If a presenter computes something expensive per call, memoize:
238
+
239
+ ```ruby
240
+ class CommentPresenter
241
+ def initialize(comment); @comment = comment; end
242
+
243
+ def author_display
244
+ @author_display ||= @comment.post.author.name.titleize
245
+ end
246
+ end
247
+ ```
248
+
249
+ ## Testing transformers
250
+
251
+ ```ruby
252
+ class CommentsByAuthorQueryTransformerTest < ActiveSupport::TestCase
253
+ setup do
254
+ @author = Author.create!(name: "Ada")
255
+ @post = Post.create!(title: "Hi", author: @author)
256
+ Comment.create!(post: @post, body: "first")
257
+ end
258
+
259
+ test "wraps each row in a presenter" do
260
+ query = CommentsByAuthorQuery.new(author_id: @author.id)
261
+ .transform { |c| CommentPresenter.new(c) }
262
+
263
+ assert_instance_of CommentPresenter, query.results.first
264
+ end
265
+
266
+ test "transformer applies to all enumerable methods" do
267
+ query = CommentsByAuthorQuery.new(author_id: @author.id)
268
+ .transform { |c| CommentPresenter.new(c) }
269
+
270
+ bodies = query.results.map(&:formatted_body)
271
+ assert_equal 1, bodies.size
272
+ end
273
+
274
+ test "#transform? reports true after transform" do
275
+ query = CommentsByAuthorQuery.new(author_id: @author.id)
276
+ refute query.transform?
277
+
278
+ transformed = query.transform { |c| CommentPresenter.new(c) }
279
+ assert transformed.transform?
280
+ end
281
+ end
282
+ ```
@@ -0,0 +1,247 @@
1
+ # Quo Core Architecture
2
+
3
+ This document provides a technical deep-dive into Quo's architecture, explaining how the gem is structured and how its components work together to provide composable query objects.
4
+
5
+ ## Foundation: Literal Framework
6
+
7
+ Quo is built on top of the [Literal](https://github.com/joeldrapper/literal) gem, leveraging its type-safe property system. Every query object inherits from `Literal::Struct`, providing:
8
+
9
+ - Type-checked properties with defaults
10
+ - Immutable struct-like behavior
11
+ - Built-in validation and coercion
12
+
13
+ ## Core Class Hierarchy
14
+
15
+ ```
16
+ Literal::Struct
17
+ └── Quo::Query (abstract base)
18
+ ├── Quo::RelationBackedQuery
19
+ │ └── Application-specific query classes
20
+ └── Quo::CollectionBackedQuery
21
+ └── Application-specific query classes
22
+ ```
23
+
24
+ ### Quo::Query
25
+
26
+ The abstract base class that defines the core interface for all query objects:
27
+
28
+ ```ruby
29
+ class Query < Literal::Struct
30
+ include Literal::Types
31
+
32
+ # Core properties for pagination
33
+ prop :page, _Nilable(Integer), &COERCE_TO_INT
34
+ prop :page_size, _Nilable(Integer), default: -> { Quo.default_page_size || 20 }, &COERCE_TO_INT
35
+
36
+ # Abstract methods that subclasses must implement
37
+ def query
38
+ raise NotImplementedError
39
+ end
40
+
41
+ # Key instance methods
42
+ def copy(**overrides)
43
+ def merge(right, joins: nil) # aliased as +
44
+ def transform(&block)
45
+ def results
46
+ end
47
+ ```
48
+
49
+ Key responsibilities:
50
+ - Defines pagination interface (page, page_size)
51
+ - Provides composition capabilities via `merge`/`+`
52
+ - Supports result transformation via `transform`
53
+ - Enforces contract through abstract methods
54
+
55
+ ### Quo::RelationBackedQuery
56
+
57
+ Specializes Query for ActiveRecord relations:
58
+
59
+ ```ruby
60
+ class RelationBackedQuery < Query
61
+ prop :_specification, _Nilable(Quo::RelationBackedQuerySpecification)
62
+
63
+ def query
64
+ # Returns ActiveRecord::Relation
65
+ end
66
+
67
+ def results
68
+ Quo::RelationResults.new(self, transformer: transformer)
69
+ end
70
+
71
+ # Fluent API via method_missing
72
+ def method_missing(method_name, *args, **kwargs, &block)
73
+ # Delegates to RelationBackedQuerySpecification
74
+ end
75
+ end
76
+ ```
77
+
78
+ Key features:
79
+ - Wraps ActiveRecord relations
80
+ - Uses `RelationBackedQuerySpecification` to store query options
81
+ - Provides fluent API matching ActiveRecord's interface
82
+ - Returns `RelationResults` for execution
83
+
84
+ ### Quo::CollectionBackedQuery
85
+
86
+ Specializes Query for enumerable collections:
87
+
88
+ ```ruby
89
+ class CollectionBackedQuery < Query
90
+ prop :total_count, _Nilable(Integer), reader: false
91
+
92
+ def collection
93
+ # Returns Enumerable
94
+ end
95
+
96
+ def query
97
+ collection # Default implementation
98
+ end
99
+
100
+ def results
101
+ Quo::CollectionResults.new(self, transformer: transformer, total_count: @total_count)
102
+ end
103
+ end
104
+ ```
105
+
106
+ Key features:
107
+ - Wraps any Enumerable collection
108
+ - Supports explicit total_count for pagination
109
+ - Can include `Quo::Preloadable` for association preloading
110
+ - Returns `CollectionResults` for execution
111
+
112
+ ## Query Specification Pattern
113
+
114
+ The `RelationBackedQuerySpecification` class implements the Specification pattern to encapsulate query-building logic:
115
+
116
+ ```ruby
117
+ class RelationBackedQuerySpecification
118
+ attr_reader :options
119
+
120
+ def initialize(options = {})
121
+ @options = options
122
+ end
123
+
124
+ def merge(new_options)
125
+ self.class.new(options.merge(new_options))
126
+ end
127
+
128
+ def apply_to(relation)
129
+ # Applies all stored options to the relation
130
+ rel = relation
131
+ rel = rel.where(options[:where]) if options[:where]
132
+ rel = rel.order(options[:order]) if options[:order]
133
+ # ... etc
134
+ rel
135
+ end
136
+
137
+ # Fluent methods that return new specifications
138
+ def where(conditions)
139
+ merge(where: conditions)
140
+ end
141
+
142
+ def order(order_clause)
143
+ merge(order: order_clause)
144
+ end
145
+ # ... etc
146
+ end
147
+ ```
148
+
149
+ This separation allows:
150
+ - Immutable query building
151
+ - Deferred execution
152
+ - Easy composition of query options
153
+ - Testability without database access
154
+
155
+ ## Results Architecture
156
+
157
+ The Results classes provide a consistent interface for working with query results:
158
+
159
+ ```
160
+ Quo::Results (abstract)
161
+ ├── Quo::RelationResults
162
+ └── Quo::CollectionResults
163
+ ```
164
+
165
+ ### Results Base Class
166
+
167
+ ```ruby
168
+ class Results
169
+ def initialize(query, transformer: nil, **options)
170
+ @query = query
171
+ @transformer = transformer
172
+ @configured_query = query.unwrap
173
+ end
174
+
175
+ # Core counting methods
176
+ def count # Total count ignoring pagination
177
+ def page_count # Count on current page
178
+ def exists?
179
+ def empty?
180
+
181
+ # Enumerable methods with transformation
182
+ def each(&block)
183
+ def map(&block)
184
+ def first
185
+ def last
186
+
187
+ # Delegation with transformation support
188
+ def method_missing(method, *args, **kwargs, &block)
189
+ # Applies transformer when delegating to underlying collection
190
+ end
191
+ end
192
+ ```
193
+
194
+ ### RelationResults
195
+
196
+ Specializes Results for ActiveRecord relations:
197
+ - Delegates to the underlying ActiveRecord::Relation
198
+ - Provides ActiveRecord-specific methods (find, find_by, where)
199
+ - Handles count efficiently via SQL
200
+ - Supports chaining (returns new Results objects)
201
+
202
+ ### CollectionResults
203
+
204
+ Specializes Results for enumerable collections:
205
+ - Delegates to the underlying Enumerable
206
+ - Handles pagination via array slicing
207
+ - Supports explicit total_count
208
+ - Works with any Ruby collection
209
+
210
+ ## Configuration System
211
+
212
+ Quo provides module-level configuration via `mattr_accessor`:
213
+
214
+ ```ruby
215
+ module Quo
216
+ mattr_accessor :relation_backed_query_base_class, default: "Quo::RelationBackedQuery"
217
+ mattr_accessor :collection_backed_query_base_class, default: "Quo::CollectionBackedQuery"
218
+ mattr_accessor :max_page_size, default: 200
219
+ mattr_accessor :default_page_size, default: 20
220
+ end
221
+ ```
222
+
223
+ This allows applications to:
224
+ - Define custom base classes with shared behavior
225
+ - Set application-wide pagination defaults
226
+ - Enforce maximum page sizes for security
227
+
228
+ ## Autoloading Strategy
229
+
230
+ Quo uses Rails' autoloading for lazy loading of components
231
+
232
+ ## Engine Integration
233
+
234
+ For Rails applications, Quo provides an Engine:
235
+
236
+ ```ruby
237
+ module Quo
238
+ class Engine < ::Rails::Engine
239
+ isolate_namespace Quo
240
+ end
241
+ end
242
+ ```
243
+
244
+ This enables:
245
+ - Proper Rails integration
246
+ - Rake task loading
247
+ - Future extensibility for Rails-specific features