From a736d7998545a9eaebae28310392309f47a4b4a7 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 6 Jun 2025 14:06:59 -0400 Subject: [PATCH] feat(database): enhance user retrieval with library associations Signed-off-by: Deluan --- persistence/user_repository.go | 67 ++++++++++++++--- persistence/user_repository_test.go | 112 ++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 12 deletions(-) diff --git a/persistence/user_repository.go b/persistence/user_repository.go index dcf3f08b7..a378f75e6 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -3,6 +3,7 @@ package persistence import ( "context" "crypto/sha256" + "encoding/json" "errors" "fmt" "strings" @@ -17,6 +18,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/slice" "github.com/pocketbase/dbx" ) @@ -24,6 +26,26 @@ type userRepository struct { sqlRepository } +type dbUser struct { + *model.User `structs:",flatten"` + LibrariesJSON string `structs:"-" json:"-"` +} + +func (u *dbUser) PostScan() error { + if u.LibrariesJSON != "" { + if err := json.Unmarshal([]byte(u.LibrariesJSON), &u.User.Libraries); err != nil { + return fmt.Errorf("parsing user libraries from db: %w", err) + } + } + return nil +} + +type dbUsers []dbUser + +func (us dbUsers) toModels() model.Users { + return slice.Map(us, func(u dbUser) model.User { return *u.User }) +} + var ( once sync.Once encKey []byte @@ -33,6 +55,7 @@ func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository r := &userRepository{} r.ctx = ctx r.db = db + r.tableName = "user" r.registerModel(&model.User{}, map[string]filterFunc{ "password": invalidFilter(ctx), }) @@ -42,28 +65,48 @@ func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository return r } +// selectUserWithLibraries returns a SelectBuilder that includes library information +func (r *userRepository) selectUserWithLibraries(options ...model.QueryOptions) SelectBuilder { + return r.newSelect(options...). + Columns(`user.*`, + `COALESCE(json_group_array(json_object( + 'id', library.id, + 'name', library.name, + 'path', library.path, + 'remote_path', library.remote_path, + 'last_scan_at', library.last_scan_at, + 'last_scan_started_at', library.last_scan_started_at, + 'full_scan_in_progress', library.full_scan_in_progress, + 'updated_at', library.updated_at, + 'created_at', library.created_at + )) FILTER (WHERE library.id IS NOT NULL), '[]') AS libraries_json`). + LeftJoin("user_library ul ON user.id = ul.user_id"). + LeftJoin("library ON ul.library_id = library.id"). + GroupBy("user.id") +} + func (r *userRepository) CountAll(qo ...model.QueryOptions) (int64, error) { return r.count(Select(), qo...) } func (r *userRepository) Get(id string) (*model.User, error) { - sel := r.newSelect().Columns("*").Where(Eq{"id": id}) - var res model.User + sel := r.selectUserWithLibraries().Where(Eq{"user.id": id}) + var res dbUser err := r.queryOne(sel, &res) if err != nil { return nil, err } - return &res, nil + return res.User, nil } func (r *userRepository) GetAll(options ...model.QueryOptions) (model.Users, error) { - sel := r.newSelect(options...).Columns("*") - res := model.Users{} + sel := r.selectUserWithLibraries(options...) + var res dbUsers err := r.queryAll(sel, &res) if err != nil { return nil, err } - return res, nil + return res.toModels(), nil } func (r *userRepository) Put(u *model.User) error { @@ -110,23 +153,23 @@ func (r *userRepository) Put(u *model.User) error { } func (r *userRepository) FindFirstAdmin() (*model.User, error) { - sel := r.newSelect(model.QueryOptions{Sort: "updated_at", Max: 1}).Columns("*").Where(Eq{"is_admin": true}) - var usr model.User + sel := r.selectUserWithLibraries(model.QueryOptions{Sort: "updated_at", Max: 1}).Where(Eq{"user.is_admin": true}) + var usr dbUser err := r.queryOne(sel, &usr) if err != nil { return nil, err } - return &usr, nil + return usr.User, nil } func (r *userRepository) FindByUsername(username string) (*model.User, error) { - sel := r.newSelect().Columns("*").Where(Expr("user_name = ? COLLATE NOCASE", username)) - var usr model.User + sel := r.selectUserWithLibraries().Where(Expr("user.user_name = ? COLLATE NOCASE", username)) + var usr dbUser err := r.queryOne(sel, &usr) if err != nil { return nil, err } - return &usr, nil + return usr.User, nil } func (r *userRepository) FindByUsernameWithPassword(username string) (*model.User, error) { diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go index 2826084c4..b768930e5 100644 --- a/persistence/user_repository_test.go +++ b/persistence/user_repository_test.go @@ -492,4 +492,116 @@ var _ = Describe("UserRepository", func() { Expect(libraries).To(HaveLen(0)) }) }) + + Describe("Libraries Field Population", func() { + var ( + libRepo model.LibraryRepository + library1 model.Library + library2 model.Library + testUser model.User + ) + + BeforeEach(func() { + libRepo = NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder()) + library1 = model.Library{ID: 301, Name: "Field Test Library 1", Path: "/field/test/path1"} + library2 = model.Library{ID: 302, Name: "Field Test Library 2", Path: "/field/test/path2"} + + // Create test libraries + Expect(libRepo.Put(&library1)).To(BeNil()) + Expect(libRepo.Put(&library2)).To(BeNil()) + + // Create test user + testUser = model.User{ + ID: "field-test-user", + UserName: "fieldtestuser", + Name: "Field Test User", + Email: "fieldtest@example.com", + NewPassword: "password", + IsAdmin: false, + } + Expect(repo.Put(&testUser)).To(BeNil()) + + // Assign libraries to user + Expect(repo.SetUserLibraries(testUser.ID, []int{library1.ID, library2.ID})).To(BeNil()) + }) + + AfterEach(func() { + // Clean up test libraries and their associations + _ = libRepo.(*libraryRepository).delete(squirrel.Eq{"id": []int{301, 302}}) + _ = repo.(*userRepository).delete(squirrel.Eq{"id": testUser.ID}) + + // Clean up user-library associations for these test libraries + _, _ = repo.(*userRepository).executeSQL(squirrel.Delete("user_library").Where(squirrel.Eq{"library_id": []int{301, 302}})) + }) + + It("populates Libraries field when getting a single user", func() { + user, err := repo.Get(testUser.ID) + Expect(err).To(BeNil()) + Expect(user.Libraries).To(HaveLen(2)) + + libIDs := []int{user.Libraries[0].ID, user.Libraries[1].ID} + Expect(libIDs).To(ContainElements(library1.ID, library2.ID)) + + // Check that library details are properly populated + for _, lib := range user.Libraries { + if lib.ID == library1.ID { + Expect(lib.Name).To(Equal("Field Test Library 1")) + Expect(lib.Path).To(Equal("/field/test/path1")) + } else if lib.ID == library2.ID { + Expect(lib.Name).To(Equal("Field Test Library 2")) + Expect(lib.Path).To(Equal("/field/test/path2")) + } + } + }) + + It("populates Libraries field when getting all users", func() { + users, err := repo.(*userRepository).GetAll() + Expect(err).To(BeNil()) + + // Find our test user in the results + var foundUser *model.User + for i := range users { + if users[i].ID == testUser.ID { + foundUser = &users[i] + break + } + } + + Expect(foundUser).ToNot(BeNil()) + Expect(foundUser.Libraries).To(HaveLen(2)) + + libIDs := []int{foundUser.Libraries[0].ID, foundUser.Libraries[1].ID} + Expect(libIDs).To(ContainElements(library1.ID, library2.ID)) + }) + + It("populates Libraries field when finding user by username", func() { + user, err := repo.FindByUsername(testUser.UserName) + Expect(err).To(BeNil()) + Expect(user.Libraries).To(HaveLen(2)) + + libIDs := []int{user.Libraries[0].ID, user.Libraries[1].ID} + Expect(libIDs).To(ContainElements(library1.ID, library2.ID)) + }) + + It("returns empty Libraries array when user has no library associations", func() { + // Create a user with no library associations + userWithoutLibs := model.User{ + ID: "no-libs-user", + UserName: "nolibsuser", + Name: "No Libs User", + Email: "nolibs@example.com", + NewPassword: "password", + IsAdmin: false, + } + Expect(repo.Put(&userWithoutLibs)).To(BeNil()) + defer func() { + _ = repo.(*userRepository).delete(squirrel.Eq{"id": userWithoutLibs.ID}) + }() + + user, err := repo.Get(userWithoutLibs.ID) + Expect(err).To(BeNil()) + Expect(user.Libraries).ToNot(BeNil()) + Expect(user.Libraries).To(HaveLen(0)) + }) + }) })