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.
- 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/README.md +379 -75
- 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,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
|