392 lines
12 KiB
Go
392 lines
12 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"
|
|
|
|
"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",
|
|
}
|
|
|
|
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]
|
|
}
|
|
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
|
|
err := a.scoped(filters).Order(orderClause(filters.Sort)).Limit(filters.Size).Offset((filters.Page - 1) * filters.Size).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)
|
|
}
|
|
}
|
|
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
|
|
if err := a.db.Model(&entity.Article{}).Where("id IN ?", pageIds).Find(&entityArticles).Error; err != nil {
|
|
return domain.PageResult{}, err
|
|
}
|
|
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
|
|
}
|
|
|
|
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
|
|
if err := db.Where("article_id IN ?", articleIDs).Order("position ASC, id ASC").Find(&credits).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
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
|
|
if err := db.Where("id IN ?", authorIDs).Find(&found).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
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 := entity.Article{
|
|
Title: article.Title,
|
|
Body: article.Body,
|
|
IsOnline: article.IsOnline,
|
|
SiteID: article.SiteID,
|
|
}
|
|
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 := tx.Create(&created).Error; err != nil {
|
|
return err
|
|
}
|
|
return replaceCredits(tx, created.ID, bylineIDs(article.Authors))
|
|
})
|
|
if err != nil {
|
|
return domain.Article{}, err
|
|
}
|
|
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(created))
|
|
}
|
|
|
|
func bylineIDs(bylines []authordomain.Byline) []uint32 {
|
|
ids := make([]uint32, 0, len(bylines))
|
|
for _, byline := range bylines {
|
|
ids = append(ids, byline.ID)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
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.Body != nil {
|
|
localArticle.Body = *update.Body
|
|
}
|
|
if update.SiteID != nil {
|
|
localArticle.SiteID = *update.SiteID
|
|
}
|
|
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{}, err
|
|
}
|
|
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle))
|
|
}
|
|
|
|
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 {
|
|
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
|
|
}
|
|
|
|
var _ domain.Repository = &ArticleRepository{}
|