Repository: frappe/frappe

Issue:
Implement `is set` operator
## Description of the issue

In Virtual DocType filters "is set" and "is not set" not working.

## Context information (for bug reports)

for example i see https://github.com/frappe/frappe/blob/develop/frappe/core/doctype/recorder/recorder.py#L67

this method call _evaluate_filters(req, filters)]_. Open this method: https://github.com/frappe/frappe/blob/81785ae52b8a45bd15671f2bce34b0fce2d1f9ab/frappe/utils/data.py#L1958 and we see its use operator_map dictionary https://github.com/frappe/frappe/blob/81785ae52b8a45bd15671f2bce34b0fce2d1f9ab/frappe/utils/data.py#L1933. In this dictionary not operators "is set" and "is not set"

**Output of `bench version`**
```
(paste here)
```

## Steps to reproduce the issue

1. Create Virtual DocType
2. Try use filter Is Set and Is Not Set
3.

### Observed result

### Expected result

### Stacktrace / full error message

```
(paste here)
```

## Additional information

OS version / distribution, `Frappe` install method, etc.



Discussion:
If someone wants to take a stab at this:
- `is set` means some value is set. For nullable fields this means value is `None` or falsy like empty string. For numeric fields this doesn't make sense. `is not set` is just opposite of it.
Anybody, pls add in https://github.com/frappe/frappe/blob/81785ae52b8a45bd15671f2bce34b0fce2d1f9ab/frappe/utils/data.py#L1933
line: 
``` python 
"is": lambda a, b: (a and (b == 'set')) or (not a and (b == 'not set')),
```

total: 
``` python 
operator_map = {
	# startswith
	"^": lambda a, b: (a or "").startswith(b),
	# in or not in a list
	"in": lambda a, b: operator.contains(b, a),
	"not in": lambda a, b: not operator.contains(b, a),
	# comparison operators
	"=": operator.eq,
	"!=": operator.ne,
	">": operator.gt,
	"<": operator.lt,
	">=": operator.ge,
	"<=": operator.le,
	"not None": lambda a, b: a is not None,
	"None": lambda a, b: a is None,
	"like": sql_like,
	"not like": lambda a, b: not sql_like(a, b),
	"is": lambda a, b: (a and (b == 'set')) or (not a and (b == 'not set')),
}
```

Resolving patch:
diff --git a/frappe/utils/data.py b/frappe/utils/data.py
--- a/frappe/utils/data.py
+++ b/frappe/utils/data.py
@@ -1930,6 +1930,25 @@ def sql_like(value: str, pattern: str) -> bool:
 		return pattern in value
 
 
+def filter_operator_is(value: str, pattern: str) -> bool:
+	"""Operator `is` can have two values: 'set' or 'not set'."""
+	pattern = pattern.lower()
+
+	def is_set():
+		if value is None:
+			return False
+		elif isinstance(value, str) and not value:
+			return False
+		return True
+
+	if pattern == "set":
+		return is_set()
+	elif pattern == "not set":
+		return not is_set()
+	else:
+		frappe.throw(frappe._(f"Invalid argument for operator 'IS': {pattern}"))
+
+
 operator_map = {
 	# startswith
 	"^": lambda a, b: (a or "").startswith(b),
@@ -1947,6 +1966,7 @@ def sql_like(value: str, pattern: str) -> bool:
 	"None": lambda a, b: a is None,
 	"like": sql_like,
 	"not like": lambda a, b: not sql_like(a, b),
+	"is": filter_operator_is,
 }
 
 


Test patch:
diff --git a/frappe/tests/test_utils.py b/frappe/tests/test_utils.py
--- a/frappe/tests/test_utils.py
+++ b/frappe/tests/test_utils.py
@@ -186,12 +186,14 @@ def test_date_time(self):
 			)
 		)
 
-	def test_like_not_like(self):
+	def test_filter_evaluation(self):
 		doc = {
 			"doctype": "User",
 			"username": "test_abc",
 			"prefix": "startswith",
 			"suffix": "endswith",
+			"empty": None,
+			"number": 0,
 		}
 
 		test_cases = [
@@ -203,10 +205,15 @@ def test_like_not_like(self):
 			([["prefix", "not like", "end%"]], True),
 			([["suffix", "like", "%with"]], True),
 			([["suffix", "not like", "%end"]], True),
+			([["suffix", "is", "set"]], True),
+			([["suffix", "is", "not set"]], False),
+			([["empty", "is", "set"]], False),
+			([["empty", "is", "not set"]], True),
+			([["number", "is", "set"]], True),
 		]
 
 		for filter, expected_result in test_cases:
-			self.assertEqual(evaluate_filters(doc, filter), expected_result)
+			self.assertEqual(evaluate_filters(doc, filter), expected_result, msg=f"{filter}")
 
 
 class TestMoney(FrappeTestCase):