Quo helps you organize database and collection queries into reusable, composable, and testable objects.
# Define query objects to encapsulate query logic
class RecentPostsQuery < Quo::RelationBackedQuery
# Type-safe properties with defaults
prop :days_ago, Integer, default: -> { 7 }
def query
Post.where(Post.arel_table[:created_at].gt(days_ago.days.ago))
.order(created_at: :desc)
end
end
# Use queries with pagination
posts_query = RecentPostsQuery.new(days_ago: 30, page: 1, page_size: 10)
page1 = posts_query.results
# => Returns first 10 posts from the last 30 days
# Navigate between pages
page2_query = posts_query.next_page_query
page2 = page2_query.results
# => Returns next 10 posts
class CommentNotSpamQuery < Quo::RelationBackedQuery
prop :spam_score_threshold, _Float(0..1.0)
def query
comments = Comment.arel_table
Comment.where(
comments[:spam_score].eq(nil).or(comments[:spam_score].lt(spam_score_threshold))
)
end
end
# Get recent posts (last 10 days) which have comments that are not Spam
posts_last_10_days = RecentPostsQuery.new(days_ago: 10).joins(:comments)
# Compose your queries
query = posts_last_10_days + CommentNotSpamQuery.new(spam_score_threshold: 0.5)
# Transform results
transformed_query = query.transform { |post| PostPresenter.new(post) }
# Work with result sets
transformed_query.results.each do |presenter|
puts presenter.formatted_title
end
- Query objects can wrap either an ActiveRecord relation (
RelationBackedQuery
) or any Enumerable collection (CollectionBackedQuery
) - Built-in pagination that works with both database queries and enumerable collections
- Flexible interface for creating custom queries or wrapping existing queries
- Type-safe properties with optional default values using the Literal gem
- Each query is (kinda) "immutable" - operations return new query instances, mutation is actively frowned upon
- Configure your own base classes, default page sizes, and more
- Combine queries using the
+
operator (alias forcompose
method) - Mix and match relation-backed and collection-backed queries
- Join queries with explicit join conditions using the
joins
parameter - Transform results consistently using the
transform
method
- Chain methods that mirror ActiveRecord's query interface (where, order, limit, etc.)
- Access utility methods that work on both relation and collection queries (exists?, empty?, etc.)
- Navigation helpers for pagination (next_page_query, previous_page_query)
- Clear separation between query definition and execution with
Results
objects - Automatic application of transformations across all result methods
- Consistent interface regardless of the underlying query type
- Support for common methods: each, map, first/last, count, exists?, group_by, and more
Query objects encapsulate query logic in dedicated classes, making complex queries more manageable and reusable.
Quo provides two main components:
- Query Objects - Define and configure queries
- Results Objects - Execute queries and provide access to the results
For queries based on ActiveRecord relations:
class RecentActiveUsers < Quo::RelationBackedQuery
# Define typed properties
prop :days_ago, Integer, default: -> { 30 }
def query
User
.where(active: true)
.where("created_at > ?", days_ago.days.ago)
end
end
# Create and use the query
query = RecentActiveUsers.new(days_ago: 7)
results = query.results
# Work with results
results.each { |user| puts user.email }
puts "Found #{results.count} users"
For queries based on any Enumerable collection:
class CachedUsers < Quo::CollectionBackedQuery
prop :role, String
def collection
@cached_users ||= Rails.cache.fetch("all_users", expires_in: 1.hour) do
User.all.to_a
end.select { |user| user.role == role }
end
end
# Use the query
admins = CachedUsers.new(role: "admin").results
Create query objects on the fly without subclassing using the wrap
class method:
# Relation-backed query from an ActiveRecord relation
users_query = Quo::RelationBackedQuery.wrap(User.active).new
active_users = users_query.results
# Relation-backed query with a block
posts_query = Quo::RelationBackedQuery.wrap(props: {tag: String}) do
Post.where(published: true).where("title LIKE ?", "%#{tag}%")
end
tagged_posts = posts_query.new(tag: "ruby").results
# Collection-backed query from an array
items_query = Quo::CollectionBackedQuery.wrap([1, 2, 3]).new
items = items_query.results
# Collection-backed query with properties and a block
filtered_query = Quo::CollectionBackedQuery.wrap(props: {min: Integer}) do
[1, 2, 3, 4, 5].select { |n| n >= min }
end
result = filtered_query.new(min: 3).results # [3, 4, 5]
Convert a relation-backed query to a collection-backed query using to_collection
:
# Start with a relation-backed query
relation_query = UsersByState.new(state: "California")
# Convert to a collection-backed query (executes the query)
collection_query = relation_query.to_collection
collection_query.collection? # => true
collection_query.relation? # => false
# You can optionally specify a total count (useful for pagination)
collection_query = relation_query.to_collection(total_count: 100)
This is useful when you want to convert an ActiveRecord relation to an enumerable collection while preserving the query interface.
Quo uses the Literal
gem for typed properties:
class UsersByState < Quo::RelationBackedQuery
prop :state, String
prop :minimum_age, Integer, default: -> { 18 }
prop :active_only, Boolean, default: -> { true }
def query
scope = User.where(state: state)
scope = scope.where("age >= ?", minimum_age) if minimum_age.present?
scope = scope.where(active: true) if active_only
scope
end
end
query = UsersByState.new(state: "California", minimum_age: 21)
query = UsersByState.new(
state: "California",
page: 2,
page_size: 20
)
# Get paginated results for page 2 with 20 items
users = query.results
# Navigation to next and previous pages creates new queries
next_page = query.next_page_query
prev_page = query.previous_page_query
Quo provides extensive query composition capabilities, letting you combine multiple query objects:
class ActiveUsers < Quo::RelationBackedQuery
def query
User.where(active: true)
end
end
class PremiumUsers < Quo::RelationBackedQuery
def query
User.where(subscription_tier: "premium")
end
end
# Compose queries using the + operator
active_premium = ActiveUsers.new + PremiumUsers.new
users = active_premium.results
You can compose queries in several ways:
- At the class level:
ActiveUsers.compose(PremiumUsers)
orActiveUsers + PremiumUsers
- At the instance level:
active_query.compose(premium_query)
oractive_query + premium_query
- With joins:
active_query.compose(premium_query, joins: :some_association)
Quo handles different composition scenarios automatically:
- Relation + Relation: Uses ActiveRecord's merge capabilities
- Relation + Collection: Combines the results of both
- Collection + Collection: Concatenates the collections
For example, to compose query objects with proper joins:
# Query for posts
class PostsQuery < Quo::RelationBackedQuery
def query
Post.where(published: true)
end
end
# Query for authors
class AuthorsQuery < Quo::RelationBackedQuery
def query
Author.where(active: true)
end
end
# Compose with a joins parameter to specify the relationship
composed_query = PostsQuery.new.compose(AuthorsQuery.new, joins: :author)
# You can also use this equivalent form:
# composed_query = PostsQuery.new.joins(:author) + AuthorsQuery.new
# Returns published posts by active authors
results = composed_query.results
Quo query objects provide several utility methods to help you work with them:
query = UsersByState.new(state: "California")
# Check query type
query.relation? # => true if backed by an ActiveRecord relation
query.collection? # => true if backed by a collection
# Check pagination status
query.paged? # => true if pagination is enabled (page is set)
# Check transformation status
query.transform? # => true if a transformer is set
# Get the raw underlying query without pagination
raw_query = query.unwrap_unpaginated # => The ActiveRecord relation or collection
# Get the configured query with pagination
configured_query = query.unwrap # => The query with pagination applied
# For RelationBackedQuery, get SQL representation
puts query.to_sql # => "SELECT users.* FROM users WHERE users.state = 'California'"
query = UsersByState.new(state: "California")
.transform { |user| UserPresenter.new(user) }
# Results are automatically transformed
presenters = query.results.to_a # Array of UserPresenter objects
When you call .results
on a query object, you get a Results
object that wraps the underlying collection and ensures consistent application of transformations.
# Create a query with a transformer
users_query = UsersByState.new(state: "California")
.transform { |user| UserPresenter.new(user) }
# Get results - transformations are applied consistently
results = users_query.results
# Existence checks
results.exists? # => true/false
results.empty? # => false/true
# Count methods
results.count # Total count of results (ignoring pagination)
results.total_count # Same as count
results.size # Same as count
results.page_count # Count of items on current page (respects pagination)
results.page_size # Same as page_count
# Enumerable methods - all respect transformations
results.each { |presenter| puts presenter.formatted_name }
results.map { |presenter| presenter.email }
results.select { |presenter| presenter.active? }
results.reject { |presenter| presenter.inactive? }
results.first # Returns the first transformed item
results.last # Returns the last transformed item
results.first(3) # Returns the first 3 transformed items
results.to_a # Returns all transformed items as an array
# ActiveRecord extensions (for RelationResults)
results.find(123) # Find by id and transform
results.find_by(email: "[email protected]") # Find by attributes and transform
results.where(active: true) # Returns a new Results with the condition applied
# Methods are delegated to the underlying collection
# and transformations are applied consistently
results.group_by(&:role) # Groups transformed objects by role
Quo provides two types of Results objects:
RelationResults
- For ActiveRecord-based queries, delegates to the underlying relationCollectionResults
- For collection-based queries, delegates to the enumerable collection
Quo implements a fluent API that mirrors ActiveRecord's query interface, allowing you to chain methods that build up your query:
# Start with a base query
query = UsersByState.new(state: "California")
# Chain method calls to build your query
refined_query = query
.order(created_at: :desc) # Order results
.includes(:profile, :posts) # Eager load associations
.joins(:posts) # Join with posts
.where(verified: true) # Add conditions
.limit(10) # Limit results
.group("users.role") # Group results
# Original query remains unchanged
original_results = query.results
refined_results = refined_query.results
# You can further refine as needed
admin_query = refined_query.where(role: "admin")
Available methods for relation-backed queries include:
where
- Add conditions to the querynot
- Negate conditionsor
- Add OR conditionsorder
- Set the order of resultsreorder
- Replace existing orderlimit
- Limit the number of resultsoffset
- Set an offset for resultsincludes
- Eager load associationspreload
- Preload associationseager_load
- Eager load with LEFT OUTER JOINjoins
- Add inner joinsleft_outer_joins
- Add left outer joinsgroup
- Group resultsselect
- Specify columns to selectdistinct
- Return distinct results
Each method returns a new query instance without modifying the original, ensuring queries are immutable and can be safely composed.
When working with enumerable collections of ActiveRecord models, you can still preload associations to avoid N+1 queries. This is particularly useful when you have collections that don't come directly from the database but still need efficient association loading.
Include the Quo::Preloadable
module in your collection-backed query and use the includes
or preload
methods:
class FirstAndLastUsers < Quo::CollectionBackedQuery
include Quo::Preloadable
def collection
[User.first, User.last] # These users come from separate queries
end
end
# Preload the profiles and posts for both users in a single efficient query
query = FirstAndLastUsers.new.includes(:profile, :posts)
# Check that the association is loaded
query.results.first.profile.loaded? # => true
query.results.last.posts.loaded? # => true
# Access the preloaded associations without triggering additional queries
query.results.each do |user|
puts "#{user.name} has #{user.posts.size} posts"
end
The Preloadable
module overrides the query
method to apply ActiveRecord's preloader to your collection.
class ProductsQuery < Quo::RelationBackedQuery
def query
Product.where(active: true)
end
end
class CategoriesQuery < Quo::RelationBackedQuery
def query
Category.where(featured: true)
end
end
# Compose with a join
products = ProductsQuery.new.compose(CategoriesQuery.new, joins: :category)
# Equivalent to:
# Product.joins(:category)
# .where(products: { active: true })
# .where(categories: { featured: true })
Quo provides testing helpers for both Minitest and RSpec to make your query objects easy to test in isolation.
The Quo::Minitest::Helpers
module includes the fake_query
method that lets you mock query results without hitting the database:
class UserQueryTest < ActiveSupport::TestCase
include Quo::Minitest::Helpers
test "filters users by state" do
# Create test data
users = [User.new(name: "Alice"), User.new(name: "Bob")]
# Mock the query results within the block
fake_query(UsersByState, results: users) do
# Any instance of UsersByState created inside this block
# will return the mocked results regardless of query parameters
result = UsersByState.new(state: "California").results.to_a
assert_equal users, result
# You can create multiple instances with different parameters
other_result = UsersByState.new(state: "New York").results.to_a
assert_equal users, other_result
end
# After the block, normal behavior resumes
end
test "works with pagination" do
users = (1..10).map { |i| User.new(name: "User #{i}") }
fake_query(UsersByState, results: users) do
# Pagination still works with fake query results
paginated = UsersByState.new(state: "California", page: 1, page_size: 5).results
assert_equal 5, paginated.page_count
assert_equal 10, paginated.total_count
end
end
end
The same functionality is available for RSpec through the Quo::RSpec::Helpers
module:
RSpec.describe UsersByState do
include Quo::RSpec::Helpers
it "filters users by state" do
users = [User.new(name: "Alice"), User.new(name: "Bob")]
fake_query(UsersByState, results: users) do
result = UsersByState.new(state: "California").results.to_a
expect(result).to eq(users)
# Test that transformations still work
transformed = UsersByState.new(state: "California")
.transform { |user| user.name.upcase }
.results
expect(transformed.first).to eq("ALICE")
end
end
it "can be nested for testing composed queries" do
users = [User.new(name: "Alice", active: true)]
premium_users = [User.new(name: "Bob", subscription: "premium")]
# Nested fake_query calls for testing composition
fake_query(ActiveUsers, results: users) do
fake_query(PremiumUsers, results: premium_users) do
composed = ActiveUsers.new + PremiumUsers.new
expect(composed.results.count).to eq(2)
end
end
end
end
Suggested directory structure:
app/
queries/
application_query.rb
users/
active_users_query.rb
by_state_query.rb
products/
featured_products_query.rb
Base classes:
# app/queries/application_query.rb
class ApplicationQuery < Quo::RelationBackedQuery
# Common functionality
end
# app/queries/application_collection_query.rb
class ApplicationCollectionQuery < Quo::CollectionBackedQuery
# Common functionality
end
Add to your Gemfile:
gem "quo"
Then execute:
$ bundle install
Quo provides several configuration options to customize its behavior to your needs. Configure these in an initializer:
# config/initializers/quo.rb
module Quo
# Set the default number of items per page (default: 20)
self.default_page_size = 25
# Set the maximum allowed page size to prevent excessive resource usage (default: 200)
self.max_page_size = 100
# Set custom base classes for your queries
# These must be string names of constantizable classes that inherit from
# Quo::RelationBackedQuery and Quo::CollectionBackedQuery respectively
self.relation_backed_query_base_class = "ApplicationQuery"
self.collection_backed_query_base_class = "ApplicationCollectionQuery"
end
Using custom base classes lets you add functionality that's shared across all your query objects in your application.
- Ruby 3.1+
- Rails 7.0+, 8.0+
After checking out the repo, run bin/setup
to install dependencies. Then, run rake test
to run the tests. You can also run bin/console
for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install
. To release a new version, update the version number in version.rb
, and then run bundle exec rake release
, which will create a git tag for the version, push git commits and the created tag, and push the .gem
file to rubygems.org.
Bug reports and pull requests are welcome on GitHub at https://github.com/stevegeek/quo.
This implementation is inspired by the Rectify
gem: https://github.com/andypike/rectify. Thanks to Andy Pike for the inspiration.
The gem is available as open source under the terms of the MIT License.