Adopt sqlc for typed Postgres queries behind store entities.

This commit is contained in:
2026-08-22 06:59:56 -07:00
parent c77298411e
commit b519cf6fe5
17 changed files with 1016 additions and 147 deletions
+7
View File
@@ -0,0 +1,7 @@
.PHONY: sqlc
sqlc:
sqlc generate
.PHONY: sqlc-check
sqlc-check:
sqlc diff
+11
View File
@@ -0,0 +1,11 @@
-- name: UpsertAnswer :exec
INSERT INTO answers (question_id, author_id, body, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (question_id) DO UPDATE
SET body = excluded.body, author_id = excluded.author_id, updated_at = excluded.updated_at;
-- name: GetAnswer :one
SELECT a.question_id, a.author_id, u.name AS author_name, a.body, a.created_at, a.updated_at
FROM answers a
JOIN users u ON u.id = a.author_id
WHERE a.question_id = $1;
+59
View File
@@ -0,0 +1,59 @@
-- name: CreateQuestion :exec
INSERT INTO questions (id, author_id, title, body, city, hunt_date, hidden, created_at)
VALUES ($1, $2, $3, $4, $5, $6, 0, $7);
-- name: HideQuestion :exec
UPDATE questions
SET hidden = 1
WHERE id = $1;
-- name: ListHunt :many
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
COALESCE(SUM(v.value), 0)::bigint AS score,
CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered,
COALESCE((
SELECT votes.value FROM votes
WHERE votes.user_id = sqlc.arg(viewer_id) AND votes.question_id = q.id
), 0)::bigint AS user_vote
FROM questions q
JOIN users u ON u.id = q.author_id
LEFT JOIN votes v ON v.question_id = q.id
LEFT JOIN answers a ON a.question_id = q.id
WHERE q.hunt_date = sqlc.arg(hunt_date) AND q.hidden = 0
GROUP BY q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, a.question_id
ORDER BY score DESC, q.created_at ASC;
-- name: GetQuestion :one
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score,
CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered,
COALESCE((
SELECT votes.value FROM votes
WHERE votes.user_id = sqlc.arg(viewer_id) AND votes.question_id = q.id
), 0)::bigint AS user_vote
FROM questions q
JOIN users u ON u.id = q.author_id
LEFT JOIN answers a ON a.question_id = q.id
WHERE q.id = sqlc.arg(id);
-- name: ListQuestionsByAuthor :many
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score,
CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered,
0::bigint AS user_vote
FROM questions q
JOIN users u ON u.id = q.author_id
LEFT JOIN answers a ON a.question_id = q.id
WHERE q.author_id = sqlc.arg(author_id) AND q.hidden = 0
ORDER BY q.created_at DESC;
-- name: ListQuestionsAnsweredBy :many
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score,
1::bigint AS answered,
0::bigint AS user_vote
FROM answers ans
JOIN questions q ON q.id = ans.question_id
JOIN users u ON u.id = q.author_id
WHERE ans.author_id = sqlc.arg(admin_id) AND q.hidden = 0
ORDER BY ans.updated_at DESC;
+43
View File
@@ -0,0 +1,43 @@
-- name: CreateUser :exec
INSERT INTO users (id, username, name, password_hash, role, avatar_url, state, created_at)
VALUES ($1, $2, $3, $4, $5, '', '', $6);
-- name: GetUserByID :one
SELECT id, username, name, role, avatar_url, state, created_at
FROM users
WHERE id = $1;
-- name: GetUserByUsername :one
SELECT id, username, name, role, avatar_url, state, created_at, password_hash
FROM users
WHERE username = $1;
-- name: ListUsers :many
SELECT id, username, name, role, avatar_url, state, created_at
FROM users
ORDER BY created_at ASC;
-- name: CountAdmins :one
SELECT COUNT(*)::bigint AS count
FROM users
WHERE role = $1;
-- name: GetUserRole :one
SELECT role
FROM users
WHERE id = $1;
-- name: UpdateUserRole :execresult
UPDATE users
SET role = $1
WHERE id = $2;
-- name: UpdateUserState :exec
UPDATE users
SET state = $1
WHERE id = $2;
-- name: UpdateUserStateAndAvatar :exec
UPDATE users
SET state = $1, avatar_url = $2
WHERE id = $3;
+14
View File
@@ -0,0 +1,14 @@
-- name: GetVote :one
SELECT value
FROM votes
WHERE user_id = $1 AND question_id = $2;
-- name: DeleteVote :exec
DELETE FROM votes
WHERE user_id = $1 AND question_id = $2;
-- name: UpsertVote :exec
INSERT INTO votes (user_id, question_id, value)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, question_id) DO UPDATE
SET value = excluded.value;
+19 -13
View File
@@ -6,6 +6,8 @@ import (
"fmt"
"strings"
"time"
"plumber/internal/store/sqlc"
)
// Answer is an admin reply to a question.
@@ -35,23 +37,27 @@ func (a *Answer) Upsert(ctx context.Context) error {
a.CreatedAt = now
}
a.UpdatedAt = now
_, err := a.db.ExecContext(ctx, `
INSERT INTO answers (question_id, author_id, body, created_at, updated_at) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (question_id) DO UPDATE SET body = excluded.body, author_id = excluded.author_id, updated_at = excluded.updated_at`,
a.QuestionID, a.AuthorID, a.Body, a.CreatedAt, a.UpdatedAt)
return err
return sqlc.New(a.db).UpsertAnswer(ctx, sqlc.UpsertAnswerParams{
QuestionID: a.QuestionID,
AuthorID: a.AuthorID,
Body: a.Body,
CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt,
})
}
func GetAnswer(ctx context.Context, db *sql.DB, questionID string) (*Answer, error) {
var a Answer
err := db.QueryRowContext(ctx, `
SELECT a.question_id, a.author_id, u.name, a.body, a.created_at, a.updated_at
FROM answers a
JOIN users u ON u.id = a.author_id
WHERE a.question_id = $1`, questionID).Scan(&a.QuestionID, &a.AuthorID, &a.AuthorName, &a.Body, &a.CreatedAt, &a.UpdatedAt)
r, err := sqlc.New(db).GetAnswer(ctx, questionID)
if err != nil {
return nil, err
}
a.db = db
return &a, nil
return &Answer{
QuestionID: r.QuestionID,
AuthorID: r.AuthorID,
AuthorName: r.AuthorName,
Body: r.Body,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
db: db,
}, nil
}
+3
View File
@@ -0,0 +1,3 @@
package store
//go:generate make -C ../.. sqlc
+61 -80
View File
@@ -10,6 +10,7 @@ import (
"github.com/google/uuid"
"plumber/internal/pacific"
"plumber/internal/store/sqlc"
)
// RankedQuestion is a question row with score / vote annotations for lists.
@@ -51,9 +52,15 @@ func (q *RankedQuestion) Create(ctx context.Context) error {
if q.CreatedAt == "" {
q.CreatedAt = time.Now().UTC().Format(time.RFC3339)
}
_, err := q.db.ExecContext(ctx, `INSERT INTO questions (id, author_id, title, body, city, hunt_date, hidden, created_at) VALUES ($1, $2, $3, $4, $5, $6, 0, $7)`,
q.ID, q.AuthorID, q.Title, q.Body, q.City, q.HuntDate, q.CreatedAt)
return err
return sqlc.New(q.db).CreateQuestion(ctx, sqlc.CreateQuestionParams{
ID: q.ID,
AuthorID: q.AuthorID,
Title: q.Title,
Body: q.Body,
City: q.City,
HuntDate: q.HuntDate,
CreatedAt: q.CreatedAt,
})
}
// Hide marks the question hidden.
@@ -61,108 +68,82 @@ func (q *RankedQuestion) Hide(ctx context.Context) error {
if q == nil || q.db == nil {
return fmt.Errorf("question: no database")
}
_, err := q.db.ExecContext(ctx, `UPDATE questions SET hidden = 1 WHERE id = $1`, q.ID)
if err == nil {
q.Hidden = true
}
if err := sqlc.New(q.db).HideQuestion(ctx, q.ID); err != nil {
return err
}
q.Hidden = true
return nil
}
func rankedFrom(
db *sql.DB,
id, authorID, authorName, title, body, city, huntDate, createdAt string,
hidden int32, score, answered, userVote int64,
) RankedQuestion {
return RankedQuestion{
ID: id,
AuthorID: authorID,
AuthorName: authorName,
Title: title,
Body: body,
City: city,
HuntDate: huntDate,
Hidden: hidden != 0,
CreatedAt: createdAt,
Score: int(score),
Answered: answered != 0,
UserVote: int(userVote),
db: db,
}
}
func ListHunt(ctx context.Context, db *sql.DB, huntDate, viewerID string) ([]RankedQuestion, error) {
rows, err := db.QueryContext(ctx, `
SELECT q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
COALESCE(SUM(v.value), 0) AS score,
CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END AS answered,
COALESCE((SELECT value FROM votes WHERE user_id = $1 AND question_id = q.id), 0) AS user_vote
FROM questions q
JOIN users u ON u.id = q.author_id
LEFT JOIN votes v ON v.question_id = q.id
LEFT JOIN answers a ON a.question_id = q.id
WHERE q.hunt_date = $2 AND q.hidden = 0
GROUP BY q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, a.question_id
ORDER BY score DESC, q.created_at ASC`, viewerID, huntDate)
rows, err := sqlc.New(db).ListHunt(ctx, sqlc.ListHuntParams{
UserID: viewerID,
HuntDate: huntDate,
})
if err != nil {
return nil, err
}
defer rows.Close()
return scanRankedList(db, rows)
out := make([]RankedQuestion, 0, len(rows))
for _, r := range rows {
out = append(out, rankedFrom(db, r.ID, r.AuthorID, r.AuthorName, r.Title, r.Body, r.City, r.HuntDate, r.CreatedAt, r.Hidden, r.Score, r.Answered, r.UserVote))
}
return out, nil
}
func GetQuestion(ctx context.Context, db *sql.DB, id, viewerID string) (*RankedQuestion, error) {
row := db.QueryRowContext(ctx, `
SELECT q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
COALESCE((SELECT SUM(value) FROM votes WHERE question_id = q.id), 0) AS score,
CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END AS answered,
COALESCE((SELECT value FROM votes WHERE user_id = $1 AND question_id = q.id), 0) AS user_vote
FROM questions q
JOIN users u ON u.id = q.author_id
LEFT JOIN answers a ON a.question_id = q.id
WHERE q.id = $2`, viewerID, id)
q, err := scanRanked(db, row)
r, err := sqlc.New(db).GetQuestion(ctx, sqlc.GetQuestionParams{
UserID: viewerID,
ID: id,
})
if err != nil {
return nil, err
}
q := rankedFrom(db, r.ID, r.AuthorID, r.AuthorName, r.Title, r.Body, r.City, r.HuntDate, r.CreatedAt, r.Hidden, r.Score, r.Answered, r.UserVote)
return &q, nil
}
func ListQuestionsByAuthor(ctx context.Context, db *sql.DB, authorID string) ([]RankedQuestion, error) {
rows, err := db.QueryContext(ctx, `
SELECT q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
COALESCE((SELECT SUM(value) FROM votes WHERE question_id = q.id), 0) AS score,
CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END AS answered,
0 AS user_vote
FROM questions q
JOIN users u ON u.id = q.author_id
LEFT JOIN answers a ON a.question_id = q.id
WHERE q.author_id = $1 AND q.hidden = 0
ORDER BY q.created_at DESC`, authorID)
rows, err := sqlc.New(db).ListQuestionsByAuthor(ctx, authorID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanRankedList(db, rows)
out := make([]RankedQuestion, 0, len(rows))
for _, r := range rows {
out = append(out, rankedFrom(db, r.ID, r.AuthorID, r.AuthorName, r.Title, r.Body, r.City, r.HuntDate, r.CreatedAt, r.Hidden, r.Score, r.Answered, r.UserVote))
}
return out, nil
}
func ListQuestionsAnsweredBy(ctx context.Context, db *sql.DB, adminID string) ([]RankedQuestion, error) {
rows, err := db.QueryContext(ctx, `
SELECT q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
COALESCE((SELECT SUM(value) FROM votes WHERE question_id = q.id), 0) AS score,
1 AS answered,
0 AS user_vote
FROM answers ans
JOIN questions q ON q.id = ans.question_id
JOIN users u ON u.id = q.author_id
WHERE ans.author_id = $1 AND q.hidden = 0
ORDER BY ans.updated_at DESC`, adminID)
rows, err := sqlc.New(db).ListQuestionsAnsweredBy(ctx, adminID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanRankedList(db, rows)
out := make([]RankedQuestion, 0, len(rows))
for _, r := range rows {
out = append(out, rankedFrom(db, r.ID, r.AuthorID, r.AuthorName, r.Title, r.Body, r.City, r.HuntDate, r.CreatedAt, r.Hidden, r.Score, r.Answered, r.UserVote))
}
type scanned interface {
Scan(dest ...any) error
}
func scanRanked(db *sql.DB, rows scanned) (RankedQuestion, error) {
var q RankedQuestion
var hidden, answered int
err := rows.Scan(&q.ID, &q.AuthorID, &q.AuthorName, &q.Title, &q.Body, &q.City, &q.HuntDate, &hidden, &q.CreatedAt, &q.Score, &answered, &q.UserVote)
q.Hidden = hidden != 0
q.Answered = answered != 0
q.db = db
return q, err
}
func scanRankedList(db *sql.DB, rows *sql.Rows) ([]RankedQuestion, error) {
var out []RankedQuestion
for rows.Next() {
q, err := scanRanked(db, rows)
if err != nil {
return nil, err
}
out = append(out, q)
}
return out, rows.Err()
return out, nil
}
+66
View File
@@ -0,0 +1,66 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: answers.sql
package sqlc
import (
"context"
)
const getAnswer = `-- name: GetAnswer :one
SELECT a.question_id, a.author_id, u.name AS author_name, a.body, a.created_at, a.updated_at
FROM answers a
JOIN users u ON u.id = a.author_id
WHERE a.question_id = $1
`
type GetAnswerRow struct {
QuestionID string
AuthorID string
AuthorName string
Body string
CreatedAt string
UpdatedAt string
}
func (q *Queries) GetAnswer(ctx context.Context, questionID string) (GetAnswerRow, error) {
row := q.db.QueryRowContext(ctx, getAnswer, questionID)
var i GetAnswerRow
err := row.Scan(
&i.QuestionID,
&i.AuthorID,
&i.AuthorName,
&i.Body,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const upsertAnswer = `-- name: UpsertAnswer :exec
INSERT INTO answers (question_id, author_id, body, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (question_id) DO UPDATE
SET body = excluded.body, author_id = excluded.author_id, updated_at = excluded.updated_at
`
type UpsertAnswerParams struct {
QuestionID string
AuthorID string
Body string
CreatedAt string
UpdatedAt string
}
func (q *Queries) UpsertAnswer(ctx context.Context, arg UpsertAnswerParams) error {
_, err := q.db.ExecContext(ctx, upsertAnswer,
arg.QuestionID,
arg.AuthorID,
arg.Body,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
+31
View File
@@ -0,0 +1,31 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package sqlc
import (
"context"
"database/sql"
)
type DBTX interface {
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
PrepareContext(context.Context, string) (*sql.Stmt, error)
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{
db: tx,
}
}
+41
View File
@@ -0,0 +1,41 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package sqlc
type Answer struct {
QuestionID string
AuthorID string
Body string
CreatedAt string
UpdatedAt string
}
type Question struct {
ID string
AuthorID string
Title string
Body string
City string
HuntDate string
Hidden int32
CreatedAt string
}
type User struct {
ID string
Username string
Name string
PasswordHash string
Role string
AvatarUrl string
State string
CreatedAt string
}
type Vote struct {
UserID string
QuestionID string
Value int32
}
+296
View File
@@ -0,0 +1,296 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: questions.sql
package sqlc
import (
"context"
)
const createQuestion = `-- name: CreateQuestion :exec
INSERT INTO questions (id, author_id, title, body, city, hunt_date, hidden, created_at)
VALUES ($1, $2, $3, $4, $5, $6, 0, $7)
`
type CreateQuestionParams struct {
ID string
AuthorID string
Title string
Body string
City string
HuntDate string
CreatedAt string
}
func (q *Queries) CreateQuestion(ctx context.Context, arg CreateQuestionParams) error {
_, err := q.db.ExecContext(ctx, createQuestion,
arg.ID,
arg.AuthorID,
arg.Title,
arg.Body,
arg.City,
arg.HuntDate,
arg.CreatedAt,
)
return err
}
const getQuestion = `-- name: GetQuestion :one
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
COALESCE((SELECT SUM(value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score,
CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered,
COALESCE((SELECT value FROM votes WHERE votes.user_id = $1 AND votes.question_id = q.id), 0)::bigint AS user_vote
FROM questions q
JOIN users u ON u.id = q.author_id
LEFT JOIN answers a ON a.question_id = q.id
WHERE q.id = $2
`
type GetQuestionParams struct {
UserID string
ID string
}
type GetQuestionRow struct {
ID string
AuthorID string
AuthorName string
Title string
Body string
City string
HuntDate string
Hidden int32
CreatedAt string
Score int64
Answered int64
UserVote int64
}
func (q *Queries) GetQuestion(ctx context.Context, arg GetQuestionParams) (GetQuestionRow, error) {
row := q.db.QueryRowContext(ctx, getQuestion, arg.UserID, arg.ID)
var i GetQuestionRow
err := row.Scan(
&i.ID,
&i.AuthorID,
&i.AuthorName,
&i.Title,
&i.Body,
&i.City,
&i.HuntDate,
&i.Hidden,
&i.CreatedAt,
&i.Score,
&i.Answered,
&i.UserVote,
)
return i, err
}
const hideQuestion = `-- name: HideQuestion :exec
UPDATE questions
SET hidden = 1
WHERE id = $1
`
func (q *Queries) HideQuestion(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, hideQuestion, id)
return err
}
const listHunt = `-- name: ListHunt :many
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
COALESCE(SUM(v.value), 0)::bigint AS score,
CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered,
COALESCE((SELECT value FROM votes WHERE votes.user_id = $1 AND votes.question_id = q.id), 0)::bigint AS user_vote
FROM questions q
JOIN users u ON u.id = q.author_id
LEFT JOIN votes v ON v.question_id = q.id
LEFT JOIN answers a ON a.question_id = q.id
WHERE q.hunt_date = $2 AND q.hidden = 0
GROUP BY q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, a.question_id
ORDER BY score DESC, q.created_at ASC
`
type ListHuntParams struct {
UserID string
HuntDate string
}
type ListHuntRow struct {
ID string
AuthorID string
AuthorName string
Title string
Body string
City string
HuntDate string
Hidden int32
CreatedAt string
Score int64
Answered int64
UserVote int64
}
func (q *Queries) ListHunt(ctx context.Context, arg ListHuntParams) ([]ListHuntRow, error) {
rows, err := q.db.QueryContext(ctx, listHunt, arg.UserID, arg.HuntDate)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListHuntRow{}
for rows.Next() {
var i ListHuntRow
if err := rows.Scan(
&i.ID,
&i.AuthorID,
&i.AuthorName,
&i.Title,
&i.Body,
&i.City,
&i.HuntDate,
&i.Hidden,
&i.CreatedAt,
&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 listQuestionsAnsweredBy = `-- name: ListQuestionsAnsweredBy :many
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
COALESCE((SELECT SUM(value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score,
1::bigint AS answered,
0::bigint AS user_vote
FROM answers ans
JOIN questions q ON q.id = ans.question_id
JOIN users u ON u.id = q.author_id
WHERE ans.author_id = $1 AND q.hidden = 0
ORDER BY ans.updated_at DESC
`
type ListQuestionsAnsweredByRow struct {
ID string
AuthorID string
AuthorName string
Title string
Body string
City string
HuntDate string
Hidden int32
CreatedAt string
Score int64
Answered int64
UserVote int64
}
func (q *Queries) ListQuestionsAnsweredBy(ctx context.Context, authorID string) ([]ListQuestionsAnsweredByRow, error) {
rows, err := q.db.QueryContext(ctx, listQuestionsAnsweredBy, authorID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListQuestionsAnsweredByRow{}
for rows.Next() {
var i ListQuestionsAnsweredByRow
if err := rows.Scan(
&i.ID,
&i.AuthorID,
&i.AuthorName,
&i.Title,
&i.Body,
&i.City,
&i.HuntDate,
&i.Hidden,
&i.CreatedAt,
&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 listQuestionsByAuthor = `-- name: ListQuestionsByAuthor :many
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
COALESCE((SELECT SUM(value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score,
CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered,
0::bigint AS user_vote
FROM questions q
JOIN users u ON u.id = q.author_id
LEFT JOIN answers a ON a.question_id = q.id
WHERE q.author_id = $1 AND q.hidden = 0
ORDER BY q.created_at DESC
`
type ListQuestionsByAuthorRow struct {
ID string
AuthorID string
AuthorName string
Title string
Body string
City string
HuntDate string
Hidden int32
CreatedAt string
Score int64
Answered int64
UserVote int64
}
func (q *Queries) ListQuestionsByAuthor(ctx context.Context, authorID string) ([]ListQuestionsByAuthorRow, error) {
rows, err := q.db.QueryContext(ctx, listQuestionsByAuthor, authorID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListQuestionsByAuthorRow{}
for rows.Next() {
var i ListQuestionsByAuthorRow
if err := rows.Scan(
&i.ID,
&i.AuthorID,
&i.AuthorName,
&i.Title,
&i.Body,
&i.City,
&i.HuntDate,
&i.Hidden,
&i.CreatedAt,
&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
}
+222
View File
@@ -0,0 +1,222 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: users.sql
package sqlc
import (
"context"
"database/sql"
)
const countAdmins = `-- name: CountAdmins :one
SELECT COUNT(*)::bigint AS count
FROM users
WHERE role = $1
`
func (q *Queries) CountAdmins(ctx context.Context, role string) (int64, error) {
row := q.db.QueryRowContext(ctx, countAdmins, role)
var count int64
err := row.Scan(&count)
return count, err
}
const createUser = `-- name: CreateUser :exec
INSERT INTO users (id, username, name, password_hash, role, avatar_url, state, created_at)
VALUES ($1, $2, $3, $4, $5, '', '', $6)
`
type CreateUserParams struct {
ID string
Username string
Name string
PasswordHash string
Role string
CreatedAt string
}
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) error {
_, err := q.db.ExecContext(ctx, createUser,
arg.ID,
arg.Username,
arg.Name,
arg.PasswordHash,
arg.Role,
arg.CreatedAt,
)
return err
}
const getUserByID = `-- name: GetUserByID :one
SELECT id, username, name, role, avatar_url, state, created_at
FROM users
WHERE id = $1
`
type GetUserByIDRow struct {
ID string
Username string
Name string
Role string
AvatarUrl string
State string
CreatedAt string
}
func (q *Queries) GetUserByID(ctx context.Context, id string) (GetUserByIDRow, error) {
row := q.db.QueryRowContext(ctx, getUserByID, id)
var i GetUserByIDRow
err := row.Scan(
&i.ID,
&i.Username,
&i.Name,
&i.Role,
&i.AvatarUrl,
&i.State,
&i.CreatedAt,
)
return i, err
}
const getUserByUsername = `-- name: GetUserByUsername :one
SELECT id, username, name, role, avatar_url, state, created_at, password_hash
FROM users
WHERE username = $1
`
type GetUserByUsernameRow struct {
ID string
Username string
Name string
Role string
AvatarUrl string
State string
CreatedAt string
PasswordHash string
}
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, error) {
row := q.db.QueryRowContext(ctx, getUserByUsername, username)
var i GetUserByUsernameRow
err := row.Scan(
&i.ID,
&i.Username,
&i.Name,
&i.Role,
&i.AvatarUrl,
&i.State,
&i.CreatedAt,
&i.PasswordHash,
)
return i, err
}
const getUserRole = `-- name: GetUserRole :one
SELECT role
FROM users
WHERE id = $1
`
func (q *Queries) GetUserRole(ctx context.Context, id string) (string, error) {
row := q.db.QueryRowContext(ctx, getUserRole, id)
var role string
err := row.Scan(&role)
return role, err
}
const listUsers = `-- name: ListUsers :many
SELECT id, username, name, role, avatar_url, state, created_at
FROM users
ORDER BY created_at ASC
`
type ListUsersRow struct {
ID string
Username string
Name string
Role string
AvatarUrl string
State string
CreatedAt string
}
func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) {
rows, err := q.db.QueryContext(ctx, listUsers)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListUsersRow{}
for rows.Next() {
var i ListUsersRow
if err := rows.Scan(
&i.ID,
&i.Username,
&i.Name,
&i.Role,
&i.AvatarUrl,
&i.State,
&i.CreatedAt,
); 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 updateUserRole = `-- name: UpdateUserRole :execresult
UPDATE users
SET role = $1
WHERE id = $2
`
type UpdateUserRoleParams struct {
Role string
ID string
}
func (q *Queries) UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (sql.Result, error) {
return q.db.ExecContext(ctx, updateUserRole, arg.Role, arg.ID)
}
const updateUserState = `-- name: UpdateUserState :exec
UPDATE users
SET state = $1
WHERE id = $2
`
type UpdateUserStateParams struct {
State string
ID string
}
func (q *Queries) UpdateUserState(ctx context.Context, arg UpdateUserStateParams) error {
_, err := q.db.ExecContext(ctx, updateUserState, arg.State, arg.ID)
return err
}
const updateUserStateAndAvatar = `-- name: UpdateUserStateAndAvatar :exec
UPDATE users
SET state = $1, avatar_url = $2
WHERE id = $3
`
type UpdateUserStateAndAvatarParams struct {
State string
AvatarUrl string
ID string
}
func (q *Queries) UpdateUserStateAndAvatar(ctx context.Context, arg UpdateUserStateAndAvatarParams) error {
_, err := q.db.ExecContext(ctx, updateUserStateAndAvatar, arg.State, arg.AvatarUrl, arg.ID)
return err
}
+61
View File
@@ -0,0 +1,61 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: votes.sql
package sqlc
import (
"context"
)
const deleteVote = `-- name: DeleteVote :exec
DELETE FROM votes
WHERE user_id = $1 AND question_id = $2
`
type DeleteVoteParams struct {
UserID string
QuestionID string
}
func (q *Queries) DeleteVote(ctx context.Context, arg DeleteVoteParams) error {
_, err := q.db.ExecContext(ctx, deleteVote, arg.UserID, arg.QuestionID)
return err
}
const getVote = `-- name: GetVote :one
SELECT value
FROM votes
WHERE user_id = $1 AND question_id = $2
`
type GetVoteParams struct {
UserID string
QuestionID string
}
func (q *Queries) GetVote(ctx context.Context, arg GetVoteParams) (int32, error) {
row := q.db.QueryRowContext(ctx, getVote, arg.UserID, arg.QuestionID)
var value int32
err := row.Scan(&value)
return value, err
}
const upsertVote = `-- name: UpsertVote :exec
INSERT INTO votes (user_id, question_id, value)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, question_id) DO UPDATE
SET value = excluded.value
`
type UpsertVoteParams struct {
UserID string
QuestionID string
Value int32
}
func (q *Queries) UpsertVote(ctx context.Context, arg UpsertVoteParams) error {
_, err := q.db.ExecContext(ctx, upsertVote, arg.UserID, arg.QuestionID, arg.Value)
return err
}
+58 -48
View File
@@ -9,6 +9,8 @@ import (
"time"
"github.com/google/uuid"
"plumber/internal/store/sqlc"
)
// ErrLastAdmin is returned when demoting the only remaining admin.
@@ -22,7 +24,7 @@ const (
RoleAdmin Role = "admin"
)
// User is an account row. Methods run SQL against db.
// User is an account row. Methods run SQL against db via sqlc.
type User struct {
ID string
Username string
@@ -48,6 +50,20 @@ func NormalizeUsername(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
func toUser(db *sql.DB, id, username, name, role, avatarURL, state, createdAt, passwordHash string) *User {
return &User{
ID: id,
Username: username,
Name: name,
Role: Role(role),
AvatarURL: avatarURL,
State: state,
CreatedAt: createdAt,
PasswordHash: passwordHash,
db: db,
}
}
// Create inserts the user. Sets ID, Name, and CreatedAt when empty.
func (u *User) Create(ctx context.Context) error {
if u == nil || u.db == nil {
@@ -66,9 +82,14 @@ func (u *User) Create(ctx context.Context) error {
if u.CreatedAt == "" {
u.CreatedAt = time.Now().UTC().Format(time.RFC3339)
}
_, err := u.db.ExecContext(ctx, `INSERT INTO users (id, username, name, password_hash, role, avatar_url, state, created_at) VALUES ($1, $2, $3, $4, $5, '', '', $6)`,
u.ID, u.Username, u.Name, u.PasswordHash, string(u.Role), u.CreatedAt)
return err
return sqlc.New(u.db).CreateUser(ctx, sqlc.CreateUserParams{
ID: u.ID,
Username: u.Username,
Name: u.Name,
PasswordHash: u.PasswordHash,
Role: string(u.Role),
CreatedAt: u.CreatedAt,
})
}
// SetRole updates this user's role (last-admin safe).
@@ -85,21 +106,24 @@ func (u *User) SetRole(ctx context.Context, role Role) error {
}
defer tx.Rollback()
var current string
err = tx.QueryRowContext(ctx, `SELECT role FROM users WHERE id = $1`, u.ID).Scan(&current)
q := sqlc.New(tx)
current, err := q.GetUserRole(ctx, u.ID)
if err != nil {
return err
}
if Role(current) == RoleAdmin && role == RoleUser {
var n int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = $1`, string(RoleAdmin)).Scan(&n); err != nil {
n, err := q.CountAdmins(ctx, string(RoleAdmin))
if err != nil {
return err
}
if n <= 1 {
return ErrLastAdmin
}
}
res, err := tx.ExecContext(ctx, `UPDATE users SET role = $1 WHERE id = $2`, string(role), u.ID)
res, err := q.UpdateUserRole(ctx, sqlc.UpdateUserRoleParams{
Role: string(role),
ID: u.ID,
})
if err != nil {
return err
}
@@ -123,61 +147,47 @@ func (u *User) SaveProfile(ctx context.Context) error {
return fmt.Errorf("user: no database")
}
u.State = strings.TrimSpace(u.State)
q := sqlc.New(u.db)
if u.AvatarURL == "" {
_, err := u.db.ExecContext(ctx, `UPDATE users SET state = $1 WHERE id = $2`, u.State, u.ID)
return err
return q.UpdateUserState(ctx, sqlc.UpdateUserStateParams{State: u.State, ID: u.ID})
}
_, err := u.db.ExecContext(ctx, `UPDATE users SET state = $1, avatar_url = $2 WHERE id = $3`, u.State, u.AvatarURL, u.ID)
return err
return q.UpdateUserStateAndAvatar(ctx, sqlc.UpdateUserStateAndAvatarParams{
State: u.State,
AvatarUrl: u.AvatarURL,
ID: u.ID,
})
}
func CountAdmins(ctx context.Context, db *sql.DB) (int, error) {
var n int
err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = $1`, string(RoleAdmin)).Scan(&n)
return n, err
n, err := sqlc.New(db).CountAdmins(ctx, string(RoleAdmin))
return int(n), err
}
func ListUsers(ctx context.Context, db *sql.DB) ([]User, error) {
rows, err := db.QueryContext(ctx, `SELECT id, username, name, role, avatar_url, state, created_at FROM users ORDER BY created_at ASC`)
rows, err := sqlc.New(db).ListUsers(ctx)
if err != nil {
return nil, err
}
defer rows.Close()
var out []User
for rows.Next() {
var u User
var role string
if err := rows.Scan(&u.ID, &u.Username, &u.Name, &role, &u.AvatarURL, &u.State, &u.CreatedAt); err != nil {
return nil, err
out := make([]User, 0, len(rows))
for _, r := range rows {
u := toUser(db, r.ID, r.Username, r.Name, r.Role, r.AvatarUrl, r.State, r.CreatedAt, "")
out = append(out, *u)
}
u.Role = Role(role)
u.db = db
out = append(out, u)
}
return out, rows.Err()
return out, nil
}
func UserByID(ctx context.Context, db *sql.DB, id string) (*User, error) {
return scanUser(db, db.QueryRowContext(ctx, `SELECT id, username, name, role, avatar_url, state, created_at FROM users WHERE id = $1`, id), false)
}
func UserByUsername(ctx context.Context, db *sql.DB, username string) (*User, error) {
return scanUser(db, db.QueryRowContext(ctx, `SELECT id, username, name, role, avatar_url, state, created_at, password_hash FROM users WHERE username = $1`, NormalizeUsername(username)), true)
}
func scanUser(db *sql.DB, row *sql.Row, withSecrets bool) (*User, error) {
var u User
var role string
var err error
if withSecrets {
err = row.Scan(&u.ID, &u.Username, &u.Name, &role, &u.AvatarURL, &u.State, &u.CreatedAt, &u.PasswordHash)
} else {
err = row.Scan(&u.ID, &u.Username, &u.Name, &role, &u.AvatarURL, &u.State, &u.CreatedAt)
}
r, err := sqlc.New(db).GetUserByID(ctx, id)
if err != nil {
return nil, err
}
u.Role = Role(role)
u.db = db
return &u, nil
return toUser(db, r.ID, r.Username, r.Name, r.Role, r.AvatarUrl, r.State, r.CreatedAt, ""), nil
}
func UserByUsername(ctx context.Context, db *sql.DB, username string) (*User, error) {
r, err := sqlc.New(db).GetUserByUsername(ctx, NormalizeUsername(username))
if err != nil {
return nil, err
}
return toUser(db, r.ID, r.Username, r.Name, r.Role, r.AvatarUrl, r.State, r.CreatedAt, r.PasswordHash), nil
}
+12 -6
View File
@@ -4,6 +4,8 @@ import (
"context"
"database/sql"
"fmt"
"plumber/internal/store/sqlc"
)
// Vote toggles or sets a user's vote on a question (value must be 1 or -1).
@@ -16,16 +18,20 @@ func Vote(ctx context.Context, db *sql.DB, userID, questionID string, value int)
return err
}
defer tx.Rollback()
var current sql.NullInt64
err = tx.QueryRowContext(ctx, `SELECT value FROM votes WHERE user_id = $1 AND question_id = $2`, userID, questionID).Scan(&current)
q := sqlc.New(tx)
current, err := q.GetVote(ctx, sqlc.GetVoteParams{UserID: userID, QuestionID: questionID})
if err != nil && err != sql.ErrNoRows {
return err
}
if err == nil && current.Valid && int(current.Int64) == value {
_, err = tx.ExecContext(ctx, `DELETE FROM votes WHERE user_id = $1 AND question_id = $2`, userID, questionID)
if err == nil && int(current) == value {
err = q.DeleteVote(ctx, sqlc.DeleteVoteParams{UserID: userID, QuestionID: questionID})
} else {
_, err = tx.ExecContext(ctx, `INSERT INTO votes (user_id, question_id, value) VALUES ($1, $2, $3)
ON CONFLICT (user_id, question_id) DO UPDATE SET value = excluded.value`, userID, questionID, value)
err = q.UpsertVote(ctx, sqlc.UpsertVoteParams{
UserID: userID,
QuestionID: questionID,
Value: int32(value),
})
}
if err != nil {
return err
+12
View File
@@ -0,0 +1,12 @@
version: "2"
sql:
- engine: "postgresql"
schema: "schema.sql"
queries: "db/queries"
gen:
go:
package: "sqlc"
out: "internal/store/sqlc"
sql_package: "database/sql"
emit_json_tags: false
emit_empty_slices: true