mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
fix(smartplaylists): optimize smart playlist performance for role and tag criteria (#5515)
* fix(server): optimize smart playlist role queries for large criteria (#5511) Role-based smart playlist criteria (artist, composer, etc.) now query the indexed media_file_artists join table instead of parsing JSON via json_tree() on every row. Multiple conditions for the same role within an OR group are merged into a single EXISTS subquery (batched at 200 to stay under SQLite's expression tree depth limit). A composite index (media_file_id, role) replaces the now-redundant single-column (media_file_id) index on media_file_artists. Benchmark (40k tracks, 500 patterns, 3 artists/track): - Merged join-table: 15ms (9.3x faster) - Merged json_tree: 30ms (4.6x faster) - Unmerged baseline: 137ms * refactor: simplify role condition SQL generation and benchmark Extract shared roleCondSQL/roleExistsSQL helpers to deduplicate the EXISTS template between roleCond and roleCondGroup. Use slices.Chunk for batching per project convention. Extract runBenchQuery helper to eliminate triplicated benchmark execution loop. * chore: raise roleCondBatchSize to 350 The empirical SQLite limit is 496 conditions per merged EXISTS subquery. Raising from 200 to 350 reduces the number of batches (e.g. 500 patterns now splits into 2 batches instead of 3). * fix(server): apply OR-merge optimization to tag conditions too Generalize mergeRoleConds into mergeJsonConds to also collapse multiple tag conditions for the same tag (e.g. genre) within OR groups. This gives the same ~5x speedup for tag-heavy smart playlists as the role optimization gives for artist-heavy ones. * refactor: benchmark uses real criteria pipeline instead of hand-built SQL The "Current" sub-benchmark now builds criteria.Criteria expressions and runs them through the actual newSmartPlaylistCriteria → Where() → ToSql() pipeline, validating the real production code path. The baseline still uses hand-built SQL representing the old json_tree approach. * fix: stabilize merged group ordering and close rows before error check Sort group keys in mergeJsonConds so the merged additions have deterministic order across runs, improving SQLite statement cache reuse. Move rows.Close() before rows.Err() in benchmark helper.
This commit is contained in:
parent
753c4cda78
commit
14b80377cd
@ -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;
|
||||
@ -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) {
|
||||
|
||||
236
persistence/criteria_sql_benchmark_test.go
Normal file
236
persistence/criteria_sql_benchmark_test.go
Normal file
@ -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)
|
||||
}
|
||||
@ -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"}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user