From 0cecb5287fb5867c1447876abb21572c1fe685bf Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Thu, 16 Jul 2026 16:53:28 +0000 Subject: [PATCH] Search: Match folder album_path case-insensitively #5724 Folder search lowercases the query term, but album_path is VARBINARY and compared byte-exact (case-sensitive) on MySQL/MariaDB, so a lowercased term no longer matched uppercase folder paths. A parent folder still appeared via its case-insensitive title, while child folders - matchable only by path - dropped out of the results. Add a dialect-aware PathLike helper: MySQL converts album_path to a case-insensitive collation for the LIKE, SQLite and other dialects fall back to a plain LIKE (SQLite is already ASCII case-insensitive). Only the folder search filter is affected; byte-exact album_path identity lookups are unchanged, so emoji and case-distinct folders stay distinct. --- internal/entity/search/albums.go | 5 ++- internal/entity/search/albums_test.go | 49 +++++++++++++++++++++++ internal/entity/search/conditions.go | 15 +++++++ internal/entity/search/conditions_test.go | 16 ++++++++ 4 files changed, 84 insertions(+), 1 deletion(-) diff --git a/internal/entity/search/albums.go b/internal/entity/search/albums.go index 5dcb6e086..eda381027 100644 --- a/internal/entity/search/albums.go +++ b/internal/entity/search/albums.go @@ -145,7 +145,10 @@ func UserAlbums(frm form.SearchAlbums, sess *entity.Session) (results AlbumResul q := "%" + strings.Trim(frm.Query, " *%") + "%" if frm.Type == entity.AlbumFolder { - s = s.Where("albums.album_title LIKE ? OR albums.album_location LIKE ? OR albums.album_path LIKE ?", q, q, q) + // album_path is VARBINARY and matched case-insensitively so a lowercased query still + // finds uppercase folder paths; album_title and album_location are VARCHAR (already + // case-insensitive). + s = s.Where("albums.album_title LIKE ? OR albums.album_location LIKE ? OR "+PathLike(s.Dialect().GetName(), "albums.album_path"), q, q, q) } else { s = s.Where("albums.album_title LIKE ? OR albums.album_location LIKE ?", q, q) } diff --git a/internal/entity/search/albums_test.go b/internal/entity/search/albums_test.go index 859020320..bbe6d5b53 100644 --- a/internal/entity/search/albums_test.go +++ b/internal/entity/search/albums_test.go @@ -744,3 +744,52 @@ func TestAlbums(t *testing.T) { assert.Equal(t, false, result[0].AlbumFavorite) }) } + +func TestUserAlbums_FolderCaseInsensitivePath(t *testing.T) { + // Regression #5724: form.Unserialize lowercases the search term, but album_path is VARBINARY and + // compared byte-exact (case-sensitive) on MySQL, so an uppercase folder path stopped matching a + // lowercased query. The child folder below can be found ONLY via album_path (its title does not + // contain the term), so this fails on MySQL without the case-insensitive path match. + const albumUID = "as724caseinsens0" + const photoUID = "ps724caseinsens0" + const folderPath = "QAUPPER/CHILD" + + photo := entity.Photo{ + PhotoUID: photoUID, + PhotoName: "qa-5724", + PhotoPath: folderPath, + PhotoType: entity.MediaImage, + PhotoQuality: 3, + TakenAt: entity.Now(), + TakenAtLocal: entity.Now(), + } + if err := entity.Db().Create(&photo).Error; err != nil { + t.Fatal(err) + } + defer entity.UnscopedDb().Delete(&entity.Photo{}, "photo_uid = ?", photoUID) + + album := entity.Album{ + AlbumUID: albumUID, + AlbumType: entity.AlbumFolder, + AlbumSlug: "qaupper-child-5724", + AlbumPath: folderPath, + AlbumTitle: "CHILD", + } + if err := entity.Db().Create(&album).Error; err != nil { + t.Fatal(err) + } + defer entity.UnscopedDb().Delete(&entity.Album{}, "album_uid = ?", albumUID) + + // "QAUPPER" is lowercased to "qaupper" by the query parser before it reaches album_path. + result, err := Albums(form.SearchAlbums{Query: "QAUPPER", Type: entity.AlbumFolder, Count: 100}) + assert.NoError(t, err) + + found := false + for i := range result { + if result[i].AlbumUID == albumUID { + found = true + } + } + + assert.True(t, found, "folder with uppercase album_path must be found by a lowercased query") +} diff --git a/internal/entity/search/conditions.go b/internal/entity/search/conditions.go index e0024deda..f3e8bf036 100644 --- a/internal/entity/search/conditions.go +++ b/internal/entity/search/conditions.go @@ -5,11 +5,26 @@ import ( "strings" "github.com/photoprism/photoprism/pkg/clean" + "github.com/photoprism/photoprism/pkg/dsn" "github.com/photoprism/photoprism/pkg/txt" "github.com/jinzhu/inflection" ) +// PathLike returns a case-insensitive "col LIKE ?" condition for a VARBINARY path column such as +// album_path or photo_path. form.Unserialize lowercases the search term, but these columns are +// compared byte-exact (case-sensitive) on MySQL, so an uppercase path would otherwise never match a +// lowercased query. MySQL therefore needs an explicit case-insensitive collation; SQLite LIKE is +// already ASCII case-insensitive, and any other or unknown dialect falls back to a plain LIKE (its +// default semantics, never an error). The dialect is the GORM dialect name, e.g. s.Dialect().GetName(). +func PathLike(dialect, col string) string { + if dialect == dsn.DriverMySQL { + return "CONVERT(" + col + " USING utf8mb4) COLLATE utf8mb4_general_ci LIKE ?" + } + + return col + " LIKE ?" +} + // SqlParam sanitizes user input for use as a LIKE-clause bind value. The // surrounding pre/post strings are concatenated verbatim so callers can add // SQL wildcards (e.g. "%") without exposing the underlying value to string diff --git a/internal/entity/search/conditions_test.go b/internal/entity/search/conditions_test.go index 77af52694..715d61d88 100644 --- a/internal/entity/search/conditions_test.go +++ b/internal/entity/search/conditions_test.go @@ -8,9 +8,25 @@ import ( "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/pkg/clean" + "github.com/photoprism/photoprism/pkg/dsn" "github.com/photoprism/photoprism/pkg/txt" ) +func TestPathLike(t *testing.T) { + t.Run("MySQLCaseInsensitiveCollation", func(t *testing.T) { + // MySQL compares VARBINARY byte-exact, so the path is converted to a case-insensitive collation. + assert.Equal(t, "CONVERT(albums.album_path USING utf8mb4) COLLATE utf8mb4_general_ci LIKE ?", PathLike(dsn.DriverMySQL, "albums.album_path")) + }) + t.Run("SQLitePlainLike", func(t *testing.T) { + // SQLite LIKE is already ASCII case-insensitive, so a plain LIKE suffices. + assert.Equal(t, "albums.album_path LIKE ?", PathLike(dsn.DriverSQLite3, "albums.album_path")) + }) + t.Run("UnknownDialectFallsBackToPlainLike", func(t *testing.T) { + // A future or unknown dialect must not error; it falls back to a plain LIKE. + assert.Equal(t, "albums.album_path LIKE ?", PathLike(dsn.DriverPostgres, "albums.album_path")) + }) +} + func TestSqlParam(t *testing.T) { t.Run("Empty", func(t *testing.T) { assert.Equal(t, "", SqlParam("", "", ""))