Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6b5d6a456 | ||
|
|
007fcd0991 |
@@ -35,6 +35,75 @@ CREATE UNIQUE INDEX IF NOT EXISTS users_email_lower_uidx
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// migratePosts creates the unified post model and snapshots legacy content.
|
||||||
|
// Legacy tables remain in place until the application cutover is complete.
|
||||||
|
func migratePosts(ctx context.Context, exec execContext) error {
|
||||||
|
steps := []struct {
|
||||||
|
name string
|
||||||
|
sql string
|
||||||
|
}{
|
||||||
|
{"create posts", `
|
||||||
|
CREATE TABLE IF NOT EXISTS posts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
parent_id TEXT REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
author_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
city TEXT NOT NULL DEFAULT '',
|
||||||
|
post_date TEXT NOT NULL DEFAULT '',
|
||||||
|
hidden INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
CHECK (
|
||||||
|
(parent_id IS NULL AND title <> '' AND post_date <> '')
|
||||||
|
OR
|
||||||
|
(parent_id IS NOT NULL AND title = '' AND city = '' AND post_date = '' AND hidden = 0)
|
||||||
|
)
|
||||||
|
)`},
|
||||||
|
{"index post replies", `
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_parent_created
|
||||||
|
ON posts(parent_id, created_at, id)`},
|
||||||
|
{"index root posts", `
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_root_date
|
||||||
|
ON posts(post_date, hidden)
|
||||||
|
WHERE parent_id IS NULL`},
|
||||||
|
{"create post votes", `
|
||||||
|
CREATE TABLE IF NOT EXISTS post_votes (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
value INTEGER NOT NULL CHECK (value IN (-1, 1)),
|
||||||
|
PRIMARY KEY (user_id, post_id)
|
||||||
|
)`},
|
||||||
|
{"copy questions", `
|
||||||
|
INSERT INTO posts (
|
||||||
|
id, parent_id, author_id, title, body, city, post_date, hidden, created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id, NULL, author_id, title, body, city, hunt_date, hidden, created_at, created_at
|
||||||
|
FROM questions
|
||||||
|
ON CONFLICT (id) DO NOTHING`},
|
||||||
|
{"copy answers", `
|
||||||
|
INSERT INTO posts (
|
||||||
|
id, parent_id, author_id, title, body, city, post_date, hidden, created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'answer:' || question_id, question_id, author_id, '', body, '', '', 0, created_at, updated_at
|
||||||
|
FROM answers
|
||||||
|
ON CONFLICT (id) DO NOTHING`},
|
||||||
|
{"copy votes", `
|
||||||
|
INSERT INTO post_votes (user_id, post_id, value)
|
||||||
|
SELECT user_id, question_id, value
|
||||||
|
FROM votes
|
||||||
|
ON CONFLICT (user_id, post_id) DO NOTHING`},
|
||||||
|
}
|
||||||
|
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)
|
||||||
@@ -81,6 +150,7 @@ 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},
|
||||||
}
|
}
|
||||||
for _, m := range migrations {
|
for _, m := range migrations {
|
||||||
if applied[m.version] {
|
if applied[m.version] {
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMigratePostsCopiesLegacyData(t *testing.T) {
|
||||||
|
rawURL := strings.TrimSpace(os.Getenv("TEST_DATABASE_URL"))
|
||||||
|
if rawURL == "" {
|
||||||
|
t.Skip("TEST_DATABASE_URL is not set")
|
||||||
|
}
|
||||||
|
dsn, err := postgresDSN(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
db, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
conn, err := db.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
schemaName := "test_posts_" + strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||||
|
if _, err := conn.ExecContext(ctx, "CREATE SCHEMA "+schemaName); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_, _ = conn.ExecContext(context.Background(), "SET search_path TO public")
|
||||||
|
_, _ = conn.ExecContext(context.Background(), "DROP SCHEMA "+schemaName+" CASCADE")
|
||||||
|
}()
|
||||||
|
if _, err := conn.ExecContext(ctx, "SET search_path TO "+schemaName); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
legacySchema := `
|
||||||
|
CREATE TABLE users (
|
||||||
|
id TEXT PRIMARY KEY
|
||||||
|
);
|
||||||
|
CREATE TABLE questions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
author_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
city TEXT NOT NULL DEFAULT '',
|
||||||
|
hunt_date TEXT NOT NULL,
|
||||||
|
hidden INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE answers (
|
||||||
|
question_id TEXT PRIMARY KEY REFERENCES questions(id) ON DELETE CASCADE,
|
||||||
|
author_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE votes (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
question_id TEXT NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
|
||||||
|
value INTEGER NOT NULL CHECK (value IN (-1, 1)),
|
||||||
|
PRIMARY KEY (user_id, question_id)
|
||||||
|
);`
|
||||||
|
if err := applySchema(ctx, conn, legacySchema); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := conn.ExecContext(ctx, `
|
||||||
|
INSERT INTO users (id) VALUES ('homeowner'), ('plumber');
|
||||||
|
INSERT INTO questions (id, author_id, title, body, city, hunt_date, hidden, created_at)
|
||||||
|
VALUES ('question-1', 'homeowner', 'Leaky sink', 'It drips.', 'Oakland', '2026-08-26', 0, '2026-08-26T08:00:00Z');
|
||||||
|
INSERT INTO answers (question_id, author_id, body, created_at, updated_at)
|
||||||
|
VALUES ('question-1', 'plumber', 'Replace the cartridge.', '2026-08-26T09:00:00Z', '2026-08-26T09:05:00Z');
|
||||||
|
INSERT INTO votes (user_id, question_id, value)
|
||||||
|
VALUES ('homeowner', 'question-1', 1);`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := migratePosts(ctx, conn); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := migratePosts(ctx, conn); err != nil {
|
||||||
|
t.Fatalf("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 {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM post_votes").Scan(&voteCount); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM questions").Scan(&legacyQuestionCount); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM answers").Scan(&legacyAnswerCount); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if postCount != 2 || voteCount != 1 || legacyQuestionCount != 1 || legacyAnswerCount != 1 {
|
||||||
|
t.Fatalf(
|
||||||
|
"counts posts=%d votes=%d legacy questions=%d answers=%d",
|
||||||
|
postCount,
|
||||||
|
voteCount,
|
||||||
|
legacyQuestionCount,
|
||||||
|
legacyAnswerCount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rootParent sql.NullString
|
||||||
|
var rootAuthor, title, rootBody, city, postDate, rootCreated, rootUpdated string
|
||||||
|
if err := conn.QueryRowContext(ctx, `
|
||||||
|
SELECT parent_id, author_id, title, body, city, post_date, created_at, updated_at
|
||||||
|
FROM posts
|
||||||
|
WHERE id = 'question-1'`).Scan(
|
||||||
|
&rootParent,
|
||||||
|
&rootAuthor,
|
||||||
|
&title,
|
||||||
|
&rootBody,
|
||||||
|
&city,
|
||||||
|
&postDate,
|
||||||
|
&rootCreated,
|
||||||
|
&rootUpdated,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rootParent.Valid ||
|
||||||
|
rootAuthor != "homeowner" ||
|
||||||
|
title != "Leaky sink" ||
|
||||||
|
rootBody != "It drips." ||
|
||||||
|
city != "Oakland" ||
|
||||||
|
postDate != "2026-08-26" ||
|
||||||
|
rootCreated != "2026-08-26T08:00:00Z" ||
|
||||||
|
rootUpdated != rootCreated {
|
||||||
|
t.Fatalf("unexpected root post")
|
||||||
|
}
|
||||||
|
|
||||||
|
var replyParent, replyAuthor, replyBody, replyCreated, replyUpdated string
|
||||||
|
if err := conn.QueryRowContext(ctx, `
|
||||||
|
SELECT parent_id, author_id, body, created_at, updated_at
|
||||||
|
FROM posts
|
||||||
|
WHERE id = 'answer:question-1'`).Scan(
|
||||||
|
&replyParent,
|
||||||
|
&replyAuthor,
|
||||||
|
&replyBody,
|
||||||
|
&replyCreated,
|
||||||
|
&replyUpdated,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if replyParent != "question-1" ||
|
||||||
|
replyAuthor != "plumber" ||
|
||||||
|
replyBody != "Replace the cartridge." ||
|
||||||
|
replyCreated != "2026-08-26T09:00:00Z" ||
|
||||||
|
replyUpdated != "2026-08-26T09:05:00Z" {
|
||||||
|
t.Fatalf("unexpected reply post")
|
||||||
|
}
|
||||||
|
|
||||||
|
var voteValue int
|
||||||
|
if err := conn.QueryRowContext(ctx, `
|
||||||
|
SELECT value FROM post_votes
|
||||||
|
WHERE user_id = 'homeowner' AND post_id = 'question-1'`).Scan(&voteValue); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if voteValue != 1 {
|
||||||
|
t.Fatalf("vote value = %d, want 1", voteValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := conn.ExecContext(ctx, `
|
||||||
|
INSERT INTO posts (
|
||||||
|
id, parent_id, author_id, title, body, city, post_date, hidden, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
'invalid-reply', 'question-1', 'homeowner', 'Replies cannot have titles', 'Body', '', '', 0, 'now', 'now'
|
||||||
|
)`); err == nil {
|
||||||
|
t.Fatal("reply with root-only title unexpectedly succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigratePostsReportsStep(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
exec := &failingMigrationExec{failAt: 5}
|
||||||
|
err := migratePosts(context.Background(), exec)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "copy questions") {
|
||||||
|
t.Fatalf("error = %v, want copy questions context", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type failingMigrationExec struct {
|
||||||
|
calls int
|
||||||
|
failAt int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *failingMigrationExec) ExecContext(context.Context, string, ...any) (sql.Result, error) {
|
||||||
|
f.calls++
|
||||||
|
if f.calls == f.failAt {
|
||||||
|
return nil, fmt.Errorf("boom")
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*failingMigrationExec) QueryContext(context.Context, string, ...any) (*sql.Rows, error) {
|
||||||
|
return nil, fmt.Errorf("not implemented")
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
package sqlc
|
package sqlc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,6 +17,25 @@ type Answer struct {
|
|||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Post struct {
|
||||||
|
ID string
|
||||||
|
ParentID sql.NullString
|
||||||
|
AuthorID string
|
||||||
|
Title string
|
||||||
|
Body string
|
||||||
|
City string
|
||||||
|
PostDate string
|
||||||
|
Hidden int32
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PostVote struct {
|
||||||
|
UserID string
|
||||||
|
PostID string
|
||||||
|
Value int32
|
||||||
|
}
|
||||||
|
|
||||||
type Question struct {
|
type Question struct {
|
||||||
ID string
|
ID string
|
||||||
AuthorID string
|
AuthorID string
|
||||||
|
|||||||
+32
@@ -42,6 +42,38 @@ CREATE TABLE IF NOT EXISTS answers (
|
|||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS posts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
parent_id TEXT REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
author_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
city TEXT NOT NULL DEFAULT '',
|
||||||
|
post_date TEXT NOT NULL DEFAULT '',
|
||||||
|
hidden INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
CHECK (
|
||||||
|
(parent_id IS NULL AND title <> '' AND post_date <> '')
|
||||||
|
OR
|
||||||
|
(parent_id IS NOT NULL AND title = '' AND city = '' AND post_date = '' AND hidden = 0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_parent_created
|
||||||
|
ON posts(parent_id, created_at, id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_root_date
|
||||||
|
ON posts(post_date, hidden)
|
||||||
|
WHERE parent_id IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS post_votes (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
|
post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
value INTEGER NOT NULL CHECK (value IN (-1, 1)),
|
||||||
|
PRIMARY KEY (user_id, 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user