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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user