Skip to content
/ quo Public

Quo is a query object gem for Rails/ActiveRecord

License

Notifications You must be signed in to change notification settings

stevegeek/quo

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Quo: Query Objects for ActiveRecord & Collections

Quo helps you organize database and collection queries into reusable, composable, and testable objects.

Quick Example

# 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

Core Features

Collections

  • 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

Configurable

  • 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

Composition and Transformation

  • Combine queries using the + operator (alias for compose 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

Fluent API

  • 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)

Query Results

  • 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

Core Concepts

Query objects encapsulate query logic in dedicated classes, making complex queries more manageable and reusable.

Quo provides two main components:

  1. Query Objects - Define and configure queries
  2. Results Objects - Execute queries and provide access to the results

Creating Query Objects

Relation-Backed Queries

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"

Collection-Backed Queries

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

Quick Queries with Wrap and to_collection

Creating Query Objects with Wrap

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]

Converting Between Query Types

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.

Type-Safe Properties

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)

Pagination

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

Composing Queries

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) or ActiveUsers + PremiumUsers
  • At the instance level: active_query.compose(premium_query) or active_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

Utility Methods

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'"

Transforming Results

query = UsersByState.new(state: "California")
  .transform { |user| UserPresenter.new(user) }

# Results are automatically transformed
presenters = query.results.to_a # Array of UserPresenter objects

Working with Results 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 relation
  • CollectionResults - For collection-based queries, delegates to the enumerable collection

Fluent API for Building Queries

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 query
  • not - Negate conditions
  • or - Add OR conditions
  • order - Set the order of results
  • reorder - Replace existing order
  • limit - Limit the number of results
  • offset - Set an offset for results
  • includes - Eager load associations
  • preload - Preload associations
  • eager_load - Eager load with LEFT OUTER JOIN
  • joins - Add inner joins
  • left_outer_joins - Add left outer joins
  • group - Group results
  • select - Specify columns to select
  • distinct - Return distinct results

Each method returns a new query instance without modifying the original, ensuring queries are immutable and can be safely composed.

Association Preloading in Collection-Backed Queries

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.

Composing with Joins

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 })

Testing Helpers

Quo provides testing helpers for both Minitest and RSpec to make your query objects easy to test in isolation.

Minitest

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

RSpec

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

Project Organization

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

Installation

Add to your Gemfile:

gem "quo"

Then execute:

$ bundle install

Configuration

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.

Requirements

  • Ruby 3.1+
  • Rails 7.0+, 8.0+

Development

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.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/stevegeek/quo.

Inspired by rectify

This implementation is inspired by the Rectify gem: https://github.com/andypike/rectify. Thanks to Andy Pike for the inspiration.

License

The gem is available as open source under the terms of the MIT License.

About

Quo is a query object gem for Rails/ActiveRecord

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages