diff --git a/internal/store/migrate.go b/internal/store/migrate.go index ce91c2f..fd5c10b 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -35,6 +35,75 @@ CREATE UNIQUE INDEX IF NOT EXISTS users_email_lower_uidx 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 { ExecContext(ctx context.Context, query string, args ...any) (sql.Result, 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}, {"003_user_email", migrateUserEmail}, + {"004_posts", migratePosts}, } for _, m := range migrations { if applied[m.version] { diff --git a/internal/store/migrate_posts_test.go b/internal/store/migrate_posts_test.go new file mode 100644 index 0000000..6a81d37 --- /dev/null +++ b/internal/store/migrate_posts_test.go @@ -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") +} diff --git a/internal/store/sqlc/models.go b/internal/store/sqlc/models.go index 5fdb986..f2b2acc 100644 --- a/internal/store/sqlc/models.go +++ b/internal/store/sqlc/models.go @@ -5,6 +5,7 @@ package sqlc import ( + "database/sql" "time" ) @@ -16,6 +17,25 @@ type Answer struct { 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 { ID string AuthorID string diff --git a/schema.sql b/schema.sql index d075baa..5571cad 100644 --- a/schema.sql +++ b/schema.sql @@ -42,6 +42,38 @@ CREATE TABLE IF NOT EXISTS answers ( 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 ( token TEXT PRIMARY KEY, data BYTEA NOT NULL,