Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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[<attribute>]`:

| 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
Expand Down
5 changes: 5 additions & 0 deletions lib/filterable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down
5 changes: 1 addition & 4 deletions lib/filterable/attachable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
86 changes: 86 additions & 0 deletions lib/filterable/concerns/matchable.rb
Original file line number Diff line number Diff line change
@@ -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[<attribute>]+ 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<Symbol, String, Hash>] 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<Symbol, String, Hash>] 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
2 changes: 1 addition & 1 deletion lib/filterable/declarations_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class DeclarationsValidator
# The column-backed declaration DSLs to check, each exposing
# +<kind>_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
# +<kind>_scope_names+ on the model. A kind the model does not respond to
Expand Down
102 changes: 102 additions & 0 deletions lib/filterable/matchable.rb
Original file line number Diff line number Diff line change
@@ -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[<attribute>][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<Array(Object, Object, String, Boolean)>] 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[<attribute>]+.
# @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[<attribute>]+.
# @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
38 changes: 38 additions & 0 deletions lib/filterable/matchable/contains.rb
Original file line number Diff line number Diff line change
@@ -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[<attribute>][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
38 changes: 38 additions & 0 deletions lib/filterable/matchable/ends_with.rb
Original file line number Diff line number Diff line change
@@ -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[<attribute>][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
38 changes: 38 additions & 0 deletions lib/filterable/matchable/starts_with.rb
Original file line number Diff line number Diff line change
@@ -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[<attribute>][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
Loading
Loading