mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 01:48:22 +08:00
2d272bb7b8
- Migrate all store packages from raw database/sql to GORM ORM - Add PostgreSQL support alongside SQLite - Move EncryptedString type to crypto package for cleaner architecture - Add automatic encryption/decryption for sensitive fields (API keys, secrets) - Fix PostgreSQL AutoMigrate conflicts by skipping existing tables - Fix duplicate /klines route registration - Update tests to use GORM database connections - Add database configuration support in config package
282 lines
7.1 KiB
Go
282 lines
7.1 KiB
Go
// Package store provides database driver abstraction
|
|
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
_ "github.com/lib/pq" // PostgreSQL driver
|
|
_ "modernc.org/sqlite" // SQLite driver
|
|
)
|
|
|
|
// DBType represents database type
|
|
type DBType string
|
|
|
|
const (
|
|
DBTypeSQLite DBType = "sqlite"
|
|
DBTypePostgres DBType = "postgres"
|
|
)
|
|
|
|
// DBConfig database configuration
|
|
type DBConfig struct {
|
|
Type DBType // sqlite or postgres
|
|
Path string // SQLite file path (for sqlite)
|
|
Host string // PostgreSQL host (for postgres)
|
|
Port int // PostgreSQL port (for postgres)
|
|
User string // PostgreSQL user (for postgres)
|
|
Password string // PostgreSQL password (for postgres)
|
|
DBName string // PostgreSQL database name (for postgres)
|
|
SSLMode string // PostgreSQL SSL mode (for postgres)
|
|
}
|
|
|
|
// DBDriver database driver abstraction
|
|
type DBDriver struct {
|
|
Type DBType
|
|
db *sql.DB
|
|
}
|
|
|
|
// NewDBDriver creates database driver from config
|
|
func NewDBDriver(cfg DBConfig) (*DBDriver, error) {
|
|
var db *sql.DB
|
|
var err error
|
|
|
|
switch cfg.Type {
|
|
case DBTypeSQLite:
|
|
db, err = openSQLite(cfg.Path)
|
|
case DBTypePostgres:
|
|
db, err = openPostgres(cfg)
|
|
default:
|
|
return nil, fmt.Errorf("unsupported database type: %s", cfg.Type)
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &DBDriver{Type: cfg.Type, db: db}, nil
|
|
}
|
|
|
|
// NewDBDriverFromEnv creates database driver from environment variables
|
|
// DB_TYPE: sqlite (default) or postgres
|
|
// For SQLite: DB_PATH (default: data/data.db)
|
|
// For PostgreSQL: DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME, DB_SSLMODE
|
|
func NewDBDriverFromEnv() (*DBDriver, error) {
|
|
dbType := DBType(strings.ToLower(getEnv("DB_TYPE", "sqlite")))
|
|
|
|
switch dbType {
|
|
case DBTypeSQLite:
|
|
path := getEnv("DB_PATH", "data/data.db")
|
|
return NewDBDriver(DBConfig{Type: DBTypeSQLite, Path: path})
|
|
|
|
case DBTypePostgres:
|
|
port := 5432
|
|
if p := os.Getenv("DB_PORT"); p != "" {
|
|
fmt.Sscanf(p, "%d", &port)
|
|
}
|
|
return NewDBDriver(DBConfig{
|
|
Type: DBTypePostgres,
|
|
Host: getEnv("DB_HOST", "localhost"),
|
|
Port: port,
|
|
User: getEnv("DB_USER", "postgres"),
|
|
Password: os.Getenv("DB_PASSWORD"),
|
|
DBName: getEnv("DB_NAME", "nofx"),
|
|
SSLMode: getEnv("DB_SSLMODE", "disable"),
|
|
})
|
|
|
|
default:
|
|
return nil, fmt.Errorf("unsupported DB_TYPE: %s (use 'sqlite' or 'postgres')", dbType)
|
|
}
|
|
}
|
|
|
|
// DB returns underlying database connection
|
|
func (d *DBDriver) DB() *sql.DB {
|
|
return d.db
|
|
}
|
|
|
|
// Close closes database connection
|
|
func (d *DBDriver) Close() error {
|
|
return d.db.Close()
|
|
}
|
|
|
|
// AutoIncrement returns auto-increment syntax for current database
|
|
func (d *DBDriver) AutoIncrement() string {
|
|
switch d.Type {
|
|
case DBTypePostgres:
|
|
return "SERIAL"
|
|
default:
|
|
return "INTEGER PRIMARY KEY AUTOINCREMENT"
|
|
}
|
|
}
|
|
|
|
// Placeholder returns placeholder for parameterized queries
|
|
// SQLite uses ?, PostgreSQL uses $1, $2, etc.
|
|
func (d *DBDriver) Placeholder(index int) string {
|
|
switch d.Type {
|
|
case DBTypePostgres:
|
|
return fmt.Sprintf("$%d", index)
|
|
default:
|
|
return "?"
|
|
}
|
|
}
|
|
|
|
// ConvertPlaceholders converts ? placeholders to database-specific format
|
|
func (d *DBDriver) ConvertPlaceholders(query string) string {
|
|
if d.Type != DBTypePostgres {
|
|
return query
|
|
}
|
|
|
|
// Convert ? to $1, $2, etc. for PostgreSQL
|
|
result := query
|
|
index := 1
|
|
for strings.Contains(result, "?") {
|
|
result = strings.Replace(result, "?", fmt.Sprintf("$%d", index), 1)
|
|
index++
|
|
}
|
|
return result
|
|
}
|
|
|
|
// TableExists checks if a table exists
|
|
func (d *DBDriver) TableExists(tableName string) (bool, error) {
|
|
var exists bool
|
|
var query string
|
|
|
|
switch d.Type {
|
|
case DBTypePostgres:
|
|
query = `SELECT EXISTS (
|
|
SELECT FROM information_schema.tables
|
|
WHERE table_schema = 'public' AND table_name = $1
|
|
)`
|
|
default:
|
|
query = `SELECT EXISTS (
|
|
SELECT 1 FROM sqlite_master
|
|
WHERE type = 'table' AND name = ?
|
|
)`
|
|
}
|
|
|
|
query = d.ConvertPlaceholders(query)
|
|
err := d.db.QueryRow(query, tableName).Scan(&exists)
|
|
return exists, err
|
|
}
|
|
|
|
// UpsertSyntax returns the upsert syntax for current database
|
|
// SQLite: INSERT ... ON CONFLICT(...) DO UPDATE SET ...
|
|
// PostgreSQL: INSERT ... ON CONFLICT(...) DO UPDATE SET ...
|
|
// Both use the same syntax in modern versions
|
|
func (d *DBDriver) UpsertSyntax() string {
|
|
return "ON CONFLICT"
|
|
}
|
|
|
|
// openSQLite opens SQLite database
|
|
func openSQLite(path string) (*sql.DB, error) {
|
|
db, err := sql.Open("sqlite", path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open SQLite database: %w", err)
|
|
}
|
|
|
|
// SQLite configuration
|
|
db.SetMaxOpenConns(1)
|
|
db.SetMaxIdleConns(1)
|
|
|
|
// Enable foreign key constraints
|
|
if _, err := db.Exec(`PRAGMA foreign_keys = ON`); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("failed to enable foreign keys: %w", err)
|
|
}
|
|
|
|
// Use DELETE mode for Docker compatibility
|
|
if _, err := db.Exec("PRAGMA journal_mode=DELETE"); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("failed to set journal_mode: %w", err)
|
|
}
|
|
|
|
// Set synchronous=FULL
|
|
if _, err := db.Exec("PRAGMA synchronous=FULL"); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("failed to set synchronous: %w", err)
|
|
}
|
|
|
|
// Set busy_timeout
|
|
if _, err := db.Exec("PRAGMA busy_timeout = 5000"); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("failed to set busy_timeout: %w", err)
|
|
}
|
|
|
|
return db, nil
|
|
}
|
|
|
|
// openPostgres opens PostgreSQL database
|
|
func openPostgres(cfg DBConfig) (*sql.DB, error) {
|
|
connStr := fmt.Sprintf(
|
|
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
|
|
cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode,
|
|
)
|
|
|
|
db, err := sql.Open("postgres", connStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open PostgreSQL database: %w", err)
|
|
}
|
|
|
|
// PostgreSQL configuration
|
|
db.SetMaxOpenConns(25)
|
|
db.SetMaxIdleConns(5)
|
|
|
|
// Test connection
|
|
if err := db.Ping(); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("failed to connect to PostgreSQL: %w", err)
|
|
}
|
|
|
|
return db, nil
|
|
}
|
|
|
|
// getEnv gets environment variable with default value
|
|
func getEnv(key, defaultValue string) string {
|
|
if value := os.Getenv(key); value != "" {
|
|
return value
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
// convertQuery converts ? placeholders to $1, $2 for PostgreSQL
|
|
// and handles other database-specific syntax differences
|
|
func convertQuery(query string, dbType DBType) string {
|
|
if dbType != DBTypePostgres {
|
|
return query
|
|
}
|
|
result := query
|
|
|
|
// Convert ? to $1, $2, etc. for PostgreSQL
|
|
index := 1
|
|
for strings.Contains(result, "?") {
|
|
result = strings.Replace(result, "?", fmt.Sprintf("$%d", index), 1)
|
|
index++
|
|
}
|
|
|
|
// Convert datetime('now') to CURRENT_TIMESTAMP
|
|
result = strings.ReplaceAll(result, "datetime('now')", "CURRENT_TIMESTAMP")
|
|
|
|
// Remove datetime() wrapper for ORDER BY (PostgreSQL timestamps sort correctly)
|
|
// This handles patterns like "ORDER BY datetime(column) DESC"
|
|
result = strings.ReplaceAll(result, "datetime(updated_at)", "updated_at")
|
|
result = strings.ReplaceAll(result, "datetime(created_at)", "created_at")
|
|
|
|
return result
|
|
}
|
|
|
|
// boolDefault returns database-appropriate boolean default for COALESCE
|
|
// Use in queries like: COALESCE(column, %s)
|
|
func boolDefault(dbType DBType, value bool) string {
|
|
if dbType == DBTypePostgres {
|
|
if value {
|
|
return "TRUE"
|
|
}
|
|
return "FALSE"
|
|
}
|
|
if value {
|
|
return "1"
|
|
}
|
|
return "0"
|
|
}
|