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.
This commit is contained in:
Michael Mayer 2026-07-16 16:53:28 +00:00
parent 3f039866ff
commit 0cecb5287f
4 changed files with 84 additions and 1 deletions

View file

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

View file

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

View file

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

View file

@ -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("", "", ""))