367 lines
12 KiB
Go
367 lines
12 KiB
Go
package persistence
|
|
|
|
import (
|
|
domain "RenewCMS/internal/domain/article"
|
|
"RenewCMS/internal/domain/language"
|
|
entity "RenewCMS/internal/infrastructure/persistence/models"
|
|
"strings"
|
|
)
|
|
|
|
type storySQL struct {
|
|
conditions []string
|
|
args []any
|
|
}
|
|
|
|
func (s *storySQL) add(condition string, args ...any) {
|
|
s.conditions = append(s.conditions, condition)
|
|
s.args = append(s.args, args...)
|
|
}
|
|
|
|
func (s storySQL) where() string {
|
|
return strings.Join(s.conditions, " AND ")
|
|
}
|
|
|
|
func readableCondition(alias string, filters domain.StoryFilters) storySQL {
|
|
condition := storySQL{}
|
|
condition.add(alias + ".deleted_at IS NULL")
|
|
if filters.OnlyOnline {
|
|
condition.add(alias+".is_online = ?", true)
|
|
}
|
|
if filters.VisibleTo != nil {
|
|
condition.add("("+alias+".is_online = ? OR "+alias+".created_by = ?)", true, *filters.VisibleTo)
|
|
}
|
|
return condition
|
|
}
|
|
|
|
func (s *storySQL) merge(other storySQL) {
|
|
s.conditions = append(s.conditions, other.conditions...)
|
|
s.args = append(s.args, other.args...)
|
|
}
|
|
|
|
func lacksReadableVersionIn(languageExpression string, filters domain.StoryFilters, languageArgs ...any) storySQL {
|
|
readable := readableCondition("w", filters)
|
|
condition := storySQL{}
|
|
condition.add("NOT EXISTS (SELECT 1 FROM articles w WHERE w.story_id = q.story_id AND "+readable.where()+" AND w.lang = "+languageExpression+")", append(readable.args, languageArgs...)...)
|
|
return condition
|
|
}
|
|
|
|
func (a *ArticleRepository) FindTitleMatches(q string, filters domain.StoryFilters) ([]uint32, error) {
|
|
readable := readableCondition("q", filters)
|
|
if filters.SiteID != nil {
|
|
readable.add("q.site_id = ?", *filters.SiteID)
|
|
}
|
|
var candidates []entity.Article
|
|
if err := a.db.Raw("SELECT q.id AS id, q.title AS title FROM articles q WHERE "+readable.where(), readable.args...).Scan(&candidates).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
needle := foldForSearch(strings.TrimSpace(q))
|
|
matching := make([]uint32, 0)
|
|
for _, candidate := range candidates {
|
|
if strings.Contains(foldForSearch(candidate.Title), needle) {
|
|
matching = append(matching, candidate.ID)
|
|
}
|
|
}
|
|
return matching, nil
|
|
}
|
|
|
|
type versionSet map[uint32]bool
|
|
|
|
func restrictionOf(filters domain.StoryFilters) versionSet {
|
|
if filters.VersionIDs == nil {
|
|
return nil
|
|
}
|
|
set := make(versionSet, len(*filters.VersionIDs))
|
|
for _, id := range *filters.VersionIDs {
|
|
set[id] = true
|
|
}
|
|
return set
|
|
}
|
|
|
|
func (set versionSet) keep(rows []storyRefRow) []storyRefRow {
|
|
kept := make([]storyRefRow, 0, len(rows))
|
|
for _, row := range rows {
|
|
if set[row.ArticleID] {
|
|
kept = append(kept, row)
|
|
}
|
|
}
|
|
return kept
|
|
}
|
|
|
|
func storiesOf(rows []storyRefRow) map[uint32]bool {
|
|
stories := make(map[uint32]bool, len(rows))
|
|
for _, row := range rows {
|
|
stories[row.StoryID] = true
|
|
}
|
|
return stories
|
|
}
|
|
|
|
func unitsIn(filters domain.StoryFilters, rows []storyRefRow) int {
|
|
if filters.PerLanguage() {
|
|
return len(rows)
|
|
}
|
|
return len(storiesOf(rows))
|
|
}
|
|
|
|
func pageOf(rows []storyRefRow, offset int, size int) []storyRefRow {
|
|
start := min(offset, len(rows))
|
|
return rows[start:min(start+size, len(rows))]
|
|
}
|
|
|
|
func (a *ArticleRepository) qualifyingRefs(filters domain.StoryFilters, restriction versionSet) ([]storyRefRow, error) {
|
|
qualifying := qualifyingVersions(filters)
|
|
var rows []storyRefRow
|
|
err := a.db.Raw("SELECT q.story_id AS story_id, q.id AS article_id FROM articles q WHERE "+qualifying.where(), qualifying.args...).Scan(&rows).Error
|
|
return restriction.keep(rows), err
|
|
}
|
|
|
|
func qualifyingVersions(filters domain.StoryFilters) storySQL {
|
|
condition := readableCondition("q", filters)
|
|
if filters.SiteID != nil {
|
|
condition.add("q.site_id = ?", *filters.SiteID)
|
|
}
|
|
if filters.PerLanguage() {
|
|
condition.add("q.lang = ?", filters.Lang)
|
|
}
|
|
switch filters.Status {
|
|
case domain.StatusOnline:
|
|
condition.add("q.is_online = ?", true)
|
|
case domain.StatusDraft:
|
|
condition.add("q.is_online = ?", false)
|
|
}
|
|
if filters.SEOStatus != "" {
|
|
condition.add("q.seo_status = ?", filters.SEOStatus)
|
|
}
|
|
if filters.AuthorID != nil {
|
|
condition.add("q.id IN (SELECT aa.article_id FROM article_authors aa WHERE aa.author_id = ?)", *filters.AuthorID)
|
|
}
|
|
if filters.Missing {
|
|
condition.merge(holdsReadableLanguage(filters))
|
|
condition.merge(missingCondition(filters))
|
|
}
|
|
return condition
|
|
}
|
|
|
|
func holdsReadableLanguage(filters domain.StoryFilters) storySQL {
|
|
readable := readableCondition("l", filters)
|
|
condition := storySQL{}
|
|
condition.add("EXISTS (SELECT 1 FROM articles l WHERE l.story_id = q.story_id AND "+readable.where()+" AND l.lang <> ?)", append(readable.args, language.Unassigned)...)
|
|
return condition
|
|
}
|
|
|
|
func missingCondition(filters domain.StoryFilters) storySQL {
|
|
condition := storySQL{}
|
|
if filters.Lang != "" {
|
|
condition.add("EXISTS (SELECT 1 FROM site_languages sl WHERE sl.site_id = q.site_id AND sl.lang = ? AND sl.enabled = ?)", filters.Lang, true)
|
|
condition.merge(lacksReadableVersionIn("?", filters, filters.Lang))
|
|
return condition
|
|
}
|
|
absent := lacksReadableVersionIn("sl.lang", filters)
|
|
condition.add("EXISTS (SELECT 1 FROM site_languages sl WHERE sl.site_id = q.site_id AND sl.enabled = ? AND "+strings.Join(absent.conditions, " AND ")+")", append([]any{true}, absent.args...)...)
|
|
return condition
|
|
}
|
|
|
|
func unitExpression(filters domain.StoryFilters) string {
|
|
if filters.PerLanguage() {
|
|
return "COUNT(*)"
|
|
}
|
|
return "COUNT(DISTINCT q.story_id)"
|
|
}
|
|
|
|
func (a *ArticleRepository) countUnits(filters domain.StoryFilters, restriction versionSet) (int, error) {
|
|
if restriction != nil {
|
|
rows, err := a.qualifyingRefs(filters, restriction)
|
|
return unitsIn(filters, rows), err
|
|
}
|
|
qualifying := qualifyingVersions(filters)
|
|
var total int64
|
|
err := a.db.Raw("SELECT "+unitExpression(filters)+" FROM articles q WHERE "+qualifying.where(), qualifying.args...).Scan(&total).Error
|
|
return int(total), err
|
|
}
|
|
|
|
var storyRowOrder = map[domain.SortField]string{
|
|
domain.SortByTitle: "s.sort_title",
|
|
domain.SortByStatus: "s.sort_online",
|
|
domain.SortByCreatedAt: "s.sort_created",
|
|
domain.SortByUpdatedAt: "s.sort_updated",
|
|
}
|
|
|
|
var versionRowOrder = map[domain.SortField]string{
|
|
domain.SortByTitle: "LOWER(q.title)",
|
|
domain.SortByStatus: "q.is_online",
|
|
domain.SortByCreatedAt: "q.created_at",
|
|
domain.SortByUpdatedAt: "q.updated_at",
|
|
}
|
|
|
|
func storyOrderClause(columns map[domain.SortField]string, sort domain.Sort, storyColumn string) string {
|
|
column, ok := columns[sort.Field]
|
|
direction, directionOk := sortDirections[sort.Direction]
|
|
if !ok || !directionOk {
|
|
column, direction = columns[domain.SortByUpdatedAt], sortDirections[domain.SortDesc]
|
|
}
|
|
return column + " " + direction + ", " + storyColumn + " DESC"
|
|
}
|
|
|
|
type storyRefRow struct {
|
|
StoryID uint32
|
|
ArticleID uint32
|
|
}
|
|
|
|
func toStoryRefs(rows []storyRefRow) []domain.StoryRef {
|
|
refs := make([]domain.StoryRef, 0, len(rows))
|
|
for _, row := range rows {
|
|
refs = append(refs, domain.StoryRef{StoryID: row.StoryID, ArticleID: row.ArticleID})
|
|
}
|
|
return refs
|
|
}
|
|
|
|
func (a *ArticleRepository) FindStoryPage(filters domain.StoryFilters) (domain.StoryPage, error) {
|
|
filters.Page = max(filters.Page, 1)
|
|
filters.Size = max(filters.Size, 0)
|
|
restriction := restrictionOf(filters)
|
|
total, err := a.countUnits(filters, restriction)
|
|
if err != nil {
|
|
return domain.StoryPage{}, err
|
|
}
|
|
page := domain.StoryPage{Refs: []domain.StoryRef{}, Total: total}
|
|
if total == 0 {
|
|
return page, nil
|
|
}
|
|
offset := (filters.Page - 1) * filters.Size
|
|
var rows []storyRefRow
|
|
if restriction != nil {
|
|
rows, err = a.restrictedStoryRows(filters, restriction)
|
|
rows = pageOf(rows, offset, filters.Size)
|
|
} else {
|
|
rows, err = a.storyRows(filters, " LIMIT ? OFFSET ?", filters.Size, offset)
|
|
}
|
|
if err != nil {
|
|
return domain.StoryPage{}, err
|
|
}
|
|
page.Refs = toStoryRefs(rows)
|
|
return page, nil
|
|
}
|
|
|
|
func (a *ArticleRepository) storyRows(filters domain.StoryFilters, suffix string, extra ...any) ([]storyRefRow, error) {
|
|
qualifying := qualifyingVersions(filters)
|
|
var query string
|
|
var args []any
|
|
if filters.PerLanguage() {
|
|
query = "SELECT q.story_id AS story_id, q.id AS article_id FROM articles q WHERE " + qualifying.where() +
|
|
" ORDER BY " + storyOrderClause(versionRowOrder, filters.Sort, "q.story_id")
|
|
args = qualifying.args
|
|
} else {
|
|
query, args = storyRowsQuery(filters, qualifying)
|
|
}
|
|
var rows []storyRefRow
|
|
err := a.db.Raw(query+suffix, append(args, extra...)...).Scan(&rows).Error
|
|
return rows, err
|
|
}
|
|
|
|
func (a *ArticleRepository) restrictedStoryRows(filters domain.StoryFilters, restriction versionSet) ([]storyRefRow, error) {
|
|
ordered, err := a.storyRows(filters, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if filters.PerLanguage() {
|
|
return restriction.keep(ordered), nil
|
|
}
|
|
qualifying, err := a.qualifyingRefs(filters, restriction)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stories := storiesOf(qualifying)
|
|
kept := make([]storyRefRow, 0, len(stories))
|
|
for _, row := range ordered {
|
|
if stories[row.StoryID] {
|
|
kept = append(kept, row)
|
|
}
|
|
}
|
|
return kept, nil
|
|
}
|
|
|
|
func storyRowsQuery(filters domain.StoryFilters, qualifying storySQL) (string, []any) {
|
|
readable := readableCondition("a", filters)
|
|
args := []any{true}
|
|
args = append(args, readable.args...)
|
|
args = append(args, qualifying.args...)
|
|
query := "SELECT s.story_id AS story_id, s.article_id AS article_id FROM (" +
|
|
"SELECT r.story_id AS story_id, r.id AS article_id, r.sort_title AS sort_title, r.sort_online AS sort_online, r.sort_updated AS sort_updated, r.sort_created AS sort_created FROM (" +
|
|
"SELECT a.id AS id, a.story_id AS story_id, LOWER(a.title) AS sort_title, a.is_online AS sort_online, " +
|
|
"MAX(a.updated_at) OVER (PARTITION BY a.story_id) AS sort_updated, " +
|
|
"MIN(a.created_at) OVER (PARTITION BY a.story_id) AS sort_created, " +
|
|
"ROW_NUMBER() OVER (PARTITION BY a.story_id ORDER BY CASE WHEN d.lang IS NULL THEN 1 ELSE 0 END, a.lang, a.id) AS row_rank " +
|
|
"FROM articles a LEFT JOIN site_languages d ON d.site_id = a.site_id AND d.lang = a.lang AND d.is_default = ? " +
|
|
"WHERE " + readable.where() + " AND a.story_id IN (SELECT q.story_id FROM articles q WHERE " + qualifying.where() + ")" +
|
|
") r WHERE r.row_rank = 1) s ORDER BY " + storyOrderClause(storyRowOrder, filters.Sort, "s.story_id")
|
|
return query, args
|
|
}
|
|
|
|
func (a *ArticleRepository) CountStories(filters domain.StoryFilters) (domain.StoryCounts, error) {
|
|
filters.Status = ""
|
|
restriction := restrictionOf(filters)
|
|
counts := domain.StoryCounts{}
|
|
var err error
|
|
for _, target := range []struct {
|
|
status string
|
|
into *int
|
|
}{{"", &counts.All}, {domain.StatusOnline, &counts.Online}, {domain.StatusDraft, &counts.Draft}} {
|
|
scoped := filters
|
|
scoped.Missing = false
|
|
scoped.Status = target.status
|
|
if *target.into, err = a.countUnits(scoped, restriction); err != nil {
|
|
return domain.StoryCounts{}, err
|
|
}
|
|
}
|
|
if filters.SkipMissing {
|
|
return counts, nil
|
|
}
|
|
missing := filters
|
|
missing.Missing = true
|
|
counts.Missing, err = a.countUnits(missing, restriction)
|
|
return counts, err
|
|
}
|
|
|
|
func (a *ArticleRepository) CountSuggestedStories(readable domain.StoryFilters) (int, error) {
|
|
condition := readableCondition("q", readable)
|
|
condition.add("q.seo_status = ?", domain.SEOStatusSuggestions)
|
|
var stories, standalone int64
|
|
if err := a.db.Raw("SELECT COUNT(DISTINCT q.story_id) FROM articles q WHERE "+condition.where()+" AND q.story_id <> ?", append(condition.args, 0)...).Scan(&stories).Error; err != nil {
|
|
return 0, err
|
|
}
|
|
err := a.db.Raw("SELECT COUNT(*) FROM articles q WHERE "+condition.where()+" AND q.story_id = ?", append(condition.args, 0)...).Scan(&standalone).Error
|
|
return int(stories + standalone), err
|
|
}
|
|
|
|
func (a *ArticleRepository) ListVersions(ids []uint32) ([]domain.Article, error) {
|
|
var found []entity.Article
|
|
for _, chunk := range chunkIDs(ids) {
|
|
var loaded []entity.Article
|
|
if err := a.db.Model(&entity.Article{}).Where("id IN ?", chunk).Find(&loaded).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
found = append(found, loaded...)
|
|
}
|
|
return a.withAuthors(a.db, toDomainArticles(found))
|
|
}
|
|
|
|
var _ domain.StoryReader = &ArticleRepository{}
|
|
|
|
func (a *ArticleRepository) ListByStories(storyIDs []uint32, filters domain.StoryFilters) ([]domain.Article, error) {
|
|
var found []entity.Article
|
|
for _, chunk := range chunkIDs(storyIDs) {
|
|
var loaded []entity.Article
|
|
query := a.db.Model(&entity.Article{}).Omit("body").Where("story_id IN ?", chunk)
|
|
if filters.OnlyOnline {
|
|
query = query.Where("is_online = ?", true)
|
|
}
|
|
if filters.VisibleTo != nil {
|
|
query = query.Where("(is_online = ? OR created_by = ?)", true, *filters.VisibleTo)
|
|
}
|
|
if err := query.Order("id ASC").Find(&loaded).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
found = append(found, loaded...)
|
|
}
|
|
return a.withAuthors(a.db, toDomainArticles(found))
|
|
}
|