Skip to content

Commit 3924b66

Browse files
authored
feat: support multiple migration sections in a single file (#620)
# Support Multiple Migrations in a Single File ## Problem previously enforced a "one migration per file" rule, leading to file proliferation and fragmented workflows. Related changes (e.g., table creation followed by alterations) required separate files, complicating organization and maintenance. ## Solution **Group multiple migrations in a single file** using `-- migrate:up` comments as section delimiters. Each `-- migrate:up` and `-- migrate:down` block is treated as a distinct migration, with automatic versioning and ordered execution. ## Key Changes - **Section-Based Parsing**: Files are split into migrations wherever `-- migrate:up` is encountered. Corresponding `-- migrate:down` blocks define rollback logic. ## Backward Compatibility Existing single-migration files remain valid. New multi-section migrations coexist seamlessly. ## Example **File:** `20231010120000_create_users.sql` ```sql -- migrate:up CREATE TABLE users (id SERIAL PRIMARY KEY); -- migrate:down DROP TABLE users; -- migrate:up ALTER TABLE users ADD COLUMN email VARCHAR; -- migrate:down ALTER TABLE users DROP COLUMN email; ```
1 parent 7cae709 commit 3924b66

File tree

5 files changed

+234
-78
lines changed

5 files changed

+234
-78
lines changed

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,22 @@ create table users (
362362
-- migrate:down
363363
```
364364

365+
For related changes, it is possible to include multiple migrations in a single file using additional `migrate:up` and `migrate:down` sections. Migration file either succeeds or fails as a whole.
366+
367+
```sql
368+
-- migrate:up
369+
CREATE TABLE users (id SERIAL PRIMARY KEY);
370+
371+
-- migrate:down
372+
DROP TABLE users;
373+
374+
-- migrate:up
375+
ALTER TABLE users ADD COLUMN email VARCHAR;
376+
377+
-- migrate:down
378+
ALTER TABLE users DROP COLUMN email;
379+
```
380+
365381
> Note: Migration files are named in the format `[version]_[description].sql`. Only the version (defined as all leading numeric characters in the file name) is recorded in the database, so you can safely rename a migration file without having any effect on its current application state.
366382
367383
### Running Migrations

pkg/dbmate/db.go

Lines changed: 53 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -384,32 +384,34 @@ func (db *DB) Migrate() error {
384384
return err
385385
}
386386

387-
execMigration := func(tx dbutil.Transaction) error {
388-
// run actual migration
389-
result, err := tx.Exec(parsed.Up)
390-
if err != nil {
391-
return drv.QueryError(parsed.Up, err)
392-
} else if db.Verbose {
393-
db.printVerbose(result)
387+
for _, migrationSection := range parsed {
388+
execMigration := func(tx dbutil.Transaction) error {
389+
// run actual migration
390+
result, err := tx.Exec(migrationSection.Up)
391+
if err != nil {
392+
return drv.QueryError(migrationSection.Up, err)
393+
} else if db.Verbose {
394+
db.printVerbose(result)
395+
}
396+
397+
// record migration
398+
return drv.InsertMigration(tx, migration.Version)
394399
}
395400

396-
// record migration
397-
return drv.InsertMigration(tx, migration.Version)
398-
}
399-
400-
if parsed.UpOptions.Transaction() {
401-
// begin transaction
402-
err = doTransaction(sqlDB, execMigration)
403-
} else {
404-
// run outside of transaction
405-
err = execMigration(sqlDB)
406-
}
401+
if migrationSection.UpOptions.Transaction() {
402+
// begin transaction
403+
err = doTransaction(sqlDB, execMigration)
404+
} else {
405+
// run outside of transaction
406+
err = execMigration(sqlDB)
407+
}
407408

408-
elapsed := time.Since(start)
409-
fmt.Fprintf(db.Log, "Applied: %s in %s\n", migration.FileName, elapsed)
409+
elapsed := time.Since(start)
410+
fmt.Fprintf(db.Log, "Applied: %s in %s\n", migration.FileName, elapsed)
410411

411-
if err != nil {
412-
return err
412+
if err != nil {
413+
return err
414+
}
413415
}
414416
}
415417

@@ -548,42 +550,44 @@ func (db *DB) Rollback() error {
548550

549551
start := time.Now()
550552

551-
parsed, err := latest.Parse()
553+
parsedSections, err := latest.Parse()
552554
if err != nil {
553555
return err
554556
}
555557

556-
execMigration := func(tx dbutil.Transaction) error {
557-
// rollback migration
558-
result, err := tx.Exec(parsed.Down)
559-
if err != nil {
560-
return drv.QueryError(parsed.Down, err)
561-
} else if db.Verbose {
562-
db.printVerbose(result)
563-
}
558+
for _, migrationSection := range parsedSections {
559+
execMigration := func(tx dbutil.Transaction) error {
560+
// rollback migration
561+
result, err := tx.Exec(migrationSection.Down)
562+
if err != nil {
563+
return drv.QueryError(migrationSection.Down, err)
564+
} else if db.Verbose {
565+
db.printVerbose(result)
566+
}
564567

565-
// remove migration record
566-
return drv.DeleteMigration(tx, latest.Version)
567-
}
568+
// remove migration record
569+
return drv.DeleteMigration(tx, latest.Version)
570+
}
568571

569-
if parsed.DownOptions.Transaction() {
570-
// begin transaction
571-
err = doTransaction(sqlDB, execMigration)
572-
} else {
573-
// run outside of transaction
574-
err = execMigration(sqlDB)
575-
}
572+
if migrationSection.DownOptions.Transaction() {
573+
// begin transaction
574+
err = doTransaction(sqlDB, execMigration)
575+
} else {
576+
// run outside of transaction
577+
err = execMigration(sqlDB)
578+
}
576579

577-
elapsed := time.Since(start)
578-
fmt.Fprintf(db.Log, "Rolled back: %s in %s\n", latest.FileName, elapsed)
580+
elapsed := time.Since(start)
581+
fmt.Fprintf(db.Log, "Rolled back: %s in %s\n", latest.FileName, elapsed)
579582

580-
if err != nil {
581-
return err
582-
}
583+
if err != nil {
584+
return err
585+
}
583586

584-
// automatically update schema file, silence errors
585-
if db.AutoDumpSchema {
586-
_ = db.DumpSchema()
587+
// automatically update schema file, silence errors
588+
if db.AutoDumpSchema {
589+
_ = db.DumpSchema()
590+
}
587591
}
588592

589593
return nil

pkg/dbmate/db_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -618,8 +618,10 @@ drop table users;
618618
require.Equal(t, false, actual[2].Applied)
619619

620620
// test parsing first migration
621-
parsed, err := actual[0].Parse()
621+
parsedSections, err := actual[0].Parse()
622622
require.Nil(t, err)
623+
624+
parsed := parsedSections[0]
623625
require.Equal(t, "-- migrate:up\ncreate table users (id serial, name text);\n", parsed.Up)
624626
require.True(t, parsed.UpOptions.Transaction())
625627
require.Equal(t, "-- migrate:down\ndrop table users;\n", parsed.Down)

pkg/dbmate/migration.go

Lines changed: 69 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func (m *Migration) readFile() (string, error) {
2828
}
2929

3030
// Parse a migration
31-
func (m *Migration) Parse() (*ParsedMigration, error) {
31+
func (m *Migration) Parse() ([]*ParsedMigration, error) {
3232
contents, err := m.readFile()
3333
if err != nil {
3434
return nil, err
@@ -76,14 +76,32 @@ var (
7676
ErrParseUnexpectedStmt = errors.New("dbmate does not support statements preceding the '-- migrate:up' block")
7777
)
7878

79-
// parseMigrationContents parses the string contents of a migration.
79+
func parseMigrationContents(contents string) ([]*ParsedMigration, error) {
80+
sectionSubstrings, err := getMigrationSectionSubstrings(contents)
81+
if err != nil {
82+
return nil, err
83+
}
84+
85+
var migrationSections []*ParsedMigration
86+
for _, sectionSubstring := range sectionSubstrings {
87+
migrationSection, err := parseMigrationSection(sectionSubstring)
88+
if err != nil {
89+
return nil, err
90+
}
91+
migrationSections = append(migrationSections, migrationSection)
92+
}
93+
94+
return migrationSections, nil
95+
}
96+
97+
// parseMigrationSection parses the string contents of a migration section.
8098
// It will return two Migration objects, the first representing the "up"
8199
// block and the second representing the "down" block. This function
82-
// requires that at least an up block was defined and will otherwise
100+
// requires that up and down blocks were defined and will otherwise
83101
// return an error.
84-
func parseMigrationContents(contents string) (*ParsedMigration, error) {
85-
upDirectiveStart, hasDefinedUpBlock := getMatchPosition(contents, upRegExp)
86-
downDirectiveStart, hasDefinedDownBlock := getMatchPosition(contents, downRegExp)
102+
func parseMigrationSection(section string) (*ParsedMigration, error) {
103+
upDirectiveStart, hasDefinedUpBlock := getMatchPosition(section, upRegExp)
104+
downDirectiveStart, hasDefinedDownBlock := getMatchPosition(section, downRegExp)
87105

88106
if !hasDefinedUpBlock {
89107
return nil, ErrParseMissingUp
@@ -94,12 +112,9 @@ func parseMigrationContents(contents string) (*ParsedMigration, error) {
94112
if upDirectiveStart > downDirectiveStart {
95113
return nil, ErrParseWrongOrder
96114
}
97-
if statementsPrecedeMigrateBlocks(contents, upDirectiveStart) {
98-
return nil, ErrParseUnexpectedStmt
99-
}
100115

101-
upBlock := substring(contents, upDirectiveStart, downDirectiveStart)
102-
downBlock := substring(contents, downDirectiveStart, len(contents))
116+
upBlock := substring(section, upDirectiveStart, downDirectiveStart)
117+
downBlock := substring(section, downDirectiveStart, len(section))
103118

104119
parsed := ParsedMigration{
105120
Up: upBlock,
@@ -117,25 +132,25 @@ func parseMigrationContents(contents string) (*ParsedMigration, error) {
117132
//
118133
// fmt.Printf("%#v", parseMigrationOptions("-- migrate:up transaction:false"))
119134
// // migrationOptions{"transaction": "false"}
120-
func parseMigrationOptions(contents string) ParsedMigrationOptions {
135+
func parseMigrationOptions(section string) ParsedMigrationOptions {
121136
options := make(migrationOptions)
122137

123138
// remove everything after first newline
124-
contents = strings.SplitN(contents, "\n", 2)[0]
139+
section = strings.SplitN(section, "\n", 2)[0]
125140

126141
// strip away the -- migrate:[up|down] part
127-
contents = blockDirectiveRegExp.ReplaceAllString(contents, "")
142+
section = blockDirectiveRegExp.ReplaceAllString(section, "")
128143

129144
// remove leading and trailing whitespace
130-
contents = strings.TrimSpace(contents)
145+
section = strings.TrimSpace(section)
131146

132147
// return empty options if nothing is left to parse
133-
if contents == "" {
148+
if section == "" {
134149
return options
135150
}
136151

137152
// split the options string into pairs, e.g. "transaction:false foo:bar" -> []string{"transaction:false", "foo:bar"}
138-
stringPairs := whitespaceRegExp.Split(contents, -1)
153+
stringPairs := whitespaceRegExp.Split(section, -1)
139154

140155
for _, stringPair := range stringPairs {
141156
// split stringified pair into key and value pairs, e.g. "transaction:false" -> []string{"transaction", "false"}
@@ -200,6 +215,43 @@ func getMatchPosition(s string, re *regexp.Regexp) (int, bool) {
200215
return match[0], true
201216
}
202217

218+
func getMigrationSectionSubstrings(contents string) ([]string, error) {
219+
// Regex to match blocks starting with "-- migrate:up" and ending before the next one or EOF
220+
allUpDirectives := upRegExp.FindAllStringIndex(contents, -1)
221+
222+
if allUpDirectives == nil {
223+
return nil, ErrParseMissingUp
224+
}
225+
226+
if statementsPrecedeMigrateBlocks(contents, allUpDirectives[0][0]) {
227+
return nil, ErrParseUnexpectedStmt
228+
}
229+
230+
var sectionBeginEndIndices [][]int
231+
for i := range len(allUpDirectives) {
232+
start := allUpDirectives[i][0]
233+
var end int
234+
if i < len(allUpDirectives)-1 {
235+
end = allUpDirectives[i+1][0]
236+
} else {
237+
end = len(contents)
238+
}
239+
sectionBeginEndIndices = append(sectionBeginEndIndices, []int{start, end})
240+
}
241+
242+
var sectionSubstrings []string
243+
for _, sectionBeginEnd := range sectionBeginEndIndices {
244+
begin, end := sectionBeginEnd[0], sectionBeginEnd[1]
245+
contentsSubstring := substring(contents, begin, end)
246+
if len(downRegExp.FindAllStringIndex(contentsSubstring, -1)) > 1 {
247+
return nil, ErrParseMissingUp
248+
}
249+
sectionSubstrings = append(sectionSubstrings, contentsSubstring)
250+
}
251+
252+
return sectionSubstrings, nil
253+
}
254+
203255
func substring(s string, begin, end int) string {
204256
if begin == -1 || end == -1 {
205257
return ""

0 commit comments

Comments
 (0)