diff --git a/docker-compose.yml b/docker-compose.yml index 2b13d8f..2747839 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,7 @@ go-test: - mysql - cassandra - crate + - mongo go-build: <<: *go command: sh -c 'go get -v && go build -ldflags ''-s'' -o migrater' @@ -27,3 +28,5 @@ cassandra: image: cassandra:2.2 crate: image: crate +mongo: + image: mongo:3.2.6 diff --git a/driver/mongodb/README.md b/driver/mongodb/README.md new file mode 100644 index 0000000..d7d8351 --- /dev/null +++ b/driver/mongodb/README.md @@ -0,0 +1,104 @@ +# MongoDB Driver + +* Runs pre-registered Golang methods that receive a single `*mgo.Session` parameter and return `error` on failure. +* Stores migration version details in collection ``db_migrations``. + This collection will be auto-generated. +* Migrations do not run in transactions, there are no built-in transactions in MongoDB. + That means that if a migration fails, it will not be rolled back. +* There is no out-of-the-box support for command-line interface via terminal. + +## Usage in Go + +```go +import "github.com/mattes/migrate/migrate" + +// Import your migration methods package so that they are registered and available for the MongoDB driver. +// There is no need to import the MongoDB driver explicitly, as it should already be imported by your migration methods package. +import _ "my_mongo_db_migrator" + +// use synchronous versions of migration functions ... +allErrors, ok := migrate.UpSync("mongodb://host:port", "./path") +if !ok { + fmt.Println("Oh no ...") + // do sth with allErrors slice +} + +// use the asynchronous version of migration functions ... +pipe := migrate.NewPipe() +go migrate.Up(pipe, "mongodb://host:port", "./path") +// pipe is basically just a channel +// write your own channel listener. see writePipe() in main.go as an example. +``` + +## Migration files format + +The migration files should have an ".mgo" extension and contain a list of registered methods names. + +Migration methods should satisfy the following: +* They should be exported (their name should start with a capital letter) +* Their type should be `func (*mgo.Session) error` + +Recommended (but not required) naming conventions for migration methods: +* Prefix with V : for example V001 for version 1. +* Suffix with "_up" or "_down" for up and down migrations correspondingly. + +001_first_release.up.mgo +``` +V001_some_migration_operation_up +V001_some_other_operation_up +... +``` + +001_first_release.down.mgo +``` +V001_some_other_operation_down +V001_some_migration_operation_down +... +``` + + +## Methods registration + +For a detailed example see: [sample_mongodb_migrator.go](https://github.com/mattes/migrate/blob/master/driver/mongodb/example/sample_mongdb_migrator.go) + +```go +package my_mongo_db_migrator + +import ( + "github.com/mattes/migrate/driver/mongodb" + "github.com/mattes/migrate/driver/mongodb/gomethods" + "gopkg.in/mgo.v2" +) + +// common boilerplate +type MyMongoDbMigrator struct { +} + +func (r *MyMongoDbMigrator) DbName() string { + return "" +} + +var _ mongodb.MethodsReceiver = (*MyMongoDbMigrator)(nil) + +func init() { + gomethods.RegisterMethodsReceiverForDriver("mongodb", &MyMongoDbMigrator{}) +} + + +// Here goes the application-specific migration logic +func (r *MyMongoDbMigrator) V001_some_migration_operation_up(session *mgo.Session) error { + // do something + return nil +} + +func (r *MyMongoDbMigrator) V001_some_migration_operation_down(session *mgo.Session) error { + // revert some_migration_operation_up from above + return nil +} + +``` + +## Authors + +* Demitry Gershovich, https://github.com/dimag-jfrog + diff --git a/driver/mongodb/example/mongodb_test.go b/driver/mongodb/example/mongodb_test.go new file mode 100644 index 0000000..86c37c0 --- /dev/null +++ b/driver/mongodb/example/mongodb_test.go @@ -0,0 +1,310 @@ +package example + +import ( + "testing" + + "github.com/mattes/migrate/file" + "github.com/mattes/migrate/migrate/direction" + + "github.com/mattes/migrate/driver" + "github.com/mattes/migrate/driver/mongodb" + "github.com/mattes/migrate/driver/mongodb/gomethods" + pipep "github.com/mattes/migrate/pipe" + "os" + "reflect" + "time" +) + +type ExpectedMigrationResult struct { + Organizations []Organization + Organizations_v2 []Organization_v2 + Users []User + Errors []error +} + +func RunMigrationAndAssertResult( + t *testing.T, + title string, + d *mongodb.Driver, + file file.File, + expected *ExpectedMigrationResult) { + + actualOrganizations := []Organization{} + actualOrganizations_v2 := []Organization_v2{} + actualUsers := []User{} + var err error + var pipe chan interface{} + var errs []error + + pipe = pipep.New() + go d.Migrate(file, pipe) + errs = pipep.ReadErrors(pipe) + + session := d.Session + if len(expected.Organizations) > 0 { + err = session.DB(DB_NAME).C(ORGANIZATIONS_C).Find(nil).All(&actualOrganizations) + } else { + err = session.DB(DB_NAME).C(ORGANIZATIONS_C).Find(nil).All(&actualOrganizations_v2) + } + if err != nil { + t.Fatal("Failed to query Organizations collection") + } + + err = session.DB(DB_NAME).C(USERS_C).Find(nil).All(&actualUsers) + if err != nil { + t.Fatal("Failed to query Users collection") + } + + if !reflect.DeepEqual(expected.Errors, errs) { + t.Fatalf("Migration '%s': FAILED\nexpected errors %v\nbut got %v", title, expected.Errors, errs) + } + + if !reflect.DeepEqual(expected.Organizations, actualOrganizations) { + t.Fatalf("Migration '%s': FAILED\nexpected organizations %v\nbut got %v", title, expected.Organizations, actualOrganizations) + } + + if !reflect.DeepEqual(expected.Organizations_v2, actualOrganizations_v2) { + t.Fatalf("Migration '%s': FAILED\nexpected organizations v2 %v\nbut got %v", title, expected.Organizations_v2, actualOrganizations_v2) + } + + if !reflect.DeepEqual(expected.Users, actualUsers) { + t.Fatalf("Migration '%s': FAILED\nexpected users %v\nbut got %v", title, expected.Users, actualUsers) + + } + // t.Logf("Migration '%s': PASSED", title) +} + +func TestMigrate(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("Test failed on panic: %v", r) + } + }() + + host := os.Getenv("MONGO_PORT_27017_TCP_ADDR") + port := os.Getenv("MONGO_PORT_27017_TCP_PORT") + + driverUrl := "mongodb://" + host + ":" + port + + d0 := driver.GetDriver("mongodb") + d, ok := d0.(*mongodb.Driver) + if !ok { + t.Fatal("MongoDbGoMethodsDriver has not registered") + } + + if err := d.Initialize(driverUrl); err != nil { + t.Fatal(err) + } + + // Reset DB + d.Session.DB(DB_NAME).C(mongodb.MIGRATE_C).DropCollection() + d.Session.DB(DB_NAME).C(ORGANIZATIONS_C).DropCollection() + d.Session.DB(DB_NAME).C(USERS_C).DropCollection() + + date1, _ := time.Parse(SHORT_DATE_LAYOUT, "1994-Jul-05") + date2, _ := time.Parse(SHORT_DATE_LAYOUT, "1998-Sep-04") + date3, _ := time.Parse(SHORT_DATE_LAYOUT, "2008-Apr-28") + + migrations := []struct { + name string + file file.File + expectedResult ExpectedMigrationResult + }{ + { + name: "v0 -> v1", + file: file.File{ + Path: "/foobar", + FileName: "001_foobar.up.gm", + Version: 1, + Name: "foobar", + Direction: direction.Up, + Content: []byte(` + V001_init_organizations_up + V001_init_users_up + `), + }, + expectedResult: ExpectedMigrationResult{ + Organizations: []Organization{ + {Id: OrganizationIds[0], Name: "Amazon", Location: "Seattle", DateFounded: date1}, + {Id: OrganizationIds[1], Name: "Google", Location: "Mountain View", DateFounded: date2}, + {Id: OrganizationIds[2], Name: "JFrog", Location: "Santa Clara", DateFounded: date3}, + }, + Organizations_v2: []Organization_v2{}, + Users: []User{ + {Id: UserIds[0], Name: "Alex"}, + {Id: UserIds[1], Name: "Beatrice"}, + {Id: UserIds[2], Name: "Cleo"}, + }, + Errors: []error{}, + }, + }, + { + name: "v1 -> v2", + file: file.File{ + Path: "/foobar", + FileName: "002_foobar.up.gm", + Version: 2, + Name: "foobar", + Direction: direction.Up, + Content: []byte(` + V002_organizations_rename_location_field_to_headquarters_up + V002_change_user_cleo_to_cleopatra_up + `), + }, + expectedResult: ExpectedMigrationResult{ + Organizations: []Organization{}, + Organizations_v2: []Organization_v2{ + {Id: OrganizationIds[0], Name: "Amazon", Headquarters: "Seattle", DateFounded: date1}, + {Id: OrganizationIds[1], Name: "Google", Headquarters: "Mountain View", DateFounded: date2}, + {Id: OrganizationIds[2], Name: "JFrog", Headquarters: "Santa Clara", DateFounded: date3}, + }, + Users: []User{ + {Id: UserIds[0], Name: "Alex"}, + {Id: UserIds[1], Name: "Beatrice"}, + {Id: UserIds[2], Name: "Cleopatra"}, + }, + Errors: []error{}, + }, + }, + { + name: "v2 -> v1", + file: file.File{ + Path: "/foobar", + FileName: "002_foobar.down.gm", + Version: 2, + Name: "foobar", + Direction: direction.Down, + Content: []byte(` + V002_change_user_cleo_to_cleopatra_down + V002_organizations_rename_location_field_to_headquarters_down + `), + }, + expectedResult: ExpectedMigrationResult{ + Organizations: []Organization{ + {Id: OrganizationIds[0], Name: "Amazon", Location: "Seattle", DateFounded: date1}, + {Id: OrganizationIds[1], Name: "Google", Location: "Mountain View", DateFounded: date2}, + {Id: OrganizationIds[2], Name: "JFrog", Location: "Santa Clara", DateFounded: date3}, + }, + Organizations_v2: []Organization_v2{}, + Users: []User{ + {Id: UserIds[0], Name: "Alex"}, + {Id: UserIds[1], Name: "Beatrice"}, + {Id: UserIds[2], Name: "Cleo"}, + }, + Errors: []error{}, + }, + }, + { + name: "v1 -> v0", + file: file.File{ + Path: "/foobar", + FileName: "001_foobar.down.gm", + Version: 1, + Name: "foobar", + Direction: direction.Down, + Content: []byte(` + V001_init_users_down + V001_init_organizations_down + `), + }, + expectedResult: ExpectedMigrationResult{ + Organizations: []Organization{}, + Organizations_v2: []Organization_v2{}, + Users: []User{}, + Errors: []error{}, + }, + }, + { + name: "v0 -> v1: missing method aborts migration", + file: file.File{ + Path: "/foobar", + FileName: "001_foobar.up.gm", + Version: 1, + Name: "foobar", + Direction: direction.Up, + Content: []byte(` + V001_init_organizations_up + V001_init_users_up + v001_non_existing_method_up + `), + }, + expectedResult: ExpectedMigrationResult{ + Organizations: []Organization{}, + Organizations_v2: []Organization_v2{}, + Users: []User{}, + Errors: []error{gomethods.MethodNotFoundError("v001_non_existing_method_up")}, + }, + }, + { + name: "v0 -> v1: not exported method aborts migration", + file: file.File{ + Path: "/foobar", + FileName: "001_foobar.up.gm", + Version: 1, + Name: "foobar", + Direction: direction.Up, + Content: []byte(` + V001_init_organizations_up + v001_not_exported_method_up + V001_init_users_up + `), + }, + expectedResult: ExpectedMigrationResult{ + Organizations: []Organization{}, + Organizations_v2: []Organization_v2{}, + Users: []User{}, + Errors: []error{gomethods.MethodNotFoundError("v001_not_exported_method_up")}, + }, + }, + { + name: "v0 -> v1: wrong signature method aborts migration", + file: file.File{ + Path: "/foobar", + FileName: "001_foobar.up.gm", + Version: 1, + Name: "foobar", + Direction: direction.Up, + Content: []byte(` + V001_init_organizations_up + V001_method_with_wrong_signature_up + V001_init_users_up + `), + }, + expectedResult: ExpectedMigrationResult{ + Organizations: []Organization{}, + Organizations_v2: []Organization_v2{}, + Users: []User{}, + Errors: []error{gomethods.WrongMethodSignatureError("V001_method_with_wrong_signature_up")}, + }, + }, + { + name: "v1 -> v0: wrong signature method aborts migration", + file: file.File{ + Path: "/foobar", + FileName: "001_foobar.down.gm", + Version: 1, + Name: "foobar", + Direction: direction.Down, + Content: []byte(` + V001_init_users_down + V001_method_with_wrong_signature_down + V001_init_organizations_down + `), + }, + expectedResult: ExpectedMigrationResult{ + Organizations: []Organization{}, + Organizations_v2: []Organization_v2{}, + Users: []User{}, + Errors: []error{gomethods.WrongMethodSignatureError("V001_method_with_wrong_signature_down")}, + }, + }, + } + + for _, m := range migrations { + RunMigrationAndAssertResult(t, m.name, d, m.file, &m.expectedResult) + } + + if err := d.Close(); err != nil { + t.Fatal(err) + } +} diff --git a/driver/mongodb/example/sample_mongdb_migrator.go b/driver/mongodb/example/sample_mongdb_migrator.go new file mode 100644 index 0000000..1ab1440 --- /dev/null +++ b/driver/mongodb/example/sample_mongdb_migrator.go @@ -0,0 +1,153 @@ +package example + +import ( + "github.com/mattes/migrate/driver/mongodb/gomethods" + _ "github.com/mattes/migrate/driver/mongodb/gomethods" + "gopkg.in/mgo.v2" + "gopkg.in/mgo.v2/bson" + "time" + + "github.com/mattes/migrate/driver/mongodb" +) + +type SampleMongoDbMigrator struct { +} + +func (r *SampleMongoDbMigrator) DbName() string { + return DB_NAME +} + +var _ mongodb.MethodsReceiver = (*SampleMongoDbMigrator)(nil) + +func init() { + gomethods.RegisterMethodsReceiverForDriver("mongodb", &SampleMongoDbMigrator{}) +} + +// Here goes the specific mongodb golang methods migration logic + +const ( + DB_NAME = "test" + SHORT_DATE_LAYOUT = "2000-Jan-01" + USERS_C = "users" + ORGANIZATIONS_C = "organizations" +) + +type Organization struct { + Id bson.ObjectId `bson:"_id,omitempty"` + Name string `bson:"name"` + Location string `bson:"location"` + DateFounded time.Time `bson:"date_founded"` +} + +type Organization_v2 struct { + Id bson.ObjectId `bson:"_id,omitempty"` + Name string `bson:"name"` + Headquarters string `bson:"headquarters"` + DateFounded time.Time `bson:"date_founded"` +} + +type User struct { + Id bson.ObjectId `bson:"_id"` + Name string `bson:"name"` +} + +var OrganizationIds []bson.ObjectId = []bson.ObjectId{ + bson.NewObjectId(), + bson.NewObjectId(), + bson.NewObjectId(), +} + +var UserIds []bson.ObjectId = []bson.ObjectId{ + bson.NewObjectId(), + bson.NewObjectId(), + bson.NewObjectId(), +} + +func (r *SampleMongoDbMigrator) V001_init_organizations_up(session *mgo.Session) error { + date1, _ := time.Parse(SHORT_DATE_LAYOUT, "1994-Jul-05") + date2, _ := time.Parse(SHORT_DATE_LAYOUT, "1998-Sep-04") + date3, _ := time.Parse(SHORT_DATE_LAYOUT, "2008-Apr-28") + + orgs := []Organization{ + {Id: OrganizationIds[0], Name: "Amazon", Location: "Seattle", DateFounded: date1}, + {Id: OrganizationIds[1], Name: "Google", Location: "Mountain View", DateFounded: date2}, + {Id: OrganizationIds[2], Name: "JFrog", Location: "Santa Clara", DateFounded: date3}, + } + + for _, org := range orgs { + err := session.DB(DB_NAME).C(ORGANIZATIONS_C).Insert(org) + if err != nil { + return err + } + } + return nil +} + +func (r *SampleMongoDbMigrator) V001_init_organizations_down(session *mgo.Session) error { + return session.DB(DB_NAME).C(ORGANIZATIONS_C).DropCollection() +} + +func (r *SampleMongoDbMigrator) V001_init_users_up(session *mgo.Session) error { + users := []User{ + {Id: UserIds[0], Name: "Alex"}, + {Id: UserIds[1], Name: "Beatrice"}, + {Id: UserIds[2], Name: "Cleo"}, + } + + for _, user := range users { + err := session.DB(DB_NAME).C(USERS_C).Insert(user) + if err != nil { + return err + } + } + return nil +} + +func (r *SampleMongoDbMigrator) V001_init_users_down(session *mgo.Session) error { + return session.DB(DB_NAME).C(USERS_C).DropCollection() +} + +func (r *SampleMongoDbMigrator) V002_organizations_rename_location_field_to_headquarters_up(session *mgo.Session) error { + c := session.DB(DB_NAME).C(ORGANIZATIONS_C) + + _, err := c.UpdateAll(nil, bson.M{"$rename": bson.M{"location": "headquarters"}}) + return err +} + +func (r *SampleMongoDbMigrator) V002_organizations_rename_location_field_to_headquarters_down(session *mgo.Session) error { + c := session.DB(DB_NAME).C(ORGANIZATIONS_C) + + _, err := c.UpdateAll(nil, bson.M{"$rename": bson.M{"headquarters": "location"}}) + return err +} + +func (r *SampleMongoDbMigrator) V002_change_user_cleo_to_cleopatra_up(session *mgo.Session) error { + c := session.DB(DB_NAME).C(USERS_C) + + colQuerier := bson.M{"name": "Cleo"} + change := bson.M{"$set": bson.M{"name": "Cleopatra"}} + + return c.Update(colQuerier, change) +} + +func (r *SampleMongoDbMigrator) V002_change_user_cleo_to_cleopatra_down(session *mgo.Session) error { + c := session.DB(DB_NAME).C(USERS_C) + + colQuerier := bson.M{"name": "Cleopatra"} + change := bson.M{"$set": bson.M{"name": "Cleo"}} + + return c.Update(colQuerier, change) +} + +// Wrong signature methods for testing +func (r *SampleMongoDbMigrator) v001_not_exported_method_up(session *mgo.Session) error { + return nil +} + +func (r *SampleMongoDbMigrator) V001_method_with_wrong_signature_up(s string) error { + return nil +} + +func (r *SampleMongoDbMigrator) V001_method_with_wrong_signature_down(session *mgo.Session) (bool, error) { + return true, nil +} diff --git a/driver/mongodb/gomethods/gomethods_migrator.go b/driver/mongodb/gomethods/gomethods_migrator.go new file mode 100644 index 0000000..97057ab --- /dev/null +++ b/driver/mongodb/gomethods/gomethods_migrator.go @@ -0,0 +1,149 @@ +package gomethods + +import ( + "bufio" + "fmt" + "github.com/mattes/migrate/driver" + "github.com/mattes/migrate/file" + "os" + "path" + "strings" +) + +type MethodNotFoundError string + +func (e MethodNotFoundError) Error() string { + return fmt.Sprintf("Method '%s' was not found. It is either not existing or has not been exported (starts with lowercase).", string(e)) +} + +type WrongMethodSignatureError string + +func (e WrongMethodSignatureError) Error() string { + return fmt.Sprintf("Method '%s' has wrong signature", string(e)) +} + +type MethodInvocationFailedError struct { + MethodName string + Err error +} + +func (e *MethodInvocationFailedError) Error() string { + return fmt.Sprintf("Method '%s' returned an error: %v", e.MethodName, e.Err) +} + +type MigrationMethodInvoker interface { + Validate(methodName string) error + Invoke(methodName string) error +} + +type GoMethodsDriver interface { + driver.Driver + + MigrationMethodInvoker + MethodsReceiver() interface{} + SetMethodsReceiver(r interface{}) error +} + +type Migrator struct { + RollbackOnFailure bool + MethodInvoker MigrationMethodInvoker +} + +func (m *Migrator) Migrate(f file.File, pipe chan interface{}) error { + methods, err := m.getMigrationMethods(f) + if err != nil { + pipe <- err + return err + } + + for i, methodName := range methods { + pipe <- methodName + err := m.MethodInvoker.Invoke(methodName) + if err != nil { + pipe <- err + if !m.RollbackOnFailure { + return err + } + + // on failure, try to rollback methods in this migration + for j := i - 1; j >= 0; j-- { + rollbackToMethodName := getRollbackToMethod(methods[j]) + if rollbackToMethodName == "" { + continue + } + if err := m.MethodInvoker.Validate(rollbackToMethodName); err != nil { + continue + } + + pipe <- rollbackToMethodName + err = m.MethodInvoker.Invoke(rollbackToMethodName) + if err != nil { + pipe <- err + break + } + } + return err + } + } + + return nil +} + +func getRollbackToMethod(methodName string) string { + if strings.HasSuffix(methodName, "_up") { + return strings.TrimSuffix(methodName, "_up") + "_down" + } else if strings.HasSuffix(methodName, "_down") { + return strings.TrimSuffix(methodName, "_down") + "_up" + } else { + return "" + } +} + +func getFileLines(file file.File) ([]string, error) { + if len(file.Content) == 0 { + lines := make([]string, 0) + file, err := os.Open(path.Join(file.Path, file.FileName)) + if err != nil { + return nil, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + return lines, nil + } else { + s := string(file.Content) + return strings.Split(s, "\n"), nil + } +} + +func (m *Migrator) getMigrationMethods(f file.File) (methods []string, err error) { + var lines []string + + lines, err = getFileLines(f) + if err != nil { + return nil, err + } + + for _, line := range lines { + line := strings.TrimSpace(line) + + if line == "" || strings.HasPrefix(line, "--") { + // an empty line or a comment, ignore + continue + } + + methodName := line + if err := m.MethodInvoker.Validate(methodName); err != nil { + return nil, err + } + + methods = append(methods, methodName) + } + + return methods, nil + +} diff --git a/driver/mongodb/gomethods/gomethods_migrator_test.go b/driver/mongodb/gomethods/gomethods_migrator_test.go new file mode 100644 index 0000000..d94506e --- /dev/null +++ b/driver/mongodb/gomethods/gomethods_migrator_test.go @@ -0,0 +1,247 @@ +package gomethods + +import ( + "reflect" + "testing" + + "github.com/mattes/migrate/file" + "github.com/mattes/migrate/migrate/direction" + + pipep "github.com/mattes/migrate/pipe" +) + +type FakeGoMethodsInvoker struct { + InvokedMethods []string +} + +func (invoker *FakeGoMethodsInvoker) Validate(methodName string) error { + if methodName == "V001_some_non_existing_method_up" { + return MethodNotFoundError(methodName) + } + + return nil +} + +func (invoker *FakeGoMethodsInvoker) Invoke(methodName string) error { + invoker.InvokedMethods = append(invoker.InvokedMethods, methodName) + + if methodName == "V001_some_failing_method_up" || methodName == "V001_some_failing_method_down" { + return &MethodInvocationFailedError{ + MethodName: methodName, + Err: SomeError{}, + } + } + return nil +} + +type SomeError struct{} + +func (e SomeError) Error() string { return "Some error happened" } + +func TestMigrate(t *testing.T) { + cases := []struct { + name string + file file.File + expectedInvokedMethods []string + expectedErrors []error + expectRollback bool + }{ + { + name: "up migration invokes up methods", + file: file.File{ + Path: "/foobar", + FileName: "001_foobar.up.gm", + Version: 1, + Name: "foobar", + Direction: direction.Up, + Content: []byte(` + V001_init_organizations_up + V001_init_users_up + `), + }, + expectedInvokedMethods: []string{"V001_init_organizations_up", "V001_init_users_up"}, + expectedErrors: []error{}, + }, + { + name: "down migration invoked down methods", + file: file.File{ + Path: "/foobar", + FileName: "001_foobar.down.gm", + Version: 1, + Name: "foobar", + Direction: direction.Down, + Content: []byte(` + V001_init_users_down + V001_init_organizations_down + `), + }, + expectedInvokedMethods: []string{"V001_init_users_down", "V001_init_organizations_down"}, + expectedErrors: []error{}, + }, + { + name: "up migration: non-existing method causes migration not to execute", + file: file.File{ + Path: "/foobar", + FileName: "001_foobar.up.gm", + Version: 1, + Name: "foobar", + Direction: direction.Up, + Content: []byte(` + V001_init_organizations_up + V001_init_users_up + V001_some_non_existing_method_up + `), + }, + expectedInvokedMethods: []string{}, + expectedErrors: []error{MethodNotFoundError("V001_some_non_existing_method_up")}, + }, + { + name: "up migration: failing method stops execution", + file: file.File{ + Path: "/foobar", + FileName: "001_foobar.up.gm", + Version: 1, + Name: "foobar", + Direction: direction.Up, + Content: []byte(` + V001_init_organizations_up + V001_some_failing_method_up + V001_init_users_up + `), + }, + expectedInvokedMethods: []string{ + "V001_init_organizations_up", + "V001_some_failing_method_up", + }, + expectedErrors: []error{&MethodInvocationFailedError{ + MethodName: "V001_some_failing_method_up", + Err: SomeError{}, + }}, + }, + { + name: "down migration: failing method stops migration", + file: file.File{ + Path: "/foobar", + FileName: "001_foobar.down.gm", + Version: 1, + Name: "foobar", + Direction: direction.Down, + Content: []byte(` + V001_init_users_down + V001_some_failing_method_down + V001_init_organizations_down + `), + }, + expectedInvokedMethods: []string{ + "V001_init_users_down", + "V001_some_failing_method_down", + }, + expectedErrors: []error{&MethodInvocationFailedError{ + MethodName: "V001_some_failing_method_down", + Err: SomeError{}, + }}, + }, + { + name: "up migration: failing method causes rollback in rollback mode", + expectRollback: true, + file: file.File{ + Path: "/foobar", + FileName: "001_foobar.up.gm", + Version: 1, + Name: "foobar", + Direction: direction.Up, + Content: []byte(` + V001_init_organizations_up + V001_init_users_up + V001_some_failing_method_up + `), + }, + expectedInvokedMethods: []string{ + "V001_init_organizations_up", + "V001_init_users_up", + "V001_some_failing_method_up", + "V001_init_users_down", + "V001_init_organizations_down", + }, + expectedErrors: []error{&MethodInvocationFailedError{ + MethodName: "V001_some_failing_method_up", + Err: SomeError{}, + }}, + }, + { + name: "down migration: failing method causes rollback in rollback mode", + expectRollback: true, + file: file.File{ + Path: "/foobar", + FileName: "001_foobar.down.gm", + Version: 1, + Name: "foobar", + Direction: direction.Down, + Content: []byte(` + V001_init_users_down + V001_some_failing_method_down + V001_init_organizations_down + `), + }, + expectedInvokedMethods: []string{ + "V001_init_users_down", + "V001_some_failing_method_down", + "V001_init_users_up", + }, + expectedErrors: []error{&MethodInvocationFailedError{ + MethodName: "V001_some_failing_method_down", + Err: SomeError{}, + }}, + }, + } + + for _, c := range cases { + migrator := Migrator{} + fakeInvoker := &FakeGoMethodsInvoker{InvokedMethods: []string{}} + + migrator.MethodInvoker = fakeInvoker + migrator.RollbackOnFailure = c.expectRollback + + pipe := pipep.New() + go func() { + migrator.Migrate(c.file, pipe) + close(pipe) + }() + errs := pipep.ReadErrors(pipe) + + var failed bool + if !reflect.DeepEqual(fakeInvoker.InvokedMethods, c.expectedInvokedMethods) { + failed = true + t.Errorf("case '%s': FAILED\nexpected invoked methods %v\nbut got %v", c.name, c.expectedInvokedMethods, fakeInvoker.InvokedMethods) + } + if !reflect.DeepEqual(errs, c.expectedErrors) { + failed = true + t.Errorf("case '%s': FAILED\nexpected errors %v\nbut got %v", c.name, c.expectedErrors, errs) + + } + if !failed { + //t.Logf("case '%s': PASSED", c.name) + } + } +} + +func TestGetRollbackToMethod(t *testing.T) { + cases := []struct { + method string + expectedRollbackMethod string + }{ + {"some_method_up", "some_method_down"}, + {"some_method_down", "some_method_up"}, + {"up_down_up", "up_down_down"}, + {"down_up", "down_down"}, + {"down_down", "down_up"}, + {"some_method", ""}, + } + + for _, c := range cases { + actualRollbackMethod := getRollbackToMethod(c.method) + if actualRollbackMethod != c.expectedRollbackMethod { + t.Errorf("Expected rollback method to be %s but got %s", c.expectedRollbackMethod, actualRollbackMethod) + } + } +} diff --git a/driver/mongodb/gomethods/gomethods_registry.go b/driver/mongodb/gomethods/gomethods_registry.go new file mode 100644 index 0000000..418256f --- /dev/null +++ b/driver/mongodb/gomethods/gomethods_registry.go @@ -0,0 +1,39 @@ +package gomethods + +import ( + "fmt" + "github.com/mattes/migrate/driver" + "sync" +) + +var methodsReceiversMu sync.Mutex + +// Registers a methods receiver for go methods driver +// Users of gomethods migration drivers should call this method +// to register objects with their migration methods before executing the migration +func RegisterMethodsReceiverForDriver(driverName string, receiver interface{}) { + methodsReceiversMu.Lock() + defer methodsReceiversMu.Unlock() + if receiver == nil { + panic("Go methods: Register receiver object is nil") + } + + driver := driver.GetDriver(driverName) + if driver == nil { + panic("Go methods: Trying to register receiver for not registered driver " + driverName) + } + + methodsDriver, ok := driver.(GoMethodsDriver) + if !ok { + panic("Go methods: Trying to register receiver for non go methods driver " + driverName) + } + + if methodsDriver.MethodsReceiver() != nil { + panic("Go methods: Methods receiver already registered for driver " + driverName) + } + + if err := methodsDriver.SetMethodsReceiver(receiver); err != nil { + panic(fmt.Sprintf("Go methods: Failed to set methods receiver for driver %s\nError: %v", + driverName, err)) + } +} diff --git a/driver/mongodb/mongodb.go b/driver/mongodb/mongodb.go new file mode 100644 index 0000000..fcfae50 --- /dev/null +++ b/driver/mongodb/mongodb.go @@ -0,0 +1,193 @@ +package mongodb + +import ( + "errors" + "github.com/mattes/migrate/driver" + "github.com/mattes/migrate/driver/mongodb/gomethods" + "github.com/mattes/migrate/file" + "github.com/mattes/migrate/migrate/direction" + "gopkg.in/mgo.v2" + "gopkg.in/mgo.v2/bson" + "reflect" + "strings" +) + +type UnregisteredMethodsReceiverError string + +func (e UnregisteredMethodsReceiverError) Error() string { + return "Unregistered methods receiver for driver: " + string(e) +} + +type WrongMethodsReceiverTypeError string + +func (e WrongMethodsReceiverTypeError) Error() string { + return "Wrong methods receiver type for driver: " + string(e) +} + +const MIGRATE_C = "db_migrations" +const DRIVER_NAME = "gomethods.mongodb" + +type Driver struct { + Session *mgo.Session + + methodsReceiver MethodsReceiver + migrator gomethods.Migrator +} + +var _ gomethods.GoMethodsDriver = (*Driver)(nil) + +type MethodsReceiver interface { + DbName() string +} + +func (d *Driver) MethodsReceiver() interface{} { + return d.methodsReceiver +} + +func (d *Driver) SetMethodsReceiver(r interface{}) error { + r1, ok := r.(MethodsReceiver) + if !ok { + return WrongMethodsReceiverTypeError(DRIVER_NAME) + } + + d.methodsReceiver = r1 + return nil +} + +func init() { + driver.RegisterDriver("mongodb", &Driver{}) +} + +type DbMigration struct { + Id bson.ObjectId `bson:"_id"` + Version uint64 `bson:"version"` +} + +func (driver *Driver) Initialize(url string) error { + if driver.methodsReceiver == nil { + return UnregisteredMethodsReceiverError(DRIVER_NAME) + } + + urlWithoutScheme := strings.SplitN(url, "mongodb://", 2) + if len(urlWithoutScheme) != 2 { + return errors.New("invalid mongodb:// scheme") + } + + session, err := mgo.Dial(url) + if err != nil { + return err + } + session.SetMode(mgo.Monotonic, true) + + c := session.DB(driver.methodsReceiver.DbName()).C(MIGRATE_C) + err = c.EnsureIndex(mgo.Index{ + Key: []string{"version"}, + Unique: true, + }) + if err != nil { + return err + } + + driver.Session = session + driver.migrator = gomethods.Migrator{MethodInvoker: driver} + + return nil +} + +func (driver *Driver) Close() error { + if driver.Session != nil { + driver.Session.Close() + } + return nil +} + +func (driver *Driver) FilenameExtension() string { + return "mgo" +} + +func (driver *Driver) Version() (uint64, error) { + var latestMigration DbMigration + c := driver.Session.DB(driver.methodsReceiver.DbName()).C(MIGRATE_C) + + err := c.Find(bson.M{}).Sort("-version").One(&latestMigration) + + switch { + case err == mgo.ErrNotFound: + return 0, nil + case err != nil: + return 0, err + default: + return latestMigration.Version, nil + } +} +func (driver *Driver) Migrate(f file.File, pipe chan interface{}) { + defer close(pipe) + pipe <- f + + err := driver.migrator.Migrate(f, pipe) + if err != nil { + return + } + + migrate_c := driver.Session.DB(driver.methodsReceiver.DbName()).C(MIGRATE_C) + + if f.Direction == direction.Up { + id := bson.NewObjectId() + dbMigration := DbMigration{Id: id, Version: f.Version} + + err := migrate_c.Insert(dbMigration) + if err != nil { + pipe <- err + return + } + + } else if f.Direction == direction.Down { + err := migrate_c.Remove(bson.M{"version": f.Version}) + if err != nil { + pipe <- err + return + } + } +} + +func (driver *Driver) Validate(methodName string) error { + methodWithReceiver, ok := reflect.TypeOf(driver.methodsReceiver).MethodByName(methodName) + if !ok { + return gomethods.MethodNotFoundError(methodName) + } + if methodWithReceiver.PkgPath != "" { + return gomethods.MethodNotFoundError(methodName) + } + + methodFunc := reflect.ValueOf(driver.methodsReceiver).MethodByName(methodName) + methodTemplate := func(*mgo.Session) error { return nil } + + if methodFunc.Type() != reflect.TypeOf(methodTemplate) { + return gomethods.WrongMethodSignatureError(methodName) + } + + return nil +} + +func (driver *Driver) Invoke(methodName string) error { + name := methodName + migrateMethod := reflect.ValueOf(driver.methodsReceiver).MethodByName(name) + if !migrateMethod.IsValid() { + return gomethods.MethodNotFoundError(methodName) + } + + retValues := migrateMethod.Call([]reflect.Value{reflect.ValueOf(driver.Session)}) + if len(retValues) != 1 { + return gomethods.WrongMethodSignatureError(name) + } + + if !retValues[0].IsNil() { + err, ok := retValues[0].Interface().(error) + if !ok { + return gomethods.WrongMethodSignatureError(name) + } + return &gomethods.MethodInvocationFailedError{MethodName: name, Err: err} + } + + return nil +}