253 lines
8.4 KiB
Go
253 lines
8.4 KiB
Go
package repositories
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"thamanyah/discovery/internal/models"
|
|
"time"
|
|
"unicode"
|
|
|
|
"github.com/lib/pq"
|
|
)
|
|
|
|
type VideoRepository struct {
|
|
SQLDB *sql.DB
|
|
}
|
|
|
|
var VideoRepo VideoRepository
|
|
|
|
// SaveVideo records an announced video, replacing what the catalogue already
|
|
// held for that id.
|
|
//
|
|
// The upsert is what makes the consumer safe to retry: the catalogue topic
|
|
// delivers at-least-once, so the same announcement can arrive twice, and a
|
|
// plain INSERT would fail the second time on the primary key. Re-announcing a
|
|
// video is also how it is corrected — a later announcement wins, which is why
|
|
// every column is overwritten rather than merged.
|
|
func (svc VideoRepository) SaveVideo(ctx context.Context, v models.Video) error {
|
|
_, err := svc.SQLDB.ExecContext(ctx, `
|
|
INSERT INTO videos (id, title, playback_url, categories)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (id) DO UPDATE
|
|
SET title = EXCLUDED.title,
|
|
playback_url = EXCLUDED.playback_url,
|
|
categories = EXCLUDED.categories,
|
|
updated_at = now()
|
|
`, v.ID, v.Title, v.PlaybackURL, pq.Array(v.Categories))
|
|
return err
|
|
}
|
|
|
|
// GetVideoByID reads the catalogue's copy of one video. It returns
|
|
// ErrVideoNotFound when the catalogue has not heard about it — which, for a
|
|
// video cms has only just made ready, is a matter of timing rather than a
|
|
// mistake.
|
|
func (svc VideoRepository) GetVideoByID(ctx context.Context, id string) (models.Video, error) {
|
|
var v models.Video
|
|
|
|
err := svc.SQLDB.QueryRowContext(ctx, `
|
|
SELECT id, title, playback_url, categories, created_at, updated_at
|
|
FROM videos
|
|
WHERE id = $1
|
|
`, id).Scan(&v.ID, &v.Title, &v.PlaybackURL, pq.Array(&v.Categories), &v.CreatedAt, &v.UpdatedAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return models.Video{}, ErrVideoNotFound
|
|
}
|
|
if err != nil {
|
|
return models.Video{}, err
|
|
}
|
|
|
|
return v, nil
|
|
}
|
|
|
|
// searchConfig is the text search configuration the search_vector column is
|
|
// generated with. Every query has to name the same one: a tsquery built under
|
|
// a different configuration stems its terms differently and matches nothing.
|
|
const searchConfig = "arabic"
|
|
|
|
// tsQueryFor turns what a reader typed into a tsquery.
|
|
//
|
|
// The words are ANDed, and the last one is given a :* prefix match — someone
|
|
// typing into a search box is usually part-way through their last word, so
|
|
// "desert fal" should still find "Desert Falcons". Earlier words are matched
|
|
// whole, because they have been finished.
|
|
//
|
|
// The input is split on everything that is not a letter or a digit, which
|
|
// drops every character tsquery gives a meaning to (&, |, !, :, *, parens) and
|
|
// leaves nothing that could change the shape of the query. That is what makes
|
|
// it safe to interpolate the result into to_tsquery: the alternative,
|
|
// websearch_to_tsquery, parses user syntax but cannot express a prefix match.
|
|
//
|
|
// It returns "" when there is nothing to search for, which callers read as
|
|
// "no title filter" rather than "match nothing".
|
|
func tsQueryFor(term string) string {
|
|
words := strings.FieldsFunc(term, func(r rune) bool {
|
|
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
|
})
|
|
if len(words) == 0 {
|
|
return ""
|
|
}
|
|
|
|
for i, word := range words {
|
|
words[i] = strings.ToLower(word)
|
|
}
|
|
return strings.Join(words, " & ") + ":*"
|
|
}
|
|
|
|
// searchCursor is where a page of results stopped: the sort key of its last
|
|
// row. Paging resumes by asking for the rows that sort after it.
|
|
//
|
|
// It carries the rank as well as the row's identity because the results are
|
|
// ordered by relevance first, and "the row after this one" is only a
|
|
// well-defined place if every term of the ordering is pinned.
|
|
type searchCursor struct {
|
|
Rank float64 `json:"r"`
|
|
CreatedAt time.Time `json:"t"`
|
|
ID string `json:"i"`
|
|
}
|
|
|
|
// encode renders a cursor as an opaque string. Opaque on purpose: it is a
|
|
// place in a result set, not a number a client should do arithmetic on, and
|
|
// making it look like one invites callers to guess a page.
|
|
func (c searchCursor) encode() (string, error) {
|
|
raw, err := json.Marshal(c)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(raw), nil
|
|
}
|
|
|
|
func decodeCursor(encoded string) (searchCursor, error) {
|
|
raw, err := base64.RawURLEncoding.DecodeString(encoded)
|
|
if err != nil {
|
|
return searchCursor{}, ErrInvalidCursor
|
|
}
|
|
|
|
var c searchCursor
|
|
if err := json.Unmarshal(raw, &c); err != nil {
|
|
return searchCursor{}, ErrInvalidCursor
|
|
}
|
|
if strings.TrimSpace(c.ID) == "" {
|
|
return searchCursor{}, ErrInvalidCursor
|
|
}
|
|
|
|
return c, nil
|
|
}
|
|
|
|
// SearchVideos finds catalogued videos whose title matches the given term and
|
|
// which are filed under any of the given categories, most relevant first.
|
|
//
|
|
// It returns the page and the cursor that reaches the page after it; that
|
|
// cursor is empty on the last page, which is how a caller knows to stop.
|
|
//
|
|
// The title predicate is skipped entirely when the term yields no words, and
|
|
// the category predicate when no categories are named, so a search with
|
|
// neither browses the whole catalogue newest-first.
|
|
//
|
|
// Paging is by key rather than by offset. OFFSET makes the database walk and
|
|
// discard every row it skips, so deep pages get steadily more expensive, and
|
|
// it addresses a page by its position — which moves whenever a video is
|
|
// announced, repeating or skipping rows for a reader mid-browse. Comparing
|
|
// against the last row's sort key has neither problem: the work is the same at
|
|
// any depth, and the page after a given row is that same page however much has
|
|
// been added since.
|
|
func (svc VideoRepository) SearchVideos(ctx context.Context, term string, categories []string, limit int, cursor string) ([]models.Video, string, error) {
|
|
query := tsQueryFor(term)
|
|
|
|
// Never nil: a nil slice reaches Postgres as NULL, and cardinality(NULL)
|
|
// is NULL rather than 0, so the "no category filter" branch would not fire
|
|
// and every search would come back empty.
|
|
if categories == nil {
|
|
categories = []string{}
|
|
}
|
|
|
|
// A zero cursor is passed as NULL, which the query reads as "start at the
|
|
// beginning" rather than as a row to seek past.
|
|
var after *searchCursor
|
|
if strings.TrimSpace(cursor) != "" {
|
|
decoded, err := decodeCursor(cursor)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
after = &decoded
|
|
}
|
|
|
|
var (
|
|
afterRank *float64
|
|
afterTime *time.Time
|
|
afterID *string
|
|
)
|
|
if after != nil {
|
|
afterRank, afterTime, afterID = &after.Rank, &after.CreatedAt, &after.ID
|
|
}
|
|
|
|
// One row more than asked for: if it comes back, there is another page and
|
|
// the extra row is discarded. That is what lets the last page say so,
|
|
// rather than handing out a cursor to an empty page.
|
|
//
|
|
// The rank is computed in the subquery and referenced by name in both the
|
|
// cursor comparison and the ORDER BY, so the two cannot drift apart — if
|
|
// they did, "the row after this one" would mean something different from
|
|
// the order the rows are actually in, and pages would overlap.
|
|
rows, err := svc.SQLDB.QueryContext(ctx, `
|
|
SELECT id, title, playback_url, categories, created_at, rank
|
|
FROM (
|
|
SELECT id, title, playback_url, categories, created_at,
|
|
CASE WHEN $1 = '' THEN 0::float8
|
|
ELSE ts_rank(search_vector, to_tsquery('`+searchConfig+`', $1))::float8
|
|
END AS rank
|
|
FROM videos
|
|
WHERE ($1 = '' OR search_vector @@ to_tsquery('`+searchConfig+`', $1))
|
|
AND (cardinality($2::text[]) = 0 OR categories && $2)
|
|
) ranked
|
|
WHERE $4::float8 IS NULL
|
|
OR (rank, created_at, id) < ($4::float8, $5::timestamptz, $6::uuid)
|
|
ORDER BY rank DESC, created_at DESC, id DESC
|
|
LIMIT $3
|
|
`, query, pq.Array(categories), limit+1, afterRank, afterTime, afterID)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
defer rows.Close()
|
|
|
|
// Never nil: a search that matches nothing is an empty list on the wire,
|
|
// not null.
|
|
videos := []models.Video{}
|
|
ranks := []float64{}
|
|
for rows.Next() {
|
|
var (
|
|
v models.Video
|
|
rank float64
|
|
)
|
|
if err := rows.Scan(&v.ID, &v.Title, &v.PlaybackURL, pq.Array(&v.Categories), &v.CreatedAt, &rank); err != nil {
|
|
return nil, "", err
|
|
}
|
|
videos = append(videos, v)
|
|
ranks = append(ranks, rank)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
if len(videos) <= limit {
|
|
return videos, "", nil
|
|
}
|
|
|
|
videos, ranks = videos[:limit], ranks[:limit]
|
|
last := videos[len(videos)-1]
|
|
|
|
next, err := searchCursor{
|
|
Rank: ranks[len(ranks)-1],
|
|
CreatedAt: last.CreatedAt,
|
|
ID: last.ID,
|
|
}.encode()
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
return videos, next, nil
|
|
}
|