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,261 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: API Reference
|
|
3
|
+
nav_order: 3
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# API Reference
|
|
7
|
+
|
|
8
|
+
## Query Object Classes
|
|
9
|
+
|
|
10
|
+
### Quo::RelationBackedQuery
|
|
11
|
+
|
|
12
|
+
Base class for queries that work with ActiveRecord relations.
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
class MyQuery < Quo::RelationBackedQuery
|
|
16
|
+
prop :some_filter, String
|
|
17
|
+
|
|
18
|
+
def query
|
|
19
|
+
MyModel.where(column: some_filter)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
**Key Methods:**
|
|
25
|
+
- `query` - Must be implemented to return an ActiveRecord relation
|
|
26
|
+
- `results` - Returns a Results object with the query results
|
|
27
|
+
- `unwrap` - Returns the underlying relation with pagination applied
|
|
28
|
+
- `unwrap_unpaginated` - Returns the underlying relation without pagination
|
|
29
|
+
- `to_sql` - Returns the SQL representation of the query
|
|
30
|
+
|
|
31
|
+
### Quo::CollectionBackedQuery
|
|
32
|
+
|
|
33
|
+
Base class for queries that work with any Enumerable collection.
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
class MyCollectionQuery < Quo::CollectionBackedQuery
|
|
37
|
+
prop :filter_value, String
|
|
38
|
+
|
|
39
|
+
def collection
|
|
40
|
+
[1, 2, 3, 4, 5].select { |n| n > filter_value.to_i }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**Key Methods:**
|
|
46
|
+
- `collection` - Must be implemented to return an Enumerable
|
|
47
|
+
- `results` - Returns a Results object with the collection results
|
|
48
|
+
- `unwrap` - Returns the underlying collection with pagination applied
|
|
49
|
+
- `unwrap_unpaginated` - Returns the underlying collection without pagination
|
|
50
|
+
|
|
51
|
+
## Common Query Methods
|
|
52
|
+
|
|
53
|
+
Both query types support these methods:
|
|
54
|
+
|
|
55
|
+
### Property Definition
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
prop :property_name, Type, default: -> { default_value }
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Define typed properties using [Literal types](https://github.com/joeldrapper/literal). Common examples:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
# Basic types
|
|
65
|
+
prop :name, String
|
|
66
|
+
prop :age, Integer
|
|
67
|
+
prop :active, _Boolean # Use _Boolean for true/false
|
|
68
|
+
|
|
69
|
+
# Optional types
|
|
70
|
+
prop :email, String | nil
|
|
71
|
+
|
|
72
|
+
# Collections
|
|
73
|
+
prop :tags, _Array(String)
|
|
74
|
+
prop :metadata, _Hash(Symbol, String)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Pagination
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
MyQuery.new(page: 1, page_size: 20)
|
|
81
|
+
query.next_page_query
|
|
82
|
+
query.previous_page_query
|
|
83
|
+
query.paged? # => true/false
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Composition
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
# Instance-level: Merge query instances
|
|
90
|
+
query1 + query2 # Returns a new query instance
|
|
91
|
+
query1.merge(query2) # Returns a new query instance
|
|
92
|
+
query1.merge(query2, joins: :association) # Returns a new query instance
|
|
93
|
+
|
|
94
|
+
# Class-level: Compose query classes
|
|
95
|
+
ComposedQuery = Query1 + Query2 # Returns a new query CLASS
|
|
96
|
+
ComposedQuery = Query1.compose(Query2) # Returns a new query CLASS
|
|
97
|
+
ComposedQuery.new # Create an instance of composed class
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Transformation
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
# Transform results with a block
|
|
104
|
+
query.transform { |item| ItemPresenter.new(item) }
|
|
105
|
+
query.transform { |item, index| ItemPresenter.new(item, position: index) }
|
|
106
|
+
query.transform? # => true/false
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Fluent API (RelationBackedQuery)
|
|
110
|
+
|
|
111
|
+
Chain ActiveRecord methods to build complex queries:
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
# Filtering, ordering, and associations
|
|
115
|
+
query.where(column: value)
|
|
116
|
+
query.select(:column1, :column2)
|
|
117
|
+
query.order(created_at: :desc)
|
|
118
|
+
query.reorder(updated_at: :asc)
|
|
119
|
+
query.joins(:association)
|
|
120
|
+
query.left_outer_joins(:association)
|
|
121
|
+
query.includes(:profile, :posts) # Alias for preload (not ActiveRecord's includes)
|
|
122
|
+
query.preload(:comments)
|
|
123
|
+
query.eager_load(:tags)
|
|
124
|
+
|
|
125
|
+
# Limiting and grouping
|
|
126
|
+
query.limit(10).offset(5)
|
|
127
|
+
query.group(:category).distinct
|
|
128
|
+
|
|
129
|
+
# Advanced
|
|
130
|
+
query.extending(MyQueryExtension)
|
|
131
|
+
query.unscope(:order, :limit)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Query Helpers
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
# Check query type
|
|
138
|
+
query.relation? # => true if backed by ActiveRecord relation
|
|
139
|
+
query.collection? # => true if backed by a collection
|
|
140
|
+
|
|
141
|
+
# Create a query class from a relation or collection
|
|
142
|
+
# IMPORTANT: wrap returns a new query CLASS, not an instance!
|
|
143
|
+
MyQuery = Quo::RelationBackedQuery.wrap(User.active)
|
|
144
|
+
instance = MyQuery.new # Must call .new to create an instance
|
|
145
|
+
|
|
146
|
+
# With dynamic properties - still returns a CLASS
|
|
147
|
+
MyQuery = Quo::RelationBackedQuery.wrap(props: {role: String}) { User.where(role: role) }
|
|
148
|
+
instance = MyQuery.new(role: "admin") # Must call .new with props
|
|
149
|
+
|
|
150
|
+
# Convert a RelationBackedQuery to CollectionBackedQuery (executes the query)
|
|
151
|
+
collection_query = query.to_collection
|
|
152
|
+
collection_query = query.to_collection(total_count: 100) # Optional total count
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Results Objects
|
|
156
|
+
|
|
157
|
+
### RelationResults
|
|
158
|
+
|
|
159
|
+
Returned by `RelationBackedQuery#results`.
|
|
160
|
+
|
|
161
|
+
**Methods:**
|
|
162
|
+
- `each`, `map`, `select`, `reject` - Enumerable methods with transformation support
|
|
163
|
+
- `first`, `last`, `first(n)`, `last(n)` - Access specific items
|
|
164
|
+
- `count`, `total_count`, `size` - Total count of ALL results (ignores pagination)
|
|
165
|
+
- `page_count`, `page_size` - Count of items on CURRENT page only
|
|
166
|
+
- `exists?`, `empty?` - Existence checks
|
|
167
|
+
- `to_a` - Convert to array
|
|
168
|
+
- `find(id)`, `find_by(attributes)` - ActiveRecord finder methods
|
|
169
|
+
|
|
170
|
+
**Note:** Transformations are applied to all items returned by result methods.
|
|
171
|
+
|
|
172
|
+
### CollectionResults
|
|
173
|
+
|
|
174
|
+
Returned by `CollectionBackedQuery#results`.
|
|
175
|
+
|
|
176
|
+
**Methods:**
|
|
177
|
+
- Same as RelationResults, except ActiveRecord-specific methods like `find` and `find_by`
|
|
178
|
+
|
|
179
|
+
## Configuration
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
module Quo
|
|
183
|
+
# Set custom base classes
|
|
184
|
+
self.relation_backed_query_base_class = "ApplicationQuery"
|
|
185
|
+
self.collection_backed_query_base_class = "ApplicationCollectionQuery"
|
|
186
|
+
|
|
187
|
+
# Configure pagination defaults
|
|
188
|
+
self.default_page_size = 25 # Default: 20
|
|
189
|
+
self.max_page_size = 100 # Default: 200
|
|
190
|
+
end
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Preloading Associations (CollectionBackedQuery)
|
|
194
|
+
|
|
195
|
+
Preload associations for CollectionBackedQuery objects containing ActiveRecord models:
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
class FirstAndLastPosts < Quo::CollectionBackedQuery
|
|
199
|
+
include Quo::Preloadable
|
|
200
|
+
|
|
201
|
+
def collection
|
|
202
|
+
[Post.first, Post.last]
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Preload associations to avoid N+1 queries
|
|
207
|
+
query = FirstAndLastPosts.new.includes(:author, :comments)
|
|
208
|
+
|
|
209
|
+
# Access preloaded data without additional queries
|
|
210
|
+
query.results.each do |post|
|
|
211
|
+
puts "#{post.title} by #{post.author.name} (#{post.comments.count} comments)"
|
|
212
|
+
end
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
**Note:** Quo's `includes` is an alias for `preload` (not ActiveRecord's `includes` which uses eager loading). Both preload associations without joining.
|
|
216
|
+
|
|
217
|
+
## Testing Helpers
|
|
218
|
+
|
|
219
|
+
### Minitest
|
|
220
|
+
|
|
221
|
+
```ruby
|
|
222
|
+
include Quo::Minitest::Helpers
|
|
223
|
+
|
|
224
|
+
# Basic usage
|
|
225
|
+
fake_query(MyQuery, results: [item1, item2]) do
|
|
226
|
+
result = MyQuery.new.results.to_a
|
|
227
|
+
assert_equal [item1, item2], result
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# With pagination metadata
|
|
231
|
+
fake_query(MyQuery, results: [item1, item2], total_count: 100, page_count: 2) do
|
|
232
|
+
results = MyQuery.new.results
|
|
233
|
+
assert_equal 100, results.total_count # Total across all pages
|
|
234
|
+
assert_equal 2, results.page_count # Items on current page
|
|
235
|
+
end
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### RSpec
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
include Quo::Rspec::Helpers
|
|
242
|
+
|
|
243
|
+
# Basic usage
|
|
244
|
+
fake_query(MyQuery, results: [item1, item2]) do
|
|
245
|
+
result = MyQuery.new.results.to_a
|
|
246
|
+
expect(result).to eq([item1, item2])
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# With specific argument expectations
|
|
250
|
+
fake_query(MyQuery, with: {role: "admin"}, results: [admin1, admin2]) do
|
|
251
|
+
result = MyQuery.new(role: "admin").results.to_a
|
|
252
|
+
expect(result).to eq([admin1, admin2])
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# With pagination metadata
|
|
256
|
+
fake_query(MyQuery, results: [item1, item2], total_count: 100, page_count: 2) do
|
|
257
|
+
results = MyQuery.new.results
|
|
258
|
+
expect(results.total_count).to eq(100) # Total across all pages
|
|
259
|
+
expect(results.page_count).to eq(2) # Items on current page
|
|
260
|
+
end
|
|
261
|
+
```
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Core API
|
|
3
|
+
nav_order: 2
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Quo Core API
|
|
7
|
+
|
|
8
|
+
This section covers the core functionality of Quo, including:
|
|
9
|
+
|
|
10
|
+
- Creating query objects
|
|
11
|
+
- Working with relations and collections
|
|
12
|
+
- Type-safe properties
|
|
13
|
+
- Pagination
|
|
14
|
+
- Query composition
|
|
15
|
+
- Result transformation
|
|
16
|
+
- Fluent API methods
|
|
17
|
+
|
|
18
|
+
# Configuration
|
|
19
|
+
|
|
20
|
+
Quo provides several configuration options to customize its behavior.
|
|
21
|
+
|
|
22
|
+
Create an initializer to configure Quo:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
# config/initializers/quo.rb
|
|
26
|
+
module Quo
|
|
27
|
+
# Set custom base classes for your queries
|
|
28
|
+
self.relation_backed_query_base_class = "ApplicationQuery"
|
|
29
|
+
self.collection_backed_query_base_class = "ApplicationCollectionQuery"
|
|
30
|
+
|
|
31
|
+
# Configure pagination defaults
|
|
32
|
+
self.default_page_size = 25 # Default: 20
|
|
33
|
+
self.max_page_size = 100 # Default: 200
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
## Custom Base Classes
|
|
39
|
+
|
|
40
|
+
You can define your own base classes for queries to add application-specific functionality:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
# app/queries/application_query.rb
|
|
44
|
+
class ApplicationQuery < Quo::RelationBackedQuery
|
|
45
|
+
# Add common scopes or methods using the fluent API
|
|
46
|
+
|
|
47
|
+
def with_status(status)
|
|
48
|
+
with(where: {status: status})
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# app/queries/application_collection_query.rb
|
|
53
|
+
class ApplicationCollectionQuery < Quo::CollectionBackedQuery
|
|
54
|
+
# Add common collection processing
|
|
55
|
+
end
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Now all your queries can inherit from these custom base classes:
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
class UsersQuery < ApplicationQuery
|
|
62
|
+
def query
|
|
63
|
+
User.all
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Use the custom methods
|
|
68
|
+
UsersQuery.new.with_status("active").results
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Project Organization
|
|
72
|
+
|
|
73
|
+
Suggested directory structure:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
app/
|
|
77
|
+
queries/
|
|
78
|
+
application_query.rb
|
|
79
|
+
application_collection_query.rb
|
|
80
|
+
users/
|
|
81
|
+
active_users_query.rb
|
|
82
|
+
by_role_query.rb
|
|
83
|
+
posts/
|
|
84
|
+
published_posts_query.rb
|
|
85
|
+
recent_posts_query.rb
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
This organization keeps your query objects well-organized and easy to find.
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# Usage Examples
|
|
92
|
+
|
|
93
|
+
This page provides real-world examples of using Quo in your Rails application.
|
|
94
|
+
|
|
95
|
+
## Basic Query Object
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
class ActiveUsersQuery < Quo::RelationBackedQuery
|
|
99
|
+
def query
|
|
100
|
+
User.where(active: true)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Usage
|
|
105
|
+
active_users = ActiveUsersQuery.new.results.to_a
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Query with Properties
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
class UsersByRoleQuery < Quo::RelationBackedQuery
|
|
112
|
+
prop :role, String
|
|
113
|
+
prop :active_only, _Boolean, default: -> { true }
|
|
114
|
+
|
|
115
|
+
def query
|
|
116
|
+
scope = User.where(role: role)
|
|
117
|
+
scope = scope.where(active: true) if active_only
|
|
118
|
+
scope
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Usage
|
|
123
|
+
admins = UsersByRoleQuery.new(role: "admin").results
|
|
124
|
+
all_moderators = UsersByRoleQuery.new(role: "moderator", active_only: false).results
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Pagination
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
class PostsQuery < Quo::RelationBackedQuery
|
|
131
|
+
prop :published_only, _Boolean, default: -> { true }
|
|
132
|
+
|
|
133
|
+
def query
|
|
134
|
+
scope = Post.order(created_at: :desc)
|
|
135
|
+
scope = scope.where(published: true) if published_only
|
|
136
|
+
scope
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Usage with pagination
|
|
141
|
+
page1 = PostsQuery.new(page: 1, page_size: 20).results
|
|
142
|
+
page2 = PostsQuery.new(page: 2, page_size: 20).results
|
|
143
|
+
|
|
144
|
+
# Navigate between pages
|
|
145
|
+
query = PostsQuery.new(page: 1, page_size: 20)
|
|
146
|
+
next_query = query.next_page_query
|
|
147
|
+
prev_query = query.previous_page_query
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Query Composition
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
class PublishedPostsQuery < Quo::RelationBackedQuery
|
|
154
|
+
def query
|
|
155
|
+
Post.where(published: true)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
class FeaturedPostsQuery < Quo::RelationBackedQuery
|
|
160
|
+
def query
|
|
161
|
+
Post.where(featured: true)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Compose queries
|
|
166
|
+
published_and_featured = PublishedPostsQuery.new + FeaturedPostsQuery.new
|
|
167
|
+
results = published_and_featured.results
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Composition with Joins
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
class PostsByAuthorQuery < Quo::RelationBackedQuery
|
|
174
|
+
prop :author_name, String
|
|
175
|
+
|
|
176
|
+
def query
|
|
177
|
+
Author.where("name LIKE ?", "%#{author_name}%")
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
class PublishedPostsQuery < Quo::RelationBackedQuery
|
|
182
|
+
def query
|
|
183
|
+
Post.where(published: true)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Compose with explicit joins
|
|
188
|
+
posts = PublishedPostsQuery.new
|
|
189
|
+
.merge(PostsByAuthorQuery.new(author_name: "John"), joins: :author)
|
|
190
|
+
.results
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Result Transformation
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
class UserPresenter
|
|
197
|
+
attr_reader :user, :position
|
|
198
|
+
|
|
199
|
+
def initialize(user, position: nil)
|
|
200
|
+
@user = user
|
|
201
|
+
@position = position
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def formatted_name
|
|
205
|
+
prefix = position ? "#{position + 1}. " : ""
|
|
206
|
+
"#{prefix}#{user.first_name} #{user.last_name}"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Apply transformation
|
|
211
|
+
query = ActiveUsersQuery.new
|
|
212
|
+
.transform { |user| UserPresenter.new(user) }
|
|
213
|
+
|
|
214
|
+
# With index parameter
|
|
215
|
+
query = ActiveUsersQuery.new
|
|
216
|
+
.transform { |user, index| UserPresenter.new(user, position: index) }
|
|
217
|
+
|
|
218
|
+
query.results.each do |presenter|
|
|
219
|
+
puts presenter.formatted_name
|
|
220
|
+
end
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Collection-Backed Queries
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
class TopRatedItemsQuery < Quo::CollectionBackedQuery
|
|
227
|
+
prop :minimum_rating, _Float(0..5.0), default: -> { 4.0 }
|
|
228
|
+
|
|
229
|
+
def collection
|
|
230
|
+
# Maybe from a cache or external API
|
|
231
|
+
cached_items = Rails.cache.fetch("all_items") { Item.all.to_a }
|
|
232
|
+
cached_items.select { |item| item.rating >= minimum_rating }
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Usage
|
|
237
|
+
top_items = TopRatedItemsQuery.new(minimum_rating: 4.5).results
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## Fluent API Methods
|
|
241
|
+
|
|
242
|
+
For detailed information on the fluent API methods (where, order, includes, limit, etc.), see the [API Reference](/api/).
|
|
243
|
+
|
|
244
|
+
```ruby
|
|
245
|
+
query = PostsQuery.new
|
|
246
|
+
.where(category: "Technology")
|
|
247
|
+
.order(published_at: :desc)
|
|
248
|
+
.includes(:author, :comments)
|
|
249
|
+
.limit(10)
|
|
250
|
+
|
|
251
|
+
results = query.results
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## Testing with Helpers
|
|
255
|
+
|
|
256
|
+
### Minitest
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
class UserQueryTest < ActiveSupport::TestCase
|
|
260
|
+
include Quo::Minitest::Helpers
|
|
261
|
+
|
|
262
|
+
test "returns active users" do
|
|
263
|
+
users = [User.new(name: "Alice"), User.new(name: "Bob")]
|
|
264
|
+
|
|
265
|
+
fake_query(ActiveUsersQuery, results: users) do
|
|
266
|
+
result = ActiveUsersQuery.new.results.to_a
|
|
267
|
+
assert_equal users, result
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### RSpec
|
|
274
|
+
|
|
275
|
+
```ruby
|
|
276
|
+
RSpec.describe ActiveUsersQuery do
|
|
277
|
+
include Quo::Rspec::Helpers
|
|
278
|
+
|
|
279
|
+
it "returns active users" do
|
|
280
|
+
users = [User.new(name: "Alice"), User.new(name: "Bob")]
|
|
281
|
+
|
|
282
|
+
fake_query(ActiveUsersQuery, results: users) do
|
|
283
|
+
result = ActiveUsersQuery.new.results.to_a
|
|
284
|
+
expect(result).to eq(users)
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
```
|
|
289
|
+
|
|
Binary file
|
data/website/index.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
---
|
|
2
|
+
layout: home
|
|
3
|
+
title: Introduction
|
|
4
|
+
permalink: /
|
|
5
|
+
|
|
6
|
+
hero:
|
|
7
|
+
name: Quo
|
|
8
|
+
text: Query Objects for ActiveRecord & Collections
|
|
9
|
+
tagline: Composable, testable, and reusable query objects for Ruby on Rails.
|
|
10
|
+
image:
|
|
11
|
+
src: /assets/quo.png
|
|
12
|
+
alt: Quo
|
|
13
|
+
width: 280
|
|
14
|
+
height: 280
|
|
15
|
+
actions:
|
|
16
|
+
- text: Get Started
|
|
17
|
+
link: /get-started/
|
|
18
|
+
theme: brand
|
|
19
|
+
- text: API Reference
|
|
20
|
+
link: /api/
|
|
21
|
+
theme: alt
|
|
22
|
+
- text: View on GitHub
|
|
23
|
+
link: https://github.com/stevegeek/quo
|
|
24
|
+
theme: alt
|
|
25
|
+
|
|
26
|
+
features:
|
|
27
|
+
- title: Organize complex queries
|
|
28
|
+
details: Encapsulate query logic in dedicated, testable classes with a clean, fluent API.
|
|
29
|
+
- title: Composable
|
|
30
|
+
details: Combine multiple query objects using the `+` operator at both the instance and class level.
|
|
31
|
+
- title: Type-safe
|
|
32
|
+
details: Built on the Literal gem for typed properties with validation.
|
|
33
|
+
- title: Pagination built-in
|
|
34
|
+
details: Automatic pagination for both database and collection queries.
|
|
35
|
+
- title: Flexible
|
|
36
|
+
details: Works with ActiveRecord relations and plain Ruby collections.
|
|
37
|
+
- title: Fluent API
|
|
38
|
+
details: Chain methods just like ActiveRecord.
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## What is Quo?
|
|
42
|
+
|
|
43
|
+
`quo` is a Ruby gem that helps you organize database and collection queries into reusable, composable, and testable objects with a clean, fluent API.
|
|
44
|
+
|
|
45
|
+
## Quick Example
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
# Define query objects to encapsulate query logic
|
|
49
|
+
class RecentPostsQuery < Quo::RelationBackedQuery
|
|
50
|
+
# Type-safe properties with defaults
|
|
51
|
+
prop :days_ago, Integer, default: -> { 7 }
|
|
52
|
+
|
|
53
|
+
def query
|
|
54
|
+
Post.where(Post.arel_table[:created_at].gt(days_ago.days.ago))
|
|
55
|
+
.order(created_at: :desc)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Use queries with pagination
|
|
60
|
+
posts_query = RecentPostsQuery.new(days_ago: 30, page: 1, page_size: 10)
|
|
61
|
+
page1 = posts_query.results
|
|
62
|
+
# => Returns first 10 posts from the last 30 days
|
|
63
|
+
|
|
64
|
+
# Navigate between pages
|
|
65
|
+
page2_query = posts_query.next_page_query
|
|
66
|
+
page2 = page2_query.results
|
|
67
|
+
# => Returns next 10 posts
|
|
68
|
+
|
|
69
|
+
# Compose queries
|
|
70
|
+
class CommentNotSpamQuery < Quo::RelationBackedQuery
|
|
71
|
+
prop :spam_score_threshold, _Float(0..1.0)
|
|
72
|
+
|
|
73
|
+
def query
|
|
74
|
+
comments = Comment.arel_table
|
|
75
|
+
Comment.where(
|
|
76
|
+
comments[:spam_score].eq(nil).or(comments[:spam_score].lt(spam_score_threshold))
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Get recent posts (last 10 days) which have comments that are not spam
|
|
82
|
+
posts_last_10_days = RecentPostsQuery.new(days_ago: 10).joins(:comments)
|
|
83
|
+
query = posts_last_10_days + CommentNotSpamQuery.new(spam_score_threshold: 0.5)
|
|
84
|
+
|
|
85
|
+
# Transform results
|
|
86
|
+
transformed_query = query.transform { |post| PostPresenter.new(post) }
|
|
87
|
+
|
|
88
|
+
# Work with result sets
|
|
89
|
+
transformed_query.results.each do |presenter|
|
|
90
|
+
puts presenter.formatted_title
|
|
91
|
+
end
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Getting Started
|
|
95
|
+
|
|
96
|
+
Explore the documentation to learn more:
|
|
97
|
+
|
|
98
|
+
- [Getting Started Guide](/get-started/) - Configuration, examples, and usage patterns
|
|
99
|
+
- [API Reference](/api/) - Detailed API documentation
|
|
100
|
+
|
|
101
|
+
## Installation
|
|
102
|
+
|
|
103
|
+
Add to your Gemfile:
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
gem "quo"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Then execute:
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
$ bundle install
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Requirements
|
|
116
|
+
|
|
117
|
+
- Ruby 3.2+
|
|
118
|
+
- Rails 7.2+
|
|
119
|
+
|
|
120
|
+
## Contributing
|
|
121
|
+
|
|
122
|
+
Bug reports and pull requests are welcome on GitHub at [https://github.com/stevegeek/quo](https://github.com/stevegeek/quo).
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
|
|
126
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
127
|
+
|
|
128
|
+
## Inspiration
|
|
129
|
+
|
|
130
|
+
This implementation is inspired by the [Rectify](https://github.com/andypike/rectify) gem by Andy Pike. Thanks for the inspiration!
|
|
131
|
+
|
|
132
|
+
**Key differences to Quo:**
|
|
133
|
+
- Much broader scope—bundles forms, presenters, commands, AND queries where Quo focuses only on queries
|
|
134
|
+
- No longer actively maintained
|
|
135
|
+
- Uses Wisper pub/sub pattern for commands; Quo has simpler return values
|
|
136
|
+
- Lacks the type-safety emphasis that Quo provides
|
|
137
|
+
- Query composition with `|` operator directly inspired Quo's composable design
|
|
138
|
+
|
|
139
|
+
**Quo as an alternative?**
|
|
140
|
+
|
|
141
|
+
Quo can be seen as a successor to Rectify's query object concepts.
|