diff --git a/driver_sqlite.go b/driver_sqlite.go new file mode 100644 index 0000000..fe74ee0 --- /dev/null +++ b/driver_sqlite.go @@ -0,0 +1,248 @@ +package gomigration + +import ( + "context" + "database/sql" + "fmt" + "time" + + _ "github.com/ncruces/go-sqlite3/driver" + _ "github.com/ncruces/go-sqlite3/embed" +) + +// SqliteDriver is a driver for sqlite +type SqliteDriver struct { + db *sql.DB + migrationTableName string +} + +// NewSqliteDriver creates a new SqliteDriver +func NewSqliteDriver( + database string, +) (*SqliteDriver, error) { + // Open database + db, err := sql.Open("sqlite3", database) + if err != nil { + return nil, err + } + + // Ping database + if err := db.Ping(); err != nil { + return nil, err + } + + // Return the driver with a default table name + return &(SqliteDriver{db, "migrations"}), nil +} + +// Close closes the database connection +func (d *SqliteDriver) Close() error { + if d.db != nil { + if err := d.db.Close(); err != nil { + return err + } + } + + return nil +} + +// SetMigrationTableName sets the migration table name of the migration tracking table +func (d *SqliteDriver) SetMigrationTableName(name string) { + if name == "" { + name = "migrations" + } + d.migrationTableName = name +} + +// CreateMigrationTable creates the migration tracking table +func (d *SqliteDriver) CreateMigrationsTable(ctx context.Context) error { + query := fmt.Sprintf(` + CREATE TABLE IF NOT EXISTS %s ( + name VARCHAR(255) PRIMARY KEY NOT NULL, + executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + `, d.migrationTableName) + + _, err := d.db.ExecContext(ctx, query) + return err +} + +// GetExecutedMigrations returns a list of previously executed migrations +func (d *SqliteDriver) GetExecutedMigrations(ctx context.Context, reverse bool) ([]ExecutedMigration, error) { + order := "ASC" + if reverse { + order = "DESC" + } + + query := fmt.Sprintf(`SELECT name, executed_at FROM %s ORDER BY name %s`, d.migrationTableName, order) + rows, err := d.db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + var migrations []ExecutedMigration + for rows.Next() { + var name string + var executedAt time.Time + if err := rows.Scan(&name, &executedAt); err != nil { + return nil, err + } + migrations = append(migrations, ExecutedMigration{Name: name, ExecutedAt: executedAt}) + } + + return migrations, rows.Err() +} + +// CleanDatabase drops all table from the current database. +func (d *SqliteDriver) CleanDatabase(ctx context.Context) error { + // Disable FK checks temporarily + _, err := d.db.ExecContext(ctx, `PRAGMA foreign_keys = OFF;`) + if err != nil { + return fmt.Errorf("failed to disable FK checks: %w", err) + } + + // Get all user-defined table names (excluding sqlite internal tables) + rows, err := d.db.QueryContext(ctx, ` + SELECT name + FROM sqlite_master + WHERE type = 'table' + AND name NOT LIKE 'sqlite_%'; + `) + if err != nil { + return fmt.Errorf("failed to query tables: %w", err) + } + defer rows.Close() + + var tableNames []string + for rows.Next() { + var table string + if err := rows.Scan(&table); err != nil { + return fmt.Errorf("failed to scan table name: %w", err) + } + tableNames = append(tableNames, fmt.Sprintf(`"%s"`, table)) + } + + // No tables to drop + if len(tableNames) == 0 { + // Re-enable FK checks before returning + _, _ = d.db.ExecContext(ctx, `PRAGMA foreign_keys = ON;`) + return nil + } + + // Drop all tables (SQLite doesn't support dropping multiple tables in one statement) + for _, tableName := range tableNames { + dropSQL := fmt.Sprintf("DROP TABLE IF EXISTS %s;", tableName) + _, err = d.db.ExecContext(ctx, dropSQL) + if err != nil { + return fmt.Errorf("failed to drop table %s: %w", tableName, err) + } + } + + // Re-enable FK checks + _, err = d.db.ExecContext(ctx, `PRAGMA foreign_keys = ON;`) + if err != nil { + return fmt.Errorf("failed to re-enable FK checks: %w", err) + } + + return nil +} + +// ApplyMigrations applies a batch of "up" migrations with optional callbacks. +func (d *SqliteDriver) ApplyMigrations( + ctx context.Context, + migrations []Migration, + onRunning func(migration *Migration), + onSuccess func(migration *Migration), + onFailed func(migration *Migration, err error), +) error { + for i := range migrations { + mig := migrations[i] + + if onRunning != nil { + onRunning(&mig) + } + + // Execute the migration SQL + if err := d.executeMigrationSQL(ctx, mig.UpScript()); err != nil { + if onFailed != nil { + onFailed(&mig, err) + } + return fmt.Errorf("failed to apply migration %s: %w", mig.Name(), err) + } + + // Record the migration + if err := d.insertExecutedMigration(ctx, mig.Name(), time.Now()); err != nil { + if onFailed != nil { + onFailed(&mig, err) + } + return fmt.Errorf("failed to record migration %s: %w", mig.Name(), err) + } + + if onSuccess != nil { + onSuccess(&mig) + } + } + return nil +} + +// UnapplyMigrations rolls back a batch of "down" migrations with optional callbacks. +func (d *SqliteDriver) UnapplyMigrations( + ctx context.Context, + migrations []Migration, + onRunning func(migration *Migration), + onSuccess func(migration *Migration), + onFailed func(migration *Migration, err error), +) error { + for i := range migrations { + mig := migrations[i] + + if onRunning != nil { + onRunning(&mig) + } + + // Execute the down migration SQL + if err := d.executeMigrationSQL(ctx, mig.DownScript()); err != nil { + if onFailed != nil { + onFailed(&mig, err) + } + return fmt.Errorf("failed to unapply migration %s: %w", mig.Name(), err) + } + + // Remove migration record from tracking table + if err := d.removeExecutedMigration(ctx, mig.Name()); err != nil { + if onFailed != nil { + onFailed(&mig, err) + } + return fmt.Errorf("failed to remove migration record %s: %w", mig.Name(), err) + } + + if onSuccess != nil { + onSuccess(&mig) + } + } + return nil +} + +// executeMigrationSQL runs a raw SQL migration script. +func (d *SqliteDriver) executeMigrationSQL(ctx context.Context, sql string) error { + if sql == "" { + return nil + } + _, err := d.db.ExecContext(ctx, sql) + return err +} + +// insertExecutedMigration logs a migration into the migration tracking table. +func (d *SqliteDriver) insertExecutedMigration(ctx context.Context, name string, executedAt time.Time) error { + query := fmt.Sprintf(`INSERT INTO %s (name, executed_at) VALUES (?, ?)`, d.migrationTableName) + _, err := d.db.ExecContext(ctx, query, name, executedAt) + return err +} + +// removeExecutedMigration deletes a migration record from the migration table. +func (d *SqliteDriver) removeExecutedMigration(ctx context.Context, name string) error { + query := fmt.Sprintf(`DELETE FROM %s WHERE name = ?`, d.migrationTableName) + _, err := d.db.ExecContext(ctx, query, name) + return err +} diff --git a/driver_sqlite_test.go b/driver_sqlite_test.go new file mode 100644 index 0000000..427e1e2 --- /dev/null +++ b/driver_sqlite_test.go @@ -0,0 +1,202 @@ +package gomigration + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/assert" +) + +func setupMockDBSqlite(t *testing.T) (*sql.DB, sqlmock.Sqlmock, *SqliteDriver) { + db, mock, err := sqlmock.New( + sqlmock.MonitorPingsOption(true), + ) + assert.NoError(t, err) + + driver := &SqliteDriver{ + db: db, + migrationTableName: "migrations", + } + + return db, mock, driver +} + +func TestNewSqliteDriver(t *testing.T) { + // Create a mock database connection + db, mock, driver := setupMockDBSqlite(t) + defer db.Close() + + // Simulate a successful ping to the DB + mock.ExpectPing().WillReturnError(nil) + + // Test that the driver is initialized correctly + assert.NotNil(t, driver) +} + +func TestCreateMigrationsTableSqliteDriver(t *testing.T) { + // Create a mock database connection + db, mock, driver := setupMockDBSqlite(t) + defer db.Close() + + // Simulate a successful table creation + mock.ExpectExec("CREATE TABLE IF NOT EXISTS migrations").WillReturnResult(sqlmock.NewResult(1, 1)) + + // Call CreateMigrationTable + err := driver.CreateMigrationsTable(context.Background()) + assert.NoError(t, err) +} + +func TestSetMigrationTableNameSqliteDriver(t *testing.T) { + driver := &SqliteDriver{} + + // Test default migration table name + driver.SetMigrationTableName("") + assert.Equal(t, "migrations", driver.migrationTableName) + + // Test custom migration table name + driver.SetMigrationTableName("custom_migrations") + assert.Equal(t, "custom_migrations", driver.migrationTableName) +} + +func TestGetExecutedMigrationsSqliteDriver(t *testing.T) { + // Create a mock database connection + db, mock, driver := setupMockDBSqlite(t) + defer db.Close() + + // Simulate the query to fetch migrations + rows := sqlmock.NewRows([]string{"name", "executed_at"}). + AddRow("migration_1", time.Now()). + AddRow("migration_2", time.Now()) + + mock.ExpectQuery("SELECT name, executed_at FROM migrations"). + WillReturnRows(rows) + + // Call GetExecutedMigrations + migrations, err := driver.GetExecutedMigrations(context.Background(), false) + assert.NoError(t, err) + assert.Len(t, migrations, 2) + assert.Equal(t, "migration_1", migrations[0].Name) +} + +func TestCleanDatabaseSqliteDriver(t *testing.T) { + db, mock, driver := setupMockDBSqlite(t) + defer db.Close() + + ctx := context.Background() + + // 1. Expect disabling foreign key checks + mock.ExpectExec(`PRAGMA foreign_keys = OFF;`).WillReturnResult(sqlmock.NewResult(0, 0)) + + // 2. Expect selecting all table names + mock.ExpectQuery(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%';`). + WillReturnRows( + sqlmock.NewRows([]string{"name"}). + AddRow("users"). + AddRow("products"), + ) + + // 3. Expect dropping tables individually (SQLite doesn't support multiple drops in one statement) + mock.ExpectExec(`DROP TABLE IF EXISTS "users";`).WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec(`DROP TABLE IF EXISTS "products";`).WillReturnResult(sqlmock.NewResult(0, 0)) + + // 4. Expect re-enabling foreign key checks + mock.ExpectExec(`PRAGMA foreign_keys = ON;`).WillReturnResult(sqlmock.NewResult(0, 0)) + + // Act + err := driver.CleanDatabase(ctx) + + assert.NoError(t, err) + + // Assert all expectations were met + err = mock.ExpectationsWereMet() + + assert.NoError(t, err, "there were unfulfilled expectations") +} + +func TestApplyMigrationsSqliteDriver(t *testing.T) { + db, mock, driver := setupMockDBSqlite(t) + defer db.Close() + + mig := &mockMigrationSqliteDriver{ + name: "migration1", + up: "CREATE TABLE test (id INTEGER);", + down: "DROP TABLE test;", + } + + mock.ExpectExec("CREATE TABLE test \\(id INTEGER\\);").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec(`INSERT INTO migrations`).WithArgs("migration1", sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(1, 1)) + + err := driver.ApplyMigrations(context.Background(), []Migration{mig}, nil, nil, nil) + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestUnapplyMigrationsSqliteDriver(t *testing.T) { + db, mock, driver := setupMockDBSqlite(t) + defer db.Close() + + mig := &mockMigrationSqliteDriver{ + name: "migration1", + up: "CREATE TABLE test (id INTEGER);", + down: "DROP TABLE test;", + } + + mock.ExpectExec(mig.down).WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec(`DELETE FROM migrations WHERE name = ?`).WithArgs(mig.name). + WillReturnResult(sqlmock.NewResult(1, 1)) + + err := driver.UnapplyMigrations(context.Background(), []Migration{mig}, nil, nil, nil) + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestExecuteMigrationSQLSqliteDriver(t *testing.T) { + db, mock, driver := setupMockDBSqlite(t) + defer db.Close() + + mock.ExpectExec(`SOME SQL STATEMENT`).WillReturnResult(sqlmock.NewResult(0, 0)) + + err := driver.executeMigrationSQL(context.Background(), "SOME SQL STATEMENT") + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestInsertExecutedMigrationSqliteDriver(t *testing.T) { + db, mock, driver := setupMockDBSqlite(t) + defer db.Close() + + mock.ExpectExec(`INSERT INTO migrations`).WithArgs("migration_name", sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(1, 1)) + + err := driver.insertExecutedMigration(context.Background(), "migration_name", time.Now()) + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestRemoveExecutedMigrationSqliteDriver(t *testing.T) { + db, mock, driver := setupMockDBSqlite(t) + defer db.Close() + + mock.ExpectExec(`DELETE FROM migrations WHERE name = ?`).WithArgs("migration_name"). + WillReturnResult(sqlmock.NewResult(1, 1)) + + err := driver.removeExecutedMigration(context.Background(), "migration_name") + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +// --- Supporting mock types --- + +type mockMigrationSqliteDriver struct { + name string + up string + down string +} + +func (m *mockMigrationSqliteDriver) Name() string { return m.name } +func (m *mockMigrationSqliteDriver) UpScript() string { return m.up } +func (m *mockMigrationSqliteDriver) DownScript() string { return m.down } diff --git a/go.mod b/go.mod index b74fb65..4cae553 100644 --- a/go.mod +++ b/go.mod @@ -1,24 +1,26 @@ module github.com/openframebox/gomigration -go 1.23.0 - -toolchain go1.23.6 +go 1.24.0 require ( github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/go-sql-driver/mysql v1.9.2 github.com/lib/pq v1.10.9 + github.com/ncruces/go-sqlite3 v0.29.1 github.com/spf13/cobra v1.9.1 github.com/stretchr/testify v1.10.0 - golang.org/x/text v0.24.0 + golang.org/x/text v0.29.0 ) require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/ncruces/julianday v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/stretchr/objx v0.5.2 // indirect + github.com/tetratelabs/wazero v1.9.0 // indirect + golang.org/x/sys v0.36.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 814583a..46ae177 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,10 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/ncruces/go-sqlite3 v0.29.1 h1:NIi8AISWBToRHyoz01FXiTNvU147Tqdibgj2tFzJCqM= +github.com/ncruces/go-sqlite3 v0.29.1/go.mod h1:PpccBNNhvjwUOwDQEn2gXQPFPTWdlromj0+fSkd5KSg= +github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= +github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -23,8 +27,12 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= +github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= 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=