mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-10 14:27:00 +08:00
feat: add SQLite/PostgreSQL database switching support
This commit is contained in:
240
store/driver.go
Normal file
240
store/driver.go
Normal file
@@ -0,0 +1,240 @@
|
||||
// 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
|
||||
}
|
||||
@@ -7,13 +7,12 @@ import (
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"sync"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Store unified data storage interface
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
db *sql.DB
|
||||
driver *DBDriver // Database driver for abstraction
|
||||
|
||||
// Sub-stores (lazy initialization)
|
||||
user *UserStore
|
||||
@@ -34,57 +33,56 @@ type Store struct {
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// New creates new Store instance
|
||||
// New creates new Store instance (SQLite mode for backward compatibility)
|
||||
func New(dbPath string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
driver, err := NewDBDriver(DBConfig{Type: DBTypeSQLite, Path: dbPath})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open 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 (traditional mode) to ensure Docker bind mount compatibility
|
||||
// Note: WAL mode causes data sync issues on macOS Docker
|
||||
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)
|
||||
}
|
||||
|
||||
s := &Store{db: db}
|
||||
s := &Store{db: driver.DB(), driver: driver}
|
||||
|
||||
// Initialize all table structures
|
||||
if err := s.initTables(); err != nil {
|
||||
db.Close()
|
||||
driver.Close()
|
||||
return nil, fmt.Errorf("failed to initialize table structure: %w", err)
|
||||
}
|
||||
|
||||
// Initialize default data
|
||||
if err := s.initDefaultData(); err != nil {
|
||||
db.Close()
|
||||
driver.Close()
|
||||
return nil, fmt.Errorf("failed to initialize default data: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("✅ Database enabled DELETE mode and FULL sync")
|
||||
logger.Infof("✅ Database initialized (type: %s)", driver.Type)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// NewFromEnv creates new Store instance 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 NewFromEnv() (*Store, error) {
|
||||
driver, err := NewDBDriverFromEnv()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
s := &Store{db: driver.DB(), driver: driver}
|
||||
|
||||
// Initialize all table structures
|
||||
if err := s.initTables(); err != nil {
|
||||
driver.Close()
|
||||
return nil, fmt.Errorf("failed to initialize table structure: %w", err)
|
||||
}
|
||||
|
||||
// Initialize default data
|
||||
if err := s.initDefaultData(); err != nil {
|
||||
driver.Close()
|
||||
return nil, fmt.Errorf("failed to initialize default data: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✅ Database initialized (type: %s)", driver.Type)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
@@ -293,9 +291,25 @@ func (s *Store) Order() *OrderStore {
|
||||
|
||||
// Close closes database connection
|
||||
func (s *Store) Close() error {
|
||||
if s.driver != nil {
|
||||
return s.driver.Close()
|
||||
}
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// Driver returns database driver for abstraction
|
||||
func (s *Store) Driver() *DBDriver {
|
||||
return s.driver
|
||||
}
|
||||
|
||||
// DBType returns current database type
|
||||
func (s *Store) DBType() DBType {
|
||||
if s.driver != nil {
|
||||
return s.driver.Type
|
||||
}
|
||||
return DBTypeSQLite
|
||||
}
|
||||
|
||||
// DB gets underlying database connection (for legacy code compatibility, gradually deprecated)
|
||||
// Deprecated: use Store methods instead
|
||||
func (s *Store) DB() *sql.DB {
|
||||
|
||||
Reference in New Issue
Block a user