Add unified post storage #3

Merged
codegirl007 merged 3 commits from posts-store into app 2026-08-27 07:43:26 +00:00
5 changed files with 215 additions and 55 deletions
Showing only changes of commit e918e5bd1d - Show all commits
+39 -28
View File
@@ -2,7 +2,18 @@
INSERT INTO posts ( INSERT INTO posts (
id, parent_id, author_id, title, body, city, post_date, hidden, created_at, updated_at id, parent_id, author_id, title, body, city, post_date, hidden, created_at, updated_at
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10); 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(hidden),
sqlc.arg(created_at),
sqlc.arg(updated_at)
);
-- name: GetPost :one -- name: GetPost :one
SELECT SELECT
@@ -10,13 +21,13 @@ SELECT
p.title, p.body, p.city, p.post_date, p.hidden, p.created_at, p.updated_at p.title, p.body, p.city, p.post_date, p.hidden, p.created_at, p.updated_at
FROM posts p FROM posts p
JOIN users u ON u.id = p.author_id JOIN users u ON u.id = p.author_id
WHERE p.id = $1; WHERE p.id = sqlc.arg(id);
-- name: ListPostThread :many -- name: ListPostThread :many
WITH RECURSIVE thread AS ( WITH RECURSIVE thread AS (
SELECT p.* SELECT p.*
FROM posts p FROM posts p
WHERE p.id = $1 AND p.parent_id IS NULL WHERE p.id = sqlc.arg(root_id) AND p.parent_id IS NULL
UNION ALL UNION ALL
@@ -35,8 +46,10 @@ ORDER BY thread.created_at, thread.id;
-- name: UpdatePost :execrows -- name: UpdatePost :execrows
UPDATE posts UPDATE posts
SET body = $2, updated_at = $3 SET
WHERE id = $1; body = sqlc.arg(body),
updated_at = sqlc.arg(updated_at)
WHERE id = sqlc.arg(id);
-- name: ListRootPosts :many -- name: ListRootPosts :many
WITH RECURSIVE roots AS ( WITH RECURSIVE roots AS (
@@ -47,8 +60,9 @@ WITH RECURSIVE roots AS (
AND p.hidden = 0 AND p.hidden = 0
), ),
thread AS ( thread AS (
SELECT roots.id AS root_id, roots.id AS post_id, roots.author_id SELECT roots.id AS root_id, child.id AS post_id, child.author_id
FROM roots FROM roots
JOIN posts child ON child.parent_id = roots.id
UNION ALL UNION ALL
@@ -57,15 +71,16 @@ thread AS (
JOIN posts child ON child.parent_id = thread.post_id JOIN posts child ON child.parent_id = thread.post_id
), ),
answered AS ( answered AS (
SELECT thread.root_id, bool_or(u.role = 'admin' AND thread.post_id <> thread.root_id) AS answered SELECT DISTINCT thread.root_id
FROM thread FROM thread
JOIN users u ON u.id = thread.author_id JOIN users u ON u.id = thread.author_id
GROUP BY thread.root_id WHERE u.role = 'admin'
), ),
scores AS ( scores AS (
SELECT post_id, COALESCE(SUM(value), 0)::bigint AS score SELECT votes.post_id, SUM(votes.value)::bigint AS score
FROM post_votes FROM roots
GROUP BY post_id JOIN post_votes votes ON votes.post_id = roots.id
GROUP BY votes.post_id
) )
SELECT SELECT
roots.id, roots.parent_id, roots.author_id, roots.id, roots.parent_id, roots.author_id,
@@ -73,17 +88,15 @@ SELECT
roots.title, roots.body, roots.city, roots.post_date, roots.title, roots.body, roots.city, roots.post_date,
roots.hidden, roots.created_at, roots.updated_at, roots.hidden, roots.created_at, roots.updated_at,
COALESCE(scores.score, 0)::bigint AS score, COALESCE(scores.score, 0)::bigint AS score,
COALESCE(answered.answered, false)::bool AS answered, (answered.root_id IS NOT NULL)::bool AS answered,
COALESCE(( COALESCE(viewer_vote.value, 0)::bigint AS user_vote
SELECT post_votes.value
FROM post_votes
WHERE post_votes.user_id = sqlc.arg(viewer_id)
AND post_votes.post_id = roots.id
), 0)::bigint AS user_vote
FROM roots FROM roots
JOIN users u ON u.id = roots.author_id JOIN users u ON u.id = roots.author_id
LEFT JOIN scores ON scores.post_id = roots.id LEFT JOIN scores ON scores.post_id = roots.id
LEFT JOIN answered ON answered.root_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 ORDER BY score DESC, roots.created_at, roots.id
LIMIT sqlc.arg(row_limit); LIMIT sqlc.arg(row_limit);
@@ -91,22 +104,20 @@ LIMIT sqlc.arg(row_limit);
SELECT EXISTS( SELECT EXISTS(
SELECT 1 SELECT 1
FROM posts FROM posts
WHERE id = $1 AND parent_id IS NULL AND hidden = 0 WHERE id = sqlc.arg(id) AND parent_id IS NULL AND hidden = 0
)::bool; )::bool;
-- name: DeletePostVote :exec -- name: DeletePostVote :exec
DELETE FROM post_votes DELETE FROM post_votes
WHERE user_id = $1 AND post_id = $2; WHERE user_id = sqlc.arg(user_id)
AND post_id = sqlc.arg(post_id);
-- name: UpsertPostVoteOnVisibleRoot :execrows -- name: UpsertPostVoteOnVisibleRoot :execrows
INSERT INTO post_votes (user_id, post_id, value) INSERT INTO post_votes (user_id, post_id, value)
SELECT $1, $2, $3 SELECT sqlc.arg(user_id), sqlc.arg(post_id), sqlc.arg(value)
FROM posts p FROM posts p
WHERE p.id = $2 AND p.parent_id IS NULL AND p.hidden = 0 WHERE p.id = sqlc.arg(post_id)
AND p.parent_id IS NULL
AND p.hidden = 0
ON CONFLICT (user_id, post_id) DO UPDATE ON CONFLICT (user_id, post_id) DO UPDATE
SET value = excluded.value SET value = excluded.value;
WHERE EXISTS (
SELECT 1
FROM posts p2
WHERE p2.id = excluded.post_id AND p2.parent_id IS NULL AND p2.hidden = 0
);
+51
View File
@@ -104,6 +104,55 @@ ON CONFLICT (user_id, post_id) DO NOTHING`},
return nil 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", `
CREATE INDEX IF NOT EXISTS idx_posts_root_date
ON posts(post_date, hidden)
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
}
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)
@@ -151,6 +200,8 @@ 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}, {"004_posts", migratePosts},
{"005_post_vote_post_id_index", migratePostVoteIndex},
{"006_post_date", migratePostDate},
} }
for _, m := range migrations { for _, m := range migrations {
if applied[m.version] { if applied[m.version] {
+84
View File
@@ -97,6 +97,12 @@ VALUES ('homeowner', 'question-1', 1);`); err != nil {
if err := migratePosts(ctx, conn); err != nil { if err := migratePosts(ctx, conn); err != nil {
t.Fatalf("migration is not idempotent: %v", err) 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 var postCount, voteCount, legacyQuestionCount, legacyAnswerCount int
if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM posts").Scan(&postCount); err != nil { if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM posts").Scan(&postCount); err != nil {
@@ -120,6 +126,23 @@ VALUES ('homeowner', 'question-1', 1);`); err != nil {
legacyAnswerCount, 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 rootParent sql.NullString
var rootAuthor, title, rootBody, city, postDate, rootCreated, rootUpdated string var rootAuthor, title, rootBody, city, postDate, rootCreated, rootUpdated string
@@ -210,6 +233,13 @@ INSERT INTO posts (
thread[2].ID != "follow-up" { thread[2].ID != "follow-up" {
t.Fatalf("recursive thread = %+v", thread) 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{ if n, err := queries.UpdatePost(ctx, sqlc.UpdatePostParams{
ID: "follow-up", ID: "follow-up",
Body: "The drip continues.", Body: "The drip continues.",
@@ -238,6 +268,60 @@ INSERT INTO posts (
roots[0].UserVote != 1 { roots[0].UserVote != 1 {
t.Fatalf("root annotations = %+v", roots) 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, hidden)
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,
)
}
} }
func TestMigratePostsReportsStep(t *testing.T) { func TestMigratePostsReportsStep(t *testing.T) {
+38 -27
View File
@@ -14,7 +14,18 @@ const createPost = `-- name: CreatePost :exec
INSERT INTO posts ( INSERT INTO posts (
id, parent_id, author_id, title, body, city, post_date, hidden, created_at, updated_at id, parent_id, author_id, title, body, city, post_date, hidden, created_at, updated_at
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
$9,
$10
)
` `
type CreatePostParams struct { type CreatePostParams struct {
@@ -48,7 +59,8 @@ func (q *Queries) CreatePost(ctx context.Context, arg CreatePostParams) error {
const deletePostVote = `-- name: DeletePostVote :exec const deletePostVote = `-- name: DeletePostVote :exec
DELETE FROM post_votes DELETE FROM post_votes
WHERE user_id = $1 AND post_id = $2 WHERE user_id = $1
AND post_id = $2
` `
type DeletePostVoteParams struct { type DeletePostVoteParams struct {
@@ -142,8 +154,8 @@ type ListPostThreadRow struct {
UpdatedAt string UpdatedAt string
} }
func (q *Queries) ListPostThread(ctx context.Context, id string) ([]ListPostThreadRow, error) { func (q *Queries) ListPostThread(ctx context.Context, rootID string) ([]ListPostThreadRow, error) {
rows, err := q.db.QueryContext(ctx, listPostThread, id) rows, err := q.db.QueryContext(ctx, listPostThread, rootID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -187,8 +199,9 @@ WITH RECURSIVE roots AS (
AND p.hidden = 0 AND p.hidden = 0
), ),
thread AS ( thread AS (
SELECT roots.id AS root_id, roots.id AS post_id, roots.author_id SELECT roots.id AS root_id, child.id AS post_id, child.author_id
FROM roots FROM roots
JOIN posts child ON child.parent_id = roots.id
UNION ALL UNION ALL
@@ -197,15 +210,16 @@ thread AS (
JOIN posts child ON child.parent_id = thread.post_id JOIN posts child ON child.parent_id = thread.post_id
), ),
answered AS ( answered AS (
SELECT thread.root_id, bool_or(u.role = 'admin' AND thread.post_id <> thread.root_id) AS answered SELECT DISTINCT thread.root_id
FROM thread FROM thread
JOIN users u ON u.id = thread.author_id JOIN users u ON u.id = thread.author_id
GROUP BY thread.root_id WHERE u.role = 'admin'
), ),
scores AS ( scores AS (
SELECT post_id, COALESCE(SUM(value), 0)::bigint AS score SELECT votes.post_id, SUM(votes.value)::bigint AS score
FROM post_votes FROM roots
GROUP BY post_id JOIN post_votes votes ON votes.post_id = roots.id
GROUP BY votes.post_id
) )
SELECT SELECT
roots.id, roots.parent_id, roots.author_id, roots.id, roots.parent_id, roots.author_id,
@@ -213,17 +227,15 @@ SELECT
roots.title, roots.body, roots.city, roots.post_date, roots.title, roots.body, roots.city, roots.post_date,
roots.hidden, roots.created_at, roots.updated_at, roots.hidden, roots.created_at, roots.updated_at,
COALESCE(scores.score, 0)::bigint AS score, COALESCE(scores.score, 0)::bigint AS score,
COALESCE(answered.answered, false)::bool AS answered, (answered.root_id IS NOT NULL)::bool AS answered,
COALESCE(( COALESCE(viewer_vote.value, 0)::bigint AS user_vote
SELECT post_votes.value
FROM post_votes
WHERE post_votes.user_id = $1
AND post_votes.post_id = roots.id
), 0)::bigint AS user_vote
FROM roots FROM roots
JOIN users u ON u.id = roots.author_id JOIN users u ON u.id = roots.author_id
LEFT JOIN scores ON scores.post_id = roots.id LEFT JOIN scores ON scores.post_id = roots.id
LEFT JOIN answered ON answered.root_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 ORDER BY score DESC, roots.created_at, roots.id
LIMIT $2 LIMIT $2
` `
@@ -308,18 +320,20 @@ func (q *Queries) PostIsVisibleRoot(ctx context.Context, id string) (bool, error
const updatePost = `-- name: UpdatePost :execrows const updatePost = `-- name: UpdatePost :execrows
UPDATE posts UPDATE posts
SET body = $2, updated_at = $3 SET
WHERE id = $1 body = $1,
updated_at = $2
WHERE id = $3
` `
type UpdatePostParams struct { type UpdatePostParams struct {
ID string
Body string Body string
UpdatedAt string UpdatedAt string
ID string
} }
func (q *Queries) UpdatePost(ctx context.Context, arg UpdatePostParams) (int64, error) { func (q *Queries) UpdatePost(ctx context.Context, arg UpdatePostParams) (int64, error) {
result, err := q.db.ExecContext(ctx, updatePost, arg.ID, arg.Body, arg.UpdatedAt) result, err := q.db.ExecContext(ctx, updatePost, arg.Body, arg.UpdatedAt, arg.ID)
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -330,14 +344,11 @@ const upsertPostVoteOnVisibleRoot = `-- name: UpsertPostVoteOnVisibleRoot :execr
INSERT INTO post_votes (user_id, post_id, value) INSERT INTO post_votes (user_id, post_id, value)
SELECT $1, $2, $3 SELECT $1, $2, $3
FROM posts p FROM posts p
WHERE p.id = $2 AND p.parent_id IS NULL AND p.hidden = 0 WHERE p.id = $2
AND p.parent_id IS NULL
AND p.hidden = 0
ON CONFLICT (user_id, post_id) DO UPDATE ON CONFLICT (user_id, post_id) DO UPDATE
SET value = excluded.value SET value = excluded.value
WHERE EXISTS (
SELECT 1
FROM posts p2
WHERE p2.id = excluded.post_id AND p2.parent_id IS NULL AND p2.hidden = 0
)
` `
type UpsertPostVoteOnVisibleRootParams struct { type UpsertPostVoteOnVisibleRootParams struct {
+3
View File
@@ -74,6 +74,9 @@ CREATE TABLE IF NOT EXISTS post_votes (
PRIMARY KEY (user_id, post_id) 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,