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
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,54 @@ 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 association, unknown
concrete type, ambiguous multi-key hash) narrows nothing, and the
[declarations validator](#validating-declarations) reports it. `sortable`
does not accept association targets.

#### Polymorphic associations and `delegated_type`

A polymorphic `belongs_to` — including the one behind `delegated_type` — is
crossed by naming the concrete type as the second segment:

```ruby
class Entry < ApplicationRecord
delegated_type :entryable, types: %w[Message Comment]

equatable message_subject: { entryable: { message: :subject } }
end

Entry.filterable(filters: { message_subject: 'hello' })
# INNER JOIN messages ON messages.id = entries.entryable_id
# WHERE entries.entryable_type = 'Message' AND messages.subject = 'hello'
```

The hop must open the path and target a column directly on the concrete type;
an unknown type narrows nothing and is reported by the validator. The reverse,
concrete direction (`Message` → `{ entry: :created_at }` through its
`has_one`) is an ordinary path.

### 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

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

"#{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
Loading
Loading