diff --git a/frappe/tests/test_utils.py b/frappe/tests/test_utils.py index 83a653528e..bd4ebf411c 100644 --- a/frappe/tests/test_utils.py +++ b/frappe/tests/test_utils.py @@ -59,6 +59,7 @@ from frappe.utils.data import ( cint, comma_and, comma_or, + compare, cstr, duration_to_seconds, evaluate_filters, @@ -236,6 +237,111 @@ class TestFilters(IntegrationTestCase): } self.assertFalse(evaluate_filters(doc, [("last_password_reset_date", "Timespan", "today")])) + def test_is_operator(self): + """Test 'is' operator for checking if values are set or not set.""" + # Test "is set" with different fieldtypes and values + self.assertTrue(compare("1", "is", "set", "Int")) + self.assertTrue(compare(1, "is", "set", "Int")) + self.assertTrue(compare(0, "is", "set", "Int")) # 0 is considered "set" + self.assertTrue(compare("hello", "is", "set", "Data")) + self.assertTrue(compare(0.0, "is", "set", "Float")) + + # Test "is set" with unset values - None should always be "not set" regardless of fieldtype + self.assertFalse(compare(None, "is", "set", "Int")) + self.assertFalse(compare(None, "is", "set", "Float")) + self.assertFalse(compare(None, "is", "set", "Check")) + self.assertFalse(compare(None, "is", "set", "Data")) + self.assertFalse(compare("", "is", "set")) + self.assertFalse(compare("", "is", "set", "Data")) + self.assertFalse(compare(None, "is", "set")) + + # Test "is not set" with set values + self.assertFalse(compare("1", "is", "not set", "Int")) + self.assertFalse(compare(1, "is", "not set", "Int")) + self.assertFalse(compare(0, "is", "not set", "Int")) + self.assertFalse(compare("hello", "is", "not set", "Data")) + self.assertFalse(compare(0.0, "is", "not set", "Float")) + + # Test "is not set" with unset values - None should always be "not set" regardless of fieldtype + self.assertTrue(compare(None, "is", "not set", "Int")) + self.assertTrue(compare(None, "is", "not set", "Float")) + self.assertTrue(compare(None, "is", "not set", "Check")) + self.assertTrue(compare(None, "is", "not set", "Data")) + self.assertTrue(compare("", "is", "not set")) + self.assertTrue(compare("", "is", "not set", "Data")) + self.assertTrue(compare(None, "is", "not set")) + + def test_in_operators(self): + """Test 'in' and 'not in' operators with and without fieldtype casting.""" + test_list = ["a", "b", "c"] + + # Test "in" operator without fieldtype + self.assertTrue(compare("a", "in", test_list)) + self.assertFalse(compare("", "in", test_list)) + self.assertFalse(compare("d", "in", test_list)) + self.assertFalse(compare(None, "in", test_list)) + + # Test "not in" operator without fieldtype + self.assertFalse(compare("a", "not in", test_list)) + self.assertTrue(compare("", "not in", test_list)) + self.assertTrue(compare("d", "not in", test_list)) + self.assertTrue(compare(None, "not in", test_list)) + + # Test "in" operator with fieldtype casting - only first value should be cast + string_list = ["1", "2", "3"] + self.assertTrue(compare(1, "in", string_list, "Data")) + self.assertTrue(compare("2", "in", string_list, "Data")) + self.assertFalse(compare(4, "in", string_list, "Data")) + + # Test type mismatch: Int fieldtype with string list (val2 is NOT cast) + mixed_list = ["1", "2", "3"] + self.assertFalse(compare("1", "in", mixed_list, "Int")) + self.assertFalse(compare(1, "in", mixed_list, "Int")) + + # Test with matching types: Int fieldtype with int list + int_list = [1, 2, 3] + self.assertTrue(compare("1", "in", int_list, "Int")) + self.assertTrue(compare(2, "in", int_list, "Int")) + self.assertFalse(compare("4", "in", int_list, "Int")) + + # Test "not in" operator with fieldtype casting + self.assertFalse(compare(1, "not in", string_list, "Data")) + self.assertFalse(compare("2", "not in", string_list, "Data")) + self.assertTrue(compare(4, "not in", string_list, "Data")) + + # Test "not in" with type mismatch + self.assertTrue(compare("1", "not in", mixed_list, "Int")) + self.assertFalse(compare("1", "not in", int_list, "Int")) + + # Test with Float fieldtype + float_list = [1.5, 2.5, 3.5] + self.assertTrue(compare("1.5", "in", float_list, "Float")) + self.assertFalse(compare("4.5", "in", float_list, "Float")) + + # Test None with "in"/"not in" operators - None should not be cast + self.assertFalse(compare(None, "in", [""], "Data")) + self.assertFalse(compare(None, "in", [0], "Int")) + self.assertFalse(compare(None, "in", [0.0], "Float")) + self.assertFalse(compare(None, "in", ["", "test"], "Data")) + self.assertTrue(compare(None, "in", [None, "test"], "Data")) + + # Test "not in" with None + self.assertTrue(compare(None, "not in", [""], "Data")) + self.assertTrue(compare(None, "not in", [0], "Int")) + self.assertTrue(compare(None, "not in", [0.0], "Float")) + self.assertTrue(compare(None, "not in", ["", "test"], "Data")) + self.assertFalse(compare(None, "not in", [None, "test"], "Data")) + + def test_is_operator_case_insensitive(self): + """Test that 'is' operator patterns are case insensitive.""" + self.assertTrue(compare("value", "is", "SET")) + self.assertTrue(compare("value", "is", "Set")) + self.assertTrue(compare("value", "is", "set")) + + self.assertTrue(compare(None, "is", "NOT SET")) + self.assertTrue(compare(None, "is", "Not Set")) + self.assertTrue(compare(None, "is", "not set")) + class TestMoney(IntegrationTestCase): def test_money_in_words(self): diff --git a/frappe/utils/data.py b/frappe/utils/data.py index e9d8c3881b..e0bdf17de8 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -2021,7 +2021,7 @@ def sql_like(value: str, pattern: str) -> bool: return pattern in value -def filter_operator_is(value: str, pattern: str) -> bool: +def filter_operator_is(value: str | None, pattern: str) -> bool: """Operator `is` can have two values: 'set' or 'not set'.""" pattern = pattern.lower() @@ -2082,11 +2082,37 @@ def evaluate_filters(doc: "Mapping", filters: FilterSignature): return True -def compare(val1: Any, condition: str, val2: Any, fieldtype: str | None = None): +def compare(val1: Any, condition: str, val2: Any, fieldtype: str | None = None) -> bool: + """Compare two values using the specified operator with optional fieldtype casting. + + Args: + val1: The left operand value to compare + condition: The comparison operator (e.g., "=", ">", "is", "in", "like") + val2: The right operand value to compare against + fieldtype: Optional fieldtype for casting val1 (and val2 for most operators) + + Returns: + bool: True if the comparison evaluates to True, False otherwise + + Note: + - For "is" operator: No casting is performed to preserve None values + - For "in"/"not in" operators: Only val1 is cast (if not None), val2 remains unchanged + - For "Timespan" operator: No casting is performed + - For other operators: Both val1 and val2 are cast to the specified fieldtype + """ if fieldtype: - val1 = cast(fieldtype, val1) - if condition != "Timespan": + if condition in {"is", "Timespan"}: + # No casting to preserve original values + pass + elif condition in {"in", "not in"}: + # Cast only val1 (if not None), preserve val2 container + if val1 is not None: + val1 = cast(fieldtype, val1) + else: + # Cast both values for comparison operators (=, !=, >, <, >=, <=, like, etc.) + val1 = cast(fieldtype, val1) val2 = cast(fieldtype, val2) + if condition in operator_map: return operator_map[condition](val1, val2)