diff --git a/README.md b/README.md index 1112b91..3b6d614 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/filterable.rb b/lib/filterable.rb index 24ff92a..974c9d2 100644 --- a/lib/filterable.rb +++ b/lib/filterable.rb @@ -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' diff --git a/lib/filterable/datable/after.rb b/lib/filterable/datable/after.rb index 7180511..40f8a25 100644 --- a/lib/filterable/datable/after.rb +++ b/lib/filterable/datable/after.rb @@ -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 diff --git a/lib/filterable/datable/before.rb b/lib/filterable/datable/before.rb index 4f5dec8..89b1c20 100644 --- a/lib/filterable/datable/before.rb +++ b/lib/filterable/datable/before.rb @@ -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 diff --git a/lib/filterable/datable/range.rb b/lib/filterable/datable/range.rb index 16a3513..b8a3fe9 100644 --- a/lib/filterable/datable/range.rb +++ b/lib/filterable/datable/range.rb @@ -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 diff --git a/lib/filterable/datable/since.rb b/lib/filterable/datable/since.rb index 6379701..ded420e 100644 --- a/lib/filterable/datable/since.rb +++ b/lib/filterable/datable/since.rb @@ -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 diff --git a/lib/filterable/declarations_validator.rb b/lib/filterable/declarations_validator.rb index bc69032..15c2d94 100644 --- a/lib/filterable/declarations_validator.rb +++ b/lib/filterable/declarations_validator.rb @@ -53,16 +53,61 @@ def errors # @api private # @param kind [Symbol] the declaration DSL to check. # @return [Array] 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. @@ -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 diff --git a/lib/filterable/equatable.rb b/lib/filterable/equatable.rb index f700279..f6f912d 100644 --- a/lib/filterable/equatable.rb +++ b/lib/filterable/equatable.rb @@ -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 diff --git a/lib/filterable/rangeable/maximum.rb b/lib/filterable/rangeable/maximum.rb index bffa2e1..05d6ba6 100644 --- a/lib/filterable/rangeable/maximum.rb +++ b/lib/filterable/rangeable/maximum.rb @@ -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 diff --git a/lib/filterable/rangeable/minimum.rb b/lib/filterable/rangeable/minimum.rb index 8c87965..c7ac4a0 100644 --- a/lib/filterable/rangeable/minimum.rb +++ b/lib/filterable/rangeable/minimum.rb @@ -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 diff --git a/lib/filterable/scopable.rb b/lib/filterable/scopable.rb index 584ff0b..136d54a 100644 --- a/lib/filterable/scopable.rb +++ b/lib/filterable/scopable.rb @@ -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 diff --git a/lib/filterable/sortable.rb b/lib/filterable/sortable.rb index 4b87fcd..4c0656c 100644 --- a/lib/filterable/sortable.rb +++ b/lib/filterable/sortable.rb @@ -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 diff --git a/lib/filterable/target.rb b/lib/filterable/target.rb new file mode 100644 index 0000000..fb75bb2 --- /dev/null +++ b/lib/filterable/target.rb @@ -0,0 +1,187 @@ +# 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. + # + # A polymorphic association — a +delegated_type+ — is crossed by naming the + # concrete type as the second segment: +{ attachable: { bank: :name } }+ + # joins the concrete table and guards on the +*_type+ column. The hop must + # open the path and target a column directly on the concrete type. + # + # An unresolvable target — unknown association, unguarded polymorphic + # reflection, unknown concrete type, 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) + + resolution = dissect(sub_scope, target) + return sub_scope unless resolution + + klass = resolution[:klass] + apply(sub_scope, resolution, klass.where(yield(klass.arel_table[resolution[: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) + + resolution = dissect(sub_scope, target) + return sub_scope unless resolution + + apply(sub_scope, resolution, resolution[:klass].where(resolution[: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, 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 + + # Resolves an association path from the model into everything needed to + # join and narrow — shared by the runtime and the declarations validator so + # the two cannot drift. + # + # @api private + # @param model [Class] the model the path starts from. + # @param path [Array] the association names to walk. + # @return [Hash, nil] +:klass+, +:collection+, +:joins+ and the optional + # +:condition+ type guard; nil when the path is unresolvable. + def resolve(model, path) + return polymorphic_resolve(model, path) if polymorphic_hop?(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: klass, collection: collection, joins: joins_spec(path), condition: nil } if klass + end + + # Whether the path opens with a polymorphic association followed by its + # concrete type — the only supported polymorphic shape. + # + # @api private + # @param model [Class] the model the path starts from. + # @param path [Array] the association names to walk. + # @return [Boolean] + def polymorphic_hop?(model, path) + return false unless path.size == 2 + + reflection = model.reflect_on_association(path.first) + !reflection.nil? && reflection.polymorphic? + end + + # Resolves a polymorphic hop: an arel join onto the concrete type's table, + # guarded by the polymorphic +*_type+ column. + # + # @api private + # @param model [Class] the model owning the polymorphic association. + # @param path [Array(Symbol, Symbol)] the association name and concrete type. + # @return [Hash, nil] the resolution, or nil when the type is not a model. + def polymorphic_resolve(model, path) + reflection = model.reflect_on_association(path.first) + concrete = constantize(path.last) + return unless concrete + + { klass: concrete, collection: false, + joins: polymorphic_join(model, reflection, concrete), + condition: model.arel_table[reflection.foreign_type].eq(concrete.polymorphic_name) } + end + + # The arel INNER JOIN from the owner onto the concrete type's table. + # + # @api private + # @param model [Class] the model owning the polymorphic association. + # @param reflection [ActiveRecord::Reflection::AssociationReflection] the polymorphic reflection. + # @param concrete [Class] the declared concrete type. + # @return [Array] the join sources for +joins+. + def polymorphic_join(model, reflection, concrete) + owner = model.arel_table + table = concrete.arel_table + owner.join(table).on(table[concrete.primary_key].eq(owner[reflection.foreign_key])).join_sources + end + + # The model class named by a declared concrete-type segment, nil when the + # name does not resolve to an ActiveRecord model. + # + # @api private + # @param name [Symbol, String] the declared type segment, e.g. +:bank+. + # @return [Class, nil] + def constantize(name) + klass = name.to_s.camelize.safe_constantize + klass if klass.is_a?(Class) && klass < ActiveRecord::Base + 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 [Hash, nil] the resolution with its +:column+, or nil when the + # target is malformed or unresolvable. + def dissect(sub_scope, target) + path, column = unpack(target) + return if column.is_a?(Hash) || path.empty? + + resolve(sub_scope.klass, path)&.merge(column: column) + end + + # Joins the resolution onto the scope and merges the condition on the + # target model. + # + # @api private + # @param sub_scope [ActiveRecord::Relation] the relation being narrowed. + # @param resolution [Hash] a {resolve} result. + # @param condition [ActiveRecord::Relation] the condition on the target model. + # @return [ActiveRecord::Relation] the joined, narrowed relation. + def apply(sub_scope, resolution, condition) + joined = sub_scope.joins(resolution[:joins]).merge(condition) + joined = joined.where(resolution[:condition]) if resolution[:condition] + resolution[:collection] ? joined.distinct : joined + end + + # The nested +joins+ argument for an association path. + # + # @api private + # @param path [Array] 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 diff --git a/lib/filterable/togglable.rb b/lib/filterable/togglable.rb index 1a8b4a8..df895aa 100644 --- a/lib/filterable/togglable.rb +++ b/lib/filterable/togglable.rb @@ -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 diff --git a/spec/filterable_spec.rb b/spec/filterable_spec.rb index eabd67f..38cf963 100644 --- a/spec/filterable_spec.rb +++ b/spec/filterable_spec.rb @@ -252,6 +252,131 @@ def detail(value_date) 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) } + let!(:savings) { Account.create!(name: 'Savings', balance_cents: 5000) } + let!(:main_detail) { MovementDetail.create!(account: main, reference: 'M-1') } + let!(:savings_detail) { MovementDetail.create!(account: savings, reference: 'S-1') } + + it 'filters by equality through a belongs_to and drops rows without it' do + result = MovementDetail.filterable(filters: { account_name: 'Main' }) + expect(result).to contain_exactly(main_detail) + end + + it 'filters through a nested association path' do + result = MovementDetail.filterable(filters: { bank_name: 'BNP' }) + expect(result).to contain_exactly(main_detail) + end + + it 'applies numeric bounds on the associated column' do + result = MovementDetail.filterable(filters: { account_balance: { min: 2000 } }) + expect(result).to contain_exactly(savings_detail) + end + + it 'applies date bounds on the associated column' do + result = MovementDetail.filterable(filters: { account_opened: { after: '2025-12-31' } }) + expect(result).to contain_exactly(main_detail) + end + + it 'deduplicates rows when filtering through a collection' do + MovementDetail.create!(account: main, reference: 'DUP') + MovementDetail.create!(account: main, reference: 'DUP') + + result = Account.filterable(filters: { detail_reference: 'DUP' }) + expect(result.map(&:id)).to eq([main.id]) + end + + it 'ignores a declared target walking an unknown association instead of raising' do + model = Class.new(MovementDetail) { equatable broken: { nope: :name } } + result = nil + expect { result = model.filterable(filters: { broken: 'x' }) }.not_to raise_error + expect(result.count).to eq(5) + end + + it 'ignores a declared target through a polymorphic association instead of raising' do + model = Class.new(MovementDetail) { equatable att_name: { attachable: :name } } + result = nil + expect { result = model.filterable(filters: { att_name: 'x' }) }.not_to raise_error + expect(result.count).to eq(5) + end + + it 'ignores an ambiguous association target instead of raising' do + model = Class.new(MovementDetail) { equatable weird: { account: :name, bank: :name } } + + expect(model.filterable(filters: { weird: 'x' }).count).to eq(5) + end + + it 'ignores an association target declared sortable' do + model = Class.new(MovementDetail) { sortable acc_name: { account: :name } } + + expect(model.filterable(filters: { sort: '-acc_name' }).count).to eq(5) + end + + it 'ignores an association target declared scopable instead of raising' do + model = Class.new(MovementDetail) { scopable weird: { account: :name } } + result = nil + expect { result = model.filterable(filters: { weird: 'x' }) }.not_to raise_error + expect(result.count).to eq(5) + end + + it 'ignores an association target declared togglable instead of raising' do + model = Class.new(MovementDetail) { togglable weird: { account: :name } } + + expect { model.filterable(filters: { weird: 'true' }) }.not_to raise_error + end + + it 'filters through a delegated type, guarded by the concrete type column' do + x_bank = Bank.create!(id: 4242, name: 'X') + x_account = Account.create!(id: 4242, name: 'X') + bank_detail = MovementDetail.create!(attachable: x_bank) + MovementDetail.create!(attachable: x_account) + + result = MovementDetail.filterable(filters: { attached_bank_name: 'X' }) + expect(result).to contain_exactly(bank_detail) + end + + it 'applies bounds through a delegated type' do + rich = Account.create!(name: 'Rich', balance_cents: 9000) + rich_detail = MovementDetail.create!(attachable: rich) + + result = MovementDetail.filterable(filters: { attached_balance: { min: 8000 } }) + expect(result).to contain_exactly(rich_detail) + end + + it 'filters through a bare polymorphic belongs_to, without delegated_type' do + src = Account.create!(name: 'Src') + sourced_detail = MovementDetail.create!(source: src) + + result = MovementDetail.filterable(filters: { source_account_name: 'Src' }) + expect(result).to contain_exactly(sourced_detail) + end + + it 'ignores a delegated-type target with an unknown concrete type instead of raising' do + model = Class.new(MovementDetail) { equatable ghost: { attachable: { spaceship: :name } } } + result = nil + expect { result = model.filterable(filters: { ghost: 'x' }) }.not_to raise_error + expect(result.count).to eq(5) + end + + it 'ignores a delegated-type target whose type is not a model' do + model = Class.new(MovementDetail) { equatable ghost: { attachable: { string: :name } } } + + expect(model.filterable(filters: { ghost: 'x' }).count).to eq(5) + end + + it 'reports association-target filters in applied_filters' do + report = MovementDetail.applied_filters(filters: { account_name: 'Main' }) + + expect(report[:account_name]).to eq('Main') + end + + it 'accepts ActionController::Parameters from a controller' do + params = ActionController::Parameters.new(filters: { account_name: 'Main' }) + expect(MovementDetail.filterable(params)).to contain_exactly(main_detail) + end + end + describe 'scopable' do let!(:cheap) { MovementDetail.create!(gross_amount_cents: 100) } let!(:mid) { MovementDetail.create!(gross_amount_cents: 250) } @@ -703,6 +828,68 @@ def self.applied(filters_params, _scope) ) end + it 'is valid for association targets resolving to real columns' do + expect(Account.filterable_declarations).to be_valid + end + + it 'reports an association target walking an unresolvable path' do + stub_const('BrokenPath', Class.new(MovementDetail) { equatable acc: { nonexistent: :name } }) + validator = BrokenPath.filterable_declarations + + expect(validator).not_to be_valid + expect(validator.errors).to contain_exactly( + "equatable: 'acc' walks an unresolvable association path 'nonexistent' on BrokenPath" + ) + end + + it 'reports a polymorphic association target as unresolvable' do + stub_const('PolyPath', Class.new(MovementDetail) { equatable att: { attachable: :name } }) + + expect(PolyPath.filterable_declarations.errors).to contain_exactly( + "equatable: 'att' walks an unresolvable association path 'attachable' on PolyPath" + ) + end + + it 'reports an association target pointing to an unknown column on the target model' do + stub_const('BrokenColumn', Class.new(MovementDetail) { equatable acc: { account: :iban } }) + + expect(BrokenColumn.filterable_declarations.errors).to contain_exactly( + "equatable: 'acc' maps to unknown column 'iban' on Account" + ) + end + + it 'reports an ambiguous association target' do + stub_const('Ambiguous', Class.new(MovementDetail) { equatable acc: { account: :name, bank: :name } }) + + expect(Ambiguous.filterable_declarations.errors).to contain_exactly( + "equatable: 'acc' has an ambiguous association target on Ambiguous" + ) + end + + it 'reports a delegated-type target with an unknown concrete type' do + stub_const('GhostType', Class.new(MovementDetail) { equatable g: { attachable: { spaceship: :name } } }) + + expect(GhostType.filterable_declarations.errors).to contain_exactly( + "equatable: 'g' walks an unresolvable association path 'attachable.spaceship' on GhostType" + ) + end + + it 'reports a delegated-type target pointing to an unknown column on the concrete type' do + stub_const('GhostColumn', Class.new(MovementDetail) { equatable g: { attachable: { bank: :iban } } }) + + expect(GhostColumn.filterable_declarations.errors).to contain_exactly( + "equatable: 'g' maps to unknown column 'iban' on Bank" + ) + end + + it 'reports an association target declared sortable as unsupported' do + stub_const('SortPath', Class.new(MovementDetail) { sortable acc: { account: :name } }) + + expect(SortPath.filterable_declarations.errors).to contain_exactly( + "sortable: 'acc' cannot sort through an association" + ) + end + it 'is valid for a model without any declaration DSL' do model = Class.new { include Filterable::Concern } diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 38faead..dc20545 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -40,25 +40,61 @@ ActiveRecord::Schema.verbose = false ActiveRecord::Schema.define do + create_table :banks, force: true do |t| + t.string :name + end + + create_table :accounts, force: true do |t| + t.string :name + t.integer :balance_cents + t.date :opened_on + t.integer :bank_id + end + create_table :movement_details, force: true do |t| t.date :value_date t.date :booking_date t.integer :gross_amount_cents t.string :reference + t.integer :account_id + t.string :attachable_type + t.integer :attachable_id + t.string :source_type + t.integer :source_id end end # Note the absence of any `include`: the Railtie auto-included the engine into # ActiveRecord::Base, so the model only declares its whitelisted columns. +class Bank < ActiveRecord::Base + has_many :accounts +end + +class Account < ActiveRecord::Base + belongs_to :bank, optional: true + has_many :movement_details + + equatable :name, detail_reference: { movement_details: :reference } +end + class MovementDetail < ActiveRecord::Base + belongs_to :account, optional: true + belongs_to :source, polymorphic: true, optional: true + delegated_type :attachable, types: %w[Bank Account], optional: true + scope :priced, -> { where.not(gross_amount_cents: nil) } scope :cheaper_than, ->(cents) { where(gross_amount_cents: ...cents) } scope :costlier_than, ->(cents) { where(gross_amount_cents: cents..) } - datable :value_date, :booking_date + datable :value_date, :booking_date, account_opened: { account: :opened_on } sortable :value_date, :gross_amount_cents - equatable :reference, :gross_amount_cents - rangeable :gross_amount_cents + equatable :reference, :gross_amount_cents, + account_name: { account: :name }, bank_name: { account: { bank: :name } }, + attached_bank_name: { attachable: { bank: :name } }, + source_account_name: { source: { account: :name } } + rangeable :gross_amount_cents, + account_balance: { account: :balance_cents }, + attached_balance: { attachable: { account: :balance_cents } } togglable :priced scopable :cheaper_than end