diff --git a/frappe/database/query.py b/frappe/database/query.py index 232cf47e92..30bec1097e 100644 --- a/frappe/database/query.py +++ b/frappe/database/query.py @@ -597,6 +597,20 @@ class Engine: v.strip().strip("'") for v in get_between_date_filter(_value, df).split(" AND ") ) + # Handle empty lists for IN/NOT IN operators before conversion + # IN with empty list should return 0 results (always False) + # NOT IN with empty list should return all results (always True) + if _operator.lower() in ("in", "not in"): + if isinstance(_value, (list, tuple, set)) and len(_value) == 0: + if _operator.lower() == "in": + # Return a criterion that always evaluates to False (1=0) + # This ensures IN with empty list returns 0 results + return RawCriterion("1=0") + else: # not in + # Return a criterion that always evaluates to True (1=1) + # NOT IN with empty set matches all rows since nothing is excluded + return RawCriterion("1=1") + if not _value and isinstance(_value, list | tuple | set): _value = ("",) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index 683adb1fe6..d4f6d40458 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -868,6 +868,15 @@ from {tables} if f.operator.lower() == "in": can_be_null &= not f.value or any(v is None or v == "" for v in f.value) + # Handle empty lists for IN/NOT IN operators before processing + # IN with empty list should return 0 results (always False: 1=0) + # NOT IN with empty list should return all results (always True: 1=1) + if isinstance(f.value, (list, tuple)) and len(f.value) == 0: + if f.operator.lower() == "in": + return "1=0" + else: # not in + return "1=1" + if value is None: values = f.value or "" if isinstance(values, str): diff --git a/frappe/tests/test_db_query.py b/frappe/tests/test_db_query.py index 5110671578..07150ce80b 100644 --- a/frappe/tests/test_db_query.py +++ b/frappe/tests/test_db_query.py @@ -1050,15 +1050,21 @@ class TestDBQuery(IntegrationTestCase): self.assertNotIn("IF", frappe.get_all("User", {"first_name": ("in", ["a", "b"])}, run=0).get_sql()) self.assertIn("IFNULL", frappe.get_all("User", {"first_name": ("in", ["a", None])}, run=0).get_sql()) self.assertIn("IFNULL", frappe.get_all("User", {"first_name": ("in", ["a", ""])}, run=0).get_sql()) - self.assertIn("IFNULL", frappe.get_all("User", {"first_name": ("in", [])}, run=0).get_sql()) + # Empty list with IN should return 1=0, not use IFNULL + self.assertIn("1=0", frappe.get_all("User", {"first_name": ("in", [])}, run=0).get_sql()) + self.assertNotIn("IFNULL", frappe.get_all("User", {"first_name": ("in", [])}, run=0).get_sql()) self.assertIn("IFNULL", frappe.get_all("User", {"first_name": ("not in", ["a"])}, run=0).get_sql()) - self.assertIn("IFNULL", frappe.get_all("User", {"first_name": ("not in", [])}, run=0).get_sql()) + # Empty list with NOT IN should return 1=1, not use IFNULL + self.assertIn("1=1", frappe.get_all("User", {"first_name": ("not in", [])}, run=0).get_sql()) + self.assertNotIn("IFNULL", frappe.get_all("User", {"first_name": ("not in", [])}, run=0).get_sql()) self.assertIn("IFNULL", frappe.get_all("User", {"first_name": ("not in", [""])}, run=0).get_sql()) # primary key is never nullable self.assertNotIn("IFNULL", frappe.get_all("User", {"name": ("in", ["a", None])}, run=0).get_sql()) self.assertNotIn("IFNULL", frappe.get_all("User", {"name": ("in", ["a", ""])}, run=0).get_sql()) self.assertNotIn("IFNULL", frappe.get_all("User", {"name": ("in", (""))}, run=0).get_sql()) + # Empty tuple with IN should return 1=0, not use IFNULL + self.assertIn("1=0", frappe.get_all("User", {"name": ("in", ())}, run=0).get_sql()) self.assertNotIn("IFNULL", frappe.get_all("User", {"name": ("in", ())}, run=0).get_sql()) def test_coalesce_with_datetime_ops(self): diff --git a/frappe/tests/test_query.py b/frappe/tests/test_query.py index 87f90b519f..e526943378 100644 --- a/frappe/tests/test_query.py +++ b/frappe/tests/test_query.py @@ -410,12 +410,22 @@ class TestQuery(IntegrationTestCase): "SELECT `name` FROM `tabDocType` WHERE `name` IN ('ToDo','Note')", ) + # Empty list with IN operator should return 0 results (1=0 condition) self.assertQueryEqual( frappe.qb.get_query( "DocType", filters={"name": ("in", [])}, ).get_sql(), - "SELECT `name` FROM `tabDocType` WHERE `name` IN ('')", + "SELECT `name` FROM `tabDocType` WHERE 1=0", + ) + + # Empty list with NOT IN operator should return all results (1=1 condition) + self.assertQueryEqual( + frappe.qb.get_query( + "DocType", + filters={"name": ("not in", [])}, + ).get_sql(), + "SELECT `name` FROM `tabDocType` WHERE 1=1", ) self.assertQueryEqual(