From da6668204f77830e555b5dd85234f0f07a2e4025 Mon Sep 17 00:00:00 2001 From: Vladimir Zagainov Date: Wed, 20 May 2026 08:58:23 +0300 Subject: [PATCH] feat: add database layer with schema migrations Add internal/database package with SQLite3 connection management, versioned migration system, and three schema tables (artist_settings, external_releases, notifications_sent). Includes 7 tests covering initialization, migration idempotency, Close() behavior, and schema validation using in-memory SQLite. Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-05-20-foundation-layer.md | 20 +-- go.mod | 5 +- go.sum | 3 + internal/database/database.go | 154 +++++++++++++++++ internal/database/database_test.go | 197 ++++++++++++++++++++++ 5 files changed, 368 insertions(+), 11 deletions(-) create mode 100644 internal/database/database.go create mode 100644 internal/database/database_test.go diff --git a/docs/plans/2026-05-20-foundation-layer.md b/docs/plans/2026-05-20-foundation-layer.md index 05c3892..81387b9 100644 --- a/docs/plans/2026-05-20-foundation-layer.md +++ b/docs/plans/2026-05-20-foundation-layer.md @@ -75,16 +75,16 @@ Build the foundation layer of NaviWatcher: a greenfield Go project with zero exi - [x] run tests — must pass before task 3 ### Task 3: Database layer — schema and migrations -- [ ] create `internal/database/database.go` with DB struct and `New(dbPath string) (*DB, error)` constructor -- [ ] implement schema migration system (versioned migrations table + ordered migration files or functions) -- [ ] create migration 001: `artist_settings` table (id, name, ignore_singles, ignore_compilations, monitored) -- [ ] create migration 002: `external_releases` table (rgid PK, artist_id, title, type, release_date, is_ignored) -- [ ] create migration 003: `notifications_sent` table (rgid FK, sent_at) -- [ ] implement `Close()` method with proper connection cleanup -- [ ] write tests for database initialization and schema creation (use in-memory SQLite `:memory:`) -- [ ] write tests for migration idempotency (running migrations twice should not fail) -- [ ] write tests for Close() behavior -- [ ] run tests — must pass before task 4 +- [x] create `internal/database/database.go` with DB struct and `New(dbPath string) (*DB, error)` constructor +- [x] implement schema migration system (versioned migrations table + ordered migration files or functions) +- [x] create migration 001: `artist_settings` table (id, name, ignore_singles, ignore_compilations, monitored) +- [x] create migration 002: `external_releases` table (rgid PK, artist_id, title, type, release_date, is_ignored) +- [x] create migration 003: `notifications_sent` table (rgid FK, sent_at) +- [x] implement `Close()` method with proper connection cleanup +- [x] write tests for database initialization and schema creation (use in-memory SQLite `:memory:`) +- [x] write tests for migration idempotency (running migrations twice should not fail) +- [x] write tests for Close() behavior +- [x] run tests — must pass before task 4 ### Task 4: Database layer — CRUD operations for artist_settings - [ ] implement `GetArtistSettings(db *DB, id string) (*ArtistSettings, error)` diff --git a/go.mod b/go.mod index e08372e..c65c288 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,7 @@ module naviwatcher go 1.25.1 -require gopkg.in/yaml.v3 v3.0.1 // indirect +require ( + github.com/mattn/go-sqlite3 v1.14.22 + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/go.sum b/go.sum index 4bc0337..2de29e1 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,6 @@ +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..3e1c23b --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,154 @@ +package database + +import ( + "database/sql" + "fmt" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +// DB wraps sql.DB with migration support. +type DB struct { + conn *sql.DB +} + +// New opens a SQLite database at dbPath and runs schema migrations. +func New(dbPath string) (*DB, error) { + conn, err := sql.Open("sqlite3", dbPath) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + + // Enable WAL mode for better concurrent read performance. + if _, err := conn.Exec("PRAGMA journal_mode=WAL"); err != nil { + conn.Close() + return nil, fmt.Errorf("set WAL mode: %w", err) + } + + db := &DB{conn: conn} + if err := db.migrate(); err != nil { + conn.Close() + return nil, fmt.Errorf("migrate: %w", err) + } + + return db, nil +} + +// Close closes the database connection. +func (db *DB) Close() error { + return db.conn.Close() +} + +// Conn returns the underlying sql.DB for use by other packages. +func (db *DB) Conn() *sql.DB { + return db.conn +} + +// migrate runs all pending schema migrations in order. +func (db *DB) migrate() error { + // Create the migrations tracking table first, unconditionally. + if _, err := db.conn.Exec(`CREATE TABLE IF NOT EXISTS _migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP + );`); err != nil { + return fmt.Errorf("create migrations table: %w", err) + } + + migrations := []struct { + name string + sql string + }{ + { + name: "001_create_artist_settings", + sql: `CREATE TABLE IF NOT EXISTS artist_settings ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + ignore_singles BOOLEAN DEFAULT 0, + ignore_compilations BOOLEAN DEFAULT 0, + monitored BOOLEAN DEFAULT 1 + );`, + }, + { + name: "002_create_external_releases", + sql: `CREATE TABLE IF NOT EXISTS external_releases ( + rgid TEXT PRIMARY KEY, + artist_id TEXT NOT NULL, + title TEXT NOT NULL, + type TEXT, + release_date TEXT, + is_ignored BOOLEAN DEFAULT 0 + );`, + }, + { + name: "003_create_notifications_sent", + sql: `CREATE TABLE IF NOT EXISTS notifications_sent ( + rgid TEXT NOT NULL, + sent_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (rgid, sent_at) + );`, + }, + } + + for _, m := range migrations { + applied, err := db.isMigrationApplied(m.name) + if err != nil { + return fmt.Errorf("check migration %s: %w", m.name, err) + } + if applied { + continue + } + + if _, err := db.conn.Exec(m.sql); err != nil { + return fmt.Errorf("apply migration %s: %w", m.name, err) + } + + if err := db.markMigrationApplied(m.name); err != nil { + return fmt.Errorf("record migration %s: %w", m.name, err) + } + } + + return nil +} + +// isMigrationApplied checks whether a migration with the given name has already been applied. +func (db *DB) isMigrationApplied(name string) (bool, error) { + var count int + err := db.conn.QueryRow("SELECT COUNT(*) FROM _migrations WHERE name = ?", name).Scan(&count) + if err != nil { + return false, err + } + return count > 0, nil +} + +// markMigrationApplied records a migration as applied. +func (db *DB) markMigrationApplied(name string) error { + _, err := db.conn.Exec("INSERT INTO _migrations (name) VALUES (?)", name) + return err +} + +// ArtistSettings represents a row in the artist_settings table. +type ArtistSettings struct { + ID string `json:"id"` + Name string `json:"name"` + IgnoreSingles bool `json:"ignore_singles"` + IgnoreCompilations bool `json:"ignore_compilations"` + Monitored bool `json:"monitored"` +} + +// ExternalRelease represents a row in the external_releases table. +type ExternalRelease struct { + RGID string `json:"rgid"` + ArtistID string `json:"artist_id"` + Title string `json:"title"` + Type string `json:"type"` + ReleaseDate string `json:"release_date"` + IsIgnored bool `json:"is_ignored"` +} + +// NotificationSent represents a row in the notifications_sent table. +type NotificationSent struct { + RGID string `json:"rgid"` + SentAt time.Time `json:"sent_at"` +} diff --git a/internal/database/database_test.go b/internal/database/database_test.go new file mode 100644 index 0000000..8ea267b --- /dev/null +++ b/internal/database/database_test.go @@ -0,0 +1,197 @@ +package database + +import ( + "testing" +) + +// TestNew_InitializationAndSchema verifies that New() creates all expected tables. +func TestNew_InitializationAndSchema(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + expectedTables := []string{ + "_migrations", + "artist_settings", + "external_releases", + "notifications_sent", + } + + for _, table := range expectedTables { + var name string + err := db.Conn().QueryRow( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", table, + ).Scan(&name) + if err != nil { + t.Errorf("expected table %q to exist, got error: %v", table, err) + } + } +} + +// TestNew_MigrationIdempency verifies that calling New() twice (via migrate) does not fail. +func TestNew_MigrationIdempotency(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("first New() error: %v", err) + } + + // Running migrate again on the same connection should be a no-op. + err = db.migrate() + if err != nil { + t.Fatalf("second migrate() error: %v", err) + } + + // Verify tables still exist. + var count int + err = db.Conn().QueryRow( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN (?,?,?,?)", + "_migrations", "artist_settings", "external_releases", "notifications_sent", + ).Scan(&count) + if err != nil { + t.Fatalf("query error: %v", err) + } + if count != 4 { + t.Errorf("expected 4 tables, got %d", count) + } + + db.Close() +} + +// TestClose verifies that Close() properly closes the connection. +func TestClose(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + + if err := db.Close(); err != nil { + t.Fatalf("Close() error: %v", err) + } + + // After close, queries should fail. + var dummy int + err = db.Conn().QueryRow("SELECT 1").Scan(&dummy) + if err == nil { + t.Error("expected error querying after Close(), got nil") + } +} + +// TestArtistSettingsSchema verifies the artist_settings table has the correct columns. +func TestArtistSettingsSchema(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + // Insert a row to verify column names and types. + _, err = db.Conn().Exec( + "INSERT INTO artist_settings (id, name, ignore_singles, ignore_compilations, monitored) VALUES (?, ?, ?, ?, ?)", + "artist-1", "Test Artist", true, false, true, + ) + if err != nil { + t.Fatalf("insert into artist_settings: %v", err) + } + + var id, name string + var ignoreSingles, ignoreCompilations, monitored bool + err = db.Conn().QueryRow( + "SELECT id, name, ignore_singles, ignore_compilations, monitored FROM artist_settings WHERE id = ?", + "artist-1", + ).Scan(&id, &name, &ignoreSingles, &ignoreCompilations, &monitored) + if err != nil { + t.Fatalf("select from artist_settings: %v", err) + } + + if id != "artist-1" || name != "Test Artist" || !ignoreSingles || ignoreCompilations || !monitored { + t.Errorf("unexpected row values: id=%q name=%q ignoreSingles=%v ignoreCompilations=%v monitored=%v", + id, name, ignoreSingles, ignoreCompilations, monitored) + } +} + +// TestExternalReleasesSchema verifies the external_releases table has the correct columns. +func TestExternalReleasesSchema(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + _, err = db.Conn().Exec( + "INSERT INTO external_releases (rgid, artist_id, title, type, release_date, is_ignored) VALUES (?, ?, ?, ?, ?, ?)", + "rgid-1", "artist-1", "Test Album", "album", "2024-01-01", false, + ) + if err != nil { + t.Fatalf("insert into external_releases: %v", err) + } + + var rgid, artistID, title, releaseType, releaseDate string + var isIgnored bool + err = db.Conn().QueryRow( + "SELECT rgid, artist_id, title, type, release_date, is_ignored FROM external_releases WHERE rgid = ?", + "rgid-1", + ).Scan(&rgid, &artistID, &title, &releaseType, &releaseDate, &isIgnored) + if err != nil { + t.Fatalf("select from external_releases: %v", err) + } + + if rgid != "rgid-1" || artistID != "artist-1" || title != "Test Album" || releaseType != "album" || releaseDate != "2024-01-01" || isIgnored { + t.Errorf("unexpected row values") + } +} + +// TestNotificationsSentSchema verifies the notifications_sent table has the correct columns. +func TestNotificationsSentSchema(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + _, err = db.Conn().Exec( + "INSERT INTO notifications_sent (rgid) VALUES (?)", + "rgid-1", + ) + if err != nil { + t.Fatalf("insert into notifications_sent: %v", err) + } + + var rgid string + var sentAt string + err = db.Conn().QueryRow( + "SELECT rgid, sent_at FROM notifications_sent WHERE rgid = ?", + "rgid-1", + ).Scan(&rgid, &sentAt) + if err != nil { + t.Fatalf("select from notifications_sent: %v", err) + } + + if rgid != "rgid-1" { + t.Errorf("expected rgid 'rgid-1', got %q", rgid) + } + if sentAt == "" { + t.Error("expected sent_at to be non-empty") + } +} + +// TestMigrationTracking verifies that migrations are recorded in _migrations table. +func TestMigrationTracking(t *testing.T) { + db, err := New(":memory:") + if err != nil { + t.Fatalf("New() error: %v", err) + } + defer db.Close() + + var count int + err = db.Conn().QueryRow("SELECT COUNT(*) FROM _migrations").Scan(&count) + if err != nil { + t.Fatalf("query migrations count: %v", err) + } + + // We have 3 recorded migrations: artist_settings, external_releases, notifications_sent. + if count != 3 { + t.Errorf("expected 4 applied migrations, got %d", count) + } +}