quo 1.0.0.beta2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/.devcontainer/Dockerfile +17 -0
  3. data/.devcontainer/compose.yml +10 -0
  4. data/.devcontainer/devcontainer.json +12 -0
  5. data/Appraisals +4 -12
  6. data/CHANGELOG.md +112 -1
  7. data/CLAUDE.md +19 -0
  8. data/Gemfile +7 -1
  9. data/LICENSE.txt +1 -1
  10. data/README.md +496 -203
  11. data/Rakefile +66 -6
  12. data/UPGRADING.md +216 -0
  13. data/badges/coverage_badge_total.svg +35 -0
  14. data/badges/rubycritic_badge_score.svg +35 -0
  15. data/claude-skill/README.md +100 -0
  16. data/claude-skill/SKILL.md +442 -0
  17. data/claude-skill/references/API_REFERENCE.md +462 -0
  18. data/claude-skill/references/COMPOSITION.md +396 -0
  19. data/claude-skill/references/PAGINATION.md +396 -0
  20. data/claude-skill/references/QUERY_TYPES.md +297 -0
  21. data/claude-skill/references/TRANSFORMERS.md +282 -0
  22. data/context/01-core-architecture.md +247 -0
  23. data/context/02-query-types-implementation.md +355 -0
  24. data/context/03-composition-transformation.md +441 -0
  25. data/context/04-pagination-results.md +485 -0
  26. data/context/05-testing-configuration.md +491 -0
  27. data/context/06-advanced-patterns-examples.md +153 -0
  28. data/gemfiles/rails_8.0.gemfile +10 -5
  29. data/gemfiles/rails_8.1.gemfile +20 -0
  30. data/lib/generators/quo/install/USAGE +21 -0
  31. data/lib/generators/quo/install/install_generator.rb +63 -0
  32. data/lib/quo/collection_backed_query.rb +21 -15
  33. data/lib/quo/collection_results.rb +1 -0
  34. data/lib/quo/composed_collection_backed_query.rb +42 -0
  35. data/lib/quo/composed_instance.rb +144 -0
  36. data/lib/quo/composed_query.rb +43 -178
  37. data/lib/quo/composed_relation_backed_query.rb +42 -0
  38. data/lib/quo/composing/base_strategy.rb +22 -0
  39. data/lib/quo/composing/class_strategy.rb +86 -0
  40. data/lib/quo/composing/class_strategy_registry.rb +31 -0
  41. data/lib/quo/composing/query_classes_strategy.rb +38 -0
  42. data/lib/quo/composing.rb +81 -0
  43. data/lib/quo/engine.rb +1 -0
  44. data/lib/quo/minitest/helpers.rb +14 -24
  45. data/lib/quo/preloadable.rb +1 -0
  46. data/lib/quo/query.rb +22 -5
  47. data/lib/quo/relation_backed_query.rb +24 -18
  48. data/lib/quo/relation_backed_query_specification.rb +44 -25
  49. data/lib/quo/relation_results.rb +1 -0
  50. data/lib/quo/results.rb +31 -2
  51. data/lib/quo/rspec/helpers.rb +15 -26
  52. data/lib/quo/testing/collection_backed_fake.rb +1 -0
  53. data/lib/quo/testing/fake_helpers.rb +30 -0
  54. data/lib/quo/testing/relation_backed_fake.rb +1 -0
  55. data/lib/quo/version.rb +1 -1
  56. data/lib/quo/wrapped_collection_backed_query.rb +21 -0
  57. data/lib/quo/wrapped_relation_backed_query.rb +21 -0
  58. data/lib/quo.rb +8 -0
  59. data/quo.png +0 -0
  60. data/sig/generated/quo/collection_backed_query.rbs +10 -4
  61. data/sig/generated/quo/collection_results.rbs +1 -0
  62. data/sig/generated/quo/composed_collection_backed_query.rbs +25 -0
  63. data/sig/generated/quo/composed_instance.rbs +61 -0
  64. data/sig/generated/quo/composed_query.rbs +23 -56
  65. data/sig/generated/quo/composed_relation_backed_query.rbs +25 -0
  66. data/sig/generated/quo/composing/base_strategy.rbs +16 -0
  67. data/sig/generated/quo/composing/class_strategy.rbs +38 -0
  68. data/sig/generated/quo/composing/class_strategy_registry.rbs +16 -0
  69. data/sig/generated/quo/composing/query_classes_strategy.rbs +24 -0
  70. data/sig/generated/quo/composing.rbs +40 -0
  71. data/sig/generated/quo/engine.rbs +1 -0
  72. data/sig/generated/quo/minitest/helpers.rbs +12 -0
  73. data/sig/generated/quo/preloadable.rbs +1 -0
  74. data/sig/generated/quo/query.rbs +15 -4
  75. data/sig/generated/quo/relation_backed_query.rbs +15 -5
  76. data/sig/generated/quo/relation_backed_query_specification.rbs +47 -39
  77. data/sig/generated/quo/relation_results.rbs +1 -0
  78. data/sig/generated/quo/results.rbs +11 -0
  79. data/sig/generated/quo/rspec/helpers.rbs +12 -0
  80. data/sig/generated/quo/testing/collection_backed_fake.rbs +1 -0
  81. data/sig/generated/quo/testing/fake_helpers.rbs +14 -0
  82. data/sig/generated/quo/testing/relation_backed_fake.rbs +1 -0
  83. data/sig/generated/quo/wrapped_collection_backed_query.rbs +13 -0
  84. data/sig/generated/quo/wrapped_relation_backed_query.rbs +13 -0
  85. data/sig/generated/quo.rbs +1 -0
  86. data/website/.gitignore +6 -0
  87. data/website/.nojekyll +0 -0
  88. data/website/404.html +26 -0
  89. data/website/Gemfile +24 -0
  90. data/website/_config.yml +50 -0
  91. data/website/_data/navigation.yml +8 -0
  92. data/website/_data/sidebar.yml +2 -0
  93. data/website/_data/social_links.yml +3 -0
  94. data/website/_docs/api.md +261 -0
  95. data/website/_docs/get-started.md +289 -0
  96. data/website/assets/quo.png +0 -0
  97. data/website/index.md +141 -0
  98. metadata +70 -13
  99. data/gemfiles/rails_7.0.gemfile +0 -15
  100. data/gemfiles/rails_7.1.gemfile +0 -15
  101. data/gemfiles/rails_7.2.gemfile +0 -15
@@ -0,0 +1,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.