// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved. // SPDX-License-Identifier: Apache-2.0 package storage // import "miniflux.app/v2/internal/storage" import ( "database/sql" "fmt" "strings" "miniflux.app/v2/internal/model" "miniflux.app/v2/internal/timezone" ) // FeedQueryBuilder builds a SQL query to fetch feeds. type FeedQueryBuilder struct { store *Storage args []interface{} conditions []string sortExpressions []string limit int offset int withCounters bool counterJoinFeeds bool counterArgs []interface{} counterConditions []string } // NewFeedQueryBuilder returns a new FeedQueryBuilder. func NewFeedQueryBuilder(store *Storage, userID int64) *FeedQueryBuilder { return &FeedQueryBuilder{ store: store, args: []interface{}{userID}, conditions: []string{"f.user_id = $1"}, counterArgs: []interface{}{userID, model.EntryStatusRead, model.EntryStatusUnread}, counterConditions: []string{"e.user_id = $1", "e.status IN ($2, $3)"}, } } // WithCategoryID filter by category ID. func (f *FeedQueryBuilder) WithCategoryID(categoryID int64) *FeedQueryBuilder { if categoryID > 0 { f.conditions = append(f.conditions, fmt.Sprintf("f.category_id = $%d", len(f.args)+1)) f.args = append(f.args, categoryID) f.counterConditions = append(f.counterConditions, fmt.Sprintf("f.category_id = $%d", len(f.counterArgs)+1)) f.counterArgs = append(f.counterArgs, categoryID) f.counterJoinFeeds = true } return f } // WithFeedID filter by feed ID. func (f *FeedQueryBuilder) WithFeedID(feedID int64) *FeedQueryBuilder { if feedID > 0 { f.conditions = append(f.conditions, fmt.Sprintf("f.id = $%d", len(f.args)+1)) f.args = append(f.args, feedID) } return f } // WithCounters let the builder return feeds with counters of statuses of entries. func (f *FeedQueryBuilder) WithCounters() *FeedQueryBuilder { f.withCounters = true return f } // WithSorting add a sort expression. func (f *FeedQueryBuilder) WithSorting(column, direction string) *FeedQueryBuilder { f.sortExpressions = append(f.sortExpressions, fmt.Sprintf("%s %s", column, direction)) return f } // WithLimit set the limit. func (f *FeedQueryBuilder) WithLimit(limit int) *FeedQueryBuilder { f.limit = limit return f } // WithOffset set the offset. func (f *FeedQueryBuilder) WithOffset(offset int) *FeedQueryBuilder { f.offset = offset return f } func (f *FeedQueryBuilder) buildCondition() string { return strings.Join(f.conditions, " AND ") } func (f *FeedQueryBuilder) buildCounterCondition() string { return strings.Join(f.counterConditions, " AND ") } func (f *FeedQueryBuilder) buildSorting() string { var parts []string if len(f.sortExpressions) > 0 { parts = append(parts, fmt.Sprintf(`ORDER BY %s`, strings.Join(f.sortExpressions, ", "))) } if len(parts) > 0 { parts = append(parts, ", lower(f.title) ASC") } if f.limit > 0 { parts = append(parts, fmt.Sprintf(`LIMIT %d`, f.limit)) } if f.offset > 0 { parts = append(parts, fmt.Sprintf(`OFFSET %d`, f.offset)) } return strings.Join(parts, " ") } // GetFeed returns a single feed that match the condition. func (f *FeedQueryBuilder) GetFeed() (*model.Feed, error) { f.limit = 1 feeds, err := f.GetFeeds() if err != nil { return nil, err } if len(feeds) != 1 { return nil, nil } return feeds[0], nil } // GetFeeds returns a list of feeds that match the condition. func (f *FeedQueryBuilder) GetFeeds() (model.Feeds, error) { var query = ` SELECT f.id, f.feed_url, f.site_url, f.title, f.etag_header, f.last_modified_header, f.user_id, f.checked_at at time zone u.timezone, f.parsing_error_count, f.parsing_error_msg, f.scraper_rules, f.rewrite_rules, f.blocklist_rules, f.keeplist_rules, f.url_rewrite_rules, f.crawler, f.user_agent, f.cookie, f.username, f.password, f.ignore_http_cache, f.allow_self_signed_certificates, f.fetch_via_proxy, f.disabled, f.no_media_player, f.hide_globally, f.category_id, c.title as category_title, c.hide_globally as category_hidden, fi.icon_id, u.timezone, f.apprise_service_urls FROM feeds f LEFT JOIN categories c ON c.id=f.category_id LEFT JOIN feed_icons fi ON fi.feed_id=f.id LEFT JOIN users u ON u.id=f.user_id WHERE %s %s ` query = fmt.Sprintf(query, f.buildCondition(), f.buildSorting()) rows, err := f.store.db.Query(query, f.args...) if err != nil { return nil, fmt.Errorf(`store: unable to fetch feeds: %w`, err) } defer rows.Close() readCounters, unreadCounters, err := f.fetchFeedCounter() if err != nil { return nil, err } feeds := make(model.Feeds, 0) for rows.Next() { var feed model.Feed var iconID sql.NullInt64 var tz string feed.Category = &model.Category{} err := rows.Scan( &feed.ID, &feed.FeedURL, &feed.SiteURL, &feed.Title, &feed.EtagHeader, &feed.LastModifiedHeader, &feed.UserID, &feed.CheckedAt, &feed.ParsingErrorCount, &feed.ParsingErrorMsg, &feed.ScraperRules, &feed.RewriteRules, &feed.BlocklistRules, &feed.KeeplistRules, &feed.UrlRewriteRules, &feed.Crawler, &feed.UserAgent, &feed.Cookie, &feed.Username, &feed.Password, &feed.IgnoreHTTPCache, &feed.AllowSelfSignedCertificates, &feed.FetchViaProxy, &feed.Disabled, &feed.NoMediaPlayer, &feed.HideGlobally, &feed.Category.ID, &feed.Category.Title, &feed.Category.HideGlobally, &iconID, &tz, &feed.AppriseServiceURLs, ) if err != nil { return nil, fmt.Errorf(`store: unable to fetch feeds row: %w`, err) } if iconID.Valid { feed.Icon = &model.FeedIcon{FeedID: feed.ID, IconID: iconID.Int64} } else { feed.Icon = &model.FeedIcon{FeedID: feed.ID, IconID: 0} } if readCounters != nil { if count, found := readCounters[feed.ID]; found { feed.ReadCount = count } } if unreadCounters != nil { if count, found := unreadCounters[feed.ID]; found { feed.UnreadCount = count } } feed.CheckedAt = timezone.Convert(tz, feed.CheckedAt) feed.Category.UserID = feed.UserID feeds = append(feeds, &feed) } return feeds, nil } func (f *FeedQueryBuilder) fetchFeedCounter() (unreadCounters map[int64]int, readCounters map[int64]int, err error) { if !f.withCounters { return nil, nil, nil } query := ` SELECT e.feed_id, e.status, count(*) FROM entries e %s WHERE %s GROUP BY e.feed_id, e.status ` join := "" if f.counterJoinFeeds { join = "LEFT JOIN feeds f ON f.id=e.feed_id" } query = fmt.Sprintf(query, join, f.buildCounterCondition()) rows, err := f.store.db.Query(query, f.counterArgs...) if err != nil { return nil, nil, fmt.Errorf(`store: unable to fetch feed counts: %w`, err) } defer rows.Close() readCounters = make(map[int64]int) unreadCounters = make(map[int64]int) for rows.Next() { var feedID int64 var status string var count int if err := rows.Scan(&feedID, &status, &count); err != nil { return nil, nil, fmt.Errorf(`store: unable to fetch feed counter row: %w`, err) } if status == model.EntryStatusRead { readCounters[feedID] = count } else if status == model.EntryStatusUnread { unreadCounters[feedID] = count } } return readCounters, unreadCounters, nil }