Add unified post storage #3
+39
-28
@@ -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;
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user