diff --git a/db/migrations/20260615010718_add_like_search_covering_indexes.sql b/db/migrations/20260615010718_add_like_search_covering_indexes.sql new file mode 100644 index 000000000..f6682d182 --- /dev/null +++ b/db/migrations/20260615010718_add_like_search_covering_indexes.sql @@ -0,0 +1,23 @@ +-- +goose Up +-- COLLATE NOCASE covering indexes for the LIKE-based search fallback (CJK and +-- punctuation-only queries route to it; see persistence/sql_search_like.go +-- likeSearchColumns). Without these, a CJK search3 is a full scan of the wide +-- media_file table across 4 columns (~4s on 1M songs); with them SQLite scans the +-- narrow per-column indexes instead (~0.3s). Columns MUST match likeSearchColumns. +-- A plain LIKE uses these because Navidrome runs with case_sensitive_like = OFF. +CREATE INDEX IF NOT EXISTS idx_media_file_title_nocase ON media_file (title COLLATE NOCASE); +CREATE INDEX IF NOT EXISTS idx_media_file_album_nocase ON media_file (album COLLATE NOCASE); +CREATE INDEX IF NOT EXISTS idx_media_file_artist_nocase ON media_file (artist COLLATE NOCASE); +CREATE INDEX IF NOT EXISTS idx_media_file_album_artist_nocase ON media_file (album_artist COLLATE NOCASE); +CREATE INDEX IF NOT EXISTS idx_album_name_nocase ON album (name COLLATE NOCASE); +CREATE INDEX IF NOT EXISTS idx_album_album_artist_nocase ON album (album_artist COLLATE NOCASE); +CREATE INDEX IF NOT EXISTS idx_artist_name_nocase ON artist (name COLLATE NOCASE); + +-- +goose Down +DROP INDEX IF EXISTS idx_media_file_title_nocase; +DROP INDEX IF EXISTS idx_media_file_album_nocase; +DROP INDEX IF EXISTS idx_media_file_artist_nocase; +DROP INDEX IF EXISTS idx_media_file_album_artist_nocase; +DROP INDEX IF EXISTS idx_album_name_nocase; +DROP INDEX IF EXISTS idx_album_album_artist_nocase; +DROP INDEX IF EXISTS idx_artist_name_nocase; diff --git a/persistence/sql_search_like.go b/persistence/sql_search_like.go index 972545ac5..b9e175b9a 100644 --- a/persistence/sql_search_like.go +++ b/persistence/sql_search_like.go @@ -71,6 +71,10 @@ func legacySearchExpr(tableName string, s string) Sqlizer { // likeSearchColumns defines the core columns to search with LIKE queries. // These are the primary user-visible fields for each entity type. // Used as a fallback when FTS5 cannot handle the query (e.g., CJK text, punctuation-only input). +// +// Each column needs a matching COLLATE NOCASE index (migration +// add_like_search_covering_indexes) so these LIKE scans hit a narrow covering index +// instead of the full table. The "likeSearchColumns covering indexes" test enforces this. var likeSearchColumns = map[string][]string{ "media_file": {"title", "album", "artist", "album_artist"}, "album": {"name", "album_artist"}, diff --git a/persistence/sql_search_like_test.go b/persistence/sql_search_like_test.go index 8ee4ef93c..d051e0fcf 100644 --- a/persistence/sql_search_like_test.go +++ b/persistence/sql_search_like_test.go @@ -2,6 +2,8 @@ package persistence import ( "context" + "fmt" + "regexp" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -132,3 +134,37 @@ var _ = Describe("Legacy Integration Search", func() { Expect(results).ToNot(BeEmpty(), "Max=0 should mean no limit, not LIMIT 0") }) }) + +// Guards the invariant that every column searched by the LIKE fallback has a COLLATE NOCASE +// covering index (migration add_like_search_covering_indexes). Without it a CJK/punctuation +// search does a full table scan. If you add a column to likeSearchColumns without an index, +// this test fails — keep the two in sync. +var _ = Describe("likeSearchColumns covering indexes", func() { + It("has a COLLATE NOCASE index for every searched column", func() { + var indexSQLs []string + err := GetDBXBuilder(). + NewQuery("SELECT sql FROM sqlite_master WHERE type='index' AND sql IS NOT NULL"). + Column(&indexSQLs) + Expect(err).ToNot(HaveOccurred()) + + for table, columns := range likeSearchColumns { + // Match the index's target table precisely: "ON