Files
sure/app/models/rule/condition.rb
T
c3cfdf03b2 fix(rules): validate Rule::Condition type registry + normalize legacy values (#1908)
* fix(rules): validate Rule::Condition type registry + normalize legacy 'name' values

Closes #1229.

/rules crashes with `ActionView::Template::Error (Unsupported condition
type: name)` when a Rule::Condition row has a condition_type the
registry doesn't know about. Reporter found two cases in the wild:

  - "name" — invalid, crashes the rules index because the view path
    (`_rule.html.erb` → `displayed_condition.filter.label`) ends up
    in `Rule::Registry#get_filter!` which raises.
  - "transaction_details" — actually a valid filter that searches the
    `transactions.extra` JSONB metadata field (see existing tests in
    test/models/rule/condition_test.rb). Reporter assumed it was a
    transaction-name synonym; it isn't. This PR leaves it alone.

Changes:

- Rule::Condition gains a SUPPORTED_CONDITION_TYPES registry constant
  matching Rule::Registry::TransactionResource#condition_filters plus
  "compound", an inclusion validation against it, and a
  before_validation callback that maps the one known legacy alias
  ("name" -> "transaction_name") so saving an existing rule fixes
  itself.
- Rule::Condition#filter now rescues UnsupportedConditionError and
  returns a Rule::ConditionFilter::Unsupported placeholder. The
  placeholder labels itself "Unsupported (<key>)" (i18n) and its
  #apply returns `scope.none`, so any stale row that survives the
  migration stops matching rather than silently matching everything.
- A one-shot migration normalizes existing "name" rows. Single UPDATE
  statement — rule_conditions is a small per-family table.

Tests added in test/models/rule/condition_test.rb cover the inclusion
validation, the normalization callback, and the graceful-render path
(uses update_columns to simulate a row written before the validation
existed — that's the exact codepath that crashes /rules today).

* test(rules): add system test for unsupported condition_type render + raise on migration down

- test/system/rules_test.rb: visit /rules with a row whose condition_type
  has been update_columns'd to "name" — assert page renders and shows
  the "Unsupported (name)" label instead of raising.
- db/migrate/...normalize_rule_condition_types.rb: replace the empty
  #down with `raise ActiveRecord::IrreversibleMigration` to match the
  repo's data-migration convention (see e.g. 20260219190000_scope_*).

* chore(rules): log unsupported condition + cross-check supported types

Address maintainer review on #1908:
- Log a Rails.logger.warn (with rule_id and condition_type) from
  Rule::ConditionFilter::Unsupported#apply so silent zero-match rules
  are traceable when debugging.
- Add a comment above Rule::Condition::SUPPORTED_CONDITION_TYPES and a
  test that cross-checks it against the registry's filter keys, so
  drift between the two surfaces as a failing test rather than a
  confusing validation error.

* refactor(rules): derive supported condition types from registry

---------

Co-authored-by: John Baillie <johnbaillie2007@gmail.com>
Co-authored-by: Khaostica <256858950+Khaostica@users.noreply.github.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
2026-08-26 07:09:55 +02:00

92 lines
2.3 KiB
Ruby

class Rule::Condition < ApplicationRecord
SUPPORTED_CONDITION_TYPES = (Rule::Registry::TransactionResource.condition_filter_keys + [ "compound" ]).freeze
LEGACY_CONDITION_TYPE_ALIASES = {
"name" => "transaction_name"
}.freeze
belongs_to :rule, touch: true, optional: -> { where.not(parent_id: nil) }
belongs_to :parent, class_name: "Rule::Condition", optional: true, inverse_of: :sub_conditions
has_many :sub_conditions, -> { order(:created_at, :id) }, class_name: "Rule::Condition", foreign_key: :parent_id, dependent: :destroy, inverse_of: :parent
before_validation :normalize_legacy_condition_type
validates :condition_type, presence: true, inclusion: { in: SUPPORTED_CONDITION_TYPES, allow_blank: true }
validates :operator, presence: true
validates :value, presence: true, unless: -> { compound? || operator == "is_null" }
accepts_nested_attributes_for :sub_conditions, allow_destroy: true
# We don't store rule_id on sub_conditions, so "walk up" to the parent rule
def rule
parent&.rule || super
end
def compound?
condition_type == "compound"
end
def apply(scope)
if compound?
build_compound_scope(scope)
else
filter.apply(scope, operator, value)
end
end
def prepare(scope)
if compound?
sub_conditions.reduce(scope) { |s, sub| sub.prepare(s) }
else
filter.prepare(scope)
end
end
def value_display
if value.present?
if options
options.find { |option| option.last == value }&.first
else
value
end
else
""
end
end
def options
filter.options
end
def operators
filter.operators
end
def filter
rule.registry.get_filter!(condition_type)
rescue Rule::Registry::UnsupportedConditionError
Rule::ConditionFilter::Unsupported.new(rule, condition_type)
end
private
def normalize_legacy_condition_type
return if condition_type.blank?
normalized = LEGACY_CONDITION_TYPE_ALIASES[condition_type]
self.condition_type = normalized if normalized
end
def build_compound_scope(scope)
if operator == "or"
combined_scope = sub_conditions
.map { |sub| sub.apply(scope) }
.reduce { |acc, s| acc.or(s) }
combined_scope || scope
else
sub_conditions.reduce(scope) { |s, sub| sub.apply(s) }
end
end
end