From 9389ce6d9a20a1ef0f6c12240dad4580443afe28 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 21 Jun 2026 11:30:29 +0530 Subject: [PATCH] fix(website): case-insensitive Item Variant attribute match on Postgres get_item_codes_by_attributes compared Item Variant Attribute `attribute`/`attribute_value` with raw equality/IN, which is case-sensitive on Postgres -- a differently-cased website filter value missed variants that MariaDB (case-insensitive collation) matches. Lower() both sides: MariaDB result is unchanged (already case-insensitive), Postgres now matches too. Co-Authored-By: Claude Opus 4.8 (1M context) --- erpnext/utilities/product.py | 8 ++++++-- erpnext/utilities/test_product.py | 25 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 erpnext/utilities/test_product.py diff --git a/erpnext/utilities/product.py b/erpnext/utilities/product.py index 282a9a6f461..e19769fa95b 100644 --- a/erpnext/utilities/product.py +++ b/erpnext/utilities/product.py @@ -2,6 +2,7 @@ # License: GNU General Public License v3. See license.txt import frappe +from frappe.query_builder.functions import Lower from frappe.utils import cint, cstr, flt, fmt_money from erpnext.accounts.doctype.pricing_rule.pricing_rule import get_pricing_rule_for_item @@ -133,9 +134,12 @@ def get_item_codes_by_attributes(attribute_filters, template_item_code=None): frappe.qb.from_(iva) .select(iva.parent) # attribute_value is a varchar column; cast values to str so postgres doesn't choke on - # `varchar = numeric` for numeric attributes (stored values are strings on both backends) + # `varchar = numeric` for numeric attributes (stored values are strings on both backends). + # Lower() both sides so matching is case-insensitive on Postgres too, matching MariaDB's + # default collation (MariaDB result is unchanged -- it already matches case-insensitively). .where( - (iva.attribute == attribute) & (iva.attribute_value.isin([cstr(v) for v in attribute_values])) + (Lower(iva.attribute) == cstr(attribute).lower()) + & (Lower(iva.attribute_value).isin([cstr(v).lower() for v in attribute_values])) ) .where(iva.parent.isin(item_subquery)) .groupby(iva.parent) diff --git a/erpnext/utilities/test_product.py b/erpnext/utilities/test_product.py new file mode 100644 index 00000000000..19c529ef638 --- /dev/null +++ b/erpnext/utilities/test_product.py @@ -0,0 +1,25 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe + +from erpnext.controllers.item_variant import create_variant +from erpnext.tests.utils import ERPNextTestSuite +from erpnext.utilities.product import get_item_codes_by_attributes + + +class TestProduct(ERPNextTestSuite): + def test_get_item_codes_by_attributes_is_case_insensitive(self): + # get_item_codes_by_attributes matches Item Variant Attribute values. A raw equality is + # case-sensitive on Postgres, so a differently-cased filter value would miss variants that + # MariaDB (case-insensitive collation) matches. Lower() both sides keeps MariaDB unchanged and + # makes Postgres match too. + template = "_Test Variant Item" + variant = create_variant(template, {"Test Size": "Small"}) + if not frappe.db.exists("Item", variant.name): + variant.insert() + self.addCleanup(frappe.delete_doc, "Item", variant.name, force=True) + + # stored attribute value is "Small"; query with a different case + matches = get_item_codes_by_attributes({"Test Size": ["small"]}, template) + self.assertIn(variant.name, matches)