Skip to content

feat(attachable): add ActiveStorage pre-filtering sugar - #17

Merged
jplot merged 4 commits into
mainfrom
feat/attachable
Jul 3, 2026
Merged

feat(attachable): add ActiveStorage pre-filtering sugar#17
jplot merged 4 commits into
mainfrom
feat/attachable

Conversation

@jplot

@jplot jplot commented Jul 3, 2026

Copy link
Copy Markdown
Member

Summary

The dedicated ActiveStorage options discussed after #16: attachable :document exposes the normed attachment filters under one public name, instead of manual association paths — and adds a capability paths cannot express: attachment absence.

Changes

  • attachable :document (alias attachable file: :document), eighth declaration DSL, reading nested keys under filters[<name>]:
    • present — strict true or bare key keeps attached records (where.associated), explicit false ('false'/'0') keeps unattached ones (where.missing); same semantics as togglable (nil/'' toggle on), 'garbage' is a no-op
    • type — blob content type, scalar = or array IN
    • min_size/max_size — inclusive blob byte size bounds
  • zero ActiveStorage dependency in the gem: whether a name is a real attachment is decided by ActiveStorage's own public reflection registry (reflect_on_attachment), looked up at runtime — an ordinary association coincidentally named <name>_attachment does not qualify (spec-proven); the generated association names (<name>_attachment(s)/<name>_blob(s), the documented has_one_attached contract) are only used to build the join, with singular/plural derived from the reflection macro
  • type/sizes delegate to the existing Target machinery (joins+merge+DISTINCT)
  • never-raise: no ActiveStorage or unknown attachment → no-op, reported by the declarations validator (attachable: 'x' maps to unknown attachment 'y'); non-hash values dropped as usual
  • applied_filters reports the accepted raw keys grouped per public name; Railtie includes the concern; README documents the DSL (167 examples, 100% coverage)

How to Test

  1. bundle exec rake
  2. Contract.filterable(filters: { document: { present: 'false' } }) → contrats sans pièce jointe ; { document: { type: 'application/pdf', min_size: 1000 } } → jointure blobs + bornes ; ?filters[document][present] (clé nue) → pièces jointes présentes

