refactor(db): migrate methods off `user.go` (#7336)

pull/7338/head
Joe Chen 2023-02-05 16:28:47 +08:00 committed by GitHub
parent 3c43b9b21c
commit 7ff09cf359
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 410 additions and 55 deletions

View File

@ -720,6 +720,10 @@ func actionsNewRepo(t *testing.T, db *actions) {
func actionsPushTag(t *testing.T, db *actions) {
ctx := context.Background()
// NOTE: We set a noop mock here to avoid data race with other tests that writes
// to the mock server because this function holds a lock.
conf.SetMockServer(t, conf.ServerOpts{})
alice, err := NewUsersStore(db.DB).Create(ctx, "alice", "alice@example.com", CreateUserOptions{})
require.NoError(t, err)
repo, err := NewReposStore(db.DB).Create(ctx,

View File

@ -127,7 +127,7 @@ func Init(w logger.Writer) (*gorm.DB, error) {
LFS = &lfs{DB: db}
Orgs = NewOrgsStore(db)
OrgUsers = NewOrgUsersStore(db)
Perms = &perms{DB: db}
Perms = NewPermsStore(db)
Repos = NewReposStore(db)
TwoFactors = &twoFactors{DB: db}
Users = NewUsersStore(db)

View File

@ -82,6 +82,12 @@ type perms struct {
*gorm.DB
}
// NewPermsStore returns a persistent interface for permissions with given
// database connection.
func NewPermsStore(db *gorm.DB) PermsStore {
return &perms{DB: db}
}
type AccessModeOptions struct {
OwnerID int64 // The ID of the repository owner.
Private bool // Whether the repository is private.

View File

@ -26,6 +26,16 @@ type ReposStore interface {
// ErrRepoAlreadyExist when a repository with same name already exists for the
// owner.
Create(ctx context.Context, ownerID int64, opts CreateRepoOptions) (*Repository, error)
// GetByCollaboratorID returns a list of repositories that the given
// collaborator has access to. Results are limited to the given limit and sorted
// by the given order (e.g. "updated_unix DESC"). Repositories that are owned
// directly by the given collaborator are not included.
GetByCollaboratorID(ctx context.Context, collaboratorID int64, limit int, orderBy string) ([]*Repository, error)
// GetByCollaboratorIDWithAccessMode returns a list of repositories and
// corresponding access mode that the given collaborator has access to.
// Repositories that are owned directly by the given collaborator are not
// included.
GetByCollaboratorIDWithAccessMode(ctx context.Context, collaboratorID int64) (map[*Repository]AccessMode, error)
// GetByName returns the repository with given owner and name. It returns
// ErrRepoNotExist when not found.
GetByName(ctx context.Context, ownerID int64, name string) (*Repository, error)
@ -170,6 +180,59 @@ func (db *repos) Create(ctx context.Context, ownerID int64, opts CreateRepoOptio
return repo, db.WithContext(ctx).Create(repo).Error
}
func (db *repos) GetByCollaboratorID(ctx context.Context, collaboratorID int64, limit int, orderBy string) ([]*Repository, error) {
/*
Equivalent SQL for PostgreSQL:
SELECT * FROM repository
JOIN access ON access.repo_id = repository.id AND access.user_id = @collaboratorID
WHERE access.mode >= @accessModeRead
ORDER BY @orderBy
LIMIT @limit
*/
var repos []*Repository
return repos, db.WithContext(ctx).
Joins("JOIN access ON access.repo_id = repository.id AND access.user_id = ?", collaboratorID).
Where("access.mode >= ?", AccessModeRead).
Order(orderBy).
Limit(limit).
Find(&repos).
Error
}
func (db *repos) GetByCollaboratorIDWithAccessMode(ctx context.Context, collaboratorID int64) (map[*Repository]AccessMode, error) {
/*
Equivalent SQL for PostgreSQL:
SELECT
repository.*,
access.mode
FROM repository
JOIN access ON access.repo_id = repository.id AND access.user_id = @collaboratorID
WHERE access.mode >= @accessModeRead
*/
var reposWithAccessMode []*struct {
*Repository
Mode AccessMode
}
err := db.WithContext(ctx).
Select("repository.*", "access.mode").
Table("repository").
Joins("JOIN access ON access.repo_id = repository.id AND access.user_id = ?", collaboratorID).
Where("access.mode >= ?", AccessModeRead).
Find(&reposWithAccessMode).
Error
if err != nil {
return nil, err
}
repos := make(map[*Repository]AccessMode, len(reposWithAccessMode))
for _, repoWithAccessMode := range reposWithAccessMode {
repos[repoWithAccessMode.Repository] = repoWithAccessMode.Mode
}
return repos, nil
}
var _ errutil.NotFound = (*ErrRepoNotExist)(nil)
type ErrRepoNotExist struct {

View File

@ -85,7 +85,7 @@ func TestRepos(t *testing.T) {
}
t.Parallel()
tables := []any{new(Repository)}
tables := []any{new(Repository), new(Access)}
db := &repos{
DB: dbtest.NewDB(t, "repos", tables...),
}
@ -95,6 +95,8 @@ func TestRepos(t *testing.T) {
test func(t *testing.T, db *repos)
}{
{"Create", reposCreate},
{"GetByCollaboratorID", reposGetByCollaboratorID},
{"GetByCollaboratorIDWithAccessMode", reposGetByCollaboratorIDWithAccessMode},
{"GetByName", reposGetByName},
{"Touch", reposTouch},
} {
@ -154,6 +156,64 @@ func reposCreate(t *testing.T, db *repos) {
assert.Equal(t, db.NowFunc().Format(time.RFC3339), repo.Created.UTC().Format(time.RFC3339))
}
func reposGetByCollaboratorID(t *testing.T, db *repos) {
ctx := context.Background()
repo1, err := db.Create(ctx, 1, CreateRepoOptions{Name: "repo1"})
require.NoError(t, err)
repo2, err := db.Create(ctx, 2, CreateRepoOptions{Name: "repo2"})
require.NoError(t, err)
permsStore := NewPermsStore(db.DB)
err = permsStore.SetRepoPerms(ctx, repo1.ID, map[int64]AccessMode{3: AccessModeRead})
require.NoError(t, err)
err = permsStore.SetRepoPerms(ctx, repo2.ID, map[int64]AccessMode{4: AccessModeAdmin})
require.NoError(t, err)
t.Run("user 3 is a collaborator of repo1", func(t *testing.T) {
got, err := db.GetByCollaboratorID(ctx, 3, 10, "")
require.NoError(t, err)
require.Len(t, got, 1)
assert.Equal(t, repo1.ID, got[0].ID)
})
t.Run("do not return directly owned repository", func(t *testing.T) {
got, err := db.GetByCollaboratorID(ctx, 1, 10, "")
require.NoError(t, err)
require.Len(t, got, 0)
})
}
func reposGetByCollaboratorIDWithAccessMode(t *testing.T, db *repos) {
ctx := context.Background()
repo1, err := db.Create(ctx, 1, CreateRepoOptions{Name: "repo1"})
require.NoError(t, err)
repo2, err := db.Create(ctx, 2, CreateRepoOptions{Name: "repo2"})
require.NoError(t, err)
repo3, err := db.Create(ctx, 2, CreateRepoOptions{Name: "repo3"})
require.NoError(t, err)
permsStore := NewPermsStore(db.DB)
err = permsStore.SetRepoPerms(ctx, repo1.ID, map[int64]AccessMode{3: AccessModeRead})
require.NoError(t, err)
err = permsStore.SetRepoPerms(ctx, repo2.ID, map[int64]AccessMode{3: AccessModeAdmin, 4: AccessModeWrite})
require.NoError(t, err)
err = permsStore.SetRepoPerms(ctx, repo3.ID, map[int64]AccessMode{4: AccessModeWrite})
require.NoError(t, err)
got, err := db.GetByCollaboratorIDWithAccessMode(ctx, 3)
require.NoError(t, err)
require.Len(t, got, 2)
accessModes := make(map[int64]AccessMode)
for repo, mode := range got {
accessModes[repo.ID] = mode
}
assert.Equal(t, AccessModeRead, accessModes[repo1.ID])
assert.Equal(t, AccessModeAdmin, accessModes[repo2.ID])
}
func reposGetByName(t *testing.T, db *repos) {
ctx := context.Background()

View File

@ -10,7 +10,6 @@ import (
"os"
"time"
log "unknwon.dev/clog/v2"
"xorm.io/xorm"
"gogs.io/gogs/internal/repoutil"
@ -196,41 +195,3 @@ func DeleteInactivateUsers() (err error) {
_, err = x.Where("is_activated = ?", false).Delete(new(EmailAddress))
return err
}
// GetRepositoryAccesses finds all repositories with their access mode where a user has access but does not own.
func (u *User) GetRepositoryAccesses() (map[*Repository]AccessMode, error) {
accesses := make([]*Access, 0, 10)
if err := x.Find(&accesses, &Access{UserID: u.ID}); err != nil {
return nil, err
}
repos := make(map[*Repository]AccessMode, len(accesses))
for _, access := range accesses {
repo, err := GetRepositoryByID(access.RepoID)
if err != nil {
if IsErrRepoNotExist(err) {
log.Error("Failed to get repository by ID: %v", err)
continue
}
return nil, err
}
if repo.OwnerID == u.ID {
continue
}
repos[repo] = access.Mode
}
return repos, nil
}
// GetAccessibleRepositories finds repositories which the user has access but does not own.
// If limit is smaller than 1 means returns all found results.
func (user *User) GetAccessibleRepositories(limit int) (repos []*Repository, _ error) {
sess := x.Where("owner_id !=? ", user.ID).Desc("updated_unix")
if limit > 0 {
sess.Limit(limit)
repos = make([]*Repository, 0, limit)
} else {
repos = make([]*Repository, 0, 10)
}
return repos, sess.Join("INNER", "access", "access.user_id = ? AND access.repo_id = repository.id", user.ID).Find(&repos)
}

View File

@ -116,26 +116,26 @@ func listUserRepositories(c *context.APIContext, username string) {
return
}
accessibleRepos, err := user.GetRepositoryAccesses()
accessibleRepos, err := db.Repos.GetByCollaboratorIDWithAccessMode(c.Req.Context(), user.ID)
if err != nil {
c.Error(err, "get repositories accesses")
c.Error(err, "get repositories accesses by collaborator")
return
}
numOwnRepos := len(ownRepos)
repos := make([]*api.Repository, numOwnRepos+len(accessibleRepos))
for i := range ownRepos {
repos[i] = ownRepos[i].APIFormatLegacy(&api.Permission{Admin: true, Push: true, Pull: true})
repos := make([]*api.Repository, 0, numOwnRepos+len(accessibleRepos))
for _, r := range ownRepos {
repos = append(repos, r.APIFormatLegacy(&api.Permission{Admin: true, Push: true, Pull: true}))
}
i := numOwnRepos
for repo, access := range accessibleRepos {
repos[i] = repo.APIFormatLegacy(&api.Permission{
repos = append(repos,
repo.APIFormatLegacy(&api.Permission{
Admin: access >= db.AccessModeAdmin,
Push: access >= db.AccessModeWrite,
Pull: true,
})
i++
}),
)
}
c.JSONSuccess(&repos)

View File

@ -1495,6 +1495,13 @@ type MockReposStore struct {
// CreateFunc is an instance of a mock function object controlling the
// behavior of the method Create.
CreateFunc *ReposStoreCreateFunc
// GetByCollaboratorIDFunc is an instance of a mock function object
// controlling the behavior of the method GetByCollaboratorID.
GetByCollaboratorIDFunc *ReposStoreGetByCollaboratorIDFunc
// GetByCollaboratorIDWithAccessModeFunc is an instance of a mock
// function object controlling the behavior of the method
// GetByCollaboratorIDWithAccessMode.
GetByCollaboratorIDWithAccessModeFunc *ReposStoreGetByCollaboratorIDWithAccessModeFunc
// GetByNameFunc is an instance of a mock function object controlling
// the behavior of the method GetByName.
GetByNameFunc *ReposStoreGetByNameFunc
@ -1512,6 +1519,16 @@ func NewMockReposStore() *MockReposStore {
return
},
},
GetByCollaboratorIDFunc: &ReposStoreGetByCollaboratorIDFunc{
defaultHook: func(context.Context, int64, int, string) (r0 []*db.Repository, r1 error) {
return
},
},
GetByCollaboratorIDWithAccessModeFunc: &ReposStoreGetByCollaboratorIDWithAccessModeFunc{
defaultHook: func(context.Context, int64) (r0 map[*db.Repository]db.AccessMode, r1 error) {
return
},
},
GetByNameFunc: &ReposStoreGetByNameFunc{
defaultHook: func(context.Context, int64, string) (r0 *db.Repository, r1 error) {
return
@ -1534,6 +1551,16 @@ func NewStrictMockReposStore() *MockReposStore {
panic("unexpected invocation of MockReposStore.Create")
},
},
GetByCollaboratorIDFunc: &ReposStoreGetByCollaboratorIDFunc{
defaultHook: func(context.Context, int64, int, string) ([]*db.Repository, error) {
panic("unexpected invocation of MockReposStore.GetByCollaboratorID")
},
},
GetByCollaboratorIDWithAccessModeFunc: &ReposStoreGetByCollaboratorIDWithAccessModeFunc{
defaultHook: func(context.Context, int64) (map[*db.Repository]db.AccessMode, error) {
panic("unexpected invocation of MockReposStore.GetByCollaboratorIDWithAccessMode")
},
},
GetByNameFunc: &ReposStoreGetByNameFunc{
defaultHook: func(context.Context, int64, string) (*db.Repository, error) {
panic("unexpected invocation of MockReposStore.GetByName")
@ -1554,6 +1581,12 @@ func NewMockReposStoreFrom(i db.ReposStore) *MockReposStore {
CreateFunc: &ReposStoreCreateFunc{
defaultHook: i.Create,
},
GetByCollaboratorIDFunc: &ReposStoreGetByCollaboratorIDFunc{
defaultHook: i.GetByCollaboratorID,
},
GetByCollaboratorIDWithAccessModeFunc: &ReposStoreGetByCollaboratorIDWithAccessModeFunc{
defaultHook: i.GetByCollaboratorIDWithAccessMode,
},
GetByNameFunc: &ReposStoreGetByNameFunc{
defaultHook: i.GetByName,
},
@ -1673,6 +1706,234 @@ func (c ReposStoreCreateFuncCall) Results() []interface{} {
return []interface{}{c.Result0, c.Result1}
}
// ReposStoreGetByCollaboratorIDFunc describes the behavior when the
// GetByCollaboratorID method of the parent MockReposStore instance is
// invoked.
type ReposStoreGetByCollaboratorIDFunc struct {
defaultHook func(context.Context, int64, int, string) ([]*db.Repository, error)
hooks []func(context.Context, int64, int, string) ([]*db.Repository, error)
history []ReposStoreGetByCollaboratorIDFuncCall
mutex sync.Mutex
}
// GetByCollaboratorID delegates to the next hook function in the queue and
// stores the parameter and result values of this invocation.
func (m *MockReposStore) GetByCollaboratorID(v0 context.Context, v1 int64, v2 int, v3 string) ([]*db.Repository, error) {
r0, r1 := m.GetByCollaboratorIDFunc.nextHook()(v0, v1, v2, v3)
m.GetByCollaboratorIDFunc.appendCall(ReposStoreGetByCollaboratorIDFuncCall{v0, v1, v2, v3, r0, r1})
return r0, r1
}
// SetDefaultHook sets function that is called when the GetByCollaboratorID
// method of the parent MockReposStore instance is invoked and the hook
// queue is empty.
func (f *ReposStoreGetByCollaboratorIDFunc) SetDefaultHook(hook func(context.Context, int64, int, string) ([]*db.Repository, error)) {
f.defaultHook = hook
}
// PushHook adds a function to the end of hook queue. Each invocation of the
// GetByCollaboratorID method of the parent MockReposStore instance invokes
// the hook at the front of the queue and discards it. After the queue is
// empty, the default hook function is invoked for any future action.
func (f *ReposStoreGetByCollaboratorIDFunc) PushHook(hook func(context.Context, int64, int, string) ([]*db.Repository, error)) {
f.mutex.Lock()
f.hooks = append(f.hooks, hook)
f.mutex.Unlock()
}
// SetDefaultReturn calls SetDefaultHook with a function that returns the
// given values.
func (f *ReposStoreGetByCollaboratorIDFunc) SetDefaultReturn(r0 []*db.Repository, r1 error) {
f.SetDefaultHook(func(context.Context, int64, int, string) ([]*db.Repository, error) {
return r0, r1
})
}
// PushReturn calls PushHook with a function that returns the given values.
func (f *ReposStoreGetByCollaboratorIDFunc) PushReturn(r0 []*db.Repository, r1 error) {
f.PushHook(func(context.Context, int64, int, string) ([]*db.Repository, error) {
return r0, r1
})
}
func (f *ReposStoreGetByCollaboratorIDFunc) nextHook() func(context.Context, int64, int, string) ([]*db.Repository, error) {
f.mutex.Lock()
defer f.mutex.Unlock()
if len(f.hooks) == 0 {
return f.defaultHook
}
hook := f.hooks[0]
f.hooks = f.hooks[1:]
return hook
}
func (f *ReposStoreGetByCollaboratorIDFunc) appendCall(r0 ReposStoreGetByCollaboratorIDFuncCall) {
f.mutex.Lock()
f.history = append(f.history, r0)
f.mutex.Unlock()
}
// History returns a sequence of ReposStoreGetByCollaboratorIDFuncCall
// objects describing the invocations of this function.
func (f *ReposStoreGetByCollaboratorIDFunc) History() []ReposStoreGetByCollaboratorIDFuncCall {
f.mutex.Lock()
history := make([]ReposStoreGetByCollaboratorIDFuncCall, len(f.history))
copy(history, f.history)
f.mutex.Unlock()
return history
}
// ReposStoreGetByCollaboratorIDFuncCall is an object that describes an
// invocation of method GetByCollaboratorID on an instance of
// MockReposStore.
type ReposStoreGetByCollaboratorIDFuncCall struct {
// Arg0 is the value of the 1st argument passed to this method
// invocation.
Arg0 context.Context
// Arg1 is the value of the 2nd argument passed to this method
// invocation.
Arg1 int64
// Arg2 is the value of the 3rd argument passed to this method
// invocation.
Arg2 int
// Arg3 is the value of the 4th argument passed to this method
// invocation.
Arg3 string
// Result0 is the value of the 1st result returned from this method
// invocation.
Result0 []*db.Repository
// Result1 is the value of the 2nd result returned from this method
// invocation.
Result1 error
}
// Args returns an interface slice containing the arguments of this
// invocation.
func (c ReposStoreGetByCollaboratorIDFuncCall) Args() []interface{} {
return []interface{}{c.Arg0, c.Arg1, c.Arg2, c.Arg3}
}
// Results returns an interface slice containing the results of this
// invocation.
func (c ReposStoreGetByCollaboratorIDFuncCall) Results() []interface{} {
return []interface{}{c.Result0, c.Result1}
}
// ReposStoreGetByCollaboratorIDWithAccessModeFunc describes the behavior
// when the GetByCollaboratorIDWithAccessMode method of the parent
// MockReposStore instance is invoked.
type ReposStoreGetByCollaboratorIDWithAccessModeFunc struct {
defaultHook func(context.Context, int64) (map[*db.Repository]db.AccessMode, error)
hooks []func(context.Context, int64) (map[*db.Repository]db.AccessMode, error)
history []ReposStoreGetByCollaboratorIDWithAccessModeFuncCall
mutex sync.Mutex
}
// GetByCollaboratorIDWithAccessMode delegates to the next hook function in
// the queue and stores the parameter and result values of this invocation.
func (m *MockReposStore) GetByCollaboratorIDWithAccessMode(v0 context.Context, v1 int64) (map[*db.Repository]db.AccessMode, error) {
r0, r1 := m.GetByCollaboratorIDWithAccessModeFunc.nextHook()(v0, v1)
m.GetByCollaboratorIDWithAccessModeFunc.appendCall(ReposStoreGetByCollaboratorIDWithAccessModeFuncCall{v0, v1, r0, r1})
return r0, r1
}
// SetDefaultHook sets function that is called when the
// GetByCollaboratorIDWithAccessMode method of the parent MockReposStore
// instance is invoked and the hook queue is empty.
func (f *ReposStoreGetByCollaboratorIDWithAccessModeFunc) SetDefaultHook(hook func(context.Context, int64) (map[*db.Repository]db.AccessMode, error)) {
f.defaultHook = hook
}
// PushHook adds a function to the end of hook queue. Each invocation of the
// GetByCollaboratorIDWithAccessMode method of the parent MockReposStore
// instance invokes the hook at the front of the queue and discards it.
// After the queue is empty, the default hook function is invoked for any
// future action.
func (f *ReposStoreGetByCollaboratorIDWithAccessModeFunc) PushHook(hook func(context.Context, int64) (map[*db.Repository]db.AccessMode, error)) {
f.mutex.Lock()
f.hooks = append(f.hooks, hook)
f.mutex.Unlock()
}
// SetDefaultReturn calls SetDefaultHook with a function that returns the
// given values.
func (f *ReposStoreGetByCollaboratorIDWithAccessModeFunc) SetDefaultReturn(r0 map[*db.Repository]db.AccessMode, r1 error) {
f.SetDefaultHook(func(context.Context, int64) (map[*db.Repository]db.AccessMode, error) {
return r0, r1
})
}
// PushReturn calls PushHook with a function that returns the given values.
func (f *ReposStoreGetByCollaboratorIDWithAccessModeFunc) PushReturn(r0 map[*db.Repository]db.AccessMode, r1 error) {
f.PushHook(func(context.Context, int64) (map[*db.Repository]db.AccessMode, error) {
return r0, r1
})
}
func (f *ReposStoreGetByCollaboratorIDWithAccessModeFunc) nextHook() func(context.Context, int64) (map[*db.Repository]db.AccessMode, error) {
f.mutex.Lock()
defer f.mutex.Unlock()
if len(f.hooks) == 0 {
return f.defaultHook
}
hook := f.hooks[0]
f.hooks = f.hooks[1:]
return hook
}
func (f *ReposStoreGetByCollaboratorIDWithAccessModeFunc) appendCall(r0 ReposStoreGetByCollaboratorIDWithAccessModeFuncCall) {
f.mutex.Lock()
f.history = append(f.history, r0)
f.mutex.Unlock()
}
// History returns a sequence of
// ReposStoreGetByCollaboratorIDWithAccessModeFuncCall objects describing
// the invocations of this function.
func (f *ReposStoreGetByCollaboratorIDWithAccessModeFunc) History() []ReposStoreGetByCollaboratorIDWithAccessModeFuncCall {
f.mutex.Lock()
history := make([]ReposStoreGetByCollaboratorIDWithAccessModeFuncCall, len(f.history))
copy(history, f.history)
f.mutex.Unlock()
return history
}
// ReposStoreGetByCollaboratorIDWithAccessModeFuncCall is an object that
// describes an invocation of method GetByCollaboratorIDWithAccessMode on an
// instance of MockReposStore.
type ReposStoreGetByCollaboratorIDWithAccessModeFuncCall struct {
// Arg0 is the value of the 1st argument passed to this method
// invocation.
Arg0 context.Context
// Arg1 is the value of the 2nd argument passed to this method
// invocation.
Arg1 int64
// Result0 is the value of the 1st result returned from this method
// invocation.
Result0 map[*db.Repository]db.AccessMode
// Result1 is the value of the 2nd result returned from this method
// invocation.
Result1 error
}
// Args returns an interface slice containing the arguments of this
// invocation.
func (c ReposStoreGetByCollaboratorIDWithAccessModeFuncCall) Args() []interface{} {
return []interface{}{c.Arg0, c.Arg1}
}
// Results returns an interface slice containing the results of this
// invocation.
func (c ReposStoreGetByCollaboratorIDWithAccessModeFuncCall) Results() []interface{} {
return []interface{}{c.Result0, c.Result1}
}
// ReposStoreGetByNameFunc describes the behavior when the GetByName method
// of the parent MockReposStore instance is invoked.
type ReposStoreGetByNameFunc struct {

View File

@ -125,9 +125,9 @@ func Dashboard(c *context.Context) {
// Only user can have collaborative repositories.
if !ctxUser.IsOrganization() {
collaborateRepos, err := c.User.GetAccessibleRepositories(conf.UI.User.RepoPagingNum)
collaborateRepos, err := db.Repos.GetByCollaboratorID(c.Req.Context(), c.User.ID, conf.UI.User.RepoPagingNum, "updated_unix DESC")
if err != nil {
c.Error(err, "get accessible repositories")
c.Error(err, "get accessible repositories by collaborator")
return
} else if err = db.RepositoryList(collaborateRepos).LoadAttributes(); err != nil {
c.Error(err, "load attributes")