package persistence import ( domain "RenewCMS/internal/domain/article" 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.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(lacksReadableVersionIn("?", filters, "")) condition.merge(missingCondition(filters)) } 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) 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)) }