feed_query_builder.go 7.9 KB

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