Scopa
Query objects for Rails. Encapsulate complex queries with validated parameters and composable filters.
Installation
Add to your Gemfile:
gem "scopa"What it does
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
Parameters define the inputs your query accepts. They support types, defaults, and validation.
Basic parameters
parameter :statusRequired by default. The query raises Scopa::InvalidError if called without it.
Optional parameters
parameter :search, optional: trueTyped parameters
Uses 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.
Dynamic defaults
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
Filters transform the scope. They run in definition order.
Unconditional filters
Always applied:
filter(:published) { |scope| scope.where(published: true) }
filter(:ordered) { |scope| scope.order(created_at: :desc) }Conditional filters
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.
Handling invalid parameters
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.
Custom scopes
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.allInheritance
Query 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.
Instrumentation
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 class -
paramsthe parameter values passed to the query
Rails integration
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
License
MIT