mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
* fix(smartplaylist): use annotation index for playcount/rating/loved filters
Annotation-field criteria wrapped the column in COALESCE(col, default) so
missing annotation rows behave as 0/false. COALESCE prevents SQLite from
using the column index, forcing a full media_file scan during smart playlist
materialization - multi-second loads on large libraries, independent of rule
complexity.
Store the raw column plus its default and drop COALESCE when the compared
value cannot match the default; fall back to 'col <op> ? OR col IS NULL' when
the default would match, so never-annotated tracks are still preserved.
Sorting keeps COALESCE to retain deterministic NULL ordering. Result set is
unchanged; the materialize query now seeks the annotation index.
Signed-off-by: Deluan <deluan@deluan.com>
* fix(smartplaylist): keep COALESCE for list-valued annotation comparisons
Hardening from final review: a list value (IN (...)) can't drive the index
and a default-inclusive list has per-element NULL semantics, so route slice
values through COALESCE(col, default) to stay exactly equivalent to the prior
form. Also make the bool-default branch explicit (loved only supports
equality operators) and share the COALESCE rendering via coalesceExpr.
Signed-off-by: Deluan <deluan@deluan.com>
* refactor(smartplaylist): make coalesced() a field method
Thermo-nuclear review follow-up: promote the free coalesceExpr(f) to a
smartPlaylistField.coalesced() method that returns the bare expression when
there is no default. This lets sortExpr call field.coalesced() unconditionally
and drop its 'if coalesceDefault != nil' branch, removing the 'only annotation
fields get coalesced' special case from the sort path. Behavior unchanged.
Signed-off-by: Deluan <deluan@deluan.com>
* fix(smartplaylist): keep COALESCE for LIKE, bool-ordering, and tag ranges
Code review (xhigh) found the index-friendly rewrite did not cover every
operator, breaking result-set equivalence on a few reachable raw-JSON paths:
- LIKE family (contains/startsWith/endsWith/notContains) on annotation fields
used the bare column, so a NULL column never matched and missing-annotation
rows were dropped.
- Ordering comparators (gt/lt/...) on bool fields (loved) were decided as
equality, wrongly including never-annotated rows.
- InTheRange on a numeric tag split into two independent json_tree EXISTS,
letting different tag values satisfy each bound.
Centralize the decision in annotationCond via bareNullInclusion: emit the
index-friendly bare form only for scalar values under an exactly-orderable
comparator, otherwise fall back to the COALESCE form (always equivalent to the
original). Route LIKE through coalesced(); reject tag/role ranges. Replace the
local toFloat/toBool with spf13/cast (fixes unhandled numeric types and string
bool forms), and drop the redundant LookupField + double reflect.TypeOf.
A 17-case brute-force check confirms row-set equivalence to the prior
COALESCE form across all operators including the fixed LIKE/bool cases.
Signed-off-by: Deluan <deluan@deluan.com>
* fix(smartplaylist): keep COALESCE for list values on bool annotation fields
Second review found the bareNullInclusion bool branch missed the non-scalar
guard the numeric branch has: a list value on loved/albumloved/artistloved
(e.g. {"is":{"loved":[true]}}) coerced through toBool (which swallowed the
cast error) to false, emitting the bare/OR-IS-NULL form and wrongly including
never-annotated rows. Make toBool return (value, ok) like toFloat and bail to
COALESCE when the value isn't a scalar bool. Also add the missing test for the
tag/role range rejection. A 21-case brute-force confirms row-set equivalence to
the original COALESCE form across every operator, including the bool/numeric
list paths.
Signed-off-by: Deluan <deluan@deluan.com>
* refactor(smartplaylist): drop spf13/cast for stdlib value coercion
The value coercion only sees the handful of types criteria produces (int,
float64, string from JSON; bool already normalized at unmarshal), so cast's
broad conversion isn't needed. Use small explicit type switches over strconv
instead, keeping the string fallback (ParseFloat/ParseBool) that closes the
'1'/'t' gap. No dependency change — cast returns to indirect.
Signed-off-by: Deluan <deluan@deluan.com>
* refactor(smartplaylist): share bool coercion via criteria.ToBool
normalizeBoolValue (unmarshal-time) and the persistence bool guard both parsed
bool-ish values independently. Extract the shared logic into an exported
criteria.ToBool(any) (bool, ok): normalizeBoolValue delegates to it (behavior
unchanged), and the persistence layer reuses it via its existing model/criteria
import instead of a local helper. No behavior change.
Signed-off-by: Deluan <deluan@deluan.com>
* refactor(smartplaylist): trim sqlLiteral and dedup rationale comments
/simplify cleanup: fmt %v already renders bool defaults as false/true, so drop
sqlLiteral's redundant bool branch. Consolidate the COALESCE-vs-index rationale
to the smartPlaylistField comment instead of repeating it across annotationCond
and the struct. No behavior change.
Signed-off-by: Deluan <deluan@deluan.com>
* fix(smartplaylist): address review feedback on multi-field maps and *any
From the PR bot reviews:
- sqlFields now uses the field's coalesced() form, so annotation fields in a
multi-field operator map (Is/Gt/Contains with >1 key) keep COALESCE and don't
silently drop never-annotated rows. Covers both the comparison and LIKE
fallback paths. (Gemini high, Copilot)
- Replace coalesceDefault *any with a plain any (0/false are non-nil
interfaces, so nil still means 'no default'); drop the coalesce() boxing
helper and the pointer indirection. (Gemini)
- Give rangeExpr clear, range-specific errors for the multi-field and malformed
-pair cases instead of an empty-field / 'in operator' message. (Copilot)
Adds tests for the multi-field COALESCE behavior and the new range errors.
Signed-off-by: Deluan <deluan@deluan.com>
* Revert multi-field COALESCE handling (YAGNI)
The multi-field operator map case the bots flagged is unreachable: marshalExpression
rejects any operator map with more than one field, so a multi-field map can never be
persisted or loaded. Revert the sqlFields change and its tests rather than harden a
code path no supported input can reach. Keep the two reachable improvements from the
review: coalesceDefault any (not *any), and the clearer malformed-range error.
Signed-off-by: Deluan <deluan@deluan.com>
* refactor(persistence): model comparator as a behavior-carrying struct
The smart-playlist comparator was a bare string alias, forcing two parallel
switches over the same six operators: squirrelCmp mapped each to its squirrel
constructor, and bareNullInclusion restated each as a float predicate. Adding
or changing an operator meant editing both in sync.
Make comparator a struct that bundles those facts per operator (the squirrel
builder, the operator as a float predicate, and whether it's an ordering op).
Both switches collapse: squirrelCmp is deleted in favor of cmp.build, and
bareNullInclusion's numeric switch becomes a single cmp.satisfy call. Generated
SQL is unchanged, as the existing table-driven tests confirm.
* docs(smartplaylist): trim comments that restate the code
Remove or tighten comments that describe what the code already says (likeCond and
comparisonExpr doc lines, redundant clauses in annotationField/coalesced/ToBool/
normalizeBoolValue). Keep the comments that explain non-obvious rationale: the
COALESCE-vs-index tradeoff, the bareNullInclusion/annotationCond contracts, and the
why-we-fall-back notes.
* docs(smartplaylist): collapse coalesceDefault comment to one line
The field's six-line block duplicated the COALESCE-vs-index rationale that already
lives on annotationCond. Reduce it to a one-line description plus a pointer there.
---------
Signed-off-by: Deluan <deluan@deluan.com>
590 lines
32 KiB
Go
590 lines
32 KiB
Go
package persistence
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/model"
|
|
"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", "recordingdate", "replaygain_album_gain"})
|
|
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 annotation.rating > ?)", "%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}, "annotation.starred = ?", 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}, "annotation.play_count > ?", 10),
|
|
Entry("lt", criteria.Lt{"playCount": 10}, "(annotation.play_count < ? OR annotation.play_count IS NULL)", 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}, "album_annotation.rating > ?", 3),
|
|
Entry("artist annotation", criteria.Is{"artistLoved": true}, "artist_annotation.starred = ?", true),
|
|
// Annotation fields use a COALESCE default (0 for numeric, false for bool) so that tracks
|
|
// with no annotation row behave as that default. To keep the annotation index usable, the
|
|
// COALESCE is dropped when the compared value cannot match the default (the missing-row
|
|
// case is then naturally excluded); otherwise an explicit `OR col IS NULL` preserves it.
|
|
Entry("is safe (value != default)", criteria.Is{"playCount": 3}, "annotation.play_count = ?", 3),
|
|
Entry("is unsafe (value == default)", criteria.Is{"playCount": 0},
|
|
"(annotation.play_count = ? OR annotation.play_count IS NULL)", 0),
|
|
Entry("is bool false (value == default)", criteria.Is{"loved": false},
|
|
"(annotation.starred = ? OR annotation.starred IS NULL)", false),
|
|
Entry("gt safe (value >= default)", criteria.Gt{"playCount": 0}, "annotation.play_count > ?", 0),
|
|
Entry("gt unsafe (value < default)", criteria.Gt{"playCount": -1},
|
|
"(annotation.play_count > ? OR annotation.play_count IS NULL)", -1),
|
|
Entry("lt safe (value <= default)", criteria.Lt{"playCount": 0}, "annotation.play_count < ?", 0),
|
|
Entry("lt unsafe (value > default)", criteria.Lt{"playCount": 5},
|
|
"(annotation.play_count < ? OR annotation.play_count IS NULL)", 5),
|
|
Entry("isNot annotation keeps null match", criteria.IsNot{"playCount": 3},
|
|
"(annotation.play_count <> ? OR annotation.play_count IS NULL)", 3),
|
|
Entry("isNot annotation value == default", criteria.IsNot{"playCount": 0},
|
|
"annotation.play_count <> ?", 0),
|
|
Entry("in range spanning default", criteria.InTheRange{"playCount": []int{-1, 5}},
|
|
"((annotation.play_count >= ? OR annotation.play_count IS NULL) AND (annotation.play_count <= ? OR annotation.play_count IS NULL))", -1, 5),
|
|
Entry("in range above default", criteria.InTheRange{"playCount": []int{1, 5}},
|
|
"(annotation.play_count >= ? AND (annotation.play_count <= ? OR annotation.play_count IS NULL))", 1, 5),
|
|
// A list value can't drive the index and a default-inclusive list has per-element NULL
|
|
// semantics, so the COALESCE form is kept to stay equivalent to the original.
|
|
Entry("is list keeps coalesce", criteria.Is{"playCount": []int{0, 3}},
|
|
"COALESCE(annotation.play_count, 0) IN (?,?)", 0, 3),
|
|
// LIKE operators can't use the column index, so annotation fields keep the COALESCE form to
|
|
// match missing-annotation rows exactly as before (a NULL column never matches LIKE).
|
|
Entry("contains annotation keeps coalesce", criteria.Contains{"playCount": 0},
|
|
"COALESCE(annotation.play_count, 0) LIKE ?", "%0%"),
|
|
Entry("starts with annotation keeps coalesce", criteria.StartsWith{"rating": 5},
|
|
"COALESCE(annotation.rating, 0) LIKE ?", "5%"),
|
|
Entry("not contains annotation keeps coalesce", criteria.NotContains{"playCount": 0},
|
|
"COALESCE(annotation.play_count, 0) NOT LIKE ?", "%0%"),
|
|
// Bool annotation fields only have a clean index-friendly form for equality; ordering
|
|
// comparators keep the COALESCE form so the missing-row default is honored exactly.
|
|
Entry("gt bool keeps coalesce", criteria.Gt{"loved": false},
|
|
"COALESCE(annotation.starred, false) > ?", false),
|
|
// A list value on a bool field is non-scalar, so it keeps the COALESCE form too (same as the
|
|
// numeric list case) — otherwise a NULL column would diverge from the original.
|
|
Entry("is bool list keeps coalesce", criteria.Is{"loved": []any{true}},
|
|
"COALESCE(annotation.starred, false) IN (?)", 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("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 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),
|
|
Entry("rgTrackPeak lt", criteria.Lt{"rgTrackPeak": 1.0}, "media_file.rg_track_peak < ?", 1.0),
|
|
// isMissing — tags
|
|
Entry("isMissing tag [true]", criteria.IsMissing{"genre": true},
|
|
"not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"),
|
|
Entry("isMissing tag [false]", criteria.IsMissing{"genre": false},
|
|
"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 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 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')"),
|
|
Entry("isPresent tag [false]", criteria.IsPresent{"genre": false},
|
|
"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 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 media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "composer"),
|
|
// isMissing/isPresent — nullable column fields (ReplayGain)
|
|
Entry("isMissing rgAlbumGain [true]", criteria.IsMissing{"rgAlbumGain": true},
|
|
"(media_file.rg_album_gain IS NULL)"),
|
|
Entry("isMissing rgAlbumGain [false]", criteria.IsMissing{"rgAlbumGain": false},
|
|
"(media_file.rg_album_gain IS NOT NULL)"),
|
|
Entry("isPresent rgTrackPeak [true]", criteria.IsPresent{"rgTrackPeak": true},
|
|
"(media_file.rg_track_peak IS NOT NULL)"),
|
|
Entry("isPresent rgTrackPeak [false]", criteria.IsPresent{"rgTrackPeak": false},
|
|
"(media_file.rg_track_peak IS NULL)"),
|
|
// isMissing — replaygain_* tag-name alias resolves to the nullable column (issue #5584)
|
|
Entry("isMissing replaygain_album_gain alias [true]", criteria.IsMissing{"replaygain_album_gain": true},
|
|
"(media_file.rg_album_gain IS NULL)"),
|
|
Entry("isPresent replaygain_album_gain alias [true]", criteria.IsPresent{"replaygain_album_gain": true},
|
|
"(media_file.rg_album_gain IS NOT NULL)"),
|
|
// isMissing/isPresent — string column fields (empty string means missing)
|
|
Entry("isMissing mbz_recording_id [true]", criteria.IsMissing{"mbz_recording_id": true},
|
|
"(media_file.mbz_recording_id IS NULL OR media_file.mbz_recording_id = ?)", ""),
|
|
Entry("isMissing mbz_recording_id [false]", criteria.IsMissing{"mbz_recording_id": false},
|
|
"(media_file.mbz_recording_id IS NOT NULL AND media_file.mbz_recording_id <> ?)", ""),
|
|
Entry("isPresent mbz_album_id [true]", criteria.IsPresent{"mbz_album_id": true},
|
|
"(media_file.mbz_album_id IS NOT NULL AND media_file.mbz_album_id <> ?)", ""),
|
|
Entry("isPresent mbz_album_id [false]", criteria.IsPresent{"mbz_album_id": false},
|
|
"(media_file.mbz_album_id IS NULL OR media_file.mbz_album_id = ?)", ""),
|
|
// lyrics: absence is encoded as '' or '[]' (empty serialized LyricList)
|
|
Entry("isMissing lyrics [true]", criteria.IsMissing{"lyrics": true},
|
|
"(media_file.lyrics IS NULL OR media_file.lyrics = ? OR media_file.lyrics = ?)", "", "[]"),
|
|
Entry("isPresent lyrics [true]", criteria.IsPresent{"lyrics": true},
|
|
"(media_file.lyrics IS NOT NULL AND media_file.lyrics <> ? AND media_file.lyrics <> ?)", "", "[]"),
|
|
Entry("isMissing lyrics [false]", criteria.IsMissing{"lyrics": false},
|
|
"(media_file.lyrics IS NOT NULL AND media_file.lyrics <> ? AND media_file.lyrics <> ?)", "", "[]"),
|
|
Entry("isPresent lyrics [false]", criteria.IsPresent{"lyrics": false},
|
|
"(media_file.lyrics IS NULL OR media_file.lyrics = ? OR media_file.lyrics = ?)", "", "[]"),
|
|
// isMissing/isPresent — nullable numeric columns (BPM, BitDepth)
|
|
Entry("isMissing bpm [true]", criteria.IsMissing{"bpm": true},
|
|
"(media_file.bpm IS NULL)"),
|
|
Entry("isPresent bpm [true]", criteria.IsPresent{"bpm": true},
|
|
"(media_file.bpm IS NOT NULL)"),
|
|
Entry("isMissing bitdepth [true]", criteria.IsMissing{"bitdepth": true},
|
|
"(media_file.bit_depth IS NULL)"),
|
|
Entry("isPresent bitdepth [false]", criteria.IsPresent{"bitdepth": false},
|
|
"(media_file.bit_depth IS NULL)"),
|
|
// isMissing/isPresent — more string column fields (empty string means missing)
|
|
Entry("isMissing album [true]", criteria.IsMissing{"album": true},
|
|
"(media_file.album IS NULL OR media_file.album = ?)", ""),
|
|
Entry("isMissing comment [true]", criteria.IsMissing{"comment": true},
|
|
"(media_file.comment IS NULL OR media_file.comment = ?)", ""),
|
|
Entry("isMissing catalognumber [true]", criteria.IsMissing{"catalognumber": true},
|
|
"(media_file.catalog_num IS NULL OR media_file.catalog_num = ?)", ""),
|
|
Entry("isMissing discsubtitle [true]", criteria.IsMissing{"discsubtitle": true},
|
|
"(media_file.disc_subtitle IS NULL OR media_file.disc_subtitle = ?)", ""),
|
|
Entry("isMissing albumcomment [true]", criteria.IsMissing{"albumcomment": true},
|
|
"(media_file.mbz_album_comment IS NULL OR media_file.mbz_album_comment = ?)", ""),
|
|
Entry("isMissing sorttitle [true]", criteria.IsMissing{"sorttitle": true},
|
|
"(media_file.sort_title IS NULL OR media_file.sort_title = ?)", ""),
|
|
Entry("isMissing sortalbum [true]", criteria.IsMissing{"sortalbum": true},
|
|
"(media_file.sort_album_name IS NULL OR media_file.sort_album_name = ?)", ""),
|
|
Entry("isMissing sortartist [true]", criteria.IsMissing{"sortartist": true},
|
|
"(media_file.sort_artist_name IS NULL OR media_file.sort_artist_name = ?)", ""),
|
|
Entry("isMissing sortalbumartist [true]", criteria.IsMissing{"sortalbumartist": true},
|
|
"(media_file.sort_album_artist_name IS NULL OR media_file.sort_album_artist_name = ?)", ""),
|
|
Entry("isMissing explicitstatus [true]", criteria.IsMissing{"explicitstatus": true},
|
|
"(media_file.explicit_status IS NULL OR media_file.explicit_status = ?)", ""),
|
|
Entry("isPresent comment [true]", criteria.IsPresent{"comment": true},
|
|
"(media_file.comment IS NOT NULL AND media_file.comment <> ?)", ""),
|
|
)
|
|
|
|
Describe("playlist permissions", func() {
|
|
It("allows public or same-owner playlist references for regular users", func() {
|
|
sqlizer, err := newSmartPlaylistCriteria(
|
|
criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}},
|
|
withSmartPlaylistOwner(model.User{ID: "owner-id", IsAdmin: false}),
|
|
).Where()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
sql, args, err := sqlizer.ToSql()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(sql).To(Equal("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 = ? OR playlist.owner_id = ?)))"))
|
|
Expect(args).To(HaveExactElements("deadbeef-dead-beef", 1, "owner-id"))
|
|
})
|
|
|
|
It("allows all playlist references for admins", func() {
|
|
sqlizer, err := newSmartPlaylistCriteria(
|
|
criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}},
|
|
withSmartPlaylistOwner(model.User{ID: "admin-id", IsAdmin: true}),
|
|
).Where()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
sql, args, err := sqlizer.ToSql()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(sql).To(Equal("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 = ?))"))
|
|
Expect(args).To(HaveExactElements("deadbeef-dead-beef"))
|
|
})
|
|
})
|
|
|
|
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"))
|
|
})
|
|
|
|
It("returns an error when isMissing is used with a regular field", func() {
|
|
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"year": true}}).Where()
|
|
Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is not supported for field")))
|
|
})
|
|
|
|
It("returns an error when isPresent is used with a regular field", func() {
|
|
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsPresent{"title": true}}).Where()
|
|
Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is not supported for field")))
|
|
})
|
|
|
|
It("returns an error when isMissing has a non-boolean value", func() {
|
|
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"genre": "hello"}}).Where()
|
|
Expect(err).To(MatchError(ContainSubstring("invalid boolean value for 'missing' expression")))
|
|
})
|
|
|
|
It("returns an error for a range over a tag/role field", func() {
|
|
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"rate": []int{1, 5}}}).Where()
|
|
Expect(err).To(MatchError(ContainSubstring("range operator not supported for tag/role field")))
|
|
})
|
|
|
|
It("returns a clear error for a malformed range value", func() {
|
|
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"playCount": []int{1, 2, 3}}}).Where()
|
|
Expect(err).To(MatchError(ContainSubstring("must be a [min, max] pair")))
|
|
})
|
|
|
|
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("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
|
|
})
|
|
|
|
It("merges negated role conditions in an AND group into a single NOT EXISTS", func() {
|
|
expr := criteria.All{
|
|
criteria.IsNot{"artist": "Beatles"},
|
|
criteria.IsNot{"artist": "Kraftwerk"},
|
|
}
|
|
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
sql, args, err := sqlizer.ToSql()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
// A single NOT EXISTS with both names ORed inside (De Morgan)
|
|
Expect(strings.Count(sql, "not exists")).To(Equal(1))
|
|
Expect(sql).To(ContainSubstring("artist.name = ? OR artist.name = ?"))
|
|
Expect(args).To(HaveExactElements("artist", "Beatles", "Kraftwerk"))
|
|
})
|
|
|
|
It("merges negated notContains role conditions in an AND group", func() {
|
|
expr := criteria.All{
|
|
criteria.NotContains{"artist": "Beatles"},
|
|
criteria.NotContains{"artist": "Kraftwerk"},
|
|
}
|
|
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
sql, args, err := sqlizer.ToSql()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(strings.Count(sql, "not exists")).To(Equal(1))
|
|
Expect(sql).To(ContainSubstring("artist.name LIKE ? OR artist.name LIKE ?"))
|
|
Expect(args).To(HaveExactElements("artist", "%Beatles%", "%Kraftwerk%"))
|
|
})
|
|
|
|
It("merges negated tag conditions in an AND group into a single NOT EXISTS", func() {
|
|
expr := criteria.All{
|
|
criteria.NotContains{"genre": "Rock"},
|
|
criteria.NotContains{"genre": "Metal"},
|
|
}
|
|
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
sql, args, err := sqlizer.ToSql()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(strings.Count(sql, "not exists")).To(Equal(1))
|
|
Expect(sql).To(ContainSubstring("value LIKE ? OR value LIKE ?"))
|
|
Expect(args).To(HaveExactElements("%Rock%", "%Metal%"))
|
|
})
|
|
|
|
It("does not merge a single negated condition with a positive one of the same role in AND", func() {
|
|
// AND of mixed polarity must not be collapsed: NOT EXISTS(a) AND EXISTS(b)
|
|
// is not equivalent to any single merged subquery.
|
|
expr := criteria.All{
|
|
criteria.Contains{"artist": "Beatles"},
|
|
criteria.IsNot{"artist": "Kraftwerk"},
|
|
}
|
|
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
sql, _, err := sqlizer.ToSql()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
// One positive EXISTS and one negated NOT EXISTS, kept separate
|
|
Expect(strings.Count(sql, "not exists")).To(Equal(1))
|
|
Expect(strings.Count(sql, "exists")).To(Equal(2)) // "not exists" contains "exists"
|
|
})
|
|
|
|
It("does not merge negated conditions of different roles in AND", func() {
|
|
expr := criteria.All{
|
|
criteria.IsNot{"artist": "Beatles"},
|
|
criteria.IsNot{"composer": "Lennon"},
|
|
}
|
|
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("batches large negated AND groups to avoid SQLite expression tree depth limit", func() {
|
|
allExprs := make(criteria.All, jsonCondBatchSize+1)
|
|
for i := range allExprs {
|
|
allExprs[i] = criteria.IsNot{"artist": fmt.Sprintf("Artist%d", i)}
|
|
}
|
|
sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: allExprs}).Where()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
sql, args, err := sqlizer.ToSql()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
// Two NOT EXISTS subqueries (one batch of jsonCondBatchSize, one of 1)
|
|
Expect(strings.Count(sql, "not exists")).To(Equal(2))
|
|
Expect(args).To(HaveLen(2 + jsonCondBatchSize + 1))
|
|
})
|
|
})
|
|
|
|
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())
|
|
})
|
|
})
|
|
})
|