3 Commits
Author SHA1 Message Date
codegirl007 b481dd2925 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.
2026-08-27 00:37:17 -07:00
codegirl007 e918e5bd1d Simplify post listing queries.
CI / test (pull_request) Successful in 6m17s
Use named sqlc arguments, scope vote aggregation to selected roots, join viewer votes directly, and add the indexes and drift migration required by the resulting access paths.
2026-08-27 00:22:07 -07:00
codegirl007 fc8f286c34 Add unified post storage.
CI / test (pull_request) Successful in 6m17s
Provide one creation path for roots and replies, recursive thread loading, body-only updates, root listings, and post voting across PostgreSQL and the in-memory test store.
2026-08-27 00:10:47 -07:00
11 changed files with 1764 additions and 30 deletions
+125
View File
@@ -0,0 +1,125 @@
-- name: CreatePost :exec
INSERT INTO posts (
id, parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at
)
VALUES (
sqlc.arg(id),
sqlc.arg(parent_id),
sqlc.arg(author_id),
sqlc.arg(title),
sqlc.arg(body),
sqlc.arg(city),
sqlc.arg(post_date),
sqlc.arg(post_state),
sqlc.arg(created_at),
sqlc.arg(updated_at)
);
-- 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.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);
-- name: ListPostThread :many
WITH RECURSIVE thread AS (
SELECT p.*
FROM posts p
WHERE p.id = sqlc.arg(root_id) AND p.parent_id IS NULL
UNION ALL
SELECT child.*
FROM posts child
JOIN thread parent ON child.parent_id = parent.id
)
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.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;
-- name: UpdatePost :execrows
UPDATE posts
SET
body = sqlc.arg(body),
updated_at = sqlc.arg(updated_at)
WHERE id = sqlc.arg(id);
-- name: ListRootPosts :many
WITH RECURSIVE roots AS (
SELECT p.*
FROM posts p
WHERE p.parent_id IS NULL
AND p.post_date = sqlc.arg(post_date)
AND p.post_state <> sqlc.arg(hidden_state)
),
thread AS (
SELECT roots.id AS root_id, child.id AS post_id, child.author_id
FROM roots
JOIN posts child ON child.parent_id = roots.id
UNION ALL
SELECT thread.root_id, child.id, child.author_id
FROM thread
JOIN posts child ON child.parent_id = thread.post_id
),
answered AS (
SELECT DISTINCT thread.root_id
FROM thread
JOIN users u ON u.id = thread.author_id
WHERE u.role = 'admin'
),
scores AS (
SELECT votes.post_id, SUM(votes.value)::bigint AS score
FROM roots
JOIN post_votes votes ON votes.post_id = roots.id
GROUP BY votes.post_id
)
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.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
FROM roots
JOIN users u ON u.id = roots.author_id
LEFT JOIN scores ON scores.post_id = roots.id
LEFT JOIN answered ON answered.root_id = roots.id
LEFT JOIN post_votes viewer_vote
ON viewer_vote.user_id = sqlc.arg(viewer_id)
AND viewer_vote.post_id = roots.id
ORDER BY score DESC, roots.created_at, roots.id
LIMIT sqlc.arg(row_limit);
-- name: PostIsVisibleRoot :one
SELECT EXISTS(
SELECT 1
FROM posts
WHERE id = sqlc.arg(id)
AND parent_id IS NULL
AND post_state <> sqlc.arg(hidden_state)
)::bool;
-- name: DeletePostVote :exec
DELETE FROM post_votes
WHERE user_id = sqlc.arg(user_id)
AND post_id = sqlc.arg(post_id);
-- name: UpsertPostVoteOnVisibleRoot :execrows
INSERT INTO post_votes (user_id, post_id, value)
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.post_state <> sqlc.arg(hidden_state)
ON CONFLICT (user_id, post_id) DO UPDATE
SET value = excluded.value;
+184
View File
@@ -22,6 +22,8 @@ type Memory struct {
questions map[string]*RankedQuestion // id -> question
answers map[string]*Answer // questionID -> answer
votes map[string]map[string]int // questionID -> userID -> value
posts map[string]*Post // id -> post
postVotes map[string]map[string]int // postID -> userID -> value
}
// NewMemory returns an empty Memory store.
@@ -32,6 +34,8 @@ func NewMemory() *Memory {
questions: map[string]*RankedQuestion{},
answers: map[string]*Answer{},
votes: map[string]map[string]int{},
posts: map[string]*Post{},
postVotes: map[string]map[string]int{},
}
}
@@ -372,6 +376,186 @@ func (m *Memory) UpsertAnswer(_ context.Context, a *Answer) error {
return nil
}
func (m *Memory) CreatePost(_ context.Context, post *Post) error {
if post == nil {
return fmt.Errorf("%w: post is nil", ErrInvalidPost)
}
if err := preparePost(post); err != nil {
return err
}
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.users[post.AuthorID]; !ok {
return fmt.Errorf("%w: unknown author", ErrInvalidPost)
}
if _, exists := m.posts[post.ID]; exists {
return fmt.Errorf("%w: duplicate id", ErrInvalidPost)
}
if post.ParentID != nil {
if _, ok := m.posts[*post.ParentID]; !ok {
return fmt.Errorf("%w: unknown parent", ErrInvalidPost)
}
}
cp := clonePost(post)
cp.db = nil
m.posts[cp.ID] = cp
*post = *clonePost(cp)
return nil
}
func (m *Memory) GetPost(_ context.Context, id string) (*Post, error) {
m.mu.Lock()
defer m.mu.Unlock()
post, ok := m.posts[id]
if !ok {
return nil, sql.ErrNoRows
}
return clonePostWithAuthor(post, m.users), nil
}
func (m *Memory) GetPostThread(_ context.Context, rootID string) (*Post, error) {
m.mu.Lock()
defer m.mu.Unlock()
root, ok := m.posts[rootID]
if !ok || root.ParentID != nil {
return nil, sql.ErrNoRows
}
inThread := map[string]bool{rootID: true}
for changed := true; changed; {
changed = false
for id, post := range m.posts {
if inThread[id] || post.ParentID == nil || !inThread[*post.ParentID] {
continue
}
inThread[id] = true
changed = true
}
}
posts := make([]Post, 0, len(inThread))
for id := range inThread {
posts = append(posts, *clonePostWithAuthor(m.posts[id], m.users))
}
return buildPostTree(posts, rootID)
}
func (m *Memory) UpdatePost(_ context.Context, post *Post) error {
if post == nil {
return fmt.Errorf("%w: post is nil", ErrInvalidPost)
}
body := strings.TrimSpace(post.Body)
if body == "" {
return fmt.Errorf("%w: body is required", ErrInvalidPost)
}
m.mu.Lock()
defer m.mu.Unlock()
existing, ok := m.posts[post.ID]
if !ok {
return sql.ErrNoRows
}
existing.Body = body
existing.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
*post = *clonePostWithAuthor(existing, m.users)
return nil
}
func (m *Memory) ListRootPosts(_ context.Context, postDate, viewerID string) ([]Post, error) {
m.mu.Lock()
defer m.mu.Unlock()
posts := make([]Post, 0)
for _, post := range m.posts {
if post.ParentID != nil || post.PostDate != postDate || post.PostState == PostStateHidden {
continue
}
cp := clonePostWithAuthor(post, m.users)
for _, value := range m.postVotes[post.ID] {
cp.Score += value
}
cp.UserVote = m.postVotes[post.ID][viewerID]
cp.Answered = m.threadContainsAdminReply(post.ID)
posts = append(posts, *cp)
}
sort.Slice(posts, func(i, j int) bool {
if posts[i].Score != posts[j].Score {
return posts[i].Score > posts[j].Score
}
if posts[i].CreatedAt != posts[j].CreatedAt {
return posts[i].CreatedAt < posts[j].CreatedAt
}
return posts[i].ID < posts[j].ID
})
if len(posts) > HuntListLimit {
posts = posts[:HuntListLimit]
}
return posts, nil
}
func (m *Memory) VotePost(_ context.Context, userID, postID string, value int) error {
m.mu.Lock()
defer m.mu.Unlock()
if value != 1 && value != -1 && value != 0 {
return fmt.Errorf("invalid vote")
}
post, ok := m.posts[postID]
if !ok || post.ParentID != nil || post.PostState == PostStateHidden {
return ErrPostNotVotable
}
if m.postVotes[postID] == nil {
m.postVotes[postID] = map[string]int{}
}
if value == 0 {
delete(m.postVotes[postID], userID)
return nil
}
m.postVotes[postID][userID] = value
return nil
}
func (m *Memory) threadContainsAdminReply(rootID string) bool {
for id, post := range m.posts {
if id == rootID || !m.postIsDescendantOf(post, rootID) {
continue
}
if author := m.users[post.AuthorID]; author != nil && author.Role == RoleAdmin {
return true
}
}
return false
}
func (m *Memory) postIsDescendantOf(post *Post, rootID string) bool {
seen := map[string]bool{}
for post != nil && post.ParentID != nil {
if *post.ParentID == rootID {
return true
}
if seen[*post.ParentID] {
return false
}
seen[*post.ParentID] = true
post = m.posts[*post.ParentID]
}
return false
}
func clonePost(post *Post) *Post {
cp := *post
if post.ParentID != nil {
parentID := *post.ParentID
cp.ParentID = &parentID
}
cp.Replies = nil
return &cp
}
func clonePostWithAuthor(post *Post, users map[string]*User) *Post {
cp := clonePost(post)
if author := users[post.AuthorID]; author != nil {
cp.AuthorName = author.Name
cp.AuthorRole = author.Role
}
return cp
}
func (m *Memory) Vote(_ context.Context, userID, questionID string, value int) error {
m.mu.Lock()
defer m.mu.Unlock()
+172 -17
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,50 +52,118 @@ 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, step.args...); err != nil {
return fmt.Errorf("%s: %w", step.name, err)
}
}
return nil
}
func migratePostVoteIndex(ctx context.Context, exec execContext) error {
if _, err := exec.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_post_votes_post_id
ON post_votes(post_id)`); err != nil {
return fmt.Errorf("idx_post_votes_post_id: %w", err)
}
return nil
}
func migratePostDate(ctx context.Context, exec execContext) error {
steps := []struct {
name string
sql string
}{
{"rename post date", `
DO $migration$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'posts'
AND column_name = 'hunt_date'
) AND NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'posts'
AND column_name = 'post_date'
) THEN
ALTER TABLE posts RENAME COLUMN hunt_date TO post_date;
END IF;
END
$migration$`},
{"drop legacy root date index", `
DROP INDEX IF EXISTS idx_posts_root_hunt`},
{"create root date index", `
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 {
@@ -104,6 +173,89 @@ ON CONFLICT (user_id, post_id) DO NOTHING`},
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 {
if _, err := exec.ExecContext(ctx, step.sql); err != nil {
return fmt.Errorf("%s: %w", step.name, err)
}
}
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)
@@ -151,6 +303,9 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
{"002_user_profile_columns", migrateUserProfileColumns},
{"003_user_email", migrateUserEmail},
{"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] {
+233 -8
View File
@@ -9,6 +9,8 @@ import (
"testing"
"github.com/google/uuid"
"plumber/internal/store/sqlc"
)
func TestMigratePostsCopiesLegacyData(t *testing.T) {
@@ -47,7 +49,9 @@ func TestMigratePostsCopiesLegacyData(t *testing.T) {
legacySchema := `
CREATE TABLE users (
id TEXT PRIMARY KEY
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
role TEXT NOT NULL
);
CREATE TABLE questions (
id TEXT PRIMARY KEY,
@@ -76,7 +80,8 @@ CREATE TABLE votes (
t.Fatal(err)
}
if _, err := conn.ExecContext(ctx, `
INSERT INTO users (id) VALUES ('homeowner'), ('plumber');
INSERT INTO users (id, name, role)
VALUES ('homeowner', 'Home Owner', 'user'), ('plumber', 'The Plumber', 'admin');
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)
@@ -92,6 +97,12 @@ VALUES ('homeowner', 'question-1', 1);`); err != nil {
if err := migratePosts(ctx, conn); err != nil {
t.Fatalf("migration is not idempotent: %v", err)
}
if err := migratePostVoteIndex(ctx, conn); err != nil {
t.Fatal(err)
}
if err := migratePostVoteIndex(ctx, conn); err != nil {
t.Fatalf("post vote index 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 {
@@ -115,11 +126,28 @@ VALUES ('homeowner', 'question-1', 1);`); err != nil {
legacyAnswerCount,
)
}
var postVoteIndexCount int
if err := conn.QueryRowContext(ctx, `
SELECT count(*)
FROM pg_indexes
WHERE schemaname = current_schema()
AND tablename = 'post_votes'
AND indexname = 'idx_post_votes_post_id'`).Scan(&postVoteIndexCount); err != nil {
t.Fatal(err)
}
if postVoteIndexCount != 1 {
t.Fatalf("post vote index count = %d, want 1", postVoteIndexCount)
}
if _, err := conn.ExecContext(ctx, `
INSERT INTO post_votes (user_id, post_id, value)
VALUES ('homeowner', 'question-1', -1)`); err == nil {
t.Fatal("duplicate user/post vote unexpectedly succeeded")
}
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,
@@ -128,6 +156,7 @@ WHERE id = 'question-1'`).Scan(
&rootBody,
&city,
&postDate,
&rootState,
&rootCreated,
&rootUpdated,
); err != nil {
@@ -139,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 {
@@ -160,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")
@@ -177,12 +209,205 @@ 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")
}
queries := sqlc.New(conn)
if err := queries.CreatePost(ctx, sqlc.CreatePostParams{
ID: "follow-up",
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 {
t.Fatal(err)
}
thread, err := queries.ListPostThread(ctx, "question-1")
if err != nil {
t.Fatal(err)
}
if len(thread) != 3 ||
thread[0].ID != "question-1" ||
thread[1].ID != "answer:question-1" ||
thread[2].ID != "follow-up" {
t.Fatalf("recursive thread = %+v", thread)
}
nonRootThread, err := queries.ListPostThread(ctx, "answer:question-1")
if err != nil {
t.Fatal(err)
}
if len(nonRootThread) != 0 {
t.Fatalf("non-root thread lookup returned %+v", nonRootThread)
}
if n, err := queries.UpdatePost(ctx, sqlc.UpdatePostParams{
ID: "follow-up",
Body: "The drip continues.",
UpdatedAt: "2026-08-26T10:05:00Z",
}); err != nil || n != 1 {
t.Fatalf("update rows=%d error=%v", n, err)
}
if n, err := queries.UpsertPostVoteOnVisibleRoot(ctx, sqlc.UpsertPostVoteOnVisibleRootParams{
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",
HiddenState: string(PostStateHidden),
})
if err != nil {
t.Fatal(err)
}
if len(roots) != 1 ||
roots[0].Score != 2 ||
!roots[0].Answered ||
roots[0].UserVote != 1 {
t.Fatalf("root annotations = %+v", roots)
}
if _, err := conn.ExecContext(ctx, `
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, post_state)
WHERE parent_id IS NULL;`); err != nil {
t.Fatal(err)
}
if err := migratePostDate(ctx, conn); err != nil {
t.Fatal(err)
}
if err := migratePostDate(ctx, conn); err != nil {
t.Fatalf("post date migration is not idempotent: %v", err)
}
var postDateColumnCount, huntDateColumnCount, rootDateIndexCount, legacyIndexCount int
if err := conn.QueryRowContext(ctx, `
SELECT
count(*) FILTER (WHERE column_name = 'post_date'),
count(*) FILTER (WHERE column_name = 'hunt_date')
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'posts'`).Scan(&postDateColumnCount, &huntDateColumnCount); err != nil {
t.Fatal(err)
}
if err := conn.QueryRowContext(ctx, `
SELECT
count(*) FILTER (WHERE indexname = 'idx_posts_root_date'),
count(*) FILTER (WHERE indexname = 'idx_posts_root_hunt')
FROM pg_indexes
WHERE schemaname = current_schema()
AND tablename = 'posts'`).Scan(&rootDateIndexCount, &legacyIndexCount); err != nil {
t.Fatal(err)
}
var migratedPostDate string
if err := conn.QueryRowContext(ctx, `
SELECT post_date FROM posts WHERE id = 'question-1'`).Scan(&migratedPostDate); err != nil {
t.Fatal(err)
}
if postDateColumnCount != 1 ||
huntDateColumnCount != 0 ||
rootDateIndexCount != 1 ||
legacyIndexCount != 0 ||
migratedPostDate != "2026-08-26" {
t.Fatalf(
"post date migration columns=%d legacy_columns=%d indexes=%d legacy_indexes=%d date=%q",
postDateColumnCount,
huntDateColumnCount,
rootDateIndexCount,
legacyIndexCount,
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) {
+372
View File
@@ -0,0 +1,372 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"plumber/internal/pacific"
"plumber/internal/store/sqlc"
)
var (
ErrInvalidPost = errors.New("invalid post")
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
ParentID *string
AuthorID string
AuthorName string
AuthorRole Role
Title string
Body string
City string
PostDate string
PostState PostState
CreatedAt string
UpdatedAt string
Score int
Answered bool
UserVote int
Replies []*Post
db *sql.DB
}
// NewPost returns a post bound to db.
func NewPost(db *sql.DB) *Post {
return &Post{db: db}
}
// Create inserts a root post or reply according to ParentID.
func (p *Post) Create(ctx context.Context) error {
if p == nil || p.db == nil {
return fmt.Errorf("post: no database")
}
if err := preparePost(p); err != nil {
return err
}
err := sqlc.New(p.db).CreatePost(ctx, sqlc.CreatePostParams{
ID: p.ID,
ParentID: nullableParentID(p.ParentID),
AuthorID: p.AuthorID,
Title: p.Title,
Body: p.Body,
City: p.City,
PostDate: p.PostDate,
PostState: string(p.PostState),
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
})
return mapPostCreateError(err)
}
// Update changes only the post body and update timestamp.
func (p *Post) Update(ctx context.Context) error {
if p == nil || p.db == nil {
return fmt.Errorf("post: no database")
}
p.Body = strings.TrimSpace(p.Body)
if p.Body == "" {
return fmt.Errorf("%w: body is required", ErrInvalidPost)
}
p.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
n, err := sqlc.New(p.db).UpdatePost(ctx, sqlc.UpdatePostParams{
ID: p.ID,
Body: p.Body,
UpdatedAt: p.UpdatedAt,
})
if err != nil {
return err
}
if n == 0 {
return sql.ErrNoRows
}
return nil
}
func preparePost(p *Post) error {
p.ID = strings.TrimSpace(p.ID)
p.AuthorID = strings.TrimSpace(p.AuthorID)
p.Title = strings.TrimSpace(p.Title)
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)
}
if p.Body == "" {
return fmt.Errorf("%w: body is required", ErrInvalidPost)
}
if p.ParentID == nil {
if p.Title == "" {
return fmt.Errorf("%w: root title is required", ErrInvalidPost)
}
if p.PostDate == "" {
p.PostDate = pacific.Today()
}
} else {
parentID := strings.TrimSpace(*p.ParentID)
if parentID == "" {
return fmt.Errorf("%w: parent is required", ErrInvalidPost)
}
p.ParentID = &parentID
if p.Title != "" || p.City != "" || p.PostDate != "" || p.PostState != PostStateVisible {
return fmt.Errorf("%w: reply contains root-only fields", ErrInvalidPost)
}
}
if p.ID == "" {
p.ID = uuid.NewString()
}
now := time.Now().UTC().Format(time.RFC3339Nano)
if p.CreatedAt == "" {
p.CreatedAt = now
}
if p.UpdatedAt == "" {
p.UpdatedAt = p.CreatedAt
}
return nil
}
func nullableParentID(parentID *string) sql.NullString {
if parentID == nil {
return sql.NullString{}
}
return sql.NullString{String: *parentID, Valid: true}
}
func parentIDFromNull(parentID sql.NullString) *string {
if !parentID.Valid {
return nil
}
id := parentID.String
return &id
}
func mapPostCreateError(err error) error {
if err == nil {
return nil
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
switch pgErr.Code {
case "23503", "23505", "23514":
return fmt.Errorf("%w: %v", ErrInvalidPost, err)
}
}
return err
}
func postFromValues(
db *sql.DB,
id string,
parentID sql.NullString,
authorID, authorName, authorRole, title, body, city, postDate string,
postState string,
createdAt, updatedAt string,
) Post {
return Post{
ID: id,
ParentID: parentIDFromNull(parentID),
AuthorID: authorID,
AuthorName: authorName,
AuthorRole: Role(authorRole),
Title: title,
Body: body,
City: city,
PostDate: postDate,
PostState: PostState(postState),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
db: db,
}
}
// GetPost returns one post without loading its replies.
func GetPost(ctx context.Context, db *sql.DB, id string) (*Post, error) {
r, err := sqlc.New(db).GetPost(ctx, id)
if err != nil {
return nil, err
}
p := postFromValues(
db,
r.ID,
r.ParentID,
r.AuthorID,
r.AuthorName,
r.AuthorRole,
r.Title,
r.Body,
r.City,
r.PostDate,
r.PostState,
r.CreatedAt,
r.UpdatedAt,
)
return &p, nil
}
// GetPostThread returns a root post with all descendants nested under Replies.
func GetPostThread(ctx context.Context, db *sql.DB, rootID string) (*Post, error) {
rows, err := sqlc.New(db).ListPostThread(ctx, rootID)
if err != nil {
return nil, err
}
posts := make([]Post, 0, len(rows))
for _, r := range rows {
posts = append(posts, postFromValues(
db,
r.ID,
r.ParentID,
r.AuthorID,
r.AuthorName,
r.AuthorRole,
r.Title,
r.Body,
r.City,
r.PostDate,
r.PostState,
r.CreatedAt,
r.UpdatedAt,
))
}
return buildPostTree(posts, rootID)
}
func buildPostTree(posts []Post, rootID string) (*Post, error) {
byID := make(map[string]*Post, len(posts))
for i := range posts {
posts[i].Replies = nil
byID[posts[i].ID] = &posts[i]
}
root, ok := byID[rootID]
if !ok || root.ParentID != nil {
return nil, sql.ErrNoRows
}
for i := range posts {
post := &posts[i]
if post.ID == rootID {
continue
}
if post.ParentID == nil {
return nil, fmt.Errorf("post %s is not in thread %s", post.ID, rootID)
}
parent, ok := byID[*post.ParentID]
if !ok {
return nil, fmt.Errorf("post %s has missing parent %s", post.ID, *post.ParentID)
}
parent.Replies = append(parent.Replies, post)
}
var sortReplies func(*Post)
sortReplies = func(post *Post) {
sort.Slice(post.Replies, func(i, j int) bool {
if post.Replies[i].CreatedAt != post.Replies[j].CreatedAt {
return post.Replies[i].CreatedAt < post.Replies[j].CreatedAt
}
return post.Replies[i].ID < post.Replies[j].ID
})
for _, reply := range post.Replies {
sortReplies(reply)
}
}
sortReplies(root)
return root, nil
}
// 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,
HiddenState: string(PostStateHidden),
})
if err != nil {
return nil, err
}
posts := make([]Post, 0, len(rows))
for _, r := range rows {
post := postFromValues(
db,
r.ID,
r.ParentID,
r.AuthorID,
r.AuthorName,
r.AuthorRole,
r.Title,
r.Body,
r.City,
r.PostDate,
r.PostState,
r.CreatedAt,
r.UpdatedAt,
)
post.Score = int(r.Score)
post.Answered = r.Answered
post.UserVote = int(r.UserVote)
posts = append(posts, post)
}
return posts, nil
}
// SetPostVote sets value to 1, -1, or 0 on a visible root post.
func SetPostVote(ctx context.Context, db *sql.DB, userID, postID string, value int) error {
if value != 1 && value != -1 && value != 0 {
return fmt.Errorf("invalid vote")
}
q := sqlc.New(db)
if value == 0 {
visible, err := q.PostIsVisibleRoot(ctx, sqlc.PostIsVisibleRootParams{
ID: postID,
HiddenState: string(PostStateHidden),
})
if err != nil {
return err
}
if !visible {
return ErrPostNotVotable
}
return q.DeletePostVote(ctx, sqlc.DeletePostVoteParams{
UserID: userID,
PostID: postID,
})
}
n, err := q.UpsertPostVoteOnVisibleRoot(ctx, sqlc.UpsertPostVoteOnVisibleRootParams{
UserID: userID,
PostID: postID,
Value: int32(value),
HiddenState: string(PostStateHidden),
})
if err != nil {
return err
}
if n == 0 {
return ErrPostNotVotable
}
return nil
}
+252
View File
@@ -0,0 +1,252 @@
package store
import (
"context"
"database/sql"
"errors"
"testing"
"github.com/jackc/pgx/v5/pgconn"
)
func TestMapPostCreateError(t *testing.T) {
t.Parallel()
for _, code := range []string{"23503", "23505", "23514"} {
err := mapPostCreateError(&pgconn.PgError{Code: code})
if !errors.Is(err, ErrInvalidPost) {
t.Errorf("code %s error = %v, want ErrInvalidPost", code, err)
}
}
original := &pgconn.PgError{Code: "08006"}
if err := mapPostCreateError(original); !errors.Is(err, original) {
t.Errorf("unexpected database error was replaced: %v", err)
}
}
func TestMemoryPostLifecycle(t *testing.T) {
t.Parallel()
ctx := context.Background()
mem := NewMemory()
homeowner := &User{Username: "homeowner", PasswordHash: "hash", Role: RoleUser}
plumber := &User{Username: "plumber", PasswordHash: "hash", Role: RoleAdmin}
voter := &User{Username: "voter", PasswordHash: "hash", Role: RoleUser}
for _, user := range []*User{homeowner, plumber, voter} {
if err := mem.CreateUser(ctx, user); err != nil {
t.Fatal(err)
}
}
root := &Post{
ID: "root",
AuthorID: homeowner.ID,
Title: "Leaky sink",
Body: "It drips.",
City: "Oakland",
PostDate: "2026-08-26",
CreatedAt: "2026-08-26T08:00:00Z",
}
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{
ID: "later",
ParentID: &rootID,
AuthorID: plumber.ID,
Body: "Is it a single-handle faucet?",
CreatedAt: "2026-08-26T09:00:00Z",
}
earlier := &Post{
ID: "earlier",
ParentID: &rootID,
AuthorID: plumber.ID,
Body: "Can you share the model number?",
CreatedAt: "2026-08-26T08:30:00Z",
}
if err := mem.CreatePost(ctx, later); err != nil {
t.Fatal(err)
}
if err := mem.CreatePost(ctx, earlier); err != nil {
t.Fatal(err)
}
laterID := later.ID
nested := &Post{
ID: "nested",
ParentID: &laterID,
AuthorID: homeowner.ID,
Body: "Yes, it is.",
CreatedAt: "2026-08-26T09:30:00Z",
}
if err := mem.CreatePost(ctx, nested); err != nil {
t.Fatal(err)
}
thread, err := mem.GetPostThread(ctx, root.ID)
if err != nil {
t.Fatal(err)
}
if thread.AuthorName != homeowner.Name || thread.AuthorRole != RoleUser {
t.Fatalf("root author = %q %q", thread.AuthorName, thread.AuthorRole)
}
if len(thread.Replies) != 2 ||
thread.Replies[0].ID != earlier.ID ||
thread.Replies[1].ID != later.ID {
t.Fatalf("root replies are not oldest-first: %+v", thread.Replies)
}
if len(thread.Replies[1].Replies) != 1 || thread.Replies[1].Replies[0].ID != nested.ID {
t.Fatalf("nested reply missing: %+v", thread.Replies[1].Replies)
}
otherParent := earlier.ID
nested.ParentID = &otherParent
nested.AuthorID = voter.ID
nested.Body = "Yes—one handle."
if err := mem.UpdatePost(ctx, nested); err != nil {
t.Fatal(err)
}
saved, err := mem.GetPost(ctx, nested.ID)
if err != nil {
t.Fatal(err)
}
if saved.ParentID == nil || *saved.ParentID != later.ID {
t.Fatalf("update changed parent to %+v", saved.ParentID)
}
if saved.AuthorID != homeowner.ID {
t.Fatalf("update changed author to %q", saved.AuthorID)
}
if saved.Body != "Yes—one handle." || saved.UpdatedAt == saved.CreatedAt {
t.Fatalf("body update not applied: %+v", saved)
}
if err := mem.VotePost(ctx, voter.ID, root.ID, 1); err != nil {
t.Fatal(err)
}
roots, err := mem.ListRootPosts(ctx, root.PostDate, voter.ID)
if err != nil {
t.Fatal(err)
}
if len(roots) != 1 || roots[0].ID != root.ID {
t.Fatalf("root list = %+v", roots)
}
if roots[0].Score != 1 || roots[0].UserVote != 1 || !roots[0].Answered {
t.Fatalf("root annotations = %+v", roots[0])
}
if err := mem.VotePost(ctx, voter.ID, later.ID, 1); !errors.Is(err, ErrPostNotVotable) {
t.Fatalf("reply vote error = %v", err)
}
}
func TestMemoryPostValidation(t *testing.T) {
t.Parallel()
ctx := context.Background()
mem := NewMemory()
homeowner := &User{Username: "homeowner", PasswordHash: "hash", Role: RoleUser}
if err := mem.CreateUser(ctx, homeowner); err != nil {
t.Fatal(err)
}
tests := []struct {
name string
post *Post
}{
{
name: "root without title",
post: &Post{AuthorID: homeowner.ID, Body: "Body"},
},
{
name: "empty parent",
post: &Post{ParentID: ptr(""), AuthorID: homeowner.ID, Body: "Body"},
},
{
name: "missing parent",
post: &Post{ParentID: ptr("missing"), AuthorID: homeowner.ID, Body: "Body"},
},
{
name: "reply with root fields",
post: &Post{
ParentID: ptr("missing"),
AuthorID: homeowner.ID,
Title: "Not allowed",
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) {
if err := mem.CreatePost(ctx, test.post); !errors.Is(err, ErrInvalidPost) {
t.Fatalf("error = %v, want ErrInvalidPost", err)
}
})
}
hidden := &Post{
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)
}
if err := mem.VotePost(ctx, homeowner.ID, hidden.ID, 1); !errors.Is(err, ErrPostNotVotable) {
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)
}
}
func ptr(value string) *string {
return &value
}
+26
View File
@@ -144,6 +144,32 @@ func (p *Postgres) UpsertAnswer(ctx context.Context, a *Answer) error {
return a.Upsert(ctx)
}
func (p *Postgres) CreatePost(ctx context.Context, post *Post) error {
post.db = p.db
return post.Create(ctx)
}
func (p *Postgres) GetPost(ctx context.Context, id string) (*Post, error) {
return GetPost(ctx, p.db, id)
}
func (p *Postgres) GetPostThread(ctx context.Context, rootID string) (*Post, error) {
return GetPostThread(ctx, p.db, rootID)
}
func (p *Postgres) UpdatePost(ctx context.Context, post *Post) error {
post.db = p.db
return post.Update(ctx)
}
func (p *Postgres) ListRootPosts(ctx context.Context, postDate, viewerID string) ([]Post, error) {
return ListRootPosts(ctx, p.db, postDate, viewerID)
}
func (p *Postgres) VotePost(ctx context.Context, userID, postID string, value int) error {
return SetPostVote(ctx, p.db, userID, postID, value)
}
func (p *Postgres) Vote(ctx context.Context, userID, questionID string, value int) error {
return Vote(ctx, p.db, userID, questionID, value)
}
+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
}
+385
View File
@@ -0,0 +1,385 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: posts.sql
package sqlc
import (
"context"
"database/sql"
)
const createPost = `-- name: CreatePost :exec
INSERT INTO posts (
id, parent_id, author_id, title, body, city, post_date, post_state, created_at, updated_at
)
VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
$9,
$10
)
`
type CreatePostParams struct {
ID string
ParentID sql.NullString
AuthorID string
Title string
Body string
City string
PostDate string
PostState string
CreatedAt string
UpdatedAt string
}
func (q *Queries) CreatePost(ctx context.Context, arg CreatePostParams) error {
_, err := q.db.ExecContext(ctx, createPost,
arg.ID,
arg.ParentID,
arg.AuthorID,
arg.Title,
arg.Body,
arg.City,
arg.PostDate,
arg.PostState,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const deletePostVote = `-- name: DeletePostVote :exec
DELETE FROM post_votes
WHERE user_id = $1
AND post_id = $2
`
type DeletePostVoteParams struct {
UserID string
PostID string
}
func (q *Queries) DeletePostVote(ctx context.Context, arg DeletePostVoteParams) error {
_, err := q.db.ExecContext(ctx, deletePostVote, arg.UserID, arg.PostID)
return err
}
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.post_state, p.created_at, p.updated_at
FROM posts p
JOIN users u ON u.id = p.author_id
WHERE p.id = $1
`
type GetPostRow struct {
ID string
ParentID sql.NullString
AuthorID string
AuthorName string
AuthorRole string
Title string
Body string
City string
PostDate string
PostState string
CreatedAt string
UpdatedAt string
}
func (q *Queries) GetPost(ctx context.Context, id string) (GetPostRow, error) {
row := q.db.QueryRowContext(ctx, getPost, id)
var i GetPostRow
err := row.Scan(
&i.ID,
&i.ParentID,
&i.AuthorID,
&i.AuthorName,
&i.AuthorRole,
&i.Title,
&i.Body,
&i.City,
&i.PostDate,
&i.PostState,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
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.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.post_state, child.created_at, child.updated_at
FROM posts child
JOIN thread parent ON child.parent_id = parent.id
)
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.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
`
type ListPostThreadRow struct {
ID string
ParentID sql.NullString
AuthorID string
AuthorName string
AuthorRole string
Title string
Body string
City string
PostDate string
PostState string
CreatedAt string
UpdatedAt string
}
func (q *Queries) ListPostThread(ctx context.Context, rootID string) ([]ListPostThreadRow, error) {
rows, err := q.db.QueryContext(ctx, listPostThread, rootID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListPostThreadRow{}
for rows.Next() {
var i ListPostThreadRow
if err := rows.Scan(
&i.ID,
&i.ParentID,
&i.AuthorID,
&i.AuthorName,
&i.AuthorRole,
&i.Title,
&i.Body,
&i.City,
&i.PostDate,
&i.PostState,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
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.post_state, p.created_at, p.updated_at
FROM posts p
WHERE p.parent_id IS NULL
AND p.post_date = $3
AND p.post_state <> $4
),
thread AS (
SELECT roots.id AS root_id, child.id AS post_id, child.author_id
FROM roots
JOIN posts child ON child.parent_id = roots.id
UNION ALL
SELECT thread.root_id, child.id, child.author_id
FROM thread
JOIN posts child ON child.parent_id = thread.post_id
),
answered AS (
SELECT DISTINCT thread.root_id
FROM thread
JOIN users u ON u.id = thread.author_id
WHERE u.role = 'admin'
),
scores AS (
SELECT votes.post_id, SUM(votes.value)::bigint AS score
FROM roots
JOIN post_votes votes ON votes.post_id = roots.id
GROUP BY votes.post_id
)
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.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
FROM roots
JOIN users u ON u.id = roots.author_id
LEFT JOIN scores ON scores.post_id = roots.id
LEFT JOIN answered ON answered.root_id = roots.id
LEFT JOIN post_votes viewer_vote
ON viewer_vote.user_id = $1
AND viewer_vote.post_id = roots.id
ORDER BY score DESC, roots.created_at, roots.id
LIMIT $2
`
type ListRootPostsParams struct {
ViewerID string
RowLimit int32
PostDate string
HiddenState string
}
type ListRootPostsRow struct {
ID string
ParentID sql.NullString
AuthorID string
AuthorName string
AuthorRole string
Title string
Body string
City string
PostDate string
PostState string
CreatedAt string
UpdatedAt string
Score int64
Answered bool
UserVote int64
}
func (q *Queries) ListRootPosts(ctx context.Context, arg ListRootPostsParams) ([]ListRootPostsRow, error) {
rows, err := q.db.QueryContext(ctx, listRootPosts,
arg.ViewerID,
arg.RowLimit,
arg.PostDate,
arg.HiddenState,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListRootPostsRow{}
for rows.Next() {
var i ListRootPostsRow
if err := rows.Scan(
&i.ID,
&i.ParentID,
&i.AuthorID,
&i.AuthorName,
&i.AuthorRole,
&i.Title,
&i.Body,
&i.City,
&i.PostDate,
&i.PostState,
&i.CreatedAt,
&i.UpdatedAt,
&i.Score,
&i.Answered,
&i.UserVote,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const postIsVisibleRoot = `-- name: PostIsVisibleRoot :one
SELECT EXISTS(
SELECT 1
FROM posts
WHERE id = $1
AND parent_id IS NULL
AND post_state <> $2
)::bool
`
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
}
const updatePost = `-- name: UpdatePost :execrows
UPDATE posts
SET
body = $1,
updated_at = $2
WHERE id = $3
`
type UpdatePostParams struct {
Body string
UpdatedAt string
ID string
}
func (q *Queries) UpdatePost(ctx context.Context, arg UpdatePostParams) (int64, error) {
result, err := q.db.ExecContext(ctx, updatePost, arg.Body, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const upsertPostVoteOnVisibleRoot = `-- name: UpsertPostVoteOnVisibleRoot :execrows
INSERT INTO post_votes (user_id, post_id, value)
SELECT $1, $2, $3
FROM posts p
WHERE p.id = $2
AND p.parent_id IS NULL
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
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,
arg.HiddenState,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
+7
View File
@@ -37,6 +37,13 @@ type Store interface {
GetAnswer(ctx context.Context, questionID string) (*Answer, error)
UpsertAnswer(ctx context.Context, a *Answer) error
CreatePost(ctx context.Context, post *Post) error
GetPost(ctx context.Context, id string) (*Post, error)
GetPostThread(ctx context.Context, rootID string) (*Post, error)
UpdatePost(ctx context.Context, post *Post) error
ListRootPosts(ctx context.Context, postDate, viewerID string) ([]Post, error)
VotePost(ctx context.Context, userID, postID string, value int) error
// Vote sets the vote to 1, -1, or 0 (clear) on a visible question.
Vote(ctx context.Context, userID, questionID string, value int) error
}
+7 -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 (
@@ -74,6 +74,9 @@ CREATE TABLE IF NOT EXISTS post_votes (
PRIMARY KEY (user_id, post_id)
);
CREATE INDEX IF NOT EXISTS idx_post_votes_post_id
ON post_votes(post_id);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
data BYTEA NOT NULL,