diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c892699..ccd22ae 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -82,7 +82,7 @@ jobs: # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs # queries: security-extended,security-and-quality - # If the analyze step fails for one of the languages you are analyzing with + # If the "analyze" step fails for one of the languages you are analyzing with # "We were unable to automatically build your code", modify the matrix above # to set the build mode to "manual" for that language. Then modify this step # to build your code. diff --git a/.golangci.yaml b/.golangci.yaml index fed4424..916b64e 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -1,3 +1,6 @@ +# Copyright the dmorph contributors. +# SPDX-License-Identifier: MPL-2.0 + # Configuration file for golangci-lint # See https://golangci-lint.run/usage/configuration/ for more information @@ -9,25 +12,85 @@ run: linters: default: all - #enable: - # # Default linters - # - errcheck - # - govet - # - ineffassign - # - staticcheck - # - unused - # # Additional linters - # - gosec - # - misspell - # - revive - # - bodyclose - # - noctx + + disable: + - exhaustruct + - forbidigo + - noinlineerr + - nonamedreturns + - wsl + - wsl_v5 exclusions: + warn-unused: true + rules: - - path: main_test.go + - path: _test\.go linters: - - gosec + - cyclop + - dupl + - dupword + - err113 + - funlen + - gocognit + - maintidx + - nestif + + - path: examples/ + linters: + - err113 + + settings: + cyclop: + max-complexity: 25 + + depguard: + rules: + main: + files: + - $all + - "!$test" + - "!**/examples/**/*" + allow: + - $gostd + test: + files: + - $test + - "**/examples/**/*" + allow: + - $gostd + - github.com/stretchr/testify/assert + - github.com/stretchr/testify/require + - github.com/AlphaOne1/dmorph + - modernc.org/sqlite + + exhaustive: + default-signifies-exhaustive: true + + mnd: + ignored-numbers: + - "2" + + paralleltest: + ignore-missing: true + + perfsprint: + errorf: false + + testpackage: + skip-regexp: internal_test\.go + + varnamelen: + max-distance: 10 + ignore-names: + - ctx + - db + - err + - tx + + whitespace: + multi-if: true + multi-func: true issues: max-issues-per-linter: 0 diff --git a/dialects.go b/dialects.go index 038a523..b65c0ab 100644 --- a/dialects.go +++ b/dialects.go @@ -4,6 +4,7 @@ package dmorph import ( + "context" "database/sql" "errors" "fmt" @@ -11,7 +12,7 @@ import ( // BaseDialect is a convenience type for databases that manage the necessary operations solely using // queries. Defining the CreateTemplate, AppliedTemplate and RegisterTemplate enables the BaseDialect to -// perform all the necessary operation to fulfill the Dialect interface. +// perform all the necessary operations to fulfill the Dialect interface. type BaseDialect struct { CreateTemplate string // statement ensuring the existence of the migration table AppliedTemplate string // statement getting applied migrations ordered by application date @@ -19,33 +20,42 @@ type BaseDialect struct { } // EnsureMigrationTableExists ensures that the migration table, saving the applied migrations ids, exists. -func (b BaseDialect) EnsureMigrationTableExists(db *sql.DB, tableName string) error { - tx, err := db.Begin() +func (b BaseDialect) EnsureMigrationTableExists(ctx context.Context, db *sql.DB, tableName string) error { + tx, err := db.BeginTx(ctx, nil) if err != nil { return err } - defer func() { _ = tx.Rollback() }() - - _, execErr := tx.Exec(fmt.Sprintf(b.CreateTemplate, tableName)) + // Safety net for unexpected panics + defer func() { + if tx != nil { + _ = tx.Rollback() + } + }() - if execErr != nil { + if _, execErr := tx.ExecContext(ctx, fmt.Sprintf(b.CreateTemplate, tableName)); execErr != nil { rollbackErr := tx.Rollback() + tx = nil + return errors.Join(execErr, rollbackErr) } if err := tx.Commit(); err != nil { rollbackErr := tx.Rollback() + tx = nil + return errors.Join(err, rollbackErr) } + tx = nil + return nil } // AppliedMigrations gets the already applied migrations from the database, ordered by application date. -func (b BaseDialect) AppliedMigrations(db *sql.DB, tableName string) ([]string, error) { - rows, rowsErr := db.Query(fmt.Sprintf(b.AppliedTemplate, tableName)) +func (b BaseDialect) AppliedMigrations(ctx context.Context, db *sql.DB, tableName string) ([]string, error) { + rows, rowsErr := db.QueryContext(ctx, fmt.Sprintf(b.AppliedTemplate, tableName)) if rowsErr != nil { return nil, rowsErr @@ -67,8 +77,8 @@ func (b BaseDialect) AppliedMigrations(db *sql.DB, tableName string) ([]string, } // RegisterMigration registers a migration in the migration table. -func (b BaseDialect) RegisterMigration(tx *sql.Tx, id string, tableName string) error { - _, err := tx.Exec(fmt.Sprintf(b.RegisterTemplate, tableName), +func (b BaseDialect) RegisterMigration(ctx context.Context, tx *sql.Tx, id string, tableName string) error { + _, err := tx.ExecContext(ctx, fmt.Sprintf(b.RegisterTemplate, tableName), sql.Named("id", id)) return err diff --git a/dialects_test.go b/dialects_test.go index 279a874..2055759 100644 --- a/dialects_test.go +++ b/dialects_test.go @@ -1,15 +1,15 @@ // Copyright the DMorph contributors. // SPDX-License-Identifier: MPL-2.0 -package dmorph +package dmorph_test import ( - "database/sql" - "os" - "regexp" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/AlphaOne1/dmorph" ) // TestDialectStatements verifies that each database dialect has valid and @@ -19,19 +19,17 @@ func TestDialectStatements(t *testing.T) { // that the statements for the databases are somehow filled tests := []struct { name string - caller func() BaseDialect + caller func() dmorph.BaseDialect }{ - {name: "CSVQ", caller: DialectCSVQ}, - {name: "DB2", caller: DialectDB2}, - {name: "MSSQL", caller: DialectMSSQL}, - {name: "MySQL", caller: DialectMySQL}, - {name: "Oracle", caller: DialectOracle}, - {name: "Postgres", caller: DialectPostgres}, - {name: "SQLite", caller: DialectSQLite}, + {name: "CSVQ", caller: dmorph.DialectCSVQ}, + {name: "DB2", caller: dmorph.DialectDB2}, + {name: "MSSQL", caller: dmorph.DialectMSSQL}, + {name: "MySQL", caller: dmorph.DialectMySQL}, + {name: "Oracle", caller: dmorph.DialectOracle}, + {name: "Postgres", caller: dmorph.DialectPostgres}, + {name: "SQLite", caller: dmorph.DialectSQLite}, } - re := regexp.MustCompile("%s") - for k, v := range tests { d := v.caller() @@ -40,54 +38,38 @@ func TestDialectStatements(t *testing.T) { } assert.Contains(t, d.CreateTemplate, "%s", "no table name placeholder in create template for", v.name) - assert.Regexp(t, re, d.CreateTemplate) if len(d.AppliedTemplate) < 10 { t.Errorf("%v: applied template is too short for %v", k, v.name) } assert.Contains(t, d.AppliedTemplate, "%s", "no table name placeholder in applied template for", v.name) - assert.Regexp(t, re, d.AppliedTemplate) if len(d.RegisterTemplate) < 10 { t.Errorf("%v: register template is too short for %v", k, v.name) } assert.Contains(t, d.RegisterTemplate, "%s", "no table name placeholder in register template for", v.name) - assert.Regexp(t, re, d.RegisterTemplate) } } // TestCallsOnClosedDB verifies that methods fail as expected when called on a closed database connection. func TestCallsOnClosedDB(t *testing.T) { - dbFile, dbFileErr := prepareDB() - - if dbFileErr != nil { - t.Errorf("DB file could not be created: %v", dbFileErr) - } else { - defer func() { _ = os.Remove(dbFile) }() - } - - db, dbErr := sql.Open("sqlite", dbFile) - - if dbErr != nil { - t.Errorf("DB file could not be created: %v", dbErr) - } else { - _ = db.Close() - } + db := openTempSQLite(t) + require.NoError(t, db.Close()) assert.Error(t, - DialectSQLite().EnsureMigrationTableExists(db, "irrelevant"), + dmorph.DialectSQLite().EnsureMigrationTableExists(t.Context(), db, "irrelevant"), "expected error on closed database") - _, err := DialectSQLite().AppliedMigrations(db, "irrelevant") + _, err := dmorph.DialectSQLite().AppliedMigrations(t.Context(), db, "irrelevant") assert.Error(t, err, "expected error on closed database") } // TestEnsureMigrationTableExistsSQLError tests the EnsureMigrationTableExists function // for handling SQL errors during execution. func TestEnsureMigrationTableExistsSQLError(t *testing.T) { - d := BaseDialect{ + dialect := dmorph.BaseDialect{ CreateTemplate: ` CRATE TABLE test ( id VARCHAR(255) PRIMARY KEY, @@ -95,28 +77,15 @@ func TestEnsureMigrationTableExistsSQLError(t *testing.T) { )`, } - dbFile, dbFileErr := prepareDB() - - if dbFileErr != nil { - t.Errorf("DB file could not be created: %v", dbFileErr) - } else { - defer func() { _ = os.Remove(dbFile) }() - } + db := openTempSQLite(t) - db, dbErr := sql.Open("sqlite", dbFile) - - if dbErr != nil { - t.Errorf("DB file could not be created: %v", dbErr) - } else { - defer func() { _ = db.Close() }() - } - - assert.Error(t, d.EnsureMigrationTableExists(db, "test"), "expected error") + assert.Error(t, dialect.EnsureMigrationTableExists(t.Context(), db, "test"), "expected error") } -// TestEnsureMigrationTableExistsCommitError tests the behavior of EnsureMigrationTableExists when a commit error occurs. +// TestEnsureMigrationTableExistsCommitError tests the behavior of EnsureMigrationTableExists +// when a commit error occurs. func TestEnsureMigrationTableExistsCommitError(t *testing.T) { - d := BaseDialect{ + dialect := dmorph.BaseDialect{ CreateTemplate: ` CREATE TABLE t0 ( id INTEGER PRIMARY KEY @@ -134,25 +103,11 @@ func TestEnsureMigrationTableExistsCommitError(t *testing.T) { DELETE FROM t0 WHERE id = 1;`, } - dbFile, dbFileErr := prepareDB() - - if dbFileErr != nil { - t.Errorf("DB file could not be created: %v", dbFileErr) - } else { - defer func() { _ = os.Remove(dbFile) }() - } - - db, dbErr := sql.Open("sqlite", dbFile) - - if dbErr != nil { - t.Errorf("DB file could not be created: %v", dbErr) - } else { - defer func() { _ = db.Close() }() - } + db := openTempSQLite(t) - _, execErr := db.Exec("PRAGMA foreign_keys = ON") + _, execErr := db.ExecContext(t.Context(), "PRAGMA foreign_keys = ON") - assert.NoError(t, execErr, "foreign keys checking could not be enabled") + require.NoError(t, execErr, "foreign keys checking could not be enabled") - assert.Error(t, d.EnsureMigrationTableExists(db, "test"), "expected error") + assert.Error(t, dialect.EnsureMigrationTableExists(t.Context(), db, "test"), "expected error") } diff --git a/exports_internal_test.go b/exports_internal_test.go new file mode 100644 index 0000000..8893598 --- /dev/null +++ b/exports_internal_test.go @@ -0,0 +1,18 @@ +package dmorph + +import ( + "context" + "database/sql" +) + +// The exported names in this file are only used for internal testing and are not part of the public API. + +var ( + TapplyStepsStream = applyStepsStream + TmigrationFromFileFS = migrationFromFileFS + TmigrationOrder = migrationOrder +) + +func (m *Morpher) TapplyMigrations(ctx context.Context, db *sql.DB, lastMigration string) error { + return m.applyMigrations(ctx, db, lastMigration) +} diff --git a/file_migration.go b/file_migration.go index a3b732f..69f84f4 100644 --- a/file_migration.go +++ b/file_migration.go @@ -6,7 +6,9 @@ package dmorph import ( "bufio" "bytes" + "context" "database/sql" + "fmt" "io" "io/fs" "log/slog" @@ -18,7 +20,7 @@ import ( type FileMigration struct { Name string FS fs.FS - migrationFunc func(tx *sql.Tx, migration string) error + migrationFunc func(ctx context.Context, tx *sql.Tx, migration string) error } // Key returns the key of the migration to register in the migration table. @@ -27,8 +29,8 @@ func (f FileMigration) Key() string { } // Migrate executes the migration on the given transaction. -func (f FileMigration) Migrate(tx *sql.Tx) error { - return f.migrationFunc(tx, f.Name) +func (f FileMigration) Migrate(ctx context.Context, tx *sql.Tx) error { + return f.migrationFunc(ctx, tx, f.Name) } // WithMigrationFromFile generates a FileMigration that will run the content of the given file. @@ -36,7 +38,7 @@ func WithMigrationFromFile(name string) MorphOption { return func(morpher *Morpher) error { morpher.Migrations = append(morpher.Migrations, FileMigration{ Name: name, - migrationFunc: func(tx *sql.Tx, migration string) error { + migrationFunc: func(ctx context.Context, tx *sql.Tx, migration string) error { m, mErr := os.Open(migration) if mErr != nil { @@ -45,7 +47,7 @@ func WithMigrationFromFile(name string) MorphOption { defer func() { _ = m.Close() }() - return applyStepsStream(tx, m, migration, morpher.Log) + return applyStepsStream(ctx, tx, m, migration, morpher.Log) }, }) @@ -65,14 +67,15 @@ func WithMigrationFromFileFS(name string, dir fs.FS) MorphOption { // WithMigrationsFromFS generates a FileMigration that will run all migration scripts of the files in the given // filesystem. -func WithMigrationsFromFS(d fs.ReadDirFS) MorphOption { +func WithMigrationsFromFS(d fs.FS) MorphOption { return func(morpher *Morpher) error { - dirEntry, err := d.ReadDir(".") + dirEntry, err := fs.ReadDir(d, ".") if err == nil { for _, entry := range dirEntry { morpher.Log.Info("entry", slog.String("name", entry.Name())) - if entry.Type().IsRegular() { + + if entry.Type().IsRegular() && strings.HasSuffix(entry.Name(), ".sql") { morpher.Migrations = append(morpher.Migrations, migrationFromFileFS(entry.Name(), d, morpher.Log)) } @@ -83,12 +86,12 @@ func WithMigrationsFromFS(d fs.ReadDirFS) MorphOption { } } -// migrationFromFileFS creates a FileMigration instance for a specific migration file from an fs.FS directory. +// migrationFromFileFS creates a FileMigration instance for a specific migration file from a fs.FS directory. func migrationFromFileFS(name string, dir fs.FS, log *slog.Logger) FileMigration { return FileMigration{ Name: name, FS: dir, - migrationFunc: func(tx *sql.Tx, migration string) error { + migrationFunc: func(ctx context.Context, tx *sql.Tx, migration string) error { m, mErr := dir.Open(migration) if mErr != nil { @@ -97,7 +100,7 @@ func migrationFromFileFS(name string, dir fs.FS, log *slog.Logger) FileMigration defer func() { _ = m.Close() }() - return applyStepsStream(tx, m, migration, log) + return applyStepsStream(ctx, tx, m, migration, log) }, } } @@ -105,48 +108,60 @@ func migrationFromFileFS(name string, dir fs.FS, log *slog.Logger) FileMigration // applyStepsStream executes database migration steps read from an io.Reader, separated by semicolons, in a transaction. // Returns the corresponding error if any step execution fails. Also, as some database drivers or engines seem to not // support comments, leading comments are removed. This function does not undertake efforts to scan the SQL to find -// other comments. So leading comments telling what a step is going to do, work. But comments in the middle of a -// statement will not be removed. At least with SQLite this will lead to hard to find errors. -func applyStepsStream(tx *sql.Tx, r io.Reader, id string, log *slog.Logger) error { +// other comments. Such leading comments telling what a step is going to do, work. But comments in the middle of a +// statement will not be removed. At least with SQLite this will lead to hard-to-find errors. +func applyStepsStream(ctx context.Context, tx *sql.Tx, r io.Reader, migrationID string, log *slog.Logger) error { + const InitialScannerBufSize = 64 * 1024 + const MaxScannerBufSize = 1024 * 1024 + buf := bytes.Buffer{} scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, InitialScannerBufSize), MaxScannerBufSize) newStep := true - var i int - - for i = 0; scanner.Scan(); { - buf.Write(scanner.Bytes()) + var step int + for step = 0; scanner.Scan(); { if newStep && strings.HasPrefix(scanner.Text(), "--") { // skip leading comments continue } - newStep = false - if scanner.Text() == ";" { log.Info("migration step", - slog.String("id", id), - slog.Int("step", i), + slog.String("migrationID", migrationID), + slog.Int("step", step), ) - if _, err := tx.Exec(buf.String()); err != nil { - return err + + if _, err := tx.ExecContext(ctx, buf.String()); err != nil { + return fmt.Errorf("apply migration %q step %d: %w", migrationID, step, err) } buf.Reset() - i++ + newStep = true + step++ + + continue + } + + // Append the current line (preserve formatting by adding a newline between lines) + if buf.Len() > 0 { + buf.WriteByte('\n') } + + buf.Write(scanner.Bytes()) + newStep = false } - // cleanup after, for final statement without the closing ; on a new line + // cleanup after, for the final statement without the closing `;` on a new line if buf.Len() > 0 { log.Info("migration step", - slog.String("id", id), - slog.Int("step", i), + slog.String("migrationID", migrationID), + slog.Int("step", step), ) - if _, err := tx.Exec(buf.String()); err != nil { - return err + if _, err := tx.ExecContext(ctx, buf.String()); err != nil { + return fmt.Errorf("apply migration %q step %d (final): %w", migrationID, step, err) } } diff --git a/file_migration_test.go b/file_migration_test.go index 3140dbb..e9de046 100644 --- a/file_migration_test.go +++ b/file_migration_test.go @@ -1,120 +1,80 @@ // Copyright the DMorph contributors. // SPDX-License-Identifier: MPL-2.0 -package dmorph +package dmorph_test import ( "bytes" - "database/sql" "io/fs" "log/slog" "os" "testing" + "github.com/AlphaOne1/dmorph" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestWithMigrationFromFile(t *testing.T) { - dbFile, dbFileErr := prepareDB() + db := openTempSQLite(t) - if dbFileErr != nil { - t.Errorf("DB file could not be created: %v", dbFileErr) - } else { - defer func() { _ = os.Remove(dbFile) }() - } - - db, dbErr := sql.Open("sqlite", dbFile) - - if dbErr != nil { - t.Errorf("DB file could not be created: %v", dbErr) - } else { - defer func() { _ = db.Close() }() - } - - runErr := Run(db, - WithDialect(DialectSQLite()), - WithMigrationFromFile("testData/01_base_table.sql")) + runErr := dmorph.Run(t.Context(), + db, + dmorph.WithDialect(dmorph.DialectSQLite()), + dmorph.WithMigrationFromFile("testData/01_base_table.sql")) assert.NoError(t, runErr, "did not expect an error") } func TestWithMigrationFromFileError(t *testing.T) { - dbFile, dbFileErr := prepareDB() - - if dbFileErr != nil { - t.Errorf("DB file could not be created: %v", dbFileErr) - } else { - defer func() { _ = os.Remove(dbFile) }() - } - - db, dbErr := sql.Open("sqlite", dbFile) - - if dbErr != nil { - t.Errorf("DB file could not be created: %v", dbErr) - } else { - defer func() { _ = db.Close() }() - } + db := openTempSQLite(t) - runErr := Run(db, - WithDialect(DialectSQLite()), - WithMigrationFromFile("testData/00_non_existent.sql")) + runErr := dmorph.Run(t.Context(), + db, + dmorph.WithDialect(dmorph.DialectSQLite()), + dmorph.WithMigrationFromFile("testData/00_non_existent.sql")) var pathErr *fs.PathError assert.ErrorAs(t, runErr, &pathErr, "unexpected error") } -// TestMigrationFromFileFSError validates that migrationFromFileFS returns an error when the specified file does not exist. +// TestMigrationFromFileFSError validates that migrationFromFileFS returns an error +// when the specified file does not exist. func TestMigrationFromFileFSError(t *testing.T) { - dir, dirErr := os.OpenRoot("testData") + dir := os.DirFS("testData") - assert.NoError(t, dirErr, "could not open test data directory") + mig := dmorph.TmigrationFromFileFS("nonexistent", dir, slog.Default()) - mig := migrationFromFileFS("nonexistent", dir.FS(), slog.Default()) - - err := mig.Migrate(nil) + err := mig.Migrate(t.Context(), nil) assert.Error(t, err, "expected error") } // TestApplyStepsStreamError tests error handling in applyStepsStream. func TestApplyStepsStreamError(t *testing.T) { - dbFile, dbFileErr := prepareDB() - - if dbFileErr != nil { - t.Errorf("DB file could not be created: %v", dbFileErr) - } else { - defer func() { _ = os.Remove(dbFile) }() - } - - db, dbErr := sql.Open("sqlite", dbFile) - - if dbErr != nil { - t.Errorf("DB file could not be created: %v", dbErr) - } else { - defer func() { _ = db.Close() }() - } + db := openTempSQLite(t) buf := bytes.Buffer{} buf.WriteString("utter nonsense") - tx, txErr := db.Begin() + tx, txErr := db.BeginTx(t.Context(), nil) - assert.NoError(t, txErr, "expected no tx error") + require.NoError(t, txErr, "expected no tx error") - err := applyStepsStream(tx, &buf, "test", slog.Default()) + err := dmorph.TapplyStepsStream(t.Context(), tx, &buf, "test", slog.Default()) - assert.Error(t, err, "expected error") + require.Error(t, err, "expected error") _ = tx.Rollback() - tx, txErr = db.Begin() + tx, txErr = db.BeginTx(t.Context(), nil) - assert.NoError(t, txErr, "expected no tx error") + require.NoError(t, txErr, "expected no tx error") buf.Reset() buf.WriteString("utter nonsense\n;") - err = applyStepsStream(tx, &buf, "test", slog.Default()) + err = dmorph.TapplyStepsStream(t.Context(), tx, &buf, "test", slog.Default()) assert.Error(t, err, "expected error") diff --git a/migration.go b/migration.go index 6d085a2..523d076 100644 --- a/migration.go +++ b/migration.go @@ -4,6 +4,7 @@ package dmorph import ( + "context" "database/sql" "errors" "fmt" @@ -19,42 +20,42 @@ const MigrationTableName = "migrations" // ValidTableNameRex is the regular expression used to check if a given migration table name is valid. var ValidTableNameRex = regexp.MustCompile("^[a-zA-Z0-9_]+$") -// ErrMigrationsUnrelated signalizes, that the set of migrations to apply and the already applied set do not have the +// ErrMigrationsUnrelated signals that the set of migrations to apply and the already applied set do not have the // same (order of) applied migrations. Applying unrelated migrations could severely harm the database. var ErrMigrationsUnrelated = errors.New("migrations unrelated") -// ErrMigrationsUnsorted tells that the already applied migrations were not registered in the order (using the timestamp) -// that they should have been registered (using their id) +// ErrMigrationsUnsorted indicates that the already applied migrations were not registered in the order +// (using the timestamp) that they should have been registered (using their id). var ErrMigrationsUnsorted = errors.New("migrations unsorted") -// ErrNoDialect signalizes that no dialect for the database operations was chosen. +// ErrNoDialect signals that no dialect for the database operations was chosen. var ErrNoDialect = errors.New("no dialect") -// ErrNoMigrations signalizes that no migrations were chosen to be applied +// ErrNoMigrations signals that no migrations were chosen to be applied. var ErrNoMigrations = errors.New("no migrations") -// ErrNoMigrationTable occurs if there is not migration table present +// ErrNoMigrationTable occurs if there is no migration table present. var ErrNoMigrationTable = errors.New("no migration table") -// ErrMigrationTableNameInvalid occurs if the migration table does not adhere to ValidTableNameRex +// ErrMigrationTableNameInvalid occurs if the migration table does not adhere to ValidTableNameRex. var ErrMigrationTableNameInvalid = errors.New("invalid migration table name") -// ErrMigrationsTooOld signalizes that the migrations to be applied are older than the migrations that are already +// ErrMigrationsTooOld signals that the migrations to be applied are older than the migrations that are already // present in the database. This error can occur when an older version of the application is started using a database -// that was used already by a newer version of the application. +// used already by a newer version of the application. var ErrMigrationsTooOld = errors.New("migrations too old") // Dialect is an interface describing the functionalities needed to manage migrations inside a database. type Dialect interface { - EnsureMigrationTableExists(db *sql.DB, tableName string) error - AppliedMigrations(db *sql.DB, tableName string) ([]string, error) - RegisterMigration(tx *sql.Tx, id string, tableName string) error + EnsureMigrationTableExists(ctx context.Context, db *sql.DB, tableName string) error + AppliedMigrations(ctx context.Context, db *sql.DB, tableName string) ([]string, error) + RegisterMigration(ctx context.Context, tx *sql.Tx, id string, tableName string) error } // Migration is an interface to provide abstract information about the migration at hand. type Migration interface { - Key() string // identifier, used for ordering - Migrate(tx *sql.Tx) error // migration functionality + Key() string // identifier, used for ordering + Migrate(ctx context.Context, tx *sql.Tx) error // migration functionality } // migrationOrder is used to order Migration instances. @@ -80,10 +81,11 @@ type Morpher struct { // MorphOption is the type used for functional options. type MorphOption func(*Morpher) error -// WithDialect sets the vendor specific database dialect to be used. +// WithDialect sets the vendor-specific database dialect to be used. func WithDialect(dialect Dialect) MorphOption { return func(m *Morpher) error { m.Dialect = dialect + return nil } } @@ -92,6 +94,7 @@ func WithDialect(dialect Dialect) MorphOption { func WithMigrations(migrations ...Migration) MorphOption { return func(m *Morpher) error { m.Migrations = append(m.Migrations, migrations...) + return nil } } @@ -101,6 +104,7 @@ func WithMigrations(migrations ...Migration) MorphOption { func WithLog(log *slog.Logger) MorphOption { return func(m *Morpher) error { m.Log = log + return nil } } @@ -110,7 +114,7 @@ func WithLog(log *slog.Logger) MorphOption { func WithTableName(tableName string) func(*Morpher) error { return func(m *Morpher) error { if len(tableName) < 1 { - return fmt.Errorf("table name empty") + return ErrMigrationTableNameInvalid } if !ValidTableNameRex.MatchString(tableName) { @@ -118,6 +122,7 @@ func WithTableName(tableName string) func(*Morpher) error { } m.TableName = tableName + return nil } } @@ -170,16 +175,16 @@ func (m *Morpher) IsValid() error { // returned. // Run will run each migration in a separate transaction, with the last step to register the // migration in the migration table. -func (m *Morpher) Run(db *sql.DB) error { +func (m *Morpher) Run(ctx context.Context, db *sql.DB) error { if validErr := m.IsValid(); validErr != nil { return validErr } - if err := m.Dialect.EnsureMigrationTableExists(db, m.TableName); err != nil { + if err := m.Dialect.EnsureMigrationTableExists(ctx, db, m.TableName); err != nil { return fmt.Errorf("could not create migration table: %w", err) } - appliedMigrations, appliedMigrationsErr := m.Dialect.AppliedMigrations(db, m.TableName) + appliedMigrations, appliedMigrationsErr := m.Dialect.AppliedMigrations(ctx, db, m.TableName) if appliedMigrationsErr != nil { return fmt.Errorf("could not get applied migrations: %w", appliedMigrationsErr) @@ -202,52 +207,73 @@ func (m *Morpher) Run(db *sql.DB) error { lastMigration = appliedMigrations[len(appliedMigrations)-1] } - return m.applyMigrations(db, lastMigration) + return m.applyMigrations(ctx, db, lastMigration) } // applyMigrations applies the given migrations to the database. // This method does not check for the validity or consistency of the database. -func (m *Morpher) applyMigrations(db *sql.DB, lastMigration string) error { +func (m *Morpher) applyMigrations(ctx context.Context, db *sql.DB, lastMigration string) error { var startMigration time.Time for _, migration := range m.Migrations { if lastMigration >= migration.Key() { m.Log.Info("migration already applied", slog.String("file", migration.Key())) + continue } m.Log.Info("applying migration", slog.String("file", migration.Key())) startMigration = time.Now() - tx, txBeginErr := db.Begin() - if txBeginErr != nil { - return txBeginErr + // Check context before starting a transaction + if err := ctx.Err(); err != nil { + return fmt.Errorf("context cancelled before migration %s: %w", migration.Key(), err) } - // even if we are sure to catch all possibilities, we use this as a safeguard that also with later - // modifications, if a successful commit cannot be done, at least the rollback is executed freeing - // allocated resources of the transaction. - defer func() { _ = tx.Rollback() }() - - if err := migration.Migrate(tx); err != nil { - rollbackErr := tx.Rollback() - return errors.Join(err, rollbackErr) - } - - if registerErr := m.Dialect.RegisterMigration(tx, migration.Key(), m.TableName); registerErr != nil { - rollbackErr := tx.Rollback() - return errors.Join(registerErr, rollbackErr) + if err := m.runOneMigration(ctx, db, migration); err != nil { + return err } - if commitErr := tx.Commit(); commitErr != nil { - rollbackErr := tx.Rollback() - return errors.Join(commitErr, rollbackErr) - } m.Log.Info("migration applied", slog.String("file", migration.Key()), slog.Duration("duration", time.Since(startMigration)), ) } + + return nil +} + +// runOneMigration executes a single migration within a database transaction and logs its completion. +func (m *Morpher) runOneMigration(ctx context.Context, db *sql.DB, mig Migration) error { + tx, err := db.BeginTx(ctx, nil) + + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + + // Even if we are sure to catch all possibilities, we use this as a safeguard that also with later + // modifications. When a successful commit cannot be done, at least the rollback is executed, freeing + // allocated resources of the transaction. + defer func() { _ = tx.Rollback() }() + + if err := mig.Migrate(ctx, tx); err != nil { + rollbackErr := tx.Rollback() + + return errors.Join(err, rollbackErr) + } + + if err := m.Dialect.RegisterMigration(ctx, tx, mig.Key(), m.TableName); err != nil { + rollbackErr := tx.Rollback() + + return errors.Join(err, rollbackErr) + } + + if commitErr := tx.Commit(); commitErr != nil { + rollbackErr := tx.Rollback() + + return errors.Join(commitErr, rollbackErr) + } + return nil } @@ -256,6 +282,7 @@ func (m *Morpher) applyMigrations(db *sql.DB, lastMigration string) error { func (m *Morpher) checkAppliedMigrations(appliedMigrations []string) error { if !slices.IsSorted(appliedMigrations) { m.Log.Error("migrations not applied in order") + return ErrMigrationsUnsorted } @@ -265,27 +292,28 @@ func (m *Morpher) checkAppliedMigrations(appliedMigrations []string) error { if len(m.Migrations) < len(appliedMigrations) { // it is impossible to have a migration newer than the one already applied - // without having at least the same amount of previous migrations + // without having at least the same number of previous migrations return ErrMigrationsUnrelated } - // we know here, that there are at least as many migrations applied as we got to apply - for i := 0; i < len(appliedMigrations); i++ { + // we know here that there are at least as many migrations applied as we got to apply + for i := range appliedMigrations { if appliedMigrations[i] != m.Migrations[i].Key() { return ErrMigrationsUnrelated } } + return nil } // Run is a convenience function to easily get the migration job done. For more control use the // Morpher directly. -func Run(db *sql.DB, options ...MorphOption) error { +func Run(ctx context.Context, db *sql.DB, options ...MorphOption) error { m, morphErr := NewMorpher(options...) if morphErr != nil { return morphErr } - return m.Run(db) + return m.Run(ctx, db) } diff --git a/migration_test.go b/migration_test.go index 5dfe7aa..8bc25bb 100644 --- a/migration_test.go +++ b/migration_test.go @@ -1,9 +1,10 @@ // Copyright the DMorph contributors. // SPDX-License-Identifier: MPL-2.0 -package dmorph +package dmorph_test import ( + "context" "database/sql" "embed" "io/fs" @@ -12,7 +13,10 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" _ "modernc.org/sqlite" + + "github.com/AlphaOne1/dmorph" ) //go:embed testData @@ -23,7 +27,6 @@ func prepareDB() (string, error) { var result string dbFile, dbFileErr := os.CreateTemp("", "") - // dbFile, dbFileErr := os.Create("testdb.sqlite") if dbFileErr != nil { return "", dbFileErr @@ -36,66 +39,55 @@ func prepareDB() (string, error) { return result, nil } -// TestMigration tests the happy flow. -func TestMigration(t *testing.T) { - dbFile, dbFileErr := prepareDB() +func openTempSQLite(t *testing.T) *sql.DB { + t.Helper() - if dbFileErr != nil { - t.Errorf("DB file could not be created: %v", dbFileErr) - } else { - defer func() { _ = os.Remove(dbFile) }() - } + dbFile, err := prepareDB() + require.NoError(t, err, "DB file could not be created") + t.Cleanup(func() { _ = os.Remove(dbFile) }) db, dbErr := sql.Open("sqlite", dbFile) + require.NoError(t, dbErr, "DB could not be opened") + t.Cleanup(func() { _ = db.Close() }) - if dbErr != nil { - t.Errorf("DB file could not be created: %v", dbErr) - } else { - defer func() { _ = db.Close() }() - } + return db +} + +// TestMigration tests the happy flow. +func TestMigration(t *testing.T) { + db := openTempSQLite(t) migrationsDir, migrationsDirErr := fs.Sub(testMigrationsDir, "testData") - assert.NoError(t, migrationsDirErr, "migrations directory could not be opened") + require.NoError(t, migrationsDirErr, "migrations directory could not be opened") - runErr := Run(db, - WithDialect(DialectSQLite()), - WithMigrationsFromFS(migrationsDir.(fs.ReadDirFS))) + runErr := dmorph.Run(t.Context(), + db, + dmorph.WithDialect(dmorph.DialectSQLite()), + dmorph.WithMigrationsFromFS(migrationsDir)) assert.NoError(t, runErr, "migrations could not be run") } // TestMigrationUpdate tests the happy flow of updating on existing migrations. func TestMigrationUpdate(t *testing.T) { - dbFile, dbFileErr := prepareDB() - - if dbFileErr != nil { - t.Errorf("DB file could not be created: %v", dbFileErr) - } else { - defer func() { _ = os.Remove(dbFile) }() - } - - db, dbErr := sql.Open("sqlite", dbFile) - - if dbErr != nil { - t.Errorf("DB file could not be created: %v", dbErr) - } else { - defer func() { _ = db.Close() }() - } + db := openTempSQLite(t) migrationsDir, migrationsDirErr := fs.Sub(testMigrationsDir, "testData") - assert.NoError(t, migrationsDirErr, "migrations directory could not be opened") + require.NoError(t, migrationsDirErr, "migrations directory could not be opened") - runErr := Run(db, - WithDialect(DialectSQLite()), - WithMigrationFromFileFS("01_base_table.sql", migrationsDir)) + runErr := dmorph.Run(t.Context(), + db, + dmorph.WithDialect(dmorph.DialectSQLite()), + dmorph.WithMigrationFromFileFS("01_base_table.sql", migrationsDir)) - assert.NoError(t, runErr, "preparation migrations could not be run") + require.NoError(t, runErr, "preparation migrations could not be run") - runErr = Run(db, - WithDialect(DialectSQLite()), - WithMigrationsFromFS(migrationsDir.(fs.ReadDirFS))) + runErr = dmorph.Run(t.Context(), + db, + dmorph.WithDialect(dmorph.DialectSQLite()), + dmorph.WithMigrationsFromFS(migrationsDir)) assert.NoError(t, runErr, "migrations could not be run") } @@ -103,32 +95,20 @@ func TestMigrationUpdate(t *testing.T) { type TestMigrationImpl struct{} func (m TestMigrationImpl) Key() string { return "TestMigration" } -func (m TestMigrationImpl) Migrate(tx *sql.Tx) error { - _, err := tx.Exec("CREATE TABLE t0 (id INTEGER PRIMARY KEY)") +func (m TestMigrationImpl) Migrate(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, "CREATE TABLE t0 (id INTEGER PRIMARY KEY)") + return err } // TestWithMigrations tests the adding of migrations using WithMigrations. func TestWithMigrations(t *testing.T) { - dbFile, dbFileErr := prepareDB() - - if dbFileErr != nil { - t.Errorf("DB file could not be created: %v", dbFileErr) - } else { - defer func() { _ = os.Remove(dbFile) }() - } - - db, dbErr := sql.Open("sqlite", dbFile) - - if dbErr != nil { - t.Errorf("DB file could not be created: %v", dbErr) - } else { - defer func() { _ = db.Close() }() - } + db := openTempSQLite(t) - runErr := Run(db, - WithDialect(DialectSQLite()), - WithMigrations(TestMigrationImpl{})) + runErr := dmorph.Run(t.Context(), + db, + dmorph.WithDialect(dmorph.DialectSQLite()), + dmorph.WithMigrations(TestMigrationImpl{})) assert.NoError(t, runErr, "did not expect error") } @@ -136,184 +116,135 @@ func TestWithMigrations(t *testing.T) { // TestMigrationUnableToCreateMorpher tests to use the Run function without any // useful parameter. func TestMigrationUnableToCreateMorpher(t *testing.T) { - runErr := Run(nil) + runErr := dmorph.Run(t.Context(), nil) assert.Error(t, runErr, "morpher should not have run") } -// TestMigration tests what happens, if the applied migrations are too old. +// TestMigrationTooOld tests what happens if the applied migrations are too old. func TestMigrationTooOld(t *testing.T) { - dbFile, dbFileErr := prepareDB() - - if dbFileErr != nil { - t.Errorf("DB file could not be created: %v", dbFileErr) - } else { - defer func() { _ = os.Remove(dbFile) }() - } - - db, dbErr := sql.Open("sqlite", dbFile) - - if dbErr != nil { - t.Errorf("DB file could not be created: %v", dbErr) - } else { - defer func() { _ = db.Close() }() - } + db := openTempSQLite(t) migrationsDir, migrationsDirErr := fs.Sub(testMigrationsDir, "testData") - assert.NoError(t, migrationsDirErr, "migrations directory could not be opened") + require.NoError(t, migrationsDirErr, "migrations directory could not be opened") - runErr := Run(db, - WithDialect(DialectSQLite()), - WithMigrationsFromFS(migrationsDir.(fs.ReadDirFS))) + runErr := dmorph.Run(t.Context(), + db, + dmorph.WithDialect(dmorph.DialectSQLite()), + dmorph.WithMigrationsFromFS(migrationsDir)) - assert.NoError(t, runErr, "preparation migrations could not be run") + require.NoError(t, runErr, "preparation migrations could not be run") - runErr = Run(db, - WithDialect(DialectSQLite()), - WithMigrationFromFileFS("01_base_table.sql", migrationsDir)) + runErr = dmorph.Run(t.Context(), + db, + dmorph.WithDialect(dmorph.DialectSQLite()), + dmorph.WithMigrationFromFileFS("01_base_table.sql", migrationsDir)) - assert.ErrorIs(t, runErr, ErrMigrationsTooOld, "migrations did not give expected error") + assert.ErrorIs(t, runErr, dmorph.ErrMigrationsTooOld, "migrations did not give expected error") } -// TestMigrationUnrelated0 tests what happens, if the applied migrations are unrelated to existing ones. +// TestMigrationUnrelated0 tests what happens if the applied migrations are unrelated to existing ones. func TestMigrationUnrelated0(t *testing.T) { - dbFile, dbFileErr := prepareDB() - - if dbFileErr != nil { - t.Errorf("DB file could not be created: %v", dbFileErr) - } else { - defer func() { _ = os.Remove(dbFile) }() - } - - db, dbErr := sql.Open("sqlite", dbFile) - - if dbErr != nil { - t.Errorf("DB file could not be created: %v", dbErr) - } else { - defer func() { _ = db.Close() }() - } + db := openTempSQLite(t) migrationsDir, migrationsDirErr := fs.Sub(testMigrationsDir, "testData") - assert.NoError(t, migrationsDirErr, "migrations directory could not be opened") + require.NoError(t, migrationsDirErr, "migrations directory could not be opened") - runErr := Run(db, - WithDialect(DialectSQLite()), - WithMigrationsFromFS(migrationsDir.(fs.ReadDirFS))) + runErr := dmorph.Run(t.Context(), + db, + dmorph.WithDialect(dmorph.DialectSQLite()), + dmorph.WithMigrationsFromFS(migrationsDir)) - assert.NoError(t, runErr, "preparation migrations could not be run") + require.NoError(t, runErr, "preparation migrations could not be run") - runErr = Run(db, - WithDialect(DialectSQLite()), - WithMigrationFromFileFS("02_addon_table.sql", migrationsDir)) + runErr = dmorph.Run(t.Context(), + db, + dmorph.WithDialect(dmorph.DialectSQLite()), + dmorph.WithMigrationFromFileFS("02_addon_table.sql", migrationsDir)) - assert.ErrorIs(t, runErr, ErrMigrationsUnrelated, "migrations did not give expected error") + assert.ErrorIs(t, runErr, dmorph.ErrMigrationsUnrelated, "migrations did not give expected error") } -// TestMigrationUnrelated1 tests what happens, if the applied migrations are unrelated to existing ones. +// TestMigrationUnrelated1 tests what happens if the applied migrations are unrelated to existing ones. func TestMigrationUnrelated1(t *testing.T) { - dbFile, dbFileErr := prepareDB() - - if dbFileErr != nil { - t.Errorf("DB file could not be created: %v", dbFileErr) - } else { - defer func() { _ = os.Remove(dbFile) }() - } - - db, dbErr := sql.Open("sqlite", dbFile) - - if dbErr != nil { - t.Errorf("DB file could not be created: %v", dbErr) - } else { - defer func() { _ = db.Close() }() - } + db := openTempSQLite(t) migrationsDir, migrationsDirErr := fs.Sub(testMigrationsDir, "testData") - assert.NoError(t, migrationsDirErr, "migrations directory could not be opened") + require.NoError(t, migrationsDirErr, "migrations directory could not be opened") - runErr := Run(db, - WithDialect(DialectSQLite()), - WithMigrationFromFileFS("01_base_table.sql", migrationsDir)) + runErr := dmorph.Run(t.Context(), + db, + dmorph.WithDialect(dmorph.DialectSQLite()), + dmorph.WithMigrationFromFileFS("01_base_table.sql", migrationsDir)) - assert.NoError(t, runErr, "preparation migrations could not be run") + require.NoError(t, runErr, "preparation migrations could not be run") - runErr = Run(db, - WithDialect(DialectSQLite()), - WithMigrationFromFileFS("02_addon_table.sql", migrationsDir)) + runErr = dmorph.Run(t.Context(), + db, + dmorph.WithDialect(dmorph.DialectSQLite()), + dmorph.WithMigrationFromFileFS("02_addon_table.sql", migrationsDir)) - assert.ErrorIs(t, runErr, ErrMigrationsUnrelated, "migrations did not give expected error") + assert.ErrorIs(t, runErr, dmorph.ErrMigrationsUnrelated, "migrations did not give expected error") } // TestMigrationAppliedUnordered tests the case, that somehow the migrations in the // database are registered not in the order of their keys. func TestMigrationAppliedUnordered(t *testing.T) { - dbFile, dbFileErr := prepareDB() - - if dbFileErr != nil { - t.Errorf("DB file could not be created: %v", dbFileErr) - } else { - defer func() { _ = os.Remove(dbFile) }() - } - - db, dbErr := sql.Open("sqlite", dbFile) - - if dbErr != nil { - t.Errorf("DB file could not be created: %v", dbErr) - } else { - defer func() { _ = db.Close() }() - } + db := openTempSQLite(t) migrationsDir, migrationsDirErr := fs.Sub(testMigrationsDir, "testData") - assert.NoError(t, migrationsDirErr, "migrations directory could not be opened") + require.NoError(t, migrationsDirErr, "migrations directory could not be opened") - assert.NoError(t, DialectSQLite().EnsureMigrationTableExists(db, "migrations")) + require.NoError(t, dmorph.DialectSQLite().EnsureMigrationTableExists(t.Context(), db, "migrations")) - _, execErr := db.Exec(` + _, execErr := db.ExecContext(t.Context(), ` INSERT INTO migrations (id, create_ts) VALUES ('01_base_table', '2021-01-02 00:00:00'); INSERT INTO migrations (id, create_ts) VALUES ('02_addon_table', '2021-01-01 00:00:00'); `) - assert.NoError(t, execErr, "unordered test could not be prepared") + require.NoError(t, execErr, "unordered test could not be prepared") - runErr := Run(db, - WithDialect(DialectSQLite()), - WithMigrationsFromFS(migrationsDir.(fs.ReadDirFS))) + runErr := dmorph.Run(t.Context(), + db, + dmorph.WithDialect(dmorph.DialectSQLite()), + dmorph.WithMigrationsFromFS(migrationsDir)) assert.ErrorIs(t, runErr, - ErrMigrationsUnsorted, + dmorph.ErrMigrationsUnsorted, "migrations did not give expected error") } // TestMigrationOrder checks that the migrations ordering function works as expected. func TestMigrationOrder(t *testing.T) { tests := []struct { - m0 Migration - m1 Migration + m0 dmorph.Migration + m1 dmorph.Migration order int }{ { - m0: FileMigration{Name: "01"}, - m1: FileMigration{Name: "01"}, + m0: dmorph.FileMigration{Name: "01"}, + m1: dmorph.FileMigration{Name: "01"}, order: 0, }, { - m0: FileMigration{Name: "01"}, - m1: FileMigration{Name: "02"}, + m0: dmorph.FileMigration{Name: "01"}, + m1: dmorph.FileMigration{Name: "02"}, order: -1, }, { - m0: FileMigration{Name: "02"}, - m1: FileMigration{Name: "01"}, + m0: dmorph.FileMigration{Name: "02"}, + m1: dmorph.FileMigration{Name: "01"}, order: 1, }, } for k, v := range tests { - res := migrationOrder(v.m0, v.m1) + res := dmorph.TmigrationOrder(v.m0, v.m1) assert.Equal(t, v.order, res, "order of migrations is wrong for test %v", k) } @@ -322,48 +253,48 @@ func TestMigrationOrder(t *testing.T) { // TestMigrationIsValid checks the validity checks for migrations. func TestMigrationIsValid(t *testing.T) { tests := []struct { - m Morpher + m dmorph.Morpher err error }{ { - m: Morpher{ - Dialect: DialectSQLite(), - Migrations: []Migration{FileMigration{Name: "01"}}, + m: dmorph.Morpher{ + Dialect: dmorph.DialectSQLite(), + Migrations: []dmorph.Migration{dmorph.FileMigration{Name: "01"}}, TableName: "migrations", }, err: nil, }, { - m: Morpher{ + m: dmorph.Morpher{ Dialect: nil, - Migrations: []Migration{FileMigration{Name: "01"}}, + Migrations: []dmorph.Migration{dmorph.FileMigration{Name: "01"}}, TableName: "migrations", }, - err: ErrNoDialect, + err: dmorph.ErrNoDialect, }, { - m: Morpher{ - Dialect: DialectSQLite(), + m: dmorph.Morpher{ + Dialect: dmorph.DialectSQLite(), Migrations: nil, TableName: "migrations", }, - err: ErrNoMigrations, + err: dmorph.ErrNoMigrations, }, { - m: Morpher{ - Dialect: DialectSQLite(), - Migrations: []Migration{FileMigration{Name: "01"}}, + m: dmorph.Morpher{ + Dialect: dmorph.DialectSQLite(), + Migrations: []dmorph.Migration{dmorph.FileMigration{Name: "01"}}, TableName: "", }, - err: ErrNoMigrationTable, + err: dmorph.ErrNoMigrationTable, }, { - m: Morpher{ - Dialect: DialectSQLite(), - Migrations: []Migration{FileMigration{Name: "01"}}, + m: dmorph.Morpher{ + Dialect: dmorph.DialectSQLite(), + Migrations: []dmorph.Migration{dmorph.FileMigration{Name: "01"}}, TableName: "blah(); DROP TABLE blah;", }, - err: ErrMigrationTableNameInvalid, + err: dmorph.ErrMigrationTableNameInvalid, }, } @@ -381,10 +312,10 @@ func TestMigrationWithLogger(t *testing.T) { Level: slog.LevelWarn, })) - morpher, err := NewMorpher( - WithDialect(DialectSQLite()), - WithMigrationFromFile("testData/01_base_table.sql"), - WithLog(l), + morpher, err := dmorph.NewMorpher( + dmorph.WithDialect(dmorph.DialectSQLite()), + dmorph.WithMigrationFromFile("testData/01_base_table.sql"), + dmorph.WithLog(l), ) assert.NoError(t, err, "morpher could not be created") @@ -393,8 +324,8 @@ func TestMigrationWithLogger(t *testing.T) { // TestMigrationWithoutMigrations ensures that creating a Morpher instance without migrations results in an error. func TestMigrationWithoutMigrations(t *testing.T) { - _, err := NewMorpher( - WithDialect(DialectSQLite()), + _, err := dmorph.NewMorpher( + dmorph.WithDialect(dmorph.DialectSQLite()), ) assert.Error(t, err, "morpher created without migrations") @@ -403,10 +334,10 @@ func TestMigrationWithoutMigrations(t *testing.T) { // TestMigrationWithTableNameValid verifies the correct creation of a Morpher // with a valid custom table name configuration. func TestMigrationWithTableNameValid(t *testing.T) { - morpher, err := NewMorpher( - WithDialect(DialectSQLite()), - WithMigrationFromFile("testData/01_base_table.sql"), - WithTableName("dimorphodon"), + morpher, err := dmorph.NewMorpher( + dmorph.WithDialect(dmorph.DialectSQLite()), + dmorph.WithMigrationFromFile("testData/01_base_table.sql"), + dmorph.WithTableName("dimorphodon"), ) assert.NoError(t, err, "morpher could not be created") @@ -416,193 +347,127 @@ func TestMigrationWithTableNameValid(t *testing.T) { // TestMigrationWithTableNameInvalidSize verifies that creating a Morpher // with an invalid table name size produces an error. func TestMigrationWithTableNameInvalidSize(t *testing.T) { - _, err := NewMorpher( - WithDialect(DialectSQLite()), - WithMigrationFromFile("testData/01_base_table.sql"), - WithTableName(""), + _, err := dmorph.NewMorpher( + dmorph.WithDialect(dmorph.DialectSQLite()), + dmorph.WithMigrationFromFile("testData/01_base_table.sql"), + dmorph.WithTableName(""), ) - assert.Error(t, err, "morpher could created with empty table name") + assert.Error(t, err, "morpher could be created with empty table name") } // TestMigrationWithTableNameInvalidChars ensures that creating a Morpher // fails when the table name contains invalid characters. func TestMigrationWithTableNameInvalidChars(t *testing.T) { - _, err := NewMorpher( - WithDialect(DialectSQLite()), - WithMigrationFromFile("testData/01_base_table.sql"), - WithTableName("di/mor/pho/don"), + _, err := dmorph.NewMorpher( + dmorph.WithDialect(dmorph.DialectSQLite()), + dmorph.WithMigrationFromFile("testData/01_base_table.sql"), + dmorph.WithTableName("di/mor/pho/don"), ) - assert.Error(t, err, "morpher could created with invalid table name") + assert.Error(t, err, "morpher could be created with invalid table name") } // TestMigrationRunInvalid verifies that running a Morpher with invalid configuration results in an error. func TestMigrationRunInvalid(t *testing.T) { - morpher := Morpher{} + morpher := dmorph.Morpher{} - runErr := morpher.Run(nil) + runErr := morpher.Run(t.Context(), nil) - assert.Error(t, runErr, "morpher should run") + assert.Error(t, runErr, "morpher should not run") } // TestMigrationRunInvalidCreate tests the behavior of running a migration // with an invalid CreateTemplate in the dialect. func TestMigrationRunInvalidCreate(t *testing.T) { - dbFile, dbFileErr := prepareDB() - - if dbFileErr != nil { - t.Errorf("DB file could not be created: %v", dbFileErr) - } else { - defer func() { _ = os.Remove(dbFile) }() - } - - db, dbErr := sql.Open("sqlite", dbFile) - - if dbErr != nil { - t.Errorf("DB file could not be created: %v", dbErr) - } else { - defer func() { _ = db.Close() }() - } + db := openTempSQLite(t) - dialect := DialectSQLite() + dialect := dmorph.DialectSQLite() dialect.CreateTemplate = "utter nonsense" - morpher, morpherErr := NewMorpher( - WithDialect(dialect), - WithMigrationFromFile("testData/01_base_table.sql")) + morpher, morpherErr := dmorph.NewMorpher( + dmorph.WithDialect(dialect), + dmorph.WithMigrationFromFile("testData/01_base_table.sql")) - assert.NoError(t, morpherErr, "morpher could not be created") + require.NoError(t, morpherErr, "morpher could not be created") - runErr := morpher.Run(db) + runErr := morpher.Run(t.Context(), db) assert.Error(t, runErr, "morpher should not run") } // TestMigrationRunInvalidApplied tests the failure scenario where the AppliedTemplate of the dialect is invalid. func TestMigrationRunInvalidApplied(t *testing.T) { - dbFile, dbFileErr := prepareDB() - - if dbFileErr != nil { - t.Errorf("DB file could not be created: %v", dbFileErr) - } else { - defer func() { _ = os.Remove(dbFile) }() - } - - db, dbErr := sql.Open("sqlite", dbFile) + db := openTempSQLite(t) - if dbErr != nil { - t.Errorf("DB file could not be created: %v", dbErr) - } else { - defer func() { _ = db.Close() }() - } - - dialect := DialectSQLite() + dialect := dmorph.DialectSQLite() dialect.AppliedTemplate = "utter nonsense" - morpher, morpherErr := NewMorpher( - WithDialect(dialect), - WithMigrationFromFile("testData/01_base_table.sql")) + morpher, morpherErr := dmorph.NewMorpher( + dmorph.WithDialect(dialect), + dmorph.WithMigrationFromFile("testData/01_base_table.sql")) - assert.NoError(t, morpherErr, "morpher could not be created") + require.NoError(t, morpherErr, "morpher could not be created") - runErr := morpher.Run(db) + runErr := morpher.Run(t.Context(), db) assert.Error(t, runErr, "morpher should not run") } // TestMigrationApplyInvalidDB verifies that applying migrations to an invalid or closed database results in an error. func TestMigrationApplyInvalidDB(t *testing.T) { - dbFile, dbFileErr := prepareDB() + db := openTempSQLite(t) - if dbFileErr != nil { - t.Errorf("DB file could not be created: %v", dbFileErr) - } else { - defer func() { _ = os.Remove(dbFile) }() - } + morpher, morpherErr := dmorph.NewMorpher( + dmorph.WithDialect(dmorph.DialectSQLite()), + dmorph.WithMigrationFromFile("testData/01_base_table.sql")) - db, dbErr := sql.Open("sqlite", dbFile) - - if dbErr != nil { - t.Errorf("DB file could not be created: %v", dbErr) - } else { - _ = db.Close() - } - - morpher, morpherErr := NewMorpher( - WithDialect(DialectSQLite()), - WithMigrationFromFile("testData/01_base_table.sql")) - - assert.NoError(t, morpherErr, "morpher could not be created") + require.NoError(t, morpherErr, "morpher could not be created") assert.Error(t, - morpher.applyMigrations(db, "irrelevant"), + morpher.TapplyMigrations(t.Context(), db, "irrelevant"), "morpher should error on invalid DB") } // TestMigrationApplyUnableRegister tests the behavior when the migration registration fails due to an invalid template. func TestMigrationApplyUnableRegister(t *testing.T) { - dbFile, dbFileErr := prepareDB() - - if dbFileErr != nil { - t.Errorf("DB file could not be created: %v", dbFileErr) - } else { - defer func() { _ = os.Remove(dbFile) }() - } - - db, dbErr := sql.Open("sqlite", dbFile) + db := openTempSQLite(t) - if dbErr != nil { - t.Errorf("DB file could not be created: %v", dbErr) - } else { - defer func() { _ = db.Close() }() - } + morpher, morpherErr := dmorph.NewMorpher( + dmorph.WithDialect(dmorph.DialectSQLite()), + dmorph.WithMigrationFromFile("testData/01_base_table.sql")) - morpher, morpherErr := NewMorpher( - WithDialect(DialectSQLite()), - WithMigrationFromFile("testData/01_base_table.sql")) + require.NoError(t, morpherErr, "morpher could not be created") - assert.NoError(t, morpherErr, "morpher could not be created") + d, dialectOK := morpher.Dialect.(dmorph.BaseDialect) + require.True(t, dialectOK, "dialect is not a BaseDialect") - d, _ := morpher.Dialect.(BaseDialect) d.RegisterTemplate = "utter nonsense" morpher.Dialect = d assert.Error(t, - morpher.applyMigrations(db, ""), + morpher.TapplyMigrations(t.Context(), db, ""), "morpher should fail to register") } -// TestMigrationApplyUnableCommit tests the scenario where migration application fails +// TestMigrationApplyUnableCommit tests the scenario where a migration application fails // due to inability to commit a transaction. func TestMigrationApplyUnableCommit(t *testing.T) { - dbFile, dbFileErr := prepareDB() + db := openTempSQLite(t) - if dbFileErr != nil { - t.Errorf("DB file could not be created: %v", dbFileErr) - } else { - defer func() { _ = os.Remove(dbFile) }() - } + morpher, morpherErr := dmorph.NewMorpher( + dmorph.WithDialect(dmorph.DialectSQLite()), + dmorph.WithMigrationFromFile("testData/01_base_table.sql")) - db, dbErr := sql.Open("sqlite", dbFile) - - if dbErr != nil { - t.Errorf("DB file could not be created: %v", dbErr) - } else { - defer func() { _ = db.Close() }() - } + require.NoError(t, morpherErr, "morpher could not be created") - morpher, morpherErr := NewMorpher( - WithDialect(DialectSQLite()), - WithMigrationFromFile("testData/01_base_table.sql")) + _, execErr := db.ExecContext(t.Context(), "PRAGMA foreign_keys = ON") + require.NoError(t, execErr, "foreign keys checking could not be enabled") - assert.NoError(t, morpherErr, "morpher could not be created") + baseDialect, dialectOK := morpher.Dialect.(dmorph.BaseDialect) + require.True(t, dialectOK, "dialect is not a BaseDialect") - _, execErr := db.Exec("PRAGMA foreign_keys = ON") - assert.NoError(t, execErr, "foreign keys checking could not be enabled") - - d, _ := morpher.Dialect.(BaseDialect) - d.RegisterTemplate = ` + baseDialect.RegisterTemplate = ` CREATE TABLE t0 ( id INTEGER PRIMARY KEY ); @@ -618,9 +483,9 @@ func TestMigrationApplyUnableCommit(t *testing.T) { -- %s catching argument DELETE FROM t0 WHERE id = 1;` - morpher.Dialect = d + morpher.Dialect = baseDialect assert.Error(t, - morpher.applyMigrations(db, ""), + morpher.TapplyMigrations(t.Context(), db, ""), "morpher should fail to register") }