Add unified post storage #3
+10
-8
@@ -1,6 +1,6 @@
|
||||
-- name: CreatePost :exec
|
||||
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, post_state, created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
sqlc.arg(id),
|
||||
@@ -10,7 +10,7 @@ VALUES (
|
||||
sqlc.arg(body),
|
||||
sqlc.arg(city),
|
||||
sqlc.arg(post_date),
|
||||
sqlc.arg(hidden),
|
||||
sqlc.arg(post_state),
|
||||
sqlc.arg(created_at),
|
||||
sqlc.arg(updated_at)
|
||||
);
|
||||
@@ -18,7 +18,7 @@ VALUES (
|
||||
-- name: GetPost :one
|
||||
SELECT
|
||||
p.id, p.parent_id, p.author_id, u.name AS author_name, u.role AS author_role,
|
||||
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.post_state, p.created_at, p.updated_at
|
||||
FROM posts p
|
||||
JOIN users u ON u.id = p.author_id
|
||||
WHERE p.id = sqlc.arg(id);
|
||||
@@ -39,7 +39,7 @@ SELECT
|
||||
thread.id, thread.parent_id, thread.author_id,
|
||||
u.name AS author_name, u.role AS author_role,
|
||||
thread.title, thread.body, thread.city, thread.post_date,
|
||||
thread.hidden, thread.created_at, thread.updated_at
|
||||
thread.post_state, thread.created_at, thread.updated_at
|
||||
FROM thread
|
||||
JOIN users u ON u.id = thread.author_id
|
||||
ORDER BY thread.created_at, thread.id;
|
||||
@@ -57,7 +57,7 @@ WITH RECURSIVE roots AS (
|
||||
FROM posts p
|
||||
WHERE p.parent_id IS NULL
|
||||
AND p.post_date = sqlc.arg(post_date)
|
||||
AND p.hidden = 0
|
||||
AND p.post_state <> sqlc.arg(hidden_state)
|
||||
),
|
||||
thread AS (
|
||||
SELECT roots.id AS root_id, child.id AS post_id, child.author_id
|
||||
@@ -86,7 +86,7 @@ SELECT
|
||||
roots.id, roots.parent_id, roots.author_id,
|
||||
u.name AS author_name, u.role AS author_role,
|
||||
roots.title, roots.body, roots.city, roots.post_date,
|
||||
roots.hidden, roots.created_at, roots.updated_at,
|
||||
roots.post_state, roots.created_at, roots.updated_at,
|
||||
COALESCE(scores.score, 0)::bigint AS score,
|
||||
(answered.root_id IS NOT NULL)::bool AS answered,
|
||||
COALESCE(viewer_vote.value, 0)::bigint AS user_vote
|
||||
@@ -104,7 +104,9 @@ LIMIT sqlc.arg(row_limit);
|
||||
SELECT EXISTS(
|
||||
SELECT 1
|
||||
FROM posts
|
||||
WHERE id = sqlc.arg(id) AND parent_id IS NULL AND hidden = 0
|
||||
WHERE id = sqlc.arg(id)
|
||||
AND parent_id IS NULL
|
||||
AND post_state <> sqlc.arg(hidden_state)
|
||||
)::bool;
|
||||
|
||||
-- name: DeletePostVote :exec
|
||||
@@ -118,6 +120,6 @@ SELECT sqlc.arg(user_id), sqlc.arg(post_id), sqlc.arg(value)
|
||||
FROM posts p
|
||||
WHERE p.id = sqlc.arg(post_id)
|
||||
AND p.parent_id IS NULL
|
||||
AND p.hidden = 0
|
||||
AND p.post_state <> sqlc.arg(hidden_state)
|
||||
ON CONFLICT (user_id, post_id) DO UPDATE
|
||||
SET value = excluded.value;
|
||||
|
||||
@@ -463,7 +463,7 @@ func (m *Memory) ListRootPosts(_ context.Context, postDate, viewerID string) ([]
|
||||
defer m.mu.Unlock()
|
||||
posts := make([]Post, 0)
|
||||
for _, post := range m.posts {
|
||||
if post.ParentID != nil || post.PostDate != postDate || post.Hidden {
|
||||
if post.ParentID != nil || post.PostDate != postDate || post.PostState == PostStateHidden {
|
||||
continue
|
||||
}
|
||||
cp := clonePostWithAuthor(post, m.users)
|
||||
@@ -496,7 +496,7 @@ func (m *Memory) VotePost(_ context.Context, userID, postID string, value int) e
|
||||
return fmt.Errorf("invalid vote")
|
||||
}
|
||||
post, ok := m.posts[postID]
|
||||
if !ok || post.ParentID != nil || post.Hidden {
|
||||
if !ok || post.ParentID != nil || post.PostState == PostStateHidden {
|
||||
return ErrPostNotVotable
|
||||
}
|
||||
if m.postVotes[postID] == nil {
|
||||
|
||||
+124
-20
@@ -41,8 +41,9 @@ func migratePosts(ctx context.Context, exec execContext) error {
|
||||
steps := []struct {
|
||||
name string
|
||||
sql string
|
||||
args []any
|
||||
}{
|
||||
{"create posts", `
|
||||
{name: "create posts", sql: `
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id TEXT PRIMARY KEY,
|
||||
parent_id TEXT REFERENCES posts(id) ON DELETE CASCADE,
|
||||
@@ -51,53 +52,56 @@ CREATE TABLE IF NOT EXISTS posts (
|
||||
body TEXT NOT NULL,
|
||||
city TEXT NOT NULL DEFAULT '',
|
||||
post_date TEXT NOT NULL DEFAULT '',
|
||||
hidden INTEGER NOT NULL DEFAULT 0,
|
||||
post_state TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK (
|
||||
CONSTRAINT posts_shape_check 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)
|
||||
(parent_id IS NOT NULL AND title = '' AND city = '' AND post_date = '')
|
||||
)
|
||||
)`},
|
||||
{"index post replies", `
|
||||
{name: "index post replies", sql: `
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_parent_created
|
||||
ON posts(parent_id, created_at, id)`},
|
||||
{"index root posts", `
|
||||
{name: "index root posts", sql: `
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_root_date
|
||||
ON posts(post_date, hidden)
|
||||
ON posts(post_date, post_state)
|
||||
WHERE parent_id IS NULL`},
|
||||
{"create post votes", `
|
||||
{name: "create post votes", sql: `
|
||||
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", `
|
||||
{name: "copy questions", sql: `
|
||||
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, post_state, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, NULL, author_id, title, body, city, hunt_date, hidden, created_at, created_at
|
||||
id, NULL, author_id, title, body, city, hunt_date,
|
||||
CASE WHEN hidden = 0 THEN $1 ELSE $2 END,
|
||||
created_at, created_at
|
||||
FROM questions
|
||||
ON CONFLICT (id) DO NOTHING`},
|
||||
{"copy answers", `
|
||||
ON CONFLICT (id) DO NOTHING`, args: []any{string(PostStateVisible), string(PostStateHidden)}},
|
||||
{name: "copy answers", sql: `
|
||||
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, post_state, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
'answer:' || question_id, question_id, author_id, '', body, '', '', 0, created_at, updated_at
|
||||
'answer:' || question_id, question_id, author_id, '', body, '', '',
|
||||
$1, created_at, updated_at
|
||||
FROM answers
|
||||
ON CONFLICT (id) DO NOTHING`},
|
||||
{"copy votes", `
|
||||
ON CONFLICT (id) DO NOTHING`, args: []any{string(PostStateVisible)}},
|
||||
{name: "copy votes", sql: `
|
||||
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 {
|
||||
if _, err := exec.ExecContext(ctx, step.sql, step.args...); err != nil {
|
||||
return fmt.Errorf("%s: %w", step.name, err)
|
||||
}
|
||||
}
|
||||
@@ -141,8 +145,79 @@ $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)
|
||||
DO $migration$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'posts'
|
||||
AND column_name = 'post_state'
|
||||
) THEN
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_root_date
|
||||
ON posts(post_date, post_state)
|
||||
WHERE parent_id IS NULL;
|
||||
ELSE
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_root_date
|
||||
ON posts(post_date, hidden)
|
||||
WHERE parent_id IS NULL;
|
||||
END IF;
|
||||
END
|
||||
$migration$`},
|
||||
}
|
||||
for _, step := range steps {
|
||||
if _, err := exec.ExecContext(ctx, step.sql); err != nil {
|
||||
return fmt.Errorf("%s: %w", step.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migratePostState(ctx context.Context, exec execContext) error {
|
||||
if _, err := exec.ExecContext(ctx, `
|
||||
ALTER TABLE posts
|
||||
ADD COLUMN IF NOT EXISTS post_state TEXT`); err != nil {
|
||||
return fmt.Errorf("add post state: %w", err)
|
||||
}
|
||||
hasHidden, err := migrationColumnExists(ctx, exec, "posts", "hidden")
|
||||
if err != nil {
|
||||
return fmt.Errorf("check hidden column: %w", err)
|
||||
}
|
||||
if hasHidden {
|
||||
if _, err := exec.ExecContext(ctx, `
|
||||
UPDATE posts
|
||||
SET post_state = CASE
|
||||
WHEN hidden = 0 THEN $1
|
||||
ELSE $2
|
||||
END`, string(PostStateVisible), string(PostStateHidden)); err != nil {
|
||||
return fmt.Errorf("copy hidden state: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
steps := []struct {
|
||||
name string
|
||||
sql string
|
||||
}{
|
||||
{"drop root date index", `
|
||||
DROP INDEX IF EXISTS idx_posts_root_date`},
|
||||
{"drop legacy post shape constraint", `
|
||||
ALTER TABLE posts DROP CONSTRAINT IF EXISTS posts_check`},
|
||||
{"drop post shape constraint", `
|
||||
ALTER TABLE posts DROP CONSTRAINT IF EXISTS posts_shape_check`},
|
||||
{"drop hidden", `
|
||||
ALTER TABLE posts DROP COLUMN IF EXISTS hidden`},
|
||||
{"require post state", `
|
||||
ALTER TABLE posts ALTER COLUMN post_state SET NOT NULL`},
|
||||
{"create post shape constraint", `
|
||||
ALTER TABLE posts
|
||||
ADD CONSTRAINT posts_shape_check CHECK (
|
||||
(parent_id IS NULL AND title <> '' AND post_date <> '')
|
||||
OR
|
||||
(parent_id IS NOT NULL AND title = '' AND city = '' AND post_date = '')
|
||||
)`},
|
||||
{"create root date index", `
|
||||
CREATE INDEX idx_posts_root_date
|
||||
ON posts(post_date, post_state)
|
||||
WHERE parent_id IS NULL`},
|
||||
}
|
||||
for _, step := range steps {
|
||||
@@ -153,6 +228,34 @@ CREATE INDEX IF NOT EXISTS idx_posts_root_date
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrationColumnExists(
|
||||
ctx context.Context,
|
||||
exec execContext,
|
||||
tableName string,
|
||||
columnName string,
|
||||
) (bool, error) {
|
||||
rows, err := exec.QueryContext(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = $1
|
||||
AND column_name = $2
|
||||
)`, tableName, columnName)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return false, rows.Err()
|
||||
}
|
||||
var exists bool
|
||||
if err := rows.Scan(&exists); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return exists, rows.Err()
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -202,6 +305,7 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
{"004_posts", migratePosts},
|
||||
{"005_post_vote_post_id_index", migratePostVoteIndex},
|
||||
{"006_post_date", migratePostDate},
|
||||
{"007_post_state", migratePostState},
|
||||
}
|
||||
for _, m := range migrations {
|
||||
if applied[m.version] {
|
||||
|
||||
@@ -145,9 +145,9 @@ VALUES ('homeowner', 'question-1', -1)`); err == nil {
|
||||
}
|
||||
|
||||
var rootParent sql.NullString
|
||||
var rootAuthor, title, rootBody, city, postDate, rootCreated, rootUpdated string
|
||||
var rootAuthor, title, rootBody, city, postDate, rootState, rootCreated, rootUpdated string
|
||||
if err := conn.QueryRowContext(ctx, `
|
||||
SELECT parent_id, author_id, title, body, city, post_date, created_at, updated_at
|
||||
SELECT parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at
|
||||
FROM posts
|
||||
WHERE id = 'question-1'`).Scan(
|
||||
&rootParent,
|
||||
@@ -156,6 +156,7 @@ WHERE id = 'question-1'`).Scan(
|
||||
&rootBody,
|
||||
&city,
|
||||
&postDate,
|
||||
&rootState,
|
||||
&rootCreated,
|
||||
&rootUpdated,
|
||||
); err != nil {
|
||||
@@ -167,19 +168,21 @@ WHERE id = 'question-1'`).Scan(
|
||||
rootBody != "It drips." ||
|
||||
city != "Oakland" ||
|
||||
postDate != "2026-08-26" ||
|
||||
rootState != "visible" ||
|
||||
rootCreated != "2026-08-26T08:00:00Z" ||
|
||||
rootUpdated != rootCreated {
|
||||
t.Fatalf("unexpected root post")
|
||||
}
|
||||
|
||||
var replyParent, replyAuthor, replyBody, replyCreated, replyUpdated string
|
||||
var replyParent, replyAuthor, replyBody, replyState, replyCreated, replyUpdated string
|
||||
if err := conn.QueryRowContext(ctx, `
|
||||
SELECT parent_id, author_id, body, created_at, updated_at
|
||||
SELECT parent_id, author_id, body, post_state, created_at, updated_at
|
||||
FROM posts
|
||||
WHERE id = 'answer:question-1'`).Scan(
|
||||
&replyParent,
|
||||
&replyAuthor,
|
||||
&replyBody,
|
||||
&replyState,
|
||||
&replyCreated,
|
||||
&replyUpdated,
|
||||
); err != nil {
|
||||
@@ -188,6 +191,7 @@ WHERE id = 'answer:question-1'`).Scan(
|
||||
if replyParent != "question-1" ||
|
||||
replyAuthor != "plumber" ||
|
||||
replyBody != "Replace the cartridge." ||
|
||||
replyState != "visible" ||
|
||||
replyCreated != "2026-08-26T09:00:00Z" ||
|
||||
replyUpdated != "2026-08-26T09:05:00Z" {
|
||||
t.Fatalf("unexpected reply post")
|
||||
@@ -205,9 +209,10 @@ WHERE user_id = 'homeowner' AND post_id = 'question-1'`).Scan(&voteValue); err !
|
||||
|
||||
if _, err := conn.ExecContext(ctx, `
|
||||
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, post_state, created_at, updated_at
|
||||
) VALUES (
|
||||
'invalid-reply', 'question-1', 'homeowner', 'Replies cannot have titles', 'Body', '', '', 0, 'now', 'now'
|
||||
'invalid-reply', 'question-1', 'homeowner', 'Replies cannot have titles', 'Body', '', '',
|
||||
'visible', 'now', 'now'
|
||||
)`); err == nil {
|
||||
t.Fatal("reply with root-only title unexpectedly succeeded")
|
||||
}
|
||||
@@ -218,6 +223,7 @@ INSERT INTO posts (
|
||||
ParentID: sql.NullString{String: "answer:question-1", Valid: true},
|
||||
AuthorID: "homeowner",
|
||||
Body: "It is still dripping.",
|
||||
PostState: string(PostStateVisible),
|
||||
CreatedAt: "2026-08-26T10:00:00Z",
|
||||
UpdatedAt: "2026-08-26T10:00:00Z",
|
||||
}); err != nil {
|
||||
@@ -248,16 +254,18 @@ INSERT INTO posts (
|
||||
t.Fatalf("update rows=%d error=%v", n, err)
|
||||
}
|
||||
if n, err := queries.UpsertPostVoteOnVisibleRoot(ctx, sqlc.UpsertPostVoteOnVisibleRootParams{
|
||||
UserID: "plumber",
|
||||
PostID: "question-1",
|
||||
Value: 1,
|
||||
UserID: "plumber",
|
||||
PostID: "question-1",
|
||||
Value: 1,
|
||||
HiddenState: string(PostStateHidden),
|
||||
}); err != nil || n != 1 {
|
||||
t.Fatalf("vote rows=%d error=%v", n, err)
|
||||
}
|
||||
roots, err := queries.ListRootPosts(ctx, sqlc.ListRootPostsParams{
|
||||
ViewerID: "plumber",
|
||||
RowLimit: 100,
|
||||
PostDate: "2026-08-26",
|
||||
ViewerID: "plumber",
|
||||
RowLimit: 100,
|
||||
PostDate: "2026-08-26",
|
||||
HiddenState: string(PostStateHidden),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -273,7 +281,7 @@ INSERT INTO posts (
|
||||
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)
|
||||
ON posts(hunt_date, post_state)
|
||||
WHERE parent_id IS NULL;`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -322,6 +330,84 @@ SELECT post_date FROM posts WHERE id = 'question-1'`).Scan(&migratedPostDate); e
|
||||
migratedPostDate,
|
||||
)
|
||||
}
|
||||
|
||||
if _, err := conn.ExecContext(ctx, `
|
||||
DROP INDEX idx_posts_root_date;
|
||||
ALTER TABLE posts DROP CONSTRAINT posts_shape_check;
|
||||
ALTER TABLE posts ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0;
|
||||
UPDATE posts SET hidden = CASE WHEN id = 'question-1' THEN 1 ELSE 0 END;
|
||||
ALTER TABLE posts DROP COLUMN post_state;
|
||||
ALTER TABLE posts ADD CONSTRAINT posts_check 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 idx_posts_root_date
|
||||
ON posts(post_date, hidden)
|
||||
WHERE parent_id IS NULL;`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migratePostState(ctx, conn); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migratePostState(ctx, conn); err != nil {
|
||||
t.Fatalf("post state migration is not idempotent: %v", err)
|
||||
}
|
||||
|
||||
var postStateColumnCount, hiddenColumnCount int
|
||||
if err := conn.QueryRowContext(ctx, `
|
||||
SELECT
|
||||
count(*) FILTER (WHERE column_name = 'post_state'),
|
||||
count(*) FILTER (WHERE column_name = 'hidden')
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'posts'`).Scan(&postStateColumnCount, &hiddenColumnCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var hiddenState, replyStateAfterMigration string
|
||||
if err := conn.QueryRowContext(ctx, `
|
||||
SELECT post_state FROM posts WHERE id = 'question-1'`).Scan(&hiddenState); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := conn.QueryRowContext(ctx, `
|
||||
SELECT post_state FROM posts WHERE id = 'answer:question-1'`).Scan(&replyStateAfterMigration); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var postStateDataType string
|
||||
if err := conn.QueryRowContext(ctx, `
|
||||
SELECT data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'posts'
|
||||
AND column_name = 'post_state'`).Scan(&postStateDataType); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stateIndexCount int
|
||||
if err := conn.QueryRowContext(ctx, `
|
||||
SELECT count(*)
|
||||
FROM pg_indexes
|
||||
WHERE schemaname = current_schema()
|
||||
AND tablename = 'posts'
|
||||
AND indexname = 'idx_posts_root_date'
|
||||
AND indexdef LIKE '%(post_date, post_state)%'`).Scan(&stateIndexCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if postStateColumnCount != 1 ||
|
||||
hiddenColumnCount != 0 ||
|
||||
hiddenState != "hidden" ||
|
||||
replyStateAfterMigration != "visible" ||
|
||||
postStateDataType != "text" ||
|
||||
stateIndexCount != 1 {
|
||||
t.Fatalf(
|
||||
"post state migration columns=%d hidden_columns=%d root=%q reply=%q type=%q indexes=%d",
|
||||
postStateColumnCount,
|
||||
hiddenColumnCount,
|
||||
hiddenState,
|
||||
replyStateAfterMigration,
|
||||
postStateDataType,
|
||||
stateIndexCount,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigratePostsReportsStep(t *testing.T) {
|
||||
|
||||
+36
-22
@@ -21,6 +21,14 @@ var (
|
||||
ErrPostNotVotable = errors.New("post not votable")
|
||||
)
|
||||
|
||||
type PostState string
|
||||
|
||||
const (
|
||||
PostStateVisible PostState = "visible"
|
||||
PostStateHidden PostState = "hidden"
|
||||
PostStateLocked PostState = "locked"
|
||||
)
|
||||
|
||||
// Post is either a root question (ParentID nil) or a reply to another post.
|
||||
type Post struct {
|
||||
ID string
|
||||
@@ -32,7 +40,7 @@ type Post struct {
|
||||
Body string
|
||||
City string
|
||||
PostDate string
|
||||
Hidden bool
|
||||
PostState PostState
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
Score int
|
||||
@@ -63,7 +71,7 @@ func (p *Post) Create(ctx context.Context) error {
|
||||
Body: p.Body,
|
||||
City: p.City,
|
||||
PostDate: p.PostDate,
|
||||
Hidden: boolInt32(p.Hidden),
|
||||
PostState: string(p.PostState),
|
||||
CreatedAt: p.CreatedAt,
|
||||
UpdatedAt: p.UpdatedAt,
|
||||
})
|
||||
@@ -101,6 +109,14 @@ func preparePost(p *Post) error {
|
||||
p.Body = strings.TrimSpace(p.Body)
|
||||
p.City = strings.TrimSpace(p.City)
|
||||
p.PostDate = strings.TrimSpace(p.PostDate)
|
||||
if p.PostState == "" {
|
||||
p.PostState = PostStateVisible
|
||||
}
|
||||
switch p.PostState {
|
||||
case PostStateVisible, PostStateHidden, PostStateLocked:
|
||||
default:
|
||||
return fmt.Errorf("%w: invalid post state", ErrInvalidPost)
|
||||
}
|
||||
if p.AuthorID == "" {
|
||||
return fmt.Errorf("%w: author is required", ErrInvalidPost)
|
||||
}
|
||||
@@ -120,7 +136,7 @@ func preparePost(p *Post) error {
|
||||
return fmt.Errorf("%w: parent is required", ErrInvalidPost)
|
||||
}
|
||||
p.ParentID = &parentID
|
||||
if p.Title != "" || p.City != "" || p.PostDate != "" || p.Hidden {
|
||||
if p.Title != "" || p.City != "" || p.PostDate != "" || p.PostState != PostStateVisible {
|
||||
return fmt.Errorf("%w: reply contains root-only fields", ErrInvalidPost)
|
||||
}
|
||||
}
|
||||
@@ -152,13 +168,6 @@ func parentIDFromNull(parentID sql.NullString) *string {
|
||||
return &id
|
||||
}
|
||||
|
||||
func boolInt32(value bool) int32 {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func mapPostCreateError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
@@ -178,7 +187,7 @@ func postFromValues(
|
||||
id string,
|
||||
parentID sql.NullString,
|
||||
authorID, authorName, authorRole, title, body, city, postDate string,
|
||||
hidden int32,
|
||||
postState string,
|
||||
createdAt, updatedAt string,
|
||||
) Post {
|
||||
return Post{
|
||||
@@ -191,7 +200,7 @@ func postFromValues(
|
||||
Body: body,
|
||||
City: city,
|
||||
PostDate: postDate,
|
||||
Hidden: hidden != 0,
|
||||
PostState: PostState(postState),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
db: db,
|
||||
@@ -215,7 +224,7 @@ func GetPost(ctx context.Context, db *sql.DB, id string) (*Post, error) {
|
||||
r.Body,
|
||||
r.City,
|
||||
r.PostDate,
|
||||
r.Hidden,
|
||||
r.PostState,
|
||||
r.CreatedAt,
|
||||
r.UpdatedAt,
|
||||
)
|
||||
@@ -241,7 +250,7 @@ func GetPostThread(ctx context.Context, db *sql.DB, rootID string) (*Post, error
|
||||
r.Body,
|
||||
r.City,
|
||||
r.PostDate,
|
||||
r.Hidden,
|
||||
r.PostState,
|
||||
r.CreatedAt,
|
||||
r.UpdatedAt,
|
||||
))
|
||||
@@ -292,9 +301,10 @@ func buildPostTree(posts []Post, rootID string) (*Post, error) {
|
||||
// ListRootPosts returns visible root posts for a post date.
|
||||
func ListRootPosts(ctx context.Context, db *sql.DB, postDate, viewerID string) ([]Post, error) {
|
||||
rows, err := sqlc.New(db).ListRootPosts(ctx, sqlc.ListRootPostsParams{
|
||||
ViewerID: viewerID,
|
||||
RowLimit: HuntListLimit,
|
||||
PostDate: postDate,
|
||||
ViewerID: viewerID,
|
||||
RowLimit: HuntListLimit,
|
||||
PostDate: postDate,
|
||||
HiddenState: string(PostStateHidden),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -312,7 +322,7 @@ func ListRootPosts(ctx context.Context, db *sql.DB, postDate, viewerID string) (
|
||||
r.Body,
|
||||
r.City,
|
||||
r.PostDate,
|
||||
r.Hidden,
|
||||
r.PostState,
|
||||
r.CreatedAt,
|
||||
r.UpdatedAt,
|
||||
)
|
||||
@@ -331,7 +341,10 @@ func SetPostVote(ctx context.Context, db *sql.DB, userID, postID string, value i
|
||||
}
|
||||
q := sqlc.New(db)
|
||||
if value == 0 {
|
||||
visible, err := q.PostIsVisibleRoot(ctx, postID)
|
||||
visible, err := q.PostIsVisibleRoot(ctx, sqlc.PostIsVisibleRootParams{
|
||||
ID: postID,
|
||||
HiddenState: string(PostStateHidden),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -344,9 +357,10 @@ func SetPostVote(ctx context.Context, db *sql.DB, userID, postID string, value i
|
||||
})
|
||||
}
|
||||
n, err := q.UpsertPostVoteOnVisibleRoot(ctx, sqlc.UpsertPostVoteOnVisibleRootParams{
|
||||
UserID: userID,
|
||||
PostID: postID,
|
||||
Value: int32(value),
|
||||
UserID: userID,
|
||||
PostID: postID,
|
||||
Value: int32(value),
|
||||
HiddenState: string(PostStateHidden),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -50,6 +50,9 @@ func TestMemoryPostLifecycle(t *testing.T) {
|
||||
if err := mem.CreatePost(ctx, root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if root.PostState != PostStateVisible {
|
||||
t.Fatalf("default post state = %q, want visible", root.PostState)
|
||||
}
|
||||
|
||||
rootID := root.ID
|
||||
later := &Post{
|
||||
@@ -175,6 +178,24 @@ func TestMemoryPostValidation(t *testing.T) {
|
||||
Body: "Body",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid post state",
|
||||
post: &Post{
|
||||
AuthorID: homeowner.ID,
|
||||
Title: "Invalid state",
|
||||
Body: "Body",
|
||||
PostState: PostState("archived"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reply with non-visible state",
|
||||
post: &Post{
|
||||
ParentID: ptr("missing"),
|
||||
AuthorID: homeowner.ID,
|
||||
Body: "Body",
|
||||
PostState: PostStateLocked,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
@@ -185,12 +206,12 @@ func TestMemoryPostValidation(t *testing.T) {
|
||||
}
|
||||
|
||||
hidden := &Post{
|
||||
ID: "hidden",
|
||||
AuthorID: homeowner.ID,
|
||||
Title: "Hidden",
|
||||
Body: "Body",
|
||||
PostDate: "2026-08-26",
|
||||
Hidden: true,
|
||||
ID: "hidden",
|
||||
AuthorID: homeowner.ID,
|
||||
Title: "Hidden",
|
||||
Body: "Body",
|
||||
PostDate: "2026-08-26",
|
||||
PostState: PostStateHidden,
|
||||
}
|
||||
if err := mem.CreatePost(ctx, hidden); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -199,6 +220,28 @@ func TestMemoryPostValidation(t *testing.T) {
|
||||
t.Fatalf("hidden root vote error = %v", err)
|
||||
}
|
||||
|
||||
locked := &Post{
|
||||
ID: "locked",
|
||||
AuthorID: homeowner.ID,
|
||||
Title: "Locked",
|
||||
Body: "Body",
|
||||
PostDate: "2026-08-26",
|
||||
PostState: PostStateLocked,
|
||||
}
|
||||
if err := mem.CreatePost(ctx, locked); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mem.VotePost(ctx, homeowner.ID, locked.ID, 1); err != nil {
|
||||
t.Fatalf("locked root should remain votable: %v", err)
|
||||
}
|
||||
roots, err := mem.ListRootPosts(ctx, locked.PostDate, homeowner.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(roots) != 1 || roots[0].ID != locked.ID || roots[0].PostState != PostStateLocked {
|
||||
t.Fatalf("locked root should remain visible: %+v", roots)
|
||||
}
|
||||
|
||||
if _, err := mem.GetPostThread(ctx, "missing"); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("missing thread error = %v", err)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ type Post struct {
|
||||
Body string
|
||||
City string
|
||||
PostDate string
|
||||
Hidden int32
|
||||
PostState string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
const createPost = `-- name: CreatePost :exec
|
||||
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, post_state, created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
@@ -36,7 +36,7 @@ type CreatePostParams struct {
|
||||
Body string
|
||||
City string
|
||||
PostDate string
|
||||
Hidden int32
|
||||
PostState string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
@@ -50,7 +50,7 @@ func (q *Queries) CreatePost(ctx context.Context, arg CreatePostParams) error {
|
||||
arg.Body,
|
||||
arg.City,
|
||||
arg.PostDate,
|
||||
arg.Hidden,
|
||||
arg.PostState,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
@@ -76,7 +76,7 @@ func (q *Queries) DeletePostVote(ctx context.Context, arg DeletePostVoteParams)
|
||||
const getPost = `-- name: GetPost :one
|
||||
SELECT
|
||||
p.id, p.parent_id, p.author_id, u.name AS author_name, u.role AS author_role,
|
||||
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.post_state, p.created_at, p.updated_at
|
||||
FROM posts p
|
||||
JOIN users u ON u.id = p.author_id
|
||||
WHERE p.id = $1
|
||||
@@ -92,7 +92,7 @@ type GetPostRow struct {
|
||||
Body string
|
||||
City string
|
||||
PostDate string
|
||||
Hidden int32
|
||||
PostState string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
@@ -110,7 +110,7 @@ func (q *Queries) GetPost(ctx context.Context, id string) (GetPostRow, error) {
|
||||
&i.Body,
|
||||
&i.City,
|
||||
&i.PostDate,
|
||||
&i.Hidden,
|
||||
&i.PostState,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
@@ -119,13 +119,13 @@ func (q *Queries) GetPost(ctx context.Context, id string) (GetPostRow, error) {
|
||||
|
||||
const listPostThread = `-- name: ListPostThread :many
|
||||
WITH RECURSIVE thread AS (
|
||||
SELECT p.id, p.parent_id, p.author_id, p.title, p.body, p.city, p.post_date, p.hidden, p.created_at, p.updated_at
|
||||
SELECT p.id, p.parent_id, p.author_id, p.title, p.body, p.city, p.post_date, p.post_state, p.created_at, p.updated_at
|
||||
FROM posts p
|
||||
WHERE p.id = $1 AND p.parent_id IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT child.id, child.parent_id, child.author_id, child.title, child.body, child.city, child.post_date, child.hidden, child.created_at, child.updated_at
|
||||
SELECT child.id, child.parent_id, child.author_id, child.title, child.body, child.city, child.post_date, child.post_state, child.created_at, child.updated_at
|
||||
FROM posts child
|
||||
JOIN thread parent ON child.parent_id = parent.id
|
||||
)
|
||||
@@ -133,7 +133,7 @@ SELECT
|
||||
thread.id, thread.parent_id, thread.author_id,
|
||||
u.name AS author_name, u.role AS author_role,
|
||||
thread.title, thread.body, thread.city, thread.post_date,
|
||||
thread.hidden, thread.created_at, thread.updated_at
|
||||
thread.post_state, thread.created_at, thread.updated_at
|
||||
FROM thread
|
||||
JOIN users u ON u.id = thread.author_id
|
||||
ORDER BY thread.created_at, thread.id
|
||||
@@ -149,7 +149,7 @@ type ListPostThreadRow struct {
|
||||
Body string
|
||||
City string
|
||||
PostDate string
|
||||
Hidden int32
|
||||
PostState string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
@@ -173,7 +173,7 @@ func (q *Queries) ListPostThread(ctx context.Context, rootID string) ([]ListPost
|
||||
&i.Body,
|
||||
&i.City,
|
||||
&i.PostDate,
|
||||
&i.Hidden,
|
||||
&i.PostState,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
@@ -192,11 +192,11 @@ func (q *Queries) ListPostThread(ctx context.Context, rootID string) ([]ListPost
|
||||
|
||||
const listRootPosts = `-- name: ListRootPosts :many
|
||||
WITH RECURSIVE roots AS (
|
||||
SELECT p.id, p.parent_id, p.author_id, p.title, p.body, p.city, p.post_date, p.hidden, p.created_at, p.updated_at
|
||||
SELECT p.id, p.parent_id, p.author_id, p.title, p.body, p.city, p.post_date, p.post_state, p.created_at, p.updated_at
|
||||
FROM posts p
|
||||
WHERE p.parent_id IS NULL
|
||||
AND p.post_date = $3
|
||||
AND p.hidden = 0
|
||||
AND p.post_state <> $4
|
||||
),
|
||||
thread AS (
|
||||
SELECT roots.id AS root_id, child.id AS post_id, child.author_id
|
||||
@@ -225,7 +225,7 @@ SELECT
|
||||
roots.id, roots.parent_id, roots.author_id,
|
||||
u.name AS author_name, u.role AS author_role,
|
||||
roots.title, roots.body, roots.city, roots.post_date,
|
||||
roots.hidden, roots.created_at, roots.updated_at,
|
||||
roots.post_state, roots.created_at, roots.updated_at,
|
||||
COALESCE(scores.score, 0)::bigint AS score,
|
||||
(answered.root_id IS NOT NULL)::bool AS answered,
|
||||
COALESCE(viewer_vote.value, 0)::bigint AS user_vote
|
||||
@@ -241,9 +241,10 @@ LIMIT $2
|
||||
`
|
||||
|
||||
type ListRootPostsParams struct {
|
||||
ViewerID string
|
||||
RowLimit int32
|
||||
PostDate string
|
||||
ViewerID string
|
||||
RowLimit int32
|
||||
PostDate string
|
||||
HiddenState string
|
||||
}
|
||||
|
||||
type ListRootPostsRow struct {
|
||||
@@ -256,7 +257,7 @@ type ListRootPostsRow struct {
|
||||
Body string
|
||||
City string
|
||||
PostDate string
|
||||
Hidden int32
|
||||
PostState string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
Score int64
|
||||
@@ -265,7 +266,12 @@ type ListRootPostsRow struct {
|
||||
}
|
||||
|
||||
func (q *Queries) ListRootPosts(ctx context.Context, arg ListRootPostsParams) ([]ListRootPostsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listRootPosts, arg.ViewerID, arg.RowLimit, arg.PostDate)
|
||||
rows, err := q.db.QueryContext(ctx, listRootPosts,
|
||||
arg.ViewerID,
|
||||
arg.RowLimit,
|
||||
arg.PostDate,
|
||||
arg.HiddenState,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -283,7 +289,7 @@ func (q *Queries) ListRootPosts(ctx context.Context, arg ListRootPostsParams) ([
|
||||
&i.Body,
|
||||
&i.City,
|
||||
&i.PostDate,
|
||||
&i.Hidden,
|
||||
&i.PostState,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Score,
|
||||
@@ -307,12 +313,19 @@ const postIsVisibleRoot = `-- name: PostIsVisibleRoot :one
|
||||
SELECT EXISTS(
|
||||
SELECT 1
|
||||
FROM posts
|
||||
WHERE id = $1 AND parent_id IS NULL AND hidden = 0
|
||||
WHERE id = $1
|
||||
AND parent_id IS NULL
|
||||
AND post_state <> $2
|
||||
)::bool
|
||||
`
|
||||
|
||||
func (q *Queries) PostIsVisibleRoot(ctx context.Context, id string) (bool, error) {
|
||||
row := q.db.QueryRowContext(ctx, postIsVisibleRoot, id)
|
||||
type PostIsVisibleRootParams struct {
|
||||
ID string
|
||||
HiddenState string
|
||||
}
|
||||
|
||||
func (q *Queries) PostIsVisibleRoot(ctx context.Context, arg PostIsVisibleRootParams) (bool, error) {
|
||||
row := q.db.QueryRowContext(ctx, postIsVisibleRoot, arg.ID, arg.HiddenState)
|
||||
var column_1 bool
|
||||
err := row.Scan(&column_1)
|
||||
return column_1, err
|
||||
@@ -346,19 +359,25 @@ SELECT $1, $2, $3
|
||||
FROM posts p
|
||||
WHERE p.id = $2
|
||||
AND p.parent_id IS NULL
|
||||
AND p.hidden = 0
|
||||
AND p.post_state <> $4
|
||||
ON CONFLICT (user_id, post_id) DO UPDATE
|
||||
SET value = excluded.value
|
||||
`
|
||||
|
||||
type UpsertPostVoteOnVisibleRootParams struct {
|
||||
UserID string
|
||||
PostID string
|
||||
Value int32
|
||||
UserID string
|
||||
PostID string
|
||||
Value int32
|
||||
HiddenState string
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertPostVoteOnVisibleRoot(ctx context.Context, arg UpsertPostVoteOnVisibleRootParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, upsertPostVoteOnVisibleRoot, arg.UserID, arg.PostID, arg.Value)
|
||||
result, err := q.db.ExecContext(ctx, upsertPostVoteOnVisibleRoot,
|
||||
arg.UserID,
|
||||
arg.PostID,
|
||||
arg.Value,
|
||||
arg.HiddenState,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
+4
-4
@@ -50,13 +50,13 @@ CREATE TABLE IF NOT EXISTS posts (
|
||||
body TEXT NOT NULL,
|
||||
city TEXT NOT NULL DEFAULT '',
|
||||
post_date TEXT NOT NULL DEFAULT '',
|
||||
hidden INTEGER NOT NULL DEFAULT 0,
|
||||
post_state TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK (
|
||||
CONSTRAINT posts_shape_check 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)
|
||||
(parent_id IS NOT NULL AND title = '' AND city = '' AND post_date = '')
|
||||
)
|
||||
);
|
||||
|
||||
@@ -64,7 +64,7 @@ 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)
|
||||
ON posts(post_date, post_state)
|
||||
WHERE parent_id IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS post_votes (
|
||||
|
||||
Reference in New Issue
Block a user