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 (
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
SELECT
@@ -10,13 +21,13 @@ SELECT
p.title, p.body, p.city, p.post_date, p.hidden, p.created_at, p.updated_at
FROM posts p
JOIN users u ON u.id = p.author_id
WHERE p.id = $1;
WHERE p.id = sqlc.arg(id);
-- name: ListPostThread :many
WITH RECURSIVE thread AS (
SELECT 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
@@ -35,8 +46,10 @@ ORDER BY thread.created_at, thread.id;
-- name: UpdatePost :execrows
UPDATE posts
SET body = $2, updated_at = $3
WHERE id = $1;
SET
body = sqlc.arg(body),
updated_at = sqlc.arg(updated_at)
WHERE id = sqlc.arg(id);
-- name: ListRootPosts :many
WITH RECURSIVE roots AS (
@@ -47,8 +60,9 @@ WITH RECURSIVE roots AS (
AND p.hidden = 0
),
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
JOIN posts child ON child.parent_id = roots.id
UNION ALL
@@ -57,15 +71,16 @@ thread AS (
JOIN posts child ON child.parent_id = thread.post_id
),
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
JOIN users u ON u.id = thread.author_id
GROUP BY thread.root_id
WHERE u.role = 'admin'
),
scores AS (
SELECT post_id, COALESCE(SUM(value), 0)::bigint AS score
FROM post_votes
GROUP BY post_id
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,
@@ -73,17 +88,15 @@ SELECT
roots.title, roots.body, roots.city, roots.post_date,
roots.hidden, roots.created_at, roots.updated_at,
COALESCE(scores.score, 0)::bigint AS score,
COALESCE(answered.answered, false)::bool AS answered,
COALESCE((
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
(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);
@@ -91,22 +104,20 @@ LIMIT sqlc.arg(row_limit);
SELECT EXISTS(
SELECT 1
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;
-- name: DeletePostVote :exec
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
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
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
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
);
SET value = excluded.value;
+51
View File
@@ -104,6 +104,55 @@ ON CONFLICT (user_id, post_id) DO NOTHING`},
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 {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, 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},
{"003_user_email", migrateUserEmail},
{"004_posts", migratePosts},
{"005_post_vote_post_id_index", migratePostVoteIndex},
{"006_post_date", migratePostDate},
}
for _, m := range migrations {
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 {
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 {
@@ -120,6 +126,23 @@ VALUES ('homeowner', 'question-1', 1);`); err != nil {
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, rootCreated, rootUpdated string
@@ -210,6 +233,13 @@ INSERT INTO posts (
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.",
@@ -238,6 +268,60 @@ INSERT INTO posts (
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, 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) {
+38 -27
View File
@@ -14,7 +14,18 @@ const createPost = `-- name: CreatePost :exec
INSERT INTO posts (
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 {
@@ -48,7 +59,8 @@ func (q *Queries) CreatePost(ctx context.Context, arg CreatePostParams) error {
const deletePostVote = `-- name: DeletePostVote :exec
DELETE FROM post_votes
WHERE user_id = $1 AND post_id = $2
WHERE user_id = $1
AND post_id = $2
`
type DeletePostVoteParams struct {
@@ -142,8 +154,8 @@ type ListPostThreadRow struct {
UpdatedAt string
}
func (q *Queries) ListPostThread(ctx context.Context, id string) ([]ListPostThreadRow, error) {
rows, err := q.db.QueryContext(ctx, listPostThread, id)
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
}
@@ -187,8 +199,9 @@ WITH RECURSIVE roots AS (
AND p.hidden = 0
),
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
JOIN posts child ON child.parent_id = roots.id
UNION ALL
@@ -197,15 +210,16 @@ thread AS (
JOIN posts child ON child.parent_id = thread.post_id
),
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
JOIN users u ON u.id = thread.author_id
GROUP BY thread.root_id
WHERE u.role = 'admin'
),
scores AS (
SELECT post_id, COALESCE(SUM(value), 0)::bigint AS score
FROM post_votes
GROUP BY post_id
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,
@@ -213,17 +227,15 @@ SELECT
roots.title, roots.body, roots.city, roots.post_date,
roots.hidden, roots.created_at, roots.updated_at,
COALESCE(scores.score, 0)::bigint AS score,
COALESCE(answered.answered, false)::bool AS answered,
COALESCE((
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
(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
`
@@ -308,18 +320,20 @@ func (q *Queries) PostIsVisibleRoot(ctx context.Context, id string) (bool, error
const updatePost = `-- name: UpdatePost :execrows
UPDATE posts
SET body = $2, updated_at = $3
WHERE id = $1
SET
body = $1,
updated_at = $2
WHERE id = $3
`
type UpdatePostParams struct {
ID string
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.ID, arg.Body, arg.UpdatedAt)
result, err := q.db.ExecContext(ctx, updatePost, arg.Body, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
@@ -330,14 +344,11 @@ const upsertPostVoteOnVisibleRoot = `-- name: UpsertPostVoteOnVisibleRoot :execr
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.hidden = 0
WHERE p.id = $2
AND p.parent_id IS NULL
AND p.hidden = 0
ON CONFLICT (user_id, post_id) DO UPDATE
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 {
+3
View File
@@ -74,6 +74,9 @@ CREATE TABLE IF NOT EXISTS post_votes (
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 (
token TEXT PRIMARY KEY,
data BYTEA NOT NULL,