From ad0bf8deb148135cdde7ca1bc05438162683d35d Mon Sep 17 00:00:00 2001 From: Jonathan PHILIPPE Date: Fri, 3 Jul 2026 16:45:07 +0200 Subject: [PATCH 1/2] feat(attachable): expand the image shortcut in the type key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `type: 'image'` / `type: :image` expands lazily at query time against ActiveStorage.variable_content_types | web_image_content_types — never frozen at boot, and the lambda only runs once the model proved it has real attachments, so the gem still loads without ActiveStorage - a real content type always carries a slash, so the shortcut name is unambiguous; an unknown literal stays a plain equality that matches nothing (never converted into a silent no-op), an unknown symbol narrows nothing; shortcuts compose inside arrays next to literals --- lib/filterable/attachable.rb | 35 ++++++++++++++++++++++++++++++++++- spec/active_storage_spec.rb | 26 ++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/lib/filterable/attachable.rb b/lib/filterable/attachable.rb index 6c7e7af..9e4e21b 100644 --- a/lib/filterable/attachable.rb +++ b/lib/filterable/attachable.rb @@ -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. @@ -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. # diff --git a/spec/active_storage_spec.rb b/spec/active_storage_spec.rb index 8e7599b..6541fa3 100644 --- a/spec/active_storage_spec.rb +++ b/spec/active_storage_spec.rb @@ -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) From b1d9ca6657e7984894ccd7fc941edf0381467af7 Mon Sep 17 00:00:00 2001 From: Jonathan PHILIPPE Date: Fri, 3 Jul 2026 16:45:07 +0200 Subject: [PATCH 2/2] docs(readme): document the image type shortcut --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2e9a6e1..b8ecdf0 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,7 @@ 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` | +| `type` | blob content type — scalar `=`, array `IN`, or the `image` shortcut | | `min_size` / `max_size`| inclusive blob byte size bounds | ```ruby @@ -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