Files
aitrade/pkg/db/db.go
T
kaedwen ea141eb012
Build and Push Docker Image / build-and-push (push) Failing after 1m41s
initial
2026-07-02 20:09:44 +02:00

186 lines
5.0 KiB
Go

package db
import (
"context"
"database/sql"
"embed"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
"sort"
_ "modernc.org/sqlite"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
// Database is the interface for database operations
type Database interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error)
Close() error
}
type database struct {
db *sql.DB
logger *slog.Logger
}
func New(dbPath string, logger *slog.Logger) (Database, error) {
// Ensure data directory exists
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
return nil, fmt.Errorf("failed to create data directory: %w", err)
}
// Open database
// Note: Using modernc.org/sqlite (pure Go, no CGO) instead of mattn/go-sqlite3
db, err := sql.Open("sqlite", dbPath+"?_pragma=busy_timeout(5000)")
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
// Test connection
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("failed to ping database: %w", err)
}
dbInstance := &database{
db: db,
logger: logger,
}
// Run migrations
if err := dbInstance.migrate(); err != nil {
db.Close()
return nil, fmt.Errorf("failed to migrate: %w", err)
}
return dbInstance, nil
}
func (d *database) migrate() error {
d.logger.Info("running database migrations")
// Create schema_migrations table if not exists
_, err := d.db.Exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`)
if err != nil {
return fmt.Errorf("failed to create schema_migrations table: %w", err)
}
// Get already applied migrations
appliedMigrations := make(map[int]bool)
rows, err := d.db.Query("SELECT version FROM schema_migrations")
if err != nil {
return fmt.Errorf("failed to query schema_migrations: %w", err)
}
defer rows.Close()
for rows.Next() {
var version int
if err := rows.Scan(&version); err != nil {
return fmt.Errorf("failed to scan version: %w", err)
}
appliedMigrations[version] = true
}
// Read migrations from embedded filesystem
entries, err := fs.ReadDir(migrationsFS, "migrations")
if err != nil {
return fmt.Errorf("failed to read embedded migrations: %w", err)
}
// Sort migration files by name
var files []string
for _, entry := range entries {
if !entry.IsDir() && filepath.Ext(entry.Name()) == ".sql" {
files = append(files, entry.Name())
}
}
sort.Strings(files)
// Execute each migration
for _, filename := range files {
// Extract version from filename (e.g., "001_init.sql" -> 1)
var version int
if _, err := fmt.Sscanf(filename, "%d_", &version); err != nil {
d.logger.Warn("skipping migration with invalid name format", slog.String("file", filename))
continue
}
// Skip if already applied
if appliedMigrations[version] {
d.logger.Debug("skipping already applied migration", slog.String("file", filename), slog.Int("version", version))
continue
}
// Read migration file from embedded FS
content, err := migrationsFS.ReadFile(filepath.Join("migrations", filename))
if err != nil {
return fmt.Errorf("failed to read migration %s: %w", filename, err)
}
// Execute migration in a transaction
tx, err := d.db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction for migration %s: %w", filename, err)
}
if _, err := tx.Exec(string(content)); err != nil {
tx.Rollback()
return fmt.Errorf("failed to execute migration %s: %w", filename, err)
}
// Record migration
if _, err := tx.Exec("INSERT INTO schema_migrations (version) VALUES (?)", version); err != nil {
tx.Rollback()
return fmt.Errorf("failed to record migration %s: %w", filename, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit migration %s: %w", filename, err)
}
d.logger.Info("applied migration", slog.String("file", filename), slog.Int("version", version))
}
return nil
}
func (d *database) Close() error {
if d.db != nil {
return d.db.Close()
}
return nil
}
// ExecContext implements Database interface
func (d *database) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
return d.db.ExecContext(ctx, query, args...)
}
// QueryContext implements Database interface
func (d *database) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
return d.db.QueryContext(ctx, query, args...)
}
// QueryRowContext implements Database interface
func (d *database) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row {
return d.db.QueryRowContext(ctx, query, args...)
}
// BeginTx implements Database interface
func (d *database) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) {
return d.db.BeginTx(ctx, opts)
}