Simplify post listing queries.
CI / test (pull_request) Successful in 6m17s

Use named sqlc arguments, scope vote aggregation to selected roots, join viewer votes directly, and add the indexes and drift migration required by the resulting access paths.
This commit is contained in:
2026-08-27 00:22:07 -07:00
parent fc8f286c34
commit e918e5bd1d
5 changed files with 215 additions and 55 deletions
+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,