mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
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:
parent
3f039866ff
commit
0cecb5287f
4 changed files with 84 additions and 1 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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("", "", ""))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue