diff --git a/pkg/sqlite/driver.go b/pkg/sqlite/driver.go index 01a86d253..89f2c609f 100644 --- a/pkg/sqlite/driver.go +++ b/pkg/sqlite/driver.go @@ -30,6 +30,7 @@ func (d *CustomSQLiteDriver) Open(dsn string) (driver.Conn, error) { "durationToTinyInt": durationToTinyIntFn, "basename": basenameFn, "phash_distance": phashDistanceFn, + "lower_unicode": lowerUnicodeFn, } for name, fn := range funcs { diff --git a/pkg/sqlite/functions.go b/pkg/sqlite/functions.go index f639d0b5b..03aa4afb2 100644 --- a/pkg/sqlite/functions.go +++ b/pkg/sqlite/functions.go @@ -1,6 +1,7 @@ package sqlite import ( + "fmt" "path/filepath" "strconv" "strings" @@ -35,3 +36,27 @@ func durationToTinyIntFn(str string) (int64, error) { func basenameFn(str string) (string, error) { return filepath.Base(str), nil } + +// custom SQLite function to enable case-insensitive searches +// that properly handle unicode characters +func lowerUnicodeFn(str interface{}) (string, error) { + // handle NULL values + if str == nil { + return "", nil + } + + // handle different types + switch v := str.(type) { + case string: + return strings.ToLower(v), nil + case int64: + // convert int64 to string (for phash fingerprints) + return strings.ToLower(strconv.FormatInt(v, 10)), nil + case []byte: + // handle BLOB type if needed + return strings.ToLower(string(v)), nil + default: + // for any other type, try converting to string + return strings.ToLower(fmt.Sprintf("%v", v)), nil + } +} diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index d5d8ce2fa..cd3d73a33 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -2432,6 +2432,86 @@ func TestPerformerStore_FindByStashIDStatus(t *testing.T) { } } +func TestPerformerQueryUnicodeSearchCaseInsensitive(t *testing.T) { + withTxn(func(ctx context.Context) error { + qb := db.Performer + + // test cases with various Unicode characters + testCases := []struct { + name string + performerName string + searchTerm string + }{ + { + "Cyrillic lowercase search", + "Анна", + "анна", + }, + { + "Cyrillic uppercase search", + "мария", + "МАРИЯ", + }, + { + "Accented Latin lowercase", + "Zoë", + "zoë", + }, + { + "Accented Latin uppercase", + "chloé", + "CHLOÉ", + }, + { + "Greek lowercase search", + "Έλενα", + "έλενα", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // create performer with unicode name + performer := models.Performer{ + Name: tc.performerName, + } + err := qb.Create(ctx, &models.CreatePerformerInput{Performer: &performer}) + if err != nil { + t.Fatalf("Error creating performer: %s", err.Error()) + } + + // search using different case + findFilter := &models.FindFilterType{ + Q: &tc.searchTerm, + } + + performers, _, err := qb.Query(ctx, nil, findFilter) + if err != nil { + t.Fatalf("Error querying performers: %s", err.Error()) + } + + // should find the performer regardless of case + found := false + for _, p := range performers { + if p.ID == performer.ID { + found = true + break + } + } + + assert.True(t, found) + + // clean up + if err := qb.Destroy(ctx, performer.ID); err != nil { + t.Fatalf("Error cleaning up performer: %s", err.Error()) + } + }) + } + + return nil + }) +} + // TODO Update // TODO Destroy // TODO Find diff --git a/pkg/sqlite/query.go b/pkg/sqlite/query.go index 4f4c0c8db..cf2c9f3bb 100644 --- a/pkg/sqlite/query.go +++ b/pkg/sqlite/query.go @@ -181,12 +181,22 @@ func (qb *queryBuilder) addFilter(f *filterBuilder) error { func (qb *queryBuilder) parseQueryString(columns []string, q string) { specs := models.ParseSearchString(q) + // helper to wrap column with coalesce if it doesn't already have it + wrapColumn := func(column string) string { + // if column already has COALESCE or CAST, don't wrap again + if strings.HasPrefix(strings.ToUpper(strings.TrimSpace(column)), "COALESCE") || + strings.HasPrefix(strings.ToUpper(strings.TrimSpace(column)), "CAST") { + return column + } + return coalesce(column) + } + for _, t := range specs.MustHave { var clauses []string for _, column := range columns { - clauses = append(clauses, column+" LIKE ?") - qb.addArg(like(t)) + clauses = append(clauses, "lower_unicode("+wrapColumn(column)+") LIKE ?") + qb.addArg(likeLower(t)) } qb.addWhere("(" + strings.Join(clauses, " OR ") + ")") @@ -194,8 +204,8 @@ func (qb *queryBuilder) parseQueryString(columns []string, q string) { for _, t := range specs.MustNot { for _, column := range columns { - qb.addWhere(coalesce(column) + " NOT LIKE ?") - qb.addArg(like(t)) + qb.addWhere("lower_unicode(" + wrapColumn(column) + ") NOT LIKE ?") + qb.addArg(likeLower(t)) } } @@ -204,8 +214,8 @@ func (qb *queryBuilder) parseQueryString(columns []string, q string) { for _, column := range columns { for _, v := range set { - clauses = append(clauses, column+" LIKE ?") - qb.addArg(like(v)) + clauses = append(clauses, "lower_unicode("+wrapColumn(column)+") LIKE ?") + qb.addArg(likeLower(v)) } } diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 0a7829f28..f73913d64 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -958,7 +958,7 @@ func (qb *SceneStore) makeQuery(ctx context.Context, sceneFilter *models.SceneFi }, ) - filepathColumn := "folders.path || '" + string(filepath.Separator) + "' || files.basename" + filepathColumn := "COALESCE(folders.path, '') || '" + string(filepath.Separator) + "' || COALESCE(files.basename, '')" searchColumns := []string{"scenes.title", "scenes.details", filepathColumn, "files_fingerprints.fingerprint", "scene_markers.title"} query.parseQueryString(searchColumns, *q) } diff --git a/pkg/sqlite/sql.go b/pkg/sqlite/sql.go index 780d2e988..cea048dd8 100644 --- a/pkg/sqlite/sql.go +++ b/pkg/sqlite/sql.go @@ -359,6 +359,8 @@ func coalesce(column string) string { return fmt.Sprintf("COALESCE(%s, '')", column) } -func like(v string) string { - return "%" + v + "%" +// wraps a string with wildcard characters and converts it to lowercase +// for use in case-insensitive LIKE queries with the lower_unicode() SQL function. +func likeLower(v string) string { + return "%" + strings.ToLower(v) + "%" }