refactor: move smart playlist criteria SQL to persistence (#5408)

* refactor: move criteria SQL generation to persistence

Keep model/criteria as a domain DSL with JSON parsing, field metadata, expression traversal, and child playlist extraction only. Move smart playlist SQL translation, sort SQL, and join planning into persistence behind smartPlaylistCriteria so repository code uses a small query-building API.

* refactor: simplify criteria translator metadata

Use generic helper functions for criteria operator maps so the SQL translator can pass named criteria map types directly. Remove unused pseudo-field metadata from the criteria field API while preserving special field name lookup.

* test: add coverage check for criteria-to-SQL field mappings

Add a test that iterates all fields registered in the criteria package and
verifies that every non-tag/non-role field has a corresponding entry in
the persistence layer's smartPlaylistFields map. This prevents silent
drift between the domain field registry and the SQL translation layer.

Also adds an AllFieldNames() function to the criteria package to support
field enumeration from outside the package.
This commit is contained in:
Deluan Quintão
2026-04-24 23:18:20 -04:00
committed by GitHub
parent 3b3b9a62ca
commit 251cc71e2d
11 changed files with 1017 additions and 960 deletions
+5 -109
View File
@@ -1,17 +1,16 @@
// Package criteria implements a Criteria API based on Masterminds/squirrel
// Package criteria implements the smart playlist criteria DSL.
package criteria
import (
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log"
)
type Expression = squirrel.Sqlizer
type Expression interface {
criteriaExpression()
}
type Criteria struct {
Expression
@@ -49,115 +48,12 @@ func (c Criteria) IsPercentageLimit() bool {
return c.Limit == 0 && c.LimitPercent > 0 && c.LimitPercent <= 100
}
func (c Criteria) OrderBy() string {
if c.Sort == "" {
c.Sort = "title"
}
order := strings.ToLower(strings.TrimSpace(c.Order))
if order != "" && order != "asc" && order != "desc" {
log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.Order)
order = ""
}
parts := strings.Split(c.Sort, ",")
fields := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
dir := "asc"
if strings.HasPrefix(p, "+") || strings.HasPrefix(p, "-") {
if strings.HasPrefix(p, "-") {
dir = "desc"
}
p = strings.TrimSpace(p[1:])
}
sortField := strings.ToLower(p)
f := fieldMap[sortField]
if f == nil {
log.Error("Invalid field in 'sort' field", "sort", sortField)
continue
}
var mapped string
if f.order != "" {
mapped = f.order
} else if f.isTag {
// Use the actual field name (handles aliases like albumtype -> releasetype)
tagName := sortField
if f.field != "" {
tagName = f.field
}
mapped = "COALESCE(json_extract(media_file.tags, '$." + tagName + "[0].value'), '')"
} else if f.isRole {
mapped = "COALESCE(json_extract(media_file.participants, '$." + sortField + "[0].name'), '')"
} else {
mapped = f.field
}
if f.numeric {
mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped)
}
// If the global 'order' field is set to 'desc', reverse the default or field-specific sort direction.
// This ensures that the global order applies consistently across all fields.
if order == "desc" {
if dir == "asc" {
dir = "desc"
} else {
dir = "asc"
}
}
fields = append(fields, mapped+" "+dir)
}
return strings.Join(fields, ", ")
}
func (c Criteria) ToSql() (sql string, args []any, err error) {
return c.Expression.ToSql()
}
// ExpressionJoins returns only the JOINs needed by the WHERE-clause expression,
// excluding any JOINs required solely for sorting. This is useful for COUNT
// queries where sort order is irrelevant.
func (c Criteria) ExpressionJoins() JoinType {
if c.Expression == nil {
return JoinNone
}
return extractJoinTypes(c.Expression)
}
// RequiredJoins inspects the expression tree and Sort field to determine which
// additional JOINs are needed when evaluating this criteria.
func (c Criteria) RequiredJoins() JoinType {
result := JoinNone
if c.Expression != nil {
result |= extractJoinTypes(c.Expression)
}
// Also check Sort fields
if c.Sort != "" {
for _, p := range strings.Split(c.Sort, ",") {
p = strings.TrimSpace(p)
p = strings.TrimLeft(p, "+-")
p = strings.TrimSpace(p)
result |= fieldJoinType(p)
}
}
return result
}
func (c Criteria) ChildPlaylistIds() []string {
if c.Expression == nil {
return nil
}
if parent := c.Expression.(interface{ ChildPlaylistIds() (ids []string) }); parent != nil {
if parent, ok := c.Expression.(interface{ ChildPlaylistIds() (ids []string) }); ok {
return parent.ChildPlaylistIds()
}
+4 -205
View File
@@ -65,16 +65,6 @@ var _ = Describe("Criteria", func() {
}
jsonObj = b.String()
})
It("generates valid SQL", func() {
sql, args, err := goObj.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal(
`(media_file.title LIKE ? AND media_file.title NOT LIKE ? ` +
`AND (not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?) ` +
`OR media_file.album = ?) AND (media_file.comment LIKE ? AND (media_file.year >= ? AND media_file.year <= ?) ` +
`AND not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?) AND COALESCE(album_annotation.rating, 0) > ?))`))
gomega.Expect(args).To(gomega.HaveExactElements("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990, "Rock", 3))
})
It("marshals to JSON", func() {
j, err := json.Marshal(goObj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
@@ -88,201 +78,6 @@ var _ = Describe("Criteria", func() {
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(string(j)).To(gomega.Equal(jsonObj))
})
Describe("OrderBy", func() {
It("sorts by regular fields", func() {
gomega.Expect(goObj.OrderBy()).To(gomega.Equal("media_file.title asc"))
})
It("sorts by tag fields", func() {
goObj.Sort = "genre"
gomega.Expect(goObj.OrderBy()).To(
gomega.Equal(
"COALESCE(json_extract(media_file.tags, '$.genre[0].value'), '') asc",
),
)
})
It("sorts by role fields", func() {
goObj.Sort = "artist"
gomega.Expect(goObj.OrderBy()).To(
gomega.Equal(
"COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') asc",
),
)
})
It("casts numeric tags when sorting", func() {
AddTagNames([]string{"rate"})
AddNumericTags([]string{"rate"})
goObj.Sort = "rate"
gomega.Expect(goObj.OrderBy()).To(
gomega.Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc"),
)
})
It("sorts by albumtype alias (resolves to releasetype)", func() {
AddTagNames([]string{"releasetype"})
goObj.Sort = "albumtype"
gomega.Expect(goObj.OrderBy()).To(
gomega.Equal(
"COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc",
),
)
})
It("sorts by random", func() {
newObj := goObj
newObj.Sort = "random"
gomega.Expect(newObj.OrderBy()).To(gomega.Equal("random() asc"))
})
It("sorts by multiple fields", func() {
goObj.Sort = "title,-rating"
gomega.Expect(goObj.OrderBy()).To(gomega.Equal(
"media_file.title asc, COALESCE(annotation.rating, 0) desc",
))
})
It("reverts order when order is desc", func() {
goObj.Sort = "-date,artist"
goObj.Order = "desc"
gomega.Expect(goObj.OrderBy()).To(gomega.Equal(
"media_file.date asc, COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') desc",
))
})
It("ignores invalid sort fields", func() {
goObj.Sort = "bogus,title"
gomega.Expect(goObj.OrderBy()).To(gomega.Equal(
"media_file.title asc",
))
})
})
})
Context("with artist roles", func() {
BeforeEach(func() {
goObj = Criteria{
Expression: All{
Is{"artist": "The Beatles"},
Contains{"composer": "Lennon"},
},
}
})
It("generates valid SQL", func() {
sql, args, err := goObj.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal(
`(exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?) AND ` +
`exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?))`,
))
gomega.Expect(args).To(gomega.HaveExactElements("The Beatles", "%Lennon%"))
})
})
Describe("ExpressionJoins", func() {
It("excludes sort-only joins", func() {
c := Criteria{
Expression: All{
Contains{"title": "love"},
},
Sort: "albumRating",
}
gomega.Expect(c.ExpressionJoins()).To(gomega.Equal(JoinNone))
gomega.Expect(c.RequiredJoins().Has(JoinAlbumAnnotation)).To(gomega.BeTrue())
})
It("includes expression-based joins", func() {
c := Criteria{
Expression: All{
Gt{"albumRating": 3},
},
}
gomega.Expect(c.ExpressionJoins().Has(JoinAlbumAnnotation)).To(gomega.BeTrue())
})
})
Describe("RequiredJoins", func() {
It("returns JoinNone when no annotation fields are used", func() {
c := Criteria{
Expression: All{
Contains{"title": "love"},
},
}
gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinNone))
})
It("returns JoinNone for media_file annotation fields", func() {
c := Criteria{
Expression: All{
Is{"loved": true},
Gt{"playCount": 5},
},
}
gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinNone))
})
It("returns JoinAlbumAnnotation for album annotation fields", func() {
c := Criteria{
Expression: All{
Gt{"albumRating": 3},
},
}
gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinAlbumAnnotation))
})
It("returns JoinArtistAnnotation for artist annotation fields", func() {
c := Criteria{
Expression: All{
Is{"artistLoved": true},
},
}
gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinArtistAnnotation))
})
It("returns both join types when both are used", func() {
c := Criteria{
Expression: All{
Gt{"albumRating": 3},
Is{"artistLoved": true},
},
}
j := c.RequiredJoins()
gomega.Expect(j.Has(JoinAlbumAnnotation)).To(gomega.BeTrue())
gomega.Expect(j.Has(JoinArtistAnnotation)).To(gomega.BeTrue())
})
It("detects join types in nested expressions", func() {
c := Criteria{
Expression: All{
Any{
All{
Is{"albumLoved": true},
},
},
Any{
Gt{"artistPlayCount": 10},
},
},
}
j := c.RequiredJoins()
gomega.Expect(j.Has(JoinAlbumAnnotation)).To(gomega.BeTrue())
gomega.Expect(j.Has(JoinArtistAnnotation)).To(gomega.BeTrue())
})
It("detects join types from Sort field", func() {
c := Criteria{
Expression: All{
Contains{"title": "love"},
},
Sort: "albumRating",
}
gomega.Expect(c.RequiredJoins().Has(JoinAlbumAnnotation)).To(gomega.BeTrue())
})
It("detects join types from Sort field with direction prefix", func() {
c := Criteria{
Expression: All{
Contains{"title": "love"},
},
Sort: "-artistRating",
}
gomega.Expect(c.RequiredJoins().Has(JoinArtistAnnotation)).To(gomega.BeTrue())
})
})
Describe("LimitPercent", func() {
@@ -470,5 +265,9 @@ var _ = Describe("Criteria", func() {
ids := Criteria{}.ChildPlaylistIds()
gomega.Expect(ids).To(gomega.BeEmpty())
})
It("returns empty list for leaf expressions", func() {
ids := Criteria{Expression: Is{"title": "Low Rider"}}.ChildPlaylistIds()
gomega.Expect(ids).To(gomega.BeEmpty())
})
})
})
+110 -258
View File
@@ -1,280 +1,133 @@
package criteria
import (
"fmt"
"reflect"
"strings"
import "strings"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log"
)
// JoinType is a bitmask indicating which additional JOINs are needed by a smart playlist expression.
type JoinType int
const (
JoinNone JoinType = 0
JoinAlbumAnnotation JoinType = 1 << iota
JoinArtistAnnotation
)
// Has returns true if j contains all bits in other.
func (j JoinType) Has(other JoinType) bool { return j&other != 0 }
var fieldMap = map[string]*mappedField{
"title": {field: "media_file.title"},
"album": {field: "media_file.album"},
"hascoverart": {field: "media_file.has_cover_art"},
"tracknumber": {field: "media_file.track_number"},
"discnumber": {field: "media_file.disc_number"},
"year": {field: "media_file.year"},
"date": {field: "media_file.date", alias: "recordingdate"},
"originalyear": {field: "media_file.original_year"},
"originaldate": {field: "media_file.original_date"},
"releaseyear": {field: "media_file.release_year"},
"releasedate": {field: "media_file.release_date"},
"size": {field: "media_file.size"},
"compilation": {field: "media_file.compilation"},
"missing": {field: "media_file.missing"},
"explicitstatus": {field: "media_file.explicit_status"},
"dateadded": {field: "media_file.created_at"},
"datemodified": {field: "media_file.updated_at"},
"discsubtitle": {field: "media_file.disc_subtitle"},
"comment": {field: "media_file.comment"},
"lyrics": {field: "media_file.lyrics"},
"sorttitle": {field: "media_file.sort_title"},
"sortalbum": {field: "media_file.sort_album_name"},
"sortartist": {field: "media_file.sort_artist_name"},
"sortalbumartist": {field: "media_file.sort_album_artist_name"},
"albumcomment": {field: "media_file.mbz_album_comment"},
"catalognumber": {field: "media_file.catalog_num"},
"filepath": {field: "media_file.path"},
"filetype": {field: "media_file.suffix"},
"codec": {field: "media_file.codec"},
"duration": {field: "media_file.duration"},
"bitrate": {field: "media_file.bit_rate"},
"bitdepth": {field: "media_file.bit_depth"},
"samplerate": {field: "media_file.sample_rate"},
"bpm": {field: "media_file.bpm"},
"channels": {field: "media_file.channels"},
"loved": {field: "COALESCE(annotation.starred, false)"},
"dateloved": {field: "annotation.starred_at"},
"lastplayed": {field: "annotation.play_date"},
"daterated": {field: "annotation.rated_at"},
"playcount": {field: "COALESCE(annotation.play_count, 0)"},
"rating": {field: "COALESCE(annotation.rating, 0)"},
"averagerating": {field: "media_file.average_rating", numeric: true},
"albumrating": {field: "COALESCE(album_annotation.rating, 0)", joinType: JoinAlbumAnnotation},
"albumloved": {field: "COALESCE(album_annotation.starred, false)", joinType: JoinAlbumAnnotation},
"albumplaycount": {field: "COALESCE(album_annotation.play_count, 0)", joinType: JoinAlbumAnnotation},
"albumlastplayed": {field: "album_annotation.play_date", joinType: JoinAlbumAnnotation},
"albumdateloved": {field: "album_annotation.starred_at", joinType: JoinAlbumAnnotation},
"albumdaterated": {field: "album_annotation.rated_at", joinType: JoinAlbumAnnotation},
"artistrating": {field: "COALESCE(artist_annotation.rating, 0)", joinType: JoinArtistAnnotation},
"artistloved": {field: "COALESCE(artist_annotation.starred, false)", joinType: JoinArtistAnnotation},
"artistplaycount": {field: "COALESCE(artist_annotation.play_count, 0)", joinType: JoinArtistAnnotation},
"artistlastplayed": {field: "artist_annotation.play_date", joinType: JoinArtistAnnotation},
"artistdateloved": {field: "artist_annotation.starred_at", joinType: JoinArtistAnnotation},
"artistdaterated": {field: "artist_annotation.rated_at", joinType: JoinArtistAnnotation},
"mbz_album_id": {field: "media_file.mbz_album_id"},
"mbz_album_artist_id": {field: "media_file.mbz_album_artist_id"},
"mbz_artist_id": {field: "media_file.mbz_artist_id"},
"mbz_recording_id": {field: "media_file.mbz_recording_id"},
"mbz_release_track_id": {field: "media_file.mbz_release_track_id"},
"mbz_release_group_id": {field: "media_file.mbz_release_group_id"},
"library_id": {field: "media_file.library_id", numeric: true},
// Backward compatibility: albumtype is an alias for releasetype tag
"albumtype": {field: "releasetype", isTag: true},
// special fields
"random": {field: "", order: "random()"}, // pseudo-field for random sorting
"value": {field: "value"}, // pseudo-field for tag and roles values
// FieldInfo describes a criteria field without tying it to persistence details.
type FieldInfo struct {
Name string
IsTag bool
IsRole bool
Numeric bool
}
type mappedField struct {
field string
order string
isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.)
isTag bool // true if the field is a tag imported from the file metadata
alias string // name from `mappings.yml` that may differ from the name used in the smart playlist
numeric bool // true if the field/tag should be treated as numeric
joinType JoinType // which additional JOINs this field requires
var fieldMap = map[string]*fieldMetadata{
"title": {name: "title"},
"album": {name: "album"},
"hascoverart": {name: "hascoverart"},
"tracknumber": {name: "tracknumber"},
"discnumber": {name: "discnumber"},
"year": {name: "year"},
"date": {name: "date", alias: "recordingdate"},
"originalyear": {name: "originalyear"},
"originaldate": {name: "originaldate"},
"releaseyear": {name: "releaseyear"},
"releasedate": {name: "releasedate"},
"size": {name: "size"},
"compilation": {name: "compilation"},
"missing": {name: "missing"},
"explicitstatus": {name: "explicitstatus"},
"dateadded": {name: "dateadded"},
"datemodified": {name: "datemodified"},
"discsubtitle": {name: "discsubtitle"},
"comment": {name: "comment"},
"lyrics": {name: "lyrics"},
"sorttitle": {name: "sorttitle"},
"sortalbum": {name: "sortalbum"},
"sortartist": {name: "sortartist"},
"sortalbumartist": {name: "sortalbumartist"},
"albumcomment": {name: "albumcomment"},
"catalognumber": {name: "catalognumber"},
"filepath": {name: "filepath"},
"filetype": {name: "filetype"},
"codec": {name: "codec"},
"duration": {name: "duration"},
"bitrate": {name: "bitrate"},
"bitdepth": {name: "bitdepth"},
"samplerate": {name: "samplerate"},
"bpm": {name: "bpm"},
"channels": {name: "channels"},
"loved": {name: "loved"},
"dateloved": {name: "dateloved"},
"lastplayed": {name: "lastplayed"},
"daterated": {name: "daterated"},
"playcount": {name: "playcount"},
"rating": {name: "rating"},
"averagerating": {name: "averagerating", numeric: true},
"albumrating": {name: "albumrating"},
"albumloved": {name: "albumloved"},
"albumplaycount": {name: "albumplaycount"},
"albumlastplayed": {name: "albumlastplayed"},
"albumdateloved": {name: "albumdateloved"},
"albumdaterated": {name: "albumdaterated"},
"artistrating": {name: "artistrating"},
"artistloved": {name: "artistloved"},
"artistplaycount": {name: "artistplaycount"},
"artistlastplayed": {name: "artistlastplayed"},
"artistdateloved": {name: "artistdateloved"},
"artistdaterated": {name: "artistdaterated"},
"mbz_album_id": {name: "mbz_album_id"},
"mbz_album_artist_id": {name: "mbz_album_artist_id"},
"mbz_artist_id": {name: "mbz_artist_id"},
"mbz_recording_id": {name: "mbz_recording_id"},
"mbz_release_track_id": {name: "mbz_release_track_id"},
"mbz_release_group_id": {name: "mbz_release_group_id"},
"library_id": {name: "library_id", numeric: true},
// Backward compatibility: albumtype is an alias for the releasetype tag.
"albumtype": {name: "releasetype", isTag: true},
"random": {name: "random"},
"value": {name: "value"},
}
func mapFields(expr map[string]any) map[string]any {
m := make(map[string]any)
for f, v := range expr {
if dbf := fieldMap[strings.ToLower(f)]; dbf != nil && dbf.field != "" {
m[dbf.field] = v
} else {
log.Error("Invalid field in criteria", "field", f)
}
type fieldMetadata struct {
name string
isRole bool
isTag bool
alias string
numeric bool
}
// AllFieldNames returns the names of all registered criteria fields.
func AllFieldNames() []string {
names := make([]string, 0, len(fieldMap))
for name := range fieldMap {
names = append(names, name)
}
return m
return names
}
// mapExpr maps a normal field expression to a specific type of expression (tag or role).
// This is required because tags are handled differently than other fields,
// as they are stored as a JSON column in the database.
func mapExpr(expr squirrel.Sqlizer, negate bool, exprFunc func(string, squirrel.Sqlizer, bool) squirrel.Sqlizer) squirrel.Sqlizer {
rv := reflect.ValueOf(expr)
if rv.Kind() != reflect.Map || rv.Type().Key().Kind() != reflect.String {
log.Fatal(fmt.Sprintf("expr is not a map-based operator: %T", expr))
// LookupField returns semantic metadata for a criteria field name.
func LookupField(name string) (FieldInfo, bool) {
f, ok := fieldMap[strings.ToLower(name)]
if !ok {
return FieldInfo{}, false
}
// Extract the field name and value, then build a new map keyed by "value"
// for the inner condition. The original map is left untouched so that
// ToSql can be called multiple times without corruption.
var k string
var v any
for _, key := range rv.MapKeys() {
k = key.String()
v = rv.MapIndex(key).Interface()
break // only one key is expected (and supported)
}
// Create a new map-based expression with "value" as the key, matching the
// column name inside json_tree subqueries.
newMap := reflect.MakeMap(rv.Type())
newMap.SetMapIndex(reflect.ValueOf("value"), reflect.ValueOf(v))
newExpr := newMap.Interface().(squirrel.Sqlizer)
return exprFunc(k, newExpr, negate)
}
// mapTagExpr maps a normal field expression to a tag expression.
func mapTagExpr(expr squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
return mapExpr(expr, negate, tagExpr)
}
// mapRoleExpr maps a normal field expression to an artist role expression.
func mapRoleExpr(expr squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
return mapExpr(expr, negate, roleExpr)
}
func isTagExpr(expr map[string]any) bool {
for f := range expr {
if f2, ok := fieldMap[strings.ToLower(f)]; ok && f2.isTag {
return true
}
}
return false
}
func isRoleExpr(expr map[string]any) bool {
for f := range expr {
if f2, ok := fieldMap[strings.ToLower(f)]; ok && f2.isRole {
return true
}
}
return false
}
func tagExpr(tag string, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
return tagCond{tag: tag, cond: cond, not: negate}
}
type tagCond struct {
tag string
cond squirrel.Sqlizer
not bool
}
func (e tagCond) ToSql() (string, []any, error) {
cond, args, err := e.cond.ToSql()
// Resolve the actual tag name (handles aliases like albumtype -> releasetype)
tagName := e.tag
if fm, ok := fieldMap[e.tag]; ok {
if fm.field != "" {
tagName = fm.field
}
if fm.numeric {
cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)")
}
}
cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)",
tagName, cond)
if e.not {
cond = "not " + cond
}
return cond, args, err
}
func roleExpr(role string, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
return roleCond{role: role, cond: cond, not: negate}
}
type roleCond struct {
role string
cond squirrel.Sqlizer
not bool
}
func (e roleCond) ToSql() (string, []any, error) {
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)
if e.not {
cond = "not " + cond
}
return cond, args, err
}
// fieldJoinType returns the JoinType for a given field name (case-insensitive).
func fieldJoinType(name string) JoinType {
if f, ok := fieldMap[strings.ToLower(name)]; ok {
return f.joinType
}
return JoinNone
}
// extractJoinTypes walks an expression tree and collects all required JoinType flags.
func extractJoinTypes(expr any) JoinType {
result := JoinNone
switch e := expr.(type) {
case All:
for _, sub := range e {
result |= extractJoinTypes(sub)
}
case Any:
for _, sub := range e {
result |= extractJoinTypes(sub)
}
default:
// Leaf expression: use reflection to check if it's a map with field names
rv := reflect.ValueOf(expr)
if rv.Kind() == reflect.Map && rv.Type().Key().Kind() == reflect.String {
for _, key := range rv.MapKeys() {
result |= fieldJoinType(key.String())
}
}
}
return result
return FieldInfo{
Name: f.name,
IsTag: f.isTag,
IsRole: f.isRole,
Numeric: f.numeric,
}, true
}
// AddRoles adds roles to the field map. This is used to add all artist roles to the field map, so they can be used in
// smart playlists. If a role already exists in the field map, it is ignored, so calls to this function are idempotent.
// smart playlists.
func AddRoles(roles []string) {
for _, role := range roles {
name := strings.ToLower(role)
if _, ok := fieldMap[name]; ok {
continue
}
fieldMap[name] = &mappedField{field: name, isRole: true}
fieldMap[name] = &fieldMetadata{name: name, isRole: true}
}
}
// AddTagNames adds tag names to the field map. This is used to add all tags mapped in the `mappings.yml`
// file to the field map, so they can be used in smart playlists.
// If a tag name already exists in the field map, it is ignored, so calls to this function are idempotent.
// configuration file.
func AddTagNames(tagNames []string) {
for _, name := range tagNames {
name := strings.ToLower(name)
for _, tagName := range tagNames {
name := strings.ToLower(tagName)
if _, ok := fieldMap[name]; ok {
continue
}
@@ -285,20 +138,19 @@ func AddTagNames(tagNames []string) {
}
}
if _, ok := fieldMap[name]; !ok {
fieldMap[name] = &mappedField{field: name, isTag: true}
fieldMap[name] = &fieldMetadata{name: name, isTag: true}
}
}
}
// AddNumericTags marks the given tag names as numeric so they can be cast
// when used in comparisons or sorting.
// AddNumericTags adds tags that should be treated as numbers.
func AddNumericTags(tagNames []string) {
for _, name := range tagNames {
name := strings.ToLower(name)
for _, tagName := range tagNames {
name := strings.ToLower(tagName)
if fm, ok := fieldMap[name]; ok {
fm.numeric = true
} else {
fieldMap[name] = &mappedField{field: name, isTag: true, numeric: true}
fieldMap[name] = &fieldMetadata{name: name, isTag: true, numeric: true}
}
}
}
+52 -5
View File
@@ -6,11 +6,58 @@ import (
)
var _ = Describe("fields", func() {
Describe("mapFields", func() {
It("ignores random fields", func() {
m := map[string]any{"random": "123"}
m = mapFields(m)
gomega.Expect(m).To(gomega.BeEmpty())
Describe("LookupField", func() {
It("finds built-in fields case-insensitively", func() {
field, ok := LookupField("Title")
gomega.Expect(ok).To(gomega.BeTrue())
gomega.Expect(field).To(gomega.Equal(FieldInfo{Name: "title"}))
})
It("resolves aliases to their semantic field name", func() {
field, ok := LookupField("albumtype")
gomega.Expect(ok).To(gomega.BeTrue())
gomega.Expect(field.Name).To(gomega.Equal("releasetype"))
gomega.Expect(field.IsTag).To(gomega.BeTrue())
})
It("finds special fields", func() {
field, ok := LookupField("value")
gomega.Expect(ok).To(gomega.BeTrue())
gomega.Expect(field.Name).To(gomega.Equal("value"))
})
It("finds registered tag names", func() {
AddTagNames([]string{"task3_mood"})
field, ok := LookupField("task3_mood")
gomega.Expect(ok).To(gomega.BeTrue())
gomega.Expect(field.Name).To(gomega.Equal("task3_mood"))
gomega.Expect(field.IsTag).To(gomega.BeTrue())
})
It("marks registered numeric tags", func() {
AddTagNames([]string{"task3_score"})
AddNumericTags([]string{"task3_score"})
field, ok := LookupField("task3_score")
gomega.Expect(ok).To(gomega.BeTrue())
gomega.Expect(field.IsTag).To(gomega.BeTrue())
gomega.Expect(field.Numeric).To(gomega.BeTrue())
})
It("finds registered roles", func() {
AddRoles([]string{"task3_producer"})
field, ok := LookupField("task3_producer")
gomega.Expect(ok).To(gomega.BeTrue())
gomega.Expect(field.Name).To(gomega.Equal("task3_producer"))
gomega.Expect(field.IsRole).To(gomega.BeTrue())
})
})
})
+25 -194
View File
@@ -1,23 +1,13 @@
package criteria
import (
"errors"
"fmt"
"reflect"
"strconv"
"time"
"github.com/Masterminds/squirrel"
)
import "time"
type (
All squirrel.And
All []Expression
And = All
)
func (all All) ToSql() (sql string, args []any, err error) {
return squirrel.And(all).ToSql()
}
func (All) criteriaExpression() {}
func (all All) MarshalJSON() ([]byte, error) {
return marshalConjunction("all", all)
@@ -28,13 +18,11 @@ func (all All) ChildPlaylistIds() (ids []string) {
}
type (
Any squirrel.Or
Any []Expression
Or = Any
)
func (any Any) ToSql() (sql string, args []any, err error) {
return squirrel.Or(any).ToSql()
}
func (Any) criteriaExpression() {}
func (any Any) MarshalJSON() ([]byte, error) {
return marshalConjunction("any", any)
@@ -44,70 +32,42 @@ func (any Any) ChildPlaylistIds() (ids []string) {
return extractPlaylistIds(any)
}
type Is squirrel.Eq
type Is map[string]any
type Eq = Is
func (is Is) ToSql() (sql string, args []any, err error) {
if isRoleExpr(is) {
return mapRoleExpr(is, false).ToSql()
}
if isTagExpr(is) {
return mapTagExpr(is, false).ToSql()
}
return squirrel.Eq(mapFields(is)).ToSql()
}
func (Is) criteriaExpression() {}
func (is Is) MarshalJSON() ([]byte, error) {
return marshalExpression("is", is)
}
type IsNot squirrel.NotEq
type IsNot map[string]any
func (in IsNot) ToSql() (sql string, args []any, err error) {
if isRoleExpr(in) {
return mapRoleExpr(squirrel.Eq(in), true).ToSql()
}
if isTagExpr(in) {
return mapTagExpr(squirrel.Eq(in), true).ToSql()
}
return squirrel.NotEq(mapFields(in)).ToSql()
}
func (IsNot) criteriaExpression() {}
func (in IsNot) MarshalJSON() ([]byte, error) {
return marshalExpression("isNot", in)
}
type Gt squirrel.Gt
type Gt map[string]any
func (gt Gt) ToSql() (sql string, args []any, err error) {
if isTagExpr(gt) {
return mapTagExpr(gt, false).ToSql()
}
return squirrel.Gt(mapFields(gt)).ToSql()
}
func (Gt) criteriaExpression() {}
func (gt Gt) MarshalJSON() ([]byte, error) {
return marshalExpression("gt", gt)
}
type Lt squirrel.Lt
type Lt map[string]any
func (lt Lt) ToSql() (sql string, args []any, err error) {
if isTagExpr(lt) {
return mapTagExpr(squirrel.Lt(lt), false).ToSql()
}
return squirrel.Lt(mapFields(lt)).ToSql()
}
func (Lt) criteriaExpression() {}
func (lt Lt) MarshalJSON() ([]byte, error) {
return marshalExpression("lt", lt)
}
type Before squirrel.Lt
type Before map[string]any
func (bf Before) ToSql() (sql string, args []any, err error) {
return Lt(bf).ToSql()
}
func (Before) criteriaExpression() {}
func (bf Before) MarshalJSON() ([]byte, error) {
return marshalExpression("before", bf)
@@ -115,9 +75,7 @@ func (bf Before) MarshalJSON() ([]byte, error) {
type After Gt
func (af After) ToSql() (sql string, args []any, err error) {
return Gt(af).ToSql()
}
func (After) criteriaExpression() {}
func (af After) MarshalJSON() ([]byte, error) {
return marshalExpression("after", af)
@@ -125,19 +83,7 @@ func (af After) MarshalJSON() ([]byte, error) {
type Contains map[string]any
func (ct Contains) ToSql() (sql string, args []any, err error) {
lk := squirrel.Like{}
for f, v := range mapFields(ct) {
lk[f] = fmt.Sprintf("%%%s%%", v)
}
if isRoleExpr(ct) {
return mapRoleExpr(lk, false).ToSql()
}
if isTagExpr(ct) {
return mapTagExpr(lk, false).ToSql()
}
return lk.ToSql()
}
func (Contains) criteriaExpression() {}
func (ct Contains) MarshalJSON() ([]byte, error) {
return marshalExpression("contains", ct)
@@ -145,19 +91,7 @@ func (ct Contains) MarshalJSON() ([]byte, error) {
type NotContains map[string]any
func (nct NotContains) ToSql() (sql string, args []any, err error) {
lk := squirrel.NotLike{}
for f, v := range mapFields(nct) {
lk[f] = fmt.Sprintf("%%%s%%", v)
}
if isRoleExpr(nct) {
return mapRoleExpr(squirrel.Like(lk), true).ToSql()
}
if isTagExpr(nct) {
return mapTagExpr(squirrel.Like(lk), true).ToSql()
}
return lk.ToSql()
}
func (NotContains) criteriaExpression() {}
func (nct NotContains) MarshalJSON() ([]byte, error) {
return marshalExpression("notContains", nct)
@@ -165,19 +99,7 @@ func (nct NotContains) MarshalJSON() ([]byte, error) {
type StartsWith map[string]any
func (sw StartsWith) ToSql() (sql string, args []any, err error) {
lk := squirrel.Like{}
for f, v := range mapFields(sw) {
lk[f] = fmt.Sprintf("%s%%", v)
}
if isRoleExpr(sw) {
return mapRoleExpr(lk, false).ToSql()
}
if isTagExpr(sw) {
return mapTagExpr(lk, false).ToSql()
}
return lk.ToSql()
}
func (StartsWith) criteriaExpression() {}
func (sw StartsWith) MarshalJSON() ([]byte, error) {
return marshalExpression("startsWith", sw)
@@ -185,19 +107,7 @@ func (sw StartsWith) MarshalJSON() ([]byte, error) {
type EndsWith map[string]any
func (sw EndsWith) ToSql() (sql string, args []any, err error) {
lk := squirrel.Like{}
for f, v := range mapFields(sw) {
lk[f] = fmt.Sprintf("%%%s", v)
}
if isRoleExpr(sw) {
return mapRoleExpr(lk, false).ToSql()
}
if isTagExpr(sw) {
return mapTagExpr(lk, false).ToSql()
}
return lk.ToSql()
}
func (EndsWith) criteriaExpression() {}
func (sw EndsWith) MarshalJSON() ([]byte, error) {
return marshalExpression("endsWith", sw)
@@ -205,20 +115,7 @@ func (sw EndsWith) MarshalJSON() ([]byte, error) {
type InTheRange map[string]any
func (itr InTheRange) ToSql() (sql string, args []any, err error) {
and := squirrel.And{}
for f, v := range mapFields(itr) {
s := reflect.ValueOf(v)
if s.Kind() != reflect.Slice || s.Len() != 2 {
return "", nil, fmt.Errorf("invalid range for 'in' operator: %s", v)
}
and = append(and,
squirrel.GtOrEq{f: s.Index(0).Interface()},
squirrel.LtOrEq{f: s.Index(1).Interface()},
)
}
return and.ToSql()
}
func (InTheRange) criteriaExpression() {}
func (itr InTheRange) MarshalJSON() ([]byte, error) {
return marshalExpression("inTheRange", itr)
@@ -226,13 +123,7 @@ func (itr InTheRange) MarshalJSON() ([]byte, error) {
type InTheLast map[string]any
func (itl InTheLast) ToSql() (sql string, args []any, err error) {
exp, err := inPeriod(itl, false)
if err != nil {
return "", nil, err
}
return exp.ToSql()
}
func (InTheLast) criteriaExpression() {}
func (itl InTheLast) MarshalJSON() ([]byte, error) {
return marshalExpression("inTheLast", itl)
@@ -240,50 +131,19 @@ func (itl InTheLast) MarshalJSON() ([]byte, error) {
type NotInTheLast map[string]any
func (nitl NotInTheLast) ToSql() (sql string, args []any, err error) {
exp, err := inPeriod(nitl, true)
if err != nil {
return "", nil, err
}
return exp.ToSql()
}
func (NotInTheLast) criteriaExpression() {}
func (nitl NotInTheLast) MarshalJSON() ([]byte, error) {
return marshalExpression("notInTheLast", nitl)
}
func inPeriod(m map[string]any, negate bool) (Expression, error) {
var field string
var value any
for f, v := range mapFields(m) {
field, value = f, v
break
}
str := fmt.Sprintf("%v", value)
v, err := strconv.ParseInt(str, 10, 64)
if err != nil {
return nil, err
}
firstDate := startOfPeriod(v, time.Now())
if negate {
return Or{
squirrel.Lt{field: firstDate},
squirrel.Eq{field: nil},
}, nil
}
return squirrel.Gt{field: firstDate}, nil
}
func startOfPeriod(numDays int64, from time.Time) string {
return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02")
}
type InPlaylist map[string]any
func (ipl InPlaylist) ToSql() (sql string, args []any, err error) {
return inList(ipl, false)
}
func (InPlaylist) criteriaExpression() {}
func (ipl InPlaylist) MarshalJSON() ([]byte, error) {
return marshalExpression("inPlaylist", ipl)
@@ -291,41 +151,12 @@ func (ipl InPlaylist) MarshalJSON() ([]byte, error) {
type NotInPlaylist map[string]any
func (ipl NotInPlaylist) ToSql() (sql string, args []any, err error) {
return inList(ipl, true)
}
func (NotInPlaylist) criteriaExpression() {}
func (ipl NotInPlaylist) MarshalJSON() ([]byte, error) {
return marshalExpression("notInPlaylist", ipl)
}
func inList(m map[string]any, negate bool) (sql string, args []any, err error) {
var playlistid string
var ok bool
if playlistid, ok = m["id"].(string); !ok {
return "", nil, errors.New("playlist id not given")
}
// Subquery to fetch all media files that are contained in given playlist
// Only evaluate playlist if it is public
subQuery := squirrel.Select("media_file_id").
From("playlist_tracks pl").
LeftJoin("playlist on pl.playlist_id = playlist.id").
Where(squirrel.And{
squirrel.Eq{"pl.playlist_id": playlistid},
squirrel.Eq{"playlist.public": 1}})
subQText, subQArgs, err := subQuery.PlaceholderFormat(squirrel.Question).ToSql()
if err != nil {
return "", nil, err
}
if negate {
return "media_file.id NOT IN (" + subQText + ")", subQArgs, nil
} else {
return "media_file.id IN (" + subQText + ")", subQArgs, nil
}
}
func extractPlaylistIds(inputRule any) (ids []string) {
var id string
var ok bool
-177
View File
@@ -3,7 +3,6 @@ package criteria_test
import (
"encoding/json"
"fmt"
"time"
. "github.com/navidrome/navidrome/model/criteria"
. "github.com/onsi/ginkgo/v2"
@@ -17,182 +16,6 @@ var _ = BeforeSuite(func() {
})
var _ = Describe("Operators", func() {
rangeStart := time.Date(2021, 10, 01, 0, 0, 0, 0, time.Local)
rangeEnd := time.Date(2021, 11, 01, 0, 0, 0, 0, time.Local)
DescribeTable("ToSQL",
func(op Expression, expectedSql string, expectedArgs ...any) {
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal(expectedSql))
gomega.Expect(args).To(gomega.HaveExactElements(expectedArgs...))
},
Entry("is [string]", Is{"title": "Low Rider"}, "media_file.title = ?", "Low Rider"),
Entry("is [bool]", Is{"loved": true}, "COALESCE(annotation.starred, false) = ?", true),
Entry("is [numeric]", Is{"library_id": 1}, "media_file.library_id = ?", 1),
Entry("is [numeric list]", Is{"library_id": []int{1, 2}}, "media_file.library_id IN (?,?)", 1, 2),
Entry("isNot", IsNot{"title": "Low Rider"}, "media_file.title <> ?", "Low Rider"),
Entry("isNot [numeric]", IsNot{"library_id": 1}, "media_file.library_id <> ?", 1),
Entry("isNot [numeric list]", IsNot{"library_id": []int{1, 2}}, "media_file.library_id NOT IN (?,?)", 1, 2),
Entry("gt", Gt{"playCount": 10}, "COALESCE(annotation.play_count, 0) > ?", 10),
Entry("lt", Lt{"playCount": 10}, "COALESCE(annotation.play_count, 0) < ?", 10),
Entry("contains", Contains{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider%"),
Entry("notContains", NotContains{"title": "Low Rider"}, "media_file.title NOT LIKE ?", "%Low Rider%"),
Entry("startsWith", StartsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "Low Rider%"),
Entry("endsWith", EndsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider"),
Entry("inTheRange [number]", InTheRange{"year": []int{1980, 1990}}, "(media_file.year >= ? AND media_file.year <= ?)", 1980, 1990),
Entry("inTheRange [date]", InTheRange{"lastPlayed": []time.Time{rangeStart, rangeEnd}}, "(annotation.play_date >= ? AND annotation.play_date <= ?)", rangeStart, rangeEnd),
Entry("before", Before{"lastPlayed": rangeStart}, "annotation.play_date < ?", rangeStart),
Entry("after", After{"lastPlayed": rangeStart}, "annotation.play_date > ?", rangeStart),
// InPlaylist and NotInPlaylist are special cases
Entry("inPlaylist", InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN "+
"(SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
Entry("notInPlaylist", NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN "+
"(SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
Entry("inTheLast", InTheLast{"lastPlayed": 30}, "annotation.play_date > ?", StartOfPeriod(30, time.Now())),
Entry("notInTheLast", NotInTheLast{"lastPlayed": 30}, "(annotation.play_date < ? OR annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())),
// Album annotation fields
Entry("albumRating", Gt{"albumRating": 3}, "COALESCE(album_annotation.rating, 0) > ?", 3),
Entry("albumLoved", Is{"albumLoved": true}, "COALESCE(album_annotation.starred, false) = ?", true),
Entry("albumPlayCount", Gt{"albumPlayCount": 5}, "COALESCE(album_annotation.play_count, 0) > ?", 5),
Entry("albumLastPlayed", After{"albumLastPlayed": rangeStart}, "album_annotation.play_date > ?", rangeStart),
Entry("albumDateLoved", Before{"albumDateLoved": rangeStart}, "album_annotation.starred_at < ?", rangeStart),
Entry("albumDateRated", After{"albumDateRated": rangeStart}, "album_annotation.rated_at > ?", rangeStart),
Entry("albumLastPlayed inTheLast", InTheLast{"albumLastPlayed": 30}, "album_annotation.play_date > ?", StartOfPeriod(30, time.Now())),
Entry("albumLastPlayed notInTheLast", NotInTheLast{"albumLastPlayed": 30}, "(album_annotation.play_date < ? OR album_annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())),
// Artist annotation fields
Entry("artistRating", Gt{"artistRating": 3}, "COALESCE(artist_annotation.rating, 0) > ?", 3),
Entry("artistLoved", Is{"artistLoved": true}, "COALESCE(artist_annotation.starred, false) = ?", true),
Entry("artistPlayCount", Gt{"artistPlayCount": 5}, "COALESCE(artist_annotation.play_count, 0) > ?", 5),
Entry("artistLastPlayed", After{"artistLastPlayed": rangeStart}, "artist_annotation.play_date > ?", rangeStart),
Entry("artistDateLoved", Before{"artistDateLoved": rangeStart}, "artist_annotation.starred_at < ?", rangeStart),
Entry("artistDateRated", After{"artistDateRated": rangeStart}, "artist_annotation.rated_at > ?", rangeStart),
Entry("artistLastPlayed inTheLast", InTheLast{"artistLastPlayed": 30}, "artist_annotation.play_date > ?", StartOfPeriod(30, time.Now())),
Entry("artistLastPlayed notInTheLast", NotInTheLast{"artistLastPlayed": 30}, "(artist_annotation.play_date < ? OR artist_annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())),
// Tag tests
Entry("tag is [string]", Is{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"),
Entry("tag isNot [string]", IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"),
Entry("tag gt", Gt{"genre": "A"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value > ?)", "A"),
Entry("tag lt", Lt{"genre": "Z"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value < ?)", "Z"),
Entry("tag contains", Contains{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
Entry("tag not contains", NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
Entry("tag startsWith", StartsWith{"genre": "Soft"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "Soft%"),
Entry("tag endsWith", EndsWith{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock"),
// Artist roles tests
Entry("role is [string]", Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"),
Entry("role isNot [string]", IsNot{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"),
Entry("role contains [string]", Contains{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
Entry("role not contains [string]", NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"),
Entry("role startsWith [string]", StartsWith{"composer": "John"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "John%"),
Entry("role endsWith [string]", EndsWith{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon"),
)
// TODO Validate operators that are not valid for each field type.
XDescribeTable("ToSQL - Invalid Operators",
func(op Expression, expectedError string) {
_, _, err := op.ToSql()
gomega.Expect(err).To(gomega.MatchError(expectedError))
},
Entry("numeric tag contains", Contains{"rate": 5}, "numeric tag 'rate' cannot be used with Contains operator"),
)
Describe("Custom Tags", func() {
It("generates valid SQL", func() {
AddTagNames([]string{"mood"})
op := EndsWith{"mood": "Soft"}
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.mood') where key='value' and value LIKE ?)"))
gomega.Expect(args).To(gomega.HaveExactElements("%Soft"))
})
It("casts numeric comparisons", func() {
AddNumericTags([]string{"rate"})
op := Lt{"rate": 6}
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)"))
gomega.Expect(args).To(gomega.HaveExactElements(6))
})
It("skips unknown tag names", func() {
op := EndsWith{"unknown": "value"}
sql, args, _ := op.ToSql()
gomega.Expect(sql).To(gomega.BeEmpty())
gomega.Expect(args).To(gomega.BeEmpty())
})
It("supports releasetype as multi-valued tag", func() {
AddTagNames([]string{"releasetype"})
op := Contains{"releasetype": "soundtrack"}
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value LIKE ?)"))
gomega.Expect(args).To(gomega.HaveExactElements("%soundtrack%"))
})
It("supports albumtype as alias for releasetype", func() {
AddTagNames([]string{"releasetype"})
op := Contains{"albumtype": "live"}
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value LIKE ?)"))
gomega.Expect(args).To(gomega.HaveExactElements("%live%"))
})
It("supports albumtype alias with Is operator", func() {
AddTagNames([]string{"releasetype"})
op := Is{"albumtype": "album"}
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
// Should query $.releasetype, not $.albumtype
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)"))
gomega.Expect(args).To(gomega.HaveExactElements("album"))
})
It("supports albumtype alias with IsNot operator", func() {
AddTagNames([]string{"releasetype"})
op := IsNot{"albumtype": "compilation"}
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
// Should query $.releasetype, not $.albumtype
gomega.Expect(sql).To(gomega.Equal("not exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)"))
gomega.Expect(args).To(gomega.HaveExactElements("compilation"))
})
})
Describe("Custom Roles", func() {
It("generates valid SQL", func() {
AddRoles([]string{"producer"})
op := EndsWith{"producer": "Eno"}
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.participants, '$.producer') where key='name' and value LIKE ?)"))
gomega.Expect(args).To(gomega.HaveExactElements("%Eno"))
})
It("skips unknown roles", func() {
op := Contains{"groupie": "Penny Lane"}
sql, args, _ := op.ToSql()
gomega.Expect(sql).To(gomega.BeEmpty())
gomega.Expect(args).To(gomega.BeEmpty())
})
})
DescribeTable("ToSql idempotency",
func(expr Expression) {
sql1, args1, err1 := expr.ToSql()
sql2, args2, err2 := expr.ToSql()
gomega.Expect(err1).ToNot(gomega.HaveOccurred())
gomega.Expect(err2).ToNot(gomega.HaveOccurred())
gomega.Expect(sql2).To(gomega.Equal(sql1))
gomega.Expect(args2).To(gomega.Equal(args1))
},
Entry("tag expression", Is{"genre": "Rock"}),
Entry("role expression", Contains{"artist": "Beatles"}),
Entry("nested criteria", Criteria{Expression: All{Is{"genre": "Rock"}, Contains{"artist": "Beatles"}}}),
)
DescribeTable("JSON Marshaling",
func(op Expression, jsonString string) {
obj := And{op}
+72
View File
@@ -0,0 +1,72 @@
package criteria
import "fmt"
type Visitor func(Expression) error
func Walk(expr Expression, visit Visitor) error {
if expr == nil {
return nil
}
if err := visit(expr); err != nil {
return err
}
switch e := expr.(type) {
case All:
for _, child := range e {
if err := Walk(child, visit); err != nil {
return err
}
}
case Any:
for _, child := range e {
if err := Walk(child, visit); err != nil {
return err
}
}
case Is, IsNot, Gt, Lt, Before, After, Contains, NotContains, StartsWith, EndsWith, InTheRange, InTheLast, NotInTheLast, InPlaylist, NotInPlaylist:
return nil
default:
return fmt.Errorf("unknown criteria expression type %T", expr)
}
return nil
}
// Fields returns field values for leaf expressions only.
// Use Walk to traverse All and Any expressions before calling Fields.
func Fields(expr Expression) map[string]any {
switch e := expr.(type) {
case Is:
return map[string]any(e)
case IsNot:
return map[string]any(e)
case Gt:
return map[string]any(e)
case Lt:
return map[string]any(e)
case Before:
return map[string]any(e)
case After:
return map[string]any(Gt(e))
case Contains:
return map[string]any(e)
case NotContains:
return map[string]any(e)
case StartsWith:
return map[string]any(e)
case EndsWith:
return map[string]any(e)
case InTheRange:
return map[string]any(e)
case InTheLast:
return map[string]any(e)
case NotInTheLast:
return map[string]any(e)
case InPlaylist:
return map[string]any(e)
case NotInPlaylist:
return map[string]any(e)
default:
return nil
}
}
+64
View File
@@ -0,0 +1,64 @@
package criteria
import (
"fmt"
. "github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
)
type unknownExpression struct{}
func (unknownExpression) criteriaExpression() {}
var _ = Describe("Walk", func() {
It("visits the expression tree depth-first", func() {
expr := All{
Contains{"title": "love"},
Any{
Is{"album": "best of"},
Gt{"rating": 3},
},
}
var visited []string
err := Walk(expr, func(expr Expression) error {
visited = append(visited, fmt.Sprintf("%T", expr))
return nil
})
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(visited).To(gomega.Equal([]string{
"criteria.All",
"criteria.Contains",
"criteria.Any",
"criteria.Is",
"criteria.Gt",
}))
})
It("stops when the visitor returns an error", func() {
expectedErr := fmt.Errorf("stop")
err := Walk(All{Contains{"title": "love"}}, func(Expression) error {
return expectedErr
})
gomega.Expect(err).To(gomega.MatchError(expectedErr))
})
It("returns fields for leaf expressions", func() {
gomega.Expect(Fields(Contains{"title": "love"})).To(gomega.Equal(map[string]any{"title": "love"}))
gomega.Expect(Fields(After{"date": "2020-01-01"})).To(gomega.Equal(map[string]any{"date": "2020-01-01"}))
})
It("returns nil fields for group expressions", func() {
gomega.Expect(Fields(All{Contains{"title": "love"}})).To(gomega.BeNil())
})
It("returns an error for unknown expression types", func() {
err := Walk(unknownExpression{}, func(Expression) error { return nil })
gomega.Expect(err).To(gomega.MatchError("unknown criteria expression type criteria.unknownExpression"))
})
})
+487
View File
@@ -0,0 +1,487 @@
package persistence
import (
"errors"
"fmt"
"reflect"
"strconv"
"strings"
"time"
squirrel "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/criteria"
)
type smartPlaylistJoinType int
const (
smartPlaylistJoinNone smartPlaylistJoinType = 0
smartPlaylistJoinAlbumAnnotation smartPlaylistJoinType = 1 << iota
smartPlaylistJoinArtistAnnotation
)
func (j smartPlaylistJoinType) has(other smartPlaylistJoinType) bool {
return j&other != 0
}
type smartPlaylistField struct {
expr string
order string
joinType smartPlaylistJoinType
}
type smartPlaylistCriteria struct {
criteria criteria.Criteria
}
func newSmartPlaylistCriteria(c criteria.Criteria) smartPlaylistCriteria {
return smartPlaylistCriteria{criteria: c}
}
var smartPlaylistFields = map[string]smartPlaylistField{
"title": {expr: "media_file.title"},
"album": {expr: "media_file.album"},
"hascoverart": {expr: "media_file.has_cover_art"},
"tracknumber": {expr: "media_file.track_number"},
"discnumber": {expr: "media_file.disc_number"},
"year": {expr: "media_file.year"},
"date": {expr: "media_file.date"},
"originalyear": {expr: "media_file.original_year"},
"originaldate": {expr: "media_file.original_date"},
"releaseyear": {expr: "media_file.release_year"},
"releasedate": {expr: "media_file.release_date"},
"size": {expr: "media_file.size"},
"compilation": {expr: "media_file.compilation"},
"missing": {expr: "media_file.missing"},
"explicitstatus": {expr: "media_file.explicit_status"},
"dateadded": {expr: "media_file.created_at"},
"datemodified": {expr: "media_file.updated_at"},
"discsubtitle": {expr: "media_file.disc_subtitle"},
"comment": {expr: "media_file.comment"},
"lyrics": {expr: "media_file.lyrics"},
"sorttitle": {expr: "media_file.sort_title"},
"sortalbum": {expr: "media_file.sort_album_name"},
"sortartist": {expr: "media_file.sort_artist_name"},
"sortalbumartist": {expr: "media_file.sort_album_artist_name"},
"albumcomment": {expr: "media_file.mbz_album_comment"},
"catalognumber": {expr: "media_file.catalog_num"},
"filepath": {expr: "media_file.path"},
"filetype": {expr: "media_file.suffix"},
"codec": {expr: "media_file.codec"},
"duration": {expr: "media_file.duration"},
"bitrate": {expr: "media_file.bit_rate"},
"bitdepth": {expr: "media_file.bit_depth"},
"samplerate": {expr: "media_file.sample_rate"},
"bpm": {expr: "media_file.bpm"},
"channels": {expr: "media_file.channels"},
"loved": {expr: "COALESCE(annotation.starred, false)"},
"dateloved": {expr: "annotation.starred_at"},
"lastplayed": {expr: "annotation.play_date"},
"daterated": {expr: "annotation.rated_at"},
"playcount": {expr: "COALESCE(annotation.play_count, 0)"},
"rating": {expr: "COALESCE(annotation.rating, 0)"},
"averagerating": {expr: "media_file.average_rating"},
"albumrating": {expr: "COALESCE(album_annotation.rating, 0)", joinType: smartPlaylistJoinAlbumAnnotation},
"albumloved": {expr: "COALESCE(album_annotation.starred, false)", joinType: smartPlaylistJoinAlbumAnnotation},
"albumplaycount": {expr: "COALESCE(album_annotation.play_count, 0)", joinType: smartPlaylistJoinAlbumAnnotation},
"albumlastplayed": {expr: "album_annotation.play_date", joinType: smartPlaylistJoinAlbumAnnotation},
"albumdateloved": {expr: "album_annotation.starred_at", joinType: smartPlaylistJoinAlbumAnnotation},
"albumdaterated": {expr: "album_annotation.rated_at", joinType: smartPlaylistJoinAlbumAnnotation},
"artistrating": {expr: "COALESCE(artist_annotation.rating, 0)", joinType: smartPlaylistJoinArtistAnnotation},
"artistloved": {expr: "COALESCE(artist_annotation.starred, false)", joinType: smartPlaylistJoinArtistAnnotation},
"artistplaycount": {expr: "COALESCE(artist_annotation.play_count, 0)", joinType: smartPlaylistJoinArtistAnnotation},
"artistlastplayed": {expr: "artist_annotation.play_date", joinType: smartPlaylistJoinArtistAnnotation},
"artistdateloved": {expr: "artist_annotation.starred_at", joinType: smartPlaylistJoinArtistAnnotation},
"artistdaterated": {expr: "artist_annotation.rated_at", joinType: smartPlaylistJoinArtistAnnotation},
"mbz_album_id": {expr: "media_file.mbz_album_id"},
"mbz_album_artist_id": {expr: "media_file.mbz_album_artist_id"},
"mbz_artist_id": {expr: "media_file.mbz_artist_id"},
"mbz_recording_id": {expr: "media_file.mbz_recording_id"},
"mbz_release_track_id": {expr: "media_file.mbz_release_track_id"},
"mbz_release_group_id": {expr: "media_file.mbz_release_group_id"},
"library_id": {expr: "media_file.library_id"},
"random": {order: "random()"},
"value": {expr: "value"},
}
func (c smartPlaylistCriteria) Where() (squirrel.Sqlizer, error) {
if c.criteria.Expression == nil {
return squirrel.Expr("1 = 1"), nil
}
return exprSQL(c.criteria.Expression)
}
func exprSQL(expr criteria.Expression) (squirrel.Sqlizer, error) {
switch e := expr.(type) {
case criteria.All:
and := squirrel.And{}
for _, child := range e {
cond, err := exprSQL(child)
if err != nil {
return nil, err
}
and = append(and, cond)
}
return and, nil
case criteria.Any:
or := squirrel.Or{}
for _, child := range e {
cond, err := exprSQL(child)
if err != nil {
return nil, err
}
or = append(or, cond)
}
return or, nil
case criteria.Is:
return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer {
return squirrel.Eq(fields)
}, false)
case criteria.IsNot:
return isNotExpr(e)
case criteria.Gt:
return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer {
return squirrel.Gt(fields)
}, false)
case criteria.Lt:
return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer {
return squirrel.Lt(fields)
}, false)
case criteria.Before:
return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer {
return squirrel.Lt(fields)
}, false)
case criteria.After:
return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer {
return squirrel.Gt(fields)
}, false)
case criteria.Contains:
return likeExpr(e, "%%%v%%", false)
case criteria.NotContains:
return likeExpr(e, "%%%v%%", true)
case criteria.StartsWith:
return likeExpr(e, "%v%%", false)
case criteria.EndsWith:
return likeExpr(e, "%%%v", false)
case criteria.InTheRange:
return rangeExpr(e)
case criteria.InTheLast:
return periodExpr(e, false)
case criteria.NotInTheLast:
return periodExpr(e, true)
case criteria.InPlaylist:
return inList(e, false)
case criteria.NotInPlaylist:
return inList(e, true)
default:
return nil, fmt.Errorf("unknown criteria expression type %T", expr)
}
}
func isNotExpr[T ~map[string]any](values T) (squirrel.Sqlizer, error) {
if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) {
return jsonExpr(info, squirrel.Eq{"value": value}, true), nil
}
fields, err := sqlFields(values)
if err != nil {
return nil, err
}
return squirrel.NotEq(fields), nil
}
func mapExpr[T ~map[string]any](values T, makeCond func(map[string]any) squirrel.Sqlizer, negateJSON bool) (squirrel.Sqlizer, error) {
if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) {
return jsonExpr(info, makeCond(map[string]any{"value": value}), negateJSON), nil
}
fields, err := sqlFields(values)
if err != nil {
return nil, err
}
return makeCond(fields), nil
}
func likeExpr[T ~map[string]any](values T, pattern string, negate bool) (squirrel.Sqlizer, error) {
if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) {
return jsonExpr(info, squirrel.Like{"value": fmt.Sprintf(pattern, value)}, negate), nil
}
fields, err := sqlFields(values)
if err != nil {
return nil, err
}
if negate {
lk := squirrel.NotLike{}
for field, value := range fields {
lk[field] = fmt.Sprintf(pattern, value)
}
return lk, nil
}
lk := squirrel.Like{}
for field, value := range fields {
lk[field] = fmt.Sprintf(pattern, value)
}
return lk, nil
}
func rangeExpr[T ~map[string]any](values T) (squirrel.Sqlizer, error) {
fields, err := sqlFields(values)
if err != nil {
return nil, err
}
and := squirrel.And{}
for field, value := range fields {
s := reflect.ValueOf(value)
if s.Kind() != reflect.Slice || s.Len() != 2 {
return nil, fmt.Errorf("invalid range for 'in' operator: %s", value)
}
and = append(and,
squirrel.GtOrEq{field: s.Index(0).Interface()},
squirrel.LtOrEq{field: s.Index(1).Interface()},
)
}
return and, nil
}
func periodExpr[T ~map[string]any](values T, negate bool) (squirrel.Sqlizer, error) {
fields, err := sqlFields(values)
if err != nil {
return nil, err
}
var field string
var value any
for f, v := range fields {
field, value = f, v
break
}
days, err := strconv.ParseInt(fmt.Sprintf("%v", value), 10, 64)
if err != nil {
return nil, err
}
firstDate := startOfPeriod(days, time.Now())
if negate {
return squirrel.Or{
squirrel.Lt{field: firstDate},
squirrel.Eq{field: nil},
}, nil
}
return squirrel.Gt{field: firstDate}, nil
}
func startOfPeriod(numDays int64, from time.Time) string {
return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02")
}
func inList[T ~map[string]any](values T, negate bool) (squirrel.Sqlizer, error) {
playlistID, ok := values["id"].(string)
if !ok {
return nil, errors.New("playlist id not given")
}
subQuery := squirrel.Select("media_file_id").
From("playlist_tracks pl").
LeftJoin("playlist on pl.playlist_id = playlist.id").
Where(squirrel.And{
squirrel.Eq{"pl.playlist_id": playlistID},
squirrel.Eq{"playlist.public": 1},
})
subSQL, subArgs, err := subQuery.PlaceholderFormat(squirrel.Question).ToSql()
if err != nil {
return nil, err
}
if negate {
return squirrel.Expr("media_file.id NOT IN ("+subSQL+")", subArgs...), nil
}
return squirrel.Expr("media_file.id IN ("+subSQL+")", subArgs...), nil
}
func jsonExpr(info criteria.FieldInfo, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
if info.IsRole {
return roleCond{role: info.Name, cond: cond, not: negate}
}
return tagCond{tag: info.Name, numeric: info.Numeric, cond: cond, not: negate}
}
type tagCond struct {
tag string
numeric bool
cond squirrel.Sqlizer
not bool
}
func (e tagCond) ToSql() (string, []any, error) {
cond, args, err := e.cond.ToSql()
if e.numeric {
cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)")
}
cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)", e.tag, cond)
if e.not {
cond = "not " + cond
}
return cond, args, err
}
type roleCond struct {
role string
cond squirrel.Sqlizer
not bool
}
func (e roleCond) ToSql() (string, []any, error) {
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)
if e.not {
cond = "not " + cond
}
return cond, args, err
}
func singleField[T ~map[string]any](values T) (string, any, criteria.FieldInfo, bool) {
if len(values) != 1 {
return "", nil, criteria.FieldInfo{}, false
}
for field, value := range values {
info, ok := criteria.LookupField(field)
return field, value, info, ok
}
return "", nil, criteria.FieldInfo{}, false
}
func sqlFields[T ~map[string]any](values T) (map[string]any, error) {
fields := make(map[string]any, len(values))
for field, value := range values {
info, ok := criteria.LookupField(field)
if !ok {
return nil, fmt.Errorf("invalid field in criteria: %s", field)
}
if info.IsTag || info.IsRole {
return nil, fmt.Errorf("tag and role criteria must contain exactly one field: %s", field)
}
sqlField, ok := fieldExpr(info.Name)
if !ok || sqlField == "" {
return nil, fmt.Errorf("invalid field in criteria: %s", field)
}
fields[sqlField] = value
}
return fields, nil
}
func fieldExpr(name string) (string, bool) {
field, ok := smartPlaylistFields[strings.ToLower(name)]
return field.expr, ok
}
func fieldJoinType(name string) smartPlaylistJoinType {
info, ok := criteria.LookupField(name)
if !ok {
return smartPlaylistJoinNone
}
field, ok := smartPlaylistFields[info.Name]
if !ok {
return smartPlaylistJoinNone
}
return field.joinType
}
func (c smartPlaylistCriteria) ExpressionJoins() smartPlaylistJoinType {
var joins smartPlaylistJoinType
_ = criteria.Walk(c.criteria.Expression, func(expr criteria.Expression) error {
for field := range criteria.Fields(expr) {
joins |= fieldJoinType(field)
}
return nil
})
return joins
}
func (c smartPlaylistCriteria) RequiredJoins() smartPlaylistJoinType {
joins := c.ExpressionJoins()
for _, sortField := range sortFields(c.criteria.Sort) {
joins |= fieldJoinType(sortField)
}
return joins
}
func (c smartPlaylistCriteria) OrderBy() string {
sortValue := c.criteria.Sort
if sortValue == "" {
sortValue = "title"
}
order := strings.ToLower(strings.TrimSpace(c.criteria.Order))
if order != "" && order != "asc" && order != "desc" {
log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.criteria.Order)
order = ""
}
parts := strings.Split(sortValue, ",")
fields := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
dir := "asc"
if strings.HasPrefix(part, "+") || strings.HasPrefix(part, "-") {
if strings.HasPrefix(part, "-") {
dir = "desc"
}
part = strings.TrimSpace(part[1:])
}
sortField := strings.ToLower(part)
mapped, ok := sortExpr(sortField)
if !ok {
log.Error("Invalid field in 'sort' field", "sort", sortField)
continue
}
if order == "desc" {
if dir == "asc" {
dir = "desc"
} else {
dir = "asc"
}
}
fields = append(fields, mapped+" "+dir)
}
return strings.Join(fields, ", ")
}
func sortFields(sortValue string) []string {
if sortValue == "" {
sortValue = "title"
}
parts := strings.Split(sortValue, ",")
fields := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(part), "+-"))
if part != "" {
fields = append(fields, part)
}
}
return fields
}
func sortExpr(sortField string) (string, bool) {
info, ok := criteria.LookupField(sortField)
if !ok {
return "", false
}
if field, ok := smartPlaylistFields[info.Name]; ok && field.order != "" {
return field.order, true
}
var mapped string
switch {
case info.IsTag:
mapped = "COALESCE(json_extract(media_file.tags, '$." + info.Name + "[0].value'), '')"
case info.IsRole:
mapped = "COALESCE(json_extract(media_file.participants, '$." + info.Name + "[0].name'), '')"
default:
field, ok := smartPlaylistFields[info.Name]
if !ok || field.expr == "" {
return "", false
}
mapped = field.expr
}
if info.Numeric {
mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped)
}
return mapped, true
}
+170
View File
@@ -0,0 +1,170 @@
package persistence
import (
"time"
"github.com/navidrome/navidrome/model/criteria"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Smart playlist criteria SQL", func() {
BeforeEach(func() {
criteria.AddRoles([]string{"artist", "composer", "producer"})
criteria.AddTagNames([]string{"genre", "mood", "releasetype"})
criteria.AddNumericTags([]string{"rate"})
})
DescribeTable("expressions",
func(expr criteria.Expression, expectedSQL string, expectedArgs ...any) {
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(expectedSQL))
Expect(args).To(HaveExactElements(expectedArgs...))
},
Entry("all group",
criteria.All{criteria.Contains{"title": "love"}, criteria.Gt{"rating": 3}},
"(media_file.title LIKE ? AND COALESCE(annotation.rating, 0) > ?)", "%love%", 3),
Entry("any group",
criteria.Any{criteria.Is{"title": "Low Rider"}, criteria.Is{"album": "Best Of"}},
"(media_file.title = ? OR media_file.album = ?)", "Low Rider", "Best Of"),
Entry("is string", criteria.Is{"title": "Low Rider"}, "media_file.title = ?", "Low Rider"),
Entry("is bool", criteria.Is{"loved": true}, "COALESCE(annotation.starred, false) = ?", true),
Entry("is numeric list", criteria.Is{"library_id": []int{1, 2}}, "media_file.library_id IN (?,?)", 1, 2),
Entry("is not", criteria.IsNot{"title": "Low Rider"}, "media_file.title <> ?", "Low Rider"),
Entry("gt", criteria.Gt{"playCount": 10}, "COALESCE(annotation.play_count, 0) > ?", 10),
Entry("lt", criteria.Lt{"playCount": 10}, "COALESCE(annotation.play_count, 0) < ?", 10),
Entry("contains", criteria.Contains{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider%"),
Entry("not contains", criteria.NotContains{"title": "Low Rider"}, "media_file.title NOT LIKE ?", "%Low Rider%"),
Entry("starts with", criteria.StartsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "Low Rider%"),
Entry("ends with", criteria.EndsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider"),
Entry("in range", criteria.InTheRange{"year": []int{1980, 1990}}, "(media_file.year >= ? AND media_file.year <= ?)", 1980, 1990),
Entry("before", criteria.Before{"lastPlayed": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "annotation.play_date < ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)),
Entry("after", criteria.After{"lastPlayed": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "annotation.play_date > ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)),
Entry("in playlist", criteria.InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
Entry("not in playlist", criteria.NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
Entry("album annotation", criteria.Gt{"albumRating": 3}, "COALESCE(album_annotation.rating, 0) > ?", 3),
Entry("artist annotation", criteria.Is{"artistLoved": true}, "COALESCE(artist_annotation.starred, false) = ?", true),
Entry("tag is", criteria.Is{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"),
Entry("tag is not", criteria.IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"),
Entry("tag contains", criteria.Contains{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
Entry("tag not contains", criteria.NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"),
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("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%"),
)
It("builds relative date expressions", func() {
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheLast{"lastPlayed": 30}}).Where()
Expect(err).ToNot(HaveOccurred())
sql, args, err := sqlizer.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(Equal("annotation.play_date > ?"))
Expect(args).To(HaveExactElements(startOfPeriod(30, time.Now())))
})
It("builds negated relative date expressions", func() {
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.NotInTheLast{"lastPlayed": 30}}).Where()
Expect(err).ToNot(HaveOccurred())
sql, args, err := sqlizer.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(Equal("(annotation.play_date < ? OR annotation.play_date IS NULL)"))
Expect(args).To(HaveExactElements(startOfPeriod(30, time.Now())))
})
It("returns an error for unknown fields", func() {
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.EndsWith{"unknown": "value"}}).Where()
Expect(err).To(MatchError("invalid field in criteria: unknown"))
})
Describe("sort", func() {
It("sorts by regular fields", func() {
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title"}).OrderBy()).To(Equal("media_file.title asc"))
})
It("sorts by tag fields", func() {
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "genre"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.genre[0].value'), '') asc"))
})
It("sorts by role fields", func() {
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "artist"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') asc"))
})
It("casts numeric tags when sorting", func() {
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "rate"}).OrderBy()).To(Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc"))
})
It("sorts by albumtype alias", func() {
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "albumtype"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc"))
})
It("sorts by random", func() {
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "random"}).OrderBy()).To(Equal("random() asc"))
})
It("sorts by multiple fields", func() {
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title,-rating"}).OrderBy()).To(Equal("media_file.title asc, COALESCE(annotation.rating, 0) desc"))
})
It("reverts order when order is desc", func() {
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-date,artist", Order: "desc"}).OrderBy()).To(Equal("media_file.date asc, COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') desc"))
})
It("ignores invalid sort fields", func() {
Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "bogus,title"}).OrderBy()).To(Equal("media_file.title asc"))
})
})
It("has SQL mappings for all non-tag/non-role criteria fields", func() {
for _, name := range criteria.AllFieldNames() {
info, ok := criteria.LookupField(name)
Expect(ok).To(BeTrue(), "field %q registered but LookupField fails", name)
if info.IsTag || info.IsRole {
continue
}
_, hasSQLField := smartPlaylistFields[info.Name]
Expect(hasSQLField).To(BeTrue(), "criteria field %q (name=%q) has no entry in smartPlaylistFields", name, info.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"}
cSQL := newSmartPlaylistCriteria(c)
Expect(cSQL.ExpressionJoins()).To(Equal(smartPlaylistJoinNone))
Expect(cSQL.RequiredJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
})
It("includes expression-based joins", func() {
c := criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumRating": 3}}}
Expect(newSmartPlaylistCriteria(c).ExpressionJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
})
It("detects nested album and artist joins", func() {
c := criteria.Criteria{Expression: criteria.All{
criteria.Any{criteria.All{criteria.Is{"albumLoved": true}}},
criteria.Any{criteria.Gt{"artistPlayCount": 10}},
}}
joins := newSmartPlaylistCriteria(c).RequiredJoins()
Expect(joins.has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue())
Expect(joins.has(smartPlaylistJoinArtistAnnotation)).To(BeTrue())
})
It("detects join types from sort fields with direction prefixes", func() {
c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "-artistRating"}
Expect(newSmartPlaylistCriteria(c).RequiredJoins().has(smartPlaylistJoinArtistAnnotation)).To(BeTrue())
})
})
})
+28 -12
View File
@@ -228,6 +228,7 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
// Re-populate playlist based on Smart Playlist criteria
rules := *pls.Rules
rulesSQL := newSmartPlaylistCriteria(rules)
// If the playlist depends on other playlists, recursively refresh them first
childPlaylistIds := rules.ChildPlaylistIds()
@@ -240,14 +241,15 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
r.refreshSmartPlaylist(childPls)
}
sq := Select("row_number() over (order by "+rules.OrderBy()+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id").
orderBy := rulesSQL.OrderBy()
sq := Select("row_number() over (order by "+orderBy+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id").
From("media_file").LeftJoin("annotation on ("+
"annotation.item_id = media_file.id"+
" AND annotation.item_type = 'media_file'"+
" AND annotation.user_id = ?)", usr.ID)
// Conditionally join album/artist annotation tables only when referenced by criteria or sort
requiredJoins := rules.RequiredJoins()
requiredJoins := rulesSQL.RequiredJoins()
sq = r.addSmartPlaylistAnnotationJoins(sq, requiredJoins, usr.ID)
// Only include media files from libraries the user has access to
@@ -256,7 +258,7 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
// Resolve percentage-based limit to an absolute number before applying criteria
if rules.IsPercentageLimit() {
// Use only expression-based joins for the COUNT query (sort joins are unnecessary)
exprJoins := rules.ExpressionJoins()
exprJoins := rulesSQL.ExpressionJoins()
countSq := Select("count(*) as count").From("media_file").
LeftJoin("annotation on ("+
"annotation.item_id = media_file.id"+
@@ -264,7 +266,12 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
" AND annotation.user_id = ?)", usr.ID)
countSq = r.addSmartPlaylistAnnotationJoins(countSq, exprJoins, usr.ID)
countSq = r.applyLibraryFilter(countSq, "media_file")
countSq = countSq.Where(rules)
cond, err := rulesSQL.Where()
if err != nil {
log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err)
return false
}
countSq = countSq.Where(cond)
var res struct{ Count int64 }
err = r.queryOne(countSq, &res)
@@ -279,7 +286,11 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
}
// Apply the criteria rules
sq = r.addCriteria(sq, rules)
sq, err = r.addCriteria(sq, rules)
if err != nil {
log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err)
return false
}
insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq)
_, err = r.executeSQL(insSql)
if err != nil {
@@ -310,14 +321,14 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
return true
}
func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, joins criteria.JoinType, userID string) SelectBuilder {
if joins.Has(criteria.JoinAlbumAnnotation) {
func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, joins smartPlaylistJoinType, userID string) SelectBuilder {
if joins.has(smartPlaylistJoinAlbumAnnotation) {
sq = sq.LeftJoin("annotation AS album_annotation ON ("+
"album_annotation.item_id = media_file.album_id"+
" AND album_annotation.item_type = 'album'"+
" AND album_annotation.user_id = ?)", userID)
}
if joins.Has(criteria.JoinArtistAnnotation) {
if joins.has(smartPlaylistJoinArtistAnnotation) {
sq = sq.LeftJoin("annotation AS artist_annotation ON ("+
"artist_annotation.item_id = media_file.artist_id"+
" AND artist_annotation.item_type = 'artist'"+
@@ -326,15 +337,20 @@ func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, j
return sq
}
func (r *playlistRepository) addCriteria(sql SelectBuilder, c criteria.Criteria) SelectBuilder {
sql = sql.Where(c)
func (r *playlistRepository) addCriteria(sql SelectBuilder, c criteria.Criteria) (SelectBuilder, error) {
cSQL := newSmartPlaylistCriteria(c)
cond, err := cSQL.Where()
if err != nil {
return sql, err
}
sql = sql.Where(cond)
if c.Limit > 0 {
sql = sql.Limit(uint64(c.Limit)).Offset(uint64(c.Offset))
}
if order := c.OrderBy(); order != "" {
if order := cSQL.OrderBy(); order != "" {
sql = sql.OrderBy(order)
}
return sql
return sql, nil
}
func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) error {