mirror of
https://github.com/we-promise/sure.git
synced 2026-09-09 08:34:26 +00:00
* feat(rules): add not-equal, does-not-contain, is-not-empty condition operators Extend transaction rule conditions beyond "equal to" / "is empty": - text: add "does not contain" (not_like), "not equal to" (!=), "is not empty" (is_not_null) - number: add "not equal to" (!=) - select: add "not equal to" (!=), "is not empty" (is_not_null) NULL handling is inclusive so the operators match user intent: - "!=" uses IS DISTINCT FROM, so e.g. "category not equal to X" also matches uncategorized (NULL) transactions - "does not contain" also matches rows where the field is NULL transaction_type keeps its custom operator set, and transaction_details is pinned to the original operators since its JSONB apply only supports contains/equals/empty semantics. The conditions Stimulus controller hides the value field for both valueless operators (is_null and is_not_null). * refactor(rules): address PR review feedback on condition operators - Pass VALUELESS_OPERATORS from Ruby to JS via Stimulus value attribute instead of duplicating the list as a static class property, so there is a single source of truth for which operators suppress the value field - Clarify IS DISTINCT FROM comment to note the NULL-inclusion behaviour is intentional for select-type fields (merchant_id, category_id) and not applicable to number fields where NULL is impossible at the DB level - Add test that exercises the OR IS NULL branch of not_like by using transaction_notes (entries.notes is nullable, unlike entries.name) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(rules): localize condition operator labels via i18n Moves all Rule::ConditionFilter operator labels (including ones that predate this PR) out of OPERATORS_MAP and into config/locales, so operators() resolves them through t() per request instead of hardcoded English strings. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GvxjdTgH34cPoJenQAqnpN --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
92 lines
2.4 KiB
Ruby
92 lines
2.4 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? || Rule::ConditionFilter::VALUELESS_OPERATORS.include?(operator) }
|
|
|
|
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
|