Query objects for Rails. Encapsulate complex queries with validated parameters and composable filters.
Add to your Gemfile:
gem "scopa"Scopa gives you a structured way to build query objects. You define parameters with types and validations, compose filters that apply conditionally, and get instrumentation out of the box.
class Users::ActiveQuery < Scopa::Base
model User
parameter :role, optional: true
parameter :created_after, :date, optional: true
filter(:active) { |scope| scope.where(active: true) }
filter(:by_role, if: :role) { |scope| scope.where(role: role) }
filter(:recent, if: :created_after) { |scope| scope.where("created_at > ?", created_after) }
end
Users::ActiveQuery.call(role: :admin)
# => User.where(active: true).where(role: :admin)
Users::ActiveQuery.call
# => User.where(active: true)Parameters define the inputs your query accepts. They support types, defaults, and validation.
parameter :statusRequired by default. The query raises Scopa::InvalidError if called without it.
parameter :search, optional: trueUses ActiveModel's attribute types for coercion:
parameter :limit, :integer, default: 25
parameter :include_archived, :boolean, default: false
parameter :start_date, :date, optional: trueString "10" becomes integer 10. String "true" becomes boolean true.
Pass a proc for defaults that need to be evaluated at call time:
parameter :since, :datetime, default: -> { 1.week.ago }The proc runs in the context of the query instance, so it has access to other parameters.
Filters transform the scope. They run in definition order.
Always applied:
filter(:published) { |scope| scope.where(published: true) }
filter(:ordered) { |scope| scope.order(created_at: :desc) }Applied only when a condition is met.
Symbol condition checks if the parameter is present:
parameter :category_id, optional: true
filter(:by_category, if: :category_id) { |scope| scope.where(category_id: category_id) }Proc condition for more complex logic:
parameter :min_price, :decimal, optional: true
parameter :max_price, :decimal, optional: true
filter(:price_range, if: -> { min_price.present? || max_price.present? }) do |scope|
scope = scope.where("price >= ?", min_price) if min_price.present?
scope = scope.where("price <= ?", max_price) if max_price.present?
scope
endFilters have access to all parameter values as instance methods.
By default, calling a query with invalid parameters raises Scopa::InvalidError. You can change this:
class SearchQuery < Scopa::Base
model Product
on_invalid :return_none # returns Product.none instead of raising
parameter :query
end
SearchQuery.call # => Product.none (no exception)Options:
:raise(default) raisesScopa::InvalidError:return_nonereturnsModel.none:ignoreruns the query anyway, skipping validation.
By default, queries start from Model.all. Pass a custom scope to narrow the base:
Users::ActiveQuery.call(scope: current_account.users, role: :admin)
# Starts from current_account.users instead of User.allQuery classes can inherit from other query classes. Filters, parameters, and configuration are inherited and can be extended:
class BaseQuery < Scopa::Base
model User
filter(:active) { |scope| scope.where(active: true) }
end
class AdminQuery < BaseQuery
filter(:admins) { |scope| scope.where(role: :admin) }
end
AdminQuery.call
# => User.where(active: true).where(role: :admin)Child classes can override on_invalid and add their own parameters without affecting the parent.
Every query call emits an ActiveSupport::Notifications event:
ActiveSupport::Notifications.subscribe("scopa.call") do |event|
Rails.logger.info "#{event.payload[:query_class]} took #{event.duration}ms"
Rails.logger.debug "Params: #{event.payload[:params]}"
endPayload includes:
query_classthe name of the query classparamsthe parameter values passed to the query
In Rails, Scopa automatically adds app/queries to the autoload paths. Create query classes there:
app/
queries/
users/
active_query.rb
search_query.rb
orders/
pending_query.rb
MIT