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{}