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
30 changes: 24 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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[<name>]`:

| 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
Expand Down
2 changes: 2 additions & 0 deletions lib/filterable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down
212 changes: 212 additions & 0 deletions lib/filterable/attachable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
# frozen_string_literal: true

module Filterable
# Filters records by their ActiveStorage attachments, declared with
# +attachable :document+. Reads these nested keys under +filters[<name>]+:
# +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 (+<name>_attachment(s)+, +<name>_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<Array(Object, Object, Symbol, Object, Object)>] 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[<name>]+.
# @return [Array<Array(Object, Object, Symbol, Object, Object)>] 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[<name>]+.
# @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)
collection = attachment_reflection(sub_scope, attachment).macro == :has_many_attached
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
# according to the attachment's macro.
#
# @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 the model has no
# such attachment.
def attachment_association(scope, 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, singular or plural
# according to the attachment's macro.
#
# @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)
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 +<name>_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
49 changes: 49 additions & 0 deletions lib/filterable/concerns/attachable.rb
Original file line number Diff line number Diff line change
@@ -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[<name>]+ 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<Symbol, String, Hash>] 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
25 changes: 24 additions & 1 deletion lib/filterable/declarations_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ class DeclarationsValidator
# is skipped.
SCOPE_KINDS = %i[scopable togglable].freeze

# The attachment-backed declaration DSLs to check, each exposing
# +<kind>_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
Expand All @@ -43,7 +48,8 @@ def valid?
# @return [Array<String>] 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
Expand Down Expand Up @@ -126,5 +132,22 @@ 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<String>] 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 @model.respond_to?(:reflect_on_attachment) && @model.reflect_on_attachment(attachment)

"#{kind}: '#{public_name}' maps to unknown attachment '#{attachment}' on #{@model.name}"
end
end
end
end
1 change: 1 addition & 0 deletions lib/filterable/railtie.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading