ActiveQuery is a Ruby gem that helps you create clean, reusable query objects with a simple DSL. It provides type validation, conditional logic, and seamless ActiveRecord integration.
- Clean Query DSL: Define queries with clear syntax and descriptions
- Type Safety: Built-in argument type validation (String, Integer, Float, Boolean)
- Optional & Default Arguments: Flexible argument handling
- Custom Operations: Extended query operations like
gt,lt,like, etc. - Conditional Logic: Apply scopes conditionally with
ifandunless - Resolver Pattern: Support for complex query logic in separate classes
- Custom Scopes: Define reusable scopes within query objects
- ActiveRecord Integration: Works seamlessly with ActiveRecord models
Add this line to your application's Gemfile:
gem 'active-query'And then execute:
bundle installCreate a query object by including ActiveQuery::Base and defining queries with the query method:
class UserQuery
include ActiveQuery::Base
# The model is automatically inferred from the class name (User)
# Or explicitly set it:
model_name 'User'
# Simple query without arguments
query :active, 'Returns all active users', -> { scope.where(active: true) }
# Query with arguments and type validation
query :by_email, 'Find users by email address',
{ email: { type: String } },
-> (email:) { scope.where(email: email) }
end# Execute queries
active_users = UserQuery.active
user = UserQuery.by_email(email: 'john@example.com')
# Chain with ActiveRecord methods
recent_active_users = UserQuery.active.where('created_at > ?', 1.week.ago)ActiveQuery supports several argument types with automatic validation:
class ProductQuery
include ActiveQuery::Base
query :filter_products, 'Filter products by various criteria',
{
name: { type: String },
price: { type: Float },
quantity: { type: Integer },
available: { type: Boolean }
},
-> (name:, price:, quantity:, available:) {
scope.where(name: name)
.where(price: price)
.where(quantity: quantity)
.where(available: available)
}
end
# Usage with type validation
ProductQuery.filter_products(
name: 'Widget',
price: 19.99,
quantity: 10,
available: true
)
# This will raise ArgumentError due to type mismatch
ProductQuery.filter_products(name: 123, price: 'invalid', quantity: true, available: 'yes')class OrderQuery
include ActiveQuery::Base
query :search_orders, 'Search orders with optional filters',
{
status: { type: String },
paid: { type: Boolean, default: true },
customer_name: { type: String, optional: true }
},
-> (status:, paid:, customer_name:) {
scope.where(status: status)
.where(paid: paid)
.if(customer_name, -> { where('customer_name LIKE ?', "%#{customer_name}%") })
}
end
# Usage - customer_name is optional, paid defaults to true
OrderQuery.search_orders(status: 'pending')
OrderQuery.search_orders(status: 'completed', customer_name: 'John')
OrderQuery.search_orders(status: 'pending', paid: false, customer_name: 'Jane')Use if and unless methods for conditional query building:
class UserQuery
include ActiveQuery::Base
query :search_users, 'Search users with conditional filters',
{
name: { type: String, optional: true },
active: { type: Boolean, optional: true }
},
-> (name:, active:) {
scope.if(name.present?, -> { where('name LIKE ?', "%#{name}%") })
.if(active == true, -> { where(active: true) })
}
query :filter_unless_admin, 'Filter users unless they are admin',
{
role: { type: String, optional: true }
},
-> (role:) {
scope.unless(role == 'admin', -> { where.not(role: 'admin') })
}
endActiveQuery provides additional query operations beyond standard ActiveRecord:
class ProductQuery
include ActiveQuery::Base
# Comparison operations
query :expensive_products, 'Products above price threshold', -> { scope.gt(:price, 100) }
query :affordable_products, 'Products within budget', -> { scope.lteq(:price, 50) }
# Text search operations
query :search_by_name, 'Search products by name pattern', -> { scope.like(:name, 'Phone') }
query :products_starting_with, 'Products starting with prefix', -> { scope.start_like(:name, 'iPhone') }
query :products_ending_with, 'Products ending with suffix', -> { scope.end_like(:name, 'Pro') }
# Dynamic filtering
query :by_price_range, 'Filter by price range',
{ min_price: { type: Float }, max_price: { type: Float } },
-> (min_price:, max_price:) {
scope.gteq(:price, min_price)
.lteq(:price, max_price)
}
endAvailable operations:
gt(column, value)- greater thangteq(column, value)- greater than or equallt(column, value)- less thanlteq(column, value)- less than or equallike(column, value)- contains pattern (wraps with %)start_like(column, value)- starts with patternend_like(column, value)- ends with pattern
Define reusable scopes within your query objects:
class UserQuery
include ActiveQuery::Base
include ActiveQuery::Scopes
# Define custom scopes
module Scopes
include ActiveQuery::Scopes
scope :recent, -> { where('created_at > ?', 1.month.ago) }
scope :by_role, -> (role:) { where(role: role) }
end
# Use scopes in queries
query :recent_admins, 'Find recent admin users', -> { scope.recent.by_role(role: 'admin') }
query :count_recent, 'Count recent users', -> { scope.recent.count }
endFor complex query logic, use the resolver pattern to keep your query objects clean:
# Define a resolver
class UserStatsResolver < ActiveQuery::Resolver
def resolve(min_orders: 5)
scope.joins(:orders)
.group('users.id')
.having('COUNT(orders.id) >= ?', min_orders)
.select('users.*, COUNT(orders.id) as order_count')
end
end
# Use resolver in query object
class UserQuery
include ActiveQuery::Base
# Resolver without arguments
query :high_value_users, 'Users with many orders',
resolver: UserStatsResolver
# Resolver with arguments
query :users_with_orders, 'Users with minimum order count',
{ min_orders: { type: Integer } },
resolver: UserStatsResolver
end
# Usage
UserQuery.high_value_users
UserQuery.users_with_orders(min_orders: 10)Query objects provide metadata about available queries:
class UserQuery
include ActiveQuery::Base
query :active, 'Find active users', -> { scope.where(active: true) }
query :by_name, 'Find by name', { name: { type: String } }, -> (name:) { scope.where(name: name) }
end
# Get all available queries
UserQuery.queries
# => [
# { name: :active, description: "Find active users", args_def: {} },
# { name: :by_name, description: "Find by name", args_def: { name: { type: String } } }
# ]ActiveQuery provides clear error messages for common mistakes:
# Missing required arguments
UserQuery.by_email
# => ArgumentError: Params missing: [:email]
# Wrong argument type
UserQuery.by_email(email: 123)
# => ArgumentError: :email must be of type String
# Unknown arguments
UserQuery.by_email(email: 'test@example.com', invalid_param: 'value')
# => ArgumentError: Unknown params: [:invalid_param]
# Optional and default together (validation error)
query :invalid_query, 'This will fail',
{ param: { type: String, optional: true, default: 'value' } },
-> (param:) { scope }
# => ArgumentError: Optional and Default params can't be present together: [:param]ActiveQuery works seamlessly with Rails applications:
# app/queries/user_query.rb
class UserQuery
include ActiveQuery::Base
query :active, 'Active users', -> { scope.where(active: true) }
query :by_role, 'Users by role', { role: { type: String } }, -> (role:) { scope.where(role: role) }
end
# In controllers
class UsersController < ApplicationController
def index
@users = UserQuery.active
end
def admins
@admins = UserQuery.by_role(role: 'admin')
end
end
# In views or anywhere else
<%= UserQuery.active.count %> active users- Ruby >= 3.2.0
- ActiveRecord >= 6.1, < 9.0
- ActiveSupport >= 6.1, < 9.0
ActiveQuery fully supports Rails 8.0! Note that Rails 8 requires SQLite3 version 2.1 or higher:
# For Rails 8 applications
gem 'sqlite3', '~> 2.1'
# For Rails 7 and below
gem 'sqlite3', '~> 1.4'See RAILS_8_MIGRATION.md for detailed migration instructions.
After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt.
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.
This gem is tested against Rails 7.0, 7.1, and 8.0 to ensure compatibility across versions.
To test against all supported Rails versions locally:
# Test against all Rails versions
./bin/test_all_rails
# Test against a specific Rails version
BUNDLE_GEMFILE=gemfiles/rails_7_0.gemfile bundle exec rake spec
BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle exec rake spec
BUNDLE_GEMFILE=gemfiles/rails_8_0.gemfile bundle exec rake specIf you prefer using the appraisal gem:
# Install appraisal gemfiles
bundle exec appraisal install
# Run tests against all Rails versions
bundle exec appraisal rake spec
# Run tests against specific Rails version
bundle exec appraisal rails-7-0 rake spec
bundle exec appraisal rails-7-1 rake spec
bundle exec appraisal rails-8-0 rake specThe gem uses GitHub Actions to automatically test against multiple Ruby and Rails versions in a matrix configuration. Each push and pull request triggers tests across:
- Ruby versions: 3.2, 3.3
- Rails versions: 7.0, 7.1, 8.0
- Appropriate SQLite3 versions for each Rails version (1.4 for Rails 7.x, 2.1+ for Rails 8.0)
See .github/workflows/main.yml for the complete CI configuration.
Bug reports and pull requests are welcome on GitHub at https://github.com/matiasasis/active-query. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.
The gem is available as open source under the terms of the MIT License.
Everyone interacting in the ActiveQuery project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.