From d29e57858e3a7afefb7c428f7fa4198f3e507534 Mon Sep 17 00:00:00 2001 From: Jonathan PHILIPPE Date: Fri, 3 Jul 2026 15:26:15 +0200 Subject: [PATCH 1/4] feat(attachable): add ActiveStorage pre-filtering sugar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `attachable :document` exposes normed keys under `filters[]`: `present` (strict true or bare key keeps attached records, explicit false keeps unattached ones — absence was inexpressible with the INNER JOIN paths), `type` (blob content type, = or IN), `min_size`/`max_size` (inclusive blob byte size bounds) - everything resolves through the associations has_one_attached / has_many_attached generate — Rails' documented contract — so the gem still never references ActiveStorage; a name without those associations narrows nothing and the validator reports it - presence uses where.associated / where.missing (public AR API) with DISTINCT on has_many_attached; type and sizes delegate to the existing Target machinery; applied_filters reports the accepted raw keys grouped per public name - present follows the togglable semantics: nil/'' (bare key) toggle attached, 'garbage' is a no-op --- lib/filterable.rb | 2 + lib/filterable/attachable.rb | 189 +++++++++++++++++++++++ lib/filterable/concerns/attachable.rb | 49 ++++++ lib/filterable/declarations_validator.rb | 26 +++- lib/filterable/railtie.rb | 1 + spec/active_storage_spec.rb | 81 +++++++++- spec/filterable_spec.rb | 39 ++++- spec/railtie_spec.rb | 1 + 8 files changed, 382 insertions(+), 6 deletions(-) create mode 100644 lib/filterable/attachable.rb create mode 100644 lib/filterable/concerns/attachable.rb diff --git a/lib/filterable.rb b/lib/filterable.rb index 974c9d2..051a45b 100644 --- a/lib/filterable.rb +++ b/lib/filterable.rb @@ -9,6 +9,7 @@ require_relative 'filterable/value_normalization' require_relative 'filterable/target' require_relative 'filterable/declarations_validator' +require_relative 'filterable/attachable' require_relative 'filterable/datable' require_relative 'filterable/datable/after' require_relative 'filterable/datable/before' @@ -21,6 +22,7 @@ require_relative 'filterable/scopable' require_relative 'filterable/sortable' require_relative 'filterable/togglable' +require_relative 'filterable/concerns/attachable' require_relative 'filterable/concerns/datable' require_relative 'filterable/concerns/equatable' require_relative 'filterable/concerns/rangeable' diff --git a/lib/filterable/attachable.rb b/lib/filterable/attachable.rb new file mode 100644 index 0000000..1260e9a --- /dev/null +++ b/lib/filterable/attachable.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +module Filterable + # Filters records by their ActiveStorage attachments, declared with + # +attachable :document+. Reads these nested keys under +filters[]+: + # +present+ (strict true or bare key keeps attached records, an explicit + # false keeps unattached ones), +type+ (blob content type, +=+ or +IN+), + # +min_size+ / +max_size+ (inclusive blob byte size bounds). + # + # Everything resolves through the associations +has_one_attached+ / + # +has_many_attached+ generate (+_attachment(s)+, +_blob(s)+ — + # 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 + + module_function + + # Narrows the scope from each accepted attachment key. + # + # @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) + accepted(params, scope).reduce(scope) do |sub_scope, (_name, attachment, key, value, _raw)| + apply(sub_scope, attachment, key, value) + end + end + + # The attachment keys {call} would apply, grouped 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 keys. + def applied(params, scope) + accepted(params, scope).each_with_object({}) do |(name, _attachment, key, _value, raw), report| + (report[name] ||= {})[key] = raw + end + end + + # The declared entries whose keys carry something usable and whose model + # exposes the generated attachment associations — the single place deciding + # what applies, so {call} and {applied} cannot drift. + # + # @api private + # @param params [Hash, ActionController::Parameters] the nested +filters+ params. + # @param scope [ActiveRecord::Relation] the relation being filtered. + # @return [Array] public + # name, attachment name, key, prepared value and raw value. + def accepted(params, scope) + declared = scope.attachable_attachment_names + sliced = params.slice(*declared.keys) + sliced = sliced.to_unsafe_h if sliced.respond_to?(:to_unsafe_h) + sliced.flat_map do |name, bounds| + next [] unless bounds.is_a?(Hash) && attachment_association(scope, declared[name]) + + entries(name, declared[name], bounds) + end + end + + # The usable entries of one attachment's bounds hash. + # + # @api private + # @param name [Object] the declared public name. + # @param attachment [Object] the attachment name. + # @param bounds [Hash] the raw keys under +filters[]+. + # @return [Array] the accepted entries. + def entries(name, attachment, bounds) + [ + [:present, presence_value(bounds)], + [:type, Filterable::ValueNormalization.normalize(bounds[:type])], + [:min_size, Filterable::Rangeable.parse(bounds[:min_size])], + [:max_size, Filterable::Rangeable.parse(bounds[:max_size])] + ].filter_map { |key, value| [name, attachment, key, value, bounds[key]] unless value.nil? } + end + + # How a +present+ key reads: +:attached+ on a strict true or bare key, + # +:missing+ on an explicit false, nil otherwise. + # + # @api private + # @param bounds [Hash] the raw keys under +filters[]+. + # @return [Symbol, nil] + def presence_value(bounds) + return unless bounds.key?(:present) + + raw = bounds[:present] + return :attached if Filterable::Togglable.toggled_on?(raw) + + :missing if FALSE_VALUES.include?(raw) + end + + # Applies one accepted entry onto the scope. + # + # @api private + # @param sub_scope [ActiveRecord::Relation] the relation being narrowed. + # @param attachment [Object] the attachment name. + # @param key [Symbol] the accepted key. + # @param value [Object] the prepared value. + # @return [ActiveRecord::Relation] the narrowed relation. + def apply(sub_scope, attachment, key, value) + case key + when :present then presence(sub_scope, attachment, value) + when :type then content_type(sub_scope, attachment, value) + when :min_size then minimum_size(sub_scope, attachment, value) + when :max_size then maximum_size(sub_scope, attachment, value) + end + end + + # Keeps attached or unattached records through the generated attachment + # association. + # + # @api private + # @param sub_scope [ActiveRecord::Relation] the relation being narrowed. + # @param attachment [Object] the attachment name. + # @param value [Symbol] +:attached+ or +:missing+. + # @return [ActiveRecord::Relation] the narrowed relation. + def presence(sub_scope, attachment, value) + association = attachment_association(sub_scope, attachment) + return sub_scope.where.missing(association) if value == :missing + + attached = sub_scope.where.associated(association) + sub_scope.klass.reflect_on_association(association).collection? ? attached.distinct : attached + end + + # Narrows on the blob content type through the generated blob association. + # + # @api private + # @param sub_scope [ActiveRecord::Relation] the relation being narrowed. + # @param attachment [Object] the attachment name. + # @param value [Object] the normalized content type(s). + # @return [ActiveRecord::Relation] the narrowed relation. + def content_type(sub_scope, attachment, value) + Filterable::Target.narrow_equal(sub_scope, blob_target(sub_scope, attachment, :content_type), value) + end + + # Narrows on the blob byte size, at or above the bound. + # + # @api private + # @param sub_scope [ActiveRecord::Relation] the relation being narrowed. + # @param attachment [Object] the attachment name. + # @param value [Numeric] the parsed bound. + # @return [ActiveRecord::Relation] the narrowed relation. + def minimum_size(sub_scope, attachment, value) + Filterable::Target.narrow(sub_scope, blob_target(sub_scope, attachment, :byte_size)) do |field| + field.gteq(value) + end + end + + # Narrows on the blob byte size, at or below the bound. + # + # @api private + # @param sub_scope [ActiveRecord::Relation] the relation being narrowed. + # @param attachment [Object] the attachment name. + # @param value [Numeric] the parsed bound. + # @return [ActiveRecord::Relation] the narrowed relation. + def maximum_size(sub_scope, attachment, value) + Filterable::Target.narrow(sub_scope, blob_target(sub_scope, attachment, :byte_size)) do |field| + field.lteq(value) + end + end + + # The generated attachment association for a name, singular or plural. + # + # @api private + # @param scope [ActiveRecord::Relation] the relation being filtered. + # @param attachment [Object] the attachment name. + # @return [Symbol, nil] the association name, or nil when absent. + def attachment_association(scope, attachment) + [:"#{attachment}_attachment", :"#{attachment}_attachments"] + .find { |name| scope.klass.reflect_on_association(name) } + end + + # The association-target hash pointing at a blob column. + # + # @api private + # @param scope [ActiveRecord::Relation] the relation being filtered. + # @param attachment [Object] the attachment name. + # @param column [Symbol] the blob column. + # @return [Hash] a target for {Filterable::Target}. + def blob_target(scope, attachment, column) + blob = attachment_association(scope, attachment).to_s.sub(/_attachment(s?)\z/, '_blob\1').to_sym + { blob => column } + end + end +end diff --git a/lib/filterable/concerns/attachable.rb b/lib/filterable/concerns/attachable.rb new file mode 100644 index 0000000..5de8305 --- /dev/null +++ b/lib/filterable/concerns/attachable.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Filterable + module Concerns + # Adds ActiveStorage attachment filtering to a Filterable::Concern model. + # Declare the attachments with +attachable :document+ (or +attachable + # public_name: :attachment_name+ to alias), then the present/type/min_size/ + # max_size keys under +filters[]+ operate on those attachments only. + module Attachable + extend ActiveSupport::Concern + + included do + add_filter Filterable::Attachable + end + + class_methods do + # The declared attachable names, mapping each public name to its + # attachment, 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 => attachment name. + def attachable_attachment_names + @attachable_attachment_names ||= + if superclass.respond_to?(:attachable_attachment_names) + superclass.attachable_attachment_names.clone + else + {}.with_indifferent_access + end + end + + # Declares one or more attachments as attachable. A bare name maps onto + # the attachment of the same name; a hash aliases a public name to a + # different attachment. + # + # @api public + # @param attachment_names [Array] the attachments to expose. + # @return [self] so calls can be chained. + def attachable(*attachment_names) + attachment_names.flatten.each do |attachment_name| + attachable_attachment_names.merge!(Filterable::AttributeNormalization.normalize(attachment_name)) + end + + self + end + end + end + end +end diff --git a/lib/filterable/declarations_validator.rb b/lib/filterable/declarations_validator.rb index 15c2d94..b3ce684 100644 --- a/lib/filterable/declarations_validator.rb +++ b/lib/filterable/declarations_validator.rb @@ -20,6 +20,11 @@ class DeclarationsValidator # is skipped. SCOPE_KINDS = %i[scopable togglable].freeze + # The attachment-backed declaration DSLs to check, each exposing + # +_attachment_names+ on the model. A kind the model does not respond + # to is skipped. + ATTACHMENT_KINDS = %i[attachable].freeze + # Builds a validator over one model's declarations. # # @api public @@ -43,7 +48,8 @@ def valid? # @return [Array] one message per broken declaration; empty when valid. def errors @errors ||= KINDS.flat_map { |kind| column_errors(kind) } + - SCOPE_KINDS.flat_map { |kind| scope_errors(kind) } + SCOPE_KINDS.flat_map { |kind| scope_errors(kind) } + + ATTACHMENT_KINDS.flat_map { |kind| attachment_errors(kind) } end private @@ -126,5 +132,23 @@ def scope_errors(kind) "#{kind}: '#{public_name}' maps to unknown scope '#{scope_name}' on #{@model.name}" end end + + # The error messages for one attachment-backed declaration DSL. + # + # @api private + # @param kind [Symbol] the declaration DSL to check. + # @return [Array] one message per declaration of that kind whose + # generated attachment associations do not exist. + def attachment_errors(kind) + reader = "#{kind}_attachment_names" + return [] unless @model.respond_to?(reader) + + @model.public_send(reader).filter_map do |public_name, attachment| + next if [:"#{attachment}_attachment", :"#{attachment}_attachments"] + .any? { |name| @model.reflect_on_association(name) } + + "#{kind}: '#{public_name}' maps to unknown attachment '#{attachment}' on #{@model.name}" + end + end end end diff --git a/lib/filterable/railtie.rb b/lib/filterable/railtie.rb index 1f5e163..80db662 100644 --- a/lib/filterable/railtie.rb +++ b/lib/filterable/railtie.rb @@ -19,6 +19,7 @@ class Railtie < Rails::Railtie initializer 'filterable.active_record' do ActiveSupport.on_load(:active_record) do include Filterable::Concern + include Filterable::Concerns::Attachable include Filterable::Concerns::Datable include Filterable::Concerns::Equatable include Filterable::Concerns::Rangeable diff --git a/spec/active_storage_spec.rb b/spec/active_storage_spec.rb index 954163f..8e7599b 100644 --- a/spec/active_storage_spec.rb +++ b/spec/active_storage_spec.rb @@ -60,18 +60,19 @@ class Contract < ActiveRecord::Base datable document_since: { document_attachment: :created_at } equatable document_type: { document_blob: :content_type }, annex_type: { annexes_blobs: :content_type } + attachable :document, annex: :annexes end RSpec.describe 'ActiveStorage integration' do - def blob(content_type) + def blob(content_type, byte_size = 1) ActiveStorage::Blob.create!( key: SecureRandom.base36(28), filename: 'file', content_type: content_type, - byte_size: 1, checksum: 'x', service_name: 'test' + byte_size: byte_size, checksum: 'x', service_name: 'test' ) end - def attach(record, name, content_type) - ActiveStorage::Attachment.create!(name: name, record: record, blob: blob(content_type)) + def attach(record, name, content_type, byte_size = 1) + ActiveStorage::Attachment.create!(name: name, record: record, blob: blob(content_type, byte_size)) end let!(:pdf_contract) { Contract.create!(label: 'pdf') } @@ -113,4 +114,76 @@ def attach(record, name, content_type) it 'validates the declarations against the generated associations' do expect(Contract.filterable_declarations).to be_valid end + + describe 'attachable sugar' do + it 'keeps attached records on a strict true present key' do + result = Contract.filterable(filters: { document: { present: 'true' } }) + + expect(result).to contain_exactly(pdf_contract, png_contract) + end + + it 'keeps attached records on a bare present key, nil or blank' do + ['', nil].each do |value| + result = Contract.filterable(filters: { document: { present: value } }) + + expect(result).to contain_exactly(pdf_contract, png_contract) + end + end + + it 'keeps unattached records on an explicit false present key' do + result = Contract.filterable(filters: { document: { present: 'false' } }) + + expect(result).to contain_exactly(bare_contract) + end + + it 'ignores an arbitrary present value' do + result = Contract.filterable(filters: { document: { present: 'garbage' } }) + + expect(result).to contain_exactly(pdf_contract, png_contract, bare_contract) + end + + it 'filters by blob content type' do + result = Contract.filterable(filters: { document: { type: 'application/pdf' } }) + + expect(result).to contain_exactly(pdf_contract) + end + + it 'filters by an array of blob content types' do + result = Contract.filterable(filters: { document: { type: %w[application/pdf image/png] } }) + + expect(result).to contain_exactly(pdf_contract, png_contract) + end + + it 'applies inclusive byte size bounds' do + attach(pdf_contract, 'annexes', 'text/csv', 100) + attach(png_contract, 'annexes', 'text/csv', 5000) + + result = Contract.filterable(filters: { annex: { min_size: 1000 } }) + expect(result.map(&:id)).to eq([png_contract.id]) + + result = Contract.filterable(filters: { annex: { max_size: '999' } }) + expect(result.map(&:id)).to eq([pdf_contract.id]) + end + + it 'combines present and type under the same name' do + result = Contract.filterable(filters: { document: { present: '1', type: 'application/pdf' } }) + + expect(result).to contain_exactly(pdf_contract) + end + + it 'deduplicates records on presence through has_many_attached' do + attach(pdf_contract, 'annexes', 'text/csv') + attach(pdf_contract, 'annexes', 'text/csv') + + result = Contract.filterable(filters: { annex: { present: '1' } }) + + expect(result.map(&:id)).to eq([pdf_contract.id]) + end + + it 'reports the accepted raw values in applied_filters' do + report = Contract.applied_filters(filters: { document: { present: '1', type: 'application/pdf', min_size: 'x' } }) + + expect(report[:document]).to eq('present' => '1', 'type' => 'application/pdf') + end + end end diff --git a/spec/filterable_spec.rb b/spec/filterable_spec.rb index 38cf963..2979a9e 100644 --- a/spec/filterable_spec.rb +++ b/spec/filterable_spec.rb @@ -828,6 +828,16 @@ def self.applied(filters_params, _scope) ) end + it 'reports an attachable declaration without the generated attachment associations' do + stub_const('NoAttach', Class.new(MovementDetail) { attachable :document }) + validator = NoAttach.filterable_declarations + + expect(validator).not_to be_valid + expect(validator.errors).to contain_exactly( + "attachable: 'document' maps to unknown attachment 'document' on NoAttach" + ) + end + it 'is valid for association targets resolving to real columns' do expect(Account.filterable_declarations).to be_valid end @@ -907,12 +917,13 @@ def self.applied(filters_params, _scope) include Filterable::Concerns::Rangeable include Filterable::Concerns::Scopable include Filterable::Concerns::Togglable + include Filterable::Concerns::Attachable 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) + .scopable(:cheaper_than).togglable(:priced).attachable(:document) expect(model.datable_attribute_names[:value_date]).to eq(:value_date) expect(model.sortable_attribute_names[:value_date]).to eq(:value_date) @@ -920,11 +931,13 @@ def self.applied(filters_params, _scope) expect(model.rangeable_attribute_names[:gross_amount_cents]).to eq(:gross_amount_cents) 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) 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) expect(model.datable_attribute_names[:date]).to eq(:value_date) expect(model.sortable_attribute_names[:amount]).to eq(:gross_amount_cents) @@ -932,10 +945,12 @@ def self.applied(filters_params, _scope) expect(model.rangeable_attribute_names[:amount]).to eq(:gross_amount_cents) 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) 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) expect(model.datable_attribute_names).to be_empty expect(model.sortable_attribute_names).to be_empty @@ -943,6 +958,28 @@ def self.applied(filters_params, _scope) expect(model.rangeable_attribute_names).to be_empty expect(model.scopable_scope_names).to be_empty expect(model.togglable_scope_names).to be_empty + expect(model.attachable_attachment_names).to be_empty + end + end + + describe 'attachable without ActiveStorage' do + it 'ignores a declared attachment when the model has no generated associations' do + model = Class.new(MovementDetail) { attachable :document } + result = nil + expect { result = model.filterable(filters: { document: { present: 'true' } }) }.not_to raise_error + expect(result.count).to eq(3) + end + + it 'ignores a scalar where the attachment keys are expected instead of raising' do + model = Class.new(MovementDetail) { attachable :document } + + expect { model.filterable(filters: { document: 'x' }) }.not_to raise_error + end + + it 'stays out of applied_filters' do + model = Class.new(MovementDetail) { attachable :document } + + expect(model.applied_filters(filters: { document: { present: 'true' } })).to be_empty end end diff --git a/spec/railtie_spec.rb b/spec/railtie_spec.rb index 7bb69d8..9443036 100644 --- a/spec/railtie_spec.rb +++ b/spec/railtie_spec.rb @@ -24,6 +24,7 @@ expect(ActiveRecord::Base).to respond_to(:rangeable) expect(ActiveRecord::Base).to respond_to(:scopable) expect(ActiveRecord::Base).to respond_to(:togglable) + expect(ActiveRecord::Base).to respond_to(:attachable) end it 'leaves a model that declares nothing as a pass-through' do From 9632e59832d49b96ef9e7ee84902e645dcc2385e Mon Sep 17 00:00:00 2001 From: Jonathan PHILIPPE Date: Fri, 3 Jul 2026 15:27:04 +0200 Subject: [PATCH 2/4] docs(readme): document the attachable DSL --- README.md | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 7ad0895..2e9a6e1 100644 --- a/README.md +++ b/README.md @@ -217,19 +217,37 @@ 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. -#### ActiveStorage +### Attachments (`Attachable`) -The associations `has_one_attached` / `has_many_attached` generate are -concrete from the owner's side, so they are ordinary paths — no polymorphic -hop needed: +ActiveStorage attachments have their own declaration. Each declared attachment +accepts these nested keys under `filters[]`: + +| Key | Meaning | +|------------------------|----------------------------------------------------| +| `present` | strict true or bare key → attached; explicit false (`'false'`, `'0'`) → unattached | +| `type` | blob content type — scalar `=`, array `IN` | +| `min_size` / `max_size`| inclusive blob byte size bounds | ```ruby class Contract < ApplicationRecord has_one_attached :document - equatable document_type: { document_blob: :content_type } - datable document_since: { document_attachment: :created_at } + attachable :document end + +Contract.filterable(filters: { document: { present: true } }) +Contract.filterable(filters: { document: { type: %w[application/pdf image/png], max_size: 5_000_000 } }) +``` + +Everything resolves through the associations `has_one_attached` / +`has_many_attached` generate, so the gem still has no ActiveStorage +dependency; a declared name without those associations narrows nothing, and +the [declarations validator](#validating-declarations) reports it. Collections +(`has_many_attached`) deduplicate automatically. For anything beyond these +keys, the generated associations remain ordinary paths: + +```ruby +datable document_since: { document_attachment: :created_at } ``` ### Default filters From 196b1e57b06c9eafb6bbbc2e7783c6409a186c26 Mon Sep 17 00:00:00 2001 From: Jonathan PHILIPPE Date: Fri, 3 Jul 2026 15:32:00 +0200 Subject: [PATCH 3/4] refactor(attachable): resolve attachments through the reflection registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - legitimacy now comes from ActiveStorage's own public reflection API (reflect_on_attachment / attachment_reflections) instead of probing the generated association name shapes; singular/plural derives from the reflection macro - fixes a false positive: an ordinary association that happens to be called _attachment no longer qualifies as an attachment — proven by a spec that failed against the name-shape probing - the generated names are still used to build the join (that is the documented contract has_one_attached publishes), but never to decide whether something is an attachment --- lib/filterable/attachable.rb | 21 ++++++++++++++++----- lib/filterable/declarations_validator.rb | 3 +-- spec/filterable_spec.rb | 14 ++++++++++++++ 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/lib/filterable/attachable.rb b/lib/filterable/attachable.rb index 1260e9a..1eddbaf 100644 --- a/lib/filterable/attachable.rb +++ b/lib/filterable/attachable.rb @@ -123,7 +123,8 @@ def presence(sub_scope, attachment, value) return sub_scope.where.missing(association) if value == :missing attached = sub_scope.where.associated(association) - sub_scope.klass.reflect_on_association(association).collection? ? attached.distinct : attached + collection = sub_scope.klass.reflect_on_attachment(attachment).macro == :has_many_attached + collection ? attached.distinct : attached end # Narrows on the blob content type through the generated blob association. @@ -163,15 +164,25 @@ def maximum_size(sub_scope, attachment, value) end end - # The generated attachment association for a name, singular or plural. + # The generated attachment association for a name, singular or plural + # according to the attachment's macro. Legitimacy comes from ActiveStorage's + # own reflection registry, never from the shape of an association name — an + # ordinary association that happens to be called +_attachment+ does + # not qualify. # # @api private # @param scope [ActiveRecord::Relation] the relation being filtered. # @param attachment [Object] the attachment name. - # @return [Symbol, nil] the association name, or nil when absent. + # @return [Symbol, nil] the association name, or nil when the model has no + # such attachment. def attachment_association(scope, attachment) - [:"#{attachment}_attachment", :"#{attachment}_attachments"] - .find { |name| scope.klass.reflect_on_association(name) } + klass = scope.klass + return unless klass.respond_to?(:reflect_on_attachment) + + reflection = klass.reflect_on_attachment(attachment) + return unless reflection + + reflection.macro == :has_one_attached ? :"#{attachment}_attachment" : :"#{attachment}_attachments" end # The association-target hash pointing at a blob column. diff --git a/lib/filterable/declarations_validator.rb b/lib/filterable/declarations_validator.rb index b3ce684..1a4f83a 100644 --- a/lib/filterable/declarations_validator.rb +++ b/lib/filterable/declarations_validator.rb @@ -144,8 +144,7 @@ def attachment_errors(kind) return [] unless @model.respond_to?(reader) @model.public_send(reader).filter_map do |public_name, attachment| - next if [:"#{attachment}_attachment", :"#{attachment}_attachments"] - .any? { |name| @model.reflect_on_association(name) } + next if @model.respond_to?(:reflect_on_attachment) && @model.reflect_on_attachment(attachment) "#{kind}: '#{public_name}' maps to unknown attachment '#{attachment}' on #{@model.name}" end diff --git a/spec/filterable_spec.rb b/spec/filterable_spec.rb index 2979a9e..95f3728 100644 --- a/spec/filterable_spec.rb +++ b/spec/filterable_spec.rb @@ -981,6 +981,20 @@ def self.applied(filters_params, _scope) expect(model.applied_filters(filters: { document: { present: 'true' } })).to be_empty end + + it 'does not mistake an ordinary association named like an attachment' do + stub_const('FakeAttach', Class.new(MovementDetail) do + belongs_to :document_attachment, class_name: 'Account', optional: true + attachable :document + end) + + result = nil + expect { result = FakeAttach.filterable(filters: { document: { present: 'true' } }) }.not_to raise_error + expect(result.count).to eq(3) + expect(FakeAttach.filterable_declarations.errors).to contain_exactly( + "attachable: 'document' maps to unknown attachment 'document' on FakeAttach" + ) + end end describe Filterable::ValueNormalization, '.normalize' do From 4dca17a806d98c2162f9e930c9353003464c12d5 Mon Sep 17 00:00:00 2001 From: Jonathan PHILIPPE Date: Fri, 3 Jul 2026 15:34:21 +0200 Subject: [PATCH 4/4] refactor(attachable): derive blob associations from the reflection macro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the blob association name came from a regex on the attachment association string; both names now derive from the attachment reflection's macro through a single attachment_reflection helper — the last name-shape logic is gone --- lib/filterable/attachable.rb | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/lib/filterable/attachable.rb b/lib/filterable/attachable.rb index 1eddbaf..6c7e7af 100644 --- a/lib/filterable/attachable.rb +++ b/lib/filterable/attachable.rb @@ -123,7 +123,7 @@ def presence(sub_scope, attachment, value) return sub_scope.where.missing(association) if value == :missing attached = sub_scope.where.associated(association) - collection = sub_scope.klass.reflect_on_attachment(attachment).macro == :has_many_attached + collection = attachment_reflection(sub_scope, attachment).macro == :has_many_attached collection ? attached.distinct : attached end @@ -165,10 +165,7 @@ def maximum_size(sub_scope, attachment, value) end # The generated attachment association for a name, singular or plural - # according to the attachment's macro. Legitimacy comes from ActiveStorage's - # own reflection registry, never from the shape of an association name — an - # ordinary association that happens to be called +_attachment+ does - # not qualify. + # according to the attachment's macro. # # @api private # @param scope [ActiveRecord::Relation] the relation being filtered. @@ -176,16 +173,14 @@ def maximum_size(sub_scope, attachment, value) # @return [Symbol, nil] the association name, or nil when the model has no # such attachment. def attachment_association(scope, attachment) - klass = scope.klass - return unless klass.respond_to?(:reflect_on_attachment) - - reflection = klass.reflect_on_attachment(attachment) + reflection = attachment_reflection(scope, attachment) return unless reflection reflection.macro == :has_one_attached ? :"#{attachment}_attachment" : :"#{attachment}_attachments" end - # The association-target hash pointing at a blob column. + # The association-target hash pointing at a blob column, singular or plural + # according to the attachment's macro. # # @api private # @param scope [ActiveRecord::Relation] the relation being filtered. @@ -193,8 +188,25 @@ def attachment_association(scope, attachment) # @param column [Symbol] the blob column. # @return [Hash] a target for {Filterable::Target}. def blob_target(scope, attachment, column) - blob = attachment_association(scope, attachment).to_s.sub(/_attachment(s?)\z/, '_blob\1').to_sym - { blob => column } + one = attachment_reflection(scope, attachment).macro == :has_one_attached + { (one ? :"#{attachment}_blob" : :"#{attachment}_blobs") => column } + end + + # The attachment reflection from ActiveStorage's own registry — legitimacy + # never comes from the shape of an association name, so an ordinary + # association that happens to be called +_attachment+ does not + # qualify. + # + # @api private + # @param scope [ActiveRecord::Relation] the relation being filtered. + # @param attachment [Object] the attachment name. + # @return [Object, nil] the attachment reflection, or nil when the model + # has no such attachment (or no ActiveStorage at all). + def attachment_reflection(scope, attachment) + klass = scope.klass + return unless klass.respond_to?(:reflect_on_attachment) + + klass.reflect_on_attachment(attachment) end end end