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
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ 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` |
| `type` | blob content type — scalar `=`, array `IN`, or the `image` shortcut |
| `min_size` / `max_size`| inclusive blob byte size bounds |

```ruby
Expand All @@ -237,8 +237,15 @@ end

Contract.filterable(filters: { document: { present: true } })
Contract.filterable(filters: { document: { type: %w[application/pdf image/png], max_size: 5_000_000 } })
Contract.filterable(filters: { document: { type: 'image' } })
```

The `image` shortcut expands at query time against
`ActiveStorage.variable_content_types` + `web_image_content_types` (a real
content type always carries a slash, so the name is unambiguous), and composes
inside arrays next to literal types. An unknown literal stays a plain equality
that matches nothing; an unknown symbol narrows nothing.

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
Expand Down
35 changes: 34 additions & 1 deletion lib/filterable/attachable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ module Attachable
# The explicit values that flip +present+ to its unattached side.
FALSE_VALUES = [false, 0, '0', 'false'].freeze

# The symbolic +type+ shortcuts, each expanded lazily at query time against
# ActiveStorage's configured content-type lists — never frozen at boot, and
# only ever called once the model proved it has real attachments, so the
# gem still loads without ActiveStorage.
TYPE_SHORTCUTS = {
'image' => -> { ActiveStorage.variable_content_types | ActiveStorage.web_image_content_types }
}.freeze

module_function

# Narrows the scope from each accepted attachment key.
Expand Down Expand Up @@ -72,12 +80,37 @@ def accepted(params, scope)
def entries(name, attachment, bounds)
[
[:present, presence_value(bounds)],
[:type, Filterable::ValueNormalization.normalize(bounds[:type])],
[:type, content_types(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

# The normalized +type+ value, with symbolic shortcuts expanded. A real
# content type always carries a slash, so a known shortcut name is
# unambiguous; an unknown literal stays a plain equality that matches
# nothing, never a silent no-op.
#
# @api private
# @param raw [Object] the raw +type+ value.
# @return [String, Array, nil] the usable content type(s), or nil.
def content_types(raw)
Filterable::ValueNormalization.normalize(expand_types(raw))
end

# Expands every known shortcut in a raw +type+ value, leaving everything
# else untouched.
#
# @api private
# @param raw [Object] the raw +type+ value, scalar or array.
# @return [Object] the value with shortcuts replaced by content-type lists.
def expand_types(raw)
return raw.flat_map { |value| expand_types(value) } if raw.is_a?(Array)

shortcut = TYPE_SHORTCUTS[raw.to_s]
shortcut ? shortcut.call : raw
end

# How a +present+ key reads: +:attached+ on a strict true or bare key,
# +:missing+ on an explicit false, nil otherwise.
#
Expand Down
26 changes: 26 additions & 0 deletions spec/active_storage_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,32 @@ def attach(record, name, content_type, byte_size = 1)
expect(result).to contain_exactly(pdf_contract, png_contract)
end

it 'expands the image shortcut against the configured content types' do
[:image, 'image'].each do |shortcut|
result = Contract.filterable(filters: { document: { type: shortcut } })

expect(result).to contain_exactly(png_contract)
end
end

it 'expands the image shortcut inside an array, next to literal types' do
result = Contract.filterable(filters: { document: { type: ['image', 'application/pdf'] } })

expect(result).to contain_exactly(pdf_contract, png_contract)
end

it 'keeps an unknown literal type as an equality that matches nothing' do
result = Contract.filterable(filters: { document: { type: 'garbage' } })

expect(result).to be_empty
end

it 'ignores an unknown symbolic type' do
result = Contract.filterable(filters: { document: { type: :video } })

expect(result).to contain_exactly(pdf_contract, png_contract, bare_contract)
end

it 'applies inclusive byte size bounds' do
attach(pdf_contract, 'annexes', 'text/csv', 100)
attach(png_contract, 'annexes', 'text/csv', 5000)
Expand Down
Loading