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,491 @@
|
|
|
1
|
+
# Testing and Configuration
|
|
2
|
+
|
|
3
|
+
This document covers testing strategies for Quo query objects and configuration options.
|
|
4
|
+
|
|
5
|
+
## Testing Support
|
|
6
|
+
|
|
7
|
+
Quo provides first-class testing support through helper modules that allow you to mock query results without hitting the database.
|
|
8
|
+
|
|
9
|
+
### Testing Helpers Overview
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
# For Minitest
|
|
13
|
+
module Quo::Minitest::Helpers
|
|
14
|
+
def fake_query(query_class, results: [], total_count: nil, page_count: nil, &block)
|
|
15
|
+
# Mocks query_class.new to return fake results
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# For RSpec
|
|
20
|
+
module Quo::RSpec::Helpers
|
|
21
|
+
def fake_query(query_class, results: [], total_count: nil, page_count: nil, &block)
|
|
22
|
+
# Same interface as Minitest version
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Minitest Testing
|
|
28
|
+
|
|
29
|
+
#### Basic Usage
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
class UserQueryTest < ActiveSupport::TestCase
|
|
33
|
+
include Quo::Minitest::Helpers
|
|
34
|
+
|
|
35
|
+
test "transforms users to presenters" do
|
|
36
|
+
users = [
|
|
37
|
+
User.new(id: 1, name: "Alice"),
|
|
38
|
+
User.new(id: 2, name: "Bob")
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
fake_query(UsersQuery, results: users) do
|
|
42
|
+
query = UsersQuery.new.transform { |u| u.name.upcase }
|
|
43
|
+
results = query.results.to_a
|
|
44
|
+
|
|
45
|
+
assert_equal ["ALICE", "BOB"], results
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
test "applies pagination" do
|
|
50
|
+
users = (1..30).map { |i| User.new(id: i) }
|
|
51
|
+
|
|
52
|
+
fake_query(UsersQuery, results: users) do
|
|
53
|
+
query = UsersQuery.new(page: 2, page_size: 10)
|
|
54
|
+
results = query.results
|
|
55
|
+
|
|
56
|
+
assert_equal 10, results.page_count
|
|
57
|
+
assert_equal 30, results.total_count
|
|
58
|
+
assert_equal 11, results.first.id # Page 2 starts at user 11
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
#### Testing Query Logic
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
class ComplexQueryTest < ActiveSupport::TestCase
|
|
68
|
+
include Quo::Minitest::Helpers
|
|
69
|
+
|
|
70
|
+
test "filters by multiple conditions" do
|
|
71
|
+
# Test the actual query logic
|
|
72
|
+
query = ComplexUsersQuery.new(
|
|
73
|
+
min_age: 21,
|
|
74
|
+
state: "CA",
|
|
75
|
+
active: true
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
sql = query.to_sql
|
|
79
|
+
assert_match /age >= 21/, sql
|
|
80
|
+
assert_match /state = 'CA'/, sql
|
|
81
|
+
assert_match /active = true/, sql
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
test "composes queries correctly" do
|
|
85
|
+
fake_query(ActiveUsersQuery, results: [User.new(id: 1)]) do
|
|
86
|
+
fake_query(PremiumUsersQuery, results: [User.new(id: 2)]) do
|
|
87
|
+
composed = ActiveUsersQuery.new + PremiumUsersQuery.new
|
|
88
|
+
|
|
89
|
+
# The composition strategy determines final results
|
|
90
|
+
results = composed.results.to_a
|
|
91
|
+
assert_equal 2, results.count
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### RSpec Testing
|
|
99
|
+
|
|
100
|
+
#### Basic Usage
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
RSpec.describe UsersQuery do
|
|
104
|
+
include Quo::RSpec::Helpers
|
|
105
|
+
|
|
106
|
+
describe "#results" do
|
|
107
|
+
it "returns transformed users" do
|
|
108
|
+
users = [User.new(name: "Alice"), User.new(name: "Bob")]
|
|
109
|
+
|
|
110
|
+
fake_query(UsersQuery, results: users) do
|
|
111
|
+
query = UsersQuery.new.transform { |u| UserPresenter.new(u) }
|
|
112
|
+
results = query.results.to_a
|
|
113
|
+
|
|
114
|
+
expect(results).to all(be_a(UserPresenter))
|
|
115
|
+
expect(results.map(&:name)).to eq(["Alice", "Bob"])
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
describe "pagination" do
|
|
121
|
+
it "respects page boundaries" do
|
|
122
|
+
users = build_list(:user, 50)
|
|
123
|
+
|
|
124
|
+
fake_query(UsersQuery, results: users) do
|
|
125
|
+
query = UsersQuery.new(page: 3, page_size: 15)
|
|
126
|
+
results = query.results
|
|
127
|
+
|
|
128
|
+
expect(results.page_count).to eq(15)
|
|
129
|
+
expect(results.total_count).to eq(50)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
#### Testing Composition
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
RSpec.describe "Query Composition" do
|
|
140
|
+
include Quo::RSpec::Helpers
|
|
141
|
+
|
|
142
|
+
it "combines results from multiple queries" do
|
|
143
|
+
active_users = [User.new(name: "Active")]
|
|
144
|
+
premium_users = [User.new(name: "Premium")]
|
|
145
|
+
|
|
146
|
+
fake_query(ActiveUsersQuery, results: active_users) do
|
|
147
|
+
fake_query(PremiumUsersQuery, results: premium_users) do
|
|
148
|
+
composed = ActiveUsersQuery.new + PremiumUsersQuery.new
|
|
149
|
+
all_users = composed.results.to_a
|
|
150
|
+
|
|
151
|
+
expect(all_users.map(&:name)).to contain_exactly("Active", "Premium")
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Testing Implementation Details
|
|
159
|
+
|
|
160
|
+
#### How fake_query Works
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
def fake_query(query_class, results: [], total_count: nil, page_count: nil, &block)
|
|
164
|
+
if query_class < Quo::CollectionBackedQuery
|
|
165
|
+
# Creates a fake collection-backed query
|
|
166
|
+
klass = Class.new(Quo::Testing::CollectionBackedFake) do
|
|
167
|
+
# Include Preloadable if original has it
|
|
168
|
+
if query_class < Quo::Preloadable
|
|
169
|
+
include Quo::Preloadable
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
query_class.stub(:new, ->(**kwargs) {
|
|
174
|
+
klass.new(results: results, total_count: total_count, page_count: page_count)
|
|
175
|
+
}) do
|
|
176
|
+
yield
|
|
177
|
+
end
|
|
178
|
+
elsif query_class < Quo::RelationBackedQuery
|
|
179
|
+
# Creates a fake relation-backed query
|
|
180
|
+
query_class.stub(:new, ->(**kwargs) {
|
|
181
|
+
Quo::Testing::RelationBackedFake.new(
|
|
182
|
+
results: results,
|
|
183
|
+
total_count: total_count,
|
|
184
|
+
page_count: page_count
|
|
185
|
+
)
|
|
186
|
+
}) do
|
|
187
|
+
yield
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
#### Testing Fake Classes
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
# CollectionBackedFake
|
|
197
|
+
module Quo::Testing
|
|
198
|
+
class CollectionBackedFake < Quo::CollectionBackedQuery
|
|
199
|
+
prop :results, _Array(_Any), default: -> { [] }
|
|
200
|
+
prop :total_count, _Nilable(Integer)
|
|
201
|
+
prop :page_count, _Nilable(Integer)
|
|
202
|
+
|
|
203
|
+
def collection
|
|
204
|
+
@results
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# RelationBackedFake
|
|
210
|
+
module Quo::Testing
|
|
211
|
+
class RelationBackedFake < Quo::RelationBackedQuery
|
|
212
|
+
prop :results, _Array(_Any), default: -> { [] }
|
|
213
|
+
prop :total_count, _Nilable(Integer)
|
|
214
|
+
prop :page_count, _Nilable(Integer)
|
|
215
|
+
|
|
216
|
+
def query
|
|
217
|
+
# Returns a relation-like object that behaves correctly
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Advanced Testing Patterns
|
|
224
|
+
|
|
225
|
+
#### Testing with Dependencies
|
|
226
|
+
|
|
227
|
+
```ruby
|
|
228
|
+
class ServiceQueryTest < ActiveSupport::TestCase
|
|
229
|
+
include Quo::Minitest::Helpers
|
|
230
|
+
|
|
231
|
+
setup do
|
|
232
|
+
@api_client = mock('api_client')
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
test "fetches data from external service" do
|
|
236
|
+
expected_data = [{ id: 1, name: "Remote User" }]
|
|
237
|
+
@api_client.expects(:fetch_users).returns(expected_data)
|
|
238
|
+
|
|
239
|
+
query = ExternalUsersQuery.new(api_client: @api_client)
|
|
240
|
+
results = query.results.to_a
|
|
241
|
+
|
|
242
|
+
assert_equal expected_data, results
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
#### Testing Error Cases
|
|
248
|
+
|
|
249
|
+
```ruby
|
|
250
|
+
RSpec.describe UsersQuery do
|
|
251
|
+
include Quo::RSpec::Helpers
|
|
252
|
+
|
|
253
|
+
context "with invalid parameters" do
|
|
254
|
+
it "raises ArgumentError for invalid state" do
|
|
255
|
+
expect {
|
|
256
|
+
UsersQuery.new(state: "INVALID")
|
|
257
|
+
}.to raise_error(ArgumentError)
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
context "when no results found" do
|
|
262
|
+
it "returns empty results" do
|
|
263
|
+
fake_query(UsersQuery, results: []) do
|
|
264
|
+
query = UsersQuery.new(state: "XX")
|
|
265
|
+
|
|
266
|
+
expect(query.results).to be_empty
|
|
267
|
+
expect(query.results.exists?).to be false
|
|
268
|
+
expect(query.results.count).to eq 0
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
## Configuration
|
|
276
|
+
|
|
277
|
+
### Global Configuration
|
|
278
|
+
|
|
279
|
+
Configure Quo in an initializer:
|
|
280
|
+
|
|
281
|
+
```ruby
|
|
282
|
+
# config/initializers/quo.rb
|
|
283
|
+
module Quo
|
|
284
|
+
# Pagination defaults
|
|
285
|
+
self.default_page_size = 25
|
|
286
|
+
self.max_page_size = 100
|
|
287
|
+
|
|
288
|
+
# Custom base classes
|
|
289
|
+
self.relation_backed_query_base_class = "ApplicationQuery"
|
|
290
|
+
self.collection_backed_query_base_class = "ApplicationCollectionQuery"
|
|
291
|
+
end
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### Configuration Options Explained
|
|
295
|
+
|
|
296
|
+
#### default_page_size
|
|
297
|
+
|
|
298
|
+
```ruby
|
|
299
|
+
# Sets the default page size when not specified
|
|
300
|
+
Quo.default_page_size = 25
|
|
301
|
+
|
|
302
|
+
# Used in Query base class
|
|
303
|
+
prop :page_size, _Nilable(Integer),
|
|
304
|
+
default: -> { Quo.default_page_size || 20 }
|
|
305
|
+
|
|
306
|
+
# Example impact
|
|
307
|
+
query = UsersQuery.new(page: 1) # No page_size specified
|
|
308
|
+
query.page_size # => 25 (from configuration)
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
#### max_page_size
|
|
312
|
+
|
|
313
|
+
```ruby
|
|
314
|
+
# Prevents excessive resource usage
|
|
315
|
+
Quo.max_page_size = 100
|
|
316
|
+
|
|
317
|
+
# Applied during sanitization
|
|
318
|
+
def sanitised_page_size
|
|
319
|
+
if page_size&.positive?
|
|
320
|
+
given_size = page_size.to_i
|
|
321
|
+
max_page_size = Quo.max_page_size || 200
|
|
322
|
+
given_size > max_page_size ? max_page_size : given_size
|
|
323
|
+
else
|
|
324
|
+
Quo.default_page_size || 20
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Example protection
|
|
329
|
+
query = UsersQuery.new(page_size: 10000) # Excessive!
|
|
330
|
+
query.results # Actually uses page_size of 100
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
#### Custom Base Classes
|
|
334
|
+
|
|
335
|
+
```ruby
|
|
336
|
+
# app/queries/application_query.rb
|
|
337
|
+
class ApplicationQuery < Quo::RelationBackedQuery
|
|
338
|
+
# Shared functionality for all relation queries
|
|
339
|
+
|
|
340
|
+
# Add default scope
|
|
341
|
+
def query
|
|
342
|
+
super.where(tenant_id: Current.tenant_id)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Add logging
|
|
346
|
+
def results
|
|
347
|
+
Rails.logger.info "Executing query: #{self.class.name}"
|
|
348
|
+
super
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# app/queries/application_collection_query.rb
|
|
353
|
+
class ApplicationCollectionQuery < Quo::CollectionBackedQuery
|
|
354
|
+
# Shared functionality for all collection queries
|
|
355
|
+
|
|
356
|
+
include Quo::Preloadable # All collections can preload
|
|
357
|
+
|
|
358
|
+
# Add caching support
|
|
359
|
+
def cached_collection(key, expires_in: 1.hour)
|
|
360
|
+
Rails.cache.fetch(key, expires_in: expires_in) do
|
|
361
|
+
collection
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Configure Quo to use these
|
|
367
|
+
Quo.relation_backed_query_base_class = "ApplicationQuery"
|
|
368
|
+
Quo.collection_backed_query_base_class = "ApplicationCollectionQuery"
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
### Environment-Specific Configuration
|
|
372
|
+
|
|
373
|
+
```ruby
|
|
374
|
+
# config/environments/development.rb
|
|
375
|
+
Quo.max_page_size = 50 # Smaller in development
|
|
376
|
+
|
|
377
|
+
# config/environments/production.rb
|
|
378
|
+
Quo.max_page_size = 200 # Larger in production
|
|
379
|
+
|
|
380
|
+
# config/environments/test.rb
|
|
381
|
+
Quo.default_page_size = 10 # Smaller for faster tests
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
### Dynamic Configuration
|
|
385
|
+
|
|
386
|
+
```ruby
|
|
387
|
+
# Configuration can be changed at runtime
|
|
388
|
+
class AdminController < ApplicationController
|
|
389
|
+
around_action :with_admin_pagination
|
|
390
|
+
|
|
391
|
+
private
|
|
392
|
+
|
|
393
|
+
def with_admin_pagination
|
|
394
|
+
old_max = Quo.max_page_size
|
|
395
|
+
Quo.max_page_size = 500 # Admins can see more
|
|
396
|
+
yield
|
|
397
|
+
ensure
|
|
398
|
+
Quo.max_page_size = old_max
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
## Testing Best Practices
|
|
404
|
+
|
|
405
|
+
### 1. Test Query Logic Separately
|
|
406
|
+
|
|
407
|
+
```ruby
|
|
408
|
+
# Test the query building logic
|
|
409
|
+
test "builds correct query" do
|
|
410
|
+
query = ComplexQuery.new(filters: { active: true, role: "admin" })
|
|
411
|
+
|
|
412
|
+
sql = query.to_sql
|
|
413
|
+
assert_match /active = true/, sql
|
|
414
|
+
assert_match /role = 'admin'/, sql
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Test the results separately with fake_query
|
|
418
|
+
test "transforms results correctly" do
|
|
419
|
+
fake_query(ComplexQuery, results: [User.new]) do
|
|
420
|
+
query = ComplexQuery.new.transform { |u| u.name.upcase }
|
|
421
|
+
assert_equal "ALICE", query.results.first
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
### 2. Use Factories
|
|
427
|
+
|
|
428
|
+
```ruby
|
|
429
|
+
# spec/support/query_helpers.rb
|
|
430
|
+
module QueryHelpers
|
|
431
|
+
def build_fake_users(count: 10, **attributes)
|
|
432
|
+
(1..count).map do |i|
|
|
433
|
+
User.new(id: i, name: "User #{i}", **attributes)
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# In tests
|
|
439
|
+
test "paginates correctly" do
|
|
440
|
+
users = build_fake_users(count: 100)
|
|
441
|
+
|
|
442
|
+
fake_query(UsersQuery, results: users) do
|
|
443
|
+
# Test pagination
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
### 3. Test Edge Cases
|
|
449
|
+
|
|
450
|
+
```ruby
|
|
451
|
+
describe "edge cases" do
|
|
452
|
+
it "handles empty results" do
|
|
453
|
+
fake_query(UsersQuery, results: []) do
|
|
454
|
+
query = UsersQuery.new
|
|
455
|
+
expect(query.results).to be_empty
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
it "handles nil transformer" do
|
|
460
|
+
fake_query(UsersQuery, results: [User.new]) do
|
|
461
|
+
query = UsersQuery.new # No transformer
|
|
462
|
+
expect(query.results.first).to be_a(User)
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
it "handles pagination beyond available results" do
|
|
467
|
+
fake_query(UsersQuery, results: [User.new]) do
|
|
468
|
+
query = UsersQuery.new(page: 999)
|
|
469
|
+
expect(query.results).to be_empty
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
### 4. Integration Tests
|
|
476
|
+
|
|
477
|
+
```ruby
|
|
478
|
+
# Sometimes you want to test against real database
|
|
479
|
+
class IntegrationTest < ActiveSupport::TestCase
|
|
480
|
+
# Don't include Quo::Minitest::Helpers
|
|
481
|
+
|
|
482
|
+
test "actually queries database" do
|
|
483
|
+
user = User.create!(name: "Real User", state: "CA")
|
|
484
|
+
|
|
485
|
+
query = UsersQuery.new(state: "CA")
|
|
486
|
+
results = query.results
|
|
487
|
+
|
|
488
|
+
assert_includes results.to_a, user
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
```
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Advanced Patterns and Examples
|
|
2
|
+
|
|
3
|
+
This document showcases advanced usage patterns and real-world examples of Quo query objects.
|
|
4
|
+
|
|
5
|
+
## Repository Pattern
|
|
6
|
+
|
|
7
|
+
### Basic Repository
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# app/repositories/user_repository.rb
|
|
11
|
+
class UserRepository
|
|
12
|
+
class << self
|
|
13
|
+
def active
|
|
14
|
+
ActiveUsersQuery.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def by_state(state)
|
|
18
|
+
UsersByStateQuery.new(state: state)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def premium
|
|
22
|
+
PremiumUsersQuery.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def with_recent_activity(days: 7)
|
|
26
|
+
RecentlyActiveUsersQuery.new(days_ago: days)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Compose common combinations
|
|
30
|
+
def active_premium_in_state(state)
|
|
31
|
+
active + premium + by_state(state)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Usage in controllers
|
|
37
|
+
class UsersController < ApplicationController
|
|
38
|
+
def index
|
|
39
|
+
@users = UserRepository
|
|
40
|
+
.active_premium_in_state("CA")
|
|
41
|
+
.transform { |u| UserPresenter.new(u) }
|
|
42
|
+
.results
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Repository with Caching
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
class CachedUserRepository
|
|
51
|
+
class << self
|
|
52
|
+
def search(term, cached: true)
|
|
53
|
+
if cached
|
|
54
|
+
CachedSearchQuery.new(search_term: term)
|
|
55
|
+
else
|
|
56
|
+
LiveSearchQuery.new(search_term: term)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
class CachedSearchQuery < Quo::CollectionBackedQuery
|
|
63
|
+
prop :search_term, String
|
|
64
|
+
|
|
65
|
+
def collection
|
|
66
|
+
Rails.cache.fetch("search_#{search_term}", expires_in: 5.minutes) do
|
|
67
|
+
LiveSearchQuery.new(search_term:).results.to_a
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Composition
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
class UserOnboardingService
|
|
77
|
+
def initialize(user)
|
|
78
|
+
@user = user
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def recommended_connections
|
|
82
|
+
# Compose multiple criteria
|
|
83
|
+
query = SimilarUsersQuery.new(interests: @user.interests) +
|
|
84
|
+
NearbyUsersQuery.new(location: @user.location, radius: 50) +
|
|
85
|
+
ActiveInLastWeekQuery.new
|
|
86
|
+
|
|
87
|
+
query.transform { |u| ConnectionRecommendation.new(@user, u) }
|
|
88
|
+
.results
|
|
89
|
+
.select(&:should_recommend?)
|
|
90
|
+
.first(5)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Performance Monitoring
|
|
96
|
+
|
|
97
|
+
### Instrumented Queries
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
class InstrumentedQuery < Quo::RelationBackedQuery
|
|
101
|
+
def results
|
|
102
|
+
ActiveSupport::Notifications.instrument(
|
|
103
|
+
"query.quo",
|
|
104
|
+
query_class: self.class.name,
|
|
105
|
+
properties: to_h
|
|
106
|
+
) do
|
|
107
|
+
super
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Subscribe to notifications
|
|
113
|
+
ActiveSupport::Notifications.subscribe("query.quo") do |*args|
|
|
114
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
115
|
+
|
|
116
|
+
Rails.logger.info(
|
|
117
|
+
"Query: #{event.payload[:query_class]} " \
|
|
118
|
+
"Duration: #{event.duration}ms " \
|
|
119
|
+
"Properties: #{event.payload[:properties]}"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if event.duration > 1000 # Log slow queries
|
|
123
|
+
SlowQueryLogger.log(event)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Query with Explain
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
class AnalyzedQuery < Quo::RelationBackedQuery
|
|
132
|
+
prop :analyze, Boolean, default: -> { Rails.env.development? }
|
|
133
|
+
|
|
134
|
+
def results
|
|
135
|
+
if analyze && relation?
|
|
136
|
+
log_query_plan
|
|
137
|
+
end
|
|
138
|
+
super
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def log_query_plan
|
|
144
|
+
plan = configured_query.explain
|
|
145
|
+
Rails.logger.debug("Query Plan for #{self.class.name}:")
|
|
146
|
+
Rails.logger.debug(plan)
|
|
147
|
+
|
|
148
|
+
if plan.include?("Seq Scan") && configured_query.count > 1000
|
|
149
|
+
Rails.logger.warn("Sequential scan detected on large table!")
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
```
|
data/gemfiles/rails_8.0.gemfile
CHANGED
|
@@ -2,14 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
source "https://rubygems.org"
|
|
4
4
|
|
|
5
|
-
gem "rails", "~> 8.0"
|
|
5
|
+
gem "rails", "~> 8.0.0"
|
|
6
6
|
|
|
7
7
|
group :development, :test do
|
|
8
8
|
gem "sqlite3"
|
|
9
|
-
gem "rake"
|
|
10
|
-
gem "minitest"
|
|
11
|
-
gem "
|
|
12
|
-
gem "
|
|
9
|
+
gem "rake", "~> 13.0"
|
|
10
|
+
gem "minitest", "~> 5.0"
|
|
11
|
+
gem "simplecov", require: false
|
|
12
|
+
gem "standard", require: false
|
|
13
|
+
gem "steep", require: false
|
|
14
|
+
gem "rbs-inline", "~> 0.11.0", require: false
|
|
15
|
+
gem "simplecov-small-badge", require: false
|
|
16
|
+
gem "rubycritic-small-badge", require: false
|
|
17
|
+
gem "repo-small-badge"
|
|
13
18
|
end
|
|
14
19
|
|
|
15
20
|
gemspec path: "../"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "rails", "~> 8.1.0"
|
|
6
|
+
|
|
7
|
+
group :development, :test do
|
|
8
|
+
gem "sqlite3"
|
|
9
|
+
gem "rake", "~> 13.0"
|
|
10
|
+
gem "minitest", "~> 5.0"
|
|
11
|
+
gem "simplecov", require: false
|
|
12
|
+
gem "standard", require: false
|
|
13
|
+
gem "steep", require: false
|
|
14
|
+
gem "rbs-inline", "~> 0.11.0", require: false
|
|
15
|
+
gem "simplecov-small-badge", require: false
|
|
16
|
+
gem "rubycritic-small-badge", require: false
|
|
17
|
+
gem "repo-small-badge"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
gemspec path: "../"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Installs the Quo Claude Code skill into the application's
|
|
3
|
+
.claude/skills/quo/ directory so Claude Code picks it up automatically.
|
|
4
|
+
|
|
5
|
+
Optionally appends a "Quo" section to the project's top-level CLAUDE.md
|
|
6
|
+
pointing Claude at the skill.
|
|
7
|
+
|
|
8
|
+
Example:
|
|
9
|
+
bin/rails generate quo:install
|
|
10
|
+
|
|
11
|
+
Copies the skill into .claude/skills/quo/ in the current working tree.
|
|
12
|
+
|
|
13
|
+
bin/rails generate quo:install --force
|
|
14
|
+
|
|
15
|
+
Re-runs the install, overwriting any existing files. Use this after
|
|
16
|
+
upgrading the Quo gem to refresh the skill content.
|
|
17
|
+
|
|
18
|
+
bin/rails generate quo:install --with-claude-md
|
|
19
|
+
|
|
20
|
+
Also appends a short "Quo" section to CLAUDE.md (creating the file if
|
|
21
|
+
it does not exist). Idempotent — safe to re-run.
|