From 7a5a0c27a2c57016c9f8a8c94d48e67fdfb02797 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 11 Aug 2023 21:08:24 +0530 Subject: [PATCH] fix: Support SQL like `LIKE` filter Other changes: - Ignore empty doctype in filter creator - Simplified recorder filter evals --- frappe/core/doctype/recorder/recorder.py | 19 ++------- frappe/core/doctype/recorder/test_recorder.py | 3 ++ frappe/tests/test_utils.py | 39 +++++++------------ frappe/utils/data.py | 20 ++++++++-- 4 files changed, 38 insertions(+), 43 deletions(-) diff --git a/frappe/core/doctype/recorder/recorder.py b/frappe/core/doctype/recorder/recorder.py index ebe6465487..c8ca1cc798 100644 --- a/frappe/core/doctype/recorder/recorder.py +++ b/frappe/core/doctype/recorder/recorder.py @@ -4,7 +4,7 @@ import frappe from frappe.model.document import Document from frappe.recorder import get as get_recorder_data -from frappe.utils import cint, compare, make_filter_dict +from frappe.utils import cint, evaluate_filters, make_filter_dict class Recorder(Document): @@ -47,7 +47,7 @@ class Recorder(Document): order_by_statment = order_by_statment.split(".")[1] if " " in order_by_statment: - sort_key, sort_order = order_by_statment.split(" ") + sort_key, sort_order = order_by_statment.split(" ", 1) else: sort_key = order_by_statment sort_order = "desc" @@ -63,9 +63,9 @@ class Recorder(Document): @staticmethod def get_filtered_requests(args): - filters = make_filter_dict(args.get("filters")) + filters = args.get("filters") requests = [serialize_request(request) for request in get_recorder_data()] - return [req for req in requests if _evaluate_filters(req, filters)] + return [req for req in requests if evaluate_filters(req, filters)] @staticmethod def get_stats(args): @@ -100,14 +100,3 @@ def serialize_request(request): ) return request - - -def _evaluate_filters(row, filters) -> bool: - for field in filters: - value = row[field] - operand = filters[field][1] - operator = filters[field][0] - - if not compare(value, operator, operand): - return False - return True diff --git a/frappe/core/doctype/recorder/test_recorder.py b/frappe/core/doctype/recorder/test_recorder.py index d0dfc3827b..aad47cadf5 100644 --- a/frappe/core/doctype/recorder/test_recorder.py +++ b/frappe/core/doctype/recorder/test_recorder.py @@ -15,6 +15,9 @@ class TestRecorder(FrappeTestCase): def setUp(self): self.start_recoder() + def tearDown(self) -> None: + frappe.recorder.stop() + def start_recoder(self): frappe.recorder.stop() frappe.recorder.delete() diff --git a/frappe/tests/test_utils.py b/frappe/tests/test_utils.py index a10978d72b..b8c074214b 100644 --- a/frappe/tests/test_utils.py +++ b/frappe/tests/test_utils.py @@ -174,32 +174,21 @@ class TestFilters(FrappeTestCase): ) def test_like_not_like(self): - doc = {"doctype": "User", "username": "test_abc"} - self.assertTrue( - evaluate_filters( - doc, - [["username", "like", "test"]], - ) - ) - self.assertFalse( - evaluate_filters( - doc, - [["username", "like", "user1"]], - ) - ) + doc = {"doctype": "User", "username": "test_abc", "prefix": "startswith", "suffix": "endswith"} - self.assertFalse( - evaluate_filters( - doc, - [["username", "not like", "test"]], - ) - ) - self.assertTrue( - evaluate_filters( - doc, - [["username", "not like", "user1"]], - ) - ) + test_cases = [ + ([["username", "like", "test"]], True), + ([["username", "like", "user1"]], False), + ([["username", "not like", "test"]], False), + ([["username", "not like", "user1"]], True), + ([["prefix", "like", "start%"]], True), + ([["prefix", "not like", "end%"]], True), + ([["suffix", "like", "%with"]], True), + ([["suffix", "not like", "%end"]], True), + ] + + for filter, expected_result in test_cases: + self.assertEqual(evaluate_filters(doc, filter), expected_result) class TestMoney(FrappeTestCase): diff --git a/frappe/utils/data.py b/frappe/utils/data.py index 43eca61893..f7c6bf59de 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -1692,6 +1692,20 @@ def get_url_to_report_with_filters(name, filters, report_type=None, doctype=None return get_url(uri=f"/app/query-report/{quoted(name)}?{filters}") +def sql_like(value: str, pattern: str) -> bool: + if not isinstance(pattern, str) and isinstance(value, str): + return False + if pattern.startswith("%") and pattern.endswith("%"): + return pattern.strip("%") in value + elif pattern.startswith("%"): + return value.endswith(pattern.lstrip("%")) + elif pattern.endswith("%"): + return value.startswith(pattern.rstrip("%")) + else: + # assume default as wrapped in '%' + return pattern in value + + operator_map = { # startswith "^": lambda a, b: (a or "").startswith(b), @@ -1707,8 +1721,8 @@ operator_map = { "<=": operator.le, "not None": lambda a, b: a is not None, "None": lambda a, b: a is None, - "like": lambda a, b: operator.contains(a.strip("%"), b.strip("%")), - "not like": lambda a, b: not operator.contains(a.strip("%"), b.strip("%")), + "like": sql_like, + "not like": lambda a, b: not sql_like(a, b), } @@ -1814,7 +1828,7 @@ def get_filter(doctype: str, f: dict | list | tuple, filters_config=None) -> "fr break try: - df = frappe.get_meta(f.doctype).get_field(f.fieldname) + df = frappe.get_meta(f.doctype).get_field(f.fieldname) if f.doctype else None except frappe.exceptions.DoesNotExistError: df = None