This commit is contained in:
Zach 2025-12-05 09:11:14 +11:00 committed by GitHub
commit 8acd8f4fa0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 127 additions and 9 deletions

View file

@ -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 {

View file

@ -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
}
}

View file

@ -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

View file

@ -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))
}
}

View file

@ -998,7 +998,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)
}

View file

@ -359,8 +359,10 @@ 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) + "%"
}
type sqlTable string