diff --git a/README.md b/README.md index 58e6bcf..598f6f7 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,13 @@ Declarative, **whitelisted** query filtering and ordering for ActiveRecord. -A model declares which columns are *datable*, *sortable*, *equatable* and *rangeable*. -`filterable(params)` then folds a chain of small, composable filters over the -relation, reading from nested request params and touching **only** the columns -the model explicitly exposed. An unknown attribute, a malformed date, or an -undeclared sort term narrows nothing — it never raises and never leaks an -arbitrary column into the query. +A model declares which columns are *datable*, *sortable*, *equatable* and +*rangeable*, and which scopes are *scopable* or *togglable*. `filterable(params)` +then folds a chain of small, composable filters over the relation, reading from +nested request params and touching **only** the columns and scopes the model +explicitly exposed. An unknown attribute, a malformed date, or an undeclared +sort term narrows nothing — it never raises and never leaks an arbitrary column +into the query. Requires **Ruby ≥ 3.2** and **ActiveRecord ≥ 7.1**. No dependency on ActionPack: `ActionController::Parameters` are supported by duck-typing (`to_unsafe_h`). @@ -38,10 +39,15 @@ a model just declares its whitelisted columns: ```ruby class MovementDetail < ApplicationRecord + scope :priced, -> { where.not(gross_amount_cents: nil) } + scope :cheaper_than, ->(cents) { where(gross_amount_cents: ...cents) } + datable :value_date, :booking_date sortable :value_date, :gross_amount_cents equatable :reference rangeable :gross_amount_cents + scopable :cheaper_than + togglable :priced end ``` @@ -118,6 +124,25 @@ unparseable or blank bounds are dropped silently. A column may be both `equatable` and `rangeable`: a scalar value filters by equality, a `min`/`max` hash by range. +### Scope filters (`Scopable` / `Togglable`) + +Existing model scopes can be exposed as filters — the scope name is fixed at +declaration time, never derived from the params: + +```ruby +scopable :cheaper_than # filters[cheaper_than]=250 → .cheaper_than(250) +togglable :priced # ?filters[priced] or =true / =1 → .priced +``` + +A `scopable` scope receives the normalized value (blank or non-scalar values +are dropped). A `togglable` scope takes no argument and toggles on a bare key +(`?filters[priced]`, `filters[priced]=`) or a strict true value (`true`, +`'true'`, `1`, `'1'`); an explicit false value — `'false'`, `'0'`, what Rails' +`check_box` hidden field submits — or an arbitrary string narrows nothing. +A declared name whose scope does not exist, or a scope not returning a +relation, narrows nothing either — the [declarations validator](#validating-declarations) +reports the former. + ### Ordering (`Sortable`) `filters[sort]` is a comma-separated list of declared attributes, each optionally @@ -131,15 +156,17 @@ Undeclared attributes are ignored. ### Public names vs. DB columns -All the DSLs accept a hash to expose a public name that differs from the column: +All the DSLs accept a hash to expose a public name that differs from the column +or scope: ```ruby -datable date: :value_date -sortable amount: :gross_amount_cents -equatable ref: :reference -rangeable amount: :gross_amount_cents +datable date: :value_date +sortable amount: :gross_amount_cents +equatable ref: :reference +rangeable amount: :gross_amount_cents +scopable max_price: :cheaper_than -# filters[date][after] / filters[sort]=-amount / filters[ref] / filters[amount][min] +# filters[date][after] / filters[sort]=-amount / filters[ref] / filters[amount][min] / filters[max_price] ``` ### Custom filters @@ -190,9 +217,11 @@ instead, every model exposes a validator over its declarations: expect(MovementDetail.filterable_declarations).to be_valid ``` -`filterable_declarations.errors` lists every declaration whose column does not -exist, e.g. `datable: 'date' maps to unknown column 'value_dat' on MovementDetail`. -The engine never calls it: production behavior stays a silent no-op. +`filterable_declarations.errors` lists every declaration whose column or scope +does not exist, e.g. `datable: 'date' maps to unknown column 'value_dat' on +MovementDetail` or `scopable: 'max_price' maps to unknown scope 'cheapest' on +MovementDetail`. The engine never calls it: production behavior stays a silent +no-op. --- @@ -207,11 +236,13 @@ include Filterable::Concern include Filterable::Concerns::Datable include Filterable::Concerns::Equatable include Filterable::Concerns::Rangeable +include Filterable::Concerns::Scopable include Filterable::Concerns::Sortable +include Filterable::Concerns::Togglable ``` So every model gains `filterable` and the `datable` / `sortable` / `equatable` / -`rangeable` DSL with no boilerplate. A model that declares no columns keeps an empty whitelist, so +`rangeable` / `scopable` / `togglable` DSL with no boilerplate. A model that declares no columns keeps an empty whitelist, so `filterable` is a harmless pass-through that returns `all`. ## Outside Rails @@ -225,18 +256,21 @@ class MovementDetail < ActiveRecord::Base include Filterable::Concerns::Datable include Filterable::Concerns::Equatable include Filterable::Concerns::Rangeable + include Filterable::Concerns::Scopable include Filterable::Concerns::Sortable + include Filterable::Concerns::Togglable datable :value_date sortable :value_date equatable :reference rangeable :gross_amount_cents + scopable :cheaper_than + togglable :priced end ``` > **Include order matters.** `Filterable::Concern` provides `add_filter`, which -> the `Datable`/`Equatable`/`Rangeable`/`Sortable` concerns call at include -> time — so it must come first. +> the declaration concerns call at include time — so it must come first. --- @@ -244,13 +278,14 @@ end - `Filterable::Concern` keeps a per-class list of filters (cloned down the inheritance chain) and `filterable` reduces the relation through them. -- `Filterable::Concerns::Datable` / `Equatable` / `Rangeable` / `Sortable` are - thin `ActiveSupport::Concern` mixins: they register the concrete filters and - add the `datable` / `equatable` / `rangeable` / `sortable` declaration DSL on - top of the engine. +- `Filterable::Concerns::Datable` / `Equatable` / `Rangeable` / `Scopable` / + `Sortable` / `Togglable` are thin `ActiveSupport::Concern` mixins: they + register the concrete filters and add the matching declaration DSL on top of + the engine. - The concrete date filters live under `Filterable::Datable::{After,Before,Range,Since}`, the equality filter is `Filterable::Equatable`, the numeric bound filters are - `Filterable::Rangeable::{Minimum,Maximum}` and the ordering filter is + `Filterable::Rangeable::{Minimum,Maximum}`, the scope filters are + `Filterable::Scopable` / `Filterable::Togglable` and the ordering filter is `Filterable::Sortable`. --- diff --git a/lib/filterable.rb b/lib/filterable.rb index 75e12aa..24ff92a 100644 --- a/lib/filterable.rb +++ b/lib/filterable.rb @@ -6,6 +6,7 @@ require_relative 'filterable/version' require_relative 'filterable/concern' require_relative 'filterable/attribute_normalization' +require_relative 'filterable/value_normalization' require_relative 'filterable/declarations_validator' require_relative 'filterable/datable' require_relative 'filterable/datable/after' @@ -16,11 +17,15 @@ require_relative 'filterable/rangeable' require_relative 'filterable/rangeable/minimum' require_relative 'filterable/rangeable/maximum' +require_relative 'filterable/scopable' require_relative 'filterable/sortable' +require_relative 'filterable/togglable' require_relative 'filterable/concerns/datable' require_relative 'filterable/concerns/equatable' require_relative 'filterable/concerns/rangeable' +require_relative 'filterable/concerns/scopable' require_relative 'filterable/concerns/sortable' +require_relative 'filterable/concerns/togglable' # Declarative, whitelisted query filtering and ordering for ActiveRecord models. # @@ -32,6 +37,8 @@ # sortable :value_date, :gross_amount_cents # equatable :reference # rangeable :gross_amount_cents +# scopable :cheaper_than +# togglable :priced # end # # MovementDetail.filterable(filters: { value_date: { after: '2026-02-01' }, sort: '-value_date' }) @@ -42,7 +49,9 @@ # include Filterable::Concerns::Datable # include Filterable::Concerns::Equatable # include Filterable::Concerns::Rangeable +# include Filterable::Concerns::Scopable # include Filterable::Concerns::Sortable +# include Filterable::Concerns::Togglable module Filterable # Base error class for the gem. class Error < StandardError; end diff --git a/lib/filterable/concerns/scopable.rb b/lib/filterable/concerns/scopable.rb new file mode 100644 index 0000000..e84af61 --- /dev/null +++ b/lib/filterable/concerns/scopable.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Filterable + module Concerns + # Adds scope delegation to a Filterable::Concern model. Declare the scopes + # with +scopable :cheaper_than+ (or +scopable public_name: :scope_name+ to + # alias), then +filters[]+ hands its value to those scopes only. + module Scopable + extend ActiveSupport::Concern + + included do + add_filter Filterable::Scopable + end + + class_methods do + # The declared scopable names, mapping each public name to its scope, + # cloned from the superclass so a subclass starts with its parent's + # declarations and can append its own. + # + # @api private + # @return [ActiveSupport::HashWithIndifferentAccess] public name => scope name. + def scopable_scope_names + @scopable_scope_names ||= + if superclass.respond_to?(:scopable_scope_names) + superclass.scopable_scope_names.clone + else + {}.with_indifferent_access + end + end + + # Declares one or more scopes as scopable. A bare name maps onto the + # scope of the same name; a hash aliases a public name to a different + # scope. + # + # @api public + # @param scope_names [Array] the scopes to expose. + # @return [self] so calls can be chained. + def scopable(*scope_names) + scope_names.flatten.each do |scope_name| + scopable_scope_names.merge!(Filterable::AttributeNormalization.normalize(scope_name)) + end + + self + end + end + end + end +end diff --git a/lib/filterable/concerns/togglable.rb b/lib/filterable/concerns/togglable.rb new file mode 100644 index 0000000..731529a --- /dev/null +++ b/lib/filterable/concerns/togglable.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Filterable + module Concerns + # Adds scope toggling to a Filterable::Concern model. Declare the + # no-argument scopes with +togglable :priced+ (or +togglable public_name: + # :scope_name+ to alias), then a strict true under +filters[]+ + # applies those scopes only — checkbox semantics. + module Togglable + extend ActiveSupport::Concern + + included do + add_filter Filterable::Togglable + end + + class_methods do + # The declared togglable names, mapping each public name to its scope, + # cloned from the superclass so a subclass starts with its parent's + # declarations and can append its own. + # + # @api private + # @return [ActiveSupport::HashWithIndifferentAccess] public name => scope name. + def togglable_scope_names + @togglable_scope_names ||= + if superclass.respond_to?(:togglable_scope_names) + superclass.togglable_scope_names.clone + else + {}.with_indifferent_access + end + end + + # Declares one or more scopes as togglable. A bare name maps onto the + # scope of the same name; a hash aliases a public name to a different + # scope. + # + # @api public + # @param scope_names [Array] the scopes to expose. + # @return [self] so calls can be chained. + def togglable(*scope_names) + scope_names.flatten.each do |scope_name| + togglable_scope_names.merge!(Filterable::AttributeNormalization.normalize(scope_name)) + end + + self + end + end + end + end +end diff --git a/lib/filterable/declarations_validator.rb b/lib/filterable/declarations_validator.rb index 30879ae..bc69032 100644 --- a/lib/filterable/declarations_validator.rb +++ b/lib/filterable/declarations_validator.rb @@ -1,24 +1,34 @@ # frozen_string_literal: true module Filterable - # Checks a model's declarations against its actual columns, so a typo in a - # declaration — a silent no-op at runtime by design — can be caught by a spec: + # Checks a model's declarations against its actual columns and scopes, so a + # typo in a declaration — a silent no-op at runtime by design — can be caught + # by a spec: # # expect(MovementDetail.filterable_declarations).to be_valid # # Never used by the engine at runtime: filtering stays a silent no-op on # unknown attributes. class DeclarationsValidator - # The declaration DSLs to check, each exposing +_attribute_names+ on - # the model. A kind the model does not respond to is skipped. + # The column-backed declaration DSLs to check, each exposing + # +_attribute_names+ on the model. A kind the model does not respond + # to is skipped. KINDS = %i[datable sortable equatable rangeable].freeze + # The scope-backed declaration DSLs to check, each exposing + # +_scope_names+ on the model. A kind the model does not respond to + # is skipped. + SCOPE_KINDS = %i[scopable togglable].freeze + + # Builds a validator over one model's declarations. + # + # @api public # @param model [Class] an ActiveRecord model using the declaration DSLs. def initialize(model) @model = model end - # Whether every declaration maps onto an existing column. + # Whether every declaration maps onto an existing column or scope. # # @api public # @return [Boolean] @@ -26,23 +36,25 @@ def valid? errors.empty? end - # The declarations pointing to unknown columns, as human-readable messages. + # The declarations pointing to unknown columns or scopes, as human-readable + # messages. # # @api public # @return [Array] one message per broken declaration; empty when valid. def errors - @errors ||= KINDS.flat_map { |kind| kind_errors(kind) } + @errors ||= KINDS.flat_map { |kind| column_errors(kind) } + + SCOPE_KINDS.flat_map { |kind| scope_errors(kind) } end private - # The error messages for one declaration DSL. + # The error messages for one column-backed declaration DSL. # # @api private # @param kind [Symbol] the declaration DSL to check. # @return [Array] one message per declaration of that kind whose # column does not exist. - def kind_errors(kind) + def column_errors(kind) reader = "#{kind}_attribute_names" return [] unless @model.respond_to?(reader) @@ -52,5 +64,22 @@ def kind_errors(kind) "#{kind}: '#{public_name}' maps to unknown column '#{column}' on #{@model.name}" end end + + # The error messages for one scope-backed declaration DSL. + # + # @api private + # @param kind [Symbol] the declaration DSL to check. + # @return [Array] one message per declaration of that kind whose + # scope does not exist. + def scope_errors(kind) + reader = "#{kind}_scope_names" + return [] unless @model.respond_to?(reader) + + @model.public_send(reader).filter_map do |public_name, scope_name| + next if @model.respond_to?(scope_name) + + "#{kind}: '#{public_name}' maps to unknown scope '#{scope_name}' on #{@model.name}" + end + end end end diff --git a/lib/filterable/equatable.rb b/lib/filterable/equatable.rb index cfc7634..f700279 100644 --- a/lib/filterable/equatable.rb +++ b/lib/filterable/equatable.rb @@ -42,7 +42,7 @@ def applied(params, scope) # @return [Array] public name and normalized value. def accepted(params, scope) values(params, scope).filter_map do |name, raw| - value = normalize(raw) + value = Filterable::ValueNormalization.normalize(raw) [name, value] unless value.nil? end end @@ -58,23 +58,5 @@ def values(params, scope) sliced = params.slice(*scope.equatable_attribute_names.keys) sliced.respond_to?(:to_unsafe_h) ? sliced.to_unsafe_h : sliced end - - # Coerce a raw filter value into a scalar or an array of scalars, or nil when - # nothing usable remains, so a blank or malformed value narrows nothing. - # - # @api private - # @param value [Object] the raw value from the params. - # @return [String, Numeric, true, false, Array, nil] the usable value, or nil. - def normalize(value) - case value - when Array - scalars = value.filter_map { |element| normalize(element) unless element.is_a?(Array) } - scalars.empty? ? nil : scalars - when String - value.presence - when Numeric, true, false - value - end - end end end diff --git a/lib/filterable/railtie.rb b/lib/filterable/railtie.rb index c26433b..1f5e163 100644 --- a/lib/filterable/railtie.rb +++ b/lib/filterable/railtie.rb @@ -5,11 +5,12 @@ module Filterable # defined (see the guarded +require_relative+ in {file:lib/filterable.rb}). # # The single initializer hooks +ActiveSupport.on_load(:active_record)+ so the - # filtering engine and the datable/equatable/rangeable/sortable concerns are - # mixed into +ActiveRecord::Base+ once — every model then exposes +filterable+ - # and the +datable+ / +sortable+ / +equatable+ / +rangeable+ declaration DSL - # without an explicit +include+. A model that declares nothing keeps an empty - # whitelist, so +filterable+ is a no-op that returns +all+. + # filtering engine and every declaration concern are mixed into + # +ActiveRecord::Base+ once — every model then exposes +filterable+ and the + # +datable+ / +sortable+ / +equatable+ / +rangeable+ / +scopable+ / + # +togglable+ declaration DSL without an explicit +include+. A model that + # declares nothing keeps an empty whitelist, so +filterable+ is a no-op that + # returns +all+. # # Outside Rails the railtie is never loaded; include the concerns manually. # @@ -21,7 +22,9 @@ class Railtie < Rails::Railtie include Filterable::Concerns::Datable include Filterable::Concerns::Equatable include Filterable::Concerns::Rangeable + include Filterable::Concerns::Scopable include Filterable::Concerns::Sortable + include Filterable::Concerns::Togglable end end end diff --git a/lib/filterable/scopable.rb b/lib/filterable/scopable.rb new file mode 100644 index 0000000..584ff0b --- /dev/null +++ b/lib/filterable/scopable.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Filterable + # Hands +filters[]+ to a named model scope. Only names the model + # declared +scopable+ are honored; the scope name is fixed at declaration + # time, never derived from the params. A blank or non-scalar value, a missing + # scope, or a scope not returning a relation narrows nothing. + module Scopable + module_function + + # Narrows the scope by handing each accepted value to its declared scope. + # + # @api private + # @param params [Hash, ActionController::Parameters] the nested +filters+ params. + # @param scope [ActiveRecord::Relation] the relation being filtered. + # @return [ActiveRecord::Relation] the narrowed relation. + def call(params, scope) + accepted(params, scope).reduce(scope) do |sub_scope, (_name, scope_name, value)| + narrowed = sub_scope.public_send(scope_name, value) + narrowed.is_a?(ActiveRecord::Relation) ? narrowed : sub_scope + end + end + + # The values {call} would hand over, keyed by public name, normalized. + # + # @api private + # @param params [Hash, ActionController::Parameters] the nested +filters+ params. + # @param scope [ActiveRecord::Relation] the relation being filtered. + # @return [Hash{Symbol, String => Object}] public name => accepted value. + def applied(params, scope) + accepted(params, scope).each_with_object({}) do |(name, _scope_name, value), report| + report[name] = value + end + end + + # The declared entries whose value normalizes to something usable and whose + # scope exists — the single place deciding whether a delegation applies, so + # {call} and {applied} cannot drift. + # + # @api private + # @param params [Hash, ActionController::Parameters] the nested +filters+ params. + # @param scope [ActiveRecord::Relation] the relation being filtered. + # @return [Array] public name, scope name and value. + def accepted(params, scope) + declared = scope.scopable_scope_names + sliced = params.slice(*declared.keys) + sliced = sliced.to_unsafe_h if sliced.respond_to?(:to_unsafe_h) + sliced.filter_map do |name, raw| + value = Filterable::ValueNormalization.normalize(raw) + next if value.nil? || !scope.respond_to?(declared[name]) + + [name, declared[name], value] + end + end + end +end diff --git a/lib/filterable/togglable.rb b/lib/filterable/togglable.rb new file mode 100644 index 0000000..1a8b4a8 --- /dev/null +++ b/lib/filterable/togglable.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module Filterable + # Applies a no-argument model scope when +filters[]+ is present as a + # bare key (+?filters[priced]+, +filters[priced]=+) or carries a strict true + # value. Only names the model declared +togglable+ are honored; the scope + # name is fixed at declaration time, never derived from the params. An + # explicit false value (+'false'+, +'0'+ — what Rails' +check_box+ hidden + # field submits), an arbitrary string, a missing scope, or a scope not + # returning a relation narrows nothing. + module Togglable + # The explicit values that switch a toggle on. A strict allowlist, unlike + # +ActiveModel::Type::Boolean+ where any unrecognized string casts to true. + TRUE_VALUES = [true, 1, '1', 'true'].freeze + + module_function + + # Narrows the scope by applying each toggled-on declared scope. + # + # @api private + # @param params [Hash, ActionController::Parameters] the nested +filters+ params. + # @param scope [ActiveRecord::Relation] the relation being filtered. + # @return [ActiveRecord::Relation] the narrowed relation. + def call(params, scope) + accepted(params, scope).reduce(scope) do |sub_scope, (_name, scope_name)| + narrowed = sub_scope.public_send(scope_name) + narrowed.is_a?(ActiveRecord::Relation) ? narrowed : sub_scope + end + end + + # The toggles {call} would apply, keyed by public name, always +true+. + # + # @api private + # @param params [Hash, ActionController::Parameters] the nested +filters+ params. + # @param scope [ActiveRecord::Relation] the relation being filtered. + # @return [Hash{Symbol, String => true}] public name => true. + def applied(params, scope) + accepted(params, scope).each_with_object({}) do |(name, _scope_name), report| + report[name] = true + end + end + + # The declared entries whose key is toggled on and whose scope exists — the + # single place deciding whether a toggle applies, so {call} and {applied} + # cannot drift. + # + # @api private + # @param params [Hash, ActionController::Parameters] the nested +filters+ params. + # @param scope [ActiveRecord::Relation] the relation being filtered. + # @return [Array] public name and scope name. + def accepted(params, scope) + declared = scope.togglable_scope_names + sliced = params.slice(*declared.keys) + sliced = sliced.to_unsafe_h if sliced.respond_to?(:to_unsafe_h) + sliced.filter_map do |name, raw| + next unless toggled_on?(raw) && scope.respond_to?(declared[name]) + + [name, declared[name]] + end + end + + # Whether a present key's value switches the toggle on: a bare key (nil or + # a blank string) counts as on, everything else must be a strict true. + # + # @api private + # @param value [Object] the raw value under the present key. + # @return [Boolean] + def toggled_on?(value) + case value + when nil + true + when String + value.strip.empty? || TRUE_VALUES.include?(value) + else + TRUE_VALUES.include?(value) + end + end + end +end diff --git a/lib/filterable/value_normalization.rb b/lib/filterable/value_normalization.rb new file mode 100644 index 0000000..b3f0a75 --- /dev/null +++ b/lib/filterable/value_normalization.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Filterable + # Shared by the filters that hand a params value to the query: coerce a raw + # value into a scalar or an array of scalars, or nil when nothing usable + # remains, so a blank or malformed value narrows nothing. + module ValueNormalization + module_function + + # Normalizes a single raw params value. + # + # @api private + # @param value [Object] the raw value from the params. + # @return [String, Numeric, true, false, Array, nil] the usable value, or nil. + def normalize(value) + case value + when Array + scalars = value.filter_map { |element| normalize(element) unless element.is_a?(Array) } + scalars.empty? ? nil : scalars + when String + value.presence + when Numeric, true, false + value + end + end + end +end diff --git a/spec/filterable_spec.rb b/spec/filterable_spec.rb index 4572dd3..dadd4ef 100644 --- a/spec/filterable_spec.rb +++ b/spec/filterable_spec.rb @@ -252,6 +252,170 @@ def detail(value_date) end end + describe 'scopable' do + let!(:cheap) { MovementDetail.create!(gross_amount_cents: 100) } + let!(:mid) { MovementDetail.create!(gross_amount_cents: 250) } + + it 'passes the value to the declared scope' do + result = MovementDetail.filterable(filters: { cheaper_than: 250 }) + expect(result).to contain_exactly(cheap) + end + + it 'passes a string value, letting the scope cast it' do + result = MovementDetail.filterable(filters: { cheaper_than: '250' }) + expect(result).to contain_exactly(cheap) + end + + it 'ignores a blank value' do + result = MovementDetail.filterable(filters: { cheaper_than: '' }) + expect(result).to contain_exactly(january, february, march, cheap, mid) + end + + it 'ignores a non-scalar value instead of raising' do + result = nil + expect { result = MovementDetail.filterable(filters: { cheaper_than: { evil: 1 } }) } + .not_to raise_error + expect(result).to contain_exactly(january, february, march, cheap, mid) + end + + it 'ignores scopes that exist on the model but are not declared scopable' do + result = MovementDetail.filterable(filters: { costlier_than: 50 }) + expect(result).to contain_exactly(january, february, march, cheap, mid) + end + + it 'ignores a declared name whose scope does not exist instead of raising' do + model = Class.new(MovementDetail) { scopable :does_not_exist } + result = nil + expect { result = model.filterable(filters: { does_not_exist: 1 }) }.not_to raise_error + expect(result.count).to eq(5) + end + + it 'leaves the relation untouched when the scope does not return a relation' do + model = Class.new(MovementDetail) do + def self.broken(_value) + nil + end + scopable :broken + end + + expect(model.filterable(filters: { broken: 1 }).count).to eq(5) + end + + it 'resolves a public name onto its aliased scope' do + model = Class.new(MovementDetail) { scopable max_price: :cheaper_than } + + expect(model.filterable(filters: { max_price: 250 }).map(&:id)).to eq([cheap.id]) + end + + it 'inherits the scopable declarations in a subclass' do + result = Class.new(MovementDetail).filterable(filters: { cheaper_than: 250 }) + + expect(result.map(&:id)).to eq([cheap.id]) + end + + it 'accepts ActionController::Parameters from a controller' do + params = ActionController::Parameters.new(filters: { cheaper_than: '250' }) + expect(MovementDetail.filterable(params).map(&:id)).to eq([cheap.id]) + end + + it 'reports the normalized value in applied_filters' do + report = MovementDetail.applied_filters(filters: { cheaper_than: '250' }) + + expect(report[:cheaper_than]).to eq('250') + end + + it 'omits a declared name whose scope does not exist from applied_filters' do + model = Class.new(MovementDetail) { scopable :does_not_exist } + + expect(model.applied_filters(filters: { does_not_exist: 1 })).to be_empty + end + end + + describe 'togglable' do + let!(:cheap) { MovementDetail.create!(gross_amount_cents: 100) } + let!(:mid) { MovementDetail.create!(gross_amount_cents: 250) } + + it 'applies the scope when the value is a true-ish string' do + result = MovementDetail.filterable(filters: { priced: 'true' }) + expect(result).to contain_exactly(cheap, mid) + end + + it 'accepts every strict true value' do + [true, 1, '1'].each do |value| + expect(MovementDetail.filterable(filters: { priced: value })).to contain_exactly(cheap, mid) + end + end + + it 'ignores false values' do + [false, 0, '0', 'false'].each do |value| + expect(MovementDetail.filterable(filters: { priced: value })) + .to contain_exactly(january, february, march, cheap, mid) + end + end + + it 'ignores an arbitrary string instead of treating it as true' do + result = MovementDetail.filterable(filters: { priced: 'garbage' }) + expect(result).to contain_exactly(january, february, march, cheap, mid) + end + + it 'toggles on a bare key carrying a blank value' do + ['', ' ', nil].each do |value| + expect(MovementDetail.filterable(filters: { priced: value })).to contain_exactly(cheap, mid) + end + end + + it 'toggles on a bare key from ActionController::Parameters' do + params = ActionController::Parameters.new(filters: { priced: nil }) + expect(MovementDetail.filterable(params).map(&:id)).to contain_exactly(cheap.id, mid.id) + end + + it 'ignores a declared name whose scope does not exist instead of raising' do + model = Class.new(MovementDetail) { togglable :does_not_exist } + result = nil + expect { result = model.filterable(filters: { does_not_exist: 'true' }) }.not_to raise_error + expect(result.count).to eq(5) + end + + it 'leaves the relation untouched when the scope does not return a relation' do + model = Class.new(MovementDetail) do + def self.broken + nil + end + togglable :broken + end + + expect(model.filterable(filters: { broken: 'true' }).count).to eq(5) + end + + it 'resolves a public name onto its aliased scope' do + model = Class.new(MovementDetail) { togglable has_price: :priced } + + expect(model.filterable(filters: { has_price: 'true' }).map(&:id)).to contain_exactly(cheap.id, mid.id) + end + + it 'inherits the togglable declarations in a subclass' do + result = Class.new(MovementDetail).filterable(filters: { priced: 'true' }) + + expect(result.map(&:id)).to contain_exactly(cheap.id, mid.id) + end + + it 'reports true in applied_filters' do + report = MovementDetail.applied_filters(filters: { priced: '1' }) + + expect(report[:priced]).to be(true) + end + + it 'reports true in applied_filters for a bare key' do + report = MovementDetail.applied_filters(filters: { priced: '' }) + + expect(report[:priced]).to be(true) + end + + it 'stays out of applied_filters when the value is not true-ish' do + expect(MovementDetail.applied_filters(filters: { priced: 'garbage' })).to be_empty + end + end + describe '.add_filter' do it 'folds a block filter over the relation like any filter object' do model = Class.new(MovementDetail) do @@ -424,6 +588,26 @@ def self.applied(filters_params, _scope) ) end + it 'reports a scopable declaration pointing to an unknown scope' do + stub_const('BrokenScope', Class.new(MovementDetail) { scopable :nonexistent }) + validator = BrokenScope.filterable_declarations + + expect(validator).not_to be_valid + expect(validator.errors).to contain_exactly( + "scopable: 'nonexistent' maps to unknown scope 'nonexistent' on BrokenScope" + ) + end + + it 'reports a togglable declaration pointing to an unknown scope through its alias' do + stub_const('BrokenToggle', Class.new(MovementDetail) { togglable active: :nonexistent }) + validator = BrokenToggle.filterable_declarations + + expect(validator).not_to be_valid + expect(validator.errors).to contain_exactly( + "togglable: 'active' maps to unknown scope 'nonexistent' on BrokenToggle" + ) + end + it 'is valid for a model without any declaration DSL' do model = Class.new { include Filterable::Concern } @@ -439,39 +623,48 @@ def self.applied(filters_params, _scope) include Filterable::Concerns::Sortable include Filterable::Concerns::Equatable include Filterable::Concerns::Rangeable + include Filterable::Concerns::Scopable + include Filterable::Concerns::Togglable end end it 'maps a bare attribute onto its own column' do model.datable(:value_date).sortable(:value_date).equatable(:reference).rangeable(:gross_amount_cents) + .scopable(:cheaper_than).togglable(:priced) expect(model.datable_attribute_names[:value_date]).to eq(:value_date) expect(model.sortable_attribute_names[:value_date]).to eq(:value_date) expect(model.equatable_attribute_names[:reference]).to eq(:reference) expect(model.rangeable_attribute_names[:gross_amount_cents]).to eq(:gross_amount_cents) + expect(model.scopable_scope_names[:cheaper_than]).to eq(:cheaper_than) + expect(model.togglable_scope_names[:priced]).to eq(:priced) end it 'aliases a public name to a column through a hash' do model.datable(date: :value_date).sortable(amount: :gross_amount_cents).equatable(ref: :reference) - .rangeable(amount: :gross_amount_cents) + .rangeable(amount: :gross_amount_cents).scopable(max_price: :cheaper_than).togglable(has_price: :priced) expect(model.datable_attribute_names[:date]).to eq(:value_date) expect(model.sortable_attribute_names[:amount]).to eq(:gross_amount_cents) expect(model.equatable_attribute_names[:ref]).to eq(:reference) expect(model.rangeable_attribute_names[:amount]).to eq(:gross_amount_cents) + expect(model.scopable_scope_names[:max_price]).to eq(:cheaper_than) + expect(model.togglable_scope_names[:has_price]).to eq(:priced) end it 'ignores an unsupported declaration' do - model.datable(42).sortable(nil).equatable(3.14).rangeable(Object.new) + model.datable(42).sortable(nil).equatable(3.14).rangeable(Object.new).scopable(42).togglable(nil) expect(model.datable_attribute_names).to be_empty expect(model.sortable_attribute_names).to be_empty expect(model.equatable_attribute_names).to be_empty expect(model.rangeable_attribute_names).to be_empty + expect(model.scopable_scope_names).to be_empty + expect(model.togglable_scope_names).to be_empty end end - describe Filterable::Equatable, '.normalize' do + describe Filterable::ValueNormalization, '.normalize' do it 'passes booleans through for JSON body params' do expect(described_class.normalize(true)).to be(true) expect(described_class.normalize(false)).to be(false) diff --git a/spec/railtie_spec.rb b/spec/railtie_spec.rb index 0926383..7bb69d8 100644 --- a/spec/railtie_spec.rb +++ b/spec/railtie_spec.rb @@ -17,11 +17,13 @@ expect(ActiveRecord::Base).to respond_to(:filterable) end - it 'exposes the datable, sortable, equatable and rangeable declaration DSL on every model' do + it 'exposes every declaration DSL on every model' do expect(ActiveRecord::Base).to respond_to(:datable) expect(ActiveRecord::Base).to respond_to(:sortable) expect(ActiveRecord::Base).to respond_to(:equatable) expect(ActiveRecord::Base).to respond_to(:rangeable) + expect(ActiveRecord::Base).to respond_to(:scopable) + expect(ActiveRecord::Base).to respond_to(:togglable) end it 'leaves a model that declares nothing as a pass-through' do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 32f45f3..38faead 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -51,10 +51,16 @@ # Note the absence of any `include`: the Railtie auto-included the engine into # ActiveRecord::Base, so the model only declares its whitelisted columns. class MovementDetail < ActiveRecord::Base + scope :priced, -> { where.not(gross_amount_cents: nil) } + scope :cheaper_than, ->(cents) { where(gross_amount_cents: ...cents) } + scope :costlier_than, ->(cents) { where(gross_amount_cents: cents..) } + datable :value_date, :booking_date sortable :value_date, :gross_amount_cents equatable :reference, :gross_amount_cents rangeable :gross_amount_cents + togglable :priced + scopable :cheaper_than end RSpec.configure do |config|