Skip to content

joshmn/scopa

Repository files navigation

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

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published