Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff111d619d | ||
|
|
f61b4fc7ab |
@@ -1,125 +0,0 @@
|
|||||||
-- name: CreatePost :exec
|
|
||||||
INSERT INTO posts (
|
|
||||||
id, parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at
|
|
||||||
)
|
|
||||||
VALUES (
|
|
||||||
sqlc.arg(id),
|
|
||||||
sqlc.arg(parent_id),
|
|
||||||
sqlc.arg(author_id),
|
|
||||||
sqlc.arg(title),
|
|
||||||
sqlc.arg(body),
|
|
||||||
sqlc.arg(city),
|
|
||||||
sqlc.arg(post_date),
|
|
||||||
sqlc.arg(post_state),
|
|
||||||
sqlc.arg(created_at),
|
|
||||||
sqlc.arg(updated_at)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- name: GetPost :one
|
|
||||||
SELECT
|
|
||||||
p.id, p.parent_id, p.author_id, u.name AS author_name, u.role AS author_role,
|
|
||||||
p.title, p.body, p.city, p.post_date, p.post_state, p.created_at, p.updated_at
|
|
||||||
FROM posts p
|
|
||||||
JOIN users u ON u.id = p.author_id
|
|
||||||
WHERE p.id = sqlc.arg(id);
|
|
||||||
|
|
||||||
-- name: ListPostThread :many
|
|
||||||
WITH RECURSIVE thread AS (
|
|
||||||
SELECT p.*
|
|
||||||
FROM posts p
|
|
||||||
WHERE p.id = sqlc.arg(root_id) AND p.parent_id IS NULL
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
SELECT child.*
|
|
||||||
FROM posts child
|
|
||||||
JOIN thread parent ON child.parent_id = parent.id
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
thread.id, thread.parent_id, thread.author_id,
|
|
||||||
u.name AS author_name, u.role AS author_role,
|
|
||||||
thread.title, thread.body, thread.city, thread.post_date,
|
|
||||||
thread.post_state, thread.created_at, thread.updated_at
|
|
||||||
FROM thread
|
|
||||||
JOIN users u ON u.id = thread.author_id
|
|
||||||
ORDER BY thread.created_at, thread.id;
|
|
||||||
|
|
||||||
-- name: UpdatePost :execrows
|
|
||||||
UPDATE posts
|
|
||||||
SET
|
|
||||||
body = sqlc.arg(body),
|
|
||||||
updated_at = sqlc.arg(updated_at)
|
|
||||||
WHERE id = sqlc.arg(id);
|
|
||||||
|
|
||||||
-- name: ListRootPosts :many
|
|
||||||
WITH RECURSIVE roots AS (
|
|
||||||
SELECT p.*
|
|
||||||
FROM posts p
|
|
||||||
WHERE p.parent_id IS NULL
|
|
||||||
AND p.post_date = sqlc.arg(post_date)
|
|
||||||
AND p.post_state <> sqlc.arg(hidden_state)
|
|
||||||
),
|
|
||||||
thread AS (
|
|
||||||
SELECT roots.id AS root_id, child.id AS post_id, child.author_id
|
|
||||||
FROM roots
|
|
||||||
JOIN posts child ON child.parent_id = roots.id
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
SELECT thread.root_id, child.id, child.author_id
|
|
||||||
FROM thread
|
|
||||||
JOIN posts child ON child.parent_id = thread.post_id
|
|
||||||
),
|
|
||||||
answered AS (
|
|
||||||
SELECT DISTINCT thread.root_id
|
|
||||||
FROM thread
|
|
||||||
JOIN users u ON u.id = thread.author_id
|
|
||||||
WHERE u.role = 'admin'
|
|
||||||
),
|
|
||||||
scores AS (
|
|
||||||
SELECT votes.post_id, SUM(votes.value)::bigint AS score
|
|
||||||
FROM roots
|
|
||||||
JOIN post_votes votes ON votes.post_id = roots.id
|
|
||||||
GROUP BY votes.post_id
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
roots.id, roots.parent_id, roots.author_id,
|
|
||||||
u.name AS author_name, u.role AS author_role,
|
|
||||||
roots.title, roots.body, roots.city, roots.post_date,
|
|
||||||
roots.post_state, roots.created_at, roots.updated_at,
|
|
||||||
COALESCE(scores.score, 0)::bigint AS score,
|
|
||||||
(answered.root_id IS NOT NULL)::bool AS answered,
|
|
||||||
COALESCE(viewer_vote.value, 0)::bigint AS user_vote
|
|
||||||
FROM roots
|
|
||||||
JOIN users u ON u.id = roots.author_id
|
|
||||||
LEFT JOIN scores ON scores.post_id = roots.id
|
|
||||||
LEFT JOIN answered ON answered.root_id = roots.id
|
|
||||||
LEFT JOIN post_votes viewer_vote
|
|
||||||
ON viewer_vote.user_id = sqlc.arg(viewer_id)
|
|
||||||
AND viewer_vote.post_id = roots.id
|
|
||||||
ORDER BY score DESC, roots.created_at, roots.id
|
|
||||||
LIMIT sqlc.arg(row_limit);
|
|
||||||
|
|
||||||
-- name: PostIsVisibleRoot :one
|
|
||||||
SELECT EXISTS(
|
|
||||||
SELECT 1
|
|
||||||
FROM posts
|
|
||||||
WHERE id = sqlc.arg(id)
|
|
||||||
AND parent_id IS NULL
|
|
||||||
AND post_state <> sqlc.arg(hidden_state)
|
|
||||||
)::bool;
|
|
||||||
|
|
||||||
-- name: DeletePostVote :exec
|
|
||||||
DELETE FROM post_votes
|
|
||||||
WHERE user_id = sqlc.arg(user_id)
|
|
||||||
AND post_id = sqlc.arg(post_id);
|
|
||||||
|
|
||||||
-- name: UpsertPostVoteOnVisibleRoot :execrows
|
|
||||||
INSERT INTO post_votes (user_id, post_id, value)
|
|
||||||
SELECT sqlc.arg(user_id), sqlc.arg(post_id), sqlc.arg(value)
|
|
||||||
FROM posts p
|
|
||||||
WHERE p.id = sqlc.arg(post_id)
|
|
||||||
AND p.parent_id IS NULL
|
|
||||||
AND p.post_state <> sqlc.arg(hidden_state)
|
|
||||||
ON CONFLICT (user_id, post_id) DO UPDATE
|
|
||||||
SET value = excluded.value;
|
|
||||||
@@ -22,8 +22,6 @@ type Memory struct {
|
|||||||
questions map[string]*RankedQuestion // id -> question
|
questions map[string]*RankedQuestion // id -> question
|
||||||
answers map[string]*Answer // questionID -> answer
|
answers map[string]*Answer // questionID -> answer
|
||||||
votes map[string]map[string]int // questionID -> userID -> value
|
votes map[string]map[string]int // questionID -> userID -> value
|
||||||
posts map[string]*Post // id -> post
|
|
||||||
postVotes map[string]map[string]int // postID -> userID -> value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMemory returns an empty Memory store.
|
// NewMemory returns an empty Memory store.
|
||||||
@@ -34,8 +32,6 @@ func NewMemory() *Memory {
|
|||||||
questions: map[string]*RankedQuestion{},
|
questions: map[string]*RankedQuestion{},
|
||||||
answers: map[string]*Answer{},
|
answers: map[string]*Answer{},
|
||||||
votes: map[string]map[string]int{},
|
votes: map[string]map[string]int{},
|
||||||
posts: map[string]*Post{},
|
|
||||||
postVotes: map[string]map[string]int{},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,186 +372,6 @@ func (m *Memory) UpsertAnswer(_ context.Context, a *Answer) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Memory) CreatePost(_ context.Context, post *Post) error {
|
|
||||||
if post == nil {
|
|
||||||
return fmt.Errorf("%w: post is nil", ErrInvalidPost)
|
|
||||||
}
|
|
||||||
if err := preparePost(post); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
if _, ok := m.users[post.AuthorID]; !ok {
|
|
||||||
return fmt.Errorf("%w: unknown author", ErrInvalidPost)
|
|
||||||
}
|
|
||||||
if _, exists := m.posts[post.ID]; exists {
|
|
||||||
return fmt.Errorf("%w: duplicate id", ErrInvalidPost)
|
|
||||||
}
|
|
||||||
if post.ParentID != nil {
|
|
||||||
if _, ok := m.posts[*post.ParentID]; !ok {
|
|
||||||
return fmt.Errorf("%w: unknown parent", ErrInvalidPost)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cp := clonePost(post)
|
|
||||||
cp.db = nil
|
|
||||||
m.posts[cp.ID] = cp
|
|
||||||
*post = *clonePost(cp)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Memory) GetPost(_ context.Context, id string) (*Post, error) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
post, ok := m.posts[id]
|
|
||||||
if !ok {
|
|
||||||
return nil, sql.ErrNoRows
|
|
||||||
}
|
|
||||||
return clonePostWithAuthor(post, m.users), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Memory) GetPostThread(_ context.Context, rootID string) (*Post, error) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
root, ok := m.posts[rootID]
|
|
||||||
if !ok || root.ParentID != nil {
|
|
||||||
return nil, sql.ErrNoRows
|
|
||||||
}
|
|
||||||
inThread := map[string]bool{rootID: true}
|
|
||||||
for changed := true; changed; {
|
|
||||||
changed = false
|
|
||||||
for id, post := range m.posts {
|
|
||||||
if inThread[id] || post.ParentID == nil || !inThread[*post.ParentID] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
inThread[id] = true
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
posts := make([]Post, 0, len(inThread))
|
|
||||||
for id := range inThread {
|
|
||||||
posts = append(posts, *clonePostWithAuthor(m.posts[id], m.users))
|
|
||||||
}
|
|
||||||
return buildPostTree(posts, rootID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Memory) UpdatePost(_ context.Context, post *Post) error {
|
|
||||||
if post == nil {
|
|
||||||
return fmt.Errorf("%w: post is nil", ErrInvalidPost)
|
|
||||||
}
|
|
||||||
body := strings.TrimSpace(post.Body)
|
|
||||||
if body == "" {
|
|
||||||
return fmt.Errorf("%w: body is required", ErrInvalidPost)
|
|
||||||
}
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
existing, ok := m.posts[post.ID]
|
|
||||||
if !ok {
|
|
||||||
return sql.ErrNoRows
|
|
||||||
}
|
|
||||||
existing.Body = body
|
|
||||||
existing.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
|
|
||||||
*post = *clonePostWithAuthor(existing, m.users)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Memory) ListRootPosts(_ context.Context, postDate, viewerID string) ([]Post, error) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
posts := make([]Post, 0)
|
|
||||||
for _, post := range m.posts {
|
|
||||||
if post.ParentID != nil || post.PostDate != postDate || post.PostState == PostStateHidden {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
cp := clonePostWithAuthor(post, m.users)
|
|
||||||
for _, value := range m.postVotes[post.ID] {
|
|
||||||
cp.Score += value
|
|
||||||
}
|
|
||||||
cp.UserVote = m.postVotes[post.ID][viewerID]
|
|
||||||
cp.Answered = m.threadContainsAdminReply(post.ID)
|
|
||||||
posts = append(posts, *cp)
|
|
||||||
}
|
|
||||||
sort.Slice(posts, func(i, j int) bool {
|
|
||||||
if posts[i].Score != posts[j].Score {
|
|
||||||
return posts[i].Score > posts[j].Score
|
|
||||||
}
|
|
||||||
if posts[i].CreatedAt != posts[j].CreatedAt {
|
|
||||||
return posts[i].CreatedAt < posts[j].CreatedAt
|
|
||||||
}
|
|
||||||
return posts[i].ID < posts[j].ID
|
|
||||||
})
|
|
||||||
if len(posts) > HuntListLimit {
|
|
||||||
posts = posts[:HuntListLimit]
|
|
||||||
}
|
|
||||||
return posts, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Memory) VotePost(_ context.Context, userID, postID string, value int) error {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
if value != 1 && value != -1 && value != 0 {
|
|
||||||
return fmt.Errorf("invalid vote")
|
|
||||||
}
|
|
||||||
post, ok := m.posts[postID]
|
|
||||||
if !ok || post.ParentID != nil || post.PostState == PostStateHidden {
|
|
||||||
return ErrPostNotVotable
|
|
||||||
}
|
|
||||||
if m.postVotes[postID] == nil {
|
|
||||||
m.postVotes[postID] = map[string]int{}
|
|
||||||
}
|
|
||||||
if value == 0 {
|
|
||||||
delete(m.postVotes[postID], userID)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
m.postVotes[postID][userID] = value
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Memory) threadContainsAdminReply(rootID string) bool {
|
|
||||||
for id, post := range m.posts {
|
|
||||||
if id == rootID || !m.postIsDescendantOf(post, rootID) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if author := m.users[post.AuthorID]; author != nil && author.Role == RoleAdmin {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Memory) postIsDescendantOf(post *Post, rootID string) bool {
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for post != nil && post.ParentID != nil {
|
|
||||||
if *post.ParentID == rootID {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if seen[*post.ParentID] {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
seen[*post.ParentID] = true
|
|
||||||
post = m.posts[*post.ParentID]
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func clonePost(post *Post) *Post {
|
|
||||||
cp := *post
|
|
||||||
if post.ParentID != nil {
|
|
||||||
parentID := *post.ParentID
|
|
||||||
cp.ParentID = &parentID
|
|
||||||
}
|
|
||||||
cp.Replies = nil
|
|
||||||
return &cp
|
|
||||||
}
|
|
||||||
|
|
||||||
func clonePostWithAuthor(post *Post, users map[string]*User) *Post {
|
|
||||||
cp := clonePost(post)
|
|
||||||
if author := users[post.AuthorID]; author != nil {
|
|
||||||
cp.AuthorName = author.Name
|
|
||||||
cp.AuthorRole = author.Role
|
|
||||||
}
|
|
||||||
return cp
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Memory) Vote(_ context.Context, userID, questionID string, value int) error {
|
func (m *Memory) Vote(_ context.Context, userID, questionID string, value int) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|||||||
@@ -35,227 +35,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS users_email_lower_uidx
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// migratePosts creates the unified post model and snapshots legacy content.
|
|
||||||
// Legacy tables remain in place until the application cutover is complete.
|
|
||||||
func migratePosts(ctx context.Context, exec execContext) error {
|
|
||||||
steps := []struct {
|
|
||||||
name string
|
|
||||||
sql string
|
|
||||||
args []any
|
|
||||||
}{
|
|
||||||
{name: "create posts", sql: `
|
|
||||||
CREATE TABLE IF NOT EXISTS posts (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
parent_id TEXT REFERENCES posts(id) ON DELETE CASCADE,
|
|
||||||
author_id TEXT NOT NULL REFERENCES users(id),
|
|
||||||
title TEXT NOT NULL DEFAULT '',
|
|
||||||
body TEXT NOT NULL,
|
|
||||||
city TEXT NOT NULL DEFAULT '',
|
|
||||||
post_date TEXT NOT NULL DEFAULT '',
|
|
||||||
post_state TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL,
|
|
||||||
CONSTRAINT posts_shape_check CHECK (
|
|
||||||
(parent_id IS NULL AND title <> '' AND post_date <> '')
|
|
||||||
OR
|
|
||||||
(parent_id IS NOT NULL AND title = '' AND city = '' AND post_date = '')
|
|
||||||
)
|
|
||||||
)`},
|
|
||||||
{name: "index post replies", sql: `
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_posts_parent_created
|
|
||||||
ON posts(parent_id, created_at, id)`},
|
|
||||||
{name: "index root posts", sql: `
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_posts_root_date
|
|
||||||
ON posts(post_date, post_state)
|
|
||||||
WHERE parent_id IS NULL`},
|
|
||||||
{name: "create post votes", sql: `
|
|
||||||
CREATE TABLE IF NOT EXISTS post_votes (
|
|
||||||
user_id TEXT NOT NULL REFERENCES users(id),
|
|
||||||
post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
|
||||||
value INTEGER NOT NULL CHECK (value IN (-1, 1)),
|
|
||||||
PRIMARY KEY (user_id, post_id)
|
|
||||||
)`},
|
|
||||||
{name: "copy questions", sql: `
|
|
||||||
INSERT INTO posts (
|
|
||||||
id, parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
id, NULL, author_id, title, body, city, hunt_date,
|
|
||||||
CASE WHEN hidden = 0 THEN $1 ELSE $2 END,
|
|
||||||
created_at, created_at
|
|
||||||
FROM questions
|
|
||||||
ON CONFLICT (id) DO NOTHING`, args: []any{string(PostStateVisible), string(PostStateHidden)}},
|
|
||||||
{name: "copy answers", sql: `
|
|
||||||
INSERT INTO posts (
|
|
||||||
id, parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
'answer:' || question_id, question_id, author_id, '', body, '', '',
|
|
||||||
$1, created_at, updated_at
|
|
||||||
FROM answers
|
|
||||||
ON CONFLICT (id) DO NOTHING`, args: []any{string(PostStateVisible)}},
|
|
||||||
{name: "copy votes", sql: `
|
|
||||||
INSERT INTO post_votes (user_id, post_id, value)
|
|
||||||
SELECT user_id, question_id, value
|
|
||||||
FROM votes
|
|
||||||
ON CONFLICT (user_id, post_id) DO NOTHING`},
|
|
||||||
}
|
|
||||||
for _, step := range steps {
|
|
||||||
if _, err := exec.ExecContext(ctx, step.sql, step.args...); err != nil {
|
|
||||||
return fmt.Errorf("%s: %w", step.name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func migratePostVoteIndex(ctx context.Context, exec execContext) error {
|
|
||||||
if _, err := exec.ExecContext(ctx, `
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_post_votes_post_id
|
|
||||||
ON post_votes(post_id)`); err != nil {
|
|
||||||
return fmt.Errorf("idx_post_votes_post_id: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func migratePostDate(ctx context.Context, exec execContext) error {
|
|
||||||
steps := []struct {
|
|
||||||
name string
|
|
||||||
sql string
|
|
||||||
}{
|
|
||||||
{"rename post date", `
|
|
||||||
DO $migration$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = current_schema()
|
|
||||||
AND table_name = 'posts'
|
|
||||||
AND column_name = 'hunt_date'
|
|
||||||
) AND NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = current_schema()
|
|
||||||
AND table_name = 'posts'
|
|
||||||
AND column_name = 'post_date'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE posts RENAME COLUMN hunt_date TO post_date;
|
|
||||||
END IF;
|
|
||||||
END
|
|
||||||
$migration$`},
|
|
||||||
{"drop legacy root date index", `
|
|
||||||
DROP INDEX IF EXISTS idx_posts_root_hunt`},
|
|
||||||
{"create root date index", `
|
|
||||||
DO $migration$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = current_schema()
|
|
||||||
AND table_name = 'posts'
|
|
||||||
AND column_name = 'post_state'
|
|
||||||
) THEN
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_posts_root_date
|
|
||||||
ON posts(post_date, post_state)
|
|
||||||
WHERE parent_id IS NULL;
|
|
||||||
ELSE
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_posts_root_date
|
|
||||||
ON posts(post_date, hidden)
|
|
||||||
WHERE parent_id IS NULL;
|
|
||||||
END IF;
|
|
||||||
END
|
|
||||||
$migration$`},
|
|
||||||
}
|
|
||||||
for _, step := range steps {
|
|
||||||
if _, err := exec.ExecContext(ctx, step.sql); err != nil {
|
|
||||||
return fmt.Errorf("%s: %w", step.name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func migratePostState(ctx context.Context, exec execContext) error {
|
|
||||||
if _, err := exec.ExecContext(ctx, `
|
|
||||||
ALTER TABLE posts
|
|
||||||
ADD COLUMN IF NOT EXISTS post_state TEXT`); err != nil {
|
|
||||||
return fmt.Errorf("add post state: %w", err)
|
|
||||||
}
|
|
||||||
hasHidden, err := migrationColumnExists(ctx, exec, "posts", "hidden")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("check hidden column: %w", err)
|
|
||||||
}
|
|
||||||
if hasHidden {
|
|
||||||
if _, err := exec.ExecContext(ctx, `
|
|
||||||
UPDATE posts
|
|
||||||
SET post_state = CASE
|
|
||||||
WHEN hidden = 0 THEN $1
|
|
||||||
ELSE $2
|
|
||||||
END`, string(PostStateVisible), string(PostStateHidden)); err != nil {
|
|
||||||
return fmt.Errorf("copy hidden state: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
steps := []struct {
|
|
||||||
name string
|
|
||||||
sql string
|
|
||||||
}{
|
|
||||||
{"drop root date index", `
|
|
||||||
DROP INDEX IF EXISTS idx_posts_root_date`},
|
|
||||||
{"drop legacy post shape constraint", `
|
|
||||||
ALTER TABLE posts DROP CONSTRAINT IF EXISTS posts_check`},
|
|
||||||
{"drop post shape constraint", `
|
|
||||||
ALTER TABLE posts DROP CONSTRAINT IF EXISTS posts_shape_check`},
|
|
||||||
{"drop hidden", `
|
|
||||||
ALTER TABLE posts DROP COLUMN IF EXISTS hidden`},
|
|
||||||
{"require post state", `
|
|
||||||
ALTER TABLE posts ALTER COLUMN post_state SET NOT NULL`},
|
|
||||||
{"create post shape constraint", `
|
|
||||||
ALTER TABLE posts
|
|
||||||
ADD CONSTRAINT posts_shape_check CHECK (
|
|
||||||
(parent_id IS NULL AND title <> '' AND post_date <> '')
|
|
||||||
OR
|
|
||||||
(parent_id IS NOT NULL AND title = '' AND city = '' AND post_date = '')
|
|
||||||
)`},
|
|
||||||
{"create root date index", `
|
|
||||||
CREATE INDEX idx_posts_root_date
|
|
||||||
ON posts(post_date, post_state)
|
|
||||||
WHERE parent_id IS NULL`},
|
|
||||||
}
|
|
||||||
for _, step := range steps {
|
|
||||||
if _, err := exec.ExecContext(ctx, step.sql); err != nil {
|
|
||||||
return fmt.Errorf("%s: %w", step.name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func migrationColumnExists(
|
|
||||||
ctx context.Context,
|
|
||||||
exec execContext,
|
|
||||||
tableName string,
|
|
||||||
columnName string,
|
|
||||||
) (bool, error) {
|
|
||||||
rows, err := exec.QueryContext(ctx, `
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = current_schema()
|
|
||||||
AND table_name = $1
|
|
||||||
AND column_name = $2
|
|
||||||
)`, tableName, columnName)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
if !rows.Next() {
|
|
||||||
return false, rows.Err()
|
|
||||||
}
|
|
||||||
var exists bool
|
|
||||||
if err := rows.Scan(&exists); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return exists, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
type execContext interface {
|
type execContext interface {
|
||||||
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
||||||
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
||||||
@@ -302,10 +81,6 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
|
|||||||
}},
|
}},
|
||||||
{"002_user_profile_columns", migrateUserProfileColumns},
|
{"002_user_profile_columns", migrateUserProfileColumns},
|
||||||
{"003_user_email", migrateUserEmail},
|
{"003_user_email", migrateUserEmail},
|
||||||
{"004_posts", migratePosts},
|
|
||||||
{"005_post_vote_post_id_index", migratePostVoteIndex},
|
|
||||||
{"006_post_date", migratePostDate},
|
|
||||||
{"007_post_state", migratePostState},
|
|
||||||
}
|
}
|
||||||
for _, m := range migrations {
|
for _, m := range migrations {
|
||||||
if applied[m.version] {
|
if applied[m.version] {
|
||||||
|
|||||||
@@ -1,438 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"plumber/internal/store/sqlc"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestMigratePostsCopiesLegacyData(t *testing.T) {
|
|
||||||
rawURL := strings.TrimSpace(os.Getenv("TEST_DATABASE_URL"))
|
|
||||||
if rawURL == "" {
|
|
||||||
t.Skip("TEST_DATABASE_URL is not set")
|
|
||||||
}
|
|
||||||
dsn, err := postgresDSN(rawURL)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
db, err := sql.Open("pgx", dsn)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
conn, err := db.Conn(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
schemaName := "test_posts_" + strings.ReplaceAll(uuid.NewString(), "-", "")
|
|
||||||
if _, err := conn.ExecContext(ctx, "CREATE SCHEMA "+schemaName); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_, _ = conn.ExecContext(context.Background(), "SET search_path TO public")
|
|
||||||
_, _ = conn.ExecContext(context.Background(), "DROP SCHEMA "+schemaName+" CASCADE")
|
|
||||||
}()
|
|
||||||
if _, err := conn.ExecContext(ctx, "SET search_path TO "+schemaName); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
legacySchema := `
|
|
||||||
CREATE TABLE users (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
role TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TABLE questions (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
author_id TEXT NOT NULL REFERENCES users(id),
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
body TEXT NOT NULL,
|
|
||||||
city TEXT NOT NULL DEFAULT '',
|
|
||||||
hunt_date TEXT NOT NULL,
|
|
||||||
hidden INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TABLE answers (
|
|
||||||
question_id TEXT PRIMARY KEY REFERENCES questions(id) ON DELETE CASCADE,
|
|
||||||
author_id TEXT NOT NULL REFERENCES users(id),
|
|
||||||
body TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TABLE votes (
|
|
||||||
user_id TEXT NOT NULL REFERENCES users(id),
|
|
||||||
question_id TEXT NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
|
|
||||||
value INTEGER NOT NULL CHECK (value IN (-1, 1)),
|
|
||||||
PRIMARY KEY (user_id, question_id)
|
|
||||||
);`
|
|
||||||
if err := applySchema(ctx, conn, legacySchema); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := conn.ExecContext(ctx, `
|
|
||||||
INSERT INTO users (id, name, role)
|
|
||||||
VALUES ('homeowner', 'Home Owner', 'user'), ('plumber', 'The Plumber', 'admin');
|
|
||||||
INSERT INTO questions (id, author_id, title, body, city, hunt_date, hidden, created_at)
|
|
||||||
VALUES ('question-1', 'homeowner', 'Leaky sink', 'It drips.', 'Oakland', '2026-08-26', 0, '2026-08-26T08:00:00Z');
|
|
||||||
INSERT INTO answers (question_id, author_id, body, created_at, updated_at)
|
|
||||||
VALUES ('question-1', 'plumber', 'Replace the cartridge.', '2026-08-26T09:00:00Z', '2026-08-26T09:05:00Z');
|
|
||||||
INSERT INTO votes (user_id, question_id, value)
|
|
||||||
VALUES ('homeowner', 'question-1', 1);`); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := migratePosts(ctx, conn); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := migratePosts(ctx, conn); err != nil {
|
|
||||||
t.Fatalf("migration is not idempotent: %v", err)
|
|
||||||
}
|
|
||||||
if err := migratePostVoteIndex(ctx, conn); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := migratePostVoteIndex(ctx, conn); err != nil {
|
|
||||||
t.Fatalf("post vote index migration is not idempotent: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var postCount, voteCount, legacyQuestionCount, legacyAnswerCount int
|
|
||||||
if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM posts").Scan(&postCount); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM post_votes").Scan(&voteCount); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM questions").Scan(&legacyQuestionCount); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM answers").Scan(&legacyAnswerCount); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if postCount != 2 || voteCount != 1 || legacyQuestionCount != 1 || legacyAnswerCount != 1 {
|
|
||||||
t.Fatalf(
|
|
||||||
"counts posts=%d votes=%d legacy questions=%d answers=%d",
|
|
||||||
postCount,
|
|
||||||
voteCount,
|
|
||||||
legacyQuestionCount,
|
|
||||||
legacyAnswerCount,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
var postVoteIndexCount int
|
|
||||||
if err := conn.QueryRowContext(ctx, `
|
|
||||||
SELECT count(*)
|
|
||||||
FROM pg_indexes
|
|
||||||
WHERE schemaname = current_schema()
|
|
||||||
AND tablename = 'post_votes'
|
|
||||||
AND indexname = 'idx_post_votes_post_id'`).Scan(&postVoteIndexCount); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if postVoteIndexCount != 1 {
|
|
||||||
t.Fatalf("post vote index count = %d, want 1", postVoteIndexCount)
|
|
||||||
}
|
|
||||||
if _, err := conn.ExecContext(ctx, `
|
|
||||||
INSERT INTO post_votes (user_id, post_id, value)
|
|
||||||
VALUES ('homeowner', 'question-1', -1)`); err == nil {
|
|
||||||
t.Fatal("duplicate user/post vote unexpectedly succeeded")
|
|
||||||
}
|
|
||||||
|
|
||||||
var rootParent sql.NullString
|
|
||||||
var rootAuthor, title, rootBody, city, postDate, rootState, rootCreated, rootUpdated string
|
|
||||||
if err := conn.QueryRowContext(ctx, `
|
|
||||||
SELECT parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at
|
|
||||||
FROM posts
|
|
||||||
WHERE id = 'question-1'`).Scan(
|
|
||||||
&rootParent,
|
|
||||||
&rootAuthor,
|
|
||||||
&title,
|
|
||||||
&rootBody,
|
|
||||||
&city,
|
|
||||||
&postDate,
|
|
||||||
&rootState,
|
|
||||||
&rootCreated,
|
|
||||||
&rootUpdated,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if rootParent.Valid ||
|
|
||||||
rootAuthor != "homeowner" ||
|
|
||||||
title != "Leaky sink" ||
|
|
||||||
rootBody != "It drips." ||
|
|
||||||
city != "Oakland" ||
|
|
||||||
postDate != "2026-08-26" ||
|
|
||||||
rootState != "visible" ||
|
|
||||||
rootCreated != "2026-08-26T08:00:00Z" ||
|
|
||||||
rootUpdated != rootCreated {
|
|
||||||
t.Fatalf("unexpected root post")
|
|
||||||
}
|
|
||||||
|
|
||||||
var replyParent, replyAuthor, replyBody, replyState, replyCreated, replyUpdated string
|
|
||||||
if err := conn.QueryRowContext(ctx, `
|
|
||||||
SELECT parent_id, author_id, body, post_state, created_at, updated_at
|
|
||||||
FROM posts
|
|
||||||
WHERE id = 'answer:question-1'`).Scan(
|
|
||||||
&replyParent,
|
|
||||||
&replyAuthor,
|
|
||||||
&replyBody,
|
|
||||||
&replyState,
|
|
||||||
&replyCreated,
|
|
||||||
&replyUpdated,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if replyParent != "question-1" ||
|
|
||||||
replyAuthor != "plumber" ||
|
|
||||||
replyBody != "Replace the cartridge." ||
|
|
||||||
replyState != "visible" ||
|
|
||||||
replyCreated != "2026-08-26T09:00:00Z" ||
|
|
||||||
replyUpdated != "2026-08-26T09:05:00Z" {
|
|
||||||
t.Fatalf("unexpected reply post")
|
|
||||||
}
|
|
||||||
|
|
||||||
var voteValue int
|
|
||||||
if err := conn.QueryRowContext(ctx, `
|
|
||||||
SELECT value FROM post_votes
|
|
||||||
WHERE user_id = 'homeowner' AND post_id = 'question-1'`).Scan(&voteValue); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if voteValue != 1 {
|
|
||||||
t.Fatalf("vote value = %d, want 1", voteValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := conn.ExecContext(ctx, `
|
|
||||||
INSERT INTO posts (
|
|
||||||
id, parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at
|
|
||||||
) VALUES (
|
|
||||||
'invalid-reply', 'question-1', 'homeowner', 'Replies cannot have titles', 'Body', '', '',
|
|
||||||
'visible', 'now', 'now'
|
|
||||||
)`); err == nil {
|
|
||||||
t.Fatal("reply with root-only title unexpectedly succeeded")
|
|
||||||
}
|
|
||||||
|
|
||||||
queries := sqlc.New(conn)
|
|
||||||
if err := queries.CreatePost(ctx, sqlc.CreatePostParams{
|
|
||||||
ID: "follow-up",
|
|
||||||
ParentID: sql.NullString{String: "answer:question-1", Valid: true},
|
|
||||||
AuthorID: "homeowner",
|
|
||||||
Body: "It is still dripping.",
|
|
||||||
PostState: string(PostStateVisible),
|
|
||||||
CreatedAt: "2026-08-26T10:00:00Z",
|
|
||||||
UpdatedAt: "2026-08-26T10:00:00Z",
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
thread, err := queries.ListPostThread(ctx, "question-1")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(thread) != 3 ||
|
|
||||||
thread[0].ID != "question-1" ||
|
|
||||||
thread[1].ID != "answer:question-1" ||
|
|
||||||
thread[2].ID != "follow-up" {
|
|
||||||
t.Fatalf("recursive thread = %+v", thread)
|
|
||||||
}
|
|
||||||
nonRootThread, err := queries.ListPostThread(ctx, "answer:question-1")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(nonRootThread) != 0 {
|
|
||||||
t.Fatalf("non-root thread lookup returned %+v", nonRootThread)
|
|
||||||
}
|
|
||||||
if n, err := queries.UpdatePost(ctx, sqlc.UpdatePostParams{
|
|
||||||
ID: "follow-up",
|
|
||||||
Body: "The drip continues.",
|
|
||||||
UpdatedAt: "2026-08-26T10:05:00Z",
|
|
||||||
}); err != nil || n != 1 {
|
|
||||||
t.Fatalf("update rows=%d error=%v", n, err)
|
|
||||||
}
|
|
||||||
if n, err := queries.UpsertPostVoteOnVisibleRoot(ctx, sqlc.UpsertPostVoteOnVisibleRootParams{
|
|
||||||
UserID: "plumber",
|
|
||||||
PostID: "question-1",
|
|
||||||
Value: 1,
|
|
||||||
HiddenState: string(PostStateHidden),
|
|
||||||
}); err != nil || n != 1 {
|
|
||||||
t.Fatalf("vote rows=%d error=%v", n, err)
|
|
||||||
}
|
|
||||||
roots, err := queries.ListRootPosts(ctx, sqlc.ListRootPostsParams{
|
|
||||||
ViewerID: "plumber",
|
|
||||||
RowLimit: 100,
|
|
||||||
PostDate: "2026-08-26",
|
|
||||||
HiddenState: string(PostStateHidden),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(roots) != 1 ||
|
|
||||||
roots[0].Score != 2 ||
|
|
||||||
!roots[0].Answered ||
|
|
||||||
roots[0].UserVote != 1 {
|
|
||||||
t.Fatalf("root annotations = %+v", roots)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := conn.ExecContext(ctx, `
|
|
||||||
DROP INDEX idx_posts_root_date;
|
|
||||||
ALTER TABLE posts RENAME COLUMN post_date TO hunt_date;
|
|
||||||
CREATE INDEX idx_posts_root_hunt
|
|
||||||
ON posts(hunt_date, post_state)
|
|
||||||
WHERE parent_id IS NULL;`); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := migratePostDate(ctx, conn); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := migratePostDate(ctx, conn); err != nil {
|
|
||||||
t.Fatalf("post date migration is not idempotent: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var postDateColumnCount, huntDateColumnCount, rootDateIndexCount, legacyIndexCount int
|
|
||||||
if err := conn.QueryRowContext(ctx, `
|
|
||||||
SELECT
|
|
||||||
count(*) FILTER (WHERE column_name = 'post_date'),
|
|
||||||
count(*) FILTER (WHERE column_name = 'hunt_date')
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = current_schema()
|
|
||||||
AND table_name = 'posts'`).Scan(&postDateColumnCount, &huntDateColumnCount); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := conn.QueryRowContext(ctx, `
|
|
||||||
SELECT
|
|
||||||
count(*) FILTER (WHERE indexname = 'idx_posts_root_date'),
|
|
||||||
count(*) FILTER (WHERE indexname = 'idx_posts_root_hunt')
|
|
||||||
FROM pg_indexes
|
|
||||||
WHERE schemaname = current_schema()
|
|
||||||
AND tablename = 'posts'`).Scan(&rootDateIndexCount, &legacyIndexCount); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var migratedPostDate string
|
|
||||||
if err := conn.QueryRowContext(ctx, `
|
|
||||||
SELECT post_date FROM posts WHERE id = 'question-1'`).Scan(&migratedPostDate); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if postDateColumnCount != 1 ||
|
|
||||||
huntDateColumnCount != 0 ||
|
|
||||||
rootDateIndexCount != 1 ||
|
|
||||||
legacyIndexCount != 0 ||
|
|
||||||
migratedPostDate != "2026-08-26" {
|
|
||||||
t.Fatalf(
|
|
||||||
"post date migration columns=%d legacy_columns=%d indexes=%d legacy_indexes=%d date=%q",
|
|
||||||
postDateColumnCount,
|
|
||||||
huntDateColumnCount,
|
|
||||||
rootDateIndexCount,
|
|
||||||
legacyIndexCount,
|
|
||||||
migratedPostDate,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := conn.ExecContext(ctx, `
|
|
||||||
DROP INDEX idx_posts_root_date;
|
|
||||||
ALTER TABLE posts DROP CONSTRAINT posts_shape_check;
|
|
||||||
ALTER TABLE posts ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0;
|
|
||||||
UPDATE posts SET hidden = CASE WHEN id = 'question-1' THEN 1 ELSE 0 END;
|
|
||||||
ALTER TABLE posts DROP COLUMN post_state;
|
|
||||||
ALTER TABLE posts ADD CONSTRAINT posts_check CHECK (
|
|
||||||
(parent_id IS NULL AND title <> '' AND post_date <> '')
|
|
||||||
OR
|
|
||||||
(parent_id IS NOT NULL AND title = '' AND city = '' AND post_date = '' AND hidden = 0)
|
|
||||||
);
|
|
||||||
CREATE INDEX idx_posts_root_date
|
|
||||||
ON posts(post_date, hidden)
|
|
||||||
WHERE parent_id IS NULL;`); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := migratePostState(ctx, conn); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := migratePostState(ctx, conn); err != nil {
|
|
||||||
t.Fatalf("post state migration is not idempotent: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var postStateColumnCount, hiddenColumnCount int
|
|
||||||
if err := conn.QueryRowContext(ctx, `
|
|
||||||
SELECT
|
|
||||||
count(*) FILTER (WHERE column_name = 'post_state'),
|
|
||||||
count(*) FILTER (WHERE column_name = 'hidden')
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = current_schema()
|
|
||||||
AND table_name = 'posts'`).Scan(&postStateColumnCount, &hiddenColumnCount); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var hiddenState, replyStateAfterMigration string
|
|
||||||
if err := conn.QueryRowContext(ctx, `
|
|
||||||
SELECT post_state FROM posts WHERE id = 'question-1'`).Scan(&hiddenState); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := conn.QueryRowContext(ctx, `
|
|
||||||
SELECT post_state FROM posts WHERE id = 'answer:question-1'`).Scan(&replyStateAfterMigration); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var postStateDataType string
|
|
||||||
if err := conn.QueryRowContext(ctx, `
|
|
||||||
SELECT data_type
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = current_schema()
|
|
||||||
AND table_name = 'posts'
|
|
||||||
AND column_name = 'post_state'`).Scan(&postStateDataType); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var stateIndexCount int
|
|
||||||
if err := conn.QueryRowContext(ctx, `
|
|
||||||
SELECT count(*)
|
|
||||||
FROM pg_indexes
|
|
||||||
WHERE schemaname = current_schema()
|
|
||||||
AND tablename = 'posts'
|
|
||||||
AND indexname = 'idx_posts_root_date'
|
|
||||||
AND indexdef LIKE '%(post_date, post_state)%'`).Scan(&stateIndexCount); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if postStateColumnCount != 1 ||
|
|
||||||
hiddenColumnCount != 0 ||
|
|
||||||
hiddenState != "hidden" ||
|
|
||||||
replyStateAfterMigration != "visible" ||
|
|
||||||
postStateDataType != "text" ||
|
|
||||||
stateIndexCount != 1 {
|
|
||||||
t.Fatalf(
|
|
||||||
"post state migration columns=%d hidden_columns=%d root=%q reply=%q type=%q indexes=%d",
|
|
||||||
postStateColumnCount,
|
|
||||||
hiddenColumnCount,
|
|
||||||
hiddenState,
|
|
||||||
replyStateAfterMigration,
|
|
||||||
postStateDataType,
|
|
||||||
stateIndexCount,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMigratePostsReportsStep(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
exec := &failingMigrationExec{failAt: 5}
|
|
||||||
err := migratePosts(context.Background(), exec)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "copy questions") {
|
|
||||||
t.Fatalf("error = %v, want copy questions context", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type failingMigrationExec struct {
|
|
||||||
calls int
|
|
||||||
failAt int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *failingMigrationExec) ExecContext(context.Context, string, ...any) (sql.Result, error) {
|
|
||||||
f.calls++
|
|
||||||
if f.calls == f.failAt {
|
|
||||||
return nil, fmt.Errorf("boom")
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*failingMigrationExec) QueryContext(context.Context, string, ...any) (*sql.Rows, error) {
|
|
||||||
return nil, fmt.Errorf("not implemented")
|
|
||||||
}
|
|
||||||
@@ -1,372 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
|
||||||
|
|
||||||
"plumber/internal/pacific"
|
|
||||||
"plumber/internal/store/sqlc"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrInvalidPost = errors.New("invalid post")
|
|
||||||
ErrPostNotVotable = errors.New("post not votable")
|
|
||||||
)
|
|
||||||
|
|
||||||
type PostState string
|
|
||||||
|
|
||||||
const (
|
|
||||||
PostStateVisible PostState = "visible"
|
|
||||||
PostStateHidden PostState = "hidden"
|
|
||||||
PostStateLocked PostState = "locked"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Post is either a root question (ParentID nil) or a reply to another post.
|
|
||||||
type Post struct {
|
|
||||||
ID string
|
|
||||||
ParentID *string
|
|
||||||
AuthorID string
|
|
||||||
AuthorName string
|
|
||||||
AuthorRole Role
|
|
||||||
Title string
|
|
||||||
Body string
|
|
||||||
City string
|
|
||||||
PostDate string
|
|
||||||
PostState PostState
|
|
||||||
CreatedAt string
|
|
||||||
UpdatedAt string
|
|
||||||
Score int
|
|
||||||
Answered bool
|
|
||||||
UserVote int
|
|
||||||
Replies []*Post
|
|
||||||
db *sql.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPost returns a post bound to db.
|
|
||||||
func NewPost(db *sql.DB) *Post {
|
|
||||||
return &Post{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create inserts a root post or reply according to ParentID.
|
|
||||||
func (p *Post) Create(ctx context.Context) error {
|
|
||||||
if p == nil || p.db == nil {
|
|
||||||
return fmt.Errorf("post: no database")
|
|
||||||
}
|
|
||||||
if err := preparePost(p); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
err := sqlc.New(p.db).CreatePost(ctx, sqlc.CreatePostParams{
|
|
||||||
ID: p.ID,
|
|
||||||
ParentID: nullableParentID(p.ParentID),
|
|
||||||
AuthorID: p.AuthorID,
|
|
||||||
Title: p.Title,
|
|
||||||
Body: p.Body,
|
|
||||||
City: p.City,
|
|
||||||
PostDate: p.PostDate,
|
|
||||||
PostState: string(p.PostState),
|
|
||||||
CreatedAt: p.CreatedAt,
|
|
||||||
UpdatedAt: p.UpdatedAt,
|
|
||||||
})
|
|
||||||
return mapPostCreateError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update changes only the post body and update timestamp.
|
|
||||||
func (p *Post) Update(ctx context.Context) error {
|
|
||||||
if p == nil || p.db == nil {
|
|
||||||
return fmt.Errorf("post: no database")
|
|
||||||
}
|
|
||||||
p.Body = strings.TrimSpace(p.Body)
|
|
||||||
if p.Body == "" {
|
|
||||||
return fmt.Errorf("%w: body is required", ErrInvalidPost)
|
|
||||||
}
|
|
||||||
p.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
|
|
||||||
n, err := sqlc.New(p.db).UpdatePost(ctx, sqlc.UpdatePostParams{
|
|
||||||
ID: p.ID,
|
|
||||||
Body: p.Body,
|
|
||||||
UpdatedAt: p.UpdatedAt,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if n == 0 {
|
|
||||||
return sql.ErrNoRows
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func preparePost(p *Post) error {
|
|
||||||
p.ID = strings.TrimSpace(p.ID)
|
|
||||||
p.AuthorID = strings.TrimSpace(p.AuthorID)
|
|
||||||
p.Title = strings.TrimSpace(p.Title)
|
|
||||||
p.Body = strings.TrimSpace(p.Body)
|
|
||||||
p.City = strings.TrimSpace(p.City)
|
|
||||||
p.PostDate = strings.TrimSpace(p.PostDate)
|
|
||||||
if p.PostState == "" {
|
|
||||||
p.PostState = PostStateVisible
|
|
||||||
}
|
|
||||||
switch p.PostState {
|
|
||||||
case PostStateVisible, PostStateHidden, PostStateLocked:
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("%w: invalid post state", ErrInvalidPost)
|
|
||||||
}
|
|
||||||
if p.AuthorID == "" {
|
|
||||||
return fmt.Errorf("%w: author is required", ErrInvalidPost)
|
|
||||||
}
|
|
||||||
if p.Body == "" {
|
|
||||||
return fmt.Errorf("%w: body is required", ErrInvalidPost)
|
|
||||||
}
|
|
||||||
if p.ParentID == nil {
|
|
||||||
if p.Title == "" {
|
|
||||||
return fmt.Errorf("%w: root title is required", ErrInvalidPost)
|
|
||||||
}
|
|
||||||
if p.PostDate == "" {
|
|
||||||
p.PostDate = pacific.Today()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
parentID := strings.TrimSpace(*p.ParentID)
|
|
||||||
if parentID == "" {
|
|
||||||
return fmt.Errorf("%w: parent is required", ErrInvalidPost)
|
|
||||||
}
|
|
||||||
p.ParentID = &parentID
|
|
||||||
if p.Title != "" || p.City != "" || p.PostDate != "" || p.PostState != PostStateVisible {
|
|
||||||
return fmt.Errorf("%w: reply contains root-only fields", ErrInvalidPost)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if p.ID == "" {
|
|
||||||
p.ID = uuid.NewString()
|
|
||||||
}
|
|
||||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
|
||||||
if p.CreatedAt == "" {
|
|
||||||
p.CreatedAt = now
|
|
||||||
}
|
|
||||||
if p.UpdatedAt == "" {
|
|
||||||
p.UpdatedAt = p.CreatedAt
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func nullableParentID(parentID *string) sql.NullString {
|
|
||||||
if parentID == nil {
|
|
||||||
return sql.NullString{}
|
|
||||||
}
|
|
||||||
return sql.NullString{String: *parentID, Valid: true}
|
|
||||||
}
|
|
||||||
|
|
||||||
func parentIDFromNull(parentID sql.NullString) *string {
|
|
||||||
if !parentID.Valid {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
id := parentID.String
|
|
||||||
return &id
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapPostCreateError(err error) error {
|
|
||||||
if err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var pgErr *pgconn.PgError
|
|
||||||
if errors.As(err, &pgErr) {
|
|
||||||
switch pgErr.Code {
|
|
||||||
case "23503", "23505", "23514":
|
|
||||||
return fmt.Errorf("%w: %v", ErrInvalidPost, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func postFromValues(
|
|
||||||
db *sql.DB,
|
|
||||||
id string,
|
|
||||||
parentID sql.NullString,
|
|
||||||
authorID, authorName, authorRole, title, body, city, postDate string,
|
|
||||||
postState string,
|
|
||||||
createdAt, updatedAt string,
|
|
||||||
) Post {
|
|
||||||
return Post{
|
|
||||||
ID: id,
|
|
||||||
ParentID: parentIDFromNull(parentID),
|
|
||||||
AuthorID: authorID,
|
|
||||||
AuthorName: authorName,
|
|
||||||
AuthorRole: Role(authorRole),
|
|
||||||
Title: title,
|
|
||||||
Body: body,
|
|
||||||
City: city,
|
|
||||||
PostDate: postDate,
|
|
||||||
PostState: PostState(postState),
|
|
||||||
CreatedAt: createdAt,
|
|
||||||
UpdatedAt: updatedAt,
|
|
||||||
db: db,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPost returns one post without loading its replies.
|
|
||||||
func GetPost(ctx context.Context, db *sql.DB, id string) (*Post, error) {
|
|
||||||
r, err := sqlc.New(db).GetPost(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
p := postFromValues(
|
|
||||||
db,
|
|
||||||
r.ID,
|
|
||||||
r.ParentID,
|
|
||||||
r.AuthorID,
|
|
||||||
r.AuthorName,
|
|
||||||
r.AuthorRole,
|
|
||||||
r.Title,
|
|
||||||
r.Body,
|
|
||||||
r.City,
|
|
||||||
r.PostDate,
|
|
||||||
r.PostState,
|
|
||||||
r.CreatedAt,
|
|
||||||
r.UpdatedAt,
|
|
||||||
)
|
|
||||||
return &p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPostThread returns a root post with all descendants nested under Replies.
|
|
||||||
func GetPostThread(ctx context.Context, db *sql.DB, rootID string) (*Post, error) {
|
|
||||||
rows, err := sqlc.New(db).ListPostThread(ctx, rootID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
posts := make([]Post, 0, len(rows))
|
|
||||||
for _, r := range rows {
|
|
||||||
posts = append(posts, postFromValues(
|
|
||||||
db,
|
|
||||||
r.ID,
|
|
||||||
r.ParentID,
|
|
||||||
r.AuthorID,
|
|
||||||
r.AuthorName,
|
|
||||||
r.AuthorRole,
|
|
||||||
r.Title,
|
|
||||||
r.Body,
|
|
||||||
r.City,
|
|
||||||
r.PostDate,
|
|
||||||
r.PostState,
|
|
||||||
r.CreatedAt,
|
|
||||||
r.UpdatedAt,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
return buildPostTree(posts, rootID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildPostTree(posts []Post, rootID string) (*Post, error) {
|
|
||||||
byID := make(map[string]*Post, len(posts))
|
|
||||||
for i := range posts {
|
|
||||||
posts[i].Replies = nil
|
|
||||||
byID[posts[i].ID] = &posts[i]
|
|
||||||
}
|
|
||||||
root, ok := byID[rootID]
|
|
||||||
if !ok || root.ParentID != nil {
|
|
||||||
return nil, sql.ErrNoRows
|
|
||||||
}
|
|
||||||
for i := range posts {
|
|
||||||
post := &posts[i]
|
|
||||||
if post.ID == rootID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if post.ParentID == nil {
|
|
||||||
return nil, fmt.Errorf("post %s is not in thread %s", post.ID, rootID)
|
|
||||||
}
|
|
||||||
parent, ok := byID[*post.ParentID]
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("post %s has missing parent %s", post.ID, *post.ParentID)
|
|
||||||
}
|
|
||||||
parent.Replies = append(parent.Replies, post)
|
|
||||||
}
|
|
||||||
var sortReplies func(*Post)
|
|
||||||
sortReplies = func(post *Post) {
|
|
||||||
sort.Slice(post.Replies, func(i, j int) bool {
|
|
||||||
if post.Replies[i].CreatedAt != post.Replies[j].CreatedAt {
|
|
||||||
return post.Replies[i].CreatedAt < post.Replies[j].CreatedAt
|
|
||||||
}
|
|
||||||
return post.Replies[i].ID < post.Replies[j].ID
|
|
||||||
})
|
|
||||||
for _, reply := range post.Replies {
|
|
||||||
sortReplies(reply)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sortReplies(root)
|
|
||||||
return root, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListRootPosts returns visible root posts for a post date.
|
|
||||||
func ListRootPosts(ctx context.Context, db *sql.DB, postDate, viewerID string) ([]Post, error) {
|
|
||||||
rows, err := sqlc.New(db).ListRootPosts(ctx, sqlc.ListRootPostsParams{
|
|
||||||
ViewerID: viewerID,
|
|
||||||
RowLimit: HuntListLimit,
|
|
||||||
PostDate: postDate,
|
|
||||||
HiddenState: string(PostStateHidden),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
posts := make([]Post, 0, len(rows))
|
|
||||||
for _, r := range rows {
|
|
||||||
post := postFromValues(
|
|
||||||
db,
|
|
||||||
r.ID,
|
|
||||||
r.ParentID,
|
|
||||||
r.AuthorID,
|
|
||||||
r.AuthorName,
|
|
||||||
r.AuthorRole,
|
|
||||||
r.Title,
|
|
||||||
r.Body,
|
|
||||||
r.City,
|
|
||||||
r.PostDate,
|
|
||||||
r.PostState,
|
|
||||||
r.CreatedAt,
|
|
||||||
r.UpdatedAt,
|
|
||||||
)
|
|
||||||
post.Score = int(r.Score)
|
|
||||||
post.Answered = r.Answered
|
|
||||||
post.UserVote = int(r.UserVote)
|
|
||||||
posts = append(posts, post)
|
|
||||||
}
|
|
||||||
return posts, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetPostVote sets value to 1, -1, or 0 on a visible root post.
|
|
||||||
func SetPostVote(ctx context.Context, db *sql.DB, userID, postID string, value int) error {
|
|
||||||
if value != 1 && value != -1 && value != 0 {
|
|
||||||
return fmt.Errorf("invalid vote")
|
|
||||||
}
|
|
||||||
q := sqlc.New(db)
|
|
||||||
if value == 0 {
|
|
||||||
visible, err := q.PostIsVisibleRoot(ctx, sqlc.PostIsVisibleRootParams{
|
|
||||||
ID: postID,
|
|
||||||
HiddenState: string(PostStateHidden),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !visible {
|
|
||||||
return ErrPostNotVotable
|
|
||||||
}
|
|
||||||
return q.DeletePostVote(ctx, sqlc.DeletePostVoteParams{
|
|
||||||
UserID: userID,
|
|
||||||
PostID: postID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
n, err := q.UpsertPostVoteOnVisibleRoot(ctx, sqlc.UpsertPostVoteOnVisibleRootParams{
|
|
||||||
UserID: userID,
|
|
||||||
PostID: postID,
|
|
||||||
Value: int32(value),
|
|
||||||
HiddenState: string(PostStateHidden),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if n == 0 {
|
|
||||||
return ErrPostNotVotable
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestMapPostCreateError(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, code := range []string{"23503", "23505", "23514"} {
|
|
||||||
err := mapPostCreateError(&pgconn.PgError{Code: code})
|
|
||||||
if !errors.Is(err, ErrInvalidPost) {
|
|
||||||
t.Errorf("code %s error = %v, want ErrInvalidPost", code, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
original := &pgconn.PgError{Code: "08006"}
|
|
||||||
if err := mapPostCreateError(original); !errors.Is(err, original) {
|
|
||||||
t.Errorf("unexpected database error was replaced: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMemoryPostLifecycle(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
mem := NewMemory()
|
|
||||||
homeowner := &User{Username: "homeowner", PasswordHash: "hash", Role: RoleUser}
|
|
||||||
plumber := &User{Username: "plumber", PasswordHash: "hash", Role: RoleAdmin}
|
|
||||||
voter := &User{Username: "voter", PasswordHash: "hash", Role: RoleUser}
|
|
||||||
for _, user := range []*User{homeowner, plumber, voter} {
|
|
||||||
if err := mem.CreateUser(ctx, user); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
root := &Post{
|
|
||||||
ID: "root",
|
|
||||||
AuthorID: homeowner.ID,
|
|
||||||
Title: "Leaky sink",
|
|
||||||
Body: "It drips.",
|
|
||||||
City: "Oakland",
|
|
||||||
PostDate: "2026-08-26",
|
|
||||||
CreatedAt: "2026-08-26T08:00:00Z",
|
|
||||||
}
|
|
||||||
if err := mem.CreatePost(ctx, root); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if root.PostState != PostStateVisible {
|
|
||||||
t.Fatalf("default post state = %q, want visible", root.PostState)
|
|
||||||
}
|
|
||||||
|
|
||||||
rootID := root.ID
|
|
||||||
later := &Post{
|
|
||||||
ID: "later",
|
|
||||||
ParentID: &rootID,
|
|
||||||
AuthorID: plumber.ID,
|
|
||||||
Body: "Is it a single-handle faucet?",
|
|
||||||
CreatedAt: "2026-08-26T09:00:00Z",
|
|
||||||
}
|
|
||||||
earlier := &Post{
|
|
||||||
ID: "earlier",
|
|
||||||
ParentID: &rootID,
|
|
||||||
AuthorID: plumber.ID,
|
|
||||||
Body: "Can you share the model number?",
|
|
||||||
CreatedAt: "2026-08-26T08:30:00Z",
|
|
||||||
}
|
|
||||||
if err := mem.CreatePost(ctx, later); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := mem.CreatePost(ctx, earlier); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
laterID := later.ID
|
|
||||||
nested := &Post{
|
|
||||||
ID: "nested",
|
|
||||||
ParentID: &laterID,
|
|
||||||
AuthorID: homeowner.ID,
|
|
||||||
Body: "Yes, it is.",
|
|
||||||
CreatedAt: "2026-08-26T09:30:00Z",
|
|
||||||
}
|
|
||||||
if err := mem.CreatePost(ctx, nested); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
thread, err := mem.GetPostThread(ctx, root.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if thread.AuthorName != homeowner.Name || thread.AuthorRole != RoleUser {
|
|
||||||
t.Fatalf("root author = %q %q", thread.AuthorName, thread.AuthorRole)
|
|
||||||
}
|
|
||||||
if len(thread.Replies) != 2 ||
|
|
||||||
thread.Replies[0].ID != earlier.ID ||
|
|
||||||
thread.Replies[1].ID != later.ID {
|
|
||||||
t.Fatalf("root replies are not oldest-first: %+v", thread.Replies)
|
|
||||||
}
|
|
||||||
if len(thread.Replies[1].Replies) != 1 || thread.Replies[1].Replies[0].ID != nested.ID {
|
|
||||||
t.Fatalf("nested reply missing: %+v", thread.Replies[1].Replies)
|
|
||||||
}
|
|
||||||
|
|
||||||
otherParent := earlier.ID
|
|
||||||
nested.ParentID = &otherParent
|
|
||||||
nested.AuthorID = voter.ID
|
|
||||||
nested.Body = "Yes—one handle."
|
|
||||||
if err := mem.UpdatePost(ctx, nested); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
saved, err := mem.GetPost(ctx, nested.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if saved.ParentID == nil || *saved.ParentID != later.ID {
|
|
||||||
t.Fatalf("update changed parent to %+v", saved.ParentID)
|
|
||||||
}
|
|
||||||
if saved.AuthorID != homeowner.ID {
|
|
||||||
t.Fatalf("update changed author to %q", saved.AuthorID)
|
|
||||||
}
|
|
||||||
if saved.Body != "Yes—one handle." || saved.UpdatedAt == saved.CreatedAt {
|
|
||||||
t.Fatalf("body update not applied: %+v", saved)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := mem.VotePost(ctx, voter.ID, root.ID, 1); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
roots, err := mem.ListRootPosts(ctx, root.PostDate, voter.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(roots) != 1 || roots[0].ID != root.ID {
|
|
||||||
t.Fatalf("root list = %+v", roots)
|
|
||||||
}
|
|
||||||
if roots[0].Score != 1 || roots[0].UserVote != 1 || !roots[0].Answered {
|
|
||||||
t.Fatalf("root annotations = %+v", roots[0])
|
|
||||||
}
|
|
||||||
if err := mem.VotePost(ctx, voter.ID, later.ID, 1); !errors.Is(err, ErrPostNotVotable) {
|
|
||||||
t.Fatalf("reply vote error = %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMemoryPostValidation(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
mem := NewMemory()
|
|
||||||
homeowner := &User{Username: "homeowner", PasswordHash: "hash", Role: RoleUser}
|
|
||||||
if err := mem.CreateUser(ctx, homeowner); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
post *Post
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "root without title",
|
|
||||||
post: &Post{AuthorID: homeowner.ID, Body: "Body"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty parent",
|
|
||||||
post: &Post{ParentID: ptr(""), AuthorID: homeowner.ID, Body: "Body"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing parent",
|
|
||||||
post: &Post{ParentID: ptr("missing"), AuthorID: homeowner.ID, Body: "Body"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "reply with root fields",
|
|
||||||
post: &Post{
|
|
||||||
ParentID: ptr("missing"),
|
|
||||||
AuthorID: homeowner.ID,
|
|
||||||
Title: "Not allowed",
|
|
||||||
Body: "Body",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid post state",
|
|
||||||
post: &Post{
|
|
||||||
AuthorID: homeowner.ID,
|
|
||||||
Title: "Invalid state",
|
|
||||||
Body: "Body",
|
|
||||||
PostState: PostState("archived"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "reply with non-visible state",
|
|
||||||
post: &Post{
|
|
||||||
ParentID: ptr("missing"),
|
|
||||||
AuthorID: homeowner.ID,
|
|
||||||
Body: "Body",
|
|
||||||
PostState: PostStateLocked,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
if err := mem.CreatePost(ctx, test.post); !errors.Is(err, ErrInvalidPost) {
|
|
||||||
t.Fatalf("error = %v, want ErrInvalidPost", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
hidden := &Post{
|
|
||||||
ID: "hidden",
|
|
||||||
AuthorID: homeowner.ID,
|
|
||||||
Title: "Hidden",
|
|
||||||
Body: "Body",
|
|
||||||
PostDate: "2026-08-26",
|
|
||||||
PostState: PostStateHidden,
|
|
||||||
}
|
|
||||||
if err := mem.CreatePost(ctx, hidden); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := mem.VotePost(ctx, homeowner.ID, hidden.ID, 1); !errors.Is(err, ErrPostNotVotable) {
|
|
||||||
t.Fatalf("hidden root vote error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
locked := &Post{
|
|
||||||
ID: "locked",
|
|
||||||
AuthorID: homeowner.ID,
|
|
||||||
Title: "Locked",
|
|
||||||
Body: "Body",
|
|
||||||
PostDate: "2026-08-26",
|
|
||||||
PostState: PostStateLocked,
|
|
||||||
}
|
|
||||||
if err := mem.CreatePost(ctx, locked); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := mem.VotePost(ctx, homeowner.ID, locked.ID, 1); err != nil {
|
|
||||||
t.Fatalf("locked root should remain votable: %v", err)
|
|
||||||
}
|
|
||||||
roots, err := mem.ListRootPosts(ctx, locked.PostDate, homeowner.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(roots) != 1 || roots[0].ID != locked.ID || roots[0].PostState != PostStateLocked {
|
|
||||||
t.Fatalf("locked root should remain visible: %+v", roots)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := mem.GetPostThread(ctx, "missing"); !errors.Is(err, sql.ErrNoRows) {
|
|
||||||
t.Fatalf("missing thread error = %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ptr(value string) *string {
|
|
||||||
return &value
|
|
||||||
}
|
|
||||||
@@ -144,32 +144,6 @@ func (p *Postgres) UpsertAnswer(ctx context.Context, a *Answer) error {
|
|||||||
return a.Upsert(ctx)
|
return a.Upsert(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Postgres) CreatePost(ctx context.Context, post *Post) error {
|
|
||||||
post.db = p.db
|
|
||||||
return post.Create(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Postgres) GetPost(ctx context.Context, id string) (*Post, error) {
|
|
||||||
return GetPost(ctx, p.db, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Postgres) GetPostThread(ctx context.Context, rootID string) (*Post, error) {
|
|
||||||
return GetPostThread(ctx, p.db, rootID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Postgres) UpdatePost(ctx context.Context, post *Post) error {
|
|
||||||
post.db = p.db
|
|
||||||
return post.Update(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Postgres) ListRootPosts(ctx context.Context, postDate, viewerID string) ([]Post, error) {
|
|
||||||
return ListRootPosts(ctx, p.db, postDate, viewerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Postgres) VotePost(ctx context.Context, userID, postID string, value int) error {
|
|
||||||
return SetPostVote(ctx, p.db, userID, postID, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Postgres) Vote(ctx context.Context, userID, questionID string, value int) error {
|
func (p *Postgres) Vote(ctx context.Context, userID, questionID string, value int) error {
|
||||||
return Vote(ctx, p.db, userID, questionID, value)
|
return Vote(ctx, p.db, userID, questionID, value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
package sqlc
|
package sqlc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,25 +16,6 @@ type Answer struct {
|
|||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Post struct {
|
|
||||||
ID string
|
|
||||||
ParentID sql.NullString
|
|
||||||
AuthorID string
|
|
||||||
Title string
|
|
||||||
Body string
|
|
||||||
City string
|
|
||||||
PostDate string
|
|
||||||
PostState string
|
|
||||||
CreatedAt string
|
|
||||||
UpdatedAt string
|
|
||||||
}
|
|
||||||
|
|
||||||
type PostVote struct {
|
|
||||||
UserID string
|
|
||||||
PostID string
|
|
||||||
Value int32
|
|
||||||
}
|
|
||||||
|
|
||||||
type Question struct {
|
type Question struct {
|
||||||
ID string
|
ID string
|
||||||
AuthorID string
|
AuthorID string
|
||||||
|
|||||||
@@ -1,385 +0,0 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
|
||||||
// versions:
|
|
||||||
// sqlc v1.31.1
|
|
||||||
// source: posts.sql
|
|
||||||
|
|
||||||
package sqlc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
)
|
|
||||||
|
|
||||||
const createPost = `-- name: CreatePost :exec
|
|
||||||
INSERT INTO posts (
|
|
||||||
id, parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at
|
|
||||||
)
|
|
||||||
VALUES (
|
|
||||||
$1,
|
|
||||||
$2,
|
|
||||||
$3,
|
|
||||||
$4,
|
|
||||||
$5,
|
|
||||||
$6,
|
|
||||||
$7,
|
|
||||||
$8,
|
|
||||||
$9,
|
|
||||||
$10
|
|
||||||
)
|
|
||||||
`
|
|
||||||
|
|
||||||
type CreatePostParams struct {
|
|
||||||
ID string
|
|
||||||
ParentID sql.NullString
|
|
||||||
AuthorID string
|
|
||||||
Title string
|
|
||||||
Body string
|
|
||||||
City string
|
|
||||||
PostDate string
|
|
||||||
PostState string
|
|
||||||
CreatedAt string
|
|
||||||
UpdatedAt string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) CreatePost(ctx context.Context, arg CreatePostParams) error {
|
|
||||||
_, err := q.db.ExecContext(ctx, createPost,
|
|
||||||
arg.ID,
|
|
||||||
arg.ParentID,
|
|
||||||
arg.AuthorID,
|
|
||||||
arg.Title,
|
|
||||||
arg.Body,
|
|
||||||
arg.City,
|
|
||||||
arg.PostDate,
|
|
||||||
arg.PostState,
|
|
||||||
arg.CreatedAt,
|
|
||||||
arg.UpdatedAt,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const deletePostVote = `-- name: DeletePostVote :exec
|
|
||||||
DELETE FROM post_votes
|
|
||||||
WHERE user_id = $1
|
|
||||||
AND post_id = $2
|
|
||||||
`
|
|
||||||
|
|
||||||
type DeletePostVoteParams struct {
|
|
||||||
UserID string
|
|
||||||
PostID string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) DeletePostVote(ctx context.Context, arg DeletePostVoteParams) error {
|
|
||||||
_, err := q.db.ExecContext(ctx, deletePostVote, arg.UserID, arg.PostID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const getPost = `-- name: GetPost :one
|
|
||||||
SELECT
|
|
||||||
p.id, p.parent_id, p.author_id, u.name AS author_name, u.role AS author_role,
|
|
||||||
p.title, p.body, p.city, p.post_date, p.post_state, p.created_at, p.updated_at
|
|
||||||
FROM posts p
|
|
||||||
JOIN users u ON u.id = p.author_id
|
|
||||||
WHERE p.id = $1
|
|
||||||
`
|
|
||||||
|
|
||||||
type GetPostRow struct {
|
|
||||||
ID string
|
|
||||||
ParentID sql.NullString
|
|
||||||
AuthorID string
|
|
||||||
AuthorName string
|
|
||||||
AuthorRole string
|
|
||||||
Title string
|
|
||||||
Body string
|
|
||||||
City string
|
|
||||||
PostDate string
|
|
||||||
PostState string
|
|
||||||
CreatedAt string
|
|
||||||
UpdatedAt string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) GetPost(ctx context.Context, id string) (GetPostRow, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, getPost, id)
|
|
||||||
var i GetPostRow
|
|
||||||
err := row.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.ParentID,
|
|
||||||
&i.AuthorID,
|
|
||||||
&i.AuthorName,
|
|
||||||
&i.AuthorRole,
|
|
||||||
&i.Title,
|
|
||||||
&i.Body,
|
|
||||||
&i.City,
|
|
||||||
&i.PostDate,
|
|
||||||
&i.PostState,
|
|
||||||
&i.CreatedAt,
|
|
||||||
&i.UpdatedAt,
|
|
||||||
)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const listPostThread = `-- name: ListPostThread :many
|
|
||||||
WITH RECURSIVE thread AS (
|
|
||||||
SELECT p.id, p.parent_id, p.author_id, p.title, p.body, p.city, p.post_date, p.post_state, p.created_at, p.updated_at
|
|
||||||
FROM posts p
|
|
||||||
WHERE p.id = $1 AND p.parent_id IS NULL
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
SELECT child.id, child.parent_id, child.author_id, child.title, child.body, child.city, child.post_date, child.post_state, child.created_at, child.updated_at
|
|
||||||
FROM posts child
|
|
||||||
JOIN thread parent ON child.parent_id = parent.id
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
thread.id, thread.parent_id, thread.author_id,
|
|
||||||
u.name AS author_name, u.role AS author_role,
|
|
||||||
thread.title, thread.body, thread.city, thread.post_date,
|
|
||||||
thread.post_state, thread.created_at, thread.updated_at
|
|
||||||
FROM thread
|
|
||||||
JOIN users u ON u.id = thread.author_id
|
|
||||||
ORDER BY thread.created_at, thread.id
|
|
||||||
`
|
|
||||||
|
|
||||||
type ListPostThreadRow struct {
|
|
||||||
ID string
|
|
||||||
ParentID sql.NullString
|
|
||||||
AuthorID string
|
|
||||||
AuthorName string
|
|
||||||
AuthorRole string
|
|
||||||
Title string
|
|
||||||
Body string
|
|
||||||
City string
|
|
||||||
PostDate string
|
|
||||||
PostState string
|
|
||||||
CreatedAt string
|
|
||||||
UpdatedAt string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) ListPostThread(ctx context.Context, rootID string) ([]ListPostThreadRow, error) {
|
|
||||||
rows, err := q.db.QueryContext(ctx, listPostThread, rootID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
items := []ListPostThreadRow{}
|
|
||||||
for rows.Next() {
|
|
||||||
var i ListPostThreadRow
|
|
||||||
if err := rows.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.ParentID,
|
|
||||||
&i.AuthorID,
|
|
||||||
&i.AuthorName,
|
|
||||||
&i.AuthorRole,
|
|
||||||
&i.Title,
|
|
||||||
&i.Body,
|
|
||||||
&i.City,
|
|
||||||
&i.PostDate,
|
|
||||||
&i.PostState,
|
|
||||||
&i.CreatedAt,
|
|
||||||
&i.UpdatedAt,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items = append(items, i)
|
|
||||||
}
|
|
||||||
if err := rows.Close(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const listRootPosts = `-- name: ListRootPosts :many
|
|
||||||
WITH RECURSIVE roots AS (
|
|
||||||
SELECT p.id, p.parent_id, p.author_id, p.title, p.body, p.city, p.post_date, p.post_state, p.created_at, p.updated_at
|
|
||||||
FROM posts p
|
|
||||||
WHERE p.parent_id IS NULL
|
|
||||||
AND p.post_date = $3
|
|
||||||
AND p.post_state <> $4
|
|
||||||
),
|
|
||||||
thread AS (
|
|
||||||
SELECT roots.id AS root_id, child.id AS post_id, child.author_id
|
|
||||||
FROM roots
|
|
||||||
JOIN posts child ON child.parent_id = roots.id
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
SELECT thread.root_id, child.id, child.author_id
|
|
||||||
FROM thread
|
|
||||||
JOIN posts child ON child.parent_id = thread.post_id
|
|
||||||
),
|
|
||||||
answered AS (
|
|
||||||
SELECT DISTINCT thread.root_id
|
|
||||||
FROM thread
|
|
||||||
JOIN users u ON u.id = thread.author_id
|
|
||||||
WHERE u.role = 'admin'
|
|
||||||
),
|
|
||||||
scores AS (
|
|
||||||
SELECT votes.post_id, SUM(votes.value)::bigint AS score
|
|
||||||
FROM roots
|
|
||||||
JOIN post_votes votes ON votes.post_id = roots.id
|
|
||||||
GROUP BY votes.post_id
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
roots.id, roots.parent_id, roots.author_id,
|
|
||||||
u.name AS author_name, u.role AS author_role,
|
|
||||||
roots.title, roots.body, roots.city, roots.post_date,
|
|
||||||
roots.post_state, roots.created_at, roots.updated_at,
|
|
||||||
COALESCE(scores.score, 0)::bigint AS score,
|
|
||||||
(answered.root_id IS NOT NULL)::bool AS answered,
|
|
||||||
COALESCE(viewer_vote.value, 0)::bigint AS user_vote
|
|
||||||
FROM roots
|
|
||||||
JOIN users u ON u.id = roots.author_id
|
|
||||||
LEFT JOIN scores ON scores.post_id = roots.id
|
|
||||||
LEFT JOIN answered ON answered.root_id = roots.id
|
|
||||||
LEFT JOIN post_votes viewer_vote
|
|
||||||
ON viewer_vote.user_id = $1
|
|
||||||
AND viewer_vote.post_id = roots.id
|
|
||||||
ORDER BY score DESC, roots.created_at, roots.id
|
|
||||||
LIMIT $2
|
|
||||||
`
|
|
||||||
|
|
||||||
type ListRootPostsParams struct {
|
|
||||||
ViewerID string
|
|
||||||
RowLimit int32
|
|
||||||
PostDate string
|
|
||||||
HiddenState string
|
|
||||||
}
|
|
||||||
|
|
||||||
type ListRootPostsRow struct {
|
|
||||||
ID string
|
|
||||||
ParentID sql.NullString
|
|
||||||
AuthorID string
|
|
||||||
AuthorName string
|
|
||||||
AuthorRole string
|
|
||||||
Title string
|
|
||||||
Body string
|
|
||||||
City string
|
|
||||||
PostDate string
|
|
||||||
PostState string
|
|
||||||
CreatedAt string
|
|
||||||
UpdatedAt string
|
|
||||||
Score int64
|
|
||||||
Answered bool
|
|
||||||
UserVote int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) ListRootPosts(ctx context.Context, arg ListRootPostsParams) ([]ListRootPostsRow, error) {
|
|
||||||
rows, err := q.db.QueryContext(ctx, listRootPosts,
|
|
||||||
arg.ViewerID,
|
|
||||||
arg.RowLimit,
|
|
||||||
arg.PostDate,
|
|
||||||
arg.HiddenState,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
items := []ListRootPostsRow{}
|
|
||||||
for rows.Next() {
|
|
||||||
var i ListRootPostsRow
|
|
||||||
if err := rows.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.ParentID,
|
|
||||||
&i.AuthorID,
|
|
||||||
&i.AuthorName,
|
|
||||||
&i.AuthorRole,
|
|
||||||
&i.Title,
|
|
||||||
&i.Body,
|
|
||||||
&i.City,
|
|
||||||
&i.PostDate,
|
|
||||||
&i.PostState,
|
|
||||||
&i.CreatedAt,
|
|
||||||
&i.UpdatedAt,
|
|
||||||
&i.Score,
|
|
||||||
&i.Answered,
|
|
||||||
&i.UserVote,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items = append(items, i)
|
|
||||||
}
|
|
||||||
if err := rows.Close(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const postIsVisibleRoot = `-- name: PostIsVisibleRoot :one
|
|
||||||
SELECT EXISTS(
|
|
||||||
SELECT 1
|
|
||||||
FROM posts
|
|
||||||
WHERE id = $1
|
|
||||||
AND parent_id IS NULL
|
|
||||||
AND post_state <> $2
|
|
||||||
)::bool
|
|
||||||
`
|
|
||||||
|
|
||||||
type PostIsVisibleRootParams struct {
|
|
||||||
ID string
|
|
||||||
HiddenState string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) PostIsVisibleRoot(ctx context.Context, arg PostIsVisibleRootParams) (bool, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, postIsVisibleRoot, arg.ID, arg.HiddenState)
|
|
||||||
var column_1 bool
|
|
||||||
err := row.Scan(&column_1)
|
|
||||||
return column_1, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatePost = `-- name: UpdatePost :execrows
|
|
||||||
UPDATE posts
|
|
||||||
SET
|
|
||||||
body = $1,
|
|
||||||
updated_at = $2
|
|
||||||
WHERE id = $3
|
|
||||||
`
|
|
||||||
|
|
||||||
type UpdatePostParams struct {
|
|
||||||
Body string
|
|
||||||
UpdatedAt string
|
|
||||||
ID string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) UpdatePost(ctx context.Context, arg UpdatePostParams) (int64, error) {
|
|
||||||
result, err := q.db.ExecContext(ctx, updatePost, arg.Body, arg.UpdatedAt, arg.ID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return result.RowsAffected()
|
|
||||||
}
|
|
||||||
|
|
||||||
const upsertPostVoteOnVisibleRoot = `-- name: UpsertPostVoteOnVisibleRoot :execrows
|
|
||||||
INSERT INTO post_votes (user_id, post_id, value)
|
|
||||||
SELECT $1, $2, $3
|
|
||||||
FROM posts p
|
|
||||||
WHERE p.id = $2
|
|
||||||
AND p.parent_id IS NULL
|
|
||||||
AND p.post_state <> $4
|
|
||||||
ON CONFLICT (user_id, post_id) DO UPDATE
|
|
||||||
SET value = excluded.value
|
|
||||||
`
|
|
||||||
|
|
||||||
type UpsertPostVoteOnVisibleRootParams struct {
|
|
||||||
UserID string
|
|
||||||
PostID string
|
|
||||||
Value int32
|
|
||||||
HiddenState string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) UpsertPostVoteOnVisibleRoot(ctx context.Context, arg UpsertPostVoteOnVisibleRootParams) (int64, error) {
|
|
||||||
result, err := q.db.ExecContext(ctx, upsertPostVoteOnVisibleRoot,
|
|
||||||
arg.UserID,
|
|
||||||
arg.PostID,
|
|
||||||
arg.Value,
|
|
||||||
arg.HiddenState,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return result.RowsAffected()
|
|
||||||
}
|
|
||||||
@@ -37,13 +37,6 @@ type Store interface {
|
|||||||
GetAnswer(ctx context.Context, questionID string) (*Answer, error)
|
GetAnswer(ctx context.Context, questionID string) (*Answer, error)
|
||||||
UpsertAnswer(ctx context.Context, a *Answer) error
|
UpsertAnswer(ctx context.Context, a *Answer) error
|
||||||
|
|
||||||
CreatePost(ctx context.Context, post *Post) error
|
|
||||||
GetPost(ctx context.Context, id string) (*Post, error)
|
|
||||||
GetPostThread(ctx context.Context, rootID string) (*Post, error)
|
|
||||||
UpdatePost(ctx context.Context, post *Post) error
|
|
||||||
ListRootPosts(ctx context.Context, postDate, viewerID string) ([]Post, error)
|
|
||||||
VotePost(ctx context.Context, userID, postID string, value int) error
|
|
||||||
|
|
||||||
// Vote sets the vote to 1, -1, or 0 (clear) on a visible question.
|
// Vote sets the vote to 1, -1, or 0 (clear) on a visible question.
|
||||||
Vote(ctx context.Context, userID, questionID string, value int) error
|
Vote(ctx context.Context, userID, questionID string, value int) error
|
||||||
}
|
}
|
||||||
|
|||||||
-35
@@ -42,41 +42,6 @@ CREATE TABLE IF NOT EXISTS answers (
|
|||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS posts (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
parent_id TEXT REFERENCES posts(id) ON DELETE CASCADE,
|
|
||||||
author_id TEXT NOT NULL REFERENCES users(id),
|
|
||||||
title TEXT NOT NULL DEFAULT '',
|
|
||||||
body TEXT NOT NULL,
|
|
||||||
city TEXT NOT NULL DEFAULT '',
|
|
||||||
post_date TEXT NOT NULL DEFAULT '',
|
|
||||||
post_state TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL,
|
|
||||||
CONSTRAINT posts_shape_check CHECK (
|
|
||||||
(parent_id IS NULL AND title <> '' AND post_date <> '')
|
|
||||||
OR
|
|
||||||
(parent_id IS NOT NULL AND title = '' AND city = '' AND post_date = '')
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_posts_parent_created
|
|
||||||
ON posts(parent_id, created_at, id);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_posts_root_date
|
|
||||||
ON posts(post_date, post_state)
|
|
||||||
WHERE parent_id IS NULL;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS post_votes (
|
|
||||||
user_id TEXT NOT NULL REFERENCES users(id),
|
|
||||||
post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
|
||||||
value INTEGER NOT NULL CHECK (value IN (-1, 1)),
|
|
||||||
PRIMARY KEY (user_id, post_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_post_votes_post_id
|
|
||||||
ON post_votes(post_id);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
token TEXT PRIMARY KEY,
|
token TEXT PRIMARY KEY,
|
||||||
data BYTEA NOT NULL,
|
data BYTEA NOT NULL,
|
||||||
|
|||||||
Reference in New Issue
Block a user