Project

scopa

0.0
No release in over 3 years
Scopa provides a clean DSL for building query objects in Rails applications. Define parameters with types and validations, compose filters conditionally, and instrument query execution.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

 Project Readme

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 :status

Required by default. The query raises Scopa::InvalidError if called without it.

Optional parameters

parameter :search, optional: true

Typed parameters

Uses ActiveModel's attribute types for coercion:

parameter :limit, :integer, default: 25
parameter :include_archived, :boolean, default: false
parameter :start_date, :date, optional: true

String "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
end

Filters 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) raises Scopa::InvalidError
  • :return_none returns Model.none
  • :ignore runs 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.all

Inheritance

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]}"
end

Payload includes:

  • query_class the name of the query class
  • params the 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