From 362c0bb3e698590665a85ed592fe96a79e4818c8 Mon Sep 17 00:00:00 2001 From: Matias Godoy Date: Tue, 5 Aug 2025 02:35:22 +0200 Subject: [PATCH] Fix accent-insensitive search for Postgres databases (#932) --- backend/internal/data/ent/item_predicates.go | 33 ++++------ .../internal/data/ent/item_predicates_test.go | 61 ++++++++----------- 2 files changed, 39 insertions(+), 55 deletions(-) diff --git a/backend/internal/data/ent/item_predicates.go b/backend/internal/data/ent/item_predicates.go index 3732ae9e..0dda2de5 100644 --- a/backend/internal/data/ent/item_predicates.go +++ b/backend/internal/data/ent/item_predicates.go @@ -22,7 +22,7 @@ func AccentInsensitiveContains(field string, searchValue string) predicate.Item return predicate.Item(func(s *sql.Selector) { dialect := s.Dialect() - + switch dialect { case "sqlite3": // For SQLite, we'll create a custom normalization function using REPLACE @@ -33,13 +33,15 @@ func AccentInsensitiveContains(field string, searchValue string) predicate.Item "%"+normalizedSearch+"%", )) case "postgres": - // For PostgreSQL, try to use unaccent extension if available - // Fall back to REPLACE-based normalization if not available - normalizeFunc := buildPostgreSQLNormalizeExpression(s.C(field)) - s.Where(sql.ExprP( - "LOWER("+normalizeFunc+") LIKE ?", - "%"+normalizedSearch+"%", - )) + // For PostgreSQL, use REPLACE-based normalization to avoid unaccent dependency + normalizeFunc := buildGenericNormalizeExpression(s.C(field)) + // Use sql.P() for proper PostgreSQL parameter binding ($1, $2, etc.) + s.Where(sql.P(func(b *sql.Builder) { + b.WriteString("LOWER(") + b.WriteString(normalizeFunc) + b.WriteString(") LIKE ") + b.Arg("%" + normalizedSearch + "%") + })) default: // Default fallback using REPLACE for common accented characters normalizeFunc := buildGenericNormalizeExpression(s.C(field)) @@ -56,22 +58,13 @@ func buildSQLiteNormalizeExpression(fieldExpr string) string { return buildGenericNormalizeExpression(fieldExpr) } -// buildPostgreSQLNormalizeExpression creates a PostgreSQL expression to normalize accented characters -func buildPostgreSQLNormalizeExpression(fieldExpr string) string { - // Use a CASE statement to check if unaccent function exists before using it - // This prevents errors when the unaccent extension is not installed - return "CASE WHEN EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'unaccent') " + - "THEN unaccent(" + fieldExpr + ") " + - "ELSE " + buildGenericNormalizeExpression(fieldExpr) + " END" -} - // buildGenericNormalizeExpression creates a database-agnostic expression to normalize common accented characters func buildGenericNormalizeExpression(fieldExpr string) string { // Chain REPLACE functions to handle the most common accented characters // Focused on the most frequently used accents in Spanish, French, and Portuguese // Ordered by frequency of use for better performance normalized := fieldExpr - + // Most common accented characters ordered by frequency commonAccents := []struct { from, to string @@ -88,11 +81,11 @@ func buildGenericNormalizeExpression(fieldExpr string) string { {"ä", "a"}, {"ö", "o"}, {"ü", "u"}, {"ã", "a"}, {"õ", "o"}, {"Ä", "A"}, {"Ö", "O"}, {"Ü", "U"}, {"Ã", "A"}, {"Õ", "O"}, } - + for _, accent := range commonAccents { normalized = "REPLACE(" + normalized + ", '" + accent.from + "', '" + accent.to + "')" } - + return normalized } diff --git a/backend/internal/data/ent/item_predicates_test.go b/backend/internal/data/ent/item_predicates_test.go index 9d49e24d..90968719 100644 --- a/backend/internal/data/ent/item_predicates_test.go +++ b/backend/internal/data/ent/item_predicates_test.go @@ -27,19 +27,19 @@ func TestBuildGenericNormalizeExpression(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := buildGenericNormalizeExpression(tt.field) - + // Should contain the original field assert.Contains(t, result, tt.field) - + // Should contain REPLACE functions for accent normalization assert.Contains(t, result, "REPLACE(") - + // Should handle common accented characters assert.Contains(t, result, "'á'", "Should handle Spanish á") assert.Contains(t, result, "'é'", "Should handle Spanish é") assert.Contains(t, result, "'ñ'", "Should handle Spanish ñ") assert.Contains(t, result, "'ü'", "Should handle German ü") - + // Should handle uppercase accents too assert.Contains(t, result, "'Á'", "Should handle uppercase Spanish Á") assert.Contains(t, result, "'É'", "Should handle uppercase Spanish É") @@ -49,7 +49,7 @@ func TestBuildGenericNormalizeExpression(t *testing.T) { func TestSQLiteNormalizeExpression(t *testing.T) { result := buildSQLiteNormalizeExpression("test_field") - + // Should contain the field name and REPLACE functions assert.Contains(t, result, "test_field") assert.Contains(t, result, "REPLACE(") @@ -58,15 +58,6 @@ func TestSQLiteNormalizeExpression(t *testing.T) { assert.Contains(t, result, "'ó'", "Should handle Spanish ó") } -func TestPostgreSQLNormalizeExpression(t *testing.T) { - result := buildPostgreSQLNormalizeExpression("test_field") - - // Should contain unaccent function and CASE WHEN logic - assert.Contains(t, result, "unaccent(") - assert.Contains(t, result, "CASE WHEN EXISTS") - assert.Contains(t, result, "test_field") -} - func TestAccentInsensitivePredicateCreation(t *testing.T) { tests := []struct { name string @@ -104,46 +95,46 @@ func TestAccentInsensitivePredicateCreation(t *testing.T) { func TestSpecificItemPredicates(t *testing.T) { tests := []struct { - name string + name string predicateFunc func(string) interface{} - searchValue string - description string + searchValue string + description string }{ { - name: "ItemNameAccentInsensitiveContains", + name: "ItemNameAccentInsensitiveContains", predicateFunc: func(val string) interface{} { return ItemNameAccentInsensitiveContains(val) }, - searchValue: "electronica", - description: "Should create accent-insensitive name search predicate", + searchValue: "electronica", + description: "Should create accent-insensitive name search predicate", }, { - name: "ItemDescriptionAccentInsensitiveContains", + name: "ItemDescriptionAccentInsensitiveContains", predicateFunc: func(val string) interface{} { return ItemDescriptionAccentInsensitiveContains(val) }, - searchValue: "descripcion", - description: "Should create accent-insensitive description search predicate", + searchValue: "descripcion", + description: "Should create accent-insensitive description search predicate", }, { - name: "ItemManufacturerAccentInsensitiveContains", + name: "ItemManufacturerAccentInsensitiveContains", predicateFunc: func(val string) interface{} { return ItemManufacturerAccentInsensitiveContains(val) }, - searchValue: "compañia", - description: "Should create accent-insensitive manufacturer search predicate", + searchValue: "compañia", + description: "Should create accent-insensitive manufacturer search predicate", }, { - name: "ItemSerialNumberAccentInsensitiveContains", + name: "ItemSerialNumberAccentInsensitiveContains", predicateFunc: func(val string) interface{} { return ItemSerialNumberAccentInsensitiveContains(val) }, - searchValue: "sn123", - description: "Should create accent-insensitive serial number search predicate", + searchValue: "sn123", + description: "Should create accent-insensitive serial number search predicate", }, { - name: "ItemModelNumberAccentInsensitiveContains", + name: "ItemModelNumberAccentInsensitiveContains", predicateFunc: func(val string) interface{} { return ItemModelNumberAccentInsensitiveContains(val) }, - searchValue: "model456", - description: "Should create accent-insensitive model number search predicate", + searchValue: "model456", + description: "Should create accent-insensitive model number search predicate", }, { - name: "ItemNotesAccentInsensitiveContains", + name: "ItemNotesAccentInsensitiveContains", predicateFunc: func(val string) interface{} { return ItemNotesAccentInsensitiveContains(val) }, - searchValue: "notas importantes", - description: "Should create accent-insensitive notes search predicate", + searchValue: "notas importantes", + description: "Should create accent-insensitive notes search predicate", }, }