package store import "context" // Tag is a label shared by posts. The slug is the URL (/tag/) and the // dedupe key: two spellings with the same slug are one tag. type Tag struct { ID int64 Name string Slug string } // TagCount is a tag with the number of published posts that carry it. type TagCount struct { Name string Slug string Count int } // ListTags returns every tag that is on at least one post, by name. func (bs *BlogStore) ListTags(ctx context.Context) ([]Tag, error) { rows, err := bs.db.Query(ctx, `SELECT t.id, t.name, t.slug FROM tags t WHERE EXISTS (SELECT 1 FROM post_tags WHERE tag_id=t.id) ORDER BY t.name`) if err != nil { return nil, err } defer rows.Close() var out []Tag for rows.Next() { var t Tag if err := rows.Scan(&t.ID, &t.Name, &t.Slug); err != nil { return nil, err } out = append(out, t) } return out, rows.Err() } func (bs *BlogStore) TagBySlug(ctx context.Context, slug string) (*Tag, error) { var t Tag err := bs.db.QueryRow(ctx, `SELECT id, name, slug FROM tags WHERE slug=$1`, slug).Scan(&t.ID, &t.Name, &t.Slug) if err != nil { return nil, wrap(err) } return &t, nil } // TagCounts counts published posts per tag, by name. Hidden posts are left // out so a tag used only on them never shows on the blog. func (bs *BlogStore) TagCounts(ctx context.Context) ([]TagCount, error) { rows, err := bs.db.Query(ctx, `SELECT t.name, t.slug, count(*) FROM tags t JOIN post_tags pt ON pt.tag_id=t.id JOIN posts p ON p.id=pt.post_id WHERE p.published GROUP BY t.id ORDER BY t.name`) if err != nil { return nil, err } defer rows.Close() var out []TagCount for rows.Next() { var t TagCount if err := rows.Scan(&t.Name, &t.Slug, &t.Count); err != nil { return nil, err } out = append(out, t) } return out, rows.Err() } // PublishedPostsByTag returns a page of the published posts carrying the tag, from every page of the blog. func (bs *BlogStore) PublishedPostsByTag(ctx context.Context, tagID int64, limit, offset int) ([]Post, int, error) { posts, err := bs.collectPosts(ctx, `SELECT `+postCols+` FROM posts p JOIN pages g ON g.id=p.page_id JOIN post_tags pt ON pt.post_id=p.id WHERE pt.tag_id=$1 AND p.published ORDER BY p.created_at DESC, p.id DESC LIMIT $2 OFFSET $3`, tagID, limit, offset) if err != nil { return nil, 0, err } var total int err = bs.db.QueryRow(ctx, `SELECT count(*) FROM posts p JOIN post_tags pt ON pt.post_id=p.id WHERE pt.tag_id=$1 AND p.published`, tagID).Scan(&total) return posts, total, err } // SetPostTags makes the given tags the post's tags: new ones are created, // dropped ones unlinked, and tags no post uses any more are deleted. func (bs *BlogStore) SetPostTags(ctx context.Context, postID int64, tags []Tag) error { tx, err := bs.db.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) ids := make([]int64, 0, len(tags)) for _, t := range tags { var id int64 // The no-op update makes RETURNING fire on conflict; the stored spelling stays. if err := tx.QueryRow(ctx, `INSERT INTO tags (name, slug) VALUES ($1,$2) ON CONFLICT (slug) DO UPDATE SET name=tags.name RETURNING id`, t.Name, t.Slug).Scan(&id); err != nil { return err } ids = append(ids, id) } if _, err := tx.Exec(ctx, `DELETE FROM post_tags WHERE post_id=$1 AND tag_id <> ALL($2)`, postID, ids); err != nil { return err } for _, id := range ids { if _, err := tx.Exec(ctx, `INSERT INTO post_tags (post_id, tag_id) VALUES ($1,$2) ON CONFLICT DO NOTHING`, postID, id); err != nil { return err } } if err := deleteOrphanTags(ctx, tx); err != nil { return err } return tx.Commit(ctx) } // deleteOrphanTags drops tags no post carries any more, so the post form's // list only offers tags that are in use. func deleteOrphanTags(ctx context.Context, q querier) error { _, err := q.Exec(ctx, `DELETE FROM tags t WHERE NOT EXISTS (SELECT 1 FROM post_tags WHERE tag_id=t.id)`) return err }