Skip to content

Commit

Permalink
fix: Change string rules to be case insensitive and adds support for …
Browse files Browse the repository at this point in the history
…matches rules. (#55)

- Fix case where 'contains' would not match strings when the their cases did not match
- Add support for 'matches' rules, that do the match using regexp.

Closes #52
  • Loading branch information
bvanelli authored Aug 13, 2024
1 parent f721099 commit 2ad5f63
Show file tree
Hide file tree
Showing 2 changed files with 31 additions and 2 deletions.
22 changes: 20 additions & 2 deletions actual/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import datetime
import enum
import re
import typing
import unicodedata

import pydantic

Expand All @@ -12,6 +14,14 @@
from actual.schedules import Schedule


def get_normalized_string(value: str) -> str:
"""Normalization of string for comparison. Uses lowercase and Canonical Decomposition.
See https://github.com/actualbudget/actual/blob/a22160579d6e1f7a17213561cec79c321a14525b/packages/loot-core/src/shared/normalisation.ts
"""
return unicodedata.normalize("NFD", value.lower())


class ConditionType(enum.Enum):
IS = "is"
IS_APPROX = "isapprox"
Expand All @@ -25,6 +35,7 @@ class ConditionType(enum.Enum):
DOES_NOT_CONTAIN = "doesNotContain"
NOT_ONE_OF = "notOneOf"
IS_BETWEEN = "isbetween"
MATCHES = "matches"


class ActionType(enum.Enum):
Expand Down Expand Up @@ -67,7 +78,7 @@ def is_valid(self, operation: ConditionType) -> bool:
if self == ValueType.DATE:
return operation.value in ("is", "isapprox", "gt", "gte", "lt", "lte")
elif self == ValueType.STRING:
return operation.value in ("is", "contains", "oneOf", "isNot", "doesNotContain", "notOneOf")
return operation.value in ("is", "contains", "oneOf", "isNot", "doesNotContain", "notOneOf", "matches")
elif self == ValueType.ID:
return operation.value in ("is", "isNot", "oneOf", "notOneOf")
elif self == ValueType.NUMBER:
Expand Down Expand Up @@ -130,6 +141,11 @@ def get_value(
return datetime.datetime.strptime(str(value), "%Y%m%d").date()
elif value_type is ValueType.BOOLEAN:
return int(value) # database accepts 0 or 1
elif value_type is ValueType.STRING:
if isinstance(value, list):
return [get_value(v, value_type) for v in value]
else:
return get_normalized_string(value)
return value


Expand Down Expand Up @@ -173,7 +189,9 @@ def condition_evaluation(
elif op == ConditionType.ONE_OF:
return true_value in self_value
elif op == ConditionType.CONTAINS:
return self_value in true_value
return get_normalized_string(self_value) in get_normalized_string(true_value)
elif op == ConditionType.MATCHES:
return bool(re.match(self_value, true_value, re.IGNORECASE))
elif op == ConditionType.NOT_ONE_OF:
return true_value not in self_value
elif op == ConditionType.DOES_NOT_CONTAIN:
Expand Down
11 changes: 11 additions & 0 deletions tests/test_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,19 @@ def test_string_condition():
assert Condition(field="notes", op="notOneOf", value=["foo", "bar"]).run(t) is False
assert Condition(field="notes", op="contains", value="fo").run(t) is True
assert Condition(field="notes", op="contains", value="foobar").run(t) is False
assert Condition(field="notes", op="matches", value="f.*").run(t) is True
assert Condition(field="notes", op="matches", value="g.*").run(t) is False
assert Condition(field="notes", op="doesNotContain", value="foo").run(t) is False
assert Condition(field="notes", op="doesNotContain", value="foobar").run(t) is True
# test the cases where the case do not match
assert Condition(field="notes", op="oneOf", value=["FOO", "BAR"]).run(t) is True
assert Condition(field="notes", op="notOneOf", value=["FOO", "BAR"]).run(t) is False
assert Condition(field="notes", op="contains", value="FO").run(t) is True
assert Condition(field="notes", op="contains", value="FOOBAR").run(t) is False
assert Condition(field="notes", op="matches", value="F.*").run(t) is True
assert Condition(field="notes", op="matches", value="G.*").run(t) is False
assert Condition(field="notes", op="doesNotContain", value="FOO").run(t) is False
assert Condition(field="notes", op="doesNotContain", value="FOOBAR").run(t) is True


def test_numeric_condition():
Expand Down

0 comments on commit 2ad5f63

Please sign in to comment.