From a7a14c82da11ff2ad16fd41a4f2017cf69548813 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 3 Aug 2026 11:38:57 +0530 Subject: [PATCH 1/2] fix(controllers): restore case-insensitive employee/lead/bom search ranking Reapplies #56330, which was reverted by #56389 with no recorded reason and has been absent since 23 June. The search filter uses .like(), which frappe renders as ILIKE on PostgreSQL, so a candidate matches regardless of case. The ranking used a bare Locate(), which frappe renders as strpos() -- case-sensitive there. A candidate can therefore pass the filter, score no match in the ranking, fall back to 99999 and sort last, while MariaDB's case-insensitive LOCATE ranks it first. Same query, different order on the two engines, and a different result page once page_len cuts between them. Lower() both operands, matching the item, project, user and pick list handlers in this same file, which were already correct. --- erpnext/controllers/queries.py | 37 +++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index 492726141ee..1f8fda36cce 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -73,14 +73,17 @@ def employee_query( .where(Criterion.any(search_conditions)) .orderby( Case() - .when(Locate(txt_no_percent, Employee.name) > 0, Locate(txt_no_percent, Employee.name)) + .when( + Locate(Lower(txt_no_percent), Lower(Employee.name)) > 0, + Locate(Lower(txt_no_percent), Lower(Employee.name)), + ) .else_(99999) ) .orderby( Case() .when( - Locate(txt_no_percent, Employee.employee_name) > 0, - Locate(txt_no_percent, Employee.employee_name), + Locate(Lower(txt_no_percent), Lower(Employee.employee_name)) > 0, + Locate(Lower(txt_no_percent), Lower(Employee.employee_name)), ) .else_(99999) ) @@ -136,17 +139,28 @@ def lead_query( query.where(Lead.docstatus < 2) .where(Lead.status.isnull() | (Lead.status != "Converted")) .where(Criterion.any(search_conditions)) - .orderby( - Case().when(Locate(txt_no_percent, Lead.name) > 0, Locate(txt_no_percent, Lead.name)).else_(99999) - ) .orderby( Case() - .when(Locate(txt_no_percent, Lead.lead_name) > 0, Locate(txt_no_percent, Lead.lead_name)) + .when( + Locate(Lower(txt_no_percent), Lower(Lead.name)) > 0, + Locate(Lower(txt_no_percent), Lower(Lead.name)), + ) .else_(99999) ) .orderby( Case() - .when(Locate(txt_no_percent, Lead.company_name) > 0, Locate(txt_no_percent, Lead.company_name)) + .when( + Locate(Lower(txt_no_percent), Lower(Lead.lead_name)) > 0, + Locate(Lower(txt_no_percent), Lower(Lead.lead_name)), + ) + .else_(99999) + ) + .orderby( + Case() + .when( + Locate(Lower(txt_no_percent), Lower(Lead.company_name)) > 0, + Locate(Lower(txt_no_percent), Lower(Lead.company_name)), + ) .else_(99999) ) .orderby(Lead.idx, order=Order.desc) @@ -387,7 +401,12 @@ def bom( .where(BOM.is_active == 1) .where(BOM[searchfield].like(f"%{txt}%")) .orderby( - Case().when(Locate(txt_no_percent, BOM.name) > 0, Locate(txt_no_percent, BOM.name)).else_(99999) + Case() + .when( + Locate(Lower(txt_no_percent), Lower(BOM.name)) > 0, + Locate(Lower(txt_no_percent), Lower(BOM.name)), + ) + .else_(99999) ) .orderby(BOM.idx, order=Order.desc) .orderby(BOM.name) From 1968f06cc81efedfad86fa011c2bc6f09da1ef08 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 3 Aug 2026 11:38:58 +0530 Subject: [PATCH 2/2] test(controllers): assert lead search ranking, not just result count The existing query tests assert only how many rows come back, so an ordering divergence between engines passes unnoticed. Adds a case-adversarial pair: a lead whose name starts with the search term in upper case, and one containing it in lower case later on. The first must rank ahead of the second. --- erpnext/controllers/tests/test_queries.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/erpnext/controllers/tests/test_queries.py b/erpnext/controllers/tests/test_queries.py index ff48ad7ed1d..3bab76ee1fa 100644 --- a/erpnext/controllers/tests/test_queries.py +++ b/erpnext/controllers/tests/test_queries.py @@ -29,6 +29,27 @@ class TestQueries(ERPNextTestSuite): self.assertGreaterEqual(len(query(txt="_Test Lead")), 4) self.assertEqual(len(query(txt="_Test Lead 4")), 1) + def test_lead_query_ranking_is_case_insensitive(self): + """A match at the start must rank first whatever its case. + + The filter uses .like(), which frappe renders as ILIKE on PostgreSQL, so both leads match. + Ranking used a bare Locate(), which becomes case-sensitive strpos() there: the upper-cased + lead scores no match, falls back to 99999 and sorts last, while MariaDB's case-insensitive + LOCATE ranks it first. Same query, different order -- and a different page when page_len is + small enough to cut between them. + """ + early, late = "ZZABCD Ranking Lead", "Ranking Lead zzabcd" + for lead_name in (early, late): + if not frappe.db.exists("Lead", {"lead_name": lead_name}): + frappe.get_doc({"doctype": "Lead", "lead_name": lead_name}).insert() + + query = add_default_params(queries.lead_query, "Lead") + names = [row[1] for row in query(txt="zzabcd")] + + self.assertIn(early, names) + self.assertIn(late, names) + self.assertLess(names.index(early), names.index(late)) + def test_item_query(self): query = add_default_params(queries.item_query, "Item")