Files
yellowjacket/backend/database/database.go
T

143 lines
3.5 KiB
Go

// Package database provides SQLite database access.
package database
import (
"context"
"database/sql"
"embed"
"fmt"
"io/fs"
"log/slog"
"path"
_ "modernc.org/sqlite" // Register sqlite driver.
"yellowjacket/backend/database/sql/sqlcgen"
"yellowjacket/backend/profiling"
"yellowjacket/backend/system"
)
//go:generate go tool sqlc generate
//go:embed sql/schemas/*.sql
var schemas embed.FS
// DB wraps the SQLite database connection and queries.
type DB struct {
db *sql.DB
Ctx context.Context
Queries *sqlcgen.Queries
logger *slog.Logger
}
// NewDB opens the database and applies schema migrations.
func NewDB(logger *slog.Logger) (*DB, error) {
defer profiling.TimeOp(logger, "database.NewDB")()
dbCtx := context.Background()
userDataDir, err := system.GetUserDataDirPath()
if err != nil {
return nil, fmt.Errorf("could not get user data directory: %w", err)
}
sqliteDBFilePath := path.Join(userDataDir, "yj.db")
logger.Debug("opening sqlite database", "filepath", sqliteDBFilePath)
db, err := sql.Open("sqlite", sqliteDBFilePath+"?_busy_timeout=5000&_journal_mode=WAL")
if err != nil {
return nil, fmt.Errorf("could not connect to sqlite database: %w", err)
}
db.SetMaxOpenConns(1) // SQLite only supports one writer at a time
// Enable foreign key enforcement — SQLite disables it by
// default, which means ON DELETE CASCADE will not work without
// this pragma.
if _, err := db.ExecContext(
dbCtx, "PRAGMA foreign_keys = ON",
); err != nil {
return nil, fmt.Errorf(
"could not enable foreign keys: %w", err,
)
}
// Execute SQL files from the embedded schemas directory
logger.Debug("reading sql schema files from embedded directory")
dirEntries, err := schemas.ReadDir("sql/schemas")
if err != nil {
return nil, fmt.Errorf("could not read schemas directory: %w", err)
}
logger.Debug("executing all sql schema files")
for _, dirEntry := range dirEntries {
if !dirEntry.IsDir() {
filePath := path.Join("sql/schemas", dirEntry.Name())
sqlContent, err := fs.ReadFile(schemas, filePath)
if err != nil {
return nil, fmt.Errorf("could not read file %s: %w", filePath, err)
}
logger.Debug(
"executing sql schema file",
"filepath",
filePath,
"sql",
string(sqlContent),
)
_, err = db.ExecContext(dbCtx, string(sqlContent)) // Execute the SQL
if err != nil {
return nil, fmt.Errorf("error executing sql from file %s: %w", filePath, err)
}
}
}
// Remove orphaned playlist_tracks left behind by past deletes
// that ran without foreign key enforcement.
orphanResult, err := db.ExecContext(
dbCtx,
"DELETE FROM playlist_tracks WHERE playlist_id NOT IN (SELECT id FROM playlists)",
)
if err != nil {
logger.Warn(
"could not clean orphaned playlist tracks",
"err", err,
)
} else if n, _ := orphanResult.RowsAffected(); n > 0 {
logger.Info(
"Cleaned orphaned playlist tracks",
"deleted", n,
)
}
// Get generated queries
queries := sqlcgen.New(db)
return &DB{
db: db,
Ctx: dbCtx,
Queries: queries,
logger: logger,
}, err
}
// BeginTx starts a new database transaction.
func (d *DB) BeginTx() (*sql.Tx, error) {
return d.db.BeginTx(d.Ctx, nil)
}
// ExecContext executes a query without returning any rows.
func (d *DB) ExecContext(query string, args ...any) (sql.Result, error) {
return d.db.ExecContext(d.Ctx, query, args...)
}
// QueryContext executes a query that returns rows.
func (d *DB) QueryContext(query string, args ...any) (*sql.Rows, error) {
return d.db.QueryContext(d.Ctx, query, args...)
}