Author SHA1 Message Date
codegirl007 c6b5d6a456 Rename the unified post date field.
CI / test (pull_request) Successful in 6m16s
Use post_date throughout the new schema while retaining hunt_date only as the legacy migration source.
2026-08-27 00:03:10 -07:00
codegirl007 007fcd0991 Add unified posts database groundwork.
CI / test (pull_request) Successful in 6m18s
Introduce additive post and post-vote tables with an idempotent legacy snapshot migration so the existing application remains compatible during the staged cutover.
2026-08-27 00:00:06 -07:00
4 changed files with 335 additions and 0 deletions
+70
View File
@@ -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] {
+213
View File
@@ -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")
}
+20
View File
@@ -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
View File
@@ -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,