diff --git a/README.md b/README.md index a4f0205..7f3dbd1 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,40 @@ 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. +### Partial match (`Matchable`) + +Each declared matchable attribute accepts these nested keys under +`filters[]`: + +| Key | SQL | Cost | +|---------------|------------------|------------------------------------------| +| `starts_with` | `LIKE 'term%'` | sargable — can use a B-tree index | +| `ends_with` | `LIKE '%term'` | full scan | +| `contains` | `LIKE '%term%'` | full scan | + +```ruby +matchable :reference +matchable :code, case_sensitive: true + +MovementDetail.filterable(filters: { reference: { starts_with: 'INV' } }) +``` + +The term is always LIKE-escaped — a user `%` or `_` matches literally — and +the wildcard placement is fixed by the key: the caller never controls the +pattern. Matching is case insensitive by default (`ILIKE` on PostgreSQL); +`case_sensitive: true` opts a declaration into sensitivity, and the request +may override either way with the reserved nested key: + +```ruby +MovementDetail.filterable(filters: { reference: { starts_with: 'INV', case_sensitive: true } }) +``` + +A strict true or bare key switches on, an explicit false (`'false'`, `'0'`) +switches off — even on an attribute declared sensitive — and anything else +falls back to the declaration (`case_sensitive` is a reserved word, never a +public name; effective sensitivity on SQLite also depends on +`PRAGMA case_sensitive_like`). Blank or non-string terms are dropped silently. + ### Scope filters (`Scopable` / `Togglable`) Existing model scopes can be exposed as filters — the scope name is fixed at diff --git a/lib/filterable.rb b/lib/filterable.rb index 051a45b..d558519 100644 --- a/lib/filterable.rb +++ b/lib/filterable.rb @@ -16,6 +16,10 @@ require_relative 'filterable/datable/range' require_relative 'filterable/datable/since' require_relative 'filterable/equatable' +require_relative 'filterable/matchable' +require_relative 'filterable/matchable/starts_with' +require_relative 'filterable/matchable/ends_with' +require_relative 'filterable/matchable/contains' require_relative 'filterable/rangeable' require_relative 'filterable/rangeable/minimum' require_relative 'filterable/rangeable/maximum' @@ -25,6 +29,7 @@ require_relative 'filterable/concerns/attachable' require_relative 'filterable/concerns/datable' require_relative 'filterable/concerns/equatable' +require_relative 'filterable/concerns/matchable' require_relative 'filterable/concerns/rangeable' require_relative 'filterable/concerns/scopable' require_relative 'filterable/concerns/sortable' diff --git a/lib/filterable/attachable.rb b/lib/filterable/attachable.rb index 9e4e21b..c29c5e8 100644 --- a/lib/filterable/attachable.rb +++ b/lib/filterable/attachable.rb @@ -12,9 +12,6 @@ module Filterable # Rails' documented contract), so the gem never references ActiveStorage # itself; a declared name without those associations narrows nothing. module Attachable - # The explicit values that flip +present+ to its unattached side. - FALSE_VALUES = [false, 0, '0', 'false'].freeze - # The symbolic +type+ shortcuts, each expanded lazily at query time against # ActiveStorage's configured content-type lists — never frozen at boot, and # only ever called once the model proved it has real attachments, so the @@ -123,7 +120,7 @@ def presence_value(bounds) raw = bounds[:present] return :attached if Filterable::Togglable.toggled_on?(raw) - :missing if FALSE_VALUES.include?(raw) + :missing if Filterable::Togglable.toggled_off?(raw) end # Applies one accepted entry onto the scope. diff --git a/lib/filterable/concerns/matchable.rb b/lib/filterable/concerns/matchable.rb new file mode 100644 index 0000000..6145b06 --- /dev/null +++ b/lib/filterable/concerns/matchable.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Filterable + module Concerns + # Adds partial matching to a Filterable::Concern model. Declare the columns + # with +matchable :reference+ (or +matchable public_name: :db_column+ to + # alias), then the starts_with/ends_with/contains keys under + # +filters[]+ operate on those columns only. +case_sensitive: + # true+ in a declaration makes its attributes match case sensitively — + # +case_sensitive+ is a reserved word, never a public name. + module Matchable + extend ActiveSupport::Concern + + included do + add_filter Filterable::Matchable::StartsWith + add_filter Filterable::Matchable::EndsWith + add_filter Filterable::Matchable::Contains + end + + class_methods do + # The declared matchable attributes, mapping each public name to its DB + # column, 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 => DB column. + def matchable_attribute_names + @matchable_attribute_names ||= + if superclass.respond_to?(:matchable_attribute_names) + superclass.matchable_attribute_names.clone + else + {}.with_indifferent_access + end + end + + # The declared attributes matching case sensitively, cloned from the + # superclass like the declarations themselves. + # + # @api private + # @return [ActiveSupport::HashWithIndifferentAccess] public name => true. + def matchable_case_sensitive + @matchable_case_sensitive ||= + if superclass.respond_to?(:matchable_case_sensitive) + superclass.matchable_case_sensitive.clone + else + {}.with_indifferent_access + end + end + + # Declares one or more attributes as matchable. A bare name maps onto + # its own column; a hash aliases a public name to a different column; + # +case_sensitive: true+ applies to every name of the call. + # + # @api public + # @param attribute_names [Array] the columns to expose. + # @return [self] so calls can be chained. + def matchable(*attribute_names) + declared, sensitive = split_matchable_options(attribute_names) + matchable_attribute_names.merge!(declared) + declared.each_key { |name| matchable_case_sensitive[name] = true } if sensitive + + self + end + + private + + # Splits a matchable call into its declarations and its options. + # + # @api private + # @param attribute_names [Array] the raw declarations. + # @return [Array(Hash, Boolean)] public name => column, and the + # call-wide case sensitivity. + def split_matchable_options(attribute_names) + declared = {} + sensitive = false + attribute_names.flatten.each do |attribute_name| + declaration = Filterable::AttributeNormalization.normalize(attribute_name).dup + sensitive = true if declaration.delete(:case_sensitive) + declared.merge!(declaration) + end + [declared, sensitive] + end + end + end + end +end diff --git a/lib/filterable/declarations_validator.rb b/lib/filterable/declarations_validator.rb index 1a4f83a..936e07b 100644 --- a/lib/filterable/declarations_validator.rb +++ b/lib/filterable/declarations_validator.rb @@ -13,7 +13,7 @@ class DeclarationsValidator # 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 + KINDS = %i[datable sortable equatable rangeable matchable].freeze # The scope-backed declaration DSLs to check, each exposing # +_scope_names+ on the model. A kind the model does not respond to diff --git a/lib/filterable/matchable.rb b/lib/filterable/matchable.rb new file mode 100644 index 0000000..a61317a --- /dev/null +++ b/lib/filterable/matchable.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +module Filterable + # Namespace for the partial match filters and the helpers they share. The + # user's term is always LIKE-escaped (+%+ and +_+ match literally) and the + # wildcard placement is fixed by the declarative key — the caller never + # controls the pattern. Matching is case insensitive by default; a + # declaration may opt into sensitivity with +case_sensitive: true+, and the + # request may override either way with the reserved + # +filters[][case_sensitive]+ key (strict true or bare key + # switches on, explicit false switches off, anything else falls back to the + # declaration). + module Matchable + # The LIKE escape character, matching what +sanitize_sql_like+ emits. + ESCAPE = '\\' + + module_function + + # Whitelist the params to the declared matchable attributes, keeping their + # public names. An attribute whose value is not a hash of terms is dropped, + # so a malformed shape narrows nothing instead of raising. + # + # @api private + # @param params [Hash, ActionController::Parameters] the nested +filters+ params. + # @param scope [ActiveRecord::Relation] the relation being filtered. + # @return [Hash{Symbol, String => Hash}] public name => raw terms for that attribute. + def bounds(params, scope) + sliced = params.slice(*scope.matchable_attribute_names.keys) + sliced = sliced.to_unsafe_h if sliced.respond_to?(:to_unsafe_h) + sliced.select { |_name, value| value.is_a?(Hash) } + end + + # The entries of {bounds} whose given key carries a usable term — the + # single place deciding whether a match applies, so the filters' +call+ + # and +applied+ cannot drift. + # + # @api private + # @param params [Hash, ActionController::Parameters] the nested +filters+ params. + # @param scope [ActiveRecord::Relation] the relation being filtered. + # @param key [Symbol] the match key the filter reads. + # @return [Array] public name, + # target, raw term and effective case sensitivity. + def accepted(params, scope, key) + declared = scope.matchable_attribute_names + bounds(params, scope).filter_map do |name, terms| + term = terms[key] + next unless term.is_a?(String) && term.present? + + [name, declared[name], term, sensitive?(scope, name, terms)] + end + end + + # The effective case sensitivity for one attribute: the request's + # +case_sensitive+ key when it carries a usable value, the declaration + # otherwise. + # + # @api private + # @param scope [ActiveRecord::Relation] the relation being filtered. + # @param name [Object] the declared public name. + # @param terms [Hash] the raw keys under +filters[]+. + # @return [Boolean] + def sensitive?(scope, name, terms) + override = request_sensitivity(terms) + override.nil? ? scope.matchable_case_sensitive[name].present? : override + end + + # The request-level sensitivity override, nil when absent or unusable. + # + # @api private + # @param terms [Hash] the raw keys under +filters[]+. + # @return [Boolean, nil] + def request_sensitivity(terms) + return unless terms.key?(:case_sensitive) + + raw = terms[:case_sensitive] + return true if Filterable::Togglable.toggled_on?(raw) + + false if Filterable::Togglable.toggled_off?(raw) + end + + # LIKE-escapes a user term so its +%+ and +_+ match literally. + # + # @api private + # @param term [String] the raw term. + # @return [String] the escaped term. + def escape(term) + ActiveRecord::Base.sanitize_sql_like(term) + end + + # Narrows the scope with a LIKE pattern on the target's column. + # + # @api private + # @param sub_scope [ActiveRecord::Relation] the relation being narrowed. + # @param target [Symbol, String, Hash] the declared target. + # @param pattern [String] the escaped LIKE pattern. + # @param sensitive [Boolean] the effective case sensitivity. + # @return [ActiveRecord::Relation] the narrowed relation. + def narrow(sub_scope, target, pattern, sensitive) + Filterable::Target.narrow(sub_scope, target) { |field| field.matches(pattern, ESCAPE, sensitive) } + end + end +end diff --git a/lib/filterable/matchable/contains.rb b/lib/filterable/matchable/contains.rb new file mode 100644 index 0000000..df41272 --- /dev/null +++ b/lib/filterable/matchable/contains.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Filterable + module Matchable + # Keeps rows whose matchable attribute contains the term. Full scan — the + # leading wildcard defeats indexes. Reads +filters[][contains]+. + module Contains + module_function + + # Narrows the scope to rows containing each declared attribute's term. + # + # @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) + entries = Filterable::Matchable.accepted(params, scope, :contains) + entries.reduce(scope) do |sub_scope, (_name, target, term, sensitive)| + pattern = "%#{Filterable::Matchable.escape(term)}%" + Filterable::Matchable.narrow(sub_scope, target, pattern, sensitive) + end + end + + # The terms {call} would apply, keyed by public name, with their raw values. + # + # @api private + # @param params [Hash, ActionController::Parameters] the nested +filters+ params. + # @param scope [ActiveRecord::Relation] the relation being filtered. + # @return [Hash{Symbol, String => Hash}] public name => accepted term. + def applied(params, scope) + entries = Filterable::Matchable.accepted(params, scope, :contains) + entries.each_with_object({}) do |(name, _target, term, _sensitive), report| + report[name] = { contains: term } + end + end + end + end +end diff --git a/lib/filterable/matchable/ends_with.rb b/lib/filterable/matchable/ends_with.rb new file mode 100644 index 0000000..b9c85f3 --- /dev/null +++ b/lib/filterable/matchable/ends_with.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Filterable + module Matchable + # Keeps rows whose matchable attribute ends with the term. Full scan — the + # leading wildcard defeats indexes. Reads +filters[][ends_with]+. + module EndsWith + module_function + + # Narrows the scope to rows ending with each declared attribute's term. + # + # @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) + entries = Filterable::Matchable.accepted(params, scope, :ends_with) + entries.reduce(scope) do |sub_scope, (_name, target, term, sensitive)| + pattern = "%#{Filterable::Matchable.escape(term)}" + Filterable::Matchable.narrow(sub_scope, target, pattern, sensitive) + end + end + + # The terms {call} would apply, keyed by public name, with their raw values. + # + # @api private + # @param params [Hash, ActionController::Parameters] the nested +filters+ params. + # @param scope [ActiveRecord::Relation] the relation being filtered. + # @return [Hash{Symbol, String => Hash}] public name => accepted term. + def applied(params, scope) + entries = Filterable::Matchable.accepted(params, scope, :ends_with) + entries.each_with_object({}) do |(name, _target, term, _sensitive), report| + report[name] = { ends_with: term } + end + end + end + end +end diff --git a/lib/filterable/matchable/starts_with.rb b/lib/filterable/matchable/starts_with.rb new file mode 100644 index 0000000..eeaf840 --- /dev/null +++ b/lib/filterable/matchable/starts_with.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Filterable + module Matchable + # Keeps rows whose matchable attribute starts with the term — the sargable + # variant, able to use a B-tree index. Reads +filters[][starts_with]+. + module StartsWith + module_function + + # Narrows the scope to rows starting with each declared attribute's term. + # + # @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) + entries = Filterable::Matchable.accepted(params, scope, :starts_with) + entries.reduce(scope) do |sub_scope, (_name, target, term, sensitive)| + pattern = "#{Filterable::Matchable.escape(term)}%" + Filterable::Matchable.narrow(sub_scope, target, pattern, sensitive) + end + end + + # The terms {call} would apply, keyed by public name, with their raw values. + # + # @api private + # @param params [Hash, ActionController::Parameters] the nested +filters+ params. + # @param scope [ActiveRecord::Relation] the relation being filtered. + # @return [Hash{Symbol, String => Hash}] public name => accepted term. + def applied(params, scope) + entries = Filterable::Matchable.accepted(params, scope, :starts_with) + entries.each_with_object({}) do |(name, _target, term, _sensitive), report| + report[name] = { starts_with: term } + end + end + end + end +end diff --git a/lib/filterable/railtie.rb b/lib/filterable/railtie.rb index 80db662..7456348 100644 --- a/lib/filterable/railtie.rb +++ b/lib/filterable/railtie.rb @@ -22,6 +22,7 @@ class Railtie < Rails::Railtie include Filterable::Concerns::Attachable include Filterable::Concerns::Datable include Filterable::Concerns::Equatable + include Filterable::Concerns::Matchable include Filterable::Concerns::Rangeable include Filterable::Concerns::Scopable include Filterable::Concerns::Sortable diff --git a/lib/filterable/togglable.rb b/lib/filterable/togglable.rb index df895aa..9465a7f 100644 --- a/lib/filterable/togglable.rb +++ b/lib/filterable/togglable.rb @@ -13,6 +13,11 @@ module Togglable # +ActiveModel::Type::Boolean+ where any unrecognized string casts to true. TRUE_VALUES = [true, 1, '1', 'true'].freeze + # The explicit values that switch a toggle off — what Rails' +check_box+ + # hidden field submits. The shared vocabulary for every boolean-ish param + # in the gem. + FALSE_VALUES = [false, 0, '0', 'false'].freeze + module_function # Narrows the scope by applying each toggled-on declared scope. @@ -76,5 +81,14 @@ def toggled_on?(value) TRUE_VALUES.include?(value) end end + + # Whether a value is an explicit off. + # + # @api private + # @param value [Object] the raw value. + # @return [Boolean] + def toggled_off?(value) + FALSE_VALUES.include?(value) + end end end diff --git a/spec/filterable_spec.rb b/spec/filterable_spec.rb index 3632ce1..4cbd400 100644 --- a/spec/filterable_spec.rb +++ b/spec/filterable_spec.rb @@ -252,6 +252,139 @@ def detail(value_date) end end + describe 'matchable' do + let!(:invoice) { MovementDetail.create!(reference: 'INV-100') } + let!(:refund) { MovementDetail.create!(reference: 'REF-INV') } + let!(:percent) { MovementDetail.create!(reference: '100%') } + let!(:under) { MovementDetail.create!(reference: 'A_B') } + let!(:cross) { MovementDetail.create!(reference: 'AXB') } + + it 'matches the start of the column' do + result = MovementDetail.filterable(filters: { reference: { starts_with: 'INV' } }) + expect(result).to contain_exactly(invoice) + end + + it 'matches the end of the column' do + result = MovementDetail.filterable(filters: { reference: { ends_with: 'INV' } }) + expect(result).to contain_exactly(refund) + end + + it 'matches anywhere in the column' do + result = MovementDetail.filterable(filters: { reference: { contains: 'INV' } }) + expect(result).to contain_exactly(invoice, refund) + end + + it 'matches case insensitively by default' do + result = MovementDetail.filterable(filters: { reference: { starts_with: 'inv' } }) + expect(result).to contain_exactly(invoice) + end + + it 'escapes percent signs in the term' do + expect(MovementDetail.filterable(filters: { reference: { contains: '%' } })).to contain_exactly(percent) + expect(MovementDetail.filterable(filters: { reference: { contains: '0%' } })).to contain_exactly(percent) + end + + it 'escapes underscores in the term' do + result = MovementDetail.filterable(filters: { reference: { contains: 'A_B' } }) + expect(result).to contain_exactly(under) + end + + it 'combines several match keys on the same attribute' do + result = MovementDetail.filterable(filters: { reference: { starts_with: 'INV', contains: '100' } }) + expect(result).to contain_exactly(invoice) + end + + it 'ignores blank and non-string terms and non-hash values' do + cases = [{ reference: { contains: '' } }, { reference: { contains: ['a'] } }, { matched_account: 'INV' }] + cases.each do |filters| + result = nil + expect { result = MovementDetail.filterable(filters: filters) }.not_to raise_error + expect(result.count).to eq(8) + end + end + + it 'renders ILIKE by default and LIKE when declared case sensitive' do + sensitive_model = Class.new(MovementDetail) { matchable code: :reference, case_sensitive: true } + insensitive = MovementDetail.filterable(filters: { reference: { starts_with: 'INV' } }) + sensitive = sensitive_model.filterable(filters: { code: { starts_with: 'INV' } }) + visitor = Arel::Visitors::PostgreSQL.new(MovementDetail.connection) + + expect(visitor.compile(insensitive.arel.ast)).to include('ILIKE') + expect(visitor.compile(sensitive.arel.ast)).not_to include('ILIKE') + end + + it 'lets the request opt into case sensitivity' do + relation = MovementDetail.filterable(filters: { reference: { starts_with: 'INV', case_sensitive: true } }) + visitor = Arel::Visitors::PostgreSQL.new(MovementDetail.connection) + + expect(visitor.compile(relation.arel.ast)).not_to include('ILIKE') + end + + it 'treats a bare case_sensitive key as on' do + relation = MovementDetail.filterable(filters: { reference: { starts_with: 'INV', case_sensitive: '' } }) + visitor = Arel::Visitors::PostgreSQL.new(MovementDetail.connection) + + expect(visitor.compile(relation.arel.ast)).not_to include('ILIKE') + end + + it 'lets the request opt out of a declared sensitivity' do + model = Class.new(MovementDetail) { matchable code: :reference, case_sensitive: true } + relation = model.filterable(filters: { code: { starts_with: 'INV', case_sensitive: 'false' } }) + visitor = Arel::Visitors::PostgreSQL.new(MovementDetail.connection) + + expect(visitor.compile(relation.arel.ast)).to include('ILIKE') + end + + it 'falls back to the declaration on an arbitrary case_sensitive value' do + relation = MovementDetail.filterable(filters: { reference: { starts_with: 'INV', case_sensitive: 'maybe' } }) + visitor = Arel::Visitors::PostgreSQL.new(MovementDetail.connection) + + expect(visitor.compile(relation.arel.ast)).to include('ILIKE') + end + + it 'matches case sensitively end to end when the adapter enforces it' do + model = Class.new(MovementDetail) { matchable code: :reference, case_sensitive: true } + MovementDetail.connection.execute('PRAGMA case_sensitive_like = ON') + + expect(model.filterable(filters: { code: { starts_with: 'inv' } }).count).to eq(0) + expect(model.filterable(filters: { code: { starts_with: 'INV' } }).map(&:id)).to eq([invoice.id]) + ensure + MovementDetail.connection.execute('PRAGMA case_sensitive_like = OFF') + end + + it 'matches through an association target' do + detail = MovementDetail.create!(account: Account.create!(name: 'Main')) + + result = MovementDetail.filterable(filters: { matched_account: { contains: 'ai' } }) + expect(result).to contain_exactly(detail) + end + + it 'does not mutate the declaration hash' do + declaration = { code: :reference, case_sensitive: true } + Class.new(MovementDetail).matchable(declaration) + + expect(declaration).to eq(code: :reference, case_sensitive: true) + end + + it 'inherits the matchable declarations in a subclass' do + result = Class.new(MovementDetail).filterable(filters: { reference: { starts_with: 'INV' } }) + expect(result.map(&:id)).to eq([invoice.id]) + end + + it 'reports the accepted terms in applied_filters' do + report = MovementDetail.applied_filters( + filters: { reference: { starts_with: 'IN', ends_with: '0', contains: 'V', until: '' } } + ) + + expect(report[:reference]).to eq('starts_with' => 'IN', 'ends_with' => '0', 'contains' => 'V') + end + + it 'accepts ActionController::Parameters from a controller' do + params = ActionController::Parameters.new(filters: { reference: { starts_with: 'INV' } }) + expect(MovementDetail.filterable(params)).to contain_exactly(invoice) + end + end + describe 'association targets' do let!(:bank) { Bank.create!(name: 'BNP') } let!(:main) { Account.create!(name: 'Main', balance_cents: 1000, opened_on: Date.new(2026, 1, 1), bank: bank) } @@ -898,6 +1031,16 @@ def self.applied(filters_params, _scope) ) end + it 'reports a matchable declaration pointing to an unknown column' do + stub_const('BrokenMatch', Class.new(MovementDetail) { matchable :referemce }) + validator = BrokenMatch.filterable_declarations + + expect(validator).not_to be_valid + expect(validator.errors).to contain_exactly( + "matchable: 'referemce' maps to unknown column 'referemce' on BrokenMatch" + ) + end + it 'is valid for association targets resolving to real columns' do expect(Account.filterable_declarations).to be_valid end @@ -978,12 +1121,13 @@ def self.applied(filters_params, _scope) include Filterable::Concerns::Scopable include Filterable::Concerns::Togglable include Filterable::Concerns::Attachable + include Filterable::Concerns::Matchable 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).attachable(:document) + .scopable(:cheaper_than).togglable(:priced).attachable(:document).matchable(:reference) expect(model.datable_attribute_names[:value_date]).to eq(:value_date) expect(model.sortable_attribute_names[:value_date]).to eq(:value_date) @@ -992,12 +1136,14 @@ def self.applied(filters_params, _scope) expect(model.scopable_scope_names[:cheaper_than]).to eq(:cheaper_than) expect(model.togglable_scope_names[:priced]).to eq(:priced) expect(model.attachable_attachment_names[:document]).to eq(:document) + expect(model.matchable_attribute_names[:reference]).to eq(:reference) + expect(model.matchable_case_sensitive).to be_empty 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).scopable(max_price: :cheaper_than).togglable(has_price: :priced) - .attachable(file: :document) + .attachable(file: :document).matchable(code: :reference, case_sensitive: true) expect(model.datable_attribute_names[:date]).to eq(:value_date) expect(model.sortable_attribute_names[:amount]).to eq(:gross_amount_cents) @@ -1006,11 +1152,13 @@ def self.applied(filters_params, _scope) expect(model.scopable_scope_names[:max_price]).to eq(:cheaper_than) expect(model.togglable_scope_names[:has_price]).to eq(:priced) expect(model.attachable_attachment_names[:file]).to eq(:document) + expect(model.matchable_attribute_names[:code]).to eq(:reference) + expect(model.matchable_case_sensitive[:code]).to be(true) end it 'ignores an unsupported declaration' do model.datable(42).sortable(nil).equatable(3.14).rangeable(Object.new).scopable(42).togglable(nil) - .attachable(1.5) + .attachable(1.5).matchable(42) expect(model.datable_attribute_names).to be_empty expect(model.sortable_attribute_names).to be_empty @@ -1019,6 +1167,7 @@ def self.applied(filters_params, _scope) expect(model.scopable_scope_names).to be_empty expect(model.togglable_scope_names).to be_empty expect(model.attachable_attachment_names).to be_empty + expect(model.matchable_attribute_names).to be_empty end end diff --git a/spec/railtie_spec.rb b/spec/railtie_spec.rb index 9443036..33d2a2c 100644 --- a/spec/railtie_spec.rb +++ b/spec/railtie_spec.rb @@ -25,6 +25,7 @@ expect(ActiveRecord::Base).to respond_to(:scopable) expect(ActiveRecord::Base).to respond_to(:togglable) expect(ActiveRecord::Base).to respond_to(:attachable) + expect(ActiveRecord::Base).to respond_to(:matchable) 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 f9baaa3..c1e712f 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -74,6 +74,7 @@ class MovementDetail < ActiveRecord::Base attached_balance: { attachable: { account: :balance_cents } } togglable :priced scopable :cheaper_than + matchable :reference, matched_account: { account: :name } end RSpec.configure do |config|