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

572 lines
18 KiB
Go

package persistence
import (
domain "RenewCMS/internal/domain/article"
authordomain "RenewCMS/internal/domain/author"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"errors"
"sort"
"strings"
"unicode"
"golang.org/x/text/runes"
"golang.org/x/text/transform"
"golang.org/x/text/unicode/norm"
"gorm.io/gorm"
)
type ArticleRepository struct {
db *gorm.DB
}
func NewArticleRepository(db *gorm.DB) *ArticleRepository {
return &ArticleRepository{db}
}
func (a *ArticleRepository) FindByFilters(filters domain.Filters) ([]domain.Article, error) {
query := a.db.Model(&entity.Article{})
if filters.IsOnline != nil {
query = query.Where("is_online = ?", *filters.IsOnline)
}
var entityArticles []entity.Article
err := query.Find(&entityArticles).Error
return toDomainArticles(entityArticles), err
}
func (a *ArticleRepository) scoped(filters domain.Filters) *gorm.DB {
query := a.db.Model(&entity.Article{})
if filters.IsOnline != nil {
query = query.Where("is_online = ?", *filters.IsOnline)
}
if filters.SiteID != nil {
query = query.Where("site_id = ?", *filters.SiteID)
}
switch filters.Status {
case domain.StatusOnline:
query = query.Where("is_online = ?", true)
case domain.StatusDraft:
query = query.Where("is_online = ?", false)
}
return query
}
var sortColumns = map[domain.SortField]string{
domain.SortByTitle: "LOWER(title)",
domain.SortByStatus: "is_online",
domain.SortByCreatedAt: "created_at",
domain.SortByUpdatedAt: "updated_at",
domain.SortByID: "id",
}
var sortDirections = map[domain.SortDirection]string{
domain.SortAsc: "ASC",
domain.SortDesc: "DESC",
}
func orderClause(sort domain.Sort) string {
column, ok := sortColumns[sort.Field]
direction, directionOk := sortDirections[sort.Direction]
if !ok || !directionOk {
column, direction = sortColumns[domain.SortByUpdatedAt], sortDirections[domain.SortDesc]
}
if sort.Field == domain.SortByID {
return column + " " + direction
}
return column + " " + direction + ", id DESC"
}
func (a *ArticleRepository) FindPage(filters domain.Filters) (domain.PageResult, error) {
filters.Page = max(filters.Page, 1)
filters.Size = max(filters.Size, 0)
filters.Q = strings.TrimSpace(filters.Q)
if filters.Q != "" {
return a.findMatchingTitle(filters)
}
var total int64
if err := a.scoped(filters).Count(&total).Error; err != nil {
return domain.PageResult{}, err
}
var entityArticles []entity.Article
ordered := a.scoped(filters).Order(orderClause(filters.Sort))
if !filters.Unpaged {
ordered = ordered.Limit(filters.Size).Offset((filters.Page - 1) * filters.Size)
}
err := ordered.Find(&entityArticles).Error
if err != nil {
return domain.PageResult{}, err
}
items, err := a.withAuthors(a.db, toDomainArticles(entityArticles))
if err != nil {
return domain.PageResult{}, err
}
return domain.PageResult{Items: items, Total: int(total)}, nil
}
func (a *ArticleRepository) findMatchingTitle(filters domain.Filters) (domain.PageResult, error) {
var candidates []entity.Article
if err := a.scoped(filters).Select("id", "title").Order(orderClause(filters.Sort)).Find(&candidates).Error; err != nil {
return domain.PageResult{}, err
}
needle := foldForSearch(filters.Q)
matchingIds := make([]uint32, 0)
for _, candidate := range candidates {
if strings.Contains(foldForSearch(candidate.Title), needle) {
matchingIds = append(matchingIds, candidate.ID)
}
}
pageIds := matchingIds
if !filters.Unpaged {
start := min((filters.Page-1)*filters.Size, len(matchingIds))
end := min(start+filters.Size, len(matchingIds))
pageIds = matchingIds[start:end]
}
page := domain.PageResult{Items: []domain.Article{}, Total: len(matchingIds)}
if len(pageIds) == 0 {
return page, nil
}
var entityArticles []entity.Article
for _, chunk := range chunkIDs(pageIds) {
var loaded []entity.Article
if err := a.db.Model(&entity.Article{}).Where("id IN ?", chunk).Find(&loaded).Error; err != nil {
return domain.PageResult{}, err
}
entityArticles = append(entityArticles, loaded...)
}
byId := make(map[uint32]entity.Article, len(entityArticles))
for _, entityArticle := range entityArticles {
byId[entityArticle.ID] = entityArticle
}
ordered := make([]entity.Article, 0, len(pageIds))
for _, id := range pageIds {
if entityArticle, found := byId[id]; found {
ordered = append(ordered, entityArticle)
}
}
items, err := a.withAuthors(a.db, toDomainArticles(ordered))
if err != nil {
return domain.PageResult{}, err
}
page.Items = items
return page, nil
}
func (a *ArticleRepository) CountByVisibility(onlineOnly bool) (domain.Counts, error) {
var online, draft int64
if err := a.db.Model(&entity.Article{}).Where("is_online = ?", true).Count(&online).Error; err != nil {
return domain.Counts{}, err
}
if !onlineOnly {
if err := a.db.Model(&entity.Article{}).Where("is_online = ?", false).Count(&draft).Error; err != nil {
return domain.Counts{}, err
}
}
return domain.Counts{All: int(online + draft), Online: int(online), Draft: int(draft)}, nil
}
func toDomainArticles(entityArticles []entity.Article) []domain.Article {
domainArticles := make([]domain.Article, 0, len(entityArticles))
for _, entityArticle := range entityArticles {
domainArticles = append(domainArticles, mappers.ArticleToDomain(entityArticle))
}
return domainArticles
}
func (a *ArticleRepository) withAuthors(db *gorm.DB, articles []domain.Article) ([]domain.Article, error) {
ids := make([]uint32, 0, len(articles))
for _, article := range articles {
ids = append(ids, article.ID)
}
bylines, err := loadBylines(db, ids)
if err != nil {
return nil, err
}
for index := range articles {
articles[index].Authors = bylines[articles[index].ID]
if articles[index].Authors == nil {
articles[index].Authors = []authordomain.Byline{}
}
}
return articles, nil
}
func (a *ArticleRepository) withAuthorsOne(db *gorm.DB, article domain.Article) (domain.Article, error) {
loaded, err := a.withAuthors(db, []domain.Article{article})
if err != nil {
return domain.Article{}, err
}
return loaded[0], nil
}
const idChunkSize = 500
func chunkIDs(ids []uint32) [][]uint32 {
chunks := make([][]uint32, 0, len(ids)/idChunkSize+1)
for start := 0; start < len(ids); start += idChunkSize {
chunks = append(chunks, ids[start:min(start+idChunkSize, len(ids))])
}
return chunks
}
func loadBylines(db *gorm.DB, articleIDs []uint32) (map[uint32][]authordomain.Byline, error) {
bylines := make(map[uint32][]authordomain.Byline, len(articleIDs))
if len(articleIDs) == 0 {
return bylines, nil
}
var credits []entity.ArticleAuthor
for _, chunk := range chunkIDs(articleIDs) {
var loaded []entity.ArticleAuthor
if err := db.Where("article_id IN ?", chunk).Order("position ASC, id ASC").Find(&loaded).Error; err != nil {
return nil, err
}
credits = append(credits, loaded...)
}
sort.SliceStable(credits, func(i, j int) bool {
if credits[i].Position != credits[j].Position {
return credits[i].Position < credits[j].Position
}
return credits[i].ID < credits[j].ID
})
if len(credits) == 0 {
return bylines, nil
}
authorIDs := make([]uint32, 0, len(credits))
for _, credit := range credits {
authorIDs = append(authorIDs, credit.AuthorID)
}
var found []entity.Author
for _, chunk := range chunkIDs(authorIDs) {
var loaded []entity.Author
if err := db.Where("id IN ?", chunk).Find(&loaded).Error; err != nil {
return nil, err
}
found = append(found, loaded...)
}
byID := make(map[uint32]entity.Author, len(found))
for _, model := range found {
byID[model.ID] = model
}
for _, credit := range credits {
if model, ok := byID[credit.AuthorID]; ok {
bylines[credit.ArticleID] = append(bylines[credit.ArticleID], mappers.AuthorToDomain(model, 0).Byline())
}
}
return bylines, nil
}
func replaceCredits(tx *gorm.DB, articleID uint32, authorIDs []uint32) error {
if err := tx.Where("article_id = ?", articleID).Delete(&entity.ArticleAuthor{}).Error; err != nil {
return err
}
for position, authorID := range authorIDs {
credit := entity.ArticleAuthor{ArticleID: articleID, AuthorID: authorID, Position: position}
if err := tx.Create(&credit).Error; err != nil {
return err
}
}
return nil
}
func foldForSearch(value string) string {
folder := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
folded, _, err := transform.String(folder, value)
if err != nil {
return strings.ToLower(value)
}
return strings.ToLower(folded)
}
func (a *ArticleRepository) Get(id uint32) (domain.Article, error) {
var article entity.Article
err := a.db.Model(&entity.Article{}).First(&article, id).Error
if err != nil {
return domain.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound)
}
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(article))
}
func (a *ArticleRepository) GetByName(name string) (domain.Article, error) {
var article entity.Article
err := a.db.Model(&entity.Article{}).Where("title = ?", name).First(&article).Error
if err != nil {
return domain.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound)
}
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(article))
}
func (a *ArticleRepository) Create(article domain.Article) (domain.Article, error) {
created := mappers.ArticleToModel(article)
err := a.db.Transaction(func(tx *gorm.DB) error {
if article.SiteID != 0 {
if _, err := lockSite(tx, article.SiteID); err != nil {
return err
}
}
if err := ensureSlugFree(tx, article.SiteID, article.Slug, 0); err != nil {
return err
}
if err := tx.Create(&created).Error; err != nil {
return err
}
return replaceCredits(tx, created.ID, authordomain.BylineIDs(article.Authors))
})
if err != nil {
return domain.Article{}, a.explainSlugFailure(article.SiteID, article.Slug, 0, err)
}
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(created))
}
func articleSlugInUse(db *gorm.DB, siteID uint32, slug string, exceptID uint32) (bool, error) {
var total int64
err := db.Model(&entity.Article{}).Where("site_id = ? AND slug = ? AND id <> ?", siteID, slug, exceptID).Count(&total).Error
return total > 0, err
}
func ensureSlugFree(db *gorm.DB, siteID uint32, slug string, exceptID uint32) error {
if slug == "" {
return nil
}
taken, err := articleSlugInUse(db, siteID, slug, exceptID)
if err != nil {
return err
}
if taken {
return domain.ErrSlugTaken
}
return nil
}
func (a *ArticleRepository) explainSlugFailure(siteID uint32, slug string, exceptID uint32, cause error) error {
if errors.Is(cause, domain.ErrSlugTaken) {
return cause
}
if err := ensureSlugFree(a.db, siteID, slug, exceptID); err != nil && errors.Is(err, domain.ErrSlugTaken) {
return err
}
return cause
}
func (a *ArticleRepository) SlugTaken(siteID uint32, slug string, exceptID uint32) (bool, error) {
return articleSlugInUse(a.db, siteID, slug, exceptID)
}
func (a *ArticleRepository) GetAll() []domain.Article {
var articles []entity.Article
err := a.db.Model(&entity.Article{}).Find(&articles).Error
if err != nil {
return []domain.Article{}
}
var domainArticles = make([]domain.Article, 0)
for _, article := range articles {
domainArticles = append(domainArticles, mappers.ArticleToDomain(article))
}
return domainArticles
}
func (a *ArticleRepository) UpdateBody(id uint32, body string) (domain.Article, error) {
var localArticle entity.Article
err := a.db.Model(&entity.Article{}).First(&localArticle, id).Error
if err != nil {
return domain.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound)
}
localArticle.Body = body
err = a.db.Save(&localArticle).Error
if err != nil {
return domain.Article{}, err
}
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle))
}
func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Article, error) {
var localArticle entity.Article
err := a.db.Transaction(func(tx *gorm.DB) error {
if update.SiteID != nil {
if _, err := lockSite(tx, *update.SiteID); err != nil {
return err
}
}
if err := tx.Model(&entity.Article{}).First(&localArticle, id).Error; err != nil {
return mapNotFoundErr(err, domain.ErrArticleNotFound)
}
if update.SiteID == nil && update.Slug != nil {
if _, err := lockSite(tx, localArticle.SiteID); err != nil {
return err
}
}
applyUpdate(&localArticle, update)
if update.Slug != nil || update.SiteID != nil {
if err := ensureSlugFree(tx, localArticle.SiteID, localArticle.Slug, id); err != nil {
return err
}
}
if err := tx.Save(&localArticle).Error; err != nil {
return err
}
if update.AuthorIDs != nil {
return replaceCredits(tx, id, *update.AuthorIDs)
}
return nil
})
if err != nil {
return domain.Article{}, a.explainSlugFailure(localArticle.SiteID, localArticle.Slug, id, err)
}
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle))
}
func applyUpdate(target *entity.Article, update domain.Update) {
setString(&target.Body, update.Body)
setString(&target.Slug, update.Slug)
setString(&target.Excerpt, update.Excerpt)
setString(&target.SEOTitle, update.SEOTitle)
setString(&target.SEODescription, update.SEODescription)
setString(&target.CoverImageAlt, update.CoverImageAlt)
setString(&target.SocialTitle, update.SocialTitle)
setString(&target.SocialDescription, update.SocialDescription)
setString(&target.CanonicalURL, update.CanonicalURL)
if update.SiteID != nil {
target.SiteID = *update.SiteID
}
if update.Noindex != nil {
target.Noindex = *update.Noindex
}
if update.CoverImageID.Set {
target.CoverImageID = update.CoverImageID.Value
}
if update.SocialImageID.Set {
target.SocialImageID = update.SocialImageID.Value
}
if update.SEODismissed != nil {
target.SEODismissed = mappers.ArticleToModel(domain.Article{SEODismissed: *update.SEODismissed}).SEODismissed
}
}
func setString(target *string, value *string) {
if value != nil {
*target = *value
}
}
func (a *ArticleRepository) UpdateIsOnline(id uint32, isOnline bool) (domain.Article, error) {
var localArticle entity.Article
err := a.db.Model(&entity.Article{}).First(&localArticle, id).Error
if err != nil {
return domain.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound)
}
localArticle.IsOnline = isOnline
err = a.db.Save(&localArticle).Error
if err != nil {
return domain.Article{}, err
}
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle))
}
func (a *ArticleRepository) Delete(id uint32) error {
return a.db.Transaction(func(tx *gorm.DB) error {
var found entity.Article
if err := tx.First(&found, id).Error; err != nil {
return mapNotFoundErr(err, domain.ErrArticleNotFound)
}
tombstone := domain.Tombstone(id)
if err := tx.Model(&entity.Article{}).Where("id = ?", id).UpdateColumn("slug", tombstone).Error; err != nil {
return err
}
if err := deleteOrNotFound(tx.Delete(&entity.Article{}, id), domain.ErrArticleNotFound); err != nil {
return err
}
return tx.Where("article_id = ?", id).Delete(&entity.ArticleAuthor{}).Error
})
}
func (a *ArticleRepository) FindByBodyContaining(filename string) ([]domain.Article, error) {
var entityArticles []entity.Article
err := a.db.Model(&entity.Article{}).Where("body LIKE ? ESCAPE '!'", containsPattern(filename)).Order("id asc").Find(&entityArticles).Error
if err != nil {
return nil, err
}
return toDomainArticles(entityArticles), nil
}
func (a *ArticleRepository) FindByImage(imageID uint32) ([]domain.Article, error) {
var entityArticles []entity.Article
err := a.db.Model(&entity.Article{}).Where("cover_image_id = ? OR social_image_id = ?", imageID, imageID).Order("id asc").Find(&entityArticles).Error
if err != nil {
return nil, err
}
return toDomainArticles(entityArticles), nil
}
func (a *ArticleRepository) ClearImage(imageID uint32) (domain.ImageReferences, error) {
references := domain.ImageReferences{Cover: []uint32{}, Social: []uint32{}}
err := a.db.Transaction(func(tx *gorm.DB) error {
if err := clearColumn(tx, "cover_image_id", imageID, &references.Cover); err != nil {
return err
}
return clearColumn(tx, "social_image_id", imageID, &references.Social)
})
if err != nil {
return domain.ImageReferences{}, err
}
return references, nil
}
func clearColumn(tx *gorm.DB, column string, imageID uint32, cleared *[]uint32) error {
if err := tx.Model(&entity.Article{}).Where(column+" = ?", imageID).Order("id ASC").Pluck("id", cleared).Error; err != nil {
return err
}
if len(*cleared) == 0 {
return nil
}
return tx.Model(&entity.Article{}).Where("id IN ?", *cleared).UpdateColumn(column, nil).Error
}
func (a *ArticleRepository) RestoreImage(references domain.ImageReferences, imageID uint32) error {
return a.db.Transaction(func(tx *gorm.DB) error {
if err := assignColumn(tx, "cover_image_id", references.Cover, imageID); err != nil {
return err
}
return assignColumn(tx, "social_image_id", references.Social, imageID)
})
}
func assignColumn(tx *gorm.DB, column string, articleIDs []uint32, imageID uint32) error {
if len(articleIDs) == 0 {
return nil
}
return tx.Model(&entity.Article{}).Where("id IN ?", articleIDs).UpdateColumn(column, imageID).Error
}
func (a *ArticleRepository) RepointImage(fromImageIDs []uint32, toImageID uint32) ([]uint32, error) {
changed := []uint32{}
if len(fromImageIDs) == 0 {
return changed, nil
}
err := a.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&entity.Article{}).Where("cover_image_id IN ? OR social_image_id IN ?", fromImageIDs, fromImageIDs).Order("id ASC").Pluck("id", &changed).Error; err != nil {
return err
}
if err := tx.Model(&entity.Article{}).Where("cover_image_id IN ?", fromImageIDs).UpdateColumn("cover_image_id", toImageID).Error; err != nil {
return err
}
return tx.Model(&entity.Article{}).Where("social_image_id IN ?", fromImageIDs).UpdateColumn("social_image_id", toImageID).Error
})
if err != nil {
return nil, err
}
return changed, nil
}
var _ domain.Repository = &ArticleRepository{}