Datenbank von SQLite auf Postgres umgestellt
Verbindung über KVER_DSN; Tabelle user heißt jetzt account (reserviertes Wort in Postgres). kver import-sqlite übernimmt die alte Datei einmalig in einer Transaktion. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c6ab082f8c
commit
2714fdb1a5
@@ -45,7 +45,7 @@ const entrySelect = `
|
||||
e.reply_to, e.reply_count, e.last_activity, e.deleted,
|
||||
e.bump_count, e.last_bump,
|
||||
COALESCE(u.username, ''), COALESCE(u.avatar, '')
|
||||
FROM entry e LEFT JOIN user u ON u.uid = e.uid`
|
||||
FROM entry e LEFT JOIN account u ON u.uid = e.uid`
|
||||
|
||||
// scanEntries liest Entry-Zeilen aus einem Query mit entrySelect-Spalten.
|
||||
func scanEntries(rows *sql.Rows) ([]Entry, error) {
|
||||
@@ -66,7 +66,7 @@ func scanEntries(rows *sql.Rows) ([]Entry, error) {
|
||||
// entryByPID liefert einen einzelnen Beitrag oder sql.ErrNoRows.
|
||||
func entryByPID(pid int64) (Entry, error) {
|
||||
var e Entry
|
||||
err := db.QueryRow(entrySelect+` WHERE e.pid = ?`, pid).Scan(
|
||||
err := db.QueryRow(entrySelect+` WHERE e.pid = $1`, pid).Scan(
|
||||
&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath,
|
||||
&e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted,
|
||||
&e.BumpCount, &e.LastBump, &e.Username, &e.Avatar)
|
||||
@@ -75,8 +75,8 @@ func entryByPID(pid int64) (Entry, error) {
|
||||
|
||||
const (
|
||||
feedPageSize = 20
|
||||
// maxFeedPage deckelt den Offset: sehr große Seitenzahlen zwingen SQLite
|
||||
// sonst dazu, entsprechend viele Zeilen zu überspringen.
|
||||
// maxFeedPage deckelt den Offset: sehr große Seitenzahlen zwingen die
|
||||
// Datenbank sonst dazu, entsprechend viele Zeilen zu überspringen.
|
||||
maxFeedPage = 100
|
||||
)
|
||||
|
||||
@@ -85,8 +85,8 @@ func feedWhere(uid int64, rootsOnly bool) (string, []any) {
|
||||
args := []any{}
|
||||
where := []string{}
|
||||
if uid != 0 {
|
||||
where = append(where, "e.uid = ?")
|
||||
args = append(args, uid)
|
||||
where = append(where, fmt.Sprintf("e.uid = $%d", len(args)))
|
||||
}
|
||||
if rootsOnly {
|
||||
where = append(where, "e.reply_to = 0")
|
||||
@@ -115,7 +115,7 @@ func feedPage(page int, uid int64, rootsOnly bool) ([]Entry, error) {
|
||||
} else {
|
||||
query += ` ORDER BY e.created_at DESC`
|
||||
}
|
||||
query += ` LIMIT ? OFFSET ?`
|
||||
query += fmt.Sprintf(` LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
|
||||
args = append(args, feedPageSize, page*feedPageSize)
|
||||
|
||||
rows, err := db.Query(query, args...)
|
||||
@@ -138,7 +138,7 @@ func handleFeed(w http.ResponseWriter, r *http.Request) error {
|
||||
// handleUserFeed liefert den Feed eines einzelnen Nutzers (öffentlich, inkl. Antworten).
|
||||
func handleUserFeed(w http.ResponseWriter, r *http.Request) error {
|
||||
var uid int64
|
||||
if err := db.QueryRow(`SELECT uid FROM user WHERE username = ?`, chi.URLParam(r, "username")).Scan(&uid); err != nil {
|
||||
if err := db.QueryRow(`SELECT uid FROM account WHERE username = $1`, chi.URLParam(r, "username")).Scan(&uid); err != nil {
|
||||
return errUserNotFound
|
||||
}
|
||||
|
||||
@@ -216,11 +216,11 @@ func threadData(pid int64) (thread, error) {
|
||||
if entry.ReplyTo != 0 {
|
||||
rows, err := db.Query(
|
||||
`WITH RECURSIVE chain(pid, depth) AS (
|
||||
SELECT ?, 1
|
||||
SELECT $1::bigint, 1
|
||||
UNION ALL
|
||||
SELECT e.reply_to, c.depth + 1
|
||||
FROM entry e JOIN chain c ON e.pid = c.pid
|
||||
WHERE e.reply_to != 0 AND c.depth < 50
|
||||
WHERE e.reply_to <> 0 AND c.depth < 50
|
||||
)`+entrySelect+`
|
||||
JOIN chain c ON c.pid = e.pid
|
||||
ORDER BY c.depth DESC`, entry.ReplyTo)
|
||||
@@ -232,7 +232,7 @@ func threadData(pid int64) (thread, error) {
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := db.Query(entrySelect+` WHERE e.reply_to = ? ORDER BY e.last_activity DESC LIMIT 100`, pid)
|
||||
rows, err := db.Query(entrySelect+` WHERE e.reply_to = $1 ORDER BY e.last_activity DESC LIMIT 100`, pid)
|
||||
if err != nil {
|
||||
return thread{}, Internal(err)
|
||||
}
|
||||
@@ -286,22 +286,28 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
|
||||
return Internal(err)
|
||||
}
|
||||
// pid würfeln; +1, weil 0 der Sentinel für "kein Elternbeitrag"/"gelöschter
|
||||
// Autor" ist. Bei der seltenen UNIQUE-Kollision neu würfeln statt 500.
|
||||
// Autor" ist. Kollisionen über ON CONFLICT statt über den Fehler: ein
|
||||
// fehlgeschlagenes Statement bricht in Postgres die ganze Transaktion ab.
|
||||
var pid int64
|
||||
for try := 0; ; try++ {
|
||||
pid = int64(rand.IntN(999999999999)) + 1
|
||||
_, err := tx.Exec(
|
||||
res, err := tx.Exec(
|
||||
`INSERT INTO entry (pid, uid, created_at, content, filepath, reply_to, reply_count, last_activity)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, ?)`,
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 0, $7)
|
||||
ON CONFLICT (pid) DO NOTHING`,
|
||||
pid, uid, now, content, filepath, replyTo, now,
|
||||
)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if try >= 4 || !strings.Contains(err.Error(), "UNIQUE") {
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return Internal(err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 1 {
|
||||
break
|
||||
}
|
||||
if try >= 4 {
|
||||
tx.Rollback()
|
||||
return Internal(errors.New("pid-Kollision nach 5 Versuchen"))
|
||||
}
|
||||
}
|
||||
// Antwort: reply_count und last_activity aller Vorfahren bis zum Root
|
||||
// anheben -- per rekursiver CTE in einem Statement statt bis zu 50
|
||||
@@ -310,14 +316,14 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
|
||||
if replyTo != 0 {
|
||||
if _, err := tx.Exec(
|
||||
`WITH RECURSIVE chain(pid, depth) AS (
|
||||
SELECT ?, 0
|
||||
SELECT $1::bigint, 0
|
||||
UNION ALL
|
||||
SELECT e.reply_to, c.depth + 1
|
||||
FROM entry e JOIN chain c ON e.pid = c.pid
|
||||
WHERE e.reply_to != 0 AND c.depth < 50
|
||||
WHERE e.reply_to <> 0 AND c.depth < 50
|
||||
)
|
||||
UPDATE entry SET reply_count = reply_count + 1,
|
||||
last_activity = MAX(last_activity, ?)
|
||||
last_activity = GREATEST(last_activity, $2)
|
||||
WHERE pid IN (SELECT pid FROM chain)`,
|
||||
replyTo, now,
|
||||
); err != nil {
|
||||
@@ -355,7 +361,7 @@ func handleEditEntry(w http.ResponseWriter, r *http.Request) error {
|
||||
return Invalid("entry.empty", "Inhalt darf nicht leer sein.").At("content")
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`UPDATE entry SET content = ? WHERE pid = ?`, content, pid); err != nil {
|
||||
if _, err := db.Exec(`UPDATE entry SET content = $1 WHERE pid = $2`, content, pid); err != nil {
|
||||
return Internal(err)
|
||||
}
|
||||
|
||||
@@ -395,7 +401,7 @@ func handleDeleteEntry(w http.ResponseWriter, r *http.Request) error {
|
||||
}
|
||||
|
||||
if _, err := db.Exec(
|
||||
`UPDATE entry SET deleted = 1, content = '', filepath = '', uid = 0 WHERE pid = ?`, pid,
|
||||
`UPDATE entry SET deleted = 1, content = '', filepath = '', uid = 0 WHERE pid = $1`, pid,
|
||||
); err != nil {
|
||||
return Internal(err)
|
||||
}
|
||||
@@ -430,16 +436,16 @@ func handleBump(w http.ResponseWriter, r *http.Request) error {
|
||||
// gleichzeitige Bumps den Cooldown nicht umgehen können.
|
||||
now := time.Now().Unix()
|
||||
res, err := db.Exec(
|
||||
`UPDATE entry SET last_activity = ?, bump_count = bump_count + 1, last_bump = ?
|
||||
WHERE pid = ? AND deleted = 0 AND last_bump + bump_count * ? <= ?`,
|
||||
now, now, pid, bumpStep, now,
|
||||
`UPDATE entry SET last_activity = $1, bump_count = bump_count + 1, last_bump = $1
|
||||
WHERE pid = $2 AND deleted = 0 AND last_bump + bump_count * $3 <= $1`,
|
||||
now, pid, bumpStep,
|
||||
)
|
||||
if err != nil {
|
||||
return Internal(err)
|
||||
}
|
||||
|
||||
var bumpCount, lastBump, deleted int64
|
||||
if err := db.QueryRow(`SELECT bump_count, last_bump, deleted FROM entry WHERE pid = ?`, pid).
|
||||
if err := db.QueryRow(`SELECT bump_count, last_bump, deleted FROM entry WHERE pid = $1`, pid).
|
||||
Scan(&bumpCount, &lastBump, &deleted); err != nil || deleted != 0 {
|
||||
return errEntryNotFound
|
||||
}
|
||||
@@ -480,12 +486,12 @@ func voteTally(pid, uid int64) (left, right int, selected string) {
|
||||
selected = "none"
|
||||
if uid != 0 {
|
||||
var cur string
|
||||
if err := db.QueryRow(`SELECT mode FROM vote WHERE uid = ? AND pid = ?`, uid, pid).Scan(&cur); err == nil {
|
||||
if err := db.QueryRow(`SELECT mode FROM vote WHERE uid = $1 AND pid = $2`, uid, pid).Scan(&cur); err == nil {
|
||||
selected = cur
|
||||
}
|
||||
}
|
||||
// Beide Zähler in einem Query statt zwei COUNTs.
|
||||
rows, err := db.Query(`SELECT mode, COUNT(*) FROM vote WHERE pid = ? GROUP BY mode`, pid)
|
||||
rows, err := db.Query(`SELECT mode, COUNT(*) FROM vote WHERE pid = $1 GROUP BY mode`, pid)
|
||||
if err != nil {
|
||||
log.Printf("voteTally pid=%d: %v", pid, err)
|
||||
return
|
||||
@@ -508,82 +514,6 @@ func voteTally(pid, uid int64) (left, right int, selected string) {
|
||||
return
|
||||
}
|
||||
|
||||
// tally ist der Abstimmungsstand eines Beitrags aus Sicht eines Betrachters.
|
||||
type tallyState struct {
|
||||
Left int
|
||||
Right int
|
||||
Selected string // "left", "right" oder "none"
|
||||
}
|
||||
|
||||
// voteTallies holt die Stände für eine ganze Feed-Seite in zwei Abfragen statt
|
||||
// in zwei pro Beitrag. Bei 20 Beiträgen wären das sonst 40 Roundtrips für eine
|
||||
// einzige Seitenansicht.
|
||||
func voteTallies(pids []int64, uid int64) (map[int64]tallyState, error) {
|
||||
out := make(map[int64]tallyState, len(pids))
|
||||
for _, pid := range pids {
|
||||
out[pid] = tallyState{Selected: "none"}
|
||||
}
|
||||
if len(pids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(pids)), ",")
|
||||
args := make([]any, 0, len(pids)+1)
|
||||
for _, pid := range pids {
|
||||
args = append(args, pid)
|
||||
}
|
||||
|
||||
rows, err := db.Query(
|
||||
`SELECT pid, mode, COUNT(*) FROM vote WHERE pid IN (`+placeholders+`) GROUP BY pid, mode`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var pid int64
|
||||
var mode string
|
||||
var n int
|
||||
if err := rows.Scan(&pid, &mode, &n); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
t := out[pid]
|
||||
switch mode {
|
||||
case "left":
|
||||
t.Left = n
|
||||
case "right":
|
||||
t.Right = n
|
||||
}
|
||||
out[pid] = t
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Eigene Stimmen nur, wenn überhaupt jemand angemeldet ist.
|
||||
if uid == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, err = db.Query(
|
||||
`SELECT pid, mode FROM vote WHERE uid = ? AND pid IN (`+placeholders+`)`,
|
||||
append([]any{uid}, args...)...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var pid int64
|
||||
var mode string
|
||||
if err := rows.Scan(&pid, &mode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t := out[pid]
|
||||
t.Selected = mode
|
||||
out[pid] = t
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func writeTally(w http.ResponseWriter, pid, uid int64) {
|
||||
left, right, selected := voteTally(pid, uid)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
@@ -625,7 +555,7 @@ func handleReport(w http.ResponseWriter, r *http.Request) error {
|
||||
}
|
||||
reason := truncateRunes(strings.TrimSpace(r.FormValue("reason")), 500)
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO report (pid, uid, reason, created_at, handled) VALUES (?, 0, ?, ?, 0)`,
|
||||
`INSERT INTO report (pid, uid, reason, created_at, handled) VALUES ($1, 0, $2, $3, 0)`,
|
||||
pid, reason, time.Now().Unix(),
|
||||
); err != nil {
|
||||
return Internal(err)
|
||||
@@ -660,14 +590,14 @@ func handleVote(w http.ResponseWriter, r *http.Request) error {
|
||||
// (parallele Requests konnten den Toggle sonst doppelt anwenden):
|
||||
// 1. Gleiche Stimme löschen -> hat das getroffen, war es ein Toggle-off.
|
||||
// 2. Sonst Upsert: neue Stimme anlegen bzw. bestehende ummodeln.
|
||||
res, err := db.Exec(`DELETE FROM vote WHERE uid = ? AND pid = ? AND mode = ?`, uid, pid, mode)
|
||||
res, err := db.Exec(`DELETE FROM vote WHERE uid = $1 AND pid = $2 AND mode = $3`, uid, pid, mode)
|
||||
if err != nil {
|
||||
return Internal(err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO vote (uid, pid, mode) VALUES (?, ?, ?)
|
||||
ON CONFLICT(uid, pid) DO UPDATE SET mode = excluded.mode`,
|
||||
`INSERT INTO vote (uid, pid, mode) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (uid, pid) DO UPDATE SET mode = excluded.mode`,
|
||||
uid, pid, mode,
|
||||
); err != nil {
|
||||
return Internal(err)
|
||||
|
||||
Reference in New Issue
Block a user