jplot added 2 commits July 3, 2026 15:26
- `attachable :document` exposes normed keys under `filters[<name>]`:
  `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
Copilot AI review requested due to automatic review settings July 3, 2026 13:27

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@fluence-ci

fluence-ci Bot commented Jul 3, 2026

Copy link
Copy Markdown

🛡️ Documentation Quality: 100.0% (0.0%)

████████████████████ 100.0% — 1270/1270 checks passing

Base PR Δ
Coverage 100.0% 100.0% ±0%
Failing 0 0 ±0

Documentation quality above threshold.

Changed files

File Failing Δ vs base
🟢 lib/filterable.rb 0 ±0
🟢 lib/filterable/attachable.rb 0 ±0
🟢 lib/filterable/concerns/attachable.rb 0 ±0
🟢 lib/filterable/declarations_validator.rb 0 ±0
🟢 lib/filterable/railtie.rb 0 ±0

@fluence-ci

fluence-ci Bot commented Jul 3, 2026

Copy link
Copy Markdown

📊 Code Quality Report

Tool Base PR Δ
Reek 59 75 smells 🔴 +16
Flog total 723.3 851.5 🔴 +128.2
Flay total 531 572 🔴 +41

🔍 16 new reek smells introduced — review below.

🔍 16 new reek smells introduced by this PR
Smell File Context Lines Message
ControlParameter lib/filterable/attachable.rb Filterable::Attachable#apply 105 is controlled by argument 'key'
ControlParameter lib/filterable/attachable.rb Filterable::Attachable#presence 123 is controlled by argument 'value'
DataClump lib/filterable/attachable.rb Filterable::Attachable 175, 190, 205 takes parameters ['attachment', 'scope'] to 3 methods
DataClump lib/filterable/attachable.rb Filterable::Attachable 104, 121, 137, 148, 161 takes parameters ['attachment', 'sub_scope', 'value'] to 5 methods
DataClump lib/filterable/attachable.rb Filterable::Attachable 26, 39, 54 takes parameters ['params', 'scope'] to 3 methods
DuplicateMethodCall lib/filterable/attachable.rb Filterable::Attachable#accepted 59, 61 calls 'declared[name]' 2 times
DuplicateMethodCall lib/filterable/attachable.rb Filterable::Attachable#presence 123, 125 calls 'sub_scope.where' 2 times
LongParameterList lib/filterable/attachable.rb Filterable::Attachable#apply 104 has 4 parameters
ManualDispatch lib/filterable/attachable.rb Filterable::Attachable#accepted 57 manually dispatches method call
ManualDispatch lib/filterable/attachable.rb Filterable::Attachable#attachment_reflection 207 manually dispatches method call
ManualDispatch lib/filterable/concerns/attachable.rb Filterable::Concerns::Attachable#attachable_attachment_names 25 manually dispatches method call
ManualDispatch lib/filterable/declarations_validator.rb Filterable::DeclarationsValidator#attachment_errors 144, 147 manually dispatches method call
NilCheck lib/filterable/attachable.rb Filterable::Attachable#entries 78 performs a nil-check
RepeatedConditional lib/filterable/declarations_validator.rb Filterable::DeclarationsValidator 65, 127, 144 tests '@model.respond_to?(reader)' at least 3 times
TooManyStatements lib/filterable/attachable.rb Filterable::Attachable#accepted 54 has approx 6 statements
TooManyStatements lib/filterable/attachable.rb Filterable::Attachable#presence 121 has approx 6 statements
🔍 All 75 reek smells on this PR
Smell File Context Lines Message
ControlParameter lib/filterable/attachable.rb Filterable::Attachable#apply 105 is controlled by argument 'key'
ControlParameter lib/filterable/attachable.rb Filterable::Attachable#presence 123 is controlled by argument 'value'
DataClump lib/filterable/attachable.rb Filterable::Attachable 175, 190, 205 takes parameters ['attachment', 'scope'] to 3 methods
DataClump lib/filterable/attachable.rb Filterable::Attachable 104, 121, 137, 148, 161 takes parameters ['attachment', 'sub_scope', 'value'] to 5 methods
DataClump lib/filterable/attachable.rb Filterable::Attachable 26, 39, 54 takes parameters ['params', 'scope'] to 3 methods
DataClump lib/filterable/declarations_validator.rb Filterable::DeclarationsValidator 79, 93, 113 takes parameters ['kind', 'public_name'] to 3 methods
DataClump lib/filterable/equatable.rb Filterable::Equatable 18, 31, 43, 57 takes parameters ['params', 'scope'] to 4 methods
DataClump lib/filterable/scopable.rb Filterable::Scopable 17, 30, 44 takes parameters ['params', 'scope'] to 3 methods
DataClump lib/filterable/sortable.rb Filterable::Sortable 19, 33, 44 takes parameters ['params', 'scope'] to 3 methods
DataClump lib/filterable/target.rb Filterable::Target 81, 102, 116 takes parameters ['model', 'path'] to 3 methods
DataClump lib/filterable/target.rb Filterable::Target 29, 47, 157 takes parameters ['sub_scope', 'target'] to 3 methods
DataClump lib/filterable/togglable.rb Filterable::Togglable 24, 37, 51 takes parameters ['params', 'scope'] to 3 methods
DuplicateMethodCall lib/filterable/attachable.rb Filterable::Attachable#accepted 59, 61 calls 'declared[name]' 2 times
DuplicateMethodCall lib/filterable/attachable.rb Filterable::Attachable#presence 123, 125 calls 'sub_scope.where' 2 times
DuplicateMethodCall lib/filterable/concern.rb Filterable::Concern#effective_filters 124, 124 calls 'params[:filters]' 2 times
DuplicateMethodCall lib/filterable/declarations_validator.rb Filterable::DeclarationsValidator#path_error 96, 102 calls '@model.name' 2 times
DuplicateMethodCall lib/filterable/sortable.rb Filterable::Sortable#terms 45, 47 calls 'params[:sort]' 2 times
DuplicateMethodCall lib/filterable/target.rb Filterable::Target#apply 174, 174 calls 'resolution[:condition]' 2 times
DuplicateMethodCall lib/filterable/togglable.rb Filterable::Togglable#toggled_on? 74, 76 calls 'TRUE_VALUES.include?(value)' 2 times
FeatureEnvy lib/filterable/concern.rb Filterable::Concern#effective_filters 123, 123, 124, 124 refers to 'params' more than self (maybe move it to another class?)
FeatureEnvy lib/filterable/concern.rb Filterable::Concern#evaluated_defaults 135, 135 refers to 'value' more than self (maybe move it to another class?)
LongParameterList lib/filterable/attachable.rb Filterable::Attachable#apply 104 has 4 parameters
LongParameterList lib/filterable/declarations_validator.rb Filterable::DeclarationsValidator#column_error 113 has 4 parameters
ManualDispatch lib/filterable/attachable.rb Filterable::Attachable#accepted 57 manually dispatches method call
ManualDispatch lib/filterable/attachable.rb Filterable::Attachable#attachment_reflection 207 manually dispatches method call
ManualDispatch lib/filterable/concern.rb Filterable::Concern#add_filter 33 manually dispatches method call
ManualDispatch lib/filterable/concern.rb Filterable::Concern#applied_filters 99 manually dispatches method call
ManualDispatch lib/filterable/concern.rb Filterable::Concern#default_filter_params 48 manually dispatches method call
ManualDispatch lib/filterable/concern.rb Filterable::Concern#effective_filters 125 manually dispatches method call
ManualDispatch lib/filterable/concern.rb Filterable::Concern#evaluated_defaults 135 manually dispatches method call
ManualDispatch lib/filterable/concern.rb Filterable::Concern#filters 21 manually dispatches method call
ManualDispatch lib/filterable/concern.rb Filterable::Concern#indifferent? 147 manually dispatches method call
ManualDispatch lib/filterable/concerns/attachable.rb Filterable::Concerns::Attachable#attachable_attachment_names 25 manually dispatches method call
ManualDispatch lib/filterable/concerns/datable.rb Filterable::Concerns::Datable#datable_attribute_names 27 manually dispatches method call
ManualDispatch lib/filterable/concerns/equatable.rb Filterable::Concerns::Equatable#equatable_attribute_names 25 manually dispatches method call
ManualDispatch lib/filterable/concerns/rangeable.rb Filterable::Concerns::Rangeable#rangeable_attribute_names 26 manually dispatches method call
ManualDispatch lib/filterable/concerns/scopable.rb Filterable::Concerns::Scopable#scopable_scope_names 24 manually dispatches method call
ManualDispatch lib/filterable/concerns/sortable.rb Filterable::Concerns::Sortable#sortable_attribute_names 24 manually dispatches method call
ManualDispatch lib/filterable/concerns/togglable.rb Filterable::Concerns::Togglable#togglable_scope_names 25 manually dispatches method call
ManualDispatch lib/filterable/datable.rb Filterable::Datable#bounds 38 manually dispatches method call
ManualDispatch lib/filterable/declarations_validator.rb Filterable::DeclarationsValidator#attachment_errors 144, 147 manually dispatches method call
ManualDispatch lib/filterable/declarations_validator.rb Filterable::DeclarationsValidator#column_errors 65 manually dispatches method call
ManualDispatch lib/filterable/declarations_validator.rb Filterable::DeclarationsValidator#scope_errors 127, 130 manually dispatches method call
ManualDispatch lib/filterable/equatable.rb Filterable::Equatable#values 59 manually dispatches method call
ManualDispatch lib/filterable/rangeable.rb Filterable::Rangeable#bounds 36 manually dispatches method call
ManualDispatch lib/filterable/scopable.rb Filterable::Scopable#accepted 47, 51 manually dispatches method call
ManualDispatch lib/filterable/togglable.rb Filterable::Togglable#accepted 54, 57 manually dispatches method call
NestedIterators lib/filterable/datable.rb Filterable::Datable#accepted 55 contains iterators nested 2 deep
NestedIterators lib/filterable/datable/after.rb Filterable::Datable::After#call 19 contains iterators nested 2 deep
NestedIterators lib/filterable/datable/before.rb Filterable::Datable::Before#call 19 contains iterators nested 2 deep
NestedIterators lib/filterable/datable/range.rb Filterable::Datable::Range#call 20 contains iterators nested 2 deep
NestedIterators lib/filterable/datable/since.rb Filterable::Datable::Since#call 19 contains iterators nested 2 deep
NestedIterators lib/filterable/rangeable.rb Filterable::Rangeable#accepted 53 contains iterators nested 2 deep
NestedIterators lib/filterable/rangeable/maximum.rb Filterable::Rangeable::Maximum#call 19 contains iterators nested 2 deep
NestedIterators lib/filterable/rangeable/minimum.rb Filterable::Rangeable::Minimum#call 19 contains iterators nested 2 deep
NilCheck lib/filterable/attachable.rb Filterable::Attachable#entries 78 performs a nil-check
NilCheck lib/filterable/concern.rb Filterable::Concern#evaluated_defaults 136 performs a nil-check
NilCheck lib/filterable/equatable.rb Filterable::Equatable#accepted 46 performs a nil-check
NilCheck lib/filterable/scopable.rb Filterable::Scopable#accepted 51 performs a nil-check
NilCheck lib/filterable/target.rb Filterable::Target#polymorphic_hop? 106 performs a nil-check
NilCheck lib/filterable/target.rb Filterable::Target#resolve 87 performs a nil-check
NilCheck lib/filterable/togglable.rb Filterable::Togglable#toggled_on? 71 performs a nil-check
RepeatedConditional lib/filterable/declarations_validator.rb Filterable::DeclarationsValidator 65, 127, 144 tests '@model.respond_to?(reader)' at least 3 times
TooManyStatements lib/filterable/attachable.rb Filterable::Attachable#accepted 54 has approx 6 statements
TooManyStatements lib/filterable/attachable.rb Filterable::Attachable#presence 121 has approx 6 statements
TooManyStatements lib/filterable/concern.rb Filterable::Concern#applied_filters 94 has approx 6 statements
TooManyStatements lib/filterable/datable.rb Filterable::Datable#accepted 52 has approx 6 statements
TooManyStatements lib/filterable/rangeable.rb Filterable::Rangeable#accepted 50 has approx 6 statements
TooManyStatements lib/filterable/scopable.rb Filterable::Scopable#accepted 44 has approx 8 statements
TooManyStatements lib/filterable/sortable.rb Filterable::Sortable#terms 44 has approx 6 statements
TooManyStatements lib/filterable/target.rb Filterable::Target#resolve 81 has approx 8 statements
TooManyStatements lib/filterable/togglable.rb Filterable::Togglable#accepted 51 has approx 7 statements
TooManyStatements lib/filterable/value_normalization.rb Filterable::ValueNormalization#normalize 15 has approx 6 statements
UtilityFunction lib/filterable/concern.rb Filterable::Concern#indifferent? 146 doesn't depend on instance state (maybe move it to another class?)
UtilityFunction lib/filterable/declarations_validator.rb Filterable::DeclarationsValidator#column_error 113 doesn't depend on instance state (maybe move it to another class?)
📊 Flog complexity breakdown
851.5: flog total
     7.2: flog/method average

    32.0: main#none
    21.2: Filterable::Railtie::initializer#filterable.active_record lib/filterable/railtie.rb:19-30
    16.6: Filterable::Target#narrow        lib/filterable/target.rb:29-37
    16.4: Filterable::Scopable#accepted    lib/filterable/scopable.rb:44-55
    15.5: Filterable::Sortable#terms       lib/filterable/sortable.rb:44-53
    15.0: Filterable::Togglable#accepted   lib/filterable/togglable.rb:51-61
    14.8: Filterable::Concern#applied_filters lib/filterable/concern.rb:94-101
    14.4: Filterable::Concern#effective_filters lib/filterable/concern.rb:122-127
    14.1: Filterable::Attachable#accepted  lib/filterable/attachable.rb:54-63
    13.9: Filterable::Target#polymorphic_join lib/filterable/target.rb:133-137
    12.5: Filterable::Target#resolve       lib/filterable/target.rb:81-93
    12.5: Filterable::Attachable#entries   lib/filterable/attachable.rb:72-79
    12.4: Filterable::Datable::Range#call  lib/filterable/datable/range.rb:17-24
    11.9: Filterable::Attachable#presence  lib/filterable/attachable.rb:121-128
    11.8: Filterable::Target#polymorphic_resolve lib/filterable/target.rb:116-124
    11.5: Filterable::Concern#evaluated_defaults lib/filterable/concern.rb:133-138
    11.3: Filterable::DeclarationsValidator#errors lib/filterable/declarations_validator.rb:49-53
    10.9: Filterable::DeclarationsValidator#path_error lib/filterable/declarations_validator.rb:93-103
    10.4: Filterable::Rangeable#accepted   lib/filterable/rangeable.rb:50-58
    10.4: Filterable::Datable#accepted     lib/filterable/datable.rb:52-60
    10.3: Filterable::ValueNormalization#normalize lib/filterable/value_normalization.rb:15-25
    10.1: Filterable::Concern#filterable   lib/filterable/concern.rb:76-81
     9.9: Filterable::Target#unpack        lib/filterable/target.rb:62-70
     9.6: Filterable::DeclarationsValidator#scope_errors lib/filterable/declarations_validator.rb:125-134
     9.6: Filterable::Target#apply         lib/filterable/target.rb:172-176
     9.0: Filterable::Concerns::Datable#none
     8.9: Filterable::Datable::After#call  lib/filterable/datable/after.rb:16-21
     8.9: Filterable::Datable::Before#call lib/filterable/datable/before.rb:16-21
     8.9: Filterable::Datable::Since#call  lib/filterable/datable/since.rb:16-21
     8.9: Filterable::Rangeable::Maximum#call lib/filterable/rangeable/maximum.rb:16-21
     8.9: Filterable::Rangeable::Minimum#call lib/filterable/rangeable/minimum.rb:16-21
     8.8: Filterable::Concern#add_filter   lib/filterable/concern.rb:30-38
     8.6: Filterable::Concern#default_filter_params lib/filterable/concern.rb:46-53
     8.6: Filterable::Concerns::Togglable#togglable_scope_names lib/filterable/concerns/togglable.rb:23-30
     8.6: Filterable::Concerns::Sortable#sortable_attribute_names lib/filterable/concerns/sortable.rb:22-29
     8.6: Filterable::Concerns::Scopable#scopable_scope_names lib/filterable/concerns/scopable.rb:22-29
     8.6: Filterable::Concerns::Rangeable#rangeable_attribute_names lib/filterable/concerns/rangeable.rb:24-31
     8.6: Filterable::Concerns::Equatable#equatable_attribute_names lib/filterable/concerns/equatable.rb:23-30
     8.6: Filterable::Concerns::Datable#datable_attribute_names lib/filterable/concerns/datable.rb:25-32
     8.6: Filterable::Concerns::Attachable#attachable_attachment_names lib/filterable/concerns/attachable.rb:23-30
     8.5: Filterable::Target#polymorphic_hop? lib/filterable/target.rb:102-107
     8.4: Filterable::Target#narrow_equal  lib/filterable/target.rb:47-54
     8.3: Filterable::Sortable#call        lib/filterable/sortable.rb:19-25
     8.3: Filterable::DeclarationsValidator#attachment_errors lib/filterable/declarations_validator.rb:142-151
     8.3: Filterable::Rangeable#bounds     lib/filterable/rangeable.rb:34-38
📊 Flay duplication breakdown
Total score (lower is better) = 572

1) Similar code found in :module (mass = 255)
  lib/filterable/datable/after.rb:4
  lib/filterable/datable/before.rb:4
  lib/filterable/datable/since.rb:4
  lib/filterable/rangeable/maximum.rb:4
  lib/filterable/rangeable/minimum.rb:4

2) Similar code found in :module (mass = 205)
  lib/filterable/concerns/attachable.rb:4
  lib/filterable/concerns/equatable.rb:4
  lib/filterable/concerns/scopable.rb:4
  lib/filterable/concerns/sortable.rb:4
  lib/filterable/concerns/togglable.rb:4

3) Similar code found in :defn (mass = 66)
  lib/filterable/datable.rb:52
  lib/filterable/rangeable.rb:50

4) Similar code found in :defn (mass = 46)
  lib/filterable/datable.rb:36
  lib/filterable/rangeable.rb:34

@fluence-ci

fluence-ci Bot commented Jul 3, 2026

Copy link
Copy Markdown

🛡️ Coverage Report: 100.0% (0.0%)

████████████████████ 100.0% — 566/566 lines covered

Base PR Δ
Total 100.0% 100.0% ±0%

All changes are tested.

Changed files

File Coverage Δ vs base Lines
🟢 lib/filterable.rb 100.0% ±0% 31/31
🟢 lib/filterable/attachable.rb 100.0% 56/56
🟢 lib/filterable/concerns/attachable.rb 100.0% 16/16
🟢 lib/filterable/declarations_validator.rb 100.0% ±0% 45/45
🟢 lib/filterable/railtie.rb 100.0% ±0% 12/12

@fluence-ci

fluence-ci Bot commented Jul 3, 2026

Copy link
Copy Markdown

🛡️ RuboCop: 0 offenses (0)

Base PR Δ
Offenses 0 0 ±0

No RuboCop offenses.

jplot added 2 commits July 3, 2026 15:32
…stry

- 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 <name>_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
- 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
@jplot
jplot merged commit ed078d3 into main Jul 3, 2026
16 checks passed
@jplot
jplot deleted the feat/attachable branch July 3, 2026 13:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants