refactor: new persistence, more SQL, less ORM

This commit is contained in:
Deluan
2020-01-28 08:22:17 -05:00
committed by Deluan Quintão
parent b26a5ef2d0
commit 71c1844bca
38 changed files with 1294 additions and 1346 deletions
+74 -158
View File
@@ -1,220 +1,136 @@
package persistence
import (
"fmt"
"strings"
"time"
"context"
"github.com/Masterminds/squirrel"
. "github.com/Masterminds/squirrel"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/rest"
)
type album struct {
ID string `json:"id" orm:"pk;column(id)"`
Name string `json:"name" orm:"index"`
ArtistID string `json:"artistId" orm:"column(artist_id);index"`
CoverArtPath string `json:"-"`
CoverArtId string `json:"-"`
Artist string `json:"artist" orm:"index"`
AlbumArtist string `json:"albumArtist"`
Year int `json:"year" orm:"index"`
Compilation bool `json:"compilation"`
SongCount int `json:"songCount"`
Duration int `json:"duration"`
Genre string `json:"genre" orm:"index"`
CreatedAt time.Time `json:"createdAt" orm:"null"`
UpdatedAt time.Time `json:"updatedAt" orm:"null"`
}
type albumRepository struct {
searchableRepository
sqlRepository
}
func NewAlbumRepository(o orm.Ormer) model.AlbumRepository {
func NewAlbumRepository(ctx context.Context, o orm.Ormer) model.AlbumRepository {
r := &albumRepository{}
r.ctx = ctx
r.ormer = o
r.tableName = "album"
r.tableName = "media_file"
return r
}
func (r *albumRepository) CountAll(options ...model.QueryOptions) (int64, error) {
sel := r.selectAlbum(options...)
return r.count(sel, options...)
}
func (r *albumRepository) Exists(id string) (bool, error) {
return r.exists(Select().Where(Eq{"album_id": id}))
}
func (r *albumRepository) Put(a *model.Album) error {
ta := album(*a)
return r.put(a.ID, a.Name, &ta)
return nil
}
func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder {
//select album_id as id, album as name, f.artist, f.album_artist, f.artist_id, f.compilation, f.genre,
// max(f.year) as year, sum(f.duration) as duration, max(f.updated_at) as updated_at,
// min(f.created_at) as created_at, count(*) as song_count, a.id as current_id, f.id as cover_art_id,
// f.path as cover_art_path, f.has_cover_art
// group by album_id
return r.newSelectWithAnnotation(model.AlbumItemType, "album_id", options...).
Columns("album_id as id", "album as name", "artist", "album_artist", "artist", "artist_id",
"compilation", "genre", "id as cover_art_id", "path as cover_art_path", "has_cover_art",
"max(year) as year", "sum(duration) as duration", "max(updated_at) as updated_at",
"min(created_at) as created_at", "count(*) as song_count").GroupBy("album_id")
}
func (r *albumRepository) Get(id string) (*model.Album, error) {
ta := album{ID: id}
err := r.ormer.Read(&ta)
if err == orm.ErrNoRows {
return nil, model.ErrNotFound
}
sq := r.selectAlbum().Where(Eq{"album_id": id})
var res model.Album
err := r.queryOne(sq, &res)
if err != nil {
return nil, err
}
a := model.Album(ta)
return &a, err
return &res, nil
}
func (r *albumRepository) FindByArtist(artistId string) (model.Albums, error) {
var albums []album
_, err := r.newQuery().Filter("artist_id", artistId).OrderBy("year", "name").All(&albums)
if err != nil {
return nil, err
}
return r.toAlbums(albums), nil
sq := r.selectAlbum().Where(Eq{"artist_id": artistId}).OrderBy("album")
var res model.Albums
err := r.queryAll(sq, &res)
return res, err
}
func (r *albumRepository) GetAll(options ...model.QueryOptions) (model.Albums, error) {
var all []album
_, err := r.newQuery(options...).All(&all)
if err != nil {
return nil, err
}
return r.toAlbums(all), nil
sq := r.selectAlbum(options...)
var res model.Albums
err := r.queryAll(sq, &res)
return res, err
}
func (r *albumRepository) GetMap(ids []string) (map[string]model.Album, error) {
var all []album
if len(ids) == 0 {
return nil, nil
}
_, err := r.newQuery().Filter("id__in", ids).All(&all)
if err != nil {
return nil, err
}
res := make(map[string]model.Album)
for _, a := range all {
res[a.ID] = model.Album(a)
}
return res, nil
return nil, nil
}
// TODO Keep order when paginating
func (r *albumRepository) GetRandom(options ...model.QueryOptions) (model.Albums, error) {
sq := r.newRawQuery(options...)
sq := r.selectAlbum(options...)
switch r.ormer.Driver().Type() {
case orm.DRMySQL:
sq = sq.OrderBy("RAND()")
default:
sq = sq.OrderBy("RANDOM()")
}
sql, args, err := sq.ToSql()
sql, args, err := r.toSql(sq)
if err != nil {
return nil, err
}
var results []album
var results model.Albums
_, err = r.ormer.Raw(sql, args...).QueryRows(&results)
return r.toAlbums(results), err
}
func (r *albumRepository) toAlbums(all []album) model.Albums {
result := make(model.Albums, len(all))
for i, a := range all {
result[i] = model.Album(a)
}
return result
return results, err
}
func (r *albumRepository) Refresh(ids ...string) error {
type refreshAlbum struct {
album
CurrentId string
HasCoverArt bool
}
var albums []refreshAlbum
o := r.ormer
sql := fmt.Sprintf(`
select album_id as id, album as name, f.artist, f.album_artist, f.artist_id, f.compilation, f.genre,
max(f.year) as year, sum(f.duration) as duration, max(f.updated_at) as updated_at,
min(f.created_at) as created_at, count(*) as song_count, a.id as current_id, f.id as cover_art_id,
f.path as cover_art_path, f.has_cover_art
from media_file f left outer join album a on f.album_id = a.id
where f.album_id in ('%s')
group by album_id order by f.id`, strings.Join(ids, "','"))
_, err := o.Raw(sql).QueryRows(&albums)
if err != nil {
return err
}
var toInsert []album
var toUpdate []album
for _, al := range albums {
if !al.HasCoverArt {
al.CoverArtId = ""
}
if al.Compilation {
al.AlbumArtist = "Various Artists"
}
if al.AlbumArtist == "" {
al.AlbumArtist = al.Artist
}
if al.CurrentId != "" {
toUpdate = append(toUpdate, al.album)
} else {
toInsert = append(toInsert, al.album)
}
err := r.addToIndex(r.tableName, al.ID, al.Name)
if err != nil {
return err
}
}
if len(toInsert) > 0 {
n, err := o.InsertMulti(10, toInsert)
if err != nil {
return err
}
log.Debug("Inserted new albums", "num", n)
}
if len(toUpdate) > 0 {
for _, al := range toUpdate {
_, err := o.Update(&al, "name", "artist_id", "cover_art_path", "cover_art_id", "artist", "album_artist",
"year", "compilation", "song_count", "duration", "updated_at", "created_at")
if err != nil {
return err
}
}
log.Debug("Updated albums", "num", len(toUpdate))
}
return err
return nil
}
func (r *albumRepository) PurgeEmpty() error {
_, err := r.ormer.Raw("delete from album where id not in (select distinct(album_id) from media_file)").Exec()
return err
return nil
}
func (r *albumRepository) GetStarred(userId string, options ...model.QueryOptions) (model.Albums, error) {
var starred []album
sq := r.newRawQuery(options...).Join("annotation").Where("annotation.item_id = " + r.tableName + ".id")
sq = sq.Where(squirrel.And{
squirrel.Eq{"annotation.user_id": userId},
squirrel.Eq{"annotation.starred": true},
})
sql, args, err := sq.ToSql()
if err != nil {
return nil, err
}
_, err = r.ormer.Raw(sql, args...).QueryRows(&starred)
if err != nil {
return nil, err
}
return r.toAlbums(starred), nil
sq := r.selectAlbum(options...).Where("starred = true")
var starred model.Albums
err := r.queryAll(sq, &starred)
return starred, err
}
func (r *albumRepository) Search(q string, offset int, size int) (model.Albums, error) {
if len(q) <= 2 {
return nil, nil
}
return nil, nil
}
var results []album
err := r.doSearch(r.tableName, q, offset, size, &results, "name")
if err != nil {
return nil, err
}
return r.toAlbums(results), nil
func (r *albumRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.CountAll(r.parseRestOptions(options...))
}
func (r *albumRepository) Read(id string) (interface{}, error) {
return r.Get(id)
}
func (r *albumRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
return r.GetAll(r.parseRestOptions(options...))
}
func (r *albumRepository) EntityName() string {
return "album"
}
func (r *albumRepository) NewInstance() interface{} {
return &model.Album{}
}
var _ model.AlbumRepository = (*albumRepository)(nil)
var _ = model.Album(album{})
var _ model.ResourceRepository = (*albumRepository)(nil)
+17 -4
View File
@@ -1,6 +1,8 @@
package persistence
import (
"context"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/model"
. "github.com/onsi/ginkgo"
@@ -11,7 +13,18 @@ var _ = Describe("AlbumRepository", func() {
var repo model.AlbumRepository
BeforeEach(func() {
repo = NewAlbumRepository(orm.NewOrm())
ctx := context.WithValue(context.Background(), "user", &model.User{ID: "userid"})
repo = NewAlbumRepository(ctx, orm.NewOrm())
})
Describe("Get", func() {
It("returns an existent album", func() {
Expect(repo.Get("3")).To(Equal(&albumRadioactivity))
})
It("returns ErrNotFound when the album does not exist", func() {
_, err := repo.Get("666")
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Describe("GetAll", func() {
@@ -20,7 +33,7 @@ var _ = Describe("AlbumRepository", func() {
})
It("returns all records sorted", func() {
Expect(repo.GetAll(model.QueryOptions{Sort: "Name"})).To(Equal(model.Albums{
Expect(repo.GetAll(model.QueryOptions{Sort: "name"})).To(Equal(model.Albums{
albumAbbeyRoad,
albumRadioactivity,
albumSgtPeppers,
@@ -28,7 +41,7 @@ var _ = Describe("AlbumRepository", func() {
})
It("returns all records sorted desc", func() {
Expect(repo.GetAll(model.QueryOptions{Sort: "Name", Order: "desc"})).To(Equal(model.Albums{
Expect(repo.GetAll(model.QueryOptions{Sort: "name", Order: "desc"})).To(Equal(model.Albums{
albumSgtPeppers,
albumRadioactivity,
albumAbbeyRoad,
@@ -52,7 +65,7 @@ var _ = Describe("AlbumRepository", func() {
Describe("FindByArtist", func() {
It("returns all records from a given ArtistID", func() {
Expect(repo.FindByArtist("1")).To(Equal(model.Albums{
Expect(repo.FindByArtist("3")).To(Equal(model.Albums{
albumAbbeyRoad,
albumSgtPeppers,
}))
+70 -59
View File
@@ -1,8 +1,10 @@
package persistence
import (
"context"
"time"
. "github.com/Masterminds/squirrel"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/model"
"github.com/google/uuid"
@@ -13,16 +15,16 @@ type annotation struct {
UserID string `orm:"column(user_id)"`
ItemID string `orm:"column(item_id)"`
ItemType string `orm:"column(item_type)"`
PlayCount int `orm:"index;null"`
PlayDate time.Time `orm:"index;null"`
Rating int `orm:"index;null"`
PlayCount int `orm:"column(play_count);index;null"`
PlayDate time.Time `orm:"column(play_date);index;null"`
Rating int `orm:"null"`
Starred bool `orm:"index"`
StarredAt time.Time `orm:"null"`
StarredAt time.Time `orm:"column(starred_at);null"`
}
func (u *annotation) TableUnique() [][]string {
return [][]string{
[]string{"UserID", "ItemID", "ItemType"},
{"UserID", "ItemID", "ItemType"},
}
}
@@ -30,40 +32,40 @@ type annotationRepository struct {
sqlRepository
}
func NewAnnotationRepository(o orm.Ormer) model.AnnotationRepository {
func NewAnnotationRepository(ctx context.Context, o orm.Ormer) model.AnnotationRepository {
r := &annotationRepository{}
r.ctx = ctx
r.ormer = o
r.tableName = "annotation"
return r
}
func (r *annotationRepository) Get(userID, itemType string, itemID string) (*model.Annotation, error) {
if userID == "" {
return nil, model.ErrInvalidAuth
}
q := r.newQuery().Filter("user_id", userID).Filter("item_type", itemType).Filter("item_id", itemID)
q := Select("*").From(r.tableName).Where(And{
Eq{"user_id": userId(r.ctx)},
Eq{"item_type": itemType},
Eq{"item_id": itemID},
})
var ann annotation
err := q.One(&ann)
if err == orm.ErrNoRows {
err := r.queryOne(q, &ann)
if err == model.ErrNotFound {
return nil, nil
}
if err != nil {
return nil, err
}
resp := model.Annotation(ann)
return &resp, nil
}
func (r *annotationRepository) GetMap(userID, itemType string, itemID []string) (model.AnnotationMap, error) {
if userID == "" {
return nil, model.ErrInvalidAuth
}
if len(itemID) == 0 {
func (r *annotationRepository) GetMap(userID, itemType string, itemIDs []string) (model.AnnotationMap, error) {
if len(itemIDs) == 0 {
return nil, nil
}
q := r.newQuery().Filter("user_id", userID).Filter("item_type", itemType).Filter("item_id__in", itemID)
q := Select("*").From(r.tableName).Where(And{
Eq{"user_id": userId(r.ctx)},
Eq{"item_type": itemType},
Eq{"item_id": itemIDs},
})
var res []annotation
_, err := q.All(&res)
err := r.queryAll(q, &res)
if err != nil {
return nil, err
}
@@ -76,12 +78,12 @@ func (r *annotationRepository) GetMap(userID, itemType string, itemID []string)
}
func (r *annotationRepository) GetAll(userID, itemType string, options ...model.QueryOptions) ([]model.Annotation, error) {
if userID == "" {
return nil, model.ErrInvalidAuth
}
q := r.newQuery(options...).Filter("user_id", userID).Filter("item_type", itemType)
q := Select("*").From(r.tableName).Where(And{
Eq{"user_id": userId(r.ctx)},
Eq{"item_type": itemType},
})
var res []annotation
_, err := q.All(&res)
err := r.queryAll(q, &res)
if err != nil {
return nil, err
}
@@ -104,16 +106,18 @@ func (r *annotationRepository) new(userID, itemType string, itemID string) *anno
}
func (r *annotationRepository) IncPlayCount(userID, itemType string, itemID string, ts time.Time) error {
if userID == "" {
return model.ErrInvalidAuth
}
q := r.newQuery().Filter("user_id", userID).Filter("item_type", itemType).Filter("item_id", itemID)
c, err := q.Update(orm.Params{
"play_count": orm.ColValue(orm.ColAdd, 1),
"play_date": ts,
})
uid := userId(r.ctx)
q := Update(r.tableName).
Set("play_count", Expr("play_count + 1")).
Set("play_date", ts).
Where(And{
Eq{"user_id": uid},
Eq{"item_type": itemType},
Eq{"item_id": itemID},
})
c, err := r.executeSQL(q)
if c == 0 || err == orm.ErrNoRows {
ann := r.new(userID, itemType, itemID)
ann := r.new(uid, itemType, itemID)
ann.PlayCount = 1
ann.PlayDate = ts
_, err = r.ormer.Insert(ann)
@@ -122,26 +126,30 @@ func (r *annotationRepository) IncPlayCount(userID, itemType string, itemID stri
}
func (r *annotationRepository) SetStar(starred bool, userID, itemType string, ids ...string) error {
if userID == "" {
return model.ErrInvalidAuth
}
q := r.newQuery().Filter("user_id", userID).Filter("item_type", itemType).Filter("item_id__in", ids)
uid := userId(r.ctx)
var starredAt time.Time
if starred {
starredAt = time.Now()
}
c, err := q.Update(orm.Params{
"starred": starred,
"starred_at": starredAt,
})
q := Update(r.tableName).
Set("starred", starred).
Set("starred_at", starredAt).
Where(And{
Eq{"user_id": uid},
Eq{"item_type": itemType},
Eq{"item_id": ids},
})
c, err := r.executeSQL(q)
if c == 0 || err == orm.ErrNoRows {
for _, id := range ids {
ann := r.new(userID, itemType, id)
ann := r.new(uid, itemType, id)
ann.Starred = starred
ann.StarredAt = starredAt
_, err = r.ormer.Insert(ann)
if err != nil {
return err
if err.Error() != "LastInsertId is not supported by this driver" {
return err
}
}
}
}
@@ -149,24 +157,27 @@ func (r *annotationRepository) SetStar(starred bool, userID, itemType string, id
}
func (r *annotationRepository) SetRating(rating int, userID, itemType string, itemID string) error {
if userID == "" {
return model.ErrInvalidAuth
}
q := r.newQuery().Filter("user_id", userID).Filter("item_type", itemType).Filter("item_id", itemID)
c, err := q.Update(orm.Params{
"rating": rating,
})
uid := userId(r.ctx)
q := Update(r.tableName).
Set("rating", rating).
Where(And{
Eq{"user_id": uid},
Eq{"item_type": itemType},
Eq{"item_id": itemID},
})
c, err := r.executeSQL(q)
if c == 0 || err == orm.ErrNoRows {
ann := r.new(userID, itemType, itemID)
ann := r.new(uid, itemType, itemID)
ann.Rating = rating
_, err = r.ormer.Insert(ann)
}
return err
}
func (r *annotationRepository) Delete(userID, itemType string, itemID ...string) error {
q := r.newQuery().Filter("user_id", userID).Filter("item_type", itemType).Filter("item_id__in", itemID)
_, err := q.Delete()
return err
func (r *annotationRepository) Delete(userID, itemType string, ids ...string) error {
return r.delete(And{
Eq{"user_id": userId(r.ctx)},
Eq{"item_type": itemType},
Eq{"item_id": ids},
})
}
+65 -138
View File
@@ -1,39 +1,49 @@
package persistence
import (
"fmt"
"context"
"sort"
"strings"
"time"
"github.com/Masterminds/squirrel"
. "github.com/Masterminds/squirrel"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/utils"
"github.com/deluan/rest"
)
type artist struct {
ID string `json:"id" orm:"pk;column(id)"`
Name string `json:"name" orm:"index"`
AlbumCount int `json:"albumCount" orm:"column(album_count)"`
}
type artistRepository struct {
searchableRepository
sqlRepository
indexGroups utils.IndexGroups
}
func NewArtistRepository(o orm.Ormer) model.ArtistRepository {
func NewArtistRepository(ctx context.Context, o orm.Ormer) model.ArtistRepository {
r := &artistRepository{}
r.ctx = ctx
r.ormer = o
r.indexGroups = utils.ParseIndexGroups(conf.Server.IndexGroups)
r.tableName = "artist"
r.tableName = "media_file"
return r
}
func (r *artistRepository) getIndexKey(a *artist) string {
func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBuilder {
// FIXME Handle AlbumArtist/Various Artists...
return r.newSelectWithAnnotation(model.ArtistItemType, "album_id", options...).
Columns("artist_id as id", "artist as name", "count(distinct album_id) as album_count").
GroupBy("artist_id").Where(Eq{"compilation": false})
}
func (r *artistRepository) CountAll(options ...model.QueryOptions) (int64, error) {
sel := r.selectArtist(options...).Where(Eq{"compilation": false})
return r.count(sel, options...)
}
func (r *artistRepository) Exists(id string) (bool, error) {
return r.exists(Select().Where(Eq{"artist_id": id}))
}
func (r *artistRepository) getIndexKey(a *model.Artist) string {
name := strings.ToLower(utils.NoArticle(a.Name))
for k, v := range r.indexGroups {
key := strings.ToLower(k)
@@ -45,28 +55,31 @@ func (r *artistRepository) getIndexKey(a *artist) string {
}
func (r *artistRepository) Put(a *model.Artist) error {
ta := artist(*a)
return r.put(a.ID, a.Name, &ta)
return nil
}
func (r *artistRepository) Get(id string) (*model.Artist, error) {
ta := artist{ID: id}
err := r.ormer.Read(&ta)
if err == orm.ErrNoRows {
return nil, model.ErrNotFound
}
if err != nil {
return nil, err
}
a := model.Artist(ta)
return &a, nil
sel := Select("artist_id as id", "artist as name", "count(distinct album_id) as album_count").
From("media_file").GroupBy("artist_id").Where(Eq{"artist_id": id})
var res model.Artist
err := r.queryOne(sel, &res)
return &res, err
}
func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists, error) {
sel := r.selectArtist(options...)
var res model.Artists
err := r.queryAll(sel, &res)
return res, err
}
// TODO Cache the index (recalculate when there are changes to the DB)
func (r *artistRepository) GetIndex() (model.ArtistIndexes, error) {
var all []artist
sq := Select("artist_id as id", "artist as name", "count(distinct album_id) as album_count").
From("media_file").GroupBy("artist_id").OrderBy("name")
var all model.Artists
// TODO Paginate
_, err := r.newQuery().OrderBy("name").All(&all)
err := r.queryAll(sq, &all)
if err != nil {
return nil, err
}
@@ -92,127 +105,41 @@ func (r *artistRepository) GetIndex() (model.ArtistIndexes, error) {
}
func (r *artistRepository) Refresh(ids ...string) error {
type refreshArtist struct {
artist
CurrentId string
AlbumArtist string
Compilation bool
}
var artists []refreshArtist
o := r.ormer
sql := fmt.Sprintf(`
select f.artist_id as id,
f.artist as name,
f.album_artist,
f.compilation,
count(*) as album_count,
a.id as current_id
from album f
left outer join artist a on f.artist_id = a.id
where f.artist_id in ('%s') group by f.artist_id order by f.id`, strings.Join(ids, "','"))
_, err := o.Raw(sql).QueryRows(&artists)
if err != nil {
return err
}
var toInsert []artist
var toUpdate []artist
for _, ar := range artists {
if ar.Compilation {
ar.AlbumArtist = "Various Artists"
}
if ar.AlbumArtist != "" {
ar.Name = ar.AlbumArtist
}
if ar.CurrentId != "" {
toUpdate = append(toUpdate, ar.artist)
} else {
toInsert = append(toInsert, ar.artist)
}
err := r.addToIndex(r.tableName, ar.ID, ar.Name)
if err != nil {
return err
}
}
if len(toInsert) > 0 {
n, err := o.InsertMulti(10, toInsert)
if err != nil {
return err
}
log.Debug("Inserted new artists", "num", n)
}
if len(toUpdate) > 0 {
for _, al := range toUpdate {
// Don't update Starred
_, err := o.Update(&al, "name", "album_count")
if err != nil {
return err
}
}
log.Debug("Updated artists", "num", len(toUpdate))
}
return err
return nil
}
func (r *artistRepository) GetStarred(userId string, options ...model.QueryOptions) (model.Artists, error) {
var starred []artist
sq := r.newRawQuery(options...).Join("annotation").Where("annotation.item_id = " + r.tableName + ".id")
sq = sq.Where(squirrel.And{
squirrel.Eq{"annotation.user_id": userId},
squirrel.Eq{"annotation.starred": true},
})
sql, args, err := sq.ToSql()
if err != nil {
return nil, err
}
_, err = r.ormer.Raw(sql, args...).QueryRows(&starred)
if err != nil {
return nil, err
}
return r.toArtists(starred), nil
}
func (r *artistRepository) SetStar(starred bool, ids ...string) error {
if len(ids) == 0 {
return model.ErrNotFound
}
var starredAt time.Time
if starred {
starredAt = time.Now()
}
_, err := r.newQuery().Filter("id__in", ids).Update(orm.Params{
"starred": starred,
"starred_at": starredAt,
})
return err
return nil, nil // TODO
}
func (r *artistRepository) PurgeEmpty() error {
_, err := r.ormer.Raw("delete from artist where id not in (select distinct(artist_id) from album)").Exec()
return err
return nil
}
func (r *artistRepository) Search(q string, offset int, size int) (model.Artists, error) {
if len(q) <= 2 {
return nil, nil
}
var results []artist
err := r.doSearch(r.tableName, q, offset, size, &results, "name")
if err != nil {
return nil, err
}
return r.toArtists(results), nil
return nil, nil // TODO
}
func (r *artistRepository) toArtists(all []artist) model.Artists {
result := make(model.Artists, len(all))
for i, a := range all {
result[i] = model.Artist(a)
}
return result
func (r *artistRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.CountAll(r.parseRestOptions(options...))
}
func (r *artistRepository) Read(id string) (interface{}, error) {
return r.Get(id)
}
func (r *artistRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
return r.GetAll(r.parseRestOptions(options...))
}
func (r *artistRepository) EntityName() string {
return "artist"
}
func (r *artistRepository) NewInstance() interface{} {
return &model.Artist{}
}
var _ model.ArtistRepository = (*artistRepository)(nil)
var _ = model.Artist(artist{})
var _ model.ArtistRepository = (*artistRepository)(nil)
var _ model.ResourceRepository = (*artistRepository)(nil)
+20 -19
View File
@@ -1,6 +1,8 @@
package persistence
import (
"context"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/model"
. "github.com/onsi/ginkgo"
@@ -11,22 +13,27 @@ var _ = Describe("ArtistRepository", func() {
var repo model.ArtistRepository
BeforeEach(func() {
repo = NewArtistRepository(orm.NewOrm())
repo = NewArtistRepository(context.Background(), orm.NewOrm())
})
Describe("Put/Get", func() {
Describe("Count", func() {
It("returns the number of artists in the DB", func() {
Expect(repo.CountAll()).To(Equal(int64(2)))
})
})
Describe("Exist", func() {
It("returns true for an artist that is in the DB", func() {
Expect(repo.Exists("3")).To(BeTrue())
})
It("returns false for an artist that is in the DB", func() {
Expect(repo.Exists("666")).To(BeFalse())
})
})
Describe("Get", func() {
It("saves and retrieves data", func() {
Expect(repo.Get("1")).To(Equal(&artistSaaraSaara))
})
It("overrides data if ID already exists", func() {
Expect(repo.Put(&model.Artist{ID: "1", Name: "Saara Saara is The Best!", AlbumCount: 3})).To(BeNil())
Expect(repo.Get("1")).To(Equal(&model.Artist{ID: "1", Name: "Saara Saara is The Best!", AlbumCount: 3}))
})
It("returns ErrNotFound when the ID does not exist", func() {
_, err := repo.Get("999")
Expect(err).To(MatchError(model.ErrNotFound))
Expect(repo.Get("2")).To(Equal(&artistKraftwerk))
})
})
@@ -47,12 +54,6 @@ var _ = Describe("ArtistRepository", func() {
artistKraftwerk,
},
},
{
ID: "S",
Artists: model.Artists{
{ID: "1", Name: "Saara Saara is The Best!", AlbumCount: 3},
},
},
}))
})
})
+15 -42
View File
@@ -1,60 +1,33 @@
package persistence
import (
"strconv"
"context"
. "github.com/Masterminds/squirrel"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/model"
)
type genreRepository struct {
ormer orm.Ormer
sqlRepository
}
func NewGenreRepository(o orm.Ormer) model.GenreRepository {
return &genreRepository{ormer: o}
func NewGenreRepository(ctx context.Context, o orm.Ormer) model.GenreRepository {
r := &genreRepository{}
r.ctx = ctx
r.ormer = o
r.tableName = "media_file"
return r
}
func (r genreRepository) GetAll() (model.Genres, error) {
genres := make(map[string]model.Genre)
// Collect SongCount
var res []orm.Params
_, err := r.ormer.Raw("select genre, count(*) as c from media_file group by genre").Values(&res)
sq := Select("genre as name", "count(distinct album_id) as album_count", "count(distinct id) as song_count").
From("media_file").GroupBy("genre")
sql, args, err := r.toSql(sq)
if err != nil {
return nil, err
}
for _, r := range res {
name := r["genre"].(string)
count := r["c"].(string)
g, ok := genres[name]
if !ok {
g = model.Genre{Name: name}
}
g.SongCount, _ = strconv.Atoi(count)
genres[name] = g
}
// Collect AlbumCount
_, err = r.ormer.Raw("select genre, count(*) as c from album group by genre").Values(&res)
if err != nil {
return nil, err
}
for _, r := range res {
name := r["genre"].(string)
count := r["c"].(string)
g, ok := genres[name]
if !ok {
g = model.Genre{Name: name}
}
g.AlbumCount, _ = strconv.Atoi(count)
genres[name] = g
}
// Build response
result := model.Genres{}
for _, g := range genres {
result = append(result, g)
}
return result, err
var res model.Genres
_, err = r.ormer.Raw(sql, args).QueryRows(&res)
return res, err
}
+5 -2
View File
@@ -1,8 +1,11 @@
package persistence
package persistence_test
import (
"context"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/persistence"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
@@ -11,7 +14,7 @@ var _ = Describe("GenreRepository", func() {
var repo model.GenreRepository
BeforeEach(func() {
repo = NewGenreRepository(orm.NewOrm())
repo = persistence.NewGenreRepository(context.Background(), orm.NewOrm())
})
It("returns all records", func() {
+62
View File
@@ -0,0 +1,62 @@
package persistence
import (
"encoding/json"
"fmt"
"regexp"
"strings"
)
func toSqlArgs(rec interface{}) (map[string]interface{}, error) {
// Convert to JSON...
b, err := json.Marshal(rec)
if err != nil {
return nil, err
}
// ... then convert to map
var m map[string]interface{}
err = json.Unmarshal(b, &m)
r := make(map[string]interface{}, len(m))
for f, v := range m {
r[toSnakeCase(f)] = v
}
return r, err
}
var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)")
var matchAllCap = regexp.MustCompile("([a-z0-9])([A-Z])")
func toSnakeCase(str string) string {
snake := matchFirstCap.ReplaceAllString(str, "${1}_${2}")
snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}")
return strings.ToLower(snake)
}
func ToStruct(m map[string]interface{}, rec interface{}, fieldNames []string) error {
var r = make(map[string]interface{}, len(m))
for _, f := range fieldNames {
v, ok := m[f]
if !ok {
return fmt.Errorf("invalid field '%s'", f)
}
r[toCamelCase(f)] = v
}
// Convert to JSON...
b, err := json.Marshal(r)
if err != nil {
return err
}
// ... then convert to struct
err = json.Unmarshal(b, &rec)
return err
}
var matchUnderscore = regexp.MustCompile("_([A-Za-z])")
func toCamelCase(str string) string {
return matchUnderscore.ReplaceAllStringFunc(str, func(s string) string {
return strings.ToUpper(strings.Replace(s, "_", "", -1))
})
}
+109 -124
View File
@@ -1,176 +1,161 @@
package persistence
import (
"os"
"context"
"strings"
"time"
"github.com/Masterminds/squirrel"
. "github.com/Masterminds/squirrel"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/model"
"github.com/deluan/rest"
"github.com/kennygrant/sanitize"
)
type mediaFile struct {
ID string `json:"id" orm:"pk;column(id)"`
Path string `json:"path" orm:"index"`
Title string `json:"title" orm:"index"`
Album string `json:"album"`
Artist string `json:"artist"`
ArtistID string `json:"artistId" orm:"column(artist_id)"`
AlbumArtist string `json:"albumArtist"`
AlbumID string `json:"albumId" orm:"column(album_id);index"`
HasCoverArt bool `json:"-"`
TrackNumber int `json:"trackNumber"`
DiscNumber int `json:"discNumber"`
Year int `json:"year"`
Size int `json:"size"`
Suffix string `json:"suffix"`
Duration int `json:"duration"`
BitRate int `json:"bitRate"`
Genre string `json:"genre" orm:"index"`
Compilation bool `json:"compilation"`
CreatedAt time.Time `json:"createdAt" orm:"null"`
UpdatedAt time.Time `json:"updatedAt" orm:"null"`
}
type mediaFileRepository struct {
searchableRepository
sqlRepository
}
func NewMediaFileRepository(o orm.Ormer) model.MediaFileRepository {
func NewMediaFileRepository(ctx context.Context, o orm.Ormer) *mediaFileRepository {
r := &mediaFileRepository{}
r.ctx = ctx
r.ormer = o
r.tableName = "media_file"
return r
}
func (r *mediaFileRepository) Put(m *model.MediaFile) error {
tm := mediaFile(*m)
// Don't update media annotation fields (playcount, starred, etc..)
// TODO Validate if this is still necessary, now that we don't have annotations in the mediafile model
return r.put(m.ID, m.Title, &tm, "path", "title", "album", "artist", "artist_id", "album_artist",
"album_id", "has_cover_art", "track_number", "disc_number", "year", "size", "suffix", "duration",
"bit_rate", "genre", "compilation", "updated_at")
func (r mediaFileRepository) CountAll(options ...model.QueryOptions) (int64, error) {
return r.count(Select(), options...)
}
func (r *mediaFileRepository) Get(id string) (*model.MediaFile, error) {
tm := mediaFile{ID: id}
err := r.ormer.Read(&tm)
if err == orm.ErrNoRows {
return nil, model.ErrNotFound
}
if err != nil {
return nil, err
}
a := model.MediaFile(tm)
return &a, nil
func (r mediaFileRepository) Exists(id string) (bool, error) {
return r.exists(Select().Where(Eq{"id": id}))
}
func (r *mediaFileRepository) toMediaFiles(all []mediaFile) model.MediaFiles {
result := make(model.MediaFiles, len(all))
for i, m := range all {
result[i] = model.MediaFile(m)
}
return result
}
func (r *mediaFileRepository) FindByAlbum(albumId string) (model.MediaFiles, error) {
var mfs []mediaFile
_, err := r.newQuery().Filter("album_id", albumId).OrderBy("disc_number", "track_number").All(&mfs)
if err != nil {
return nil, err
}
return r.toMediaFiles(mfs), nil
}
func (r *mediaFileRepository) FindByPath(path string) (model.MediaFiles, error) {
var mfs []mediaFile
_, err := r.newQuery().Filter("path__istartswith", path).OrderBy("disc_number", "track_number").All(&mfs)
if err != nil {
return nil, err
}
var filtered []mediaFile
path = strings.ToLower(path) + string(os.PathSeparator)
for _, mf := range mfs {
filename := strings.TrimPrefix(strings.ToLower(mf.Path), path)
if len(strings.Split(filename, string(os.PathSeparator))) > 1 {
continue
}
filtered = append(filtered, mf)
}
return r.toMediaFiles(filtered), nil
}
func (r *mediaFileRepository) DeleteByPath(path string) error {
var mfs []mediaFile
// TODO Paginate this (and all other situations similar)
_, err := r.newQuery().Filter("path__istartswith", path).OrderBy("disc_number", "track_number").All(&mfs)
func (r mediaFileRepository) Put(m *model.MediaFile) error {
values, _ := toSqlArgs(*m)
update := Update(r.tableName).Where(Eq{"id": m.ID}).SetMap(values)
count, err := r.executeSQL(update)
if err != nil {
return err
}
var filtered []string
path = strings.ToLower(path) + string(os.PathSeparator)
for _, mf := range mfs {
filename := strings.TrimPrefix(strings.ToLower(mf.Path), path)
if len(strings.Split(filename, string(os.PathSeparator))) > 1 {
continue
}
filtered = append(filtered, mf.ID)
}
if len(filtered) == 0 {
if count > 0 {
return nil
}
_, err = r.newQuery().Filter("id__in", filtered).Delete()
insert := Insert(r.tableName).SetMap(values)
_, err = r.executeSQL(insert)
return err
}
func (r *mediaFileRepository) GetRandom(options ...model.QueryOptions) (model.MediaFiles, error) {
sq := r.newRawQuery(options...)
func (r mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder {
return r.newSelectWithAnnotation(model.MediaItemType, "media_file.id", options...).Columns("media_file.*")
}
func (r mediaFileRepository) Get(id string) (*model.MediaFile, error) {
sel := r.selectMediaFile().Where(Eq{"id": id})
var res model.MediaFile
err := r.queryOne(sel, &res)
return &res, err
}
func (r mediaFileRepository) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
sq := r.selectMediaFile(options...)
var res model.MediaFiles
err := r.queryAll(sq, &res)
return res, err
}
func (r mediaFileRepository) FindByAlbum(albumId string) (model.MediaFiles, error) {
sel := r.selectMediaFile().Where(Eq{"album_id": albumId})
var res model.MediaFiles
err := r.queryAll(sel, &res)
return res, err
}
func (r mediaFileRepository) FindByPath(path string) (model.MediaFiles, error) {
sel := r.selectMediaFile().Where(Like{"path": path + "%"})
var res model.MediaFiles
err := r.queryAll(sel, &res)
return res, err
}
func (r mediaFileRepository) GetStarred(userId string, options ...model.QueryOptions) (model.MediaFiles, error) {
sq := r.selectMediaFile(options...).Where("starred = true")
var starred model.MediaFiles
err := r.queryAll(sq, &starred)
return starred, err
}
// TODO Keep order when paginating
func (r mediaFileRepository) GetRandom(options ...model.QueryOptions) (model.MediaFiles, error) {
sq := r.selectMediaFile(options...)
switch r.ormer.Driver().Type() {
case orm.DRMySQL:
sq = sq.OrderBy("RAND()")
default:
sq = sq.OrderBy("RANDOM()")
}
sql, args, err := sq.ToSql()
sql, args, err := r.toSql(sq)
if err != nil {
return nil, err
}
var results []mediaFile
var results model.MediaFiles
_, err = r.ormer.Raw(sql, args...).QueryRows(&results)
return r.toMediaFiles(results), err
return results, err
}
func (r *mediaFileRepository) GetStarred(userId string, options ...model.QueryOptions) (model.MediaFiles, error) {
var starred []mediaFile
sq := r.newRawQuery(options...).Join("annotation").Where("annotation.item_id = " + r.tableName + ".id")
sq = sq.Where(squirrel.And{
squirrel.Eq{"annotation.user_id": userId},
squirrel.Eq{"annotation.starred": true},
})
sql, args, err := sq.ToSql()
if err != nil {
return nil, err
}
_, err = r.ormer.Raw(sql, args...).QueryRows(&starred)
if err != nil {
return nil, err
}
return r.toMediaFiles(starred), nil
func (r mediaFileRepository) Delete(id string) error {
return r.delete(Eq{"id": id})
}
func (r *mediaFileRepository) Search(q string, offset int, size int) (model.MediaFiles, error) {
func (r mediaFileRepository) DeleteByPath(path string) error {
del := Delete(r.tableName).Where(Like{"path": path + "%"})
_, err := r.executeSQL(del)
return err
}
func (r mediaFileRepository) Search(q string, offset int, size int) (model.MediaFiles, error) {
q = strings.TrimSpace(sanitize.Accents(strings.ToLower(strings.TrimSuffix(q, "*"))))
if len(q) <= 2 {
return nil, nil
return model.MediaFiles{}, nil
}
var results []mediaFile
err := r.doSearch(r.tableName, q, offset, size, &results, "title")
sq := Select("*").From(r.tableName)
sq = sq.Limit(uint64(size)).Offset(uint64(offset)).OrderBy("title")
sq = sq.Join("search").Where("search.id = " + r.tableName + ".id")
parts := strings.Split(q, " ")
for _, part := range parts {
sq = sq.Where(Or{
Like{"full_text": part + "%"},
Like{"full_text": "%" + part + "%"},
})
}
sql, args, err := r.toSql(sq)
if err != nil {
return nil, err
}
return r.toMediaFiles(results), nil
var results model.MediaFiles
_, err = r.ormer.Raw(sql, args...).QueryRows(results)
return results, err
}
func (r mediaFileRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.CountAll(r.parseRestOptions(options...))
}
func (r mediaFileRepository) Read(id string) (interface{}, error) {
return r.Get(id)
}
func (r mediaFileRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
return r.GetAll(r.parseRestOptions(options...))
}
func (r mediaFileRepository) EntityName() string {
return "mediafile"
}
func (r mediaFileRepository) NewInstance() interface{} {
return model.MediaFile{}
}
var _ model.MediaFileRepository = (*mediaFileRepository)(nil)
var _ = model.MediaFile(mediaFile{})
var _ model.ResourceRepository = (*mediaFileRepository)(nil)
+69 -12
View File
@@ -1,29 +1,86 @@
package persistence
import (
"os"
"path/filepath"
"context"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/model"
"github.com/google/uuid"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("MediaFileRepository", func() {
var repo model.MediaFileRepository
var _ = Describe("MediaRepository", func() {
var mr model.MediaFileRepository
BeforeEach(func() {
repo = NewMediaFileRepository(orm.NewOrm())
ctx := context.WithValue(context.Background(), "user", &model.User{ID: "userid"})
mr = NewMediaFileRepository(ctx, orm.NewOrm())
})
Describe("FindByPath", func() {
It("returns all records from a given ArtistID", func() {
path := string(os.PathSeparator) + filepath.Join("beatles", "1")
Expect(repo.FindByPath(path)).To(Equal(model.MediaFiles{
songComeTogether,
}))
})
It("gets mediafile from the DB", func() {
Expect(mr.Get("4")).To(Equal(&songAntenna))
})
It("returns ErrNotFound", func() {
_, err := mr.Get("56")
Expect(err).To(MatchError(model.ErrNotFound))
})
It("counts the number of mediafiles in the DB", func() {
Expect(mr.CountAll()).To(Equal(int64(4)))
})
It("checks existence of mediafiles in the DB", func() {
Expect(mr.Exists(songAntenna.ID)).To(BeTrue())
Expect(mr.Exists("666")).To(BeFalse())
})
It("find mediafiles by album", func() {
Expect(mr.FindByAlbum("3")).To(Equal(model.MediaFiles{
songRadioactivity,
songAntenna,
}))
})
It("returns empty array when no tracks are found", func() {
Expect(mr.FindByAlbum("67")).To(Equal(model.MediaFiles{}))
})
It("finds tracks by path", func() {
Expect(mr.FindByPath(P("/beatles/1/sgt"))).To(Equal(model.MediaFiles{
songDayInALife,
}))
})
It("returns starred tracks", func() {
Expect(mr.GetStarred("userid")).To(Equal(model.MediaFiles{
songComeTogether,
}))
})
It("delete tracks by id", func() {
random, _ := uuid.NewRandom()
id := random.String()
Expect(mr.Put(&model.MediaFile{ID: id})).To(BeNil())
Expect(mr.Delete(id)).To(BeNil())
_, err := mr.Get(id)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("delete tracks by path", func() {
id1 := "1111"
Expect(mr.Put(&model.MediaFile{ID: id1, Path: P("/abc/123/" + id1 + ".mp3")})).To(BeNil())
id2 := "2222"
Expect(mr.Put(&model.MediaFile{ID: id2, Path: P("/abc/123/" + id2 + ".mp3")})).To(BeNil())
Expect(mr.DeleteByPath(P("/abc"))).To(BeNil())
_, err := mr.Get(id1)
Expect(err).To(MatchError(model.ErrNotFound))
_, err = mr.Get(id2)
Expect(err).To(MatchError(model.ErrNotFound))
})
})
+5 -3
View File
@@ -1,17 +1,19 @@
package persistence
import (
"context"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/model"
)
type mediaFolderRepository struct {
model.MediaFolderRepository
ctx context.Context
}
func NewMediaFolderRepository(o orm.Ormer) model.MediaFolderRepository {
return &mediaFolderRepository{}
func NewMediaFolderRepository(ctx context.Context, o orm.Ormer) model.MediaFolderRepository {
return &mediaFolderRepository{ctx}
}
func (*mediaFolderRepository) GetAll() (model.MediaFolders, error) {
+49 -78
View File
@@ -10,19 +10,14 @@ import (
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
_ "github.com/lib/pq"
_ "github.com/mattn/go-sqlite3"
)
const batchSize = 100
var (
once sync.Once
driver = "sqlite3"
mappedModels map[interface{}]interface{}
once sync.Once
driver = "sqlite3"
)
type SQLStore struct {
type NewSQLStore struct {
orm orm.Ormer
}
@@ -39,57 +34,72 @@ func New() model.DataStore {
panic(err)
}
})
return &SQLStore{}
return &NewSQLStore{}
}
func (db *SQLStore) Album(context.Context) model.AlbumRepository {
return NewAlbumRepository(db.getOrmer())
func (db *NewSQLStore) Album(ctx context.Context) model.AlbumRepository {
return NewAlbumRepository(ctx, db.getOrmer())
}
func (db *SQLStore) Artist(context.Context) model.ArtistRepository {
return NewArtistRepository(db.getOrmer())
func (db *NewSQLStore) Artist(ctx context.Context) model.ArtistRepository {
return NewArtistRepository(ctx, db.getOrmer())
}
func (db *SQLStore) MediaFile(context.Context) model.MediaFileRepository {
return NewMediaFileRepository(db.getOrmer())
func (db *NewSQLStore) MediaFile(ctx context.Context) model.MediaFileRepository {
return NewMediaFileRepository(ctx, db.getOrmer())
}
func (db *SQLStore) MediaFolder(context.Context) model.MediaFolderRepository {
return NewMediaFolderRepository(db.getOrmer())
func (db *NewSQLStore) MediaFolder(ctx context.Context) model.MediaFolderRepository {
return NewMediaFolderRepository(ctx, db.getOrmer())
}
func (db *SQLStore) Genre(context.Context) model.GenreRepository {
return NewGenreRepository(db.getOrmer())
func (db *NewSQLStore) Genre(ctx context.Context) model.GenreRepository {
return NewGenreRepository(ctx, db.getOrmer())
}
func (db *SQLStore) Playlist(context.Context) model.PlaylistRepository {
return NewPlaylistRepository(db.getOrmer())
func (db *NewSQLStore) Playlist(ctx context.Context) model.PlaylistRepository {
return NewPlaylistRepository(ctx, db.getOrmer())
}
func (db *SQLStore) Property(context.Context) model.PropertyRepository {
return NewPropertyRepository(db.getOrmer())
func (db *NewSQLStore) Property(ctx context.Context) model.PropertyRepository {
return NewPropertyRepository(ctx, db.getOrmer())
}
func (db *SQLStore) User(context.Context) model.UserRepository {
return NewUserRepository(db.getOrmer())
func (db *NewSQLStore) User(ctx context.Context) model.UserRepository {
return NewUserRepository(ctx, db.getOrmer())
}
func (db *SQLStore) Annotation(context.Context) model.AnnotationRepository {
return NewAnnotationRepository(db.getOrmer())
func (db *NewSQLStore) Annotation(ctx context.Context) model.AnnotationRepository {
return NewAnnotationRepository(ctx, db.getOrmer())
}
func (db *SQLStore) Resource(ctx context.Context, model interface{}) model.ResourceRepository {
return NewResource(db.getOrmer(), model, getMappedModel(model))
func getTypeName(model interface{}) string {
return reflect.TypeOf(model).Name()
}
func (db *SQLStore) WithTx(block func(tx model.DataStore) error) error {
func (db *NewSQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository {
switch m.(type) {
case model.User:
return db.User(ctx).(model.ResourceRepository)
case model.Artist:
return db.Artist(ctx).(model.ResourceRepository)
case model.Album:
return db.Album(ctx).(model.ResourceRepository)
case model.MediaFile:
return db.MediaFile(ctx).(model.ResourceRepository)
}
log.Error("Resource no implemented", "model", getTypeName(m))
return nil
}
func (db *NewSQLStore) WithTx(block func(tx model.DataStore) error) error {
o := orm.NewOrm()
err := o.Begin()
if err != nil {
return err
}
newDb := &SQLStore{orm: o}
newDb := &NewSQLStore{orm: o}
err = block(newDb)
if err != nil {
@@ -107,7 +117,7 @@ func (db *SQLStore) WithTx(block func(tx model.DataStore) error) error {
return nil
}
func (db *SQLStore) getOrmer() orm.Ormer {
func (db *NewSQLStore) getOrmer() orm.Ormer {
if db.orm == nil {
return orm.NewOrm()
}
@@ -115,56 +125,17 @@ func (db *SQLStore) getOrmer() orm.Ormer {
}
func initORM(dbPath string) error {
verbose := conf.Server.LogLevel == "trace"
orm.Debug = verbose
//verbose := conf.Server.LogLevel == "trace"
//orm.Debug = verbose
if strings.Contains(dbPath, "postgres") {
driver = "postgres"
}
err := orm.RegisterDataBase("default", driver, dbPath)
if err != nil {
panic(err)
return err
}
return orm.RunSyncdb("default", false, verbose)
}
func collectField(collection interface{}, getValue func(item interface{}) string) []string {
s := reflect.ValueOf(collection)
result := make([]string, s.Len())
for i := 0; i < s.Len(); i++ {
result[i] = getValue(s.Index(i).Interface())
}
return result
}
func getType(myvar interface{}) string {
if t := reflect.TypeOf(myvar); t.Kind() == reflect.Ptr {
return t.Elem().Name()
} else {
return t.Name()
}
}
func registerModel(model interface{}, mappedModel interface{}) {
mappedModels[getType(model)] = mappedModel
orm.RegisterModel(mappedModel)
}
func getMappedModel(model interface{}) interface{} {
return mappedModels[getType(model)]
}
func init() {
mappedModels = map[interface{}]interface{}{}
registerModel(model.Artist{}, new(artist))
registerModel(model.Album{}, new(album))
registerModel(model.MediaFile{}, new(mediaFile))
registerModel(model.Property{}, new(property))
registerModel(model.Playlist{}, new(playlist))
registerModel(model.User{}, new(user))
registerModel(model.Annotation{}, new(annotation))
orm.RegisterModel(new(search))
// TODO Remove all RegisterModels (i.e. don't use orm.Insert/Update)
orm.RegisterModel(new(annotation))
return nil
}
+50 -39
View File
@@ -7,47 +7,44 @@ import (
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/conf"
"github.com/deluan/navidrome/db"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/tests"
_ "github.com/mattn/go-sqlite3"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
func TestPersistence(t *testing.T) {
tests.Init(t, true)
//os.Remove("./test-123.db")
//conf.Server.DbPath = "./test-123.db"
conf.Server.DbPath = "file::memory:?cache=shared"
New()
db.EnsureDB()
log.SetLevel(log.LevelCritical)
RegisterFailHandler(Fail)
RunSpecs(t, "Persistence Suite")
}
var artistSaaraSaara = model.Artist{ID: "1", Name: "Saara Saara", AlbumCount: 2}
var artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk"}
var artistBeatles = model.Artist{ID: "3", Name: "The Beatles"}
var testArtists = model.Artists{
artistSaaraSaara,
artistKraftwerk,
artistBeatles,
}
var artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", AlbumCount: 1}
var artistBeatles = model.Artist{ID: "3", Name: "The Beatles", AlbumCount: 2}
var albumSgtPeppers = model.Album{ID: "1", Name: "Sgt Peppers", Artist: "The Beatles", ArtistID: "1", Genre: "Rock"}
var albumAbbeyRoad = model.Album{ID: "2", Name: "Abbey Road", Artist: "The Beatles", ArtistID: "1", Genre: "Rock"}
var albumRadioactivity = model.Album{ID: "3", Name: "Radioactivity", Artist: "Kraftwerk", ArtistID: "2", Genre: "Electronic"}
var albumSgtPeppers = model.Album{ID: "1", Name: "Sgt Peppers", Artist: "The Beatles", ArtistID: "3", Genre: "Rock", CoverArtId: "1", CoverArtPath: P("/beatles/1/sgt/a day.mp3"), SongCount: 1}
var albumAbbeyRoad = model.Album{ID: "2", Name: "Abbey Road", Artist: "The Beatles", ArtistID: "3", Genre: "Rock", CoverArtId: "2", CoverArtPath: P("/beatles/1/come together.mp3"), SongCount: 1}
var albumRadioactivity = model.Album{ID: "3", Name: "Radioactivity", Artist: "Kraftwerk", ArtistID: "2", Genre: "Electronic", CoverArtId: "3", CoverArtPath: P("/kraft/radio/radio.mp3"), SongCount: 2, Starred: true}
var testAlbums = model.Albums{
albumSgtPeppers,
albumAbbeyRoad,
albumRadioactivity,
}
var annRadioactivity = model.Annotation{AnnotationID: "1", UserID: "userid", ItemType: model.AlbumItemType, ItemID: "3", Starred: true}
var testAnnotations = []model.Annotation{
annRadioactivity,
}
var songDayInALife = model.MediaFile{ID: "1", Title: "A Day In A Life", ArtistID: "3", AlbumID: "1", Genre: "Rock", Path: P("/beatles/1/sgt/a day.mp3")}
var songComeTogether = model.MediaFile{ID: "2", Title: "Come Together", ArtistID: "3", AlbumID: "2", Genre: "Rock", Path: P("/beatles/1/come together.mp3")}
var songRadioactivity = model.MediaFile{ID: "3", Title: "Radioactivity", ArtistID: "2", AlbumID: "3", Genre: "Electronic", Path: P("/kraft/radio/radio.mp3")}
var songAntenna = model.MediaFile{ID: "4", Title: "Antenna", ArtistID: "2", AlbumID: "3", Genre: "Electronic", Path: P("/kraft/radio/antenna.mp3")}
var songDayInALife = model.MediaFile{ID: "1", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "1", Album: "Sgt Peppers", Genre: "Rock", Path: P("/beatles/1/sgt/a day.mp3")}
var songComeTogether = model.MediaFile{ID: "2", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "2", Album: "Abbey Road", Genre: "Rock", Path: P("/beatles/1/come together.mp3"), Starred: true}
var songRadioactivity = model.MediaFile{ID: "3", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "3", Album: "Radioactivity", Genre: "Electronic", Path: P("/kraft/radio/radio.mp3")}
var songAntenna = model.MediaFile{ID: "4", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "3", Genre: "Electronic", Path: P("/kraft/radio/antenna.mp3")}
var testSongs = model.MediaFiles{
songDayInALife,
songComeTogether,
@@ -55,37 +52,43 @@ var testSongs = model.MediaFiles{
songAntenna,
}
var annAlbumRadioactivity = model.Annotation{AnnotationID: "1", UserID: "userid", ItemType: model.AlbumItemType, ItemID: "3", Starred: true}
var annSongComeTogether = model.Annotation{AnnotationID: "2", UserID: "userid", ItemType: model.MediaItemType, ItemID: "2", Starred: true}
var testAnnotations = []model.Annotation{
annAlbumRadioactivity,
annSongComeTogether,
}
var (
plsBest = model.Playlist{
ID: "10",
Name: "Best",
Comment: "No Comments",
Duration: 10,
Owner: "userid",
Public: true,
Tracks: model.MediaFiles{{ID: "1"}, {ID: "3"}},
}
plsCool = model.Playlist{ID: "11", Name: "Cool", Tracks: model.MediaFiles{{ID: "4"}}}
testPlaylists = model.Playlists{plsBest, plsCool}
)
func P(path string) string {
return strings.ReplaceAll(path, "/", string(os.PathSeparator))
}
var _ = Describe("Initialize test DB", func() {
BeforeSuite(func() {
conf.Server.DbPath = ":memory:"
ds := New()
artistRepo := ds.Artist(nil)
for _, a := range testArtists {
err := artistRepo.Put(&a)
if err != nil {
panic(err)
}
}
albumRepository := ds.Album(nil)
for _, a := range testAlbums {
err := albumRepository.Put(&a)
if err != nil {
panic(err)
}
}
mediaFileRepository := ds.MediaFile(nil)
o := orm.NewOrm()
mr := NewMediaFileRepository(nil, o)
for _, s := range testSongs {
err := mediaFileRepository.Put(&s)
err := mr.Put(&s)
if err != nil {
panic(err)
}
}
o := orm.NewOrm()
for _, a := range testAnnotations {
ann := annotation(a)
_, err := o.Insert(&ann)
@@ -93,5 +96,13 @@ var _ = Describe("Initialize test DB", func() {
panic(err)
}
}
pr := NewPlaylistRepository(nil, o)
for _, pls := range testPlaylists {
err := pr.Put(&pls)
if err != nil {
panic(err)
}
}
})
})
+43 -28
View File
@@ -1,57 +1,73 @@
package persistence
import (
"context"
"strings"
. "github.com/Masterminds/squirrel"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/model"
"github.com/google/uuid"
)
type playlist struct {
ID string `orm:"pk;column(id)"`
Name string `orm:"index"`
ID string `orm:"column(id)"`
Name string
Comment string
Duration int
Owner string
Public bool
Tracks string `orm:"type(text)"`
Tracks string
}
type playlistRepository struct {
sqlRepository
}
func NewPlaylistRepository(o orm.Ormer) model.PlaylistRepository {
func NewPlaylistRepository(ctx context.Context, o orm.Ormer) model.PlaylistRepository {
r := &playlistRepository{}
r.ctx = ctx
r.ormer = o
r.tableName = "playlist"
return r
}
func (r *playlistRepository) CountAll() (int64, error) {
return r.count(Select())
}
func (r *playlistRepository) Exists(id string) (bool, error) {
return r.exists(Select().Where(Eq{"id": id}))
}
func (r *playlistRepository) Delete(id string) error {
return r.delete(Eq{"id": id})
}
func (r *playlistRepository) Put(p *model.Playlist) error {
if p.ID == "" {
id, _ := uuid.NewRandom()
p.ID = id.String()
}
tp := r.fromModel(p)
err := r.put(p.ID, &tp)
values, _ := toSqlArgs(r.fromModel(p))
update := Update(r.tableName).Where(Eq{"id": p.ID}).SetMap(values)
count, err := r.executeSQL(update)
if err != nil {
return err
}
if count > 0 {
return nil
}
insert := Insert(r.tableName).SetMap(values)
_, err = r.executeSQL(insert)
return err
}
func (r *playlistRepository) Get(id string) (*model.Playlist, error) {
tp := &playlist{ID: id}
err := r.ormer.Read(tp)
if err == orm.ErrNoRows {
return nil, model.ErrNotFound
}
if err != nil {
return nil, err
}
pls := r.toModel(tp)
sel := r.newSelect().Columns("*").Where(Eq{"id": id})
var res playlist
err := r.queryOne(sel, &res)
pls := r.toModel(&res)
return &pls, err
}
@@ -60,35 +76,34 @@ func (r *playlistRepository) GetWithTracks(id string) (*model.Playlist, error) {
if err != nil {
return nil, err
}
qs := r.ormer.QueryTable(&mediaFile{})
mfRepo := NewMediaFileRepository(r.ctx, r.ormer)
pls.Duration = 0
var newTracks model.MediaFiles
for _, t := range pls.Tracks {
mf := &mediaFile{}
if err := qs.Filter("id", t.ID).One(mf); err == nil {
pls.Duration += mf.Duration
newTracks = append(newTracks, model.MediaFile(*mf))
mf, err := mfRepo.Get(t.ID)
if err != nil {
continue
}
pls.Duration += mf.Duration
newTracks = append(newTracks, model.MediaFile(*mf))
}
pls.Tracks = newTracks
return pls, err
}
func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playlists, error) {
var all []playlist
_, err := r.newQuery(options...).All(&all)
if err != nil {
return nil, err
}
return r.toModels(all)
sel := r.newSelect(options...).Columns("*")
var res []playlist
err := r.queryAll(sel, &res)
return r.toModels(res), err
}
func (r *playlistRepository) toModels(all []playlist) (model.Playlists, error) {
func (r *playlistRepository) toModels(all []playlist) model.Playlists {
result := make(model.Playlists, len(all))
for i, p := range all {
result[i] = r.toModel(&p)
}
return result, nil
return result
}
func (r *playlistRepository) toModel(p *playlist) model.Playlist {
+78
View File
@@ -0,0 +1,78 @@
package persistence
import (
"context"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/model"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("PlaylistRepository", func() {
var repo model.PlaylistRepository
BeforeEach(func() {
repo = NewPlaylistRepository(context.Background(), orm.NewOrm())
})
Describe("Count", func() {
It("returns the number of playlists in the DB", func() {
Expect(repo.CountAll()).To(Equal(int64(2)))
})
})
Describe("Exist", func() {
It("returns true for an existing playlist", func() {
Expect(repo.Exists("11")).To(BeTrue())
})
It("returns false for a non-existing playlist", func() {
Expect(repo.Exists("666")).To(BeFalse())
})
})
Describe("Get", func() {
It("returns an existing playlist", func() {
Expect(repo.Get("10")).To(Equal(&plsBest))
})
It("returns ErrNotFound for a non-existing playlist", func() {
_, err := repo.Get("666")
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Describe("Put/Get/Delete", func() {
newPls := model.Playlist{ID: "22", Name: "Great!", Tracks: model.MediaFiles{{ID: "4"}}}
It("saves the playlist to the DB", func() {
Expect(repo.Put(&newPls)).To(BeNil())
})
It("returns the newly created playlist", func() {
Expect(repo.Get("22")).To(Equal(&newPls))
})
It("returns deletes the playlist", func() {
Expect(repo.Delete("22")).To(BeNil())
})
It("returns error if tries to retrieve the deleted playlist", func() {
_, err := repo.Get("22")
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Describe("GetWithTracks", func() {
It("returns an existing playlist", func() {
pls, err := repo.GetWithTracks("10")
Expect(err).To(BeNil())
Expect(pls.Name).To(Equal(plsBest.Name))
Expect(pls.Tracks).To(Equal(model.MediaFiles{
songDayInALife,
songRadioactivity,
}))
})
})
Describe("GetAll", func() {
It("returns all playlists from DB", func() {
Expect(repo.GetAll()).To(Equal(model.Playlists{plsBest, plsCool}))
})
})
})
+22 -15
View File
@@ -1,6 +1,9 @@
package persistence
import (
"context"
"github.com/Masterminds/squirrel"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/model"
)
@@ -14,35 +17,41 @@ type propertyRepository struct {
sqlRepository
}
func NewPropertyRepository(o orm.Ormer) model.PropertyRepository {
func NewPropertyRepository(ctx context.Context, o orm.Ormer) model.PropertyRepository {
r := &propertyRepository{}
r.ctx = ctx
r.ormer = o
r.tableName = "property"
return r
}
func (r *propertyRepository) Put(id string, value string) error {
p := &property{ID: id, Value: value}
num, err := r.ormer.Update(p)
func (r propertyRepository) Put(id string, value string) error {
update := squirrel.Update(r.tableName).Set("value", value).Where(squirrel.Eq{"id": id})
count, err := r.executeSQL(update)
if err != nil {
return nil
}
if num == 0 {
_, err = r.ormer.Insert(p)
if count > 0 {
return nil
}
insert := squirrel.Insert(r.tableName).Columns("id", "value").Values(id, value)
_, err = r.executeSQL(insert)
return err
}
func (r *propertyRepository) Get(id string) (string, error) {
p := &property{ID: id}
err := r.ormer.Read(p)
if err == orm.ErrNoRows {
return "", model.ErrNotFound
func (r propertyRepository) Get(id string) (string, error) {
sel := squirrel.Select("value").From(r.tableName).Where(squirrel.Eq{"id": id})
resp := struct {
Value string
}{}
err := r.queryOne(sel, &resp)
if err != nil {
return "", err
}
return p.Value, err
return resp.Value, nil
}
func (r *propertyRepository) DefaultGet(id string, defaultValue string) (string, error) {
func (r propertyRepository) DefaultGet(id string, defaultValue string) (string, error) {
value, err := r.Get(id)
if err == model.ErrNotFound {
return defaultValue, nil
@@ -52,5 +61,3 @@ func (r *propertyRepository) DefaultGet(id string, defaultValue string) (string,
}
return value, nil
}
var _ model.PropertyRepository = (*propertyRepository)(nil)
+15 -12
View File
@@ -1,31 +1,34 @@
package persistence
import (
"context"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/model"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("PropertyRepository", func() {
var repo model.PropertyRepository
var _ = Describe("Property Repository", func() {
var pr model.PropertyRepository
BeforeEach(func() {
repo = NewPropertyRepository(orm.NewOrm())
repo.(*propertyRepository).DeleteAll()
pr = NewPropertyRepository(context.Background(), orm.NewOrm())
})
It("saves and retrieves data", func() {
Expect(repo.Put("1", "test")).To(BeNil())
Expect(repo.Get("1")).To(Equal("test"))
It("saves and restore a new property", func() {
id := "1"
value := "a_value"
Expect(pr.Put(id, value)).To(BeNil())
Expect(pr.Get(id)).To(Equal("a_value"))
})
It("returns default if data is not found", func() {
Expect(repo.DefaultGet("2", "default")).To(Equal("default"))
It("updates a property", func() {
Expect(pr.Put("1", "another_value")).To(BeNil())
Expect(pr.Get("1")).To(Equal("another_value"))
})
It("returns value if found", func() {
Expect(repo.Put("3", "test")).To(BeNil())
Expect(repo.DefaultGet("3", "default")).To(Equal("test"))
It("returns a default value if property does not exist", func() {
Expect(pr.DefaultGet("2", "default")).To(Equal("default"))
})
})
-204
View File
@@ -1,204 +0,0 @@
package persistence
import (
"fmt"
"reflect"
"strconv"
"strings"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/model"
"github.com/deluan/rest"
"github.com/google/uuid"
)
type resourceRepository struct {
model.ResourceRepository
model interface{}
mappedModel interface{}
ormer orm.Ormer
instanceType reflect.Type
sliceType reflect.Type
}
func NewResource(o orm.Ormer, model interface{}, mappedModel interface{}) model.ResourceRepository {
r := &resourceRepository{model: model, mappedModel: mappedModel, ormer: o}
// Get type of mappedModel (which is a *struct)
rv := reflect.ValueOf(mappedModel)
for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
rv = rv.Elem()
}
r.instanceType = rv.Type()
r.sliceType = reflect.SliceOf(r.instanceType)
return r
}
func (r *resourceRepository) EntityName() string {
return r.instanceType.Name()
}
func (r *resourceRepository) newQuery(options ...rest.QueryOptions) orm.QuerySeter {
qs := r.ormer.QueryTable(r.mappedModel)
if len(options) > 0 {
qs = r.addOptions(qs, options)
qs = r.addFilters(qs, r.buildFilters(qs, options))
}
return qs
}
func (r *resourceRepository) NewInstance() interface{} {
return reflect.New(r.instanceType).Interface()
}
func (r *resourceRepository) NewSlice() interface{} {
slice := reflect.MakeSlice(r.sliceType, 0, 0)
x := reflect.New(slice.Type())
x.Elem().Set(slice)
return x.Interface()
}
func (r *resourceRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
qs := r.newQuery(options...)
dataSet := r.NewSlice()
_, err := qs.All(dataSet)
if err == orm.ErrNoRows {
return dataSet, rest.ErrNotFound
}
return dataSet, err
}
func (r *resourceRepository) Count(options ...rest.QueryOptions) (int64, error) {
qs := r.newQuery(options...)
count, err := qs.Count()
if err == orm.ErrNoRows {
err = rest.ErrNotFound
}
return count, err
}
func (r *resourceRepository) Read(id string) (interface{}, error) {
qs := r.newQuery().Filter("id", id)
data := r.NewInstance()
err := qs.One(data)
if err == orm.ErrNoRows {
return data, rest.ErrNotFound
}
return data, err
}
func setUUID(p interface{}) {
f := reflect.ValueOf(p).Elem().FieldByName("ID")
if f.Kind() == reflect.String {
id, _ := uuid.NewRandom()
f.SetString(id.String())
}
}
func (r *resourceRepository) Save(p interface{}) (string, error) {
setUUID(p)
id, err := r.ormer.Insert(p)
if err != nil {
if err.Error() != "LastInsertId is not supported by this driver" {
return "", err
}
}
return strconv.FormatInt(id, 10), nil
}
func (r *resourceRepository) Update(p interface{}, cols ...string) error {
count, err := r.ormer.Update(p, cols...)
if err != nil {
return err
}
if count == 0 {
return rest.ErrNotFound
}
return err
}
func (r *resourceRepository) addOptions(qs orm.QuerySeter, options []rest.QueryOptions) orm.QuerySeter {
if len(options) == 0 {
return qs
}
opt := options[0]
sort := strings.Split(opt.Sort, ",")
reverse := strings.ToLower(opt.Order) == "desc"
for i, s := range sort {
s = strings.TrimSpace(s)
if reverse {
if s[0] == '-' {
s = strings.TrimPrefix(s, "-")
} else {
s = "-" + s
}
}
sort[i] = strings.Replace(s, ".", "__", -1)
}
if opt.Sort != "" {
qs = qs.OrderBy(sort...)
}
if opt.Max > 0 {
qs = qs.Limit(opt.Max)
}
if opt.Offset > 0 {
qs = qs.Offset(opt.Offset)
}
return qs
}
func (r *resourceRepository) addFilters(qs orm.QuerySeter, conditions ...*orm.Condition) orm.QuerySeter {
var cond *orm.Condition
for _, c := range conditions {
if c != nil {
if cond == nil {
cond = c
} else {
cond = cond.AndCond(c)
}
}
}
if cond != nil {
return qs.SetCond(cond)
}
return qs
}
func unmarshalValue(val interface{}) string {
switch v := val.(type) {
case float64:
return strconv.FormatFloat(v, 'f', -1, 64)
case string:
return v
default:
return fmt.Sprintf("%v", val)
}
}
func (r *resourceRepository) buildFilters(qs orm.QuerySeter, options []rest.QueryOptions) *orm.Condition {
if len(options) == 0 {
return nil
}
cond := orm.NewCondition()
clauses := cond
for f, v := range options[0].Filters {
fn := strings.Replace(f, ".", "__", -1)
s := unmarshalValue(v)
if strings.HasSuffix(fn, "Id") || strings.HasSuffix(fn, "__id") {
clauses = IdFilter(clauses, fn, s)
} else {
clauses = StartsWithFilter(clauses, fn, s)
}
}
return clauses
}
func IdFilter(cond *orm.Condition, field, value string) *orm.Condition {
field = strings.TrimSuffix(field, "Id") + "__id"
return cond.And(field, value)
}
func StartsWithFilter(cond *orm.Condition, field, value string) *orm.Condition {
return cond.And(field+"__istartswith", value)
}
-111
View File
@@ -1,111 +0,0 @@
package persistence
import (
"strings"
"github.com/Masterminds/squirrel"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/log"
"github.com/kennygrant/sanitize"
)
type search struct {
ID string `orm:"pk;column(id)"`
Table string `orm:"index"`
FullText string `orm:"index"`
}
type searchableRepository struct {
sqlRepository
}
func (r *searchableRepository) DeleteAll() error {
_, err := r.newQuery().Filter("id__isnull", false).Delete()
if err != nil {
return err
}
return r.removeAllFromIndex(r.ormer, r.tableName)
}
func (r *searchableRepository) put(id string, textToIndex string, a interface{}, fields ...string) error {
c, err := r.newQuery().Filter("id", id).Count()
if err != nil {
return err
}
if c == 0 {
err = r.insert(a)
if err != nil && err.Error() == "LastInsertId is not supported by this driver" {
err = nil
}
} else {
_, err = r.ormer.Update(a, fields...)
}
if err != nil {
return err
}
return r.addToIndex(r.tableName, id, textToIndex)
}
func (r *searchableRepository) addToIndex(table, id, text string) error {
item := search{ID: id, Table: table}
err := r.ormer.Read(&item)
if err != nil && err != orm.ErrNoRows {
return err
}
sanitizedText := strings.TrimSpace(sanitize.Accents(strings.ToLower(text)))
item = search{ID: id, Table: table, FullText: sanitizedText}
if err == orm.ErrNoRows {
err = r.insert(&item)
} else {
_, err = r.ormer.Update(&item)
}
return err
}
func (r *searchableRepository) removeFromIndex(table string, ids []string) error {
var offset int
for {
var subset = paginateSlice(ids, offset, batchSize)
if len(subset) == 0 {
break
}
log.Trace("Deleting searchable items", "table", table, "num", len(subset), "from", offset)
offset += len(subset)
_, err := r.ormer.QueryTable(&search{}).Filter("table", table).Filter("id__in", subset).Delete()
if err != nil {
return err
}
}
return nil
}
func (r *searchableRepository) removeAllFromIndex(o orm.Ormer, table string) error {
_, err := o.QueryTable(&search{}).Filter("table", table).Delete()
return err
}
func (r *searchableRepository) doSearch(table string, q string, offset, size int, results interface{}, orderBys ...string) error {
q = strings.TrimSpace(sanitize.Accents(strings.ToLower(strings.TrimSuffix(q, "*"))))
if len(q) <= 2 {
return nil
}
sq := squirrel.Select("*").From(table)
sq = sq.Limit(uint64(size)).Offset(uint64(offset))
if len(orderBys) > 0 {
sq = sq.OrderBy(orderBys...)
}
sq = sq.Join("search").Where("search.id = " + table + ".id")
parts := strings.Split(q, " ")
for _, part := range parts {
sq = sq.Where(squirrel.Or{
squirrel.Like{"full_text": part + "%"},
squirrel.Like{"full_text": "%" + part + "%"},
})
}
sql, args, err := sq.ToSql()
if err != nil {
return err
}
_, err = r.ormer.Raw(sql, args...).QueryRows(results)
return err
}
+116 -80
View File
@@ -1,40 +1,51 @@
package persistence
import (
"github.com/Masterminds/squirrel"
"context"
"fmt"
"strings"
. "github.com/Masterminds/squirrel"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/rest"
)
type sqlRepository struct {
tableName string
ormer orm.Ormer
ctx context.Context
tableName string
fieldNames []string
ormer orm.Ormer
}
func (r *sqlRepository) newQuery(options ...model.QueryOptions) orm.QuerySeter {
q := r.ormer.QueryTable(r.tableName)
if len(options) > 0 {
opts := options[0]
q = q.Offset(opts.Offset)
if opts.Max > 0 {
q = q.Limit(opts.Max)
}
if opts.Sort != "" {
if opts.Order == "desc" {
q = q.OrderBy("-" + opts.Sort)
} else {
q = q.OrderBy(opts.Sort)
}
}
for field, value := range opts.Filters {
q = q.Filter(field, value)
}
const invalidUserId = "-1"
func userId(ctx context.Context) string {
user := ctx.Value("user")
if user == nil {
return invalidUserId
}
return q
usr := user.(*model.User)
return usr.ID
}
func (r *sqlRepository) newRawQuery(options ...model.QueryOptions) squirrel.SelectBuilder {
sq := squirrel.Select("*").From(r.tableName)
func (r *sqlRepository) newSelectWithAnnotation(itemType, idField string, options ...model.QueryOptions) SelectBuilder {
return r.newSelect(options...).
LeftJoin("annotation on ("+
"annotation.item_id = "+idField+
" AND annotation.item_type = '"+itemType+"'"+
" AND annotation.user_id = '"+userId(r.ctx)+"')").
Columns("starred", "starred_at", "play_count", "play_date", "rating")
}
func (r *sqlRepository) newSelect(options ...model.QueryOptions) SelectBuilder {
sq := Select().From(r.tableName)
sq = r.applyOptions(sq, options...)
return sq
}
func (r *sqlRepository) applyOptions(sq SelectBuilder, options ...model.QueryOptions) SelectBuilder {
if len(options) > 0 {
if options[0].Max > 0 {
sq = sq.Limit(uint64(options[0].Max))
@@ -49,83 +60,108 @@ func (r *sqlRepository) newRawQuery(options ...model.QueryOptions) squirrel.Sele
sq = sq.OrderBy(options[0].Sort)
}
}
if len(options[0].Filters) > 0 {
for f, v := range options[0].Filters {
sq = sq.Where(Eq{f: v})
}
}
}
return sq
}
func (r *sqlRepository) CountAll() (int64, error) {
return r.newQuery().Count()
}
func (r *sqlRepository) Exists(id string) (bool, error) {
c, err := r.newQuery().Filter("id", id).Count()
return c == 1, err
}
// "Hack" to bypass Postgres driver limitation
func (r *sqlRepository) insert(record interface{}) error {
_, err := r.ormer.Insert(record)
if err != nil && err.Error() != "LastInsertId is not supported by this driver" {
return err
func (r sqlRepository) executeSQL(sq Sqlizer) (int64, error) {
query, args, err := r.toSql(sq)
if err != nil {
return 0, err
}
return nil
res, err := r.ormer.Raw(query, args...).Exec()
if err != nil {
if err.Error() != "LastInsertId is not supported by this driver" {
return 0, err
}
}
return res.RowsAffected()
}
func (r *sqlRepository) put(id string, a interface{}) error {
c, err := r.newQuery().Filter("id", id).Count()
func (r sqlRepository) queryOne(sq Sqlizer, response interface{}) error {
query, args, err := r.toSql(sq)
if err != nil {
return err
}
if c == 0 {
err = r.insert(a)
if err != nil && err.Error() == "LastInsertId is not supported by this driver" {
err = nil
}
err = r.ormer.Raw(query, args...).QueryRow(response)
if err == orm.ErrNoRows {
return model.ErrNotFound
}
return err
}
func (r sqlRepository) queryAll(sq Sqlizer, response interface{}) error {
query, args, err := r.toSql(sq)
if err != nil {
return err
}
_, err = r.ormer.Update(a)
_, err = r.ormer.Raw(query, args...).QueryRows(response)
if err == orm.ErrNoRows {
return model.ErrNotFound
}
return err
}
func paginateSlice(slice []string, skip int, size int) []string {
if skip > len(slice) {
skip = len(slice)
func (r sqlRepository) exists(existsQuery SelectBuilder) (bool, error) {
existsQuery = existsQuery.Columns("count(*) as count").From(r.tableName)
query, args, err := r.toSql(existsQuery)
if err != nil {
return false, err
}
end := skip + size
if end > len(slice) {
end = len(slice)
}
return slice[skip:end]
var res struct{ Count int64 }
err = r.ormer.Raw(query, args...).QueryRow(&res)
return res.Count > 0, err
}
func difference(slice1 []string, slice2 []string) []string {
var diffStr []string
m := map[string]int{}
for _, s1Val := range slice1 {
m[s1Val] = 1
func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOptions) (int64, error) {
countQuery = countQuery.Columns("count(*) as count").From(r.tableName)
countQuery = r.applyOptions(countQuery, options...)
query, args, err := r.toSql(countQuery)
if err != nil {
return 0, err
}
for _, s2Val := range slice2 {
m[s2Val] = m[s2Val] + 1
var res struct{ Count int64 }
err = r.ormer.Raw(query, args...).QueryRow(&res)
if err == orm.ErrNoRows {
return 0, model.ErrNotFound
}
return res.Count, nil
}
for mKey, mVal := range m {
if mVal == 1 {
diffStr = append(diffStr, mKey)
func (r sqlRepository) delete(cond Sqlizer) error {
del := Delete(r.tableName).Where(cond)
_, err := r.executeSQL(del)
if err == orm.ErrNoRows {
return model.ErrNotFound
}
return err
}
func (r sqlRepository) toSql(sq Sqlizer) (string, []interface{}, error) {
sql, args, err := sq.ToSql()
if err == nil {
log.Trace(r.ctx, "SQL: `"+sql+"`", "args", strings.TrimPrefix(fmt.Sprintf("%#v", args), "[]interface {}"))
}
return sql, args, err
}
func (r sqlRepository) parseRestOptions(options ...rest.QueryOptions) model.QueryOptions {
qo := model.QueryOptions{}
if len(options) > 0 {
qo.Sort = toSnakeCase(options[0].Sort)
qo.Order = options[0].Order
qo.Max = options[0].Max
qo.Offset = options[0].Offset
if len(options[0].Filters) > 0 {
for f, v := range options[0].Filters {
qo.Filters = Like{f: fmt.Sprintf("%s%%", v)}
}
}
}
return diffStr
}
func (r *sqlRepository) Delete(id string) error {
_, err := r.newQuery().Filter("id", id).Delete()
return err
}
func (r *sqlRepository) DeleteAll() error {
_, err := r.newQuery().Filter("id__isnull", false).Delete()
return err
return qo
}
+95 -50
View File
@@ -1,92 +1,137 @@
package persistence
import (
"context"
"strings"
"time"
. "github.com/Masterminds/squirrel"
"github.com/astaxie/beego/orm"
"github.com/deluan/navidrome/model"
"github.com/deluan/rest"
"github.com/google/uuid"
)
type user struct {
ID string `json:"id" orm:"pk;column(id)"`
UserName string `json:"userName" orm:"index;unique"`
Name string `json:"name"`
Email string `json:"email" orm:"unique"`
Password string `json:"password"`
IsAdmin bool `json:"isAdmin"`
LastLoginAt *time.Time `json:"lastLoginAt" orm:"null"`
LastAccessAt *time.Time `json:"lastAccessAt" orm:"null"`
CreatedAt time.Time `json:"createdAt" orm:"auto_now_add;type(datetime)"`
UpdatedAt time.Time `json:"updatedAt" orm:"auto_now;type(datetime)"`
}
type userRepository struct {
ormer orm.Ormer
userResource model.ResourceRepository
sqlRepository
}
func NewUserRepository(o orm.Ormer) model.UserRepository {
r := &userRepository{ormer: o}
r.userResource = NewResource(o, model.User{}, new(user))
func NewUserRepository(ctx context.Context, o orm.Ormer) model.UserRepository {
r := &userRepository{}
r.ctx = ctx
r.ormer = o
r.tableName = "user"
return r
}
func (r *userRepository) CountAll(qo ...model.QueryOptions) (int64, error) {
if len(qo) > 0 {
return r.userResource.Count(rest.QueryOptions(qo[0]))
}
return r.userResource.Count()
return r.count(Select(), qo...)
}
func (r *userRepository) Get(id string) (*model.User, error) {
u, err := r.userResource.Read(id)
if err != nil {
return nil, err
}
res := model.User(u.(user))
return &res, nil
sel := r.newSelect().Columns("*").Where(Eq{"id": id})
var res model.User
err := r.queryOne(sel, &res)
return &res, err
}
func (r *userRepository) GetAll(options ...model.QueryOptions) (model.Users, error) {
sel := r.newSelect(options...).Columns("*")
var res model.Users
err := r.queryAll(sel, &res)
return res, err
}
func (r *userRepository) Put(u *model.User) error {
tu := user(*u)
c, err := r.CountAll()
if u.ID == "" {
id, _ := uuid.NewRandom()
u.ID = id.String()
}
u.UserName = strings.ToLower(u.UserName)
values, _ := toSqlArgs(*u)
update := Update(r.tableName).Where(Eq{"id": u.ID}).SetMap(values)
count, err := r.executeSQL(update)
if err != nil {
return err
}
if c == 0 {
_, err = r.userResource.Save(&tu)
return err
if count > 0 {
return nil
}
return r.userResource.Update(&tu, "user_name", "is_admin", "password")
insert := Insert(r.tableName).SetMap(values)
_, err = r.executeSQL(insert)
return err
}
func (r *userRepository) FindByUsername(username string) (*model.User, error) {
tu := user{}
err := r.ormer.QueryTable(user{}).Filter("user_name__iexact", username).One(&tu)
if err == orm.ErrNoRows {
return nil, model.ErrNotFound
}
if err != nil {
return nil, err
}
u := model.User(tu)
return &u, err
sel := r.newSelect().Columns("*").Where(Eq{"user_name": username})
var usr model.User
err := r.queryOne(sel, &usr)
return &usr, err
}
func (r *userRepository) UpdateLastLoginAt(id string) error {
now := time.Now()
tu := user{ID: id, LastLoginAt: &now}
_, err := r.ormer.Update(&tu, "last_login_at")
upd := Update(r.tableName).Where(Eq{"id": id}).Set("last_login_at", time.Now())
_, err := r.executeSQL(upd)
return err
}
func (r *userRepository) UpdateLastAccessAt(id string) error {
now := time.Now()
tu := user{ID: id, LastAccessAt: &now}
_, err := r.ormer.Update(&tu, "last_access_at")
upd := Update(r.tableName).Where(Eq{"id": id}).Set("last_access_at", now)
_, err := r.executeSQL(upd)
return err
}
func (r *userRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.CountAll(r.parseRestOptions(options...))
}
func (r *userRepository) Read(id string) (interface{}, error) {
usr, err := r.Get(id)
if err == model.ErrNotFound {
return nil, rest.ErrNotFound
}
return usr, err
}
func (r *userRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
return r.GetAll(r.parseRestOptions(options...))
}
func (r *userRepository) EntityName() string {
return "user"
}
func (r *userRepository) NewInstance() interface{} {
return &model.User{}
}
func (r *userRepository) Save(entity interface{}) (string, error) {
usr := entity.(*model.User)
err := r.Put(usr)
if err != nil {
return "", err
}
return usr.ID, err
}
func (r *userRepository) Update(entity interface{}, cols ...string) error {
usr := entity.(*model.User)
err := r.Put(usr)
if err == model.ErrNotFound {
return rest.ErrNotFound
}
return err
}
func (r *userRepository) Delete(id string) error {
err := r.Delete(id)
if err == model.ErrNotFound {
return rest.ErrNotFound
}
return err
}
var _ = model.User(user{})
var _ model.UserRepository = (*userRepository)(nil)
var _ rest.Repository = (*userRepository)(nil)
var _ rest.Persistable = (*userRepository)(nil)