add support for SQLite as an alternative database backend
Some checks failed
Build & Publish Docker Image / build-and-push (push) Failing after 33s

Implement SQLite support using the pure Go `modernc.org/sqlite` driver and update the migration system to handle driver-specific schemas. Users can now choose between MySQL and SQLite by setting the `DB_DRIVER` environment variable.
This commit is contained in:
Flavio Fois
2026-03-29 17:46:27 +02:00
parent e6d663f4f2
commit fa1f65baf7
14 changed files with 317 additions and 79 deletions

View File

@@ -6,13 +6,20 @@ API_DOMAIN=api.esempio.com
ACME_EMAIL=tua@email.com ACME_EMAIL=tua@email.com
MYSQL_ROOT_PASSWORD=password-sicura MYSQL_ROOT_PASSWORD=password-sicura
# DB Settings (usato per sviluppo locale; in Docker il DSN è costruito dal compose) # DB Settings
# DB_DRIVER: "mysql" (default) o "sqlite"
DB_DRIVER=mysql
# MySQL
DB_DSN=root:secret@tcp(127.0.0.1:3306)/emly?parseTime=true&loc=UTC DB_DSN=root:secret@tcp(127.0.0.1:3306)/emly?parseTime=true&loc=UTC
DB_MAX_OPEN_CONNS=25 DB_MAX_OPEN_CONNS=25
DB_MAX_IDLE_CONNS=5 DB_MAX_IDLE_CONNS=5
DB_CONN_MAX_LIFETIME=5 DB_CONN_MAX_LIFETIME=5
DATABASE_NAME=emly DATABASE_NAME=emly
# SQLite (usare invece di MySQL: DB_DRIVER=sqlite, DB_DSN=./data.db, DATABASE_NAME non necessario)
# DB_DSN=./data.db
# API Keys # API Keys
API_KEY=key-one API_KEY=key-one
ADMIN_KEY=admin-key-one ADMIN_KEY=admin-key-one

3
.gitignore vendored
View File

@@ -39,3 +39,6 @@ go.work.sum
tmp/ tmp/
build/ build/
# Database files
*.db

13
go.mod
View File

@@ -10,7 +10,18 @@ require (
golang.org/x/crypto v0.49.0 golang.org/x/crypto v0.49.0
) )
require golang.org/x/sys v0.42.0 // indirect require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.42.0 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.47.0 // indirect
)
require ( require (
filippo.io/edwards25519 v1.1.1 // indirect filippo.io/edwards25519 v1.1.1 // indirect

19
go.sum
View File

@@ -2,6 +2,8 @@ filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4= github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/httprate v0.14.1 h1:EKZHYEZ58Cg6hWcYzoZILsv7ppb46Wt4uQ738IRtpZs= github.com/go-chi/httprate v0.14.1 h1:EKZHYEZ58Cg6hWcYzoZILsv7ppb46Wt4uQ738IRtpZs=
@@ -9,17 +11,34 @@ github.com/go-chi/httprate v0.14.1/go.mod h1:TUepLXaz/pCjmCtf/obgOQJ2Sz6rC8fSf5c
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
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/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0= github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
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/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=

View File

@@ -21,6 +21,7 @@ type RateLimitConfig struct {
type Config struct { type Config struct {
Port string Port string
Driver string
DSN string DSN string
Database string Database string
APIKey string APIKey string
@@ -80,10 +81,20 @@ func load() *Config {
connMaxLifetime = 5 connMaxLifetime = 5
} }
dbName := os.Getenv("DATABASE_NAME") driver := os.Getenv("DB_DRIVER")
if driver == "" {
driver = "mysql"
}
var dbName string
if driver == "sqlite" {
dbName = "main"
} else {
dbName = os.Getenv("DATABASE_NAME")
if dbName == "" { if dbName == "" {
panic("DATABASE_NAME environment variable is required") panic("DATABASE_NAME environment variable is required")
} }
}
if os.Getenv("DB_DSN") == "" { if os.Getenv("DB_DSN") == "" {
panic("DB_DSN environment variable is required") panic("DB_DSN environment variable is required")
@@ -91,6 +102,7 @@ func load() *Config {
return &Config{ return &Config{
Port: port, Port: port,
Driver: driver,
DSN: os.Getenv("DB_DSN"), DSN: os.Getenv("DB_DSN"),
Database: dbName, Database: dbName,
APIKey: apiKey, APIKey: apiKey,

View File

@@ -1,23 +1,41 @@
package database package database
import ( import (
"fmt"
"time" "time"
_ "github.com/go-sql-driver/mysql" _ "github.com/go-sql-driver/mysql"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
_ "modernc.org/sqlite"
"emly-api-go/internal/config" "emly-api-go/internal/config"
) )
func Connect(cfg *config.Config) (*sqlx.DB, error) { func Connect(cfg *config.Config) (*sqlx.DB, error) {
db, err := sqlx.Connect("mysql", cfg.DSN) var db *sqlx.DB
var err error
switch cfg.Driver {
case "sqlite":
db, err = sqlx.Connect("sqlite", cfg.DSN)
if err != nil {
return nil, err
}
// Enable foreign key support (disabled by default in SQLite)
if _, err = db.Exec("PRAGMA foreign_keys = ON"); err != nil {
return nil, fmt.Errorf("sqlite: enable foreign_keys: %w", err)
}
case "mysql":
db, err = sqlx.Connect("mysql", cfg.DSN)
if err != nil { if err != nil {
return nil, err return nil, err
} }
db.SetMaxOpenConns(cfg.MaxOpenConns) db.SetMaxOpenConns(cfg.MaxOpenConns)
db.SetMaxIdleConns(cfg.MaxIdleConns) db.SetMaxIdleConns(cfg.MaxIdleConns)
db.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Minute) db.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Minute)
default:
return nil, fmt.Errorf("unsupported DB_DRIVER %q: must be mysql or sqlite", cfg.Driver)
}
return db, nil return db, nil
} }

View File

@@ -10,7 +10,7 @@ import (
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
//go:embed init.sql migrations/*.json migrations/*.sql //go:embed mysql sqlite
var migrationsFS embed.FS var migrationsFS embed.FS
type taskFile struct { type taskFile struct {
@@ -31,62 +31,43 @@ type condition struct {
Index string `json:"index,omitempty"` Index string `json:"index,omitempty"`
} }
// Migrate reads migrations/tasks.json and executes every task whose // Migrate reads the driver-specific migrations and applies them.
// conditions are ALL satisfied (i.e. logical AND). func Migrate(db *sqlx.DB, dbName string, driver string) error {
func Migrate(db *sqlx.DB, dbName string) error { empty, err := schemaIsEmpty(db, dbName, driver)
// If the database has no tables at all, bootstrap with init.sql.
empty, err := schemaIsEmpty(db, dbName)
if err != nil { if err != nil {
return fmt.Errorf("schema: check empty: %w", err) return fmt.Errorf("schema: check empty: %w", err)
} }
if empty { if empty {
log.Println("[migrate] empty schema detected running init.sql") log.Println("[migrate] empty schema detected running init.sql")
initSQL, err := migrationsFS.ReadFile("init.sql") if err := runInitSQL(db, driver); err != nil {
if err != nil { return err
return fmt.Errorf("schema: read init.sql: %w", err)
} }
for _, stmt := range splitStatements(string(initSQL)) {
if _, err := db.Exec(stmt); err != nil {
return fmt.Errorf("schema: exec init.sql: %w\nSQL: %s", err, stmt)
}
}
log.Println("[migrate] init.sql applied base schema created")
} else { } else {
log.Println("[migrate] checking if tables exist") log.Println("[migrate] checking if tables exist")
// Check if the tables are there or not tableNames := []string{"bug_reports", "bug_report_files", "rate_limit_hwid", "user", "session"}
var tableNames []string
var foundTables []string var foundTables []string
tableNames = append(tableNames, "bug_reports", "bug_report_files", "rate_limit_hwid", "user", "session")
for _, tableName := range tableNames { for _, tableName := range tableNames {
found, err := tableExists(db, dbName, tableName) found, err := tableExists(db, dbName, tableName, driver)
if err != nil { if err != nil {
return fmt.Errorf("schema: check table %s: %w", tableName, err) return fmt.Errorf("schema: check table %s: %w", tableName, err)
} }
if !found { if !found {
log.Printf("[migrate] warning: expected table %s not found schema may be in an inconsistent state", tableName) log.Printf("[migrate] warning: expected table %s not found", tableName)
continue continue
} }
foundTables = append(foundTables, tableName) foundTables = append(foundTables, tableName)
} }
if len(foundTables) != len(tableNames) { if len(foundTables) != len(tableNames) {
log.Printf("[migrate] warning: expected %d tables, found %d", len(tableNames), len(foundTables)) log.Printf("[migrate] warning: expected %d tables, found %d running init.sql", len(tableNames), len(foundTables))
log.Printf("[migrate] info: running init.sql") if err := runInitSQL(db, driver); err != nil {
initSQL, err := migrationsFS.ReadFile("init.sql") return err
if err != nil {
return fmt.Errorf("schema: read init.sql: %w", err)
} }
for _, stmt := range splitStatements(string(initSQL)) {
if _, err := db.Exec(stmt); err != nil {
return fmt.Errorf("schema: exec init.sql: %w\nSQL: %s", err, stmt)
}
}
log.Println("[migrate] init.sql applied base schema created")
} else { } else {
log.Println("[migrate] all expected tables found skipping init.sql") log.Println("[migrate] all expected tables found skipping init.sql")
} }
} }
raw, err := migrationsFS.ReadFile("migrations/tasks.json") raw, err := migrationsFS.ReadFile(driver + "/migrations/tasks.json")
if err != nil { if err != nil {
return fmt.Errorf("schema: read tasks.json: %w", err) return fmt.Errorf("schema: read tasks.json: %w", err)
} }
@@ -97,7 +78,7 @@ func Migrate(db *sqlx.DB, dbName string) error {
} }
for _, t := range tf.Tasks { for _, t := range tf.Tasks {
needed, err := shouldRun(db, dbName, t.Conditions) needed, err := shouldRun(db, dbName, t.Conditions, driver)
if err != nil { if err != nil {
return fmt.Errorf("schema: evaluate conditions for %s: %w", t.ID, err) return fmt.Errorf("schema: evaluate conditions for %s: %w", t.ID, err)
} }
@@ -106,7 +87,7 @@ func Migrate(db *sqlx.DB, dbName string) error {
continue continue
} }
sqlBytes, err := migrationsFS.ReadFile("migrations/" + t.SQLFile) sqlBytes, err := migrationsFS.ReadFile(driver + "/migrations/" + t.SQLFile)
if err != nil { if err != nil {
return fmt.Errorf("schema: read %s: %w", t.SQLFile, err) return fmt.Errorf("schema: read %s: %w", t.SQLFile, err)
} }
@@ -122,11 +103,25 @@ func Migrate(db *sqlx.DB, dbName string) error {
return nil return nil
} }
func runInitSQL(db *sqlx.DB, driver string) error {
initSQL, err := migrationsFS.ReadFile(driver + "/init.sql")
if err != nil {
return fmt.Errorf("schema: read init.sql: %w", err)
}
for _, stmt := range splitStatements(string(initSQL)) {
if _, err := db.Exec(stmt); err != nil {
return fmt.Errorf("schema: exec init.sql: %w\nSQL: %s", err, stmt)
}
}
log.Println("[migrate] init.sql applied base schema created")
return nil
}
// ---------- Condition evaluator ---------- // ---------- Condition evaluator ----------
func shouldRun(db *sqlx.DB, dbName string, conds []condition) (bool, error) { func shouldRun(db *sqlx.DB, dbName string, conds []condition, driver string) (bool, error) {
for _, c := range conds { for _, c := range conds {
met, err := evaluate(db, dbName, c) met, err := evaluate(db, dbName, c, driver)
if err != nil { if err != nil {
return false, err return false, err
} }
@@ -137,81 +132,186 @@ func shouldRun(db *sqlx.DB, dbName string, conds []condition) (bool, error) {
return false, nil return false, nil
} }
func evaluate(db *sqlx.DB, dbName string, c condition) (bool, error) { func evaluate(db *sqlx.DB, dbName string, c condition, driver string) (bool, error) {
switch c.Type { switch c.Type {
case "column_not_exists": case "column_not_exists":
exists, err := columnExists(db, dbName, c.Table, c.Column) exists, err := columnExists(db, dbName, c.Table, c.Column, driver)
return !exists, err return !exists, err
case "column_exists": case "column_exists":
return columnExists(db, dbName, c.Table, c.Column) return columnExists(db, dbName, c.Table, c.Column, driver)
case "index_not_exists": case "index_not_exists":
exists, err := indexExists(db, dbName, c.Table, c.Index) exists, err := indexExists(db, dbName, c.Table, c.Index, driver)
return !exists, err return !exists, err
case "index_exists": case "index_exists":
return indexExists(db, dbName, c.Table, c.Index) return indexExists(db, dbName, c.Table, c.Index, driver)
case "table_not_exists": case "table_not_exists":
exists, err := tableExists(db, dbName, c.Table) exists, err := tableExists(db, dbName, c.Table, driver)
return !exists, err return !exists, err
case "table_exists": case "table_exists":
return tableExists(db, dbName, c.Table) return tableExists(db, dbName, c.Table, driver)
default: default:
return false, fmt.Errorf("unknown condition type: %s", c.Type) return false, fmt.Errorf("unknown condition type: %s", c.Type)
} }
} }
func columnExists(db *sqlx.DB, dbName, table, column string) (bool, error) { // ---------- MySQL condition checks ----------
func columnExistsMySQL(db *sqlx.DB, dbName, table, column string) (bool, error) {
var count int var count int
err := db.Get(&count, err := db.Get(&count,
`SELECT COUNT(*) FROM information_schema.COLUMNS `SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = ? WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?`,
AND TABLE_NAME = ? dbName, table, column)
AND COLUMN_NAME = ?`, dbName, table, column)
return count > 0, err return count > 0, err
} }
func indexExists(db *sqlx.DB, dbName, table, index string) (bool, error) { func indexExistsMySQL(db *sqlx.DB, dbName, table, index string) (bool, error) {
var count int var count int
err := db.Get(&count, err := db.Get(&count,
`SELECT COUNT(*) FROM information_schema.STATISTICS `SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = ? WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND INDEX_NAME = ?`,
AND TABLE_NAME = ? dbName, table, index)
AND INDEX_NAME = ?`, dbName, table, index)
return count > 0, err return count > 0, err
} }
func tableExists(db *sqlx.DB, dbName, table string) (bool, error) { func tableExistsMySQL(db *sqlx.DB, dbName, table string) (bool, error) {
var count int var count int
err := db.Get(&count, err := db.Get(&count,
`SELECT COUNT(*) FROM information_schema.TABLES `SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = ? WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?`,
AND TABLE_NAME = ?`, dbName, table) dbName, table)
return count > 0, err return count > 0, err
} }
func schemaIsEmpty(db *sqlx.DB, dbName string) (bool, error) { func schemaIsEmptyMySQL(db *sqlx.DB, dbName string) (bool, error) {
var count int var count int
err := db.Get(&count, err := db.Get(&count,
`SELECT COUNT(*) FROM information_schema.TABLES `SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = ?`, dbName)
WHERE TABLE_SCHEMA = ?`, dbName)
return count == 0, err return count == 0, err
} }
// splitStatements splits a SQL blob on ";" respecting only top-level // ---------- SQLite condition checks ----------
// semicolons (good enough for simple ALTER / CREATE statements).
func splitStatements(sql string) []string { func columnExistsSQLite(db *sqlx.DB, table, column string) (bool, error) {
raw := strings.Split(sql, ";") var count int
out := make([]string, 0, len(raw)) // pragma_table_info is a table-valued function available since SQLite 3.16.0
for _, s := range raw { err := db.Get(&count,
s = strings.TrimSpace(s) fmt.Sprintf("SELECT COUNT(*) FROM pragma_table_info('%s') WHERE name = ?", table),
if s != "" { column)
out = append(out, s) return count > 0, err
}
func indexExistsSQLite(db *sqlx.DB, table, index string) (bool, error) {
var count int
err := db.Get(&count,
`SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND tbl_name=? AND name=?`,
table, index)
return count > 0, err
}
func tableExistsSQLite(db *sqlx.DB, table string) (bool, error) {
var count int
err := db.Get(&count,
`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, table)
return count > 0, err
}
func schemaIsEmptySQLite(db *sqlx.DB) (bool, error) {
var count int
err := db.Get(&count, `SELECT COUNT(*) FROM sqlite_master WHERE type='table'`)
return count == 0, err
}
// ---------- Driver-dispatched wrappers ----------
func columnExists(db *sqlx.DB, dbName, table, column, driver string) (bool, error) {
if driver == "sqlite" {
return columnExistsSQLite(db, table, column)
} }
return columnExistsMySQL(db, dbName, table, column)
}
func indexExists(db *sqlx.DB, dbName, table, index, driver string) (bool, error) {
if driver == "sqlite" {
return indexExistsSQLite(db, table, index)
}
return indexExistsMySQL(db, dbName, table, index)
}
func tableExists(db *sqlx.DB, dbName, table, driver string) (bool, error) {
if driver == "sqlite" {
return tableExistsSQLite(db, table)
}
return tableExistsMySQL(db, dbName, table)
}
func schemaIsEmpty(db *sqlx.DB, dbName, driver string) (bool, error) {
if driver == "sqlite" {
return schemaIsEmptySQLite(db)
}
return schemaIsEmptyMySQL(db, dbName)
}
// splitStatements splits a SQL blob on top-level ";" only, respecting
// BEGIN...END blocks (e.g. triggers) so their inner semicolons are not split.
func splitStatements(sql string) []string {
var out []string
var buf strings.Builder
depth := 0
n := len(sql)
for i := 0; i < n; {
c := sql[i]
// Collect whole identifier tokens to detect BEGIN / END keywords.
if isIdentStart(c) {
j := i
for j < n && isIdentChar(sql[j]) {
j++
}
word := strings.ToUpper(sql[i:j])
switch word {
case "BEGIN":
depth++
case "END":
if depth > 0 {
depth--
}
}
buf.WriteString(sql[i:j])
i = j
continue
}
if c == ';' && depth == 0 {
if stmt := strings.TrimSpace(buf.String()); stmt != "" {
out = append(out, stmt)
}
buf.Reset()
i++
continue
}
buf.WriteByte(c)
i++
}
if stmt := strings.TrimSpace(buf.String()); stmt != "" {
out = append(out, stmt)
} }
return out return out
} }
func isIdentStart(c byte) bool {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'
}
func isIdentChar(c byte) bool {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'
}

View File

@@ -0,0 +1,65 @@
CREATE TABLE IF NOT EXISTS bug_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL,
description TEXT NOT NULL,
hwid TEXT NOT NULL DEFAULT '',
hostname TEXT NOT NULL DEFAULT '',
os_user TEXT NOT NULL DEFAULT '',
submitter_ip TEXT NOT NULL DEFAULT '',
system_info TEXT NULL,
status TEXT NOT NULL DEFAULT 'new' CHECK(status IN ('new','in_review','resolved','closed')),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_status ON bug_reports(status);
CREATE INDEX IF NOT EXISTS idx_hwid ON bug_reports(hwid);
CREATE INDEX IF NOT EXISTS idx_hostname ON bug_reports(hostname);
CREATE INDEX IF NOT EXISTS idx_os_user ON bug_reports(os_user);
CREATE INDEX IF NOT EXISTS idx_created_at ON bug_reports(created_at);
CREATE TRIGGER IF NOT EXISTS trg_bug_reports_updated_at
AFTER UPDATE ON bug_reports
FOR EACH ROW
WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE bug_reports SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;
CREATE TABLE IF NOT EXISTS bug_report_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
report_id INTEGER NOT NULL,
file_role TEXT NOT NULL CHECK(file_role IN ('screenshot','mail_file','localstorage','config','system_info')),
filename TEXT NOT NULL,
mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
file_size INTEGER NOT NULL DEFAULT 0,
data BLOB NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (report_id) REFERENCES bug_reports(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_report_id ON bug_report_files(report_id);
CREATE TABLE IF NOT EXISTS rate_limit_hwid (
hwid TEXT PRIMARY KEY,
window_start DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS user (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin','user')),
enabled INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
displayname TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS session (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at DATETIME NOT NULL,
FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE
);

View File

@@ -0,0 +1,3 @@
{
"tasks": []
}

View File

@@ -42,7 +42,7 @@ func main() {
}(db) }(db)
// Run conditional schema migrations // Run conditional schema migrations
if err := schema.Migrate(db, cfg.Database); err != nil { if err := schema.Migrate(db, cfg.Database, cfg.Driver); err != nil {
log.Fatalf("schema migration failed: %v", err) log.Fatalf("schema migration failed: %v", err)
} }