Skip to content

feat(target): filter through declared association paths - #15

Merged
jplot merged 3 commits into
mainfrom
feat/association-targets
Jul 3, 2026
Merged

feat(target): filter through declared association paths#15
jplot merged 3 commits into
mainfrom
feat/association-targets

Conversation

@jplot

@jplot jplot commented Jul 3, 2026

Copy link
Copy Markdown
Member

Summary

Closes the last item of the gem-landscape backlog — association filtering, including the delegated_type case. datable/equatable/rangeable declarations may target a column through associations with an explicit nested hash: equatable account_name: { account: :name }, multi-level { account: { bank: :name } }, and polymorphic hop { entryable: { message: :subject } }. The path is declared, never inferred from names (the toschas anti-pattern flagged in the review).

Changes

  • Filterable::Target: resolves declared targets — own column, or association path via reflect_on_association; filtering joins the declared path and merges the condition on the target model (filterameter's clean pattern), adding DISTINCT when the path crosses a collection; INNER JOIN semantics, rows without the association drop out
  • polymorphic belongs_todelegated_type included — crossed by naming the concrete type as the second segment; arel join (no SQL strings) guarded by the *_type column via polymorphic_name, proven safe by an id-collision spec; the hop must open the path and target a column directly on the concrete type; specs cover both the real delegated_type macro and a bare polymorphic belongs_to
  • never-raise holds: unknown associations, unguarded polymorphic reflections, unknown or non-model concrete types and ambiguous multi-key hashes narrow nothing at runtime
  • sortable refuses association targets (the DISTINCT + ORDER BY select-list pitfall on Postgres); scopable/togglable now guard against hash-valued declarations that previously raised TypeError on respond_to?
  • the declarations validator resolves paths with the same Target helpers — no runtime/validator drift — and reports: unresolvable path (unknown association, polymorphic without type, unknown concrete type), unknown column on the target model, ambiguous target, sortable-through-association
  • spec schema grows banks/accounts, a real delegated_type :attachable and a bare polymorphic belongs_to :source to cover every branch (147 examples, 100% coverage)

How to Test

  1. bundle exec rake
  2. MovementDetail.filterable(filters: { account_name: 'Main' })INNER JOIN accounts ... WHERE accounts.name = 'Main' ; filters: { attached_bank_name: 'X' } → jointure arel + garde attachable_type = 'Bank' ; Account.filterable(filters: { detail_reference: 'DUP' }) → une seule ligne malgré deux détails (DISTINCT) ; déclarer equatable x: { nope: :col } → no-op au runtime, erreur dans filterable_declarations.errors

jplot added 2 commits July 3, 2026 14:15
- datable/equatable/rangeable targets may walk associations with an
  explicit nested hash — `equatable account_name: { account: :name }`,
  multi-level `{ account: { bank: :name } }` — never inferred from
  names
- Filterable::Target joins the declared path and merges the condition
  on the target model, adding DISTINCT when the path crosses a
  collection; the never-raise contract holds: unknown or polymorphic
  associations and ambiguous multi-key hashes narrow nothing
- sortable refuses association targets (DISTINCT + ORDER BY pitfall);
  scopable/togglable guard against hash-valued declarations that used
  to raise TypeError on respond_to?
- declarations validator resolves paths with the same Target helpers
  (no drift): unresolvable path, unknown column on the target model,
  ambiguous target and sortable-through-association are all reported
Copilot AI review requested due to automatic review settings July 3, 2026 12:16

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

📊 Code Quality Report

Tool Base PR Δ
Reek 45 59 smells 🔴 +14
Flog total 603.8 723.3 🔴 +119.5
Flay total 521 531 🔴 +10

🔍 16 new reek smells introduced — review below.

🔍 16 new reek smells introduced by this PR
Smell File Context Lines Message
DataClump lib/filterable/declarations_validator.rb Filterable::DeclarationsValidator 73, 87, 107 takes parameters ['kind', 'public_name'] 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
DuplicateMethodCall lib/filterable/declarations_validator.rb Filterable::DeclarationsValidator#path_error 90, 96 calls '@model.name' 2 times
DuplicateMethodCall lib/filterable/target.rb Filterable::Target#apply 174, 174 calls 'resolution[:condition]' 2 times
LongParameterList lib/filterable/declarations_validator.rb Filterable::DeclarationsValidator#column_error 107 has 4 parameters
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/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/target.rb Filterable::Target#polymorphic_hop? 106 performs a nil-check
NilCheck lib/filterable/target.rb Filterable::Target#resolve 87 performs a nil-check
TooManyStatements lib/filterable/target.rb Filterable::Target#resolve 81 has approx 8 statements
UtilityFunction lib/filterable/declarations_validator.rb Filterable::DeclarationsValidator#column_error 107 doesn't depend on instance state (maybe move it to another class?)
✅ 2 reek smells resolved by this PR

The exact list isn't shown — these smells are gone, that's what matters.

🔍 All 59 reek smells on this PR
Smell File Context Lines Message
DataClump lib/filterable/declarations_validator.rb Filterable::DeclarationsValidator 73, 87, 107 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/concern.rb Filterable::Concern#effective_filters 124, 124 calls 'params[:filters]' 2 times
DuplicateMethodCall lib/filterable/declarations_validator.rb Filterable::DeclarationsValidator#path_error 90, 96 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/declarations_validator.rb Filterable::DeclarationsValidator#column_error 107 has 4 parameters
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/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#column_errors 59 manually dispatches method call
ManualDispatch lib/filterable/declarations_validator.rb Filterable::DeclarationsValidator#scope_errors 121, 124 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/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
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 107 doesn't depend on instance state (maybe move it to another class?)
📊 Flog complexity breakdown
723.3: flog total
     7.2: flog/method average

    29.8: main#none
    18.8: Filterable::Railtie::initializer#filterable.active_record lib/filterable/railtie.rb:19-29
    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
    13.9: Filterable::Target#polymorphic_join lib/filterable/target.rb:133-137
    12.5: Filterable::Target#resolve       lib/filterable/target.rb:81-93
    12.4: Filterable::Datable::Range#call  lib/filterable/datable/range.rb:17-24
    11.8: Filterable::Target#polymorphic_resolve lib/filterable/target.rb:116-124
    11.5: Filterable::Concern#evaluated_defaults lib/filterable/concern.rb:133-138
    10.9: Filterable::DeclarationsValidator#path_error lib/filterable/declarations_validator.rb:87-97
    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:119-128
     9.6: Filterable::Target#apply         lib/filterable/target.rb:172-176
     9.0: Filterable::Concerns::Datable#none
     8.9: Filterable::Rangeable::Minimum#call lib/filterable/rangeable/minimum.rb:16-21
     8.9: Filterable::Rangeable::Maximum#call lib/filterable/rangeable/maximum.rb:16-21
     8.9: Filterable::Datable::Since#call  lib/filterable/datable/since.rb:16-21
     8.9: Filterable::Datable::Before#call lib/filterable/datable/before.rb:16-21
     8.9: Filterable::Datable::After#call  lib/filterable/datable/after.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::Datable#datable_attribute_names lib/filterable/concerns/datable.rb:25-32
     8.6: Filterable::Concerns::Togglable#togglable_scope_names lib/filterable/concerns/togglable.rb:23-30
     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::Sortable#sortable_attribute_names lib/filterable/concerns/sortable.rb:22-29
     8.6: Filterable::Concerns::Equatable#equatable_attribute_names lib/filterable/concerns/equatable.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::Rangeable#bounds     lib/filterable/rangeable.rb:34-38
📊 Flay duplication breakdown
Total score (lower is better) = 531

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 = 164)
  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

🛡️ Documentation Quality: 100.0% (0.0%)

████████████████████ 100.0% — 1000/1000 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/datable/after.rb 0 ±0
🟢 lib/filterable/datable/before.rb 0 ±0
🟢 lib/filterable/datable/range.rb 0 ±0
🟢 lib/filterable/datable/since.rb 0 ±0
🟢 lib/filterable/declarations_validator.rb 0 ±0
🟢 lib/filterable/equatable.rb 0 ±0
🟢 lib/filterable/rangeable/maximum.rb 0 ±0
🟢 lib/filterable/rangeable/minimum.rb 0 ±0
🟢 lib/filterable/scopable.rb 0 ±0
🟢 lib/filterable/sortable.rb 0 ±0
🟢 lib/filterable/target.rb 0 ±0
🟢 lib/filterable/togglable.rb 0 ±0

@fluence-ci

fluence-ci Bot commented Jul 3, 2026

Copy link
Copy Markdown

🛡️ Coverage Report: 100.0% (0.0%)

████████████████████ 100.0% — 483/483 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% 29/29
🟢 lib/filterable/datable/after.rb 100.0% ±0% 12/12
🟢 lib/filterable/datable/before.rb 100.0% ±0% 12/12
🟢 lib/filterable/datable/range.rb 100.0% ±0% 13/13
🟢 lib/filterable/datable/since.rb 100.0% ±0% 12/12
🟢 lib/filterable/declarations_validator.rb 100.0% ±0% 37/37
🟢 lib/filterable/equatable.rb 100.0% ±0% 16/16
🟢 lib/filterable/rangeable/maximum.rb 100.0% ±0% 12/12
🟢 lib/filterable/rangeable/minimum.rb 100.0% ±0% 12/12
🟢 lib/filterable/scopable.rb 100.0% ±0% 19/19
🟢 lib/filterable/sortable.rb 100.0% ±0% 21/21
🟢 lib/filterable/target.rb 100.0% 56/56
🟢 lib/filterable/togglable.rb 100.0% ±0% 24/24

@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.

- a polymorphic association — delegated_type included — is crossed by
  naming the concrete type as the second segment:
  `equatable subject: { entryable: { message: :subject } }`; there is
  no ambiguity since an association name can never follow a
  polymorphic reflection
- the join is built in arel (no SQL strings) and guarded by the
  polymorphic *_type column via polymorphic_name, so two types sharing
  ids can never leak into each other — proven by an id-collision spec
- the hop must open the path and target a column directly on the
  concrete type; unknown or non-model types narrow nothing and the
  validator reports them through the shared Target resolution
- specs cover the real delegated_type macro and a bare polymorphic
  belongs_to, so support does not depend on the sugar
@jplot
jplot merged commit 98593d9 into main Jul 3, 2026
16 checks passed
@jplot
jplot deleted the feat/association-targets branch July 3, 2026 13:00
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