From 64837ff1dae74734c5040f233a5b2bdfa3e0cac7 Mon Sep 17 00:00:00 2001 From: Jonathan PHILIPPE Date: Fri, 3 Jul 2026 18:15:34 +0200 Subject: [PATCH 1/3] feat(matchable): add whitelisted partial matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `matchable :reference` exposes starts_with/ends_with/contains under `filters[]` — three distinct keys so the sargable prefix variant is distinguishable from the full-scan ones, never a catch-all like - the term is LIKE-escaped through sanitize_sql_like (user % and _ match literally) and the wildcard placement is fixed by the key: the caller never controls the pattern - arel matches, case insensitive by default (ILIKE on PostgreSQL); `matchable :code, case_sensitive: true` opts a declaration into sensitivity — the option is peeled from the declaration hash, so it coexists with aliases without the kwargs capture pitfall; case_sensitive is a reserved word, documented - association targets, aliasing, inheritance, applied_filters and the declarations validator all come from the existing machinery --- lib/filterable.rb | 5 + lib/filterable/concerns/matchable.rb | 86 ++++++++++++++++ lib/filterable/declarations_validator.rb | 2 +- lib/filterable/matchable.rb | 70 +++++++++++++ lib/filterable/matchable/contains.rb | 38 +++++++ lib/filterable/matchable/ends_with.rb | 38 +++++++ lib/filterable/matchable/starts_with.rb | 38 +++++++ lib/filterable/railtie.rb | 1 + spec/filterable_spec.rb | 126 ++++++++++++++++++++++- spec/railtie_spec.rb | 1 + spec/spec_helper.rb | 1 + 11 files changed, 402 insertions(+), 4 deletions(-) create mode 100644 lib/filterable/concerns/matchable.rb create mode 100644 lib/filterable/matchable.rb create mode 100644 lib/filterable/matchable/contains.rb create mode 100644 lib/filterable/matchable/ends_with.rb create mode 100644 lib/filterable/matchable/starts_with.rb 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/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..ef10006 --- /dev/null +++ b/lib/filterable/matchable.rb @@ -0,0 +1,70 @@ +# 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+. + 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 and raw term. + def accepted(params, scope, key) + declared = scope.matchable_attribute_names + bounds(params, scope).filter_map do |name, terms| + term = terms[key] + [name, declared[name], term] if term.is_a?(String) && term.present? + end + 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, case + # sensitive when the attribute was declared so. + # + # @api private + # @param sub_scope [ActiveRecord::Relation] the relation being narrowed. + # @param scope [ActiveRecord::Relation] the relation being filtered. + # @param name [Object] the declared public name. + # @param target [Symbol, String, Hash] the declared target. + # @param pattern [String] the escaped LIKE pattern. + # @return [ActiveRecord::Relation] the narrowed relation. + def narrow(sub_scope, scope, name, target, pattern) + sensitive = scope.matchable_case_sensitive[name] ? true : false + 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..613c6b5 --- /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)| + pattern = "%#{Filterable::Matchable.escape(term)}%" + Filterable::Matchable.narrow(sub_scope, scope, name, target, pattern) + 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), 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..8e4999f --- /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)| + pattern = "%#{Filterable::Matchable.escape(term)}" + Filterable::Matchable.narrow(sub_scope, scope, name, target, pattern) + 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), 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..543a428 --- /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)| + pattern = "#{Filterable::Matchable.escape(term)}%" + Filterable::Matchable.narrow(sub_scope, scope, name, target, pattern) + 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), 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/spec/filterable_spec.rb b/spec/filterable_spec.rb index 3632ce1..63e0d0e 100644 --- a/spec/filterable_spec.rb +++ b/spec/filterable_spec.rb @@ -252,6 +252,110 @@ 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 '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 +1002,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 +1092,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 +1107,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 +1123,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 +1138,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| From 4ea723959025a43d85ed34193ce9d74aaf8d1e95 Mon Sep 17 00:00:00 2001 From: Jonathan PHILIPPE Date: Fri, 3 Jul 2026 18:16:20 +0200 Subject: [PATCH 2/3] docs(readme): document the matchable DSL --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.md b/README.md index a4f0205..c4c5649 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,32 @@ 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 (`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 From 39bcdcd9056c06b629312aed2fa0027f5d0b7b1d Mon Sep 17 00:00:00 2001 From: Jonathan PHILIPPE Date: Fri, 3 Jul 2026 18:58:42 +0200 Subject: [PATCH 3/3] feat(matchable): let the request override case sensitivity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the reserved `filters[][case_sensitive]` key overrides the declaration either way: strict true or bare key switches on, explicit false switches off — even on an attribute declared sensitive — and anything else falls back to the declaration - the boolean-param vocabulary moves to one home: Togglable gains FALSE_VALUES and toggled_off?, Attachable's local copy is gone --- README.md | 16 ++++++-- lib/filterable/attachable.rb | 5 +-- lib/filterable/matchable.rb | 50 ++++++++++++++++++++----- lib/filterable/matchable/contains.rb | 6 +-- lib/filterable/matchable/ends_with.rb | 6 +-- lib/filterable/matchable/starts_with.rb | 6 +-- lib/filterable/togglable.rb | 14 +++++++ spec/filterable_spec.rb | 29 ++++++++++++++ 8 files changed, 106 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index c4c5649..7f3dbd1 100644 --- a/README.md +++ b/README.md @@ -145,10 +145,18 @@ 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 (`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. +`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`) 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/matchable.rb b/lib/filterable/matchable.rb index ef10006..a61317a 100644 --- a/lib/filterable/matchable.rb +++ b/lib/filterable/matchable.rb @@ -5,7 +5,11 @@ module Filterable # 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+. + # 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 = '\\' @@ -34,15 +38,46 @@ def bounds(params, scope) # @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 and raw term. + # @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] - [name, declared[name], term] if term.is_a?(String) && term.present? + 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 @@ -52,18 +87,15 @@ def escape(term) ActiveRecord::Base.sanitize_sql_like(term) end - # Narrows the scope with a LIKE pattern on the target's column, case - # sensitive when the attribute was declared so. + # Narrows the scope with a LIKE pattern on the target's column. # # @api private # @param sub_scope [ActiveRecord::Relation] the relation being narrowed. - # @param scope [ActiveRecord::Relation] the relation being filtered. - # @param name [Object] the declared public name. # @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, scope, name, target, pattern) - sensitive = scope.matchable_case_sensitive[name] ? true : false + def narrow(sub_scope, target, pattern, sensitive) Filterable::Target.narrow(sub_scope, target) { |field| field.matches(pattern, ESCAPE, sensitive) } end end diff --git a/lib/filterable/matchable/contains.rb b/lib/filterable/matchable/contains.rb index 613c6b5..df41272 100644 --- a/lib/filterable/matchable/contains.rb +++ b/lib/filterable/matchable/contains.rb @@ -15,9 +15,9 @@ module Contains # @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)| + entries.reduce(scope) do |sub_scope, (_name, target, term, sensitive)| pattern = "%#{Filterable::Matchable.escape(term)}%" - Filterable::Matchable.narrow(sub_scope, scope, name, target, pattern) + Filterable::Matchable.narrow(sub_scope, target, pattern, sensitive) end end @@ -29,7 +29,7 @@ def call(params, scope) # @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), report| + entries.each_with_object({}) do |(name, _target, term, _sensitive), report| report[name] = { contains: term } end end diff --git a/lib/filterable/matchable/ends_with.rb b/lib/filterable/matchable/ends_with.rb index 8e4999f..b9c85f3 100644 --- a/lib/filterable/matchable/ends_with.rb +++ b/lib/filterable/matchable/ends_with.rb @@ -15,9 +15,9 @@ module EndsWith # @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)| + entries.reduce(scope) do |sub_scope, (_name, target, term, sensitive)| pattern = "%#{Filterable::Matchable.escape(term)}" - Filterable::Matchable.narrow(sub_scope, scope, name, target, pattern) + Filterable::Matchable.narrow(sub_scope, target, pattern, sensitive) end end @@ -29,7 +29,7 @@ def call(params, scope) # @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), report| + entries.each_with_object({}) do |(name, _target, term, _sensitive), report| report[name] = { ends_with: term } end end diff --git a/lib/filterable/matchable/starts_with.rb b/lib/filterable/matchable/starts_with.rb index 543a428..eeaf840 100644 --- a/lib/filterable/matchable/starts_with.rb +++ b/lib/filterable/matchable/starts_with.rb @@ -15,9 +15,9 @@ module StartsWith # @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)| + entries.reduce(scope) do |sub_scope, (_name, target, term, sensitive)| pattern = "#{Filterable::Matchable.escape(term)}%" - Filterable::Matchable.narrow(sub_scope, scope, name, target, pattern) + Filterable::Matchable.narrow(sub_scope, target, pattern, sensitive) end end @@ -29,7 +29,7 @@ def call(params, scope) # @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), report| + entries.each_with_object({}) do |(name, _target, term, _sensitive), report| report[name] = { starts_with: term } end end 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 63e0d0e..4cbd400 100644 --- a/spec/filterable_spec.rb +++ b/spec/filterable_spec.rb @@ -313,6 +313,35 @@ def detail(value_date) 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')