Files
RenewCMS/internal/infrastructure/persistence/repositories/authorRepository.go
T

358 lines
10 KiB
Go

package persistence
import (
domain "RenewCMS/internal/domain/author"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type AuthorRepository struct {
db *gorm.DB
}
func NewAuthorRepository(db *gorm.DB) *AuthorRepository {
return &AuthorRepository{db}
}
type creditTotal struct {
AuthorID uint32
Total int
}
func creditCounts(db *gorm.DB, authorIDs []uint32) (map[uint32]int, error) {
counts := make(map[uint32]int, len(authorIDs))
if len(authorIDs) == 0 {
return counts, nil
}
var totals []creditTotal
err := db.Table("article_authors").
Select("article_authors.author_id AS author_id, COUNT(*) AS total").
Joins("JOIN articles ON articles.id = article_authors.article_id").
Where("articles.deleted_at IS NULL").
Where("article_authors.author_id IN ?", authorIDs).
Group("article_authors.author_id").
Scan(&totals).Error
if err != nil {
return nil, err
}
for _, total := range totals {
counts[total.AuthorID] = total.Total
}
return counts, nil
}
func (a *AuthorRepository) toDomain(db *gorm.DB, models []entity.Author) ([]domain.Author, error) {
ids := make([]uint32, 0, len(models))
for _, model := range models {
ids = append(ids, model.ID)
}
counts, err := creditCounts(db, ids)
if err != nil {
return nil, err
}
authors := make([]domain.Author, 0, len(models))
for _, model := range models {
authors = append(authors, mappers.AuthorToDomain(model, counts[model.ID]))
}
return authors, nil
}
func (a *AuthorRepository) loadOne(db *gorm.DB, model entity.Author) (domain.Author, error) {
authors, err := a.toDomain(db, []entity.Author{model})
if err != nil {
return domain.Author{}, err
}
return authors[0], nil
}
func (a *AuthorRepository) List(query domain.ListQuery) (domain.Page, error) {
query.Page = max(query.Page, 1)
query.Size = max(query.Size, 0)
needle := strings.TrimSpace(query.Q)
if needle != "" {
return a.findMatching(query, foldForSearch(needle))
}
var total int64
if err := a.db.Model(&entity.Author{}).Count(&total).Error; err != nil {
return domain.Page{}, err
}
var found []entity.Author
err := a.db.Order("id DESC").Limit(query.Size).Offset(pageStart(query.Page, query.Size, int(total))).Find(&found).Error
if err != nil {
return domain.Page{}, err
}
items, err := a.toDomain(a.db, found)
if err != nil {
return domain.Page{}, err
}
return domain.Page{Items: items, Total: int(total)}, nil
}
func pageStart(page int, size int, total int) int {
if size <= 0 || page-1 > total/size {
return total
}
return min((page-1)*size, total)
}
func (a *AuthorRepository) findMatching(query domain.ListQuery, needle string) (domain.Page, error) {
var candidates []entity.Author
if err := a.db.Select("id", "name", "slug").Order("id DESC").Find(&candidates).Error; err != nil {
return domain.Page{}, err
}
matching := make([]uint32, 0)
for _, candidate := range candidates {
if strings.Contains(foldForSearch(candidate.Name), needle) || strings.Contains(foldForSearch(candidate.Slug), needle) {
matching = append(matching, candidate.ID)
}
}
start := pageStart(query.Page, query.Size, len(matching))
end := min(start+query.Size, len(matching))
page := domain.Page{Items: []domain.Author{}, Total: len(matching)}
if start == end {
return page, nil
}
var found []entity.Author
if err := a.db.Where("id IN ?", matching[start:end]).Order("id DESC").Find(&found).Error; err != nil {
return domain.Page{}, err
}
items, err := a.toDomain(a.db, found)
if err != nil {
return domain.Page{}, err
}
page.Items = items
return page, nil
}
func (a *AuthorRepository) Get(id uint32) (domain.Author, error) {
var model entity.Author
if err := a.db.First(&model, id).Error; err != nil {
return domain.Author{}, mapNotFoundErr(err, domain.ErrAuthorNotFound)
}
return a.loadOne(a.db, model)
}
func (a *AuthorRepository) GetBySlug(slug string) (domain.Author, error) {
var model entity.Author
if err := a.db.Where("slug = ?", slug).First(&model).Error; err != nil {
return domain.Author{}, mapNotFoundErr(err, domain.ErrAuthorNotFound)
}
return a.loadOne(a.db, model)
}
func (a *AuthorRepository) GetByUserID(userID uint32) (domain.Author, error) {
var model entity.Author
if err := a.db.Where("user_id = ?", userID).First(&model).Error; err != nil {
return domain.Author{}, mapNotFoundErr(err, domain.ErrAuthorNotFound)
}
return a.loadOne(a.db, model)
}
func slugInUse(tx *gorm.DB, slug string, exceptID uint32) (bool, error) {
var total int64
err := tx.Model(&entity.Author{}).Where("slug = ? AND id <> ?", slug, exceptID).Count(&total).Error
return total > 0, err
}
func userInUse(tx *gorm.DB, userID *uint32, exceptID uint32) (bool, error) {
if userID == nil {
return false, nil
}
var total int64
err := tx.Model(&entity.Author{}).Where("user_id = ? AND id <> ?", *userID, exceptID).Count(&total).Error
return total > 0, err
}
func ensureUnique(tx *gorm.DB, author domain.Author) error {
taken, err := slugInUse(tx, author.Slug, author.ID)
if err != nil {
return err
}
if taken {
return domain.ErrSlugTaken
}
linked, err := userInUse(tx, author.UserID, author.ID)
if err != nil {
return err
}
if linked {
return domain.ErrUserAlreadyLinked
}
return nil
}
func (a *AuthorRepository) explainWriteFailure(author domain.Author, cause error) error {
if err := ensureUnique(a.db, author); err != nil {
return err
}
return cause
}
func (a *AuthorRepository) Create(author domain.Author) (domain.Author, error) {
model := mappers.AuthorToModel(author)
model.ID = 0
author.ID = 0
err := a.db.Transaction(func(tx *gorm.DB) error {
if err := ensureUnique(tx, author); err != nil {
return err
}
return tx.Create(&model).Error
})
if err != nil {
return domain.Author{}, a.explainWriteFailure(author, err)
}
return mappers.AuthorToDomain(model, 0), nil
}
func (a *AuthorRepository) Update(author domain.Author) (domain.Author, error) {
changes := map[string]any{
"name": author.Name,
"slug": author.Slug,
"bio": author.Bio,
"avatar_id": author.AvatarID,
"website": author.Website,
"social_links": mappers.AuthorToModel(author).SocialLinks,
"user_id": author.UserID,
}
err := a.db.Transaction(func(tx *gorm.DB) error {
if _, err := lockAuthor(tx, author.ID); err != nil {
return err
}
if err := ensureUnique(tx, author); err != nil {
return err
}
return tx.Model(&entity.Author{}).Where("id = ?", author.ID).Updates(changes).Error
})
if err != nil {
return domain.Author{}, a.explainWriteFailure(author, err)
}
return a.Get(author.ID)
}
func lockAuthor(tx *gorm.DB, id uint32) (entity.Author, error) {
var locked entity.Author
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&locked, id).Error; err != nil {
return entity.Author{}, mapNotFoundErr(err, domain.ErrAuthorNotFound)
}
return locked, nil
}
func (a *AuthorRepository) DeleteMovingCredits(id uint32, reassignTo *uint32) error {
return a.db.Transaction(func(tx *gorm.DB) error {
if _, err := lockAuthor(tx, id); err != nil {
return err
}
if reassignTo == nil {
credited, err := hasCredits(tx, id)
if err != nil {
return err
}
if credited {
return domain.ErrAuthorHasArticles
}
return removeAuthor(tx, id)
}
if _, err := lockAuthor(tx, *reassignTo); err != nil {
return err
}
if err := moveCredits(tx, id, *reassignTo); err != nil {
return err
}
return removeAuthor(tx, id)
})
}
func (a *AuthorRepository) DeleteUncrediting(id uint32) error {
return a.db.Transaction(func(tx *gorm.DB) error {
if _, err := lockAuthor(tx, id); err != nil {
return err
}
return removeAuthor(tx, id)
})
}
func hasCredits(tx *gorm.DB, id uint32) (bool, error) {
var total int64
err := tx.Model(&entity.ArticleAuthor{}).Where("author_id = ?", id).Count(&total).Error
return total > 0, err
}
func removeAuthor(tx *gorm.DB, id uint32) error {
if err := tx.Where("author_id = ?", id).Delete(&entity.ArticleAuthor{}).Error; err != nil {
return err
}
return deleteOrNotFound(tx.Delete(&entity.Author{}, id), domain.ErrAuthorNotFound)
}
func moveCredits(tx *gorm.DB, from uint32, to uint32) error {
var alreadyCredited []uint32
if err := tx.Model(&entity.ArticleAuthor{}).Where("author_id = ?", to).Pluck("article_id", &alreadyCredited).Error; err != nil {
return err
}
if len(alreadyCredited) > 0 {
if err := tx.Where("author_id = ? AND article_id IN ?", from, alreadyCredited).Delete(&entity.ArticleAuthor{}).Error; err != nil {
return err
}
}
return tx.Model(&entity.ArticleAuthor{}).Where("author_id = ?", from).UpdateColumn("author_id", to).Error
}
type avatarTotal struct {
AvatarID uint32
Total int
}
func (a *AuthorRepository) AvatarUsage() (map[uint32]int, error) {
var totals []avatarTotal
err := a.db.Model(&entity.Author{}).Select("avatar_id, COUNT(*) AS total").Where("avatar_id IS NOT NULL").Group("avatar_id").Scan(&totals).Error
if err != nil {
return nil, err
}
usage := make(map[uint32]int, len(totals))
for _, total := range totals {
usage[total.AvatarID] = total.Total
}
return usage, nil
}
func (a *AuthorRepository) ClearAvatar(imageID uint32) ([]uint32, error) {
ids := []uint32{}
err := a.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&entity.Author{}).Where("avatar_id = ?", imageID).Order("id ASC").Pluck("id", &ids).Error; err != nil {
return err
}
if len(ids) == 0 {
return nil
}
return tx.Model(&entity.Author{}).Where("id IN ?", ids).UpdateColumn("avatar_id", nil).Error
})
if err != nil {
return nil, err
}
return ids, nil
}
func (a *AuthorRepository) AssignAvatar(authorIDs []uint32, imageID uint32) error {
if len(authorIDs) == 0 {
return nil
}
return a.db.Model(&entity.Author{}).Where("id IN ?", authorIDs).UpdateColumn("avatar_id", imageID).Error
}
func (a *AuthorRepository) RepointAvatar(fromImageIDs []uint32, toImageID uint32) (int, error) {
if len(fromImageIDs) == 0 {
return 0, nil
}
result := a.db.Model(&entity.Author{}).Where("avatar_id IN ?", fromImageIDs).UpdateColumn("avatar_id", toImageID)
if result.Error != nil {
return 0, result.Error
}
return int(result.RowsAffected), nil
}
var _ domain.Repository = &AuthorRepository{}