mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-22 07:32:32 +01:00
feat: add SQLite support, setup wizard enhancements, and live dashboard
Add zero-dependency SQLite mode so users can run Erupe without
PostgreSQL. A transparent db.DB wrapper auto-translates PostgreSQL
SQL ($N placeholders, now(), ::casts, ILIKE, public. prefix,
TRUNCATE) for SQLite at runtime — all 28 repo files use the wrapper
with no per-query changes needed.
Setup wizard gains two new steps: quest file detection with download
link, and gameplay presets (solo/small/community/rebalanced). The API
server gets a /dashboard endpoint with auto-refreshing stats.
CI release workflow now builds and pushes Docker images to GHCR
alongside binary artifacts on tag push.
Key changes:
- common/db: DB/Tx wrapper with 6 SQL translation rules
- server/migrations/sqlite: full SQLite schema (0001-0005)
- config: Database.Driver field ("postgres" or "sqlite")
- main.go: SQLite connection with WAL mode, single writer
- server/setup: quest check + preset selection steps
- server/api: /dashboard with live stats
- .github/workflows: Docker in release, deduplicate docker.yml
This commit is contained in:
68
common/db/adapter.go
Normal file
68
common/db/adapter.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// Package db provides a database adapter that transparently translates
|
||||
// PostgreSQL-style SQL to the active driver's dialect.
|
||||
//
|
||||
// When the driver is "sqlite", queries are rewritten on the fly:
|
||||
// - $1, $2, ... → ?, ?, ...
|
||||
// - now() → CURRENT_TIMESTAMP
|
||||
// - ::type casts → removed
|
||||
// - ILIKE → LIKE (SQLite LIKE is case-insensitive for ASCII)
|
||||
package db
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// IsSQLite reports whether the given sqlx.DB is backed by a SQLite driver.
|
||||
func IsSQLite(db *sqlx.DB) bool {
|
||||
return db.DriverName() == "sqlite" || db.DriverName() == "sqlite3"
|
||||
}
|
||||
|
||||
// Adapt rewrites a PostgreSQL query for the active driver.
|
||||
// For Postgres it's a no-op. For SQLite it translates placeholders and
|
||||
// Postgres-specific syntax.
|
||||
func Adapt(db *sqlx.DB, query string) string {
|
||||
if !IsSQLite(db) {
|
||||
return query
|
||||
}
|
||||
return AdaptSQL(query)
|
||||
}
|
||||
|
||||
// castRe matches Postgres type casts like ::int, ::text, ::timestamptz,
|
||||
// ::character varying, etc.
|
||||
// castRe matches Postgres type casts: ::int, ::text, ::timestamptz,
|
||||
// ::character varying(N), etc. The space is allowed only when followed
|
||||
// by a word char (e.g. "character varying") to avoid eating trailing spaces.
|
||||
var castRe = regexp.MustCompile(`::[a-zA-Z_]\w*(?:\s+\w+)*(?:\([^)]*\))?`)
|
||||
|
||||
// dollarParamRe matches Postgres-style positional parameters: $1, $2, etc.
|
||||
var dollarParamRe = regexp.MustCompile(`\$\d+`)
|
||||
|
||||
// AdaptSQL translates a PostgreSQL query to SQLite-compatible SQL.
|
||||
// Exported so it can be tested without a real DB connection.
|
||||
func AdaptSQL(query string) string {
|
||||
// 1. Replace now() with CURRENT_TIMESTAMP
|
||||
query = strings.ReplaceAll(query, "now()", "CURRENT_TIMESTAMP")
|
||||
query = strings.ReplaceAll(query, "NOW()", "CURRENT_TIMESTAMP")
|
||||
|
||||
// 2. Strip Postgres type casts (::int, ::text, ::timestamptz, etc.)
|
||||
query = castRe.ReplaceAllString(query, "")
|
||||
|
||||
// 3. ILIKE → LIKE (SQLite LIKE is case-insensitive for ASCII by default)
|
||||
query = strings.ReplaceAll(query, " ILIKE ", " LIKE ")
|
||||
query = strings.ReplaceAll(query, " ilike ", " LIKE ")
|
||||
|
||||
// 4. Strip "public." schema prefix (SQLite has no schemas)
|
||||
query = strings.ReplaceAll(query, "public.", "")
|
||||
|
||||
// 5. TRUNCATE → DELETE FROM (SQLite has no TRUNCATE)
|
||||
query = strings.ReplaceAll(query, "TRUNCATE ", "DELETE FROM ")
|
||||
query = strings.ReplaceAll(query, "truncate ", "DELETE FROM ")
|
||||
|
||||
// 6. Replace $1,$2,... → ?,?,...
|
||||
query = dollarParamRe.ReplaceAllString(query, "?")
|
||||
|
||||
return query
|
||||
}
|
||||
83
common/db/adapter_test.go
Normal file
83
common/db/adapter_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAdaptSQL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "placeholder rebind",
|
||||
input: "SELECT * FROM users WHERE id=$1 AND name=$2",
|
||||
want: "SELECT * FROM users WHERE id=? AND name=?",
|
||||
},
|
||||
{
|
||||
name: "now() replacement",
|
||||
input: "UPDATE characters SET guild_post_checked=now() WHERE id=$1",
|
||||
want: "UPDATE characters SET guild_post_checked=CURRENT_TIMESTAMP WHERE id=?",
|
||||
},
|
||||
{
|
||||
name: "type cast removal",
|
||||
input: "UPDATE users SET frontier_points=frontier_points::int - $1 WHERE id=$2 RETURNING frontier_points",
|
||||
want: "UPDATE users SET frontier_points=frontier_points - ? WHERE id=? RETURNING frontier_points",
|
||||
},
|
||||
{
|
||||
name: "text cast removal",
|
||||
input: "SELECT COALESCE(friends, ''::text) FROM characters WHERE id=$1",
|
||||
want: "SELECT COALESCE(friends, '') FROM characters WHERE id=?",
|
||||
},
|
||||
{
|
||||
name: "timestamptz cast removal",
|
||||
input: "SELECT COALESCE(created_at, '2000-01-01'::timestamptz) FROM guilds WHERE id=$1",
|
||||
want: "SELECT COALESCE(created_at, '2000-01-01') FROM guilds WHERE id=?",
|
||||
},
|
||||
{
|
||||
name: "ILIKE to LIKE",
|
||||
input: "SELECT * FROM characters WHERE name ILIKE $1",
|
||||
want: "SELECT * FROM characters WHERE name LIKE ?",
|
||||
},
|
||||
{
|
||||
name: "character varying cast",
|
||||
input: "DEFAULT ''::character varying",
|
||||
want: "DEFAULT ''",
|
||||
},
|
||||
{
|
||||
name: "no changes for standard SQL",
|
||||
input: "SELECT COUNT(*) FROM users",
|
||||
want: "SELECT COUNT(*) FROM users",
|
||||
},
|
||||
{
|
||||
name: "NOW uppercase",
|
||||
input: "INSERT INTO events (start_time) VALUES (NOW())",
|
||||
want: "INSERT INTO events (start_time) VALUES (CURRENT_TIMESTAMP)",
|
||||
},
|
||||
{
|
||||
name: "multi-digit params",
|
||||
input: "INSERT INTO t (a,b,c) VALUES ($1,$2,$10)",
|
||||
want: "INSERT INTO t (a,b,c) VALUES (?,?,?)",
|
||||
},
|
||||
{
|
||||
name: "public schema prefix",
|
||||
input: "INSERT INTO public.distributions_accepted VALUES ($1, $2)",
|
||||
want: "INSERT INTO distributions_accepted VALUES (?, ?)",
|
||||
},
|
||||
{
|
||||
name: "TRUNCATE to DELETE FROM",
|
||||
input: "TRUNCATE public.cafebonus;",
|
||||
want: "DELETE FROM cafebonus;",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := AdaptSQL(tc.input)
|
||||
if got != tc.want {
|
||||
t.Errorf("\ngot: %s\nwant: %s", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
149
common/db/db.go
Normal file
149
common/db/db.go
Normal file
@@ -0,0 +1,149 @@
|
||||
// Package db provides a transparent database wrapper that rewrites
|
||||
// PostgreSQL-style SQL for SQLite when needed.
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// DB wraps *sqlx.DB and transparently adapts PostgreSQL queries for SQLite.
|
||||
// For PostgreSQL, all methods are simple pass-throughs with zero overhead.
|
||||
type DB struct {
|
||||
*sqlx.DB
|
||||
sqlite bool
|
||||
}
|
||||
|
||||
// Wrap creates a DB wrapper around an existing *sqlx.DB connection.
|
||||
// Returns nil if db is nil.
|
||||
func Wrap(db *sqlx.DB) *DB {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
return &DB{
|
||||
DB: db,
|
||||
sqlite: IsSQLite(db),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DB) adapt(query string) string {
|
||||
if !d.sqlite {
|
||||
return query
|
||||
}
|
||||
return AdaptSQL(query)
|
||||
}
|
||||
|
||||
// Exec executes a query without returning any rows.
|
||||
func (d *DB) Exec(query string, args ...interface{}) (sql.Result, error) {
|
||||
return d.DB.Exec(d.adapt(query), args...)
|
||||
}
|
||||
|
||||
// ExecContext executes a query without returning any rows.
|
||||
func (d *DB) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {
|
||||
return d.DB.ExecContext(ctx, d.adapt(query), args...)
|
||||
}
|
||||
|
||||
// Query executes a query that returns rows.
|
||||
func (d *DB) Query(query string, args ...interface{}) (*sql.Rows, error) {
|
||||
return d.DB.Query(d.adapt(query), args...)
|
||||
}
|
||||
|
||||
// QueryContext executes a query that returns rows.
|
||||
func (d *DB) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
|
||||
return d.DB.QueryContext(ctx, d.adapt(query), args...)
|
||||
}
|
||||
|
||||
// QueryRow executes a query that is expected to return at most one row.
|
||||
func (d *DB) QueryRow(query string, args ...interface{}) *sql.Row {
|
||||
return d.DB.QueryRow(d.adapt(query), args...)
|
||||
}
|
||||
|
||||
// QueryRowContext executes a query that is expected to return at most one row.
|
||||
func (d *DB) QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row {
|
||||
return d.DB.QueryRowContext(ctx, d.adapt(query), args...)
|
||||
}
|
||||
|
||||
// Get queries a single row and scans it into dest.
|
||||
func (d *DB) Get(dest interface{}, query string, args ...interface{}) error {
|
||||
return d.DB.Get(dest, d.adapt(query), args...)
|
||||
}
|
||||
|
||||
// Select queries multiple rows and scans them into dest.
|
||||
func (d *DB) Select(dest interface{}, query string, args ...interface{}) error {
|
||||
return d.DB.Select(dest, d.adapt(query), args...)
|
||||
}
|
||||
|
||||
// Queryx executes a query that returns sqlx.Rows.
|
||||
func (d *DB) Queryx(query string, args ...interface{}) (*sqlx.Rows, error) {
|
||||
return d.DB.Queryx(d.adapt(query), args...)
|
||||
}
|
||||
|
||||
// QueryRowx executes a query that returns a sqlx.Row.
|
||||
func (d *DB) QueryRowx(query string, args ...interface{}) *sqlx.Row {
|
||||
return d.DB.QueryRowx(d.adapt(query), args...)
|
||||
}
|
||||
|
||||
// QueryRowxContext executes a query that returns a sqlx.Row.
|
||||
func (d *DB) QueryRowxContext(ctx context.Context, query string, args ...interface{}) *sqlx.Row {
|
||||
return d.DB.QueryRowxContext(ctx, d.adapt(query), args...)
|
||||
}
|
||||
|
||||
// BeginTxx starts a new transaction with context and options.
|
||||
// The returned Tx wrapper adapts queries the same way as DB.
|
||||
func (d *DB) BeginTxx(ctx context.Context, opts *sql.TxOptions) (*Tx, error) {
|
||||
tx, err := d.DB.BeginTxx(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Tx{Tx: tx, sqlite: d.sqlite}, nil
|
||||
}
|
||||
|
||||
// Tx wraps *sqlx.Tx and transparently adapts PostgreSQL queries for SQLite.
|
||||
type Tx struct {
|
||||
*sqlx.Tx
|
||||
sqlite bool
|
||||
}
|
||||
|
||||
func (t *Tx) adapt(query string) string {
|
||||
if !t.sqlite {
|
||||
return query
|
||||
}
|
||||
return AdaptSQL(query)
|
||||
}
|
||||
|
||||
// Exec executes a query without returning any rows.
|
||||
func (t *Tx) Exec(query string, args ...interface{}) (sql.Result, error) {
|
||||
return t.Tx.Exec(t.adapt(query), args...)
|
||||
}
|
||||
|
||||
// ExecContext executes a query without returning any rows.
|
||||
func (t *Tx) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {
|
||||
return t.Tx.ExecContext(ctx, t.adapt(query), args...)
|
||||
}
|
||||
|
||||
// Query executes a query that returns rows.
|
||||
func (t *Tx) Query(query string, args ...interface{}) (*sql.Rows, error) {
|
||||
return t.Tx.Query(t.adapt(query), args...)
|
||||
}
|
||||
|
||||
// QueryRow executes a query that is expected to return at most one row.
|
||||
func (t *Tx) QueryRow(query string, args ...interface{}) *sql.Row {
|
||||
return t.Tx.QueryRow(t.adapt(query), args...)
|
||||
}
|
||||
|
||||
// Queryx executes a query that returns sqlx.Rows.
|
||||
func (t *Tx) Queryx(query string, args ...interface{}) (*sqlx.Rows, error) {
|
||||
return t.Tx.Queryx(t.adapt(query), args...)
|
||||
}
|
||||
|
||||
// QueryRowx executes a query that returns a sqlx.Row.
|
||||
func (t *Tx) QueryRowx(query string, args ...interface{}) *sqlx.Row {
|
||||
return t.Tx.QueryRowx(t.adapt(query), args...)
|
||||
}
|
||||
|
||||
// Get queries a single row and scans it into dest.
|
||||
func (t *Tx) Get(dest interface{}, query string, args ...interface{}) error {
|
||||
return t.Tx.Get(dest, t.adapt(query), args...)
|
||||
}
|
||||
Reference in New Issue
Block a user