Files
mygo/internal/repository/file.go
T
ld a18f96912d feat(repository): refactor query/mutation ports with capability-safe
interfaces

- refactor: split UserRepository into AuthUserRepository and
  AdminUserRepository capabilities
- refactor: split SessionRepository into AuthSessionRepository and
  repository-owned CLI/admin methods
- refactor: replace generic Repository Update/Delete with
  operation-specific params and ownership predicates
- refactor: replace CredentialRepository generic CRUD with
  passkey-specific methods
- refactor: replace FileRepository generic Create/Update/Delete with
  UploadedFileParams, DirectoryParams, and owned soft-delete
- refactor: remove repository fields from WebApp struct; repositories
  are now composition-time wiring only
- feat: add domain error kinds ErrParentNotFound, ErrParentNotDir,
  ErrDirectoryNotEmpty, ErrInvalidMove
- feat: add CredentialTypeAppPasskey constant
- feat: add testutil.SetUserAdmin for test fixture setup that bypasses
  production service ports
- test: add architecture test banning GORM Save in repository package
- test: add capability interface contract tests ensuring each service
  receives the minimal interface
- test: add blockingStorage helper for concurrent promotion tests
- test: add preserved DSN parameter test for sqliteImmediateDSN
- docs: update architecture decisions with repository write rules and
  capability separation
- docs: update roadmap to clarify atomic single-use refresh sessions
- docs: add -race test target to development docs
2026-07-16 12:24:36 +08:00

329 lines
9.5 KiB
Go

package repository
import (
"context"
"errors"
"sort"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/dhao2001/mygo/internal/model"
)
// UploadedFileParams contains persisted metadata for a completed upload.
type UploadedFileParams struct {
ID string
UserID string
ParentID *string
Name string
Size int64
MimeType string
StoragePath string
Hash string
}
// DirectoryParams contains the fields accepted when creating a directory.
type DirectoryParams struct {
ID string
UserID string
ParentID *string
Name string
}
// FileMetadataUpdate contains the user-editable metadata fields.
// Nil fields are left unchanged.
type FileMetadataUpdate struct {
FileID string
UserID string
NewName *string
NewParentID *string
}
// FileRepository provides access to file records.
type FileRepository interface {
CreateUploadedFile(ctx context.Context, params UploadedFileParams) (*model.File, error)
CreateDirectory(ctx context.Context, params DirectoryParams) (*model.File, error)
FindByID(ctx context.Context, id string) (*model.File, error)
FindByUserID(ctx context.Context, userID string, offset, limit int) ([]model.File, int64, error)
FindByParentID(ctx context.Context, userID string, parentID *string) ([]model.File, error)
FindByParentIDPaginated(ctx context.Context, userID string, parentID *string, offset, limit int) ([]model.File, int64, error)
FindByNameAndParent(ctx context.Context, userID string, parentID *string, name string) (*model.File, error)
UpdateOwnedMetadata(ctx context.Context, params FileMetadataUpdate) (*model.File, error)
SoftDeleteOwned(ctx context.Context, userID, fileID string) error
}
type fileRepository struct {
db *gorm.DB
}
// NewFileRepository creates a FileRepository backed by GORM.
func NewFileRepository(db *gorm.DB) FileRepository {
return &fileRepository{db: db}
}
func (r *fileRepository) CreateUploadedFile(ctx context.Context, params UploadedFileParams) (*model.File, error) {
file := &model.File{
ID: params.ID,
UserID: params.UserID,
ParentID: params.ParentID,
Name: params.Name,
Size: params.Size,
MimeType: params.MimeType,
StoragePath: params.StoragePath,
Hash: params.Hash,
Status: model.StatusActive,
IsDir: false,
}
if err := r.createEntry(ctx, file); err != nil {
return nil, err
}
return file, nil
}
func (r *fileRepository) CreateDirectory(ctx context.Context, params DirectoryParams) (*model.File, error) {
dir := &model.File{
ID: params.ID,
UserID: params.UserID,
ParentID: params.ParentID,
Name: params.Name,
Status: model.StatusActive,
IsDir: true,
}
if err := r.createEntry(ctx, dir); err != nil {
return nil, err
}
return dir, nil
}
func (r *fileRepository) createEntry(ctx context.Context, file *model.File) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if file.ParentID != nil {
locked, err := lockActiveOwnedFiles(tx, file.UserID, []string{*file.ParentID})
if err != nil {
return err
}
parent, ok := locked[*file.ParentID]
if !ok {
return model.ErrParentNotFound
}
if !parent.IsDir {
return model.ErrParentNotDir
}
}
result := tx.Create(file)
if result.Error != nil {
if isDuplicateKeyError(result.Error) {
return model.ErrDuplicate
}
return result.Error
}
return nil
})
}
func (r *fileRepository) FindByID(ctx context.Context, id string) (*model.File, error) {
var file model.File
result := r.db.WithContext(ctx).First(&file, "id = ? AND status = ?", id, model.StatusActive)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, model.ErrNotFound
}
if result.Error != nil {
return nil, result.Error
}
return &file, nil
}
func (r *fileRepository) FindByUserID(ctx context.Context, userID string, offset, limit int) ([]model.File, int64, error) {
var files []model.File
var total int64
if err := r.db.WithContext(ctx).Model(&model.File{}).Where("user_id = ? AND status = ?", userID, model.StatusActive).Count(&total).Error; err != nil {
return nil, 0, err
}
result := r.db.WithContext(ctx).Where("user_id = ? AND status = ?", userID, model.StatusActive).Offset(offset).Limit(limit).Find(&files)
if result.Error != nil {
return nil, 0, result.Error
}
return files, total, nil
}
func (r *fileRepository) FindByParentID(ctx context.Context, userID string, parentID *string) ([]model.File, error) {
var files []model.File
result := r.db.WithContext(ctx).Where("user_id = ? AND parent_id IS ? AND status = ?", userID, parentID, model.StatusActive).Find(&files)
if result.Error != nil {
return nil, result.Error
}
return files, nil
}
func (r *fileRepository) FindByParentIDPaginated(ctx context.Context, userID string, parentID *string, offset, limit int) ([]model.File, int64, error) {
var files []model.File
var total int64
if err := r.db.WithContext(ctx).Model(&model.File{}).Where("user_id = ? AND parent_id IS ? AND status = ?", userID, parentID, model.StatusActive).Count(&total).Error; err != nil {
return nil, 0, err
}
result := r.db.WithContext(ctx).Where("user_id = ? AND parent_id IS ? AND status = ?", userID, parentID, model.StatusActive).Offset(offset).Limit(limit).Order("is_dir DESC, name ASC").Find(&files)
if result.Error != nil {
return nil, 0, result.Error
}
return files, total, nil
}
func (r *fileRepository) FindByNameAndParent(ctx context.Context, userID string, parentID *string, name string) (*model.File, error) {
var file model.File
result := r.db.WithContext(ctx).Where("user_id = ? AND parent_id IS ? AND name = ? AND status = ?", userID, parentID, name, model.StatusActive).First(&file)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, model.ErrNotFound
}
if result.Error != nil {
return nil, result.Error
}
return &file, nil
}
func (r *fileRepository) UpdateOwnedMetadata(ctx context.Context, params FileMetadataUpdate) (*model.File, error) {
var updated model.File
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
ids := []string{params.FileID}
if params.NewParentID != nil {
if *params.NewParentID == params.FileID {
return model.ErrInvalidMove
}
ids = append(ids, *params.NewParentID)
}
locked, err := lockActiveOwnedFiles(tx, params.UserID, ids)
if err != nil {
return err
}
source, ok := locked[params.FileID]
if !ok {
return model.ErrNotFound
}
updates := make(map[string]any, 2)
finalName := source.Name
finalParentID := source.ParentID
if params.NewName != nil {
updates["name"] = *params.NewName
finalName = *params.NewName
}
if params.NewParentID != nil {
parent, ok := locked[*params.NewParentID]
if !ok {
return model.ErrParentNotFound
}
if !parent.IsDir {
return model.ErrParentNotDir
}
updates["parent_id"] = *params.NewParentID
finalParentID = params.NewParentID
}
if len(updates) > 0 {
var conflictCount int64
if err := tx.Model(&model.File{}).
Where("user_id = ? AND parent_id IS ? AND name = ? AND status = ? AND id <> ?", params.UserID, finalParentID, finalName, model.StatusActive, params.FileID).
Count(&conflictCount).Error; err != nil {
return err
}
if conflictCount > 0 {
return model.ErrDuplicate
}
result := tx.Model(&model.File{}).
Where("id = ? AND user_id = ? AND status = ?", params.FileID, params.UserID, model.StatusActive).
Updates(updates)
if result.Error != nil {
if isDuplicateKeyError(result.Error) {
return model.ErrDuplicate
}
return result.Error
}
if result.RowsAffected == 0 {
return model.ErrNotFound
}
}
result := tx.First(&updated, "id = ? AND user_id = ? AND status = ?", source.ID, params.UserID, model.StatusActive)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return model.ErrNotFound
}
return result.Error
})
if err != nil {
return nil, err
}
return &updated, nil
}
func (r *fileRepository) SoftDeleteOwned(ctx context.Context, userID, fileID string) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
locked, err := lockActiveOwnedFiles(tx, userID, []string{fileID})
if err != nil {
return err
}
file, ok := locked[fileID]
if !ok {
return model.ErrNotFound
}
if file.IsDir {
var count int64
if err := tx.Model(&model.File{}).
Where("parent_id = ? AND status = ?", fileID, model.StatusActive).
Count(&count).Error; err != nil {
return err
}
if count > 0 {
return model.ErrDirectoryNotEmpty
}
}
result := tx.Model(&model.File{}).
Where("id = ? AND user_id = ? AND status = ?", fileID, userID, model.StatusActive).
Update("status", model.StatusUserDeleted)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return model.ErrNotFound
}
return nil
})
}
func lockActiveOwnedFiles(tx *gorm.DB, userID string, ids []string) (map[string]model.File, error) {
unique := make(map[string]struct{}, len(ids))
for _, id := range ids {
unique[id] = struct{}{}
}
ordered := make([]string, 0, len(unique))
for id := range unique {
ordered = append(ordered, id)
}
sort.Strings(ordered)
var files []model.File
result := tx.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
Where("id IN ? AND user_id = ? AND status = ?", ordered, userID, model.StatusActive).
Order("id ASC").
Find(&files)
if result.Error != nil {
return nil, result.Error
}
locked := make(map[string]model.File, len(files))
for i := range files {
locked[files[i].ID] = files[i]
}
return locked, nil
}