Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,36 @@ scopable max_price: :cheaper_than
# filters[date][after] / filters[sort]=-amount / filters[ref] / filters[amount][min] / filters[max_price]
```

### Association targets

`datable`, `equatable` and `rangeable` declarations may target a column through
associations, with an explicit nested hash — the path is declared, never
inferred from names:

```ruby
class MovementDetail < ApplicationRecord
belongs_to :account

equatable account_name: { account: :name }
rangeable account_balance: { account: :balance_cents }
equatable bank_name: { account: { bank: :name } } # nested path
end

MovementDetail.filterable(filters: { account_name: 'Main' })
# INNER JOIN accounts ... WHERE accounts.name = 'Main'
```

Filtering joins the declared path — rows without the association drop out —
and merges the condition on the target model; a collection anywhere in the
path adds `DISTINCT`. An unresolvable target (unknown or polymorphic
association, ambiguous multi-key hash) narrows nothing, and the
[declarations validator](#validating-declarations) reports it. `sortable`
does not accept association targets.

For `delegated_type`, declare from the concrete side (`Message` →
`{ entry: :created_at }` through its `has_one`); filtering the polymorphic
direction goes through a `scopable`.

### Default filters

A model can declare default filter params, applied whenever the request does
Expand Down
1 change: 1 addition & 0 deletions lib/filterable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
require_relative 'filterable/concern'
require_relative 'filterable/attribute_normalization'
require_relative 'filterable/value_normalization'
require_relative 'filterable/target'
require_relative 'filterable/declarations_validator'
require_relative 'filterable/datable'
require_relative 'filterable/datable/after'
Expand Down
4 changes: 2 additions & 2 deletions lib/filterable/datable/after.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ module After
# @return [ActiveRecord::Relation] the narrowed relation.
def call(params, scope)
entries = Filterable::Datable.accepted(params, scope, :after)
entries.reduce(scope) do |sub_scope, (_name, column, _bounds, parsed)|
sub_scope.where(scope.arel_table[column].gt(parsed[:after]))
entries.reduce(scope) do |sub_scope, (_name, target, _bounds, parsed)|
Filterable::Target.narrow(sub_scope, target) { |field| field.gt(parsed[:after]) }
end
end

Expand Down
4 changes: 2 additions & 2 deletions lib/filterable/datable/before.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ module Before
# @return [ActiveRecord::Relation] the narrowed relation.
def call(params, scope)
entries = Filterable::Datable.accepted(params, scope, :before)
entries.reduce(scope) do |sub_scope, (_name, column, _bounds, parsed)|
sub_scope.where(scope.arel_table[column].lt(parsed[:before]))
entries.reduce(scope) do |sub_scope, (_name, target, _bounds, parsed)|
Filterable::Target.narrow(sub_scope, target) { |field| field.lt(parsed[:before]) }
end
end

Expand Down
7 changes: 4 additions & 3 deletions lib/filterable/datable/range.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ module Range
# @return [ActiveRecord::Relation] the narrowed relation.
def call(params, scope)
entries = Filterable::Datable.accepted(params, scope, :from, :to)
entries.reduce(scope) do |sub_scope, (_name, column, _bounds, parsed)|
field = scope.arel_table[column]
sub_scope.where(field.gteq(parsed[:from])).where(field.lteq(parsed[:to]))
entries.reduce(scope) do |sub_scope, (_name, target, _bounds, parsed)|
Filterable::Target.narrow(sub_scope, target) do |field|
field.gteq(parsed[:from]).and(field.lteq(parsed[:to]))
end
end
end

Expand Down
4 changes: 2 additions & 2 deletions lib/filterable/datable/since.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ module Since
# @return [ActiveRecord::Relation] the narrowed relation.
def call(params, scope)
entries = Filterable::Datable.accepted(params, scope, :since)
entries.reduce(scope) do |sub_scope, (_name, column, _bounds, parsed)|
sub_scope.where(scope.arel_table[column].lteq(parsed[:since]))
entries.reduce(scope) do |sub_scope, (_name, target, _bounds, parsed)|
Filterable::Target.narrow(sub_scope, target) { |field| field.lteq(parsed[:since]) }
end
end

Expand Down
55 changes: 50 additions & 5 deletions lib/filterable/declarations_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,61 @@ def errors
# @api private
# @param kind [Symbol] the declaration DSL to check.
# @return [Array<String>] one message per declaration of that kind whose
# column does not exist.
# target does not check out.
def column_errors(kind)
reader = "#{kind}_attribute_names"
return [] unless @model.respond_to?(reader)

@model.public_send(reader).filter_map do |public_name, column|
next if @model.column_names.include?(column.to_s)
@model.public_send(reader).filter_map do |public_name, target|
target_error(kind, public_name, target)
end
end

# The error for one declared target, nil when it checks out.
#
# @api private
# @param kind [Symbol] the declaration DSL being checked.
# @param public_name [Object] the declared public name.
# @param target [Object] the declared target — a column, or an association path.
# @return [String, nil] the error message, or nil.
def target_error(kind, public_name, target)
return column_error(kind, public_name, @model, target) unless target.is_a?(Hash)
return "#{kind}: '#{public_name}' cannot sort through an association" if kind == :sortable

path_error(kind, public_name, target)
end

"#{kind}: '#{public_name}' maps to unknown column '#{column}' on #{@model.name}"
# The error for one association-path target, nil when it checks out.
#
# @api private
# @param kind [Symbol] the declaration DSL being checked.
# @param public_name [Object] the declared public name.
# @param target [Hash] the declared association path.
# @return [String, nil] the error message, or nil.
def path_error(kind, public_name, target)
path, column = Filterable::Target.unpack(target)
if column.is_a?(Hash) || path.empty?
return "#{kind}: '#{public_name}' has an ambiguous association target on #{@model.name}"
end

klass, = Filterable::Target.resolve(@model, path)
return column_error(kind, public_name, klass, column) if klass

"#{kind}: '#{public_name}' walks an unresolvable association path '#{path.join(".")}' on #{@model.name}"
end

# The error for a column expected on a model, nil when it exists.
#
# @api private
# @param kind [Symbol] the declaration DSL being checked.
# @param public_name [Object] the declared public name.
# @param klass [Class] the model expected to own the column.
# @param column [Object] the declared column.
# @return [String, nil] the error message, or nil.
def column_error(kind, public_name, klass, column)
return if klass.column_names.include?(column.to_s)

"#{kind}: '#{public_name}' maps to unknown column '#{column}' on #{klass.name}"
end

# The error messages for one scope-backed declaration DSL.
Expand All @@ -76,7 +121,7 @@ def scope_errors(kind)
return [] unless @model.respond_to?(reader)

@model.public_send(reader).filter_map do |public_name, scope_name|
next if @model.respond_to?(scope_name)
next if !scope_name.is_a?(Hash) && @model.respond_to?(scope_name)

"#{kind}: '#{public_name}' maps to unknown scope '#{scope_name}' on #{@model.name}"
end
Expand Down
2 changes: 1 addition & 1 deletion lib/filterable/equatable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ module Equatable
def call(params, scope)
declared = scope.equatable_attribute_names
accepted(params, scope).reduce(scope) do |sub_scope, (name, value)|
sub_scope.where(declared[name] => value)
Filterable::Target.narrow_equal(sub_scope, declared[name], value)
end
end

Expand Down
4 changes: 2 additions & 2 deletions lib/filterable/rangeable/maximum.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ module Maximum
# @return [ActiveRecord::Relation] the narrowed relation.
def call(params, scope)
entries = Filterable::Rangeable.accepted(params, scope, :max)
entries.reduce(scope) do |sub_scope, (_name, column, _bounds, parsed)|
sub_scope.where(scope.arel_table[column].lteq(parsed[:max]))
entries.reduce(scope) do |sub_scope, (_name, target, _bounds, parsed)|
Filterable::Target.narrow(sub_scope, target) { |field| field.lteq(parsed[:max]) }
end
end

Expand Down
4 changes: 2 additions & 2 deletions lib/filterable/rangeable/minimum.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ module Minimum
# @return [ActiveRecord::Relation] the narrowed relation.
def call(params, scope)
entries = Filterable::Rangeable.accepted(params, scope, :min)
entries.reduce(scope) do |sub_scope, (_name, column, _bounds, parsed)|
sub_scope.where(scope.arel_table[column].gteq(parsed[:min]))
entries.reduce(scope) do |sub_scope, (_name, target, _bounds, parsed)|
Filterable::Target.narrow(sub_scope, target) { |field| field.gteq(parsed[:min]) }
end
end

Expand Down
5 changes: 3 additions & 2 deletions lib/filterable/scopable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,10 @@ def accepted(params, scope)
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])
scope_name = declared[name]
next if value.nil? || scope_name.is_a?(Hash) || !scope.respond_to?(scope_name)

[name, declared[name], value]
[name, scope_name, value]
end
end
end
Expand Down
2 changes: 1 addition & 1 deletion lib/filterable/sortable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def terms(params, scope)
term = raw.strip
sign, name = split_direction(term)
column = scope.sortable_attribute_names[name]
[sign, column, term] if column
[sign, column, term] if column && !column.is_a?(Hash)
end
end

Expand Down
120 changes: 120 additions & 0 deletions lib/filterable/target.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# frozen_string_literal: true

module Filterable
# Resolves declared targets: a bare column on the model itself, or a nested
# one-key hash walking associations to a column on the associated model
# (+{ account: { bank: :name } }+). Association filtering joins the declared
# path and merges the condition on the target model, adding DISTINCT when
# the path crosses a collection. An unresolvable target — unknown or
# polymorphic association, ambiguous multi-key hash — narrows nothing.
module Target
module_function

# Narrows the scope with an arel condition built on the target's column.
#
# @api private
# @param sub_scope [ActiveRecord::Relation] the relation being narrowed.
# @param target [Symbol, String, Hash] the declared target.
# @yield [field] builds the condition for the resolved column.
# @yieldparam field [Arel::Attributes::Attribute] the target column.
# @return [ActiveRecord::Relation] the narrowed relation.
def narrow(sub_scope, target)
return sub_scope.where(yield(sub_scope.arel_table[target])) unless target.is_a?(Hash)

path, column, klass, collection = dissect(sub_scope, target)
return sub_scope unless klass

join(sub_scope, path, collection, klass.where(yield(klass.arel_table[column])))
end

# Narrows the scope with a hash equality on the target's column, letting
# ActiveRecord cast the value and expand arrays into IN.
#
# @api private
# @param sub_scope [ActiveRecord::Relation] the relation being narrowed.
# @param target [Symbol, String, Hash] the declared target.
# @param value [Object] the accepted value.
# @return [ActiveRecord::Relation] the narrowed relation.
def narrow_equal(sub_scope, target, value)
return sub_scope.where(target => value) unless target.is_a?(Hash)

path, column, klass, collection = dissect(sub_scope, target)
return sub_scope unless klass

join(sub_scope, path, collection, klass.where(column => value))
end

# Splits a nested one-key hash target into its association path and column.
#
# @api private
# @param target [Hash] the declared association target.
# @return [Array(Array<Symbol>, Object)] the path and the final column —
# still a hash when the target is ambiguous.
def unpack(target)
path = []
current = target
while current.is_a?(Hash) && current.size == 1
name, current = current.first
path << name.to_sym
end
[path, current]
end

# Walks the association path from the model, refusing unknown and
# polymorphic reflections.
#
# @api private
# @param model [Class] the model the path starts from.
# @param path [Array<Symbol>] the association names to walk.
# @return [Array(Class, Boolean)] the target model — nil when the path is
# unresolvable — and whether the path crosses a collection.
def resolve(model, path)
collection = false
klass = path.reduce(model) do |current, name|
reflection = current.reflect_on_association(name)
break nil if reflection.nil? || reflection.polymorphic?

collection ||= reflection.collection?
reflection.klass
end
[klass, collection]
end

# Unpacks and resolves a hash target in one go.
#
# @api private
# @param sub_scope [ActiveRecord::Relation] the relation being narrowed.
# @param target [Hash] the declared association target.
# @return [Array(Array<Symbol>, Object, Class, Boolean)] path, column,
# target model (nil when malformed or unresolvable) and collection flag.
def dissect(sub_scope, target)
path, column = unpack(target)
return [path, column, nil, false] if column.is_a?(Hash) || path.empty?

klass, collection = resolve(sub_scope.klass, path)
[path, column, klass, collection]
end

# Joins the association path and merges the condition on the target model.
#
# @api private
# @param sub_scope [ActiveRecord::Relation] the relation being narrowed.
# @param path [Array<Symbol>] the association names to join.
# @param collection [Boolean] whether the path crosses a collection.
# @param condition [ActiveRecord::Relation] the condition on the target model.
# @return [ActiveRecord::Relation] the joined, narrowed relation.
def join(sub_scope, path, collection, condition)
joined = sub_scope.joins(joins_spec(path)).merge(condition)
collection ? joined.distinct : joined
end

# The nested +joins+ argument for an association path.
#
# @api private
# @param path [Array<Symbol>] the association names.
# @return [Symbol, Hash] e.g. +:account+, or +{ account: :bank }+ when nested.
def joins_spec(path)
path.reverse.reduce { |spec, name| { name => spec } }
end
end
end
5 changes: 3 additions & 2 deletions lib/filterable/togglable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,10 @@ def accepted(params, scope)
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])
scope_name = declared[name]
next unless toggled_on?(raw) && !scope_name.is_a?(Hash) && scope.respond_to?(scope_name)

[name, declared[name]]
[name, scope_name]
end
end

Expand Down
Loading
Loading