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.
- checksums.yaml +4 -4
- data/.devcontainer/Dockerfile +17 -0
- data/.devcontainer/compose.yml +10 -0
- data/.devcontainer/devcontainer.json +12 -0
- data/Appraisals +4 -12
- data/CHANGELOG.md +112 -1
- data/CLAUDE.md +19 -0
- data/Gemfile +7 -1
- data/LICENSE.txt +1 -1
- data/README.md +496 -203
- data/Rakefile +66 -6
- data/UPGRADING.md +216 -0
- data/badges/coverage_badge_total.svg +35 -0
- data/badges/rubycritic_badge_score.svg +35 -0
- data/claude-skill/README.md +100 -0
- data/claude-skill/SKILL.md +442 -0
- data/claude-skill/references/API_REFERENCE.md +462 -0
- data/claude-skill/references/COMPOSITION.md +396 -0
- data/claude-skill/references/PAGINATION.md +396 -0
- data/claude-skill/references/QUERY_TYPES.md +297 -0
- data/claude-skill/references/TRANSFORMERS.md +282 -0
- data/context/01-core-architecture.md +247 -0
- data/context/02-query-types-implementation.md +355 -0
- data/context/03-composition-transformation.md +441 -0
- data/context/04-pagination-results.md +485 -0
- data/context/05-testing-configuration.md +491 -0
- data/context/06-advanced-patterns-examples.md +153 -0
- data/gemfiles/rails_8.0.gemfile +10 -5
- data/gemfiles/rails_8.1.gemfile +20 -0
- data/lib/generators/quo/install/USAGE +21 -0
- data/lib/generators/quo/install/install_generator.rb +63 -0
- data/lib/quo/collection_backed_query.rb +21 -15
- data/lib/quo/collection_results.rb +1 -0
- data/lib/quo/composed_collection_backed_query.rb +42 -0
- data/lib/quo/composed_instance.rb +144 -0
- data/lib/quo/composed_query.rb +43 -178
- data/lib/quo/composed_relation_backed_query.rb +42 -0
- data/lib/quo/composing/base_strategy.rb +22 -0
- data/lib/quo/composing/class_strategy.rb +86 -0
- data/lib/quo/composing/class_strategy_registry.rb +31 -0
- data/lib/quo/composing/query_classes_strategy.rb +38 -0
- data/lib/quo/composing.rb +81 -0
- data/lib/quo/engine.rb +1 -0
- data/lib/quo/minitest/helpers.rb +14 -24
- data/lib/quo/preloadable.rb +1 -0
- data/lib/quo/query.rb +22 -5
- data/lib/quo/relation_backed_query.rb +24 -18
- data/lib/quo/relation_backed_query_specification.rb +44 -25
- data/lib/quo/relation_results.rb +1 -0
- data/lib/quo/results.rb +31 -2
- data/lib/quo/rspec/helpers.rb +15 -26
- data/lib/quo/testing/collection_backed_fake.rb +1 -0
- data/lib/quo/testing/fake_helpers.rb +30 -0
- data/lib/quo/testing/relation_backed_fake.rb +1 -0
- data/lib/quo/version.rb +1 -1
- data/lib/quo/wrapped_collection_backed_query.rb +21 -0
- data/lib/quo/wrapped_relation_backed_query.rb +21 -0
- data/lib/quo.rb +8 -0
- data/quo.png +0 -0
- data/sig/generated/quo/collection_backed_query.rbs +10 -4
- data/sig/generated/quo/collection_results.rbs +1 -0
- data/sig/generated/quo/composed_collection_backed_query.rbs +25 -0
- data/sig/generated/quo/composed_instance.rbs +61 -0
- data/sig/generated/quo/composed_query.rbs +23 -56
- data/sig/generated/quo/composed_relation_backed_query.rbs +25 -0
- data/sig/generated/quo/composing/base_strategy.rbs +16 -0
- data/sig/generated/quo/composing/class_strategy.rbs +38 -0
- data/sig/generated/quo/composing/class_strategy_registry.rbs +16 -0
- data/sig/generated/quo/composing/query_classes_strategy.rbs +24 -0
- data/sig/generated/quo/composing.rbs +40 -0
- data/sig/generated/quo/engine.rbs +1 -0
- data/sig/generated/quo/minitest/helpers.rbs +12 -0
- data/sig/generated/quo/preloadable.rbs +1 -0
- data/sig/generated/quo/query.rbs +15 -4
- data/sig/generated/quo/relation_backed_query.rbs +15 -5
- data/sig/generated/quo/relation_backed_query_specification.rbs +47 -39
- data/sig/generated/quo/relation_results.rbs +1 -0
- data/sig/generated/quo/results.rbs +11 -0
- data/sig/generated/quo/rspec/helpers.rbs +12 -0
- data/sig/generated/quo/testing/collection_backed_fake.rbs +1 -0
- data/sig/generated/quo/testing/fake_helpers.rbs +14 -0
- data/sig/generated/quo/testing/relation_backed_fake.rbs +1 -0
- data/sig/generated/quo/wrapped_collection_backed_query.rbs +13 -0
- data/sig/generated/quo/wrapped_relation_backed_query.rbs +13 -0
- data/sig/generated/quo.rbs +1 -0
- data/website/.gitignore +6 -0
- data/website/.nojekyll +0 -0
- data/website/404.html +26 -0
- data/website/Gemfile +24 -0
- data/website/_config.yml +50 -0
- data/website/_data/navigation.yml +8 -0
- data/website/_data/sidebar.yml +2 -0
- data/website/_data/social_links.yml +3 -0
- data/website/_docs/api.md +261 -0
- data/website/_docs/get-started.md +289 -0
- data/website/assets/quo.png +0 -0
- data/website/index.md +141 -0
- metadata +70 -13
- data/gemfiles/rails_7.0.gemfile +0 -15
- data/gemfiles/rails_7.1.gemfile +0 -15
- 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
|