Replace hidden flag with post state.
CI / test (pull_request) Successful in 6m16s

Store post state as text so Go owns the allowed values and future states such as locked remain representable without a database enum migration.
This commit is contained in:
2026-08-27 00:37:17 -07:00
parent e918e5bd1d
commit b481dd2925
9 changed files with 372 additions and 104 deletions
+10 -8
View File
@@ -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;
+2 -2
View File
@@ -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 {
+123 -19
View File
@@ -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
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] {
+93 -7
View File
@@ -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 {
@@ -251,6 +257,7 @@ INSERT INTO posts (
UserID: "plumber",
PostID: "question-1",
Value: 1,
HiddenState: string(PostStateHidden),
}); err != nil || n != 1 {
t.Fatalf("vote rows=%d error=%v", n, err)
}
@@ -258,6 +265,7 @@ INSERT INTO posts (
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) {
+30 -16
View File
@@ -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,
))
@@ -295,6 +304,7 @@ func ListRootPosts(ctx context.Context, db *sql.DB, postDate, viewerID string) (
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
}
@@ -347,6 +360,7 @@ func SetPostVote(ctx context.Context, db *sql.DB, userID, postID string, value i
UserID: userID,
PostID: postID,
Value: int32(value),
HiddenState: string(PostStateHidden),
})
if err != nil {
return err
+44 -1
View File
@@ -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) {
@@ -190,7 +211,7 @@ func TestMemoryPostValidation(t *testing.T) {
Title: "Hidden",
Body: "Body",
PostDate: "2026-08-26",
Hidden: true,
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)
}
+1 -1
View File
@@ -25,7 +25,7 @@ type Post struct {
Body string
City string
PostDate string
Hidden int32
PostState string
CreatedAt string
UpdatedAt string
}
+41 -22
View File
@@ -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
@@ -244,6 +244,7 @@ type ListRootPostsParams struct {
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,7 +359,7 @@ 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
`
@@ -355,10 +368,16 @@ type UpsertPostVoteOnVisibleRootParams struct {
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
View File
@@ -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 (