diff --git a/db/migrations/20260520211813_add_media_file_artists_composite_index.sql b/db/migrations/20260520211813_add_media_file_artists_composite_index.sql new file mode 100644 index 000000000..f65050d80 --- /dev/null +++ b/db/migrations/20260520211813_add_media_file_artists_composite_index.sql @@ -0,0 +1,9 @@ +-- +goose Up +CREATE INDEX IF NOT EXISTS media_file_artists_media_file_id_role + ON media_file_artists (media_file_id, role); +DROP INDEX IF EXISTS media_file_artists_media_file_id; + +-- +goose Down +CREATE INDEX IF NOT EXISTS media_file_artists_media_file_id + ON media_file_artists (media_file_id); +DROP INDEX IF EXISTS media_file_artists_media_file_id_role; diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index a1bae3170..37e4ae340 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -3,7 +3,9 @@ package persistence import ( "errors" "fmt" + "maps" "reflect" + "slices" "strconv" "strings" "time" @@ -147,7 +149,7 @@ func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqliz } or = append(or, cond) } - return or, nil + return mergeJsonConds(or), nil case criteria.Is: return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { return squirrel.Eq(fields) @@ -381,17 +383,173 @@ type roleCond struct { func (e roleCond) ToSql() (string, []any, error) { var cond string var args []any - var err error if e.cond != nil { - cond, args, err = e.cond.ToSql() - cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name' and %s)", e.role, cond) + innerSQL, innerArgs, err := roleCondSQL(e.cond) + if err != nil { + return "", nil, err + } + cond = roleExistsSQL(innerSQL) + args = append([]any{e.role}, innerArgs...) } else { - cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name')", e.role) + cond = "exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)" + args = []any{e.role} } if e.not { cond = "not " + cond } - return cond, args, err + return cond, args, nil +} + +// roleCondSQL extracts SQL from a squirrel condition and rewrites the placeholder column name. +func roleCondSQL(cond squirrel.Sqlizer) (string, []any, error) { + sql, args, err := cond.ToSql() + if err != nil { + return "", nil, err + } + return strings.ReplaceAll(sql, "value", "artist.name"), args, nil +} + +// roleExistsSQL wraps a condition fragment in the standard role EXISTS subquery. +func roleExistsSQL(innerCond string) string { + return fmt.Sprintf("exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id "+ + "where mfa.media_file_id = media_file.id and mfa.role = ? and %s)", innerCond) +} + +// jsonCondBatchSize limits how many conditions are ORed inside a single EXISTS subquery +// to stay within SQLite's expression tree depth limit (max 1000). The EXISTS wrapper +// consumes ~4 levels; each ORed condition adds 1 level. Empirically, 496 is the maximum. +const jsonCondBatchSize = 350 + +// mergeJsonConds collapses multiple non-negated roleCond or tagCond entries for the same +// field within an OR group into batched EXISTS subqueries with the conditions ORed inside. +// This turns N separate correlated subqueries into ceil(N/batchSize), dramatically +// improving performance for smart playlists with many patterns. +func mergeJsonConds(or squirrel.Or) squirrel.Sqlizer { + type condEntry struct { + index int + cond squirrel.Sqlizer + } + type group struct { + entries []condEntry + isRole bool + numeric bool + tag string + } + groups := make(map[string]*group) + for i, s := range or { + switch c := s.(type) { + case roleCond: + if c.not || c.cond == nil { + continue + } + g, exists := groups["role:"+c.role] + if !exists { + g = &group{isRole: true} + groups["role:"+c.role] = g + } + g.entries = append(g.entries, condEntry{index: i, cond: c.cond}) + case tagCond: + if c.not || c.cond == nil { + continue + } + g, exists := groups["tag:"+c.tag] + if !exists { + g = &group{tag: c.tag, numeric: c.numeric} + groups["tag:"+c.tag] = g + } + g.entries = append(g.entries, condEntry{index: i, cond: c.cond}) + } + } + + merged := false + remove := make(map[int]bool) + var additions []squirrel.Sqlizer + for _, key := range slices.Sorted(maps.Keys(groups)) { + g := groups[key] + if len(g.entries) < 2 { + continue + } + merged = true + for _, e := range g.entries { + remove[e.index] = true + } + conds := make([]squirrel.Sqlizer, len(g.entries)) + for i, e := range g.entries { + conds[i] = e.cond + } + if g.isRole { + role := key[len("role:"):] + for batch := range slices.Chunk(conds, jsonCondBatchSize) { + additions = append(additions, roleCondGroup{role: role, conds: batch}) + } + } else { + for batch := range slices.Chunk(conds, jsonCondBatchSize) { + additions = append(additions, tagCondGroup{tag: g.tag, numeric: g.numeric, conds: batch}) + } + } + } + + if !merged { + return or + } + + result := make(squirrel.Or, 0, len(or)-len(remove)+len(additions)) + for i, s := range or { + if !remove[i] { + result = append(result, s) + } + } + result = append(result, additions...) + return result +} + +// roleCondGroup represents multiple role conditions for the same role, merged into +// a single EXISTS subquery for performance. +type roleCondGroup struct { + role string + conds []squirrel.Sqlizer +} + +func (g roleCondGroup) ToSql() (string, []any, error) { + innerParts := make([]string, 0, len(g.conds)) + allArgs := []any{g.role} + for _, c := range g.conds { + part, args, err := roleCondSQL(c) + if err != nil { + return "", nil, err + } + innerParts = append(innerParts, part) + allArgs = append(allArgs, args...) + } + cond := roleExistsSQL("(" + strings.Join(innerParts, " OR ") + ")") + return cond, allArgs, nil +} + +// tagCondGroup represents multiple tag conditions for the same tag, merged into +// a single EXISTS subquery for performance. +type tagCondGroup struct { + tag string + numeric bool + conds []squirrel.Sqlizer +} + +func (g tagCondGroup) ToSql() (string, []any, error) { + innerParts := make([]string, 0, len(g.conds)) + var allArgs []any + for _, c := range g.conds { + part, args, err := c.ToSql() + if err != nil { + return "", nil, err + } + if g.numeric { + part = strings.ReplaceAll(part, "value", "CAST(value AS REAL)") + } + innerParts = append(innerParts, part) + allArgs = append(allArgs, args...) + } + cond := fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and (%s))", + g.tag, strings.Join(innerParts, " OR ")) + return cond, allArgs, nil } func singleField(values map[string]any) (string, any, criteria.FieldInfo, bool) { diff --git a/persistence/criteria_sql_benchmark_test.go b/persistence/criteria_sql_benchmark_test.go new file mode 100644 index 000000000..d901e9eda --- /dev/null +++ b/persistence/criteria_sql_benchmark_test.go @@ -0,0 +1,236 @@ +package persistence + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "strings" + "testing" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" + "github.com/navidrome/navidrome/model/request" + "github.com/pocketbase/dbx" +) + +const ( + benchNumArtists = 1_000 + benchNumTracks = 40_000 + benchNumPatterns = 500 + benchArtistsPerTrack = 3 +) + +// BenchmarkSmartPlaylistRole compares role-based smart playlist query performance +// between the current implementation (merged join-table via criteria pipeline) and +// the old baseline (unmerged json_tree subqueries). +func BenchmarkSmartPlaylistRole(b *testing.B) { + configtest.SetupConfig() + tmpDir := b.TempDir() + conf.Server.DbPath = filepath.Join(tmpDir, "bench-smartpl.db") + cleanup := db.Init(context.Background()) + defer cleanup() + log.SetLevel(log.LevelFatal) + + conn := dbx.NewFromDB(db.Db(), db.Dialect) + ctx := log.NewContext(context.Background()) + user := model.User{ID: "bench-user", UserName: "bench", Name: "Bench User", IsAdmin: true} + ctx = request.WithUser(ctx, user) + + setupBenchData(b, ctx, conn, user) + criteria.AddRoles([]string{"artist"}) + + // Build the criteria expression: 500 "contains artist" patterns in an OR group + anyExprs := make(criteria.Any, benchNumPatterns) + for i := range benchNumPatterns { + anyExprs[i] = criteria.Contains{"artist": fmt.Sprintf("Artist %04d", i)} + } + expr := criteria.Criteria{Expression: anyExprs, Sort: "title", Limit: 500} + + b.Run("Current", func(b *testing.B) { + benchmarkCriteriaPipeline(b, ctx, expr) + }) + b.Run("Baseline_UnmergedJSONTree", func(b *testing.B) { + benchmarkUnmergedJSONTree(b, ctx) + }) +} + +// benchmarkCriteriaPipeline runs the criteria through the actual production code path: +// newSmartPlaylistCriteria → Where() → ToSql(), then executes the resulting query. +func benchmarkCriteriaPipeline(b *testing.B, ctx context.Context, expr criteria.Criteria) { + b.Helper() + + cSQL := newSmartPlaylistCriteria(expr) + + // Build the full query matching buildSmartPlaylistQuery + addCriteria + sq := squirrel.Select("media_file.id").From("media_file") + cond, err := cSQL.Where() + if err != nil { + b.Fatal(err) + } + sq = sq.Where(cond) + if expr.Limit > 0 { + sq = sq.Limit(uint64(expr.Limit)) + } + if order := cSQL.OrderBy(); order != "" { + sq = sq.OrderBy(order) + } + + query, args, err := sq.PlaceholderFormat(squirrel.Question).ToSql() + if err != nil { + b.Fatal(err) + } + + runBenchQuery(b, ctx, query, args) +} + +// benchmarkUnmergedJSONTree builds the old-style query with N separate json_tree EXISTS +// subqueries (the pre-optimization baseline). +func benchmarkUnmergedJSONTree(b *testing.B, ctx context.Context) { + b.Helper() + + var sb strings.Builder + sb.WriteString("SELECT media_file.id FROM media_file WHERE (") + args := make([]any, 0, benchNumPatterns) + for i := range benchNumPatterns { + if i > 0 { + sb.WriteString(" OR ") + } + sb.WriteString("exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)") + args = append(args, fmt.Sprintf("%%Artist %04d%%", i)) + } + sb.WriteString(") ORDER BY media_file.title LIMIT 500") + + runBenchQuery(b, ctx, sb.String(), args) +} + +func runBenchQuery(b *testing.B, ctx context.Context, query string, args []any) { + b.Helper() + sqlDB := db.Db() + b.ResetTimer() + for range b.N { + rows, err := sqlDB.QueryContext(ctx, query, args...) + if err != nil { + b.Fatal(err) + } + for rows.Next() { + var id string + _ = rows.Scan(&id) + } + rows.Close() + if err := rows.Err(); err != nil { + b.Fatal(err) + } + } +} + +func setupBenchData(b *testing.B, ctx context.Context, conn *dbx.DB, user model.User) { + b.Helper() + + sqlDB := db.Db() + + ur := NewUserRepository(ctx, conn) + if err := ur.Put(&user); err != nil { + b.Fatal(err) + } + if err := ur.SetUserLibraries(user.ID, []int{1}); err != nil { + b.Fatal(err) + } + + tx, err := sqlDB.Begin() + if err != nil { + b.Fatal(err) + } + + // Create artists + artistStmt, err := tx.Prepare("INSERT INTO artist (id, name) VALUES (?, ?)") + if err != nil { + b.Fatal(err) + } + for i := range benchNumArtists { + if _, err := artistStmt.Exec(fmt.Sprintf("artist-%04d", i), fmt.Sprintf("Artist %04d", i)); err != nil { + b.Fatal(err) + } + } + artistStmt.Close() + + // Ensure folder exists + folderID := "bench-folder" + if _, err := tx.Exec("INSERT OR IGNORE INTO folder (id, library_id, path, name, parent_id) VALUES (?, 1, '.', '.', '')", folderID); err != nil { + b.Fatal(err) + } + + // Create media files with participants JSON, cycling through artists + mfStmt, err := tx.Prepare(`INSERT INTO media_file (id, path, title, album, artist, artist_id, album_id, + duration, year, size, suffix, tags, participants, lyrics, library_id, folder_id, pid, codec) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + if err != nil { + b.Fatal(err) + } + + // Populate media_file_artists join table + mfaStmt, err := tx.Prepare("INSERT INTO media_file_artists (media_file_id, artist_id, role, sub_role) VALUES (?, ?, ?, ?)") + if err != nil { + b.Fatal(err) + } + + for i := range benchNumTracks { + trackID := fmt.Sprintf("track-%05d", i) + + // Assign benchArtistsPerTrack artists to each track, cycling through the pool + artistEntries := make([]map[string]string, benchArtistsPerTrack) + for a := range benchArtistsPerTrack { + artistIdx := (i + a) % benchNumArtists + artistEntries[a] = map[string]string{ + "id": fmt.Sprintf("artist-%04d", artistIdx), + "name": fmt.Sprintf("Artist %04d", artistIdx), + } + } + primaryArtistIdx := i % benchNumArtists + primaryArtistID := fmt.Sprintf("artist-%04d", primaryArtistIdx) + primaryArtistName := fmt.Sprintf("Artist %04d", primaryArtistIdx) + + participants := map[string][]map[string]string{"artist": artistEntries} + participantsJSON, _ := json.Marshal(participants) + + if _, err := mfStmt.Exec( + trackID, + fmt.Sprintf("music/%s.mp3", trackID), + fmt.Sprintf("Track %05d", i), + "Bench Album", + primaryArtistName, + primaryArtistID, + "bench-album", + 180, 2024, 5000000, "mp3", + "{}", + string(participantsJSON), + "[]", + 1, folderID, trackID, "mp3", + ); err != nil { + b.Fatal(err) + } + + // Insert all artist associations into the join table + for a := range benchArtistsPerTrack { + artistIdx := (i + a) % benchNumArtists + artistID := fmt.Sprintf("artist-%04d", artistIdx) + if _, err := mfaStmt.Exec(trackID, artistID, "artist", ""); err != nil { + b.Fatal(err) + } + } + } + mfStmt.Close() + mfaStmt.Close() + + if err := tx.Commit(); err != nil { + b.Fatal(err) + } + + b.Logf("Setup complete: %d artists, %d tracks (%d artists/track), %d patterns", + benchNumArtists, benchNumTracks, benchArtistsPerTrack, benchNumPatterns) +} diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index ae2695a4d..5c8909e1c 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -1,6 +1,8 @@ package persistence import ( + "fmt" + "strings" "time" "github.com/navidrome/navidrome/model" @@ -56,9 +58,9 @@ var _ = Describe("Smart playlist criteria SQL", func() { Entry("numeric tag", criteria.Lt{"rate": 6}, "exists (select 1 from json_tree(media_file.tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)", 6), Entry("tag alias", criteria.Is{"albumtype": "album"}, "exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)", "album"), Entry("field alias via tag registration", criteria.Is{"recordingdate": "2024-01-01"}, "media_file.date = ?", "2024-01-01"), - Entry("role is", criteria.Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"), - Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon%"), - Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), + Entry("role is", criteria.Is{"artist": "u2"}, "exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and artist.name = ?)", "artist", "u2"), + Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and artist.name LIKE ?)", "composer", "%Lennon%"), + Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and artist.name LIKE ?)", "artist", "%u2%"), // ReplayGain fields Entry("rgAlbumGain is", criteria.Is{"rgAlbumGain": 0}, "media_file.rg_album_gain = ?", 0), Entry("rgAlbumGain gt", criteria.Gt{"rgAlbumGain": -6.0}, "media_file.rg_album_gain > ?", -6.0), @@ -70,9 +72,9 @@ var _ = Describe("Smart playlist criteria SQL", func() { "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), // isMissing — roles Entry("isMissing role [true]", criteria.IsMissing{"artist": true}, - "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name')"), + "not exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "artist"), Entry("isMissing role [false]", criteria.IsMissing{"artist": false}, - "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name')"), + "exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "artist"), // isPresent — tags Entry("isPresent tag [true]", criteria.IsPresent{"genre": true}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), @@ -80,9 +82,9 @@ var _ = Describe("Smart playlist criteria SQL", func() { "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), // isPresent — roles Entry("isPresent role [true]", criteria.IsPresent{"composer": true}, - "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name')"), + "exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "composer"), Entry("isPresent role [false]", criteria.IsPresent{"composer": false}, - "not exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name')"), + "not exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "composer"), ) Describe("playlist permissions", func() { @@ -204,6 +206,146 @@ var _ = Describe("Smart playlist criteria SQL", func() { } }) + Describe("JSON condition merging", func() { + It("merges multiple role conditions in an OR group into a single EXISTS", func() { + expr := criteria.Any{ + criteria.Contains{"artist": "Beatles"}, + criteria.Contains{"artist": "Kraftwerk"}, + criteria.Contains{"artist": "Pink Floyd"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal("(exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and (artist.name LIKE ? OR artist.name LIKE ? OR artist.name LIKE ?)))")) + Expect(args).To(HaveExactElements("artist", "%Beatles%", "%Kraftwerk%", "%Pink Floyd%")) + }) + + It("does not merge role conditions from different roles", func() { + expr := criteria.Any{ + criteria.Contains{"artist": "Beatles"}, + criteria.Contains{"composer": "Lennon"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, _, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("mfa.role = ?")) + // Two separate EXISTS since roles differ + Expect(strings.Count(sql, "exists")).To(Equal(2)) + }) + + It("does not merge negated role conditions", func() { + expr := criteria.Any{ + criteria.NotContains{"artist": "Beatles"}, + criteria.NotContains{"artist": "Kraftwerk"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, _, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + // Two separate "not exists" since they are negated + Expect(strings.Count(sql, "not exists")).To(Equal(2)) + }) + + It("batches large groups to avoid SQLite expression tree depth limit", func() { + // Create jsonCondBatchSize + 1 conditions to trigger batching into 2 groups + anyExprs := make(criteria.Any, jsonCondBatchSize+1) + for i := range anyExprs { + anyExprs[i] = criteria.Contains{"artist": fmt.Sprintf("Artist%d", i)} + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: anyExprs}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + // Should produce 2 EXISTS subqueries (one batch of jsonCondBatchSize, one of 1) + Expect(strings.Count(sql, "exists")).To(Equal(2)) + // First batch has jsonCondBatchSize patterns, second has 1 => total args: + // 2 roles + (jsonCondBatchSize + 1) patterns + Expect(args).To(HaveLen(2 + jsonCondBatchSize + 1)) + }) + + It("merges role conditions while preserving non-role conditions", func() { + expr := criteria.Any{ + criteria.Contains{"title": "Love"}, + criteria.Contains{"artist": "Beatles"}, + criteria.Contains{"artist": "Kraftwerk"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("media_file.title LIKE ?")) + Expect(sql).To(ContainSubstring("artist.name LIKE ? OR artist.name LIKE ?")) + Expect(args).To(HaveExactElements("%Love%", "artist", "%Beatles%", "%Kraftwerk%")) + }) + + It("merges multiple tag conditions in an OR group into a single EXISTS", func() { + expr := criteria.Any{ + criteria.Contains{"genre": "Rock"}, + criteria.Contains{"genre": "Metal"}, + criteria.Contains{"genre": "Punk"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal("(exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and (value LIKE ? OR value LIKE ? OR value LIKE ?)))")) + Expect(args).To(HaveExactElements("%Rock%", "%Metal%", "%Punk%")) + }) + + It("does not merge tag conditions from different tags", func() { + expr := criteria.Any{ + criteria.Contains{"genre": "Rock"}, + criteria.Contains{"mood": "Happy"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, _, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(strings.Count(sql, "exists")).To(Equal(2)) + }) + + It("does not merge negated tag conditions", func() { + expr := criteria.Any{ + criteria.NotContains{"genre": "Rock"}, + criteria.NotContains{"genre": "Metal"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, _, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(strings.Count(sql, "not exists")).To(Equal(2)) + }) + + It("merges role and tag conditions independently", func() { + expr := criteria.Any{ + criteria.Contains{"artist": "Beatles"}, + criteria.Contains{"artist": "Kraftwerk"}, + criteria.Contains{"genre": "Rock"}, + criteria.Contains{"genre": "Metal"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + // Two merged EXISTS: one for roles, one for tags + Expect(strings.Count(sql, "exists")).To(Equal(2)) + Expect(sql).To(ContainSubstring("artist.name LIKE ? OR artist.name LIKE ?")) + Expect(sql).To(ContainSubstring("value LIKE ? OR value LIKE ?")) + Expect(args).To(HaveLen(2 + 2 + 1)) // 2 tag patterns + 2 role patterns + 1 role name + }) + }) + Describe("joins", func() { It("excludes sort-only joins from expression joins", func() { c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "albumRating"}