From 0d5fb99347dc73d685e012f77235ef1ddb468c72 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 13 Sep 2026 16:16:42 +0200 Subject: [PATCH] pg --- .env.example | 1 + backend/db.go | 102 ++++++++++++++++++++++++++++++++++++++++---- backend/go.mod | 1 + backend/go.sum | 2 + backend/handlers.go | 8 ++-- backend/main.go | 4 +- 6 files changed, 103 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index 08ad6ba..622ec08 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ # sqlite (default): sqlite:///data/skeps.db # mysql: mysql://user:password@tcp(mysql:3306)/skeps +# postgres: postgres://user:password@postgres:5432/skeps?sslmode=disable DATABASE_URL=sqlite:///data/skeps.db diff --git a/backend/db.go b/backend/db.go index 68b8376..f9c03e5 100644 --- a/backend/db.go +++ b/backend/db.go @@ -3,9 +3,11 @@ package main import ( "database/sql" "fmt" + "strconv" "strings" _ "github.com/go-sql-driver/mysql" + _ "github.com/lib/pq" _ "modernc.org/sqlite" ) @@ -58,10 +60,88 @@ CREATE TABLE IF NOT EXISTS items ( FOREIGN KEY (sublist_id) REFERENCES sublists(id) );` -func openDB(databaseURL string) (*sql.DB, error) { +const postgresSchema = ` +CREATE TABLE IF NOT EXISTS lists ( + id TEXT PRIMARY KEY, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS sublists ( + id SERIAL PRIMARY KEY, + list_id TEXT NOT NULL REFERENCES lists(id), + name TEXT NOT NULL, + color TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS items ( + id SERIAL PRIMARY KEY, + list_id TEXT NOT NULL REFERENCES lists(id), + name TEXT NOT NULL, + quantity INTEGER NOT NULL DEFAULT 1, + done BOOLEAN NOT NULL DEFAULT FALSE, + position DOUBLE PRECISION NOT NULL DEFAULT 0, + sublist_id INTEGER REFERENCES sublists(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +);` + +// querier is the subset of *sql.DB every handler uses. Postgres needs its own +// implementation since, unlike sqlite and mysql, it takes $1-style positional +// placeholders instead of "?" and has no LastInsertId support. +type querier interface { + Exec(query string, args ...any) (sql.Result, error) + Query(query string, args ...any) (*sql.Rows, error) + QueryRow(query string, args ...any) *sql.Row + insertReturningID(query string, args ...any) (int64, error) +} + +type stdDB struct{ *sql.DB } + +func (d stdDB) insertReturningID(query string, args ...any) (int64, error) { + res, err := d.DB.Exec(query, args...) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +type pgDB struct{ *sql.DB } + +func rebindPositional(query string) string { + var b strings.Builder + n := 0 + for _, r := range query { + if r == '?' { + n++ + b.WriteByte('$') + b.WriteString(strconv.Itoa(n)) + } else { + b.WriteRune(r) + } + } + return b.String() +} + +func (d pgDB) Exec(query string, args ...any) (sql.Result, error) { + return d.DB.Exec(rebindPositional(query), args...) +} + +func (d pgDB) Query(query string, args ...any) (*sql.Rows, error) { + return d.DB.Query(rebindPositional(query), args...) +} + +func (d pgDB) QueryRow(query string, args ...any) *sql.Row { + return d.DB.QueryRow(rebindPositional(query), args...) +} + +func (d pgDB) insertReturningID(query string, args ...any) (int64, error) { + var id int64 + err := d.DB.QueryRow(rebindPositional(query)+" RETURNING id", args...).Scan(&id) + return id, err +} + +func openDB(databaseURL string) (*sql.DB, querier, error) { driver, dsn, found := strings.Cut(databaseURL, "://") if !found { - return nil, fmt.Errorf("DATABASE_URL must be in the form driver://dsn, got %q", databaseURL) + return nil, nil, fmt.Errorf("DATABASE_URL must be in the form driver://dsn, got %q", databaseURL) } var schema string @@ -69,23 +149,26 @@ func openDB(databaseURL string) (*sql.DB, error) { case "sqlite": schema = sqliteSchema case "mysql": - driver = "mysql" schema = mysqlSchema + case "postgres", "postgresql": + driver = "postgres" + dsn = databaseURL // lib/pq expects the full URL, scheme included + schema = postgresSchema default: - return nil, fmt.Errorf("unsupported database driver %q, expected sqlite or mysql", driver) + return nil, nil, fmt.Errorf("unsupported database driver %q, expected sqlite, mysql or postgres", driver) } db, err := sql.Open(driver, dsn) if err != nil { - return nil, fmt.Errorf("opening database: %w", err) + return nil, nil, fmt.Errorf("opening database: %w", err) } if err := db.Ping(); err != nil { - return nil, fmt.Errorf("connecting to database: %w", err) + return nil, nil, fmt.Errorf("connecting to database: %w", err) } if _, err := db.Exec(schema); err != nil { - return nil, fmt.Errorf("running migration: %w", err) + return nil, nil, fmt.Errorf("running migration: %w", err) } // Best-effort: adds columns for databases created before they existed. @@ -93,5 +176,8 @@ func openDB(databaseURL string) (*sql.DB, error) { db.Exec("ALTER TABLE items ADD COLUMN position REAL NOT NULL DEFAULT 0") db.Exec("ALTER TABLE items ADD COLUMN sublist_id INTEGER") - return db, nil + if driver == "postgres" { + return db, pgDB{db}, nil + } + return db, stdDB{db}, nil } diff --git a/backend/go.mod b/backend/go.mod index c56c7fe..42d2c13 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -13,6 +13,7 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/lib/pq v1.12.3 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect diff --git a/backend/go.sum b/backend/go.sum index e29e954..0ab9066 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -12,6 +12,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= diff --git a/backend/handlers.go b/backend/handlers.go index 762c627..e4f5cc8 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -31,7 +31,7 @@ type Sublist struct { } type api struct { - db *sql.DB + db querier hub *hub } @@ -132,12 +132,11 @@ func (a *api) createSublist(w http.ResponseWriter, r *http.Request) { return } - res, err := a.db.Exec("INSERT INTO sublists (list_id, name, color) VALUES (?, ?, ?)", listID, in.Name, in.Color) + id, err := a.db.insertReturningID("INSERT INTO sublists (list_id, name, color) VALUES (?, ?, ?)", listID, in.Name, in.Color) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } - id, _ := res.LastInsertId() var s Sublist err = a.db.QueryRow("SELECT id, name, color, created_at FROM sublists WHERE id = ?", id). @@ -238,12 +237,11 @@ func (a *api) createItem(w http.ResponseWriter, r *http.Request) { } position := maxPosition.Float64 + 1 - res, err := a.db.Exec("INSERT INTO items (list_id, name, quantity, position, sublist_id) VALUES (?, ?, ?, ?, ?)", listID, in.Name, in.Quantity, position, in.SublistID) + id, err := a.db.insertReturningID("INSERT INTO items (list_id, name, quantity, position, sublist_id) VALUES (?, ?, ?, ?, ?)", listID, in.Name, in.Quantity, position, in.SublistID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } - id, _ := res.LastInsertId() var it Item err = a.db.QueryRow("SELECT id, name, quantity, done, position, sublist_id, created_at FROM items WHERE id = ?", id). diff --git a/backend/main.go b/backend/main.go index a274e81..b0b0ded 100644 --- a/backend/main.go +++ b/backend/main.go @@ -12,11 +12,11 @@ func main() { databaseURL = "sqlite://./skeps.db" } - db, err := openDB(databaseURL) + rawDB, db, err := openDB(databaseURL) if err != nil { log.Fatalf("database: %v", err) } - defer db.Close() + defer rawDB.Close() a := &api{db: db, hub: newHub()}