mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 18:37:09 +02:00
297 lines
11 KiB
Go
297 lines
11 KiB
Go
package persistence
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/Masterminds/squirrel"
|
|
"github.com/deluan/rest"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("Annotation Filters", func() {
|
|
var (
|
|
albumRepo *albumRepository
|
|
albumWithoutAnnotation model.Album
|
|
ctx context.Context
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "userid", UserName: "johndoe"})
|
|
albumRepo = NewAlbumRepository(GetDBXBuilder()).(*albumRepository)
|
|
|
|
// Create album without any annotation (no star, no rating)
|
|
albumWithoutAnnotation = model.Album{ID: "no-annotation-album", Name: "No Annotation", LibraryID: 1}
|
|
Expect(albumRepo.Put(ctx, &albumWithoutAnnotation)).To(Succeed())
|
|
})
|
|
|
|
AfterEach(func() {
|
|
_, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": albumWithoutAnnotation.ID}))
|
|
})
|
|
|
|
Describe("ReassignAnnotation", func() {
|
|
var prev, next model.Album
|
|
|
|
BeforeEach(func() {
|
|
prev = model.Album{ID: "reassign-prev", Name: "Prev", LibraryID: 1}
|
|
next = model.Album{ID: "reassign-next", Name: "Next", LibraryID: 1}
|
|
Expect(albumRepo.Put(ctx, &prev)).To(Succeed())
|
|
Expect(albumRepo.Put(ctx, &next)).To(Succeed())
|
|
})
|
|
|
|
AfterEach(func() {
|
|
_, _ = albumRepo.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": []string{prev.ID, next.ID}}))
|
|
_, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": []string{prev.ID, next.ID}}))
|
|
})
|
|
|
|
It("moves the annotation when the new item has none", func() {
|
|
Expect(albumRepo.SetRating(ctx, 4, prev.ID)).To(Succeed())
|
|
|
|
Expect(albumRepo.ReassignAnnotation(ctx, prev.ID, next.ID)).To(Succeed())
|
|
|
|
got, err := albumRepo.Get(ctx, next.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(got.Rating).To(Equal(4))
|
|
})
|
|
|
|
It("recomputes the new item's cached average rating", func() {
|
|
Expect(albumRepo.SetRating(ctx, 4, prev.ID)).To(Succeed())
|
|
|
|
Expect(albumRepo.ReassignAnnotation(ctx, prev.ID, next.ID)).To(Succeed())
|
|
|
|
got, err := albumRepo.Get(ctx, next.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(got.AverageRating).To(Equal(4.0))
|
|
})
|
|
|
|
It("keeps the new item's annotation when both exist", func() {
|
|
Expect(albumRepo.SetRating(ctx, 4, prev.ID)).To(Succeed())
|
|
Expect(albumRepo.SetRating(ctx, 2, next.ID)).To(Succeed())
|
|
|
|
Expect(albumRepo.ReassignAnnotation(ctx, prev.ID, next.ID)).To(Succeed())
|
|
|
|
got, err := albumRepo.Get(ctx, next.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(got.Rating).To(Equal(2))
|
|
})
|
|
})
|
|
|
|
Describe("annotationBoolFilter", func() {
|
|
DescribeTable("creates correct SQL expressions",
|
|
func(field, value string, expectedSQL string, expectedArgs []any) {
|
|
sqlizer := annotationBoolFilter(field)(field, value)
|
|
sql, args, err := sqlizer.ToSql()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(sql).To(Equal(expectedSQL))
|
|
Expect(args).To(Equal(expectedArgs))
|
|
},
|
|
Entry("starred=true", "starred", "true", "COALESCE(starred, 0) > 0", []any(nil)),
|
|
Entry("starred=false", "starred", "false", "COALESCE(starred, 0) = 0", []any(nil)),
|
|
Entry("starred=True (case insensitive)", "starred", "True", "COALESCE(starred, 0) > 0", []any(nil)),
|
|
Entry("rating=true", "rating", "true", "COALESCE(rating, 0) > 0", []any(nil)),
|
|
Entry("play_count=true", "play_count", "true", "COALESCE(play_count, 0) > 0", []any(nil)),
|
|
Entry("play_count=false", "play_count", "false", "COALESCE(play_count, 0) = 0", []any(nil)),
|
|
)
|
|
|
|
It("returns nil if value is not a string", func() {
|
|
sqlizer := annotationBoolFilter("starred")("starred", 123)
|
|
Expect(sqlizer).To(BeNil())
|
|
})
|
|
})
|
|
|
|
Describe("starredFilter", func() {
|
|
It("false includes items without annotations", func() {
|
|
albums, err := albumRepo.GetAll(ctx, model.QueryOptions{
|
|
Filters: annotationBoolFilter("starred")("starred", "false"),
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
var found bool
|
|
for _, a := range albums {
|
|
if a.ID == albumWithoutAnnotation.ID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
Expect(found).To(BeTrue(), "Item without annotation should be included in starred=false filter")
|
|
})
|
|
|
|
It("true excludes items without annotations", func() {
|
|
albums, err := albumRepo.GetAll(ctx, model.QueryOptions{
|
|
Filters: annotationBoolFilter("starred")("starred", "true"),
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
for _, a := range albums {
|
|
Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID))
|
|
}
|
|
})
|
|
})
|
|
|
|
Describe("hasRatingFilter", func() {
|
|
It("false includes items without annotations", func() {
|
|
albums, err := albumRepo.GetAll(ctx, model.QueryOptions{
|
|
Filters: annotationBoolFilter("rating")("rating", "false"),
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
var found bool
|
|
for _, a := range albums {
|
|
if a.ID == albumWithoutAnnotation.ID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
Expect(found).To(BeTrue(), "Item without annotation should be included in has_rating=false filter")
|
|
})
|
|
|
|
It("true excludes items without annotations", func() {
|
|
albums, err := albumRepo.GetAll(ctx, model.QueryOptions{
|
|
Filters: annotationBoolFilter("rating")("rating", "true"),
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
for _, a := range albums {
|
|
Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID))
|
|
}
|
|
})
|
|
|
|
It("true includes items with rating > 0", func() {
|
|
// Create album with rating 1
|
|
ratedAlbum := model.Album{ID: "rated-album", Name: "Rated Album", LibraryID: 1}
|
|
Expect(albumRepo.Put(ctx, &ratedAlbum)).To(Succeed())
|
|
Expect(albumRepo.SetRating(ctx, 1, ratedAlbum.ID)).To(Succeed())
|
|
defer func() {
|
|
_, _ = albumRepo.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": ratedAlbum.ID}))
|
|
_, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": ratedAlbum.ID}))
|
|
}()
|
|
|
|
albums, err := albumRepo.GetAll(ctx, model.QueryOptions{
|
|
Filters: annotationBoolFilter("rating")("rating", "true"),
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
var found bool
|
|
for _, a := range albums {
|
|
if a.ID == ratedAlbum.ID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
Expect(found).To(BeTrue(), "Album with rating 5 should be included in has_rating=true filter")
|
|
})
|
|
})
|
|
|
|
It("ignores invalid filter values (not strings)", func() {
|
|
res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{
|
|
Filters: map[string]any{"starred": 123},
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
albums := res
|
|
|
|
var found bool
|
|
for _, a := range albums {
|
|
if a.ID == albumWithoutAnnotation.ID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
Expect(found).To(BeTrue(), "Item without annotation should be included when filter is ignored")
|
|
})
|
|
|
|
Describe("annotationColumns", func() {
|
|
It("derives the annotation join columns from model.Annotations, excluding average_rating", func() {
|
|
cols := annotationColumns()
|
|
Expect(cols).To(HaveKey("starred"))
|
|
Expect(cols).To(HaveKey("starred_at"))
|
|
Expect(cols).To(HaveKey("rating"))
|
|
Expect(cols).To(HaveKey("rated_at"))
|
|
Expect(cols).To(HaveKey("play_count"))
|
|
Expect(cols).To(HaveKey("play_date"))
|
|
Expect(cols).To(HaveLen(6), "expected exactly the 6 annotation-join columns")
|
|
Expect(cols).ToNot(HaveKey("average_rating"), "average_rating lives on the base table, not the annotation join")
|
|
})
|
|
})
|
|
|
|
Describe("filtersNeedAnnotation", func() {
|
|
It("is true when the query references an annotation column", func() {
|
|
q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Eq{"starred": true})
|
|
Expect(filtersNeedAnnotation(q)).To(BeTrue())
|
|
})
|
|
|
|
It("is true for a raw expression referencing an annotation column", func() {
|
|
q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Expr("rating > 0"))
|
|
Expect(filtersNeedAnnotation(q)).To(BeTrue())
|
|
})
|
|
|
|
It("is false for a query that references no annotation column", func() {
|
|
q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Eq{"missing": false})
|
|
Expect(filtersNeedAnnotation(q)).To(BeFalse())
|
|
})
|
|
|
|
It("is false for a filter on average_rating (base-table column, not the annotation rating)", func() {
|
|
// Regression: average_rating must not match the annotation column "rating".
|
|
q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Gt{"average_rating": 3})
|
|
Expect(filtersNeedAnnotation(q)).To(BeFalse())
|
|
})
|
|
|
|
It("is true when both average_rating and a real annotation column are referenced", func() {
|
|
q := squirrel.Select("count(1)").From("media_file").
|
|
Where(squirrel.Gt{"average_rating": 3}).
|
|
Where(squirrel.Expr("COALESCE(rating, 0) > 0"))
|
|
Expect(filtersNeedAnnotation(q)).To(BeTrue())
|
|
})
|
|
|
|
It("is true for uppercase/mixed-case annotation columns (SQLite is case-insensitive)", func() {
|
|
q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Expr("RATING > 0"))
|
|
Expect(filtersNeedAnnotation(q)).To(BeTrue())
|
|
})
|
|
|
|
It("is false for uppercase average_rating (still excluded case-insensitively)", func() {
|
|
q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Expr("AVERAGE_RATING > 3"))
|
|
Expect(filtersNeedAnnotation(q)).To(BeFalse())
|
|
})
|
|
})
|
|
|
|
Describe("CountAll annotation-join gating", func() {
|
|
It("counts all items unfiltered (join dropped)", func() {
|
|
total, err := albumRepo.CountAll(ctx)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(total).To(BeNumerically(">=", int64(1)))
|
|
|
|
filtered, err := albumRepo.CountAll(ctx, model.QueryOptions{
|
|
Filters: squirrel.Eq{"album.id": albumWithoutAnnotation.ID},
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(filtered).To(Equal(int64(1)))
|
|
})
|
|
|
|
It("counts starred items correctly (named annotation filter keeps the join)", func() {
|
|
starredAlbum := model.Album{ID: "counted-starred-album", Name: "Counted Starred", LibraryID: 1}
|
|
Expect(albumRepo.Put(ctx, &starredAlbum)).To(Succeed())
|
|
Expect(albumRepo.SetStar(ctx, true, starredAlbum.ID)).To(Succeed())
|
|
defer func() {
|
|
_, _ = albumRepo.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": starredAlbum.ID}))
|
|
_, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": starredAlbum.ID}))
|
|
}()
|
|
|
|
// Exactly two albums are starred for this user: the one created above and
|
|
// albumRadioactivity (id 103) from the seed data.
|
|
count, err := albumRepo.CountAll(ctx, model.QueryOptions{
|
|
Filters: annotationBoolFilter("starred")("starred", "true"),
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(count).To(Equal(int64(2)))
|
|
})
|
|
|
|
It("counts via a raw annotation filter without a 'no such column' error", func() {
|
|
count, err := albumRepo.CountAll(ctx, model.QueryOptions{
|
|
Filters: squirrel.Expr("COALESCE(rating, 0) > 0"),
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(count).To(BeNumerically(">=", int64(0)))
|
|
})
|
|
})
|
|
})
|