// Copyright 2017 Frédéric Guillot. All rights reserved. // Use of this source code is governed by the Apache 2.0 // license that can be found in the LICENSE file. package storage // import "miniflux.app/storage" import ( "fmt" "strings" "time" "github.com/lib/pq" "miniflux.app/model" "miniflux.app/timezone" ) // EntryQueryBuilder builds a SQL query to fetch entries. type EntryQueryBuilder struct { store *Storage args []interface{} conditions []string order string direction string limit int offset int } // WithSearchQuery adds full-text search query to the condition. func (e *EntryQueryBuilder) WithSearchQuery(query string) *EntryQueryBuilder { if query != "" { e.conditions = append(e.conditions, fmt.Sprintf("e.document_vectors @@ plainto_tsquery($%d)", len(e.args)+1)) e.args = append(e.args, query) } // ordered by relevance, can be overrode e.WithOrder(fmt.Sprintf("ts_rank(document_vectors, plainto_tsquery('%s'))", query)) e.WithDirection("DESC") return e } // WithStarred adds starred filter. func (e *EntryQueryBuilder) WithStarred() *EntryQueryBuilder { e.conditions = append(e.conditions, "e.starred is true") return e } // BeforeDate adds a condition < published_at func (e *EntryQueryBuilder) BeforeDate(date time.Time) *EntryQueryBuilder { e.conditions = append(e.conditions, fmt.Sprintf("e.published_at < $%d", len(e.args)+1)) e.args = append(e.args, date) return e } // AfterDate adds a condition > published_at func (e *EntryQueryBuilder) AfterDate(date time.Time) *EntryQueryBuilder { e.conditions = append(e.conditions, fmt.Sprintf("e.published_at > $%d", len(e.args)+1)) e.args = append(e.args, date) return e } // BeforeEntryID adds a condition < entryID. func (e *EntryQueryBuilder) BeforeEntryID(entryID int64) *EntryQueryBuilder { if entryID != 0 { e.conditions = append(e.conditions, fmt.Sprintf("e.id < $%d", len(e.args)+1)) e.args = append(e.args, entryID) } return e } // AfterEntryID adds a condition > entryID. func (e *EntryQueryBuilder) AfterEntryID(entryID int64) *EntryQueryBuilder { if entryID != 0 { e.conditions = append(e.conditions, fmt.Sprintf("e.id > $%d", len(e.args)+1)) e.args = append(e.args, entryID) } return e } // WithEntryIDs adds a condition to fetch only the given entry IDs. func (e *EntryQueryBuilder) WithEntryIDs(entryIDs []int64) *EntryQueryBuilder { e.conditions = append(e.conditions, fmt.Sprintf("e.id = ANY($%d)", len(e.args)+1)) e.args = append(e.args, pq.Array(entryIDs)) return e } // WithEntryID set the entryID. func (e *EntryQueryBuilder) WithEntryID(entryID int64) *EntryQueryBuilder { if entryID != 0 { e.conditions = append(e.conditions, fmt.Sprintf("e.id = $%d", len(e.args)+1)) e.args = append(e.args, entryID) } return e } // WithFeedID set the feedID. func (e *EntryQueryBuilder) WithFeedID(feedID int64) *EntryQueryBuilder { if feedID != 0 { e.conditions = append(e.conditions, fmt.Sprintf("e.feed_id = $%d", len(e.args)+1)) e.args = append(e.args, feedID) } return e } // WithCategoryID set the categoryID. func (e *EntryQueryBuilder) WithCategoryID(categoryID int64) *EntryQueryBuilder { if categoryID > 0 { e.conditions = append(e.conditions, fmt.Sprintf("f.category_id = $%d", len(e.args)+1)) e.args = append(e.args, categoryID) } return e } // WithStatus set the entry status. func (e *EntryQueryBuilder) WithStatus(status string) *EntryQueryBuilder { if status != "" { e.conditions = append(e.conditions, fmt.Sprintf("e.status = $%d", len(e.args)+1)) e.args = append(e.args, status) } return e } // WithoutStatus set the entry status that should not be returned. func (e *EntryQueryBuilder) WithoutStatus(status string) *EntryQueryBuilder { if status != "" { e.conditions = append(e.conditions, fmt.Sprintf("e.status <> $%d", len(e.args)+1)) e.args = append(e.args, status) } return e } // WithOrder set the sorting order. func (e *EntryQueryBuilder) WithOrder(order string) *EntryQueryBuilder { e.order = order return e } // WithDirection set the sorting direction. func (e *EntryQueryBuilder) WithDirection(direction string) *EntryQueryBuilder { e.direction = direction return e } // WithLimit set the limit. func (e *EntryQueryBuilder) WithLimit(limit int) *EntryQueryBuilder { e.limit = limit return e } // WithOffset set the offset. func (e *EntryQueryBuilder) WithOffset(offset int) *EntryQueryBuilder { e.offset = offset return e } // CountEntries count the number of entries that match the condition. func (e *EntryQueryBuilder) CountEntries() (count int, err error) { query := `SELECT count(*) FROM entries e LEFT JOIN feeds f ON f.id=e.feed_id WHERE %s` condition := e.buildCondition() err = e.store.db.QueryRow(fmt.Sprintf(query, condition), e.args...).Scan(&count) if err != nil { return 0, fmt.Errorf("unable to count entries: %v", err) } return count, nil } // GetEntry returns a single entry that match the condition. func (e *EntryQueryBuilder) GetEntry() (*model.Entry, error) { e.limit = 1 entries, err := e.GetEntries() if err != nil { return nil, err } if len(entries) != 1 { return nil, nil } entries[0].Enclosures, err = e.store.GetEnclosures(entries[0].ID) if err != nil { return nil, err } return entries[0], nil } // GetEntries returns a list of entries that match the condition. func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) { query := ` SELECT e.id, e.user_id, e.feed_id, e.hash, e.published_at at time zone u.timezone, e.title, e.url, e.comments_url, e.author, e.content, e.status, e.starred, f.title as feed_title, f.feed_url, f.site_url, f.checked_at, f.category_id, c.title as category_title, f.scraper_rules, f.rewrite_rules, f.crawler, f.user_agent, fi.icon_id, u.timezone FROM entries e LEFT JOIN feeds f ON f.id=e.feed_id 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=e.user_id WHERE %s %s ` condition := e.buildCondition() sorting := e.buildSorting() query = fmt.Sprintf(query, condition, sorting) rows, err := e.store.db.Query(query, e.args...) if err != nil { return nil, fmt.Errorf("unable to get entries: %v", err) } defer rows.Close() entries := make(model.Entries, 0) for rows.Next() { var entry model.Entry var iconID interface{} var tz string entry.Feed = &model.Feed{} entry.Feed.Category = &model.Category{} entry.Feed.Icon = &model.FeedIcon{} err := rows.Scan( &entry.ID, &entry.UserID, &entry.FeedID, &entry.Hash, &entry.Date, &entry.Title, &entry.URL, &entry.CommentsURL, &entry.Author, &entry.Content, &entry.Status, &entry.Starred, &entry.Feed.Title, &entry.Feed.FeedURL, &entry.Feed.SiteURL, &entry.Feed.CheckedAt, &entry.Feed.Category.ID, &entry.Feed.Category.Title, &entry.Feed.ScraperRules, &entry.Feed.RewriteRules, &entry.Feed.Crawler, &entry.Feed.UserAgent, &iconID, &tz, ) if err != nil { return nil, fmt.Errorf("unable to fetch entry row: %v", err) } if iconID == nil { entry.Feed.Icon.IconID = 0 } else { entry.Feed.Icon.IconID = iconID.(int64) } // Make sure that timestamp fields contains timezone information (API) entry.Date = timezone.Convert(tz, entry.Date) entry.Feed.CheckedAt = timezone.Convert(tz, entry.Feed.CheckedAt) entry.Feed.ID = entry.FeedID entry.Feed.UserID = entry.UserID entry.Feed.Icon.FeedID = entry.FeedID entry.Feed.Category.UserID = entry.UserID entries = append(entries, &entry) } return entries, nil } // GetEntryIDs returns a list of entry IDs that match the condition. func (e *EntryQueryBuilder) GetEntryIDs() ([]int64, error) { query := `SELECT e.id FROM entries e LEFT JOIN feeds f ON f.id=e.feed_id WHERE %s %s` condition := e.buildCondition() query = fmt.Sprintf(query, condition, e.buildSorting()) rows, err := e.store.db.Query(query, e.args...) if err != nil { return nil, fmt.Errorf("unable to get entries: %v", err) } defer rows.Close() var entryIDs []int64 for rows.Next() { var entryID int64 err := rows.Scan(&entryID) if err != nil { return nil, fmt.Errorf("unable to fetch entry row: %v", err) } entryIDs = append(entryIDs, entryID) } return entryIDs, nil } func (e *EntryQueryBuilder) buildCondition() string { return strings.Join(e.conditions, " AND ") } func (e *EntryQueryBuilder) buildSorting() string { var parts []string if e.order != "" { parts = append(parts, fmt.Sprintf(`ORDER BY %s`, e.order)) } if e.direction != "" { parts = append(parts, fmt.Sprintf(`%s`, e.direction)) } if e.limit != 0 { parts = append(parts, fmt.Sprintf(`LIMIT %d`, e.limit)) } if e.offset != 0 { parts = append(parts, fmt.Sprintf(`OFFSET %d`, e.offset)) } return strings.Join(parts, " ") } // NewEntryQueryBuilder returns a new EntryQueryBuilder. func NewEntryQueryBuilder(store *Storage, userID int64) *EntryQueryBuilder { return &EntryQueryBuilder{ store: store, args: []interface{}{userID}, conditions: []string{"e.user_id = $1"}, } }