From 80fb69be1a0a3b8dc0d7ad9d11bfab6ab0c5f730 Mon Sep 17 00:00:00 2001 From: Sid Olofsson Date: Tue, 28 Jul 2026 14:06:08 +0200 Subject: [PATCH 1/9] Add -statement-delimiter CLI flag for custom migration statement splitting Adds a StatementDelimiter field to migrate.Migrate. When set, each migration is split on the delimiter before being passed to the database driver, with each fragment executed as a separate Run call. This allows migrations containing SQL bodies with embedded semicolons (e.g. DO $$ ... $$;) to be split reliably without touching the database URL. The CLI flag wraps the user-supplied string with newlines so the delimiter must appear as a complete line: migrate -statement-delimiter '---' up Existing behaviour is unchanged when the flag is not set (nil delimiter takes the original single-Run path). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- database/postgres/README.md | 15 ++++++ internal/cli/main.go | 21 +++++--- migrate.go | 25 ++++++++- migrate_delimiter_test.go | 103 ++++++++++++++++++++++++++++++++++++ 4 files changed, 154 insertions(+), 10 deletions(-) create mode 100644 migrate_delimiter_test.go diff --git a/database/postgres/README.md b/database/postgres/README.md index bc823f4e1..592690f51 100644 --- a/database/postgres/README.md +++ b/database/postgres/README.md @@ -37,3 +37,18 @@ In PostgreSQL running multiple SQL statements in one `Exec` executes them inside behavior is not desirable because some statements can be only run outside of transaction (e.g. `CREATE INDEX CONCURRENTLY`). If you want to use `CREATE INDEX CONCURRENTLY` without activating multi-statement mode you have to put such statements in a separate migration files. + +### Custom statement delimiter + +If your migrations contain SQL bodies with embedded semicolons (e.g. `DO $$ BEGIN ... END $$;`), the +semicolon-based splitting used by `x-multi-statement` will produce incorrect statement boundaries. Instead, you +can use the CLI `-statement-delimiter` flag to split migrations on a delimiter line you control: + +``` +migrate -database "$DATABASE_URL" -source "file://migrations" \ + -statement-delimiter '---' up +``` + +Place a line containing only `---` between each statement in your migration file. The delimiter is matched +as a whole line and works with any database driver — no URL modification required. + diff --git a/internal/cli/main.go b/internal/cli/main.go index c7a3bd74a..036c8cfed 100644 --- a/internal/cli/main.go +++ b/internal/cli/main.go @@ -68,6 +68,7 @@ func Main(version string) { pathPtr := flag.String("path", "", "") databasePtr := flag.String("database", "", "") sourcePtr := flag.String("source", "", "") + statementDelimiterPtr := flag.String("statement-delimiter", "", "") flag.Usage = func() { fmt.Fprintf(os.Stderr, @@ -75,14 +76,15 @@ func Main(version string) { migrate [ -version | -help ] Options: - -source Location of the migrations (driver://url) - -path Shorthand for -source=file://path - -database Run migrations against this database (driver://url) - -prefetch N Number of migrations to load in advance before executing (default 10) - -lock-timeout N Allow N seconds to acquire database lock (default 15) - -verbose Print verbose logging - -version Print version - -help Print usage + -source Location of the migrations (driver://url) + -path Shorthand for -source=file://path + -database Run migrations against this database (driver://url) + -prefetch N Number of migrations to load in advance before executing (default 10) + -lock-timeout N Allow N seconds to acquire database lock (default 15) + -statement-delimiter Split migrations on lines matching this literal string before executing + -verbose Print verbose logging + -version Print version + -help Print usage Commands: %s @@ -134,6 +136,9 @@ Database drivers: `+strings.Join(database.List(), ", ")+"\n", createUsage, gotoU migrater.Log = log migrater.PrefetchMigrations = *prefetchPtr migrater.LockTimeout = time.Duration(int64(*lockTimeoutPtr)) * time.Second + if *statementDelimiterPtr != "" { + migrater.StatementDelimiter = []byte("\n" + *statementDelimiterPtr + "\n") + } // handle Ctrl+c signals := make(chan os.Signal, 1) diff --git a/migrate.go b/migrate.go index 7cac0ba1b..4a2604c77 100644 --- a/migrate.go +++ b/migrate.go @@ -5,8 +5,10 @@ package migrate import ( + "bytes" "errors" "fmt" + "io" "os" "sync" "time" @@ -78,6 +80,13 @@ type Migrate struct { // LockTimeout defaults to DefaultLockTimeout, // but can be set per Migrate instance. LockTimeout time.Duration + + // StatementDelimiter, if non-nil, is used to split each migration into + // individual statements before passing them to the database driver. + // The delimiter is matched as a literal byte sequence. To require it to + // appear on its own line, include surrounding newlines (e.g. []byte("\n---\n")). + // If nil (the default), each migration is passed to the driver as-is. + StatementDelimiter []byte } // New returns a new Migrate instance from a source URL and a database URL. @@ -741,8 +750,20 @@ func (m *Migrate) runMigrations(ret <-chan interface{}) error { if migr.Body != nil { m.logVerbosePrintf("Read and execute %v\n", migr.LogString()) - if err := m.databaseDrv.Run(migr.BufferedBody); err != nil { - return err + if m.StatementDelimiter != nil { + content, err := io.ReadAll(migr.BufferedBody) + if err != nil { + return err + } + for _, stmt := range bytes.SplitAfter(content, m.StatementDelimiter) { + if err := m.databaseDrv.Run(bytes.NewReader(stmt)); err != nil { + return err + } + } + } else { + if err := m.databaseDrv.Run(migr.BufferedBody); err != nil { + return err + } } } diff --git a/migrate_delimiter_test.go b/migrate_delimiter_test.go new file mode 100644 index 000000000..1498b82a4 --- /dev/null +++ b/migrate_delimiter_test.go @@ -0,0 +1,103 @@ +package migrate + +import ( + "testing" + + dStub "github.com/golang-migrate/migrate/v4/database/stub" + "github.com/golang-migrate/migrate/v4/source" + sStub "github.com/golang-migrate/migrate/v4/source/stub" +) + +// newMigrateWithContent creates a Migrate instance backed by stub drivers. +// The single up migration at version 1 has the given content as its body. +func newMigrateWithContent(t *testing.T, content string) (*Migrate, *dStub.Stub) { + t.Helper() + + migrations := source.NewMigrations() + migrations.Append(&source.Migration{Version: 1, Direction: source.Up, Identifier: content}) + + m, err := New("stub://", "stub://") + if err != nil { + t.Fatal(err) + } + m.sourceDrv.(*sStub.Stub).Migrations = migrations + + dbDrv := m.databaseDrv.(*dStub.Stub) + return m, dbDrv +} + +// TestStatementDelimiterSplitsFragments verifies that when StatementDelimiter is +// set, each fragment between delimiter lines is passed to the driver as a +// separate Run call. +func TestStatementDelimiterSplitsFragments(t *testing.T) { + const content = "stmt1;\n---\nstmt2;" + m, dbDrv := newMigrateWithContent(t, content) + m.StatementDelimiter = []byte("\n---\n") + + if err := m.Up(); err != nil { + t.Fatal(err) + } + + // multistmt.Parse includes the delimiter bytes in the first fragment. + want := []string{"stmt1;\n---\n", "stmt2;"} + if !dbDrv.EqualSequence(want) { + t.Errorf("MigrationSequence = %q, want %q", dbDrv.MigrationSequence, want) + } +} + +// TestStatementDelimiterDollarQuotedBody verifies that a migration containing a +// DO $$ ... $$; block (which contains semicolons) is split correctly when a +// newline-anchored delimiter is used, so the inner semicolons are never split on. +func TestStatementDelimiterDollarQuotedBody(t *testing.T) { + const content = "CREATE TABLE foo (id INT);\n---\nDO $$ BEGIN RAISE NOTICE 'hi'; END $$;" + m, dbDrv := newMigrateWithContent(t, content) + m.StatementDelimiter = []byte("\n---\n") + + if err := m.Up(); err != nil { + t.Fatal(err) + } + + want := []string{ + "CREATE TABLE foo (id INT);\n---\n", + "DO $$ BEGIN RAISE NOTICE 'hi'; END $$;", + } + if !dbDrv.EqualSequence(want) { + t.Errorf("MigrationSequence = %q, want %q", dbDrv.MigrationSequence, want) + } +} + +// TestStatementDelimiterNilPreservesExistingBehavior verifies that when +// StatementDelimiter is nil (the default), the migration body is passed to the +// driver in a single Run call with its content unchanged. +func TestStatementDelimiterNilPreservesExistingBehavior(t *testing.T) { + const content = "stmt1; stmt2;" + m, dbDrv := newMigrateWithContent(t, content) + // StatementDelimiter is nil by default — no split should occur. + + if err := m.Up(); err != nil { + t.Fatal(err) + } + + want := []string{content} + if !dbDrv.EqualSequence(want) { + t.Errorf("MigrationSequence = %q, want %q", dbDrv.MigrationSequence, want) + } +} + +// TestStatementDelimiterAbsentInContent verifies that when the delimiter is set +// but does not appear in the migration body, the entire body is passed as a +// single statement. +func TestStatementDelimiterAbsentInContent(t *testing.T) { + const content = "CREATE TABLE bar (id INT);" + m, dbDrv := newMigrateWithContent(t, content) + m.StatementDelimiter = []byte("\n---\n") + + if err := m.Up(); err != nil { + t.Fatal(err) + } + + want := []string{content} + if !dbDrv.EqualSequence(want) { + t.Errorf("MigrationSequence = %q, want %q", dbDrv.MigrationSequence, want) + } +} From 580b7b4a72dd5bd18a31bec5a50a56534baf9415 Mon Sep 17 00:00:00 2001 From: Sid Olofsson Date: Tue, 28 Jul 2026 14:44:46 +0200 Subject: [PATCH 2/9] Rename statement delimiter to migration splitter Replace statement-delimiter terminology with migration-splitter across the CLI, core migrate options, tests, and Postgres docs.\n\nMigration pieces are now described as sequential steps executed via separate database calls. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- database/postgres/README.md | 12 ++++++------ internal/cli/main.go | 8 ++++---- migrate.go | 14 +++++++------- migrate_delimiter_test.go | 26 +++++++++++++------------- 4 files changed, 30 insertions(+), 30 deletions(-) diff --git a/database/postgres/README.md b/database/postgres/README.md index 592690f51..4322b9018 100644 --- a/database/postgres/README.md +++ b/database/postgres/README.md @@ -38,17 +38,17 @@ behavior is not desirable because some statements can be only run outside of tra `CREATE INDEX CONCURRENTLY`). If you want to use `CREATE INDEX CONCURRENTLY` without activating multi-statement mode you have to put such statements in a separate migration files. -### Custom statement delimiter +### Custom migration splitter If your migrations contain SQL bodies with embedded semicolons (e.g. `DO $$ BEGIN ... END $$;`), the semicolon-based splitting used by `x-multi-statement` will produce incorrect statement boundaries. Instead, you -can use the CLI `-statement-delimiter` flag to split migrations on a delimiter line you control: +can use the CLI `-migration-splitter` flag to split a migration into steps using a delimiter line you control: ``` migrate -database "$DATABASE_URL" -source "file://migrations" \ - -statement-delimiter '---' up + -migration-splitter '---' up ``` -Place a line containing only `---` between each statement in your migration file. The delimiter is matched -as a whole line and works with any database driver — no URL modification required. - +Place a line containing only `---` between each step in your migration file. The splitter is matched +as a whole line, and each step is executed sequentially in a separate database call. This works with +any database driver — no URL modification required. diff --git a/internal/cli/main.go b/internal/cli/main.go index 036c8cfed..708f44a0c 100644 --- a/internal/cli/main.go +++ b/internal/cli/main.go @@ -68,7 +68,7 @@ func Main(version string) { pathPtr := flag.String("path", "", "") databasePtr := flag.String("database", "", "") sourcePtr := flag.String("source", "", "") - statementDelimiterPtr := flag.String("statement-delimiter", "", "") + migrationSplitterPtr := flag.String("migration-splitter", "", "") flag.Usage = func() { fmt.Fprintf(os.Stderr, @@ -81,7 +81,7 @@ Options: -database Run migrations against this database (driver://url) -prefetch N Number of migrations to load in advance before executing (default 10) -lock-timeout N Allow N seconds to acquire database lock (default 15) - -statement-delimiter Split migrations on lines matching this literal string before executing + -migration-splitter Split migrations into sequential steps on lines matching this literal string -verbose Print verbose logging -version Print version -help Print usage @@ -136,8 +136,8 @@ Database drivers: `+strings.Join(database.List(), ", ")+"\n", createUsage, gotoU migrater.Log = log migrater.PrefetchMigrations = *prefetchPtr migrater.LockTimeout = time.Duration(int64(*lockTimeoutPtr)) * time.Second - if *statementDelimiterPtr != "" { - migrater.StatementDelimiter = []byte("\n" + *statementDelimiterPtr + "\n") + if *migrationSplitterPtr != "" { + migrater.MigrationSplitter = []byte("\n" + *migrationSplitterPtr + "\n") } // handle Ctrl+c diff --git a/migrate.go b/migrate.go index 4a2604c77..2df055535 100644 --- a/migrate.go +++ b/migrate.go @@ -81,12 +81,12 @@ type Migrate struct { // but can be set per Migrate instance. LockTimeout time.Duration - // StatementDelimiter, if non-nil, is used to split each migration into - // individual statements before passing them to the database driver. - // The delimiter is matched as a literal byte sequence. To require it to + // MigrationSplitter, if non-nil, splits each migration into delimiter-separated + // steps before passing each step to the database driver in order. + // The splitter is matched as a literal byte sequence. To require it to // appear on its own line, include surrounding newlines (e.g. []byte("\n---\n")). // If nil (the default), each migration is passed to the driver as-is. - StatementDelimiter []byte + MigrationSplitter []byte } // New returns a new Migrate instance from a source URL and a database URL. @@ -750,13 +750,13 @@ func (m *Migrate) runMigrations(ret <-chan interface{}) error { if migr.Body != nil { m.logVerbosePrintf("Read and execute %v\n", migr.LogString()) - if m.StatementDelimiter != nil { + if m.MigrationSplitter != nil { content, err := io.ReadAll(migr.BufferedBody) if err != nil { return err } - for _, stmt := range bytes.SplitAfter(content, m.StatementDelimiter) { - if err := m.databaseDrv.Run(bytes.NewReader(stmt)); err != nil { + for _, step := range bytes.SplitAfter(content, m.MigrationSplitter) { + if err := m.databaseDrv.Run(bytes.NewReader(step)); err != nil { return err } } diff --git a/migrate_delimiter_test.go b/migrate_delimiter_test.go index 1498b82a4..ee3564b19 100644 --- a/migrate_delimiter_test.go +++ b/migrate_delimiter_test.go @@ -26,13 +26,13 @@ func newMigrateWithContent(t *testing.T, content string) (*Migrate, *dStub.Stub) return m, dbDrv } -// TestStatementDelimiterSplitsFragments verifies that when StatementDelimiter is +// TestMigrationSplitterSplitsIntoSteps verifies that when MigrationSplitter is // set, each fragment between delimiter lines is passed to the driver as a // separate Run call. -func TestStatementDelimiterSplitsFragments(t *testing.T) { +func TestMigrationSplitterSplitsIntoSteps(t *testing.T) { const content = "stmt1;\n---\nstmt2;" m, dbDrv := newMigrateWithContent(t, content) - m.StatementDelimiter = []byte("\n---\n") + m.MigrationSplitter = []byte("\n---\n") if err := m.Up(); err != nil { t.Fatal(err) @@ -45,13 +45,13 @@ func TestStatementDelimiterSplitsFragments(t *testing.T) { } } -// TestStatementDelimiterDollarQuotedBody verifies that a migration containing a +// TestMigrationSplitterDollarQuotedBody verifies that a migration containing a // DO $$ ... $$; block (which contains semicolons) is split correctly when a // newline-anchored delimiter is used, so the inner semicolons are never split on. -func TestStatementDelimiterDollarQuotedBody(t *testing.T) { +func TestMigrationSplitterDollarQuotedBody(t *testing.T) { const content = "CREATE TABLE foo (id INT);\n---\nDO $$ BEGIN RAISE NOTICE 'hi'; END $$;" m, dbDrv := newMigrateWithContent(t, content) - m.StatementDelimiter = []byte("\n---\n") + m.MigrationSplitter = []byte("\n---\n") if err := m.Up(); err != nil { t.Fatal(err) @@ -66,13 +66,13 @@ func TestStatementDelimiterDollarQuotedBody(t *testing.T) { } } -// TestStatementDelimiterNilPreservesExistingBehavior verifies that when -// StatementDelimiter is nil (the default), the migration body is passed to the +// TestMigrationSplitterNilPreservesExistingBehavior verifies that when +// MigrationSplitter is nil (the default), the migration body is passed to the // driver in a single Run call with its content unchanged. -func TestStatementDelimiterNilPreservesExistingBehavior(t *testing.T) { +func TestMigrationSplitterNilPreservesExistingBehavior(t *testing.T) { const content = "stmt1; stmt2;" m, dbDrv := newMigrateWithContent(t, content) - // StatementDelimiter is nil by default — no split should occur. + // MigrationSplitter is nil by default — no split should occur. if err := m.Up(); err != nil { t.Fatal(err) @@ -84,13 +84,13 @@ func TestStatementDelimiterNilPreservesExistingBehavior(t *testing.T) { } } -// TestStatementDelimiterAbsentInContent verifies that when the delimiter is set +// TestMigrationSplitterAbsentInContent verifies that when the splitter is set // but does not appear in the migration body, the entire body is passed as a // single statement. -func TestStatementDelimiterAbsentInContent(t *testing.T) { +func TestMigrationSplitterAbsentInContent(t *testing.T) { const content = "CREATE TABLE bar (id INT);" m, dbDrv := newMigrateWithContent(t, content) - m.StatementDelimiter = []byte("\n---\n") + m.MigrationSplitter = []byte("\n---\n") if err := m.Up(); err != nil { t.Fatal(err) From 839f8d214cb5efb47e005a215e225e3c379543b3 Mon Sep 17 00:00:00 2001 From: Sid Olofsson Date: Tue, 28 Jul 2026 14:53:26 +0200 Subject: [PATCH 3/9] Remove migration splitter delimiter from executed steps Switch migration splitting from bytes.SplitAfter to bytes.Split so splitter delimiters are not sent to the database driver. Update migration splitter tests to assert delimiter-free step execution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- migrate.go | 2 +- migrate_delimiter_test.go | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/migrate.go b/migrate.go index 2df055535..d46bb1af8 100644 --- a/migrate.go +++ b/migrate.go @@ -755,7 +755,7 @@ func (m *Migrate) runMigrations(ret <-chan interface{}) error { if err != nil { return err } - for _, step := range bytes.SplitAfter(content, m.MigrationSplitter) { + for _, step := range bytes.Split(content, m.MigrationSplitter) { if err := m.databaseDrv.Run(bytes.NewReader(step)); err != nil { return err } diff --git a/migrate_delimiter_test.go b/migrate_delimiter_test.go index ee3564b19..16839bdbd 100644 --- a/migrate_delimiter_test.go +++ b/migrate_delimiter_test.go @@ -27,7 +27,7 @@ func newMigrateWithContent(t *testing.T, content string) (*Migrate, *dStub.Stub) } // TestMigrationSplitterSplitsIntoSteps verifies that when MigrationSplitter is -// set, each fragment between delimiter lines is passed to the driver as a +// set, each step between delimiter lines is passed to the driver as a // separate Run call. func TestMigrationSplitterSplitsIntoSteps(t *testing.T) { const content = "stmt1;\n---\nstmt2;" @@ -38,8 +38,7 @@ func TestMigrationSplitterSplitsIntoSteps(t *testing.T) { t.Fatal(err) } - // multistmt.Parse includes the delimiter bytes in the first fragment. - want := []string{"stmt1;\n---\n", "stmt2;"} + want := []string{"stmt1;", "stmt2;"} if !dbDrv.EqualSequence(want) { t.Errorf("MigrationSequence = %q, want %q", dbDrv.MigrationSequence, want) } @@ -58,7 +57,7 @@ func TestMigrationSplitterDollarQuotedBody(t *testing.T) { } want := []string{ - "CREATE TABLE foo (id INT);\n---\n", + "CREATE TABLE foo (id INT);", "DO $$ BEGIN RAISE NOTICE 'hi'; END $$;", } if !dbDrv.EqualSequence(want) { From 53781d397608738c0c61f87ddf3104bae562540e Mon Sep 17 00:00:00 2001 From: Sid Olofsson Date: Tue, 28 Jul 2026 14:56:34 +0200 Subject: [PATCH 4/9] Skip empty trailing migration steps When a migration ends with the migration splitter delimiter, bytes.Split returns a trailing empty fragment. Skip empty fragments before Run calls to avoid empty-query errors in strict drivers. Add test coverage for trailing-delimiter behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- migrate.go | 3 +++ migrate_delimiter_test.go | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/migrate.go b/migrate.go index d46bb1af8..cb568f702 100644 --- a/migrate.go +++ b/migrate.go @@ -756,6 +756,9 @@ func (m *Migrate) runMigrations(ret <-chan interface{}) error { return err } for _, step := range bytes.Split(content, m.MigrationSplitter) { + if len(step) == 0 { + continue + } if err := m.databaseDrv.Run(bytes.NewReader(step)); err != nil { return err } diff --git a/migrate_delimiter_test.go b/migrate_delimiter_test.go index 16839bdbd..4ba240d52 100644 --- a/migrate_delimiter_test.go +++ b/migrate_delimiter_test.go @@ -100,3 +100,21 @@ func TestMigrationSplitterAbsentInContent(t *testing.T) { t.Errorf("MigrationSequence = %q, want %q", dbDrv.MigrationSequence, want) } } + +// TestMigrationSplitterTrailingDelimiterSkipsEmptyStep verifies that when a +// migration ends with the splitter delimiter, the final empty split fragment +// is not executed. +func TestMigrationSplitterTrailingDelimiterSkipsEmptyStep(t *testing.T) { + const content = "stmt1;\n---\nstmt2;\n---\n" + m, dbDrv := newMigrateWithContent(t, content) + m.MigrationSplitter = []byte("\n---\n") + + if err := m.Up(); err != nil { + t.Fatal(err) + } + + want := []string{"stmt1;", "stmt2;"} + if !dbDrv.EqualSequence(want) { + t.Errorf("MigrationSequence = %q, want %q", dbDrv.MigrationSequence, want) + } +} From b9c3c2aa3f20fece6d666bb59f5e5f49b07a0b02 Mon Sep 17 00:00:00 2001 From: Sid Olofsson Date: Tue, 28 Jul 2026 15:27:51 +0200 Subject: [PATCH 5/9] Harden migration splitter edge-case handling Implement whole-line migration splitter matching with LF/CRLF support and file-boundary handling. Treat empty splitter as disabled, skip whitespace-only split steps, and keep non-split behavior unchanged. Update CLI/help semantics and Postgres docs, and expand splitter tests for empty splitter, CRLF, boundary lines, whitespace-only fragments, and trailing delimiters. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- database/postgres/README.md | 7 ++-- internal/cli/main.go | 4 +- migrate.go | 69 +++++++++++++++++++++++++++++---- migrate_delimiter_test.go | 76 +++++++++++++++++++++++++++++++++++-- 4 files changed, 139 insertions(+), 17 deletions(-) diff --git a/database/postgres/README.md b/database/postgres/README.md index 4322b9018..c65b31031 100644 --- a/database/postgres/README.md +++ b/database/postgres/README.md @@ -49,6 +49,7 @@ migrate -database "$DATABASE_URL" -source "file://migrations" \ -migration-splitter '---' up ``` -Place a line containing only `---` between each step in your migration file. The splitter is matched -as a whole line, and each step is executed sequentially in a separate database call. This works with -any database driver — no URL modification required. +Place a line containing only `---` between each step in your migration file. The splitter matches when +a whole line exactly equals the token (supports both LF and CRLF line endings), and each step is +executed sequentially in a separate database call. This works with any database driver — no URL +modification required. diff --git a/internal/cli/main.go b/internal/cli/main.go index 708f44a0c..57c40be21 100644 --- a/internal/cli/main.go +++ b/internal/cli/main.go @@ -81,7 +81,7 @@ Options: -database Run migrations against this database (driver://url) -prefetch N Number of migrations to load in advance before executing (default 10) -lock-timeout N Allow N seconds to acquire database lock (default 15) - -migration-splitter Split migrations into sequential steps on lines matching this literal string + -migration-splitter Split migrations into sequential steps when a whole line equals this string -verbose Print verbose logging -version Print version -help Print usage @@ -137,7 +137,7 @@ Database drivers: `+strings.Join(database.List(), ", ")+"\n", createUsage, gotoU migrater.PrefetchMigrations = *prefetchPtr migrater.LockTimeout = time.Duration(int64(*lockTimeoutPtr)) * time.Second if *migrationSplitterPtr != "" { - migrater.MigrationSplitter = []byte("\n" + *migrationSplitterPtr + "\n") + migrater.MigrationSplitter = []byte(*migrationSplitterPtr) } // handle Ctrl+c diff --git a/migrate.go b/migrate.go index cb568f702..e4ac08150 100644 --- a/migrate.go +++ b/migrate.go @@ -81,11 +81,10 @@ type Migrate struct { // but can be set per Migrate instance. LockTimeout time.Duration - // MigrationSplitter, if non-nil, splits each migration into delimiter-separated - // steps before passing each step to the database driver in order. - // The splitter is matched as a literal byte sequence. To require it to - // appear on its own line, include surrounding newlines (e.g. []byte("\n---\n")). - // If nil (the default), each migration is passed to the driver as-is. + // MigrationSplitter, if non-empty, splits each migration into sequential steps. + // A splitter matches only when a full logical line equals this token. + // Both LF and CRLF input line endings are recognized. + // If nil or empty, each migration is passed to the driver as-is. MigrationSplitter []byte } @@ -750,13 +749,13 @@ func (m *Migrate) runMigrations(ret <-chan interface{}) error { if migr.Body != nil { m.logVerbosePrintf("Read and execute %v\n", migr.LogString()) - if m.MigrationSplitter != nil { + if len(m.MigrationSplitter) > 0 { content, err := io.ReadAll(migr.BufferedBody) if err != nil { return err } - for _, step := range bytes.Split(content, m.MigrationSplitter) { - if len(step) == 0 { + for _, step := range splitMigrationSteps(content, m.MigrationSplitter) { + if len(bytes.TrimSpace(step)) == 0 { continue } if err := m.databaseDrv.Run(bytes.NewReader(step)); err != nil { @@ -768,6 +767,7 @@ func (m *Migrate) runMigrations(ret <-chan interface{}) error { return err } } + } // set clean state @@ -795,6 +795,59 @@ func (m *Migrate) runMigrations(ret <-chan interface{}) error { return nil } +func splitMigrationSteps(content, splitter []byte) [][]byte { + if len(splitter) == 0 { + return [][]byte{content} + } + + steps := make([][]byte, 0, 1) + currentStep := make([]byte, 0, len(content)) + + for lineStart := 0; lineStart < len(content); { + nextLineBreakOffset := bytes.IndexByte(content[lineStart:], '\n') + lineEnd := len(content) + if nextLineBreakOffset >= 0 { + lineEnd = lineStart + nextLineBreakOffset + 1 + } + + line := content[lineStart:lineEnd] + lineForMatch := line + if len(lineForMatch) > 0 && lineForMatch[len(lineForMatch)-1] == '\n' { + lineForMatch = lineForMatch[:len(lineForMatch)-1] + } + if len(lineForMatch) > 0 && lineForMatch[len(lineForMatch)-1] == '\r' { + lineForMatch = lineForMatch[:len(lineForMatch)-1] + } + + if bytes.Equal(lineForMatch, splitter) { + steps = append(steps, trimTrailingLineEnding(currentStep)) + currentStep = make([]byte, 0, len(content)-lineEnd) + } else { + currentStep = append(currentStep, line...) + } + + lineStart = lineEnd + } + + steps = append(steps, currentStep) + return steps +} + +func trimTrailingLineEnding(step []byte) []byte { + if len(step) == 0 { + return step + } + + if step[len(step)-1] == '\n' { + step = step[:len(step)-1] + if len(step) > 0 && step[len(step)-1] == '\r' { + step = step[:len(step)-1] + } + } + + return step +} + // versionExists checks the source if either the up or down migration for // the specified migration version exists. func (m *Migrate) versionExists(version uint) (result error) { diff --git a/migrate_delimiter_test.go b/migrate_delimiter_test.go index 4ba240d52..62ef1ce01 100644 --- a/migrate_delimiter_test.go +++ b/migrate_delimiter_test.go @@ -32,7 +32,7 @@ func newMigrateWithContent(t *testing.T, content string) (*Migrate, *dStub.Stub) func TestMigrationSplitterSplitsIntoSteps(t *testing.T) { const content = "stmt1;\n---\nstmt2;" m, dbDrv := newMigrateWithContent(t, content) - m.MigrationSplitter = []byte("\n---\n") + m.MigrationSplitter = []byte("---") if err := m.Up(); err != nil { t.Fatal(err) @@ -50,7 +50,7 @@ func TestMigrationSplitterSplitsIntoSteps(t *testing.T) { func TestMigrationSplitterDollarQuotedBody(t *testing.T) { const content = "CREATE TABLE foo (id INT);\n---\nDO $$ BEGIN RAISE NOTICE 'hi'; END $$;" m, dbDrv := newMigrateWithContent(t, content) - m.MigrationSplitter = []byte("\n---\n") + m.MigrationSplitter = []byte("---") if err := m.Up(); err != nil { t.Fatal(err) @@ -89,7 +89,7 @@ func TestMigrationSplitterNilPreservesExistingBehavior(t *testing.T) { func TestMigrationSplitterAbsentInContent(t *testing.T) { const content = "CREATE TABLE bar (id INT);" m, dbDrv := newMigrateWithContent(t, content) - m.MigrationSplitter = []byte("\n---\n") + m.MigrationSplitter = []byte("---") if err := m.Up(); err != nil { t.Fatal(err) @@ -107,7 +107,75 @@ func TestMigrationSplitterAbsentInContent(t *testing.T) { func TestMigrationSplitterTrailingDelimiterSkipsEmptyStep(t *testing.T) { const content = "stmt1;\n---\nstmt2;\n---\n" m, dbDrv := newMigrateWithContent(t, content) - m.MigrationSplitter = []byte("\n---\n") + m.MigrationSplitter = []byte("---") + + if err := m.Up(); err != nil { + t.Fatal(err) + } + + want := []string{"stmt1;", "stmt2;"} + if !dbDrv.EqualSequence(want) { + t.Errorf("MigrationSequence = %q, want %q", dbDrv.MigrationSequence, want) + } +} + +// TestMigrationSplitterEmptyPreservesExistingBehavior verifies that an empty +// splitter is treated as disabled and the full migration body is executed once. +func TestMigrationSplitterEmptyPreservesExistingBehavior(t *testing.T) { + const content = "stmt1; stmt2;" + m, dbDrv := newMigrateWithContent(t, content) + m.MigrationSplitter = []byte{} + + if err := m.Up(); err != nil { + t.Fatal(err) + } + + want := []string{content} + if !dbDrv.EqualSequence(want) { + t.Errorf("MigrationSequence = %q, want %q", dbDrv.MigrationSequence, want) + } +} + +// TestMigrationSplitterSkipsWhitespaceOnlySteps verifies that fragments +// containing only whitespace between splitter lines are not executed. +func TestMigrationSplitterSkipsWhitespaceOnlySteps(t *testing.T) { + const content = "stmt1;\n---\n \t \r\n---\nstmt2;" + m, dbDrv := newMigrateWithContent(t, content) + m.MigrationSplitter = []byte("---") + + if err := m.Up(); err != nil { + t.Fatal(err) + } + + want := []string{"stmt1;", "stmt2;"} + if !dbDrv.EqualSequence(want) { + t.Errorf("MigrationSequence = %q, want %q", dbDrv.MigrationSequence, want) + } +} + +// TestMigrationSplitterSupportsCRLF verifies that splitter lines are detected +// in CRLF-formatted files. +func TestMigrationSplitterSupportsCRLF(t *testing.T) { + const content = "stmt1;\r\n---\r\nstmt2;" + m, dbDrv := newMigrateWithContent(t, content) + m.MigrationSplitter = []byte("---") + + if err := m.Up(); err != nil { + t.Fatal(err) + } + + want := []string{"stmt1;", "stmt2;"} + if !dbDrv.EqualSequence(want) { + t.Errorf("MigrationSequence = %q, want %q", dbDrv.MigrationSequence, want) + } +} + +// TestMigrationSplitterAtFileBoundaries verifies splitter lines are recognized +// at both the start and end of a file. +func TestMigrationSplitterAtFileBoundaries(t *testing.T) { + const content = "---\nstmt1;\n---\nstmt2;\n---" + m, dbDrv := newMigrateWithContent(t, content) + m.MigrationSplitter = []byte("---") if err := m.Up(); err != nil { t.Fatal(err) From 8d597ef306e158074959deb185283494f65c7828 Mon Sep 17 00:00:00 2001 From: Sid Olofsson Date: Tue, 28 Jul 2026 15:37:32 +0200 Subject: [PATCH 6/9] Trim splitter function and remove URL wording Reduce splitMigrationSteps to a small wrapper (<=10 lines) by delegating line-based logic to a helper. Remove the 'no URL modification required' wording from the Postgres migration-splitter docs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- database/postgres/README.md | 3 +-- migrate.go | 3 +++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/database/postgres/README.md b/database/postgres/README.md index c65b31031..2d9b3cc23 100644 --- a/database/postgres/README.md +++ b/database/postgres/README.md @@ -51,5 +51,4 @@ migrate -database "$DATABASE_URL" -source "file://migrations" \ Place a line containing only `---` between each step in your migration file. The splitter matches when a whole line exactly equals the token (supports both LF and CRLF line endings), and each step is -executed sequentially in a separate database call. This works with any database driver — no URL -modification required. +executed sequentially in a separate database call. diff --git a/migrate.go b/migrate.go index e4ac08150..92b32d311 100644 --- a/migrate.go +++ b/migrate.go @@ -799,7 +799,10 @@ func splitMigrationSteps(content, splitter []byte) [][]byte { if len(splitter) == 0 { return [][]byte{content} } + return splitMigrationStepsByLine(content, splitter) +} +func splitMigrationStepsByLine(content, splitter []byte) [][]byte { steps := make([][]byte, 0, 1) currentStep := make([]byte, 0, len(content)) From 69246feefb8f035db9a494dfc50b31b475a4eb16 Mon Sep 17 00:00:00 2001 From: Sid Olofsson Date: Tue, 28 Jul 2026 16:31:32 +0200 Subject: [PATCH 7/9] chore: cleanup refactor of PR --- migrate.go | 43 +-------- migrate_delimiter_test.go | 188 -------------------------------------- migrate_test.go | 179 ++++++++++++++++++++++++++++++++++++ 3 files changed, 183 insertions(+), 227 deletions(-) delete mode 100644 migrate_delimiter_test.go diff --git a/migrate.go b/migrate.go index 92b32d311..65e0bfba6 100644 --- a/migrate.go +++ b/migrate.go @@ -799,58 +799,23 @@ func splitMigrationSteps(content, splitter []byte) [][]byte { if len(splitter) == 0 { return [][]byte{content} } - return splitMigrationStepsByLine(content, splitter) -} -func splitMigrationStepsByLine(content, splitter []byte) [][]byte { steps := make([][]byte, 0, 1) currentStep := make([]byte, 0, len(content)) - for lineStart := 0; lineStart < len(content); { - nextLineBreakOffset := bytes.IndexByte(content[lineStart:], '\n') - lineEnd := len(content) - if nextLineBreakOffset >= 0 { - lineEnd = lineStart + nextLineBreakOffset + 1 - } - - line := content[lineStart:lineEnd] - lineForMatch := line - if len(lineForMatch) > 0 && lineForMatch[len(lineForMatch)-1] == '\n' { - lineForMatch = lineForMatch[:len(lineForMatch)-1] - } - if len(lineForMatch) > 0 && lineForMatch[len(lineForMatch)-1] == '\r' { - lineForMatch = lineForMatch[:len(lineForMatch)-1] - } - - if bytes.Equal(lineForMatch, splitter) { - steps = append(steps, trimTrailingLineEnding(currentStep)) - currentStep = make([]byte, 0, len(content)-lineEnd) + for _, line := range bytes.SplitAfter(content, []byte("\n")) { + if bytes.Equal(bytes.TrimRight(line, "\r\n"), splitter) { + steps = append(steps, currentStep) + currentStep = make([]byte, 0, len(content)-len(currentStep)) } else { currentStep = append(currentStep, line...) } - - lineStart = lineEnd } steps = append(steps, currentStep) return steps } -func trimTrailingLineEnding(step []byte) []byte { - if len(step) == 0 { - return step - } - - if step[len(step)-1] == '\n' { - step = step[:len(step)-1] - if len(step) > 0 && step[len(step)-1] == '\r' { - step = step[:len(step)-1] - } - } - - return step -} - // versionExists checks the source if either the up or down migration for // the specified migration version exists. func (m *Migrate) versionExists(version uint) (result error) { diff --git a/migrate_delimiter_test.go b/migrate_delimiter_test.go deleted file mode 100644 index 62ef1ce01..000000000 --- a/migrate_delimiter_test.go +++ /dev/null @@ -1,188 +0,0 @@ -package migrate - -import ( - "testing" - - dStub "github.com/golang-migrate/migrate/v4/database/stub" - "github.com/golang-migrate/migrate/v4/source" - sStub "github.com/golang-migrate/migrate/v4/source/stub" -) - -// newMigrateWithContent creates a Migrate instance backed by stub drivers. -// The single up migration at version 1 has the given content as its body. -func newMigrateWithContent(t *testing.T, content string) (*Migrate, *dStub.Stub) { - t.Helper() - - migrations := source.NewMigrations() - migrations.Append(&source.Migration{Version: 1, Direction: source.Up, Identifier: content}) - - m, err := New("stub://", "stub://") - if err != nil { - t.Fatal(err) - } - m.sourceDrv.(*sStub.Stub).Migrations = migrations - - dbDrv := m.databaseDrv.(*dStub.Stub) - return m, dbDrv -} - -// TestMigrationSplitterSplitsIntoSteps verifies that when MigrationSplitter is -// set, each step between delimiter lines is passed to the driver as a -// separate Run call. -func TestMigrationSplitterSplitsIntoSteps(t *testing.T) { - const content = "stmt1;\n---\nstmt2;" - m, dbDrv := newMigrateWithContent(t, content) - m.MigrationSplitter = []byte("---") - - if err := m.Up(); err != nil { - t.Fatal(err) - } - - want := []string{"stmt1;", "stmt2;"} - if !dbDrv.EqualSequence(want) { - t.Errorf("MigrationSequence = %q, want %q", dbDrv.MigrationSequence, want) - } -} - -// TestMigrationSplitterDollarQuotedBody verifies that a migration containing a -// DO $$ ... $$; block (which contains semicolons) is split correctly when a -// newline-anchored delimiter is used, so the inner semicolons are never split on. -func TestMigrationSplitterDollarQuotedBody(t *testing.T) { - const content = "CREATE TABLE foo (id INT);\n---\nDO $$ BEGIN RAISE NOTICE 'hi'; END $$;" - m, dbDrv := newMigrateWithContent(t, content) - m.MigrationSplitter = []byte("---") - - if err := m.Up(); err != nil { - t.Fatal(err) - } - - want := []string{ - "CREATE TABLE foo (id INT);", - "DO $$ BEGIN RAISE NOTICE 'hi'; END $$;", - } - if !dbDrv.EqualSequence(want) { - t.Errorf("MigrationSequence = %q, want %q", dbDrv.MigrationSequence, want) - } -} - -// TestMigrationSplitterNilPreservesExistingBehavior verifies that when -// MigrationSplitter is nil (the default), the migration body is passed to the -// driver in a single Run call with its content unchanged. -func TestMigrationSplitterNilPreservesExistingBehavior(t *testing.T) { - const content = "stmt1; stmt2;" - m, dbDrv := newMigrateWithContent(t, content) - // MigrationSplitter is nil by default — no split should occur. - - if err := m.Up(); err != nil { - t.Fatal(err) - } - - want := []string{content} - if !dbDrv.EqualSequence(want) { - t.Errorf("MigrationSequence = %q, want %q", dbDrv.MigrationSequence, want) - } -} - -// TestMigrationSplitterAbsentInContent verifies that when the splitter is set -// but does not appear in the migration body, the entire body is passed as a -// single statement. -func TestMigrationSplitterAbsentInContent(t *testing.T) { - const content = "CREATE TABLE bar (id INT);" - m, dbDrv := newMigrateWithContent(t, content) - m.MigrationSplitter = []byte("---") - - if err := m.Up(); err != nil { - t.Fatal(err) - } - - want := []string{content} - if !dbDrv.EqualSequence(want) { - t.Errorf("MigrationSequence = %q, want %q", dbDrv.MigrationSequence, want) - } -} - -// TestMigrationSplitterTrailingDelimiterSkipsEmptyStep verifies that when a -// migration ends with the splitter delimiter, the final empty split fragment -// is not executed. -func TestMigrationSplitterTrailingDelimiterSkipsEmptyStep(t *testing.T) { - const content = "stmt1;\n---\nstmt2;\n---\n" - m, dbDrv := newMigrateWithContent(t, content) - m.MigrationSplitter = []byte("---") - - if err := m.Up(); err != nil { - t.Fatal(err) - } - - want := []string{"stmt1;", "stmt2;"} - if !dbDrv.EqualSequence(want) { - t.Errorf("MigrationSequence = %q, want %q", dbDrv.MigrationSequence, want) - } -} - -// TestMigrationSplitterEmptyPreservesExistingBehavior verifies that an empty -// splitter is treated as disabled and the full migration body is executed once. -func TestMigrationSplitterEmptyPreservesExistingBehavior(t *testing.T) { - const content = "stmt1; stmt2;" - m, dbDrv := newMigrateWithContent(t, content) - m.MigrationSplitter = []byte{} - - if err := m.Up(); err != nil { - t.Fatal(err) - } - - want := []string{content} - if !dbDrv.EqualSequence(want) { - t.Errorf("MigrationSequence = %q, want %q", dbDrv.MigrationSequence, want) - } -} - -// TestMigrationSplitterSkipsWhitespaceOnlySteps verifies that fragments -// containing only whitespace between splitter lines are not executed. -func TestMigrationSplitterSkipsWhitespaceOnlySteps(t *testing.T) { - const content = "stmt1;\n---\n \t \r\n---\nstmt2;" - m, dbDrv := newMigrateWithContent(t, content) - m.MigrationSplitter = []byte("---") - - if err := m.Up(); err != nil { - t.Fatal(err) - } - - want := []string{"stmt1;", "stmt2;"} - if !dbDrv.EqualSequence(want) { - t.Errorf("MigrationSequence = %q, want %q", dbDrv.MigrationSequence, want) - } -} - -// TestMigrationSplitterSupportsCRLF verifies that splitter lines are detected -// in CRLF-formatted files. -func TestMigrationSplitterSupportsCRLF(t *testing.T) { - const content = "stmt1;\r\n---\r\nstmt2;" - m, dbDrv := newMigrateWithContent(t, content) - m.MigrationSplitter = []byte("---") - - if err := m.Up(); err != nil { - t.Fatal(err) - } - - want := []string{"stmt1;", "stmt2;"} - if !dbDrv.EqualSequence(want) { - t.Errorf("MigrationSequence = %q, want %q", dbDrv.MigrationSequence, want) - } -} - -// TestMigrationSplitterAtFileBoundaries verifies splitter lines are recognized -// at both the start and end of a file. -func TestMigrationSplitterAtFileBoundaries(t *testing.T) { - const content = "---\nstmt1;\n---\nstmt2;\n---" - m, dbDrv := newMigrateWithContent(t, content) - m.MigrationSplitter = []byte("---") - - if err := m.Up(); err != nil { - t.Fatal(err) - } - - want := []string{"stmt1;", "stmt2;"} - if !dbDrv.EqualSequence(want) { - t.Errorf("MigrationSequence = %q, want %q", dbDrv.MigrationSequence, want) - } -} diff --git a/migrate_test.go b/migrate_test.go index 19a6d8820..f11b8f661 100644 --- a/migrate_test.go +++ b/migrate_test.go @@ -1409,3 +1409,182 @@ func equalDbSeq(t *testing.T, i int, expected migrationSequence, got *dStub.Stub t.Fatalf("\nexpected sequence %v,\ngot %v, in %v", bs, got.MigrationSequence, i) } } + +// newMigrateWithContent creates a Migrate instance backed by stub drivers. +// The single up migration at version 1 has the given content as its body. +func newMigrateWithContent(t *testing.T, content string) (*Migrate, *dStub.Stub) { + t.Helper() + + migrations := source.NewMigrations() + migrations.Append(&source.Migration{Version: 1, Direction: source.Up, Identifier: content}) + + m, err := New("stub://", "stub://") + if err != nil { + t.Fatal(err) + } + m.sourceDrv.(*sStub.Stub).Migrations = migrations + + dbDrv := m.databaseDrv.(*dStub.Stub) + return m, dbDrv +} + +// TestMigrationSplitterSplitsIntoSteps verifies that when MigrationSplitter is +// set, each step between delimiter lines is passed to the driver as a +// separate Run call. +func TestMigrationSplitterSplitsIntoSteps(t *testing.T) { + const content = "stmt1;\n---\nstmt2;" + m, dbDrv := newMigrateWithContent(t, content) + m.MigrationSplitter = []byte("---") + + if err := m.Up(); err != nil { + t.Fatal(err) + } + + want := []string{"stmt1;\n", "stmt2;"} + if !dbDrv.EqualSequence(want) { + t.Errorf("MigrationSequence = %q, want %q", dbDrv.MigrationSequence, want) + } +} + +// TestMigrationSplitterDollarQuotedBody verifies that a migration containing a +// DO $$ ... $$; block (which contains semicolons) is split correctly when a +// newline-anchored delimiter is used, so the inner semicolons are never split on. +func TestMigrationSplitterDollarQuotedBody(t *testing.T) { + const content = "CREATE TABLE foo (id INT);\n---\nDO $$ BEGIN RAISE NOTICE 'hi'; END $$;" + m, dbDrv := newMigrateWithContent(t, content) + m.MigrationSplitter = []byte("---") + + if err := m.Up(); err != nil { + t.Fatal(err) + } + + want := []string{ + "CREATE TABLE foo (id INT);\n", + "DO $$ BEGIN RAISE NOTICE 'hi'; END $$;", + } + if !dbDrv.EqualSequence(want) { + t.Errorf("MigrationSequence = %q, want %q", dbDrv.MigrationSequence, want) + } +} + +// TestMigrationSplitterNilPreservesExistingBehavior verifies that when +// MigrationSplitter is nil (the default), the migration body is passed to the +// driver in a single Run call with its content unchanged. +func TestMigrationSplitterNilPreservesExistingBehavior(t *testing.T) { + const content = "stmt1; stmt2;" + m, dbDrv := newMigrateWithContent(t, content) + // MigrationSplitter is nil by default — no split should occur. + + if err := m.Up(); err != nil { + t.Fatal(err) + } + + want := []string{content} + if !dbDrv.EqualSequence(want) { + t.Errorf("MigrationSequence = %q, want %q", dbDrv.MigrationSequence, want) + } +} + +// TestMigrationSplitterAbsentInContent verifies that when the splitter is set +// but does not appear in the migration body, the entire body is passed as a +// single statement. +func TestMigrationSplitterAbsentInContent(t *testing.T) { + const content = "CREATE TABLE bar (id INT);" + m, dbDrv := newMigrateWithContent(t, content) + m.MigrationSplitter = []byte("---") + + if err := m.Up(); err != nil { + t.Fatal(err) + } + + want := []string{content} + if !dbDrv.EqualSequence(want) { + t.Errorf("MigrationSequence = %q, want %q", dbDrv.MigrationSequence, want) + } +} + +// TestMigrationSplitterTrailingDelimiterSkipsEmptyStep verifies that when a +// migration ends with the splitter delimiter, the final empty split fragment +// is not executed. +func TestMigrationSplitterTrailingDelimiterSkipsEmptyStep(t *testing.T) { + const content = "stmt1;\n---\nstmt2;\n---\n" + m, dbDrv := newMigrateWithContent(t, content) + m.MigrationSplitter = []byte("---") + + if err := m.Up(); err != nil { + t.Fatal(err) + } + + want := []string{"stmt1;\n", "stmt2;\n"} + if !dbDrv.EqualSequence(want) { + t.Errorf("MigrationSequence = %q, want %q", dbDrv.MigrationSequence, want) + } +} + +// TestMigrationSplitterEmptyPreservesExistingBehavior verifies that an empty +// splitter is treated as disabled and the full migration body is executed once. +func TestMigrationSplitterEmptyPreservesExistingBehavior(t *testing.T) { + const content = "stmt1; stmt2;" + m, dbDrv := newMigrateWithContent(t, content) + m.MigrationSplitter = []byte{} + + if err := m.Up(); err != nil { + t.Fatal(err) + } + + want := []string{content} + if !dbDrv.EqualSequence(want) { + t.Errorf("MigrationSequence = %q, want %q", dbDrv.MigrationSequence, want) + } +} + +// TestMigrationSplitterSkipsWhitespaceOnlySteps verifies that fragments +// containing only whitespace between splitter lines are not executed. +func TestMigrationSplitterSkipsWhitespaceOnlySteps(t *testing.T) { + const content = "stmt1;\n---\n \t \r\n---\nstmt2;" + m, dbDrv := newMigrateWithContent(t, content) + m.MigrationSplitter = []byte("---") + + if err := m.Up(); err != nil { + t.Fatal(err) + } + + want := []string{"stmt1;\n", "stmt2;"} + if !dbDrv.EqualSequence(want) { + t.Errorf("MigrationSequence = %q, want %q", dbDrv.MigrationSequence, want) + } +} + +// TestMigrationSplitterSupportsCRLF verifies that splitter lines are detected +// in CRLF-formatted files. +func TestMigrationSplitterSupportsCRLF(t *testing.T) { + const content = "stmt1;\r\n---\r\nstmt2;" + m, dbDrv := newMigrateWithContent(t, content) + m.MigrationSplitter = []byte("---") + + if err := m.Up(); err != nil { + t.Fatal(err) + } + + want := []string{"stmt1;\r\n", "stmt2;"} + if !dbDrv.EqualSequence(want) { + t.Errorf("MigrationSequence = %q, want %q", dbDrv.MigrationSequence, want) + } +} + +// TestMigrationSplitterAtFileBoundaries verifies splitter lines are recognized +// at both the start and end of a file. +func TestMigrationSplitterAtFileBoundaries(t *testing.T) { + const content = "---\nstmt1;\n---\nstmt2;\n---" + m, dbDrv := newMigrateWithContent(t, content) + m.MigrationSplitter = []byte("---") + + if err := m.Up(); err != nil { + t.Fatal(err) + } + + want := []string{"stmt1;\n", "stmt2;\n"} + if !dbDrv.EqualSequence(want) { + t.Errorf("MigrationSequence = %q, want %q", dbDrv.MigrationSequence, want) + } +} From 4e4e396f257ba0dc276bbb8b719b7b8ed1678655 Mon Sep 17 00:00:00 2001 From: Sid Olofsson Date: Tue, 28 Jul 2026 16:45:11 +0200 Subject: [PATCH 8/9] chore: further cleanup --- migrate.go | 21 +++++------------- migrate_test.go | 59 +++++++++++++++++++++++++++++-------------------- 2 files changed, 41 insertions(+), 39 deletions(-) diff --git a/migrate.go b/migrate.go index 65e0bfba6..e95969186 100644 --- a/migrate.go +++ b/migrate.go @@ -749,21 +749,12 @@ func (m *Migrate) runMigrations(ret <-chan interface{}) error { if migr.Body != nil { m.logVerbosePrintf("Read and execute %v\n", migr.LogString()) - if len(m.MigrationSplitter) > 0 { - content, err := io.ReadAll(migr.BufferedBody) - if err != nil { - return err - } - for _, step := range splitMigrationSteps(content, m.MigrationSplitter) { - if len(bytes.TrimSpace(step)) == 0 { - continue - } - if err := m.databaseDrv.Run(bytes.NewReader(step)); err != nil { - return err - } - } - } else { - if err := m.databaseDrv.Run(migr.BufferedBody); err != nil { + content, err := io.ReadAll(migr.BufferedBody) + if err != nil { + return err + } + for _, step := range splitMigrationSteps(content, m.MigrationSplitter) { + if err := m.databaseDrv.Run(bytes.NewReader(step)); err != nil { return err } } diff --git a/migrate_test.go b/migrate_test.go index f11b8f661..ecaedac3e 100644 --- a/migrate_test.go +++ b/migrate_test.go @@ -1428,6 +1428,23 @@ func newMigrateWithContent(t *testing.T, content string) (*Migrate, *dStub.Stub) return m, dbDrv } +var errEmptyMigrationBody = errors.New("empty migration body") + +type emptyRejectingStub struct { + *dStub.Stub +} + +func (s *emptyRejectingStub) Run(migration io.Reader) error { + m, err := io.ReadAll(migration) + if err != nil { + return err + } + if len(bytes.TrimSpace(m)) == 0 { + return errEmptyMigrationBody + } + return s.Stub.Run(bytes.NewReader(m)) +} + // TestMigrationSplitterSplitsIntoSteps verifies that when MigrationSplitter is // set, each step between delimiter lines is passed to the driver as a // separate Run call. @@ -1485,6 +1502,18 @@ func TestMigrationSplitterNilPreservesExistingBehavior(t *testing.T) { } } +// TestMigrationSplitterNilDoesNotSkipEmptyMigration verifies that when +// MigrationSplitter is nil, empty migration bodies are still passed through. +func TestMigrationSplitterNilDoesNotSkipEmptyMigration(t *testing.T) { + m, _ := newMigrateWithContent(t, "") + m.databaseDrv = &emptyRejectingStub{Stub: m.databaseDrv.(*dStub.Stub)} + + err := m.Up() + if !errors.Is(err, errEmptyMigrationBody) { + t.Fatalf("expected %v, got %v", errEmptyMigrationBody, err) + } +} + // TestMigrationSplitterAbsentInContent verifies that when the splitter is set // but does not appear in the migration body, the entire body is passed as a // single statement. @@ -1503,24 +1532,6 @@ func TestMigrationSplitterAbsentInContent(t *testing.T) { } } -// TestMigrationSplitterTrailingDelimiterSkipsEmptyStep verifies that when a -// migration ends with the splitter delimiter, the final empty split fragment -// is not executed. -func TestMigrationSplitterTrailingDelimiterSkipsEmptyStep(t *testing.T) { - const content = "stmt1;\n---\nstmt2;\n---\n" - m, dbDrv := newMigrateWithContent(t, content) - m.MigrationSplitter = []byte("---") - - if err := m.Up(); err != nil { - t.Fatal(err) - } - - want := []string{"stmt1;\n", "stmt2;\n"} - if !dbDrv.EqualSequence(want) { - t.Errorf("MigrationSequence = %q, want %q", dbDrv.MigrationSequence, want) - } -} - // TestMigrationSplitterEmptyPreservesExistingBehavior verifies that an empty // splitter is treated as disabled and the full migration body is executed once. func TestMigrationSplitterEmptyPreservesExistingBehavior(t *testing.T) { @@ -1538,9 +1549,9 @@ func TestMigrationSplitterEmptyPreservesExistingBehavior(t *testing.T) { } } -// TestMigrationSplitterSkipsWhitespaceOnlySteps verifies that fragments -// containing only whitespace between splitter lines are not executed. -func TestMigrationSplitterSkipsWhitespaceOnlySteps(t *testing.T) { +// TestMigrationSplitterIncludesWhitespaceOnlySteps verifies that fragments +// containing only whitespace between splitter lines are executed as-is. +func TestMigrationSplitterIncludesWhitespaceOnlySteps(t *testing.T) { const content = "stmt1;\n---\n \t \r\n---\nstmt2;" m, dbDrv := newMigrateWithContent(t, content) m.MigrationSplitter = []byte("---") @@ -1549,7 +1560,7 @@ func TestMigrationSplitterSkipsWhitespaceOnlySteps(t *testing.T) { t.Fatal(err) } - want := []string{"stmt1;\n", "stmt2;"} + want := []string{"stmt1;\n", " \t \r\n", "stmt2;"} if !dbDrv.EqualSequence(want) { t.Errorf("MigrationSequence = %q, want %q", dbDrv.MigrationSequence, want) } @@ -1573,7 +1584,7 @@ func TestMigrationSplitterSupportsCRLF(t *testing.T) { } // TestMigrationSplitterAtFileBoundaries verifies splitter lines are recognized -// at both the start and end of a file. +// at both the start and end of a file, including empty boundary fragments. func TestMigrationSplitterAtFileBoundaries(t *testing.T) { const content = "---\nstmt1;\n---\nstmt2;\n---" m, dbDrv := newMigrateWithContent(t, content) @@ -1583,7 +1594,7 @@ func TestMigrationSplitterAtFileBoundaries(t *testing.T) { t.Fatal(err) } - want := []string{"stmt1;\n", "stmt2;\n"} + want := []string{"", "stmt1;\n", "stmt2;\n", ""} if !dbDrv.EqualSequence(want) { t.Errorf("MigrationSequence = %q, want %q", dbDrv.MigrationSequence, want) } From 6c187a6dc741f7d6ca22cdbf0be10ece3bbc9178 Mon Sep 17 00:00:00 2001 From: Sid Olofsson Date: Tue, 28 Jul 2026 17:18:37 +0200 Subject: [PATCH 9/9] fix comments --- migrate.go | 43 +++++++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/migrate.go b/migrate.go index e95969186..1f6e80459 100644 --- a/migrate.go +++ b/migrate.go @@ -749,16 +749,21 @@ func (m *Migrate) runMigrations(ret <-chan interface{}) error { if migr.Body != nil { m.logVerbosePrintf("Read and execute %v\n", migr.LogString()) - content, err := io.ReadAll(migr.BufferedBody) - if err != nil { - return err - } - for _, step := range splitMigrationSteps(content, m.MigrationSplitter) { - if err := m.databaseDrv.Run(bytes.NewReader(step)); err != nil { + if len(m.MigrationSplitter) == 0 { + if err := m.databaseDrv.Run(migr.BufferedBody); err != nil { + return err + } + } else { + content, err := io.ReadAll(migr.BufferedBody) + if err != nil { return err } + for _, step := range splitMigrationSteps(content, m.MigrationSplitter) { + if err := m.databaseDrv.Run(bytes.NewReader(step)); err != nil { + return err + } + } } - } // set clean state @@ -792,19 +797,25 @@ func splitMigrationSteps(content, splitter []byte) [][]byte { } steps := make([][]byte, 0, 1) - currentStep := make([]byte, 0, len(content)) - - for _, line := range bytes.SplitAfter(content, []byte("\n")) { - if bytes.Equal(bytes.TrimRight(line, "\r\n"), splitter) { - steps = append(steps, currentStep) - currentStep = make([]byte, 0, len(content)-len(currentStep)) + stepStart := 0 + offset := 0 + + for offset < len(content) { + lineStart := offset + lineEnd := bytes.IndexByte(content[offset:], '\n') + if lineEnd < 0 { + offset = len(content) } else { - currentStep = append(currentStep, line...) + offset += lineEnd + 1 + } + + if bytes.Equal(bytes.TrimRight(content[lineStart:offset], "\r\n"), splitter) { + steps = append(steps, content[stepStart:lineStart]) + stepStart = offset } } - steps = append(steps, currentStep) - return steps + return append(steps, content[stepStart:]) } // versionExists checks the source if either the up or down migration for