feed_query_builder.go 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
  2. // SPDX-License-Identifier: Apache-2.0
  3. package storage // import "miniflux.app/v2/internal/storage"
  4. import (
  5. "database/sql"
  6. "fmt"
  7. "strconv"
  8. "strings"
  9. "github.com/lib/pq"
  10. "miniflux.app/v2/internal/model"
  11. "miniflux.app/v2/internal/timezone"
  12. )
  13. // feedQueryBuilder builds a SQL query to fetch feeds.
  14. type feedQueryBuilder struct {
  15. db *sql.DB
  16. args []any
  17. conditions []string
  18. sortExpressions []string
  19. limit int
  20. offset int
  21. withCounters bool
  22. counterJoinFeeds bool
  23. counterArgs []any
  24. counterConditions []string
  25. }
  26. // NewFeedQueryBuilder returns a new FeedQueryBuilder.
  27. func (s *Storage) NewFeedQueryBuilder(userID int64) *feedQueryBuilder {
  28. return &feedQueryBuilder{
  29. db: s.db,
  30. args: []any{userID},
  31. conditions: []string{"f.user_id = $1"},
  32. counterArgs: []any{userID, model.EntryStatusRead, model.EntryStatusUnread},
  33. counterConditions: []string{"e.user_id = $1", "e.status IN ($2, $3)"},
  34. }
  35. }
  36. // WithCategoryID filter by category ID.
  37. func (f *feedQueryBuilder) WithCategoryID(categoryID int64) *feedQueryBuilder {
  38. if categoryID > 0 {
  39. f.conditions = append(f.conditions, "f.category_id = $"+strconv.Itoa(len(f.args)+1))
  40. f.args = append(f.args, categoryID)
  41. f.counterConditions = append(f.counterConditions, "f.category_id = $"+strconv.Itoa(len(f.counterArgs)+1))
  42. f.counterArgs = append(f.counterArgs, categoryID)
  43. f.counterJoinFeeds = true
  44. }
  45. return f
  46. }
  47. // WithFeedID filter by feed ID.
  48. func (f *feedQueryBuilder) WithFeedID(feedID int64) *feedQueryBuilder {
  49. if feedID > 0 {
  50. f.conditions = append(f.conditions, "f.id = $"+strconv.Itoa(len(f.args)+1))
  51. f.args = append(f.args, feedID)
  52. }
  53. return f
  54. }
  55. // WithCounters let the builder return feeds with counters of statuses of entries.
  56. func (f *feedQueryBuilder) WithCounters() *feedQueryBuilder {
  57. f.withCounters = true
  58. return f
  59. }
  60. // WithSorting add a sort expression.
  61. func (f *feedQueryBuilder) WithSorting(column, direction string) *feedQueryBuilder {
  62. switch {
  63. case strings.EqualFold(direction, "ASC"):
  64. f.sortExpressions = append(f.sortExpressions, pq.QuoteIdentifier(column)+" ASC")
  65. case strings.EqualFold(direction, "DESC"):
  66. f.sortExpressions = append(f.sortExpressions, pq.QuoteIdentifier(column)+" DESC")
  67. }
  68. return f
  69. }
  70. // WithLimit set the limit.
  71. func (f *feedQueryBuilder) WithLimit(limit int) *feedQueryBuilder {
  72. f.limit = limit
  73. return f
  74. }
  75. // WithOffset set the offset.
  76. func (f *feedQueryBuilder) WithOffset(offset int) *feedQueryBuilder {
  77. f.offset = offset
  78. return f
  79. }
  80. func (f *feedQueryBuilder) buildCondition() string {
  81. return strings.Join(f.conditions, " AND ")
  82. }
  83. func (f *feedQueryBuilder) buildCounterCondition() string {
  84. return strings.Join(f.counterConditions, " AND ")
  85. }
  86. func (f *feedQueryBuilder) buildSorting() string {
  87. var parts string
  88. if len(f.sortExpressions) > 0 {
  89. parts += " ORDER BY " + strings.Join(f.sortExpressions, ", ")
  90. }
  91. if len(parts) > 0 {
  92. parts += ", lower(f.title) ASC"
  93. }
  94. if f.limit > 0 {
  95. parts += " LIMIT " + strconv.Itoa(f.limit)
  96. }
  97. if f.offset > 0 {
  98. parts += " OFFSET " + strconv.Itoa(f.offset)
  99. }
  100. return parts
  101. }
  102. // GetFeed returns a single feed that match the condition.
  103. func (f *feedQueryBuilder) GetFeed() (*model.Feed, error) {
  104. f.limit = 1
  105. feeds, err := f.GetFeeds()
  106. if err != nil {
  107. return nil, err
  108. }
  109. if len(feeds) != 1 {
  110. return nil, nil
  111. }
  112. return feeds[0], nil
  113. }
  114. // GetFeeds returns a list of feeds that match the condition.
  115. func (f *feedQueryBuilder) GetFeeds() (model.Feeds, error) {
  116. query := `
  117. SELECT
  118. f.id,
  119. f.feed_url,
  120. f.site_url,
  121. f.title,
  122. f.description,
  123. f.etag_header,
  124. f.last_modified_header,
  125. f.user_id,
  126. f.checked_at at time zone u.timezone,
  127. f.next_check_at at time zone u.timezone,
  128. f.parsing_error_count,
  129. f.parsing_error_msg,
  130. f.scraper_rules,
  131. f.rewrite_rules,
  132. f.url_rewrite_rules,
  133. f.blocklist_rules,
  134. f.keeplist_rules,
  135. f.block_filter_entry_rules,
  136. f.keep_filter_entry_rules,
  137. f.crawler,
  138. f.user_agent,
  139. f.cookie,
  140. f.username,
  141. f.password,
  142. f.ignore_http_cache,
  143. f.allow_self_signed_certificates,
  144. f.fetch_via_proxy,
  145. f.disabled,
  146. f.no_media_player,
  147. f.hide_globally,
  148. f.category_id,
  149. c.title as category_title,
  150. c.hide_globally as category_hidden,
  151. fi.icon_id,
  152. i.external_id,
  153. u.timezone,
  154. f.apprise_service_urls,
  155. f.webhook_url,
  156. f.disable_http2,
  157. f.ntfy_enabled,
  158. f.ntfy_priority,
  159. f.ntfy_topic,
  160. f.pushover_enabled,
  161. f.pushover_priority,
  162. f.proxy_url,
  163. f.ignore_entry_updates
  164. FROM
  165. feeds f
  166. LEFT JOIN
  167. categories c ON c.id=f.category_id
  168. LEFT JOIN
  169. feed_icons fi ON fi.feed_id=f.id
  170. LEFT JOIN
  171. icons i ON i.id=fi.icon_id
  172. LEFT JOIN
  173. users u ON u.id=f.user_id
  174. WHERE %s
  175. %s
  176. `
  177. query = fmt.Sprintf(query, f.buildCondition(), f.buildSorting())
  178. readCounters, unreadCounters, err := f.fetchFeedCounter()
  179. if err != nil {
  180. return nil, err
  181. }
  182. rows, err := f.db.Query(query, f.args...)
  183. if err != nil {
  184. return nil, fmt.Errorf(`store: unable to fetch feeds: %w`, err)
  185. }
  186. defer rows.Close()
  187. feeds := make(model.Feeds, 0)
  188. for rows.Next() {
  189. var feed model.Feed
  190. var iconID sql.NullInt64
  191. var externalIconID sql.NullString
  192. var tz string
  193. feed.Category = &model.Category{}
  194. err := rows.Scan(
  195. &feed.ID,
  196. &feed.FeedURL,
  197. &feed.SiteURL,
  198. &feed.Title,
  199. &feed.Description,
  200. &feed.EtagHeader,
  201. &feed.LastModifiedHeader,
  202. &feed.UserID,
  203. &feed.CheckedAt,
  204. &feed.NextCheckAt,
  205. &feed.ParsingErrorCount,
  206. &feed.ParsingErrorMsg,
  207. &feed.ScraperRules,
  208. &feed.RewriteRules,
  209. &feed.UrlRewriteRules,
  210. &feed.BlocklistRules,
  211. &feed.KeeplistRules,
  212. &feed.BlockFilterEntryRules,
  213. &feed.KeepFilterEntryRules,
  214. &feed.Crawler,
  215. &feed.UserAgent,
  216. &feed.Cookie,
  217. &feed.Username,
  218. &feed.Password,
  219. &feed.IgnoreHTTPCache,
  220. &feed.AllowSelfSignedCertificates,
  221. &feed.FetchViaProxy,
  222. &feed.Disabled,
  223. &feed.NoMediaPlayer,
  224. &feed.HideGlobally,
  225. &feed.Category.ID,
  226. &feed.Category.Title,
  227. &feed.Category.HideGlobally,
  228. &iconID,
  229. &externalIconID,
  230. &tz,
  231. &feed.AppriseServiceURLs,
  232. &feed.WebhookURL,
  233. &feed.DisableHTTP2,
  234. &feed.NtfyEnabled,
  235. &feed.NtfyPriority,
  236. &feed.NtfyTopic,
  237. &feed.PushoverEnabled,
  238. &feed.PushoverPriority,
  239. &feed.ProxyURL,
  240. &feed.IgnoreEntryUpdates,
  241. )
  242. if err != nil {
  243. return nil, fmt.Errorf(`store: unable to fetch feeds row: %w`, err)
  244. }
  245. if iconID.Valid && externalIconID.Valid {
  246. feed.Icon = &model.FeedIcon{FeedID: feed.ID, IconID: iconID.Int64, ExternalIconID: externalIconID.String}
  247. } else {
  248. feed.Icon = &model.FeedIcon{FeedID: feed.ID, IconID: 0, ExternalIconID: ""}
  249. }
  250. if readCounters != nil {
  251. if count, found := readCounters[feed.ID]; found {
  252. feed.ReadCount = count
  253. }
  254. }
  255. if unreadCounters != nil {
  256. if count, found := unreadCounters[feed.ID]; found {
  257. feed.UnreadCount = count
  258. }
  259. }
  260. feed.NumberOfVisibleEntries = feed.ReadCount + feed.UnreadCount
  261. feed.CheckedAt = timezone.Convert(tz, feed.CheckedAt)
  262. feed.NextCheckAt = timezone.Convert(tz, feed.NextCheckAt)
  263. feed.Category.UserID = feed.UserID
  264. feeds = append(feeds, &feed)
  265. }
  266. return feeds, nil
  267. }
  268. func (f *feedQueryBuilder) fetchFeedCounter() (unreadCounters map[int64]int, readCounters map[int64]int, err error) {
  269. if !f.withCounters {
  270. return nil, nil, nil
  271. }
  272. query := `
  273. SELECT
  274. e.feed_id,
  275. e.status,
  276. count(*)
  277. FROM
  278. entries e
  279. %s
  280. WHERE
  281. %s
  282. GROUP BY
  283. e.feed_id, e.status
  284. `
  285. join := ""
  286. if f.counterJoinFeeds {
  287. join = "INNER JOIN feeds f ON f.id=e.feed_id"
  288. }
  289. query = fmt.Sprintf(query, join, f.buildCounterCondition())
  290. rows, err := f.db.Query(query, f.counterArgs...)
  291. if err != nil {
  292. return nil, nil, fmt.Errorf(`store: unable to fetch feed counts: %w`, err)
  293. }
  294. defer rows.Close()
  295. readCounters = make(map[int64]int)
  296. unreadCounters = make(map[int64]int)
  297. for rows.Next() {
  298. var feedID int64
  299. var status string
  300. var count int
  301. if err := rows.Scan(&feedID, &status, &count); err != nil {
  302. return nil, nil, fmt.Errorf(`store: unable to fetch feed counter row: %w`, err)
  303. }
  304. switch status {
  305. case model.EntryStatusRead:
  306. readCounters[feedID] = count
  307. case model.EntryStatusUnread:
  308. unreadCounters[feedID] = count
  309. }
  310. }
  311. return readCounters, unreadCounters, nil
  312. }