├── .dockerignore ├── examples ├── mysql │ ├── migrations │ │ ├── 001_create_product_down.sql │ │ └── 001_create_product_up.sql │ ├── test1 │ │ ├── 20060102150405_create_product_down.sql │ │ ├── 20160628145314_insert_product_down.sql │ │ ├── 20160628145324_insert_product_down.sql │ │ ├── 20160628145645_insert_product_down.sql │ │ ├── 20160628145314_insert_product_up.sql │ │ ├── 20160628145324_insert_product_up.sql │ │ ├── 20160628145645_insert_product_up.sql │ │ └── 20060102150405_create_product_up.sql │ └── config.tml ├── sqlite3 │ ├── migrations │ │ ├── 001_create_product_down.sql │ │ └── 001_create_product_up.sql │ ├── config.tml │ └── config.yml ├── invalid │ ├── migrations │ │ ├── 001_duplicate_down.sql │ │ ├── 001_create_product_down.sql │ │ ├── 001_duplicate_up.sql │ │ └── 001_create_product_up.sql │ └── config.tml └── testdata │ ├── migrations │ ├── 001_create_product_down.sql │ └── 001_create_product_up.sql │ └── config.tml ├── cmd └── kamimai │ ├── main_test.go │ ├── cmd_sync.go │ ├── cmd.go │ ├── cmd_migrate.go │ ├── cmd_test.go │ ├── cmd_up.go │ ├── cmd_down.go │ ├── cmd_create.go │ └── main.go ├── driver ├── init.go ├── driver_test.go └── mysql.go ├── CONTRIBUTING.md ├── Dockerfile ├── docker-compose.yml ├── .github └── workflows │ └── go.yml ├── .gitignore ├── core ├── version.go ├── driver_test.go ├── service_test.go ├── migration_test.go ├── driver.go ├── config_test.go ├── config.go ├── migration.go └── service.go ├── go.mod ├── internal ├── direction │ ├── direction.go │ └── direction_test.go ├── version │ ├── version.go │ └── version_test.go └── cast │ ├── cast.go │ └── cast_test.go ├── LICENSE ├── kamimai.go ├── kamimai_test.go ├── Makefile ├── README.md └── go.sum /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.git 2 | -------------------------------------------------------------------------------- /examples/mysql/migrations/001_create_product_down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE product; 2 | -------------------------------------------------------------------------------- /examples/mysql/test1/20060102150405_create_product_down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE product; 2 | -------------------------------------------------------------------------------- /examples/sqlite3/migrations/001_create_product_down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE product; 2 | -------------------------------------------------------------------------------- /examples/invalid/migrations/001_duplicate_down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS product; 2 | -------------------------------------------------------------------------------- /examples/invalid/migrations/001_create_product_down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS product; 2 | -------------------------------------------------------------------------------- /examples/testdata/migrations/001_create_product_down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS product; 2 | -------------------------------------------------------------------------------- /examples/mysql/test1/20160628145314_insert_product_down.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM product WHERE id in (1, 2); 2 | -------------------------------------------------------------------------------- /examples/mysql/test1/20160628145324_insert_product_down.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM product WHERE id in (11, 12); 2 | -------------------------------------------------------------------------------- /examples/mysql/test1/20160628145645_insert_product_down.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM product WHERE id in (3, 4, 5); 2 | -------------------------------------------------------------------------------- /examples/sqlite3/config.tml: -------------------------------------------------------------------------------- 1 | [development] 2 | driver = "sqlite3" 3 | dsn = "file:test.db?mode=memory" 4 | -------------------------------------------------------------------------------- /examples/sqlite3/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | development: 3 | driver: sqlite3 4 | dsn: file:test.db?mode=memory 5 | -------------------------------------------------------------------------------- /examples/mysql/test1/20160628145314_insert_product_up.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO product (id, name) VALUES (1, 'prod1'), (2, 'prod2'); 2 | -------------------------------------------------------------------------------- /examples/mysql/test1/20160628145324_insert_product_up.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO product (id, name) VALUES (11, 'prod11'), (12, 'prod12'); 2 | -------------------------------------------------------------------------------- /examples/sqlite3/migrations/001_create_product_up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE product( 2 | id INTEGER PRIMARY KEY, 3 | name TEXT 4 | ); 5 | -------------------------------------------------------------------------------- /examples/mysql/test1/20160628145645_insert_product_up.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO product (id, name) VALUES (3, 'prod3'), (4, 'prod4'), (5, 'prod5'); 2 | -------------------------------------------------------------------------------- /examples/invalid/migrations/001_duplicate_up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE product( 2 | id INT NOT NULL, 3 | name TEXT, 4 | PRIMARY KEY(id) 5 | ); 6 | -------------------------------------------------------------------------------- /examples/mysql/migrations/001_create_product_up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE product( 2 | id INT NOT NULL, 3 | name TEXT, 4 | PRIMARY KEY(id) 5 | ); 6 | -------------------------------------------------------------------------------- /cmd/kamimai/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestSimple(t *testing.T) { 8 | t.Log("MAIN") 9 | } 10 | -------------------------------------------------------------------------------- /examples/invalid/migrations/001_create_product_up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE product( 2 | id INT NOT NULL, 3 | name TEXT, 4 | PRIMARY KEY(id) 5 | ); 6 | -------------------------------------------------------------------------------- /examples/mysql/test1/20060102150405_create_product_up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE product( 2 | id INT NOT NULL, 3 | name TEXT, 4 | PRIMARY KEY(id) 5 | ); 6 | -------------------------------------------------------------------------------- /examples/testdata/migrations/001_create_product_up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE product( 2 | id INT NOT NULL, 3 | name TEXT, 4 | PRIMARY KEY(id) 5 | ); 6 | -------------------------------------------------------------------------------- /examples/invalid/config.tml: -------------------------------------------------------------------------------- 1 | [development] 2 | driver = "mysql" 3 | dsn = "mysql://$MYSQL_USER:$MYSQL_PASSWORD@tcp($MYSQL_HOST:$MYSQL_PORT)/$MYSQL_DATABASE?charset=utf8" 4 | -------------------------------------------------------------------------------- /driver/init.go: -------------------------------------------------------------------------------- 1 | package driver 2 | 3 | import ( 4 | "github.com/eure/kamimai/core" 5 | ) 6 | 7 | const versionTableName = "schema_version" 8 | 9 | func init() { 10 | core.RegisterDriver("mysql", &MySQL{}) 11 | } 12 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | **Working on your first Pull Request?** You can learn how from this *free* series [How to Contribute to an Open Source Project on GitHub](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github) 2 | -------------------------------------------------------------------------------- /examples/mysql/config.tml: -------------------------------------------------------------------------------- 1 | [development] 2 | driver = "mysql" 3 | dsn = "mysql://$MYSQL_USER:$MYSQL_PASSWORD@/kamimai?charset=utf8" 4 | 5 | [test1] 6 | driver = "mysql" 7 | dsn = "mysql://$MYSQL_USER:$MYSQL_PASSWORD@/kamimai?charset=utf8" 8 | directory = "test1" 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.21-buster as builder 2 | WORKDIR /go 3 | COPY ./ . 4 | RUN export GOPATH= && go mod download && go build -o /go/ -v -ldflags '-s -w' ./cmd/kamimai 5 | 6 | FROM gcr.io/distroless/base 7 | COPY --from=builder go/kamimai . 8 | ENTRYPOINT ["/kamimai"] 9 | CMD ["--help"] 10 | -------------------------------------------------------------------------------- /examples/testdata/config.tml: -------------------------------------------------------------------------------- 1 | [development] 2 | driver = "mysql" 3 | dsn = "mysql://$MYSQL_USER:$MYSQL_PASSWORD@tcp($MYSQL_HOST:$MYSQL_PORT)/$MYSQL_DATABASE?charset=utf8" 4 | 5 | [test] 6 | driver = "mysql" 7 | dsn = "mysql://$MYSQL_USER:$MYSQL_PASSWORD@tcp($MYSQL_HOST:$MYSQL_PORT)/$MYSQL_DATABASE?charset=utf8" 8 | directory = "test" 9 | -------------------------------------------------------------------------------- /cmd/kamimai/cmd_sync.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/eure/kamimai" 4 | 5 | var ( 6 | syncCmd = &Cmd{ 7 | Name: "sync", 8 | Usage: "apply all migrations", 9 | Run: doSyncCmd, 10 | } 11 | ) 12 | 13 | func doSyncCmd(cmd *Cmd, args ...string) error { 14 | 15 | // Sync all migrations 16 | return kamimai.Sync(config) 17 | } 18 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | services: 3 | mysql: 4 | image: mysql:8.0.20 5 | platform: linux/x86_64 6 | environment: 7 | MYSQL_ALLOW_EMPTY_PASSWORD: "yes" 8 | MYSQL_USER: kamimai 9 | MYSQL_PASSWORD: kamimai 10 | MYSQL_DATABASE: kamimai 11 | MYSQL_ROOT_PASSWORD: root 12 | MYSQL_HOST: 127.0.0.1 13 | MYSQL_PORT: 3306 14 | ports: 15 | - "3306:3306" 16 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | on: [push] 3 | jobs: 4 | 5 | build: 6 | name: Build 7 | runs-on: ubuntu-latest 8 | steps: 9 | 10 | - name: Set up Go 1.13 11 | uses: actions/setup-go@v1 12 | with: 13 | go-version: 1.13 14 | id: go 15 | 16 | - name: Check out code into the Go module directory 17 | uses: actions/checkout@v1 18 | 19 | - name: Build 20 | run: go build -v . 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | artifacts 2 | 3 | # Created by https://www.gitignore.io/api/go 4 | 5 | ### Go ### 6 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 7 | *.o 8 | *.a 9 | *.so 10 | 11 | # Folders 12 | _obj 13 | _test 14 | 15 | # Architecture specific extensions/prefixes 16 | *.[568vq] 17 | [568vq].out 18 | 19 | *.cgo1.go 20 | *.cgo2.c 21 | _cgo_defun.c 22 | _cgo_gotypes.go 23 | _cgo_export.* 24 | 25 | _testmain.go 26 | 27 | *.exe 28 | *.test 29 | *.prof 30 | -------------------------------------------------------------------------------- /core/version.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | type ( 4 | // Version interface. 5 | Version interface { 6 | // Insert inserts the given migration version. 7 | Insert(uint64) error 8 | 9 | // Delete deletes the given migration version. 10 | Delete(uint64) error 11 | 12 | // Count counts number of row the given migration version. 13 | Count(uint64) int 14 | 15 | // Current returns the current migration version. 16 | Current() (uint64, error) 17 | 18 | // Create creates 19 | Create() error 20 | 21 | // Drop drops 22 | Drop() error 23 | } 24 | ) 25 | -------------------------------------------------------------------------------- /cmd/kamimai/cmd.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | 6 | "github.com/eure/kamimai/core" 7 | ) 8 | 9 | type ( 10 | // A Cmd executes a command 11 | Cmd struct { 12 | Name string 13 | Usage string 14 | Run func(*Cmd, ...string) error 15 | 16 | flag flag.FlagSet 17 | } 18 | ) 19 | 20 | var ( 21 | version uint64 22 | ) 23 | 24 | // Exec executes a command with arguments. 25 | func (c *Cmd) Exec(args []string) error { 26 | c.flag.Uint64Var(&version, "version", 0, "") 27 | c.flag.Parse(args) 28 | 29 | // Load config 30 | config = core.MustNewConfig(*dirPath).WithEnv(*env) 31 | return c.Run(c, c.flag.Args()...) 32 | } 33 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/eure/kamimai 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/BurntSushi/toml v0.3.1 7 | github.com/davecgh/go-spew v1.1.1 // indirect 8 | github.com/go-sql-driver/mysql v1.5.0 9 | github.com/hashicorp/go-version v1.6.0 // indirect 10 | github.com/kr/text v0.2.0 // indirect 11 | github.com/mitchellh/gox v1.0.1 // indirect 12 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect 13 | github.com/pkg/errors v0.9.1 14 | github.com/stretchr/testify v1.6.1 15 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect 16 | gopkg.in/yaml.v2 v2.3.0 17 | gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /cmd/kamimai/cmd_migrate.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/eure/kamimai/internal/cast" 7 | ) 8 | 9 | var ( 10 | migrateCmd = &Cmd{ 11 | Name: "migrate", 12 | Usage: "apply or rollback the number of n migrations", 13 | Run: doMigrateCmd, 14 | } 15 | ) 16 | 17 | func doMigrateCmd(cmd *Cmd, args ...string) error { 18 | 19 | if len(args) == 0 { 20 | return nil 21 | } 22 | 23 | // FIXME: 24 | // -1 couldn't be passed from args. 25 | val := cast.Int(args[0]) 26 | 27 | switch { 28 | case val > 0: 29 | // kamimai up n 30 | return doUpCmd(upCmd, strconv.Itoa(val)) 31 | 32 | case val < 0: 33 | // kamimai down n 34 | return doDownCmd(downCmd, strconv.Itoa(-val)) 35 | 36 | } 37 | 38 | return nil 39 | } 40 | -------------------------------------------------------------------------------- /internal/direction/direction.go: -------------------------------------------------------------------------------- 1 | package direction 2 | 3 | const ( 4 | // Unknown XXX 5 | Unknown = iota 6 | 7 | // Up XXX 8 | Up 9 | 10 | // Down XXX 11 | Down 12 | ) 13 | 14 | // Suffix returns a string for filename suffix. 15 | func Suffix(d int) string { 16 | switch d { 17 | case Up: 18 | return "up" 19 | case Down: 20 | return "down" 21 | } 22 | return "" 23 | } 24 | 25 | // Get returns a string which contains numbers. 26 | func Get(name string) int { 27 | 28 | dotpos := len(name) 29 | for i := len(name) - 1; 0 <= i; i-- { 30 | switch name[i] { 31 | case '.': 32 | dotpos = i 33 | case '_': 34 | switch name[i+1 : dotpos] { 35 | case "up": 36 | return Up 37 | case "down": 38 | return Down 39 | } 40 | return Unknown 41 | } 42 | } 43 | 44 | return Unknown 45 | } 46 | -------------------------------------------------------------------------------- /cmd/kamimai/cmd_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | var ( 11 | testCmd *Cmd 12 | initTestCmd = func() { 13 | testCmd = &Cmd{ 14 | Name: "test", 15 | Usage: "", 16 | Run: func(cmd *Cmd, args ...string) error { 17 | for _, v := range args { 18 | if v == "error" { 19 | return fmt.Errorf("just error") 20 | } 21 | } 22 | return nil 23 | }, 24 | } 25 | } 26 | ) 27 | 28 | func TestCmd(t *testing.T) { 29 | assert := assert.New(t) 30 | 31 | var err error 32 | 33 | *dirPath = "../../examples/testdata" 34 | *env = "development" 35 | args := []string{} 36 | 37 | initTestCmd() 38 | err = testCmd.Exec(args) 39 | assert.NoError(err) 40 | assert.Equal(0, len(testCmd.flag.Args())) 41 | 42 | // should be return an error. 43 | initTestCmd() 44 | err = testCmd.Exec(append(args, "error")) 45 | assert.Error(err) 46 | assert.Equal(1, len(testCmd.flag.Args())) 47 | } 48 | -------------------------------------------------------------------------------- /core/driver_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "database/sql" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | type drv struct { 11 | } 12 | 13 | func (d *drv) Open(_ string) error { return nil } 14 | func (d *drv) Close() error { return nil } 15 | func (d *drv) Ext() string { return "" } 16 | func (d *drv) Transaction(f func(*sql.Tx) error) error { return nil } 17 | func (d *drv) Migrate(mig *Migration) error { return nil } 18 | func (d *drv) Version() Version { return nil } 19 | 20 | func TestRegisterDriver(t *testing.T) { 21 | assert := assert.New(t) 22 | 23 | assert.Panics(func() { 24 | RegisterDriver("nil", (Driver)(nil)) 25 | }) 26 | 27 | // gets and sets 28 | assert.Nil(GetDriver("driver"), "should be nil.") 29 | RegisterDriver("driver", &drv{}) 30 | assert.NotNil(GetDriver("driver"), "should be retrieve a registered driver.") 31 | 32 | assert.Panics(func() { 33 | RegisterDriver("driver", &drv{}) 34 | }, "should be panic if registering driver twice.") 35 | } 36 | -------------------------------------------------------------------------------- /cmd/kamimai/cmd_up.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/eure/kamimai/core" 7 | "github.com/eure/kamimai/internal/cast" 8 | "github.com/eure/kamimai/internal/direction" 9 | ) 10 | 11 | var ( 12 | upCmd = &Cmd{ 13 | Name: "up", 14 | Usage: "apply all available migrations", 15 | Run: doUpCmd, 16 | } 17 | ) 18 | 19 | func doUpCmd(cmd *Cmd, args ...string) error { 20 | 21 | // driver 22 | driver := core.GetDriver(config.Driver()) 23 | if err := driver.Open(config.Dsn()); err != nil { 24 | return err 25 | } 26 | 27 | current, err := driver.Version().Current() 28 | if err != nil { 29 | return err 30 | } 31 | 32 | // generate a service 33 | svc := core.NewService(config). 34 | WithVersion(current). 35 | WithDriver(driver) 36 | 37 | return driver.Transaction(func(tx *sql.Tx) error { 38 | 39 | if version != 0 { 40 | return svc.Apply(direction.Up, version) 41 | } 42 | 43 | if len(args) == 0 { 44 | // All 45 | return svc.Up() 46 | } 47 | 48 | val := cast.Int(args[0]) 49 | if val == 0 { 50 | return nil 51 | } 52 | return svc.Next(val) 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /internal/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strconv" 7 | "time" 8 | ) 9 | 10 | var ( 11 | versionReg = regexp.MustCompile("^[0-9]+$") 12 | ) 13 | 14 | // Get returns a string which contains numbers. 15 | func Get(name string) string { 16 | for i := 0; i < len(name); i++ { 17 | if name[i] == '_' { 18 | str := name[:i] 19 | if versionReg.FindString(str) != "" { 20 | return str 21 | } 22 | return "" 23 | } 24 | } 25 | 26 | return "" 27 | } 28 | 29 | // Format returns a version format for printing. 30 | func Format(name string) string { 31 | ver := Get(name) 32 | if len(ver) == 0 { 33 | return "" 34 | } 35 | 36 | if ver[0] != '0' { 37 | return "%d" 38 | } 39 | 40 | return fmt.Sprintf("%%0%dd", len(ver)) 41 | } 42 | 43 | // IsTimestamp returns if value is timestamp or not 44 | func IsTimestamp(value uint64) bool { 45 | const layout = "20060102150405" 46 | str := strconv.FormatUint(value, 10) 47 | t, err := time.Parse(layout, str) 48 | if err != nil { 49 | return false 50 | } 51 | // compare by zero-unixtime 52 | return t.After(time.Unix(0, 0)) 53 | } 54 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 eureka, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /cmd/kamimai/cmd_down.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/eure/kamimai/core" 7 | "github.com/eure/kamimai/internal/cast" 8 | "github.com/eure/kamimai/internal/direction" 9 | ) 10 | 11 | var ( 12 | downCmd = &Cmd{ 13 | Name: "down", 14 | Usage: "rollback the latest applied migration", 15 | Run: doDownCmd, 16 | } 17 | ) 18 | 19 | func doDownCmd(cmd *Cmd, args ...string) error { 20 | 21 | // driver 22 | driver := core.GetDriver(config.Driver()) 23 | if err := driver.Open(config.Dsn()); err != nil { 24 | return err 25 | } 26 | 27 | current, err := driver.Version().Current() 28 | if err != nil { 29 | return err 30 | } 31 | 32 | // generate a service 33 | svc := core.NewService(config). 34 | WithVersion(current). 35 | WithDriver(driver) 36 | 37 | return driver.Transaction(func(tx *sql.Tx) error { 38 | 39 | if version != 0 { 40 | return svc.Apply(direction.Down, version) 41 | } 42 | 43 | if len(args) == 0 { 44 | // Just one 45 | return svc.Prev(1) 46 | } 47 | 48 | val := cast.Int(args[0]) 49 | if val == 0 { 50 | return nil 51 | } 52 | return svc.Prev(val) 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /core/service_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/eure/kamimai/internal/direction" 10 | ) 11 | 12 | func TestService(t *testing.T) { 13 | assrt := assert.New(t) 14 | 15 | conf := testMustNewConfig(t) 16 | svc := NewService(conf).WithVersion(123) 17 | 18 | assrt.EqualValues(123, svc.version) 19 | 20 | svc.WithVersion(101) 21 | assrt.EqualValues(101, svc.version) 22 | 23 | svc.direction = direction.Up 24 | assrt.NoError(svc.apply()) 25 | migs := ([]*Migration)(svc.data) 26 | assrt.EqualValues(1, migs[0].version) 27 | assrt.True(strings.HasSuffix(migs[0].name, "migrations/001_create_product_up.sql")) 28 | 29 | svc.direction = direction.Down 30 | assrt.NoError(svc.apply()) 31 | migs = ([]*Migration)(svc.data) 32 | assrt.EqualValues(1, migs[0].version) 33 | assrt.True(strings.HasSuffix(migs[0].name, "migrations/001_create_product_down.sql")) 34 | } 35 | 36 | func TestInvalidService(t *testing.T) { 37 | assrt := assert.New(t) 38 | 39 | conf := MustNewConfig("../examples/invalid") 40 | conf.WithEnv("development") 41 | 42 | svc := NewService(conf) 43 | 44 | svc.direction = direction.Up 45 | err := svc.apply() 46 | assrt.Error(err) 47 | } 48 | -------------------------------------------------------------------------------- /cmd/kamimai/cmd_create.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "log" 6 | "os" 7 | 8 | "github.com/eure/kamimai/core" 9 | ) 10 | 11 | var ( 12 | createCmd = &Cmd{ 13 | Name: "create", 14 | Usage: "create a new migration files", 15 | Run: doCreateCmd, 16 | } 17 | ) 18 | 19 | func doCreateCmd(cmd *Cmd, args ...string) error { 20 | // arguments validation 21 | if len(args) < 1 { 22 | return errors.New("no file name specified") 23 | } 24 | 25 | // driver 26 | driver := core.GetDriver(config.Driver()) 27 | if err := driver.Open(config.Dsn()); err != nil { 28 | return err 29 | } 30 | 31 | current, err := driver.Version().Current() 32 | if err != nil { 33 | return err 34 | } 35 | 36 | // generate a service 37 | svc := core.NewService(config). 38 | WithVersion(current). 39 | WithDriver(driver) 40 | 41 | up, down, err := svc.NextMigration(args[len(args)-1]) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | // create migration files 47 | for _, v := range []*core.Migration{up, down} { 48 | if err := svc.MakeMigrationsDir(); err != nil { 49 | log.Fatal(err) 50 | } 51 | 52 | name := v.Name() 53 | if !*dryRun { 54 | if _, err := os.Create(name); err != nil { 55 | log.Fatal(err) 56 | } 57 | } 58 | 59 | // print filename on stdout 60 | log.Printf("created %s", name) 61 | } 62 | 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /internal/direction/direction_test.go: -------------------------------------------------------------------------------- 1 | package direction 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestSuffix(t *testing.T) { 10 | asrt := assert.New(t) 11 | 12 | candidates := []struct { 13 | value int 14 | expected string 15 | message string 16 | }{ 17 | {value: Up, expected: "up", message: ""}, 18 | {value: Down, expected: "down", message: ""}, 19 | {value: Unknown, expected: "", message: ""}, 20 | } 21 | 22 | for _, c := range candidates { 23 | asrt.Equal(c.expected, Suffix(c.value), c.message) 24 | } 25 | } 26 | 27 | func TestGet(t *testing.T) { 28 | asrt := assert.New(t) 29 | 30 | candidates := []struct { 31 | value string 32 | expected int 33 | message string 34 | }{ 35 | {value: "000_foo_up", expected: Up, message: ""}, 36 | {value: "001_foo_up.sql", expected: Up, message: ""}, 37 | {value: "000_foo_down", expected: Down, message: ""}, 38 | {value: "001_foo_down.sql", expected: Down, message: ""}, 39 | {value: "", expected: Unknown, message: ""}, 40 | {value: "foo_up_bar", expected: Unknown, message: ""}, 41 | {value: "foo_down_bar", expected: Unknown, message: ""}, 42 | } 43 | 44 | for _, c := range candidates { 45 | asrt.EqualValues(c.expected, Get(c.value), c.message) 46 | } 47 | } 48 | 49 | func BenchmarkGet(b *testing.B) { 50 | for n := 0; n < b.N; n++ { 51 | _ = Get("000_foo_up") 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /internal/cast/cast.go: -------------------------------------------------------------------------------- 1 | package cast 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | ) 7 | 8 | // Int casts an interface value to a uint64. 9 | func Int(v interface{}) int { 10 | switch v := v.(type) { 11 | case int: 12 | return v 13 | case int8: 14 | return (int)(v) 15 | case int16: 16 | return (int)(v) 17 | case int32: 18 | return (int)(v) 19 | case int64: 20 | return (int)(v) 21 | case uint8: 22 | return (int)(v) 23 | case uint16: 24 | return (int)(v) 25 | case uint32: 26 | return (int)(v) 27 | case uint64: 28 | return (int)(v) 29 | case string: 30 | n, _ := strconv.Atoi(v) 31 | return n 32 | case *string: 33 | if v != nil { 34 | n, _ := strconv.Atoi(*v) 35 | return n 36 | } 37 | } 38 | return 0 39 | } 40 | 41 | // Uint64 casts an interface value to a uint64. 42 | func Uint64(v interface{}) uint64 { 43 | switch v := v.(type) { 44 | case int: 45 | return (uint64)(v) 46 | case int8: 47 | return (uint64)(v) 48 | case int16: 49 | return (uint64)(v) 50 | case int32: 51 | return (uint64)(v) 52 | case int64: 53 | return (uint64)(v) 54 | case uint8: 55 | return (uint64)(v) 56 | case uint16: 57 | return (uint64)(v) 58 | case uint32: 59 | return (uint64)(v) 60 | case uint64: 61 | return v 62 | case string: 63 | n, _ := strconv.ParseUint(v, 10, 64) 64 | return n 65 | case *string: 66 | if v != nil { 67 | n, _ := strconv.ParseUint(*v, 10, 64) 68 | return n 69 | } 70 | case time.Time: 71 | if v.IsZero() { 72 | return 0 73 | } 74 | return Uint64(v.Format("20060102150405")) 75 | } 76 | return 0 77 | } 78 | -------------------------------------------------------------------------------- /core/migration_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "sort" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestMigration(t *testing.T) { 11 | asrt := assert.New(t) 12 | 13 | var mig *Migration 14 | asrt.False(mig.IsValid()) 15 | 16 | mig = NewMigration().WithVersion(123) 17 | asrt.EqualValues(123, mig.version) 18 | mig.WithVersion(101) 19 | asrt.EqualValues(101, mig.version) 20 | asrt.False(mig.IsValid()) 21 | 22 | mig.name = "foo" 23 | asrt.True(mig.IsValid()) 24 | } 25 | 26 | func TestSortMigrations(t *testing.T) { 27 | asrt := assert.New(t) 28 | 29 | migs := (Migrations)([]*Migration{ 30 | NewMigration().WithVersion(123), 31 | NewMigration().WithVersion(12), 32 | NewMigration().WithVersion(1023), 33 | NewMigration().WithVersion(383), 34 | NewMigration().WithVersion(971), 35 | NewMigration().WithVersion(184), 36 | }) 37 | 38 | sort.Sort(migs) 39 | asrt.EqualValues(12, migs[0].version) 40 | asrt.EqualValues(123, migs[1].version) 41 | asrt.EqualValues(184, migs[2].version) 42 | asrt.EqualValues(383, migs[3].version) 43 | asrt.EqualValues(971, migs[4].version) 44 | asrt.EqualValues(1023, migs[5].version) 45 | } 46 | 47 | func TestMigrationsIndex(t *testing.T) { 48 | asrt := assert.New(t) 49 | 50 | migs := (Migrations)([]*Migration{ 51 | NewMigration().WithVersion(123), 52 | NewMigration().WithVersion(12), 53 | NewMigration().WithVersion(1023), 54 | NewMigration().WithVersion(383), 55 | NewMigration().WithVersion(971), 56 | NewMigration().WithVersion(184), 57 | }) 58 | asrt.EqualValues(notFoundIndex, migs.index(Migration{version: 1000000})) 59 | 60 | asrt.Equal(1, migs.index(Migration{version: 12})) 61 | asrt.Equal(2, migs.index(Migration{version: 1023})) 62 | 63 | sort.Sort(migs) 64 | 65 | asrt.Equal(0, migs.index(Migration{version: 12})) 66 | asrt.Equal(5, migs.index(Migration{version: 1023})) 67 | } 68 | -------------------------------------------------------------------------------- /core/driver.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "database/sql" 5 | "sync" 6 | ) 7 | 8 | type ( 9 | // Driver interface. 10 | Driver interface { 11 | // Open is the first function to be called. 12 | // Check the dsn string and open and verify any connection 13 | // that has to be made. 14 | Open(string) error 15 | 16 | // Close is the last function to be called. 17 | // Close any open connection here. 18 | Close() error 19 | 20 | // Ext returns the sql file extension used by path. The extension is the 21 | // suffix beginning at the final dot in the final element of path; it is 22 | // empty if there is no dot. 23 | Ext() string 24 | 25 | // Transaction starts a db transaction. The isolation level is dependent on the 26 | // driver. 27 | Transaction(func(*sql.Tx) error) error 28 | 29 | // Migrate is the heart of the driver. 30 | // It will receive a file which the driver should apply 31 | // to its backend or whatever. The migration function should use 32 | // the pipe channel to return any errors or other useful information. 33 | Migrate(*Migration) error 34 | 35 | // Version returns a version interface. 36 | Version() Version 37 | } 38 | ) 39 | 40 | var ( 41 | registry = struct { 42 | mu sync.RWMutex 43 | drivers map[string]Driver 44 | }{drivers: make(map[string]Driver)} 45 | ) 46 | 47 | // RegisterDriver a driver so it can be created from its name. Drivers should 48 | // call this from an init() function so that they registers themselvse on 49 | // import 50 | func RegisterDriver(name string, d Driver) { 51 | if d == nil { 52 | panic("driver: register driver is nil") 53 | } 54 | registry.mu.Lock() 55 | defer registry.mu.Unlock() 56 | if _, dup := registry.drivers[name]; dup { 57 | panic("sql: register called twice for driver " + name) 58 | } 59 | registry.drivers[name] = d 60 | } 61 | 62 | // GetDriver retrieves a registered driver by name. 63 | func GetDriver(name string) Driver { 64 | registry.mu.RLock() 65 | defer registry.mu.RUnlock() 66 | return registry.drivers[name] 67 | } 68 | -------------------------------------------------------------------------------- /core/config_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func init() { 11 | os.Clearenv() 12 | os.Setenv("MYSQL_USER", "testuser") 13 | os.Setenv("MYSQL_PASSWORD", "testpassword") 14 | os.Setenv("MYSQL_DATABASE", "kamimai") 15 | } 16 | 17 | func testMustNewConfig(t *testing.T) *Config { 18 | conf, err := NewConfig("../examples/testdata") 19 | if assert.NoError(t, err) { 20 | if assert.NotNil(t, conf) { 21 | conf.WithEnv("development") 22 | } 23 | } 24 | return conf 25 | } 26 | 27 | func TestNewConfig(t *testing.T) { 28 | asrt := assert.New(t) 29 | 30 | var ( 31 | conf *Config 32 | err error 33 | ) 34 | 35 | conf, err = NewConfig("") 36 | asrt.Nil(conf) 37 | asrt.Error(err) 38 | 39 | asrt.Equal("", Config{}.Driver()) 40 | asrt.Equal("", Config{}.Dsn()) 41 | asrt.Equal("", Config{}.migrationsDir()) 42 | 43 | conf, err = NewConfig("../examples/testdata") 44 | asrt.NotNil(conf) 45 | 46 | conf.WithEnv("development") 47 | if asrt.NoError(err) { 48 | asrt.Equal("mysql", conf.Driver()) 49 | asrt.Equal("mysql://testuser:testpassword@tcp(:)/kamimai?charset=utf8", conf.Dsn()) 50 | asrt.Equal("../examples/testdata/migrations", conf.migrationsDir()) 51 | } 52 | 53 | conf.WithEnv("test") 54 | if asrt.NoError(err) { 55 | asrt.Equal("mysql", conf.Driver()) 56 | asrt.Equal("mysql://testuser:testpassword@tcp(:)/kamimai?charset=utf8", conf.Dsn()) 57 | asrt.Equal("../examples/testdata/test", conf.migrationsDir()) 58 | } 59 | 60 | var ( 61 | confMySQL *Config 62 | confSQLite *Config 63 | ) 64 | 65 | confMySQL, err = NewConfig("../examples/mysql") 66 | confMySQL.WithEnv("development") 67 | if asrt.NoError(err) { 68 | asrt.Equal("mysql", confMySQL.Driver()) 69 | } 70 | 71 | confSQLite, err = NewConfig("../examples/sqlite3") 72 | confSQLite.WithEnv("development") 73 | if asrt.NoError(err) { 74 | asrt.Equal("sqlite3", confSQLite.Driver()) 75 | } 76 | 77 | conf = MergeConfig(confMySQL, confSQLite) 78 | conf.WithEnv("development") 79 | if asrt.NoError(err) { 80 | asrt.Equal("mysql", conf.Driver()) 81 | } 82 | 83 | conf = MergeConfig(confSQLite, confMySQL) 84 | conf.WithEnv("development") 85 | if asrt.NoError(err) { 86 | asrt.Equal("sqlite3", conf.Driver()) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /driver/driver_test.go: -------------------------------------------------------------------------------- 1 | package driver 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/eure/kamimai/core" 9 | _ "github.com/go-sql-driver/mysql" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | type harness struct { 14 | driver core.Driver 15 | dsn string 16 | } 17 | 18 | func testDriver(t *testing.T, h harness) { 19 | asrt := assert.New(t) 20 | 21 | if asrt.NoError(h.driver.Open(h.dsn)) { 22 | testVersion(t, h.driver.Version()) 23 | 24 | asrt.NoError(h.driver.Transaction(func(tx *sql.Tx) error { 25 | return nil 26 | })) 27 | 28 | asrt.Error(h.driver.Transaction(func(tx *sql.Tx) error { 29 | return errors.New("Error") 30 | })) 31 | 32 | asrt.NoError(h.driver.Close()) 33 | } 34 | } 35 | 36 | func testVersion(t *testing.T, version core.Version) { 37 | asrt := assert.New(t) 38 | var ( 39 | err error 40 | val uint64 41 | ) 42 | 43 | if asrt.NoError(version.Drop()) { 44 | // current 45 | val, err = version.Current() 46 | if asrt.Error(err) { 47 | asrt.EqualValues(0, val) 48 | } 49 | asrt.NoError(version.Create()) 50 | } 51 | 52 | // current 53 | val, err = version.Current() 54 | if asrt.NoError(err) { 55 | asrt.EqualValues(0, val) 56 | } 57 | 58 | asrt.NoError(version.Insert(1)) 59 | asrt.EqualValues(0, version.Count(100)) 60 | asrt.NoError(version.Insert(100)) 61 | val, err = version.Current() 62 | if asrt.NoError(err) { 63 | asrt.EqualValues(100, val, "should be 100") 64 | asrt.EqualValues(1, version.Count(100)) 65 | 66 | // delete 67 | asrt.NoError(version.Delete(50)) 68 | val, err = version.Current() 69 | if asrt.NoError(err) { 70 | asrt.EqualValues(100, val, "should be 100") 71 | 72 | // delete 73 | asrt.NoError(version.Delete(100)) 74 | val, err = version.Current() 75 | if asrt.NoError(err) { 76 | asrt.EqualValues(1, val, "should be 1") 77 | asrt.EqualValues(0, version.Count(100)) 78 | } 79 | } 80 | } 81 | } 82 | 83 | func TestMySQLDriver(t *testing.T) { 84 | asrt := assert.New(t) 85 | 86 | driver := new(MySQL) 87 | asrt.Implements((*core.Driver)(nil), driver) 88 | asrt.Implements((*core.Version)(nil), driver.Version()) 89 | 90 | conf, err := core.NewConfig("../examples/testdata") 91 | if asrt.NoError(err) { 92 | conf.WithEnv("development") 93 | testDriver(t, harness{driver, conf.Dsn()}) 94 | } 95 | 96 | asrt.Equal(".sql", driver.Ext()) 97 | } 98 | -------------------------------------------------------------------------------- /kamimai.go: -------------------------------------------------------------------------------- 1 | package kamimai 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/eure/kamimai/core" 7 | ) 8 | 9 | // Version represents kamimai's semantic version. 10 | const Version = "v0.4.4" 11 | 12 | // Current returns the current migration version. 13 | func Current(c *core.Config) (uint64, error) { 14 | 15 | // driver 16 | driver := core.GetDriver(c.Driver()) 17 | if err := driver.Open(c.Dsn()); err != nil { 18 | return 0, err 19 | } 20 | 21 | // current version 22 | return driver.Version().Current() 23 | } 24 | 25 | // Sync applies non-applied migration files. 26 | func Sync(c *core.Config) error { 27 | 28 | // driver 29 | driver := core.GetDriver(c.Driver()) 30 | if err := driver.Open(c.Dsn()); err != nil { 31 | return err 32 | } 33 | 34 | // current version 35 | current, err := driver.Version().Current() 36 | if err != nil { 37 | return err 38 | } 39 | 40 | // create a service 41 | svc := core.NewService(c). 42 | WithVersion(current). 43 | WithDriver(driver) 44 | 45 | return driver.Transaction(func(tx *sql.Tx) error { 46 | // Sync all migrations 47 | return svc.Sync() 48 | }) 49 | } 50 | 51 | // Up applies up migration files. 52 | func Up(c *core.Config) error { 53 | 54 | // driver 55 | driver := core.GetDriver(c.Driver()) 56 | if err := driver.Open(c.Dsn()); err != nil { 57 | return err 58 | } 59 | 60 | // current version 61 | current, err := driver.Version().Current() 62 | if err != nil { 63 | return err 64 | } 65 | 66 | // create a service 67 | svc := core.NewService(c). 68 | WithVersion(current). 69 | WithDriver(driver) 70 | 71 | return driver.Transaction(func(tx *sql.Tx) error { 72 | // Up migrations 73 | return svc.Up() 74 | }) 75 | } 76 | 77 | // Down applies down migration files. 78 | func Down(c *core.Config) error { 79 | 80 | // driver 81 | driver := core.GetDriver(c.Driver()) 82 | if err := driver.Open(c.Dsn()); err != nil { 83 | return err 84 | } 85 | 86 | // current version 87 | current, err := driver.Version().Current() 88 | if err != nil { 89 | return err 90 | } 91 | 92 | // create a service 93 | svc := core.NewService(c). 94 | WithVersion(current). 95 | WithDriver(driver) 96 | 97 | return driver.Transaction(func(tx *sql.Tx) error { 98 | // Down migrations 99 | return svc.Down() 100 | }) 101 | } 102 | -------------------------------------------------------------------------------- /kamimai_test.go: -------------------------------------------------------------------------------- 1 | package kamimai 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/eure/kamimai/core" 7 | _ "github.com/eure/kamimai/driver" 8 | _ "github.com/go-sql-driver/mysql" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestSync(t *testing.T) { 13 | assert := assert.New(t) 14 | 15 | conf, err := core.NewConfig("examples/testdata") 16 | if assert.NoError(err) { 17 | conf.WithEnv("development") 18 | 19 | defer func() { 20 | // Down 21 | assert.NoError(Down(conf)) 22 | ver, err := Current(conf) 23 | if assert.NoError(err) { 24 | assert.EqualValues(0, ver) 25 | } 26 | }() 27 | 28 | // Down 29 | assert.NoError(Down(conf)) 30 | ver, err := Current(conf) 31 | if assert.NoError(err) { 32 | assert.EqualValues(0, ver) 33 | } 34 | 35 | // Sync 36 | assert.NoError(Sync(conf)) 37 | ver, err = Current(conf) 38 | if assert.NoError(err) { 39 | assert.EqualValues(1, ver) 40 | } 41 | 42 | // Down 43 | assert.NoError(Down(conf)) 44 | ver, err = Current(conf) 45 | if assert.NoError(err) { 46 | assert.EqualValues(0, ver) 47 | } 48 | 49 | // Sync 50 | assert.NoError(Sync(conf)) 51 | ver, err = Current(conf) 52 | if assert.NoError(err) { 53 | assert.EqualValues(1, ver) 54 | } 55 | } 56 | } 57 | 58 | func TestUp(t *testing.T) { 59 | assert := assert.New(t) 60 | 61 | conf, err := core.NewConfig("examples/testdata") 62 | if assert.NoError(err) { 63 | conf.WithEnv("development") 64 | 65 | defer func() { 66 | // Down 67 | assert.NoError(Down(conf)) 68 | ver, err := Current(conf) 69 | if assert.NoError(err) { 70 | assert.EqualValues(0, ver) 71 | } 72 | }() 73 | 74 | // Down 75 | assert.NoError(Down(conf)) 76 | ver, err := Current(conf) 77 | if assert.NoError(err) { 78 | assert.EqualValues(0, ver) 79 | } 80 | 81 | // Up 82 | assert.NoError(Up(conf)) 83 | ver, err = Current(conf) 84 | if assert.NoError(err) { 85 | assert.EqualValues(1, ver) 86 | } 87 | 88 | // Down 89 | assert.NoError(Down(conf)) 90 | ver, err = Current(conf) 91 | if assert.NoError(err) { 92 | assert.EqualValues(0, ver) 93 | } 94 | 95 | // Up 96 | assert.NoError(Up(conf)) 97 | ver, err = Current(conf) 98 | if assert.NoError(err) { 99 | assert.EqualValues(1, ver) 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /internal/version/version_test.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestGet(t *testing.T) { 10 | asrt := assert.New(t) 11 | 12 | candidates := []struct { 13 | value string 14 | expected string 15 | message string 16 | }{ 17 | {value: "000_foo", expected: "000", message: ""}, 18 | {value: "001_foo", expected: "001", message: ""}, 19 | {value: "99999_foo", expected: "99999", message: ""}, 20 | {value: "-000_foo", expected: "", message: ""}, 21 | {value: "-001_foo", expected: "", message: ""}, 22 | {value: "", expected: "", message: ""}, 23 | {value: "foo", expected: "", message: ""}, 24 | {value: "foo_bar", expected: "", message: ""}, 25 | } 26 | 27 | for _, c := range candidates { 28 | asrt.EqualValues(c.expected, Get(c.value), c.message) 29 | } 30 | } 31 | 32 | func BenchmarkGet(b *testing.B) { 33 | for n := 0; n < b.N; n++ { 34 | _ = Get("000_foo") 35 | } 36 | } 37 | 38 | func TestFormat(t *testing.T) { 39 | asrt := assert.New(t) 40 | 41 | candidates := []struct { 42 | value string 43 | expected string 44 | message string 45 | }{ 46 | {value: "000_foo", expected: "%03d", message: ""}, 47 | {value: "001_foo", expected: "%03d", message: ""}, 48 | {value: "99999_foo", expected: "%d", message: ""}, 49 | {value: "-000_foo", expected: "", message: ""}, 50 | {value: "-001_foo", expected: "", message: ""}, 51 | {value: "", expected: "", message: ""}, 52 | {value: "foo", expected: "", message: ""}, 53 | {value: "foo_bar", expected: "", message: ""}, 54 | {value: "20200101150405_foo", expected: "%d", message: ""}, 55 | {value: "001_20200101150405_foo", expected: "%03d", message: ""}, 56 | {value: "999_20200101150405_foo", expected: "%d", message: ""}, 57 | } 58 | 59 | for _, c := range candidates { 60 | asrt.EqualValues(c.expected, Format(c.value), c.message) 61 | } 62 | } 63 | 64 | func BenchmarkFormat(b *testing.B) { 65 | for n := 0; n < b.N; n++ { 66 | _ = Format("000_foo") 67 | } 68 | } 69 | 70 | func TestIsTimestamp(t *testing.T) { 71 | asrt := assert.New(t) 72 | 73 | candidates := []struct { 74 | value uint64 75 | expected bool 76 | message string 77 | }{ 78 | {value: 0, expected: false, message: ""}, 79 | {value: 1, expected: false, message: ""}, 80 | {value: 99999, expected: false, message: ""}, 81 | {value: 19700101000000, expected: false, message: ""}, 82 | {value: 19700101000001, expected: true, message: ""}, 83 | {value: 20200101150405, expected: true, message: ""}, 84 | } 85 | 86 | for _, c := range candidates { 87 | asrt.EqualValues(c.expected, IsTimestamp(c.value), c.message) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GOVERSION=$(shell go version) 2 | GOOS=$(word 1,$(subst /, ,$(lastword $(GOVERSION)))) 3 | GOARCH=$(word 2,$(subst /, ,$(lastword $(GOVERSION)))) 4 | IGNORE_DEPS_GOLINT='vendor/.+\.go' 5 | IGNORE_DEPS_GOCYCLO='vendor/.+\.go' 6 | HAVE_GOLINT:=$(shell which golint) 7 | HAVE_GOCYCLO:=$(shell which gocyclo) 8 | HAVE_GHR:=$(shell which ghr) 9 | HAVE_GOX:=$(shell which gox) 10 | PROJECT_REPONAME=$(notdir $(abspath ./)) 11 | PROJECT_USERNAME=$(notdir $(abspath ../)) 12 | OBJS=$(notdir $(TARGETS)) 13 | LDFLAGS=-ldflags="-s -w" 14 | COMMITISH=$(shell git rev-parse HEAD) 15 | ARTIFACTS_DIR=artifacts 16 | TARGETS=$(addprefix github.com/$(PROJECT_USERNAME)/$(PROJECT_REPONAME)/cmd/,kamimai) 17 | VERSION=$(patsubst "%",%,$(lastword $(shell grep 'const Version' kamimai.go))) 18 | 19 | all: $(TARGETS) 20 | 21 | $(TARGETS): 22 | @go install $(LDFLAGS) -v $@ 23 | 24 | .PHONY: build release clean 25 | build: gox 26 | @mkdir -p $(ARTIFACTS_DIR)/$(VERSION) && cd $(ARTIFACTS_DIR)/$(VERSION); \ 27 | gox $(LDFLAGS) $(TARGETS) 28 | 29 | build-docker-image: 30 | docker build -t kamimai:$(VERSION) --no-cache --rm --compress . 31 | 32 | release: ghr verify-github-token build 33 | @ghr -c $(COMMITISH) -u $(PROJECT_USERNAME) -r $(PROJECT_REPONAME) -t $$GITHUB_TOKEN \ 34 | --replace $(VERSION) $(ARTIFACTS_DIR)/$(VERSION) 35 | 36 | clean: 37 | $(RM) -r $(ARTIFACTS_DIR) 38 | 39 | .PHONY: unit lint cyclo test 40 | unit: lint cyclo test 41 | 42 | lint: golint 43 | @echo "go lint" 44 | @lint=`golint ./...`; \ 45 | lint=`echo "$$lint" | grep -E -v -e ${IGNORE_DEPS_GOLINT}`; \ 46 | echo "$$lint"; if [ "$$lint" != "" ]; then exit 1; fi 47 | 48 | cyclo: gocyclo 49 | @echo "gocyclo -over 20" 50 | @cyclo=`gocyclo -over 20 . 2>&1`; \ 51 | cyclo=`echo "$$cyclo" | grep -E -v -e ${IGNORE_DEPS_GOCYCLO}`; \ 52 | echo "$$cyclo"; if [ "$$cyclo" != "" ]; then exit 1; fi 53 | 54 | test: 55 | @MYSQL_USER=kamimai MYSQL_PASSWORD=kamimai MYSQL_DATABASE=kamimai MYSQL_HOST=127.0.0.1 MYSQL_PORT=3306 go test ./... 56 | 57 | .PHONY: verify-github-token 58 | verify-github-token: 59 | @if [ -z "$$GITHUB_TOKEN" ]; then echo '$$GITHUB_TOKEN is required'; exit 1; fi 60 | 61 | .PHONY: golint gocyclo ghr gox 62 | golint: 63 | ifndef HAVE_GOLINT 64 | @echo "Installing linter" 65 | @go get -u golang.org/x/lint/golint 66 | endif 67 | 68 | gocyclo: 69 | ifndef HAVE_GOCYCLO 70 | @echo "Installing gocyclo" 71 | @go get -u github.com/fzipp/gocyclo 72 | endif 73 | 74 | ghr: 75 | ifndef HAVE_GHR 76 | @echo "Installing ghr to upload binaries for release page" 77 | @go get -u github.com/tcnksm/ghr 78 | endif 79 | 80 | gox: 81 | ifndef HAVE_GOX 82 | @echo "Installing gox to build binaries for Go cross compilation" 83 | @go get -u github.com/mitchellh/gox 84 | endif 85 | 86 | -------------------------------------------------------------------------------- /internal/cast/cast_test.go: -------------------------------------------------------------------------------- 1 | package cast 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestInt(t *testing.T) { 11 | asrt := assert.New(t) 12 | 13 | str := "123" 14 | 15 | candidates := []struct { 16 | value interface{} 17 | expected int 18 | message string 19 | }{ 20 | {value: 0, expected: 0, message: ""}, 21 | {value: 1, expected: 1, message: ""}, 22 | {value: int8(1), expected: 1, message: ""}, 23 | {value: int16(1), expected: 1, message: ""}, 24 | {value: int32(1), expected: 1, message: ""}, 25 | {value: int64(1), expected: 1, message: ""}, 26 | {value: uint8(1), expected: 1, message: ""}, 27 | {value: uint16(1), expected: 1, message: ""}, 28 | {value: uint32(1), expected: 1, message: ""}, 29 | {value: uint64(1), expected: 1, message: ""}, 30 | {value: 0x10, expected: 16, message: ""}, 31 | {value: "10", expected: 10, message: ""}, 32 | {value: "+10", expected: 10, message: ""}, 33 | {value: "-10", expected: -10, message: ""}, 34 | {value: "-0", expected: 0, message: ""}, 35 | {value: nil, expected: 0, message: ""}, 36 | {value: (*string)(nil), expected: 0, message: ""}, 37 | {value: str, expected: 123, message: ""}, 38 | {value: &str, expected: 123, message: ""}, 39 | } 40 | 41 | for _, c := range candidates { 42 | asrt.EqualValues(c.expected, Int(c.value), c.message) 43 | } 44 | } 45 | 46 | func TestUint64(t *testing.T) { 47 | asrt := assert.New(t) 48 | 49 | str := "123" 50 | now, _ := time.Parse("20060102150405", "20160622150000") 51 | 52 | candidates := []struct { 53 | value interface{} 54 | expected uint64 55 | message string 56 | }{ 57 | {value: 0, expected: 0, message: ""}, 58 | {value: 1, expected: 1, message: ""}, 59 | {value: int8(1), expected: 1, message: ""}, 60 | {value: int16(1), expected: 1, message: ""}, 61 | {value: int32(1), expected: 1, message: ""}, 62 | {value: int64(1), expected: 1, message: ""}, 63 | {value: uint8(1), expected: 1, message: ""}, 64 | {value: uint16(1), expected: 1, message: ""}, 65 | {value: uint32(1), expected: 1, message: ""}, 66 | {value: uint64(1), expected: 1, message: ""}, 67 | {value: 0x10, expected: 16, message: ""}, 68 | {value: "10", expected: 10, message: ""}, 69 | {value: "-10", expected: 0, message: ""}, 70 | {value: "-0", expected: 0, message: ""}, 71 | {value: nil, expected: 0, message: ""}, 72 | {value: (*string)(nil), expected: 0, message: ""}, 73 | {value: str, expected: 123, message: ""}, 74 | {value: &str, expected: 123, message: ""}, 75 | {value: time.Time{}, expected: 0, message: ""}, 76 | {value: now, expected: 20160622150000, message: ""}, 77 | } 78 | 79 | for _, c := range candidates { 80 | asrt.EqualValues(c.expected, Uint64(c.value), c.message) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kamimai - 紙舞 2 | 3 | A migration manager written in Golang. Use it in run commands via the CLI. 4 | 5 | [![GoDoc](https://godoc.org/github.com/eure/kamimai?status.svg)](https://godoc.org/github.com/eure/kamimai) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/eure/kamimai)](https://goreportcard.com/report/github.com/eure/kamimai) 7 | 8 | ## Installation 9 | 10 | `kamimai` is written in Go, so if you have Go installed you can install it with go get: 11 | 12 | ```shell 13 | go get github.com/eure/kamimai/cmd/kamimai 14 | ``` 15 | 16 | Make sure that `kamimai` was installed correctly: 17 | 18 | ```shell 19 | kamimai -h 20 | ``` 21 | 22 | ## Usage: 23 | 24 | ### Create 25 | 26 | ```shell 27 | # create new migration files 28 | kamimai -path=./examples/mysql -env=test1 create migrate_name 29 | ``` 30 | 31 | ### Up 32 | 33 | ```shell 34 | # apply all available migrations 35 | kamimai -path=./examples/mysql -env=test1 up 36 | 37 | # apply the next n migrations 38 | kamimai -path=./examples/mysql -env=test1 up n 39 | 40 | # apply the given version migration 41 | kamimai -path=./examples/mysql -env=test1 up -version=20060102150405 42 | ``` 43 | 44 | ### Down 45 | 46 | ```shell 47 | # rollback the previous migration 48 | kamimai -path=./examples/mysql -env=test1 down 49 | 50 | # rollback the previous n migrations 51 | kamimai -path=./example/mysql -env=test1 down n 52 | 53 | # rollback the given version migration 54 | kamimai -path=./examples/mysql -env=test1 down -version=20060102150405 55 | ``` 56 | 57 | ### Sync 58 | 59 | ```shell 60 | # sync all migrations 61 | kamimai -path=./examples/mysql -env=test1 sync 62 | ``` 63 | 64 | ## Usage in Go code 65 | 66 | ```go 67 | package main 68 | 69 | import ( 70 | "github.com/eure/kamimai" 71 | "github.com/eure/kamimai/core" 72 | _ "github.com/eure/kamimai/driver" 73 | ) 74 | 75 | func main() { 76 | conf, err := core.NewConfig("examples/testdata") 77 | if err != nil { 78 | panic(err) 79 | } 80 | 81 | conf.WithEnv("development") 82 | 83 | // Sync 84 | kamimai.Sync(conf) 85 | 86 | // ... 87 | ``` 88 | 89 | ## Drivers 90 | 91 | ### Availables 92 | 93 | - MySQL 94 | 95 | ### Plan 96 | 97 | - SQLite 98 | - PostgreSQL 99 | - _and more_ 100 | 101 | ## Contribution 102 | 103 | ### Setup 104 | 105 | ```sh 106 | docker-compose up -d 107 | 108 | # kamimai の実行 109 | MYSQL_HOST=127.0.0.1 MYSQL_USER=kamimai MYSQL_PASSWORD=kamimai go run ./cmd/kamimai --dry-run --env=development --path=./examples/mysql create test1 110 | kamimai: created examples/mysql/migrations/002_test_up.sql 111 | kamimai: created examples/mysql/migrations/002_test_down.sql 112 | ``` 113 | 114 | ## License 115 | 116 | [The MIT License (MIT)](https://github.com/eure/kamimai/blob/master/LICENSE) 117 | -------------------------------------------------------------------------------- /core/config.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path" 7 | "path/filepath" 8 | 9 | "github.com/BurntSushi/toml" 10 | "gopkg.in/yaml.v2" 11 | ) 12 | 13 | type ( 14 | // Config object. 15 | Config struct { 16 | data map[string]internal 17 | env string 18 | dir string 19 | } 20 | 21 | internal struct { 22 | Driver string `yaml:"driver"` 23 | Dsn string `yaml:"dsn"` 24 | Directory string `yaml:"directory"` 25 | } 26 | ) 27 | 28 | // MustNewConfig returns a new config. dir cannot be empty. 29 | func MustNewConfig(dir string) *Config { 30 | c, err := NewConfig(dir) 31 | if err != nil { 32 | panic(err) 33 | } 34 | return c 35 | } 36 | 37 | // NewConfig returns a new config. dir cannot be empty. 38 | func NewConfig(dir string) (*Config, error) { 39 | files, err := ioutil.ReadDir(dir) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | conf := Config{dir: dir} 45 | for _, f := range files { 46 | fpath := filepath.Join(dir, f.Name()) 47 | 48 | switch filepath.Ext(fpath) { 49 | case ".yml", ".yaml": 50 | b, err := ioutil.ReadFile(fpath) 51 | if err != nil { 52 | return nil, err 53 | } 54 | if err := yaml.Unmarshal(b, &conf.data); err != nil { 55 | return nil, err 56 | } 57 | 58 | case ".tml", ".toml": 59 | if _, err := toml.DecodeFile(fpath, &conf.data); err != nil { 60 | return nil, err 61 | } 62 | } 63 | } 64 | 65 | return &conf, nil 66 | } 67 | 68 | // MergeConfig returns merged config. 69 | func MergeConfig(c ...*Config) *Config { 70 | conf := Config{data: map[string]internal{}} 71 | for _, v := range c { 72 | if conf.env == "" && v.env != "" { 73 | conf.env = v.env 74 | } 75 | 76 | for key, vv := range v.data { 77 | if _, ok := conf.data[key]; !ok { 78 | conf.data[key] = vv 79 | } 80 | } 81 | } 82 | return &conf 83 | } 84 | 85 | // WithEnv sets an environment of config. 86 | func (c *Config) WithEnv(env string) *Config { 87 | c.env = env 88 | return c 89 | } 90 | 91 | // Dir returns a config file existing path name. 92 | func (c Config) Dir() string { 93 | return c.dir 94 | } 95 | 96 | // Driver returns a raw driver string. 97 | func (c Config) Driver() string { 98 | if d, ok := c.data[c.env]; ok { 99 | return d.Driver 100 | } 101 | return "" 102 | } 103 | 104 | // Dsn returns a raw dsn string. 105 | func (c Config) Dsn() string { 106 | if d, ok := c.data[c.env]; ok { 107 | return os.ExpandEnv(d.Dsn) 108 | } 109 | return "" 110 | } 111 | 112 | func (c Config) migrationsDir() string { 113 | if d, ok := c.data[c.env]; ok { 114 | if d.Directory == "" { 115 | d.Directory = "migrations" 116 | } 117 | return path.Clean(path.Join(c.Dir(), d.Directory)) 118 | } 119 | return "" 120 | } 121 | -------------------------------------------------------------------------------- /cmd/kamimai/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "os" 8 | "text/template" 9 | 10 | "github.com/eure/kamimai" 11 | "github.com/eure/kamimai/core" 12 | _ "github.com/eure/kamimai/driver" 13 | _ "github.com/go-sql-driver/mysql" 14 | ) 15 | 16 | var ( 17 | cmds = []*Cmd{ 18 | createCmd, 19 | upCmd, 20 | downCmd, 21 | syncCmd, 22 | // migrateCmd, 23 | } 24 | 25 | help = flag.String("help", "", "show help") 26 | dirPath = flag.String("path", "", "migration dir containing config") 27 | env = flag.String("env", "", "config environment to use") 28 | dryRun = flag.Bool("dry-run", false, "") 29 | 30 | config *core.Config 31 | ) 32 | 33 | func init() { 34 | log.SetPrefix("kamimai: ") 35 | log.SetFlags(0) 36 | } 37 | 38 | func main() { 39 | flag.Usage = usage 40 | flag.Parse() 41 | os.Exit(run(flag.Args())) 42 | } 43 | 44 | func run(args []string) int { 45 | 46 | if len(args) == 0 { 47 | flag.Usage() 48 | return 1 49 | } 50 | 51 | var cmd *Cmd 52 | name := args[0] 53 | for _, c := range cmds { 54 | if c.Name == name { 55 | cmd = c 56 | break 57 | } 58 | } 59 | 60 | if cmd == nil { 61 | fmt.Printf("error: unknown command %q\n", name) 62 | flag.Usage() 63 | return 1 64 | } 65 | 66 | if err := cmd.Exec(args[1:]); err != nil { 67 | log.Fatal(err) 68 | return 1 69 | } 70 | 71 | return 0 72 | } 73 | 74 | func usage() { 75 | params := map[string]interface{}{ 76 | "name": "kamimai", 77 | "description": "kamimai is a database migration management system.", 78 | "usage": "kamimai [global options] command [command options] [arguments...]", 79 | "version": kamimai.Version, 80 | "author": "kaneshin ", 81 | } 82 | 83 | params["opts"] = func() (list []map[string]interface{}) { 84 | flag.VisitAll(func(f *flag.Flag) { 85 | opt := map[string]interface{}{ 86 | "name": f.Name, 87 | "summary": f.Usage, 88 | } 89 | list = append(list, opt) 90 | }) 91 | return 92 | }() 93 | 94 | params["cmds"] = func() (list []map[string]interface{}) { 95 | for _, c := range cmds { 96 | cmd := map[string]interface{}{ 97 | "name": c.Name, 98 | "summary": c.Usage, 99 | } 100 | list = append(list, cmd) 101 | } 102 | return 103 | }() 104 | 105 | helpTemplate.Execute(os.Stdout, params) 106 | } 107 | 108 | var helpTemplate = template.Must(template.New("usage").Parse(` 109 | NAME: 110 | {{.name}} - {{.description}} 111 | 112 | USAGE: 113 | {{.usage}} 114 | 115 | VERSION: 116 | {{.version}} 117 | 118 | AUTHOR(S): 119 | {{.author}} 120 | 121 | COMMANDS:{{range .cmds}} 122 | {{.name | printf "%-18s"}} {{.summary}}{{end}} 123 | 124 | GLOBAL OPTIONS:{{range .opts}} 125 | {{.name | printf "--%-16s"}} {{.summary}}{{end}} 126 | `)) 127 | -------------------------------------------------------------------------------- /core/migration.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "strconv" 7 | "time" 8 | 9 | "github.com/eure/kamimai/internal/cast" 10 | ) 11 | 12 | type ( 13 | // A Migration manages migration files. 14 | Migration struct { 15 | version uint64 16 | name string 17 | } 18 | 19 | // A Migrations collects Migration for sorting. 20 | Migrations []*Migration 21 | ) 22 | 23 | ////////////////////////////// 24 | // Migration 25 | ////////////////////////////// 26 | 27 | // NewMigration returns a new Migration pointer that can be chained with builder methods to 28 | // set multiple configuration values inline without using pointers. 29 | func NewMigration() *Migration { 30 | return &Migration{} 31 | } 32 | 33 | // WithVersion sets a config version value returning a Config pointer 34 | // for chaining. 35 | func (m *Migration) WithVersion(v interface{}) *Migration { 36 | m.version = cast.Uint64(v) 37 | return m 38 | } 39 | 40 | // Version returns a migration version value. 41 | func (m Migration) Version() uint64 { 42 | return m.version 43 | } 44 | 45 | // Read reads from file until an error or EOF and returns the data it read. 46 | // A successful call returns err == nil, not err == EOF. Because ReadAll is 47 | // defined to read from src until EOF, it does not treat an EOF from Read 48 | // as an error to be reported. 49 | func (m Migration) Read() ([]byte, error) { 50 | file, err := os.Open(m.name) 51 | if err != nil { 52 | return nil, err 53 | } 54 | return ioutil.ReadAll(file) 55 | } 56 | 57 | // Name returns a file name. 58 | func (m Migration) Name() string { 59 | return m.name 60 | } 61 | 62 | // IsValid reports whether the migration is valid. 63 | func (m *Migration) IsValid() bool { 64 | if m == nil { 65 | return false 66 | } 67 | return m.version > 0 && m.name != "" 68 | } 69 | 70 | func (m *Migration) IsValidTimestamp() bool { 71 | _, err := time.Parse("20060102150405", strconv.FormatUint(m.version, 10)) 72 | return err == nil 73 | } 74 | 75 | ////////////////////////////// 76 | // Migrations 77 | ////////////////////////////// 78 | 79 | func (m Migrations) index(mig Migration) int { 80 | for i, v := range m { 81 | if v.version == mig.version { 82 | return i 83 | } 84 | } 85 | return int(notFoundIndex) 86 | } 87 | 88 | func (m Migrations) first() *Migration { 89 | if m.Len() == 0 { 90 | return nil 91 | } 92 | return m[0] 93 | } 94 | 95 | func (m Migrations) last() *Migration { 96 | c := m.Len() 97 | if c == 0 { 98 | return nil 99 | } 100 | return m[c-1] 101 | } 102 | 103 | // Len is the number of elements in the collection. 104 | // Required by Sort Interface{} 105 | func (m Migrations) Len() int { 106 | return len(m) 107 | } 108 | 109 | // Less reports whether the element with 110 | // index i should sort before the element with index j. 111 | // Required by Sort Interface{} 112 | func (m Migrations) Less(i, j int) bool { 113 | return m[i].version < m[j].version 114 | } 115 | 116 | // Swap swaps the elements with indexes i and j. 117 | // Required by Sort Interface{} 118 | func (m Migrations) Swap(i, j int) { 119 | m[i], m[j] = m[j], m[i] 120 | } 121 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 2 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 3 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= 8 | github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= 9 | github.com/hashicorp/go-version v1.0.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 10 | github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= 11 | github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 12 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 13 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 14 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 15 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 16 | github.com/mitchellh/gox v1.0.1 h1:x0jD3dcHk9a9xPSDN6YEL4xL6Qz0dvNYm8yZqui5chI= 17 | github.com/mitchellh/gox v1.0.1/go.mod h1:ED6BioOGXMswlXa2zxfh/xdd5QhwYliBFn9V18Ap4z4= 18 | github.com/mitchellh/iochan v1.0.0 h1:C+X3KsSTLFVBr/tK1eYN/vs4rJcvsiLU338UhYPJWeY= 19 | github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= 20 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= 21 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 22 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 23 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 24 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 25 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 26 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 27 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 28 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 29 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 30 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= 31 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 32 | gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= 33 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 34 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 35 | gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c h1:grhR+C34yXImVGp7EzNk+DTIk+323eIUWOmEevy6bDo= 36 | gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 37 | -------------------------------------------------------------------------------- /driver/mysql.go: -------------------------------------------------------------------------------- 1 | package driver 2 | 3 | import ( 4 | "bytes" 5 | "database/sql" 6 | "errors" 7 | "fmt" 8 | "strings" 9 | "sync" 10 | 11 | "github.com/eure/kamimai/core" 12 | ) 13 | 14 | type ( 15 | // MySQL driver object. 16 | MySQL struct { 17 | db *sql.DB 18 | tx *sql.Tx 19 | mu sync.Mutex 20 | } 21 | ) 22 | 23 | // Open is the first function to be called. 24 | // Check the dsn string and open and verify any connection 25 | // that has to be made. 26 | func (d *MySQL) Open(dsn string) error { 27 | z := strings.SplitN(dsn, "mysql://", 2) 28 | if len(z) != 2 { 29 | return errors.New("invalid data source name of mysql") 30 | } 31 | 32 | db, err := sql.Open("mysql", z[1]) 33 | if err != nil { 34 | return err 35 | } 36 | if err := db.Ping(); err != nil { 37 | return err 38 | } 39 | d.db = db 40 | 41 | return d.Version().Create() 42 | } 43 | 44 | // Close is the last function to be called. 45 | // Close any open connection here. 46 | func (d *MySQL) Close() error { 47 | return d.db.Close() 48 | } 49 | 50 | // Ext returns the sql file extension used by path. The extension is the 51 | // suffix beginning at the final dot in the final element of path; it is 52 | // empty if there is no dot. 53 | func (d *MySQL) Ext() string { 54 | return ".sql" 55 | } 56 | 57 | // Transaction starts a db transaction. The isolation level is dependent on the 58 | // driver. 59 | func (d *MySQL) Transaction(fn func(*sql.Tx) error) error { 60 | d.mu.Lock() 61 | defer func() { 62 | d.tx = nil 63 | d.mu.Unlock() 64 | }() 65 | 66 | tx, err := d.db.Begin() 67 | if err != nil { 68 | return err 69 | } 70 | d.tx = tx 71 | 72 | // Procedure 73 | if err := fn(d.tx); err != nil { 74 | if rberr := d.tx.Rollback(); rberr != nil { 75 | return rberr 76 | } 77 | return err 78 | } 79 | 80 | // Commit 81 | if err := d.tx.Commit(); err != nil { 82 | if rberr := d.tx.Rollback(); rberr != nil { 83 | return rberr 84 | } 85 | return err 86 | } 87 | 88 | return nil 89 | } 90 | 91 | // Exec executes a query without returning any rows. The args are for any 92 | // placeholder parameters in the query. 93 | func (d *MySQL) Exec(query string, args ...interface{}) (sql.Result, error) { 94 | if d.tx != nil { 95 | return d.tx.Exec(query, args...) 96 | } 97 | return d.db.Exec(query, args...) 98 | } 99 | 100 | // Version returns a version interface. 101 | func (d *MySQL) Version() core.Version { 102 | return d 103 | } 104 | 105 | // Migrate applies migration file. 106 | func (d *MySQL) Migrate(m *core.Migration) error { 107 | b, err := m.Read() 108 | if err != nil { 109 | return err 110 | } 111 | 112 | var list []string 113 | stmts := bytes.Split(b, []byte(";")) 114 | for _, stmt := range stmts { 115 | list = append(list, string(stmt)) 116 | var query string 117 | if len(list) == 1 { 118 | query = strings.TrimSpace(list[0]) 119 | if len(query) == 0 { 120 | continue 121 | } 122 | } else { 123 | query = strings.TrimSpace(strings.Join(list, ";")) 124 | } 125 | _, err = d.Exec(query) 126 | if err != nil { 127 | continue 128 | } else { 129 | list = nil 130 | } 131 | } 132 | 133 | return err 134 | } 135 | 136 | // Insert inserts the given migration version. 137 | func (d *MySQL) Insert(val uint64) error { 138 | query := fmt.Sprintf(`INSERT INTO %s (version) VALUES (%d)`, 139 | versionTableName, val) 140 | 141 | _, err := d.Exec(query) 142 | if err != nil { 143 | return err 144 | } 145 | return nil 146 | } 147 | 148 | // Delete deletes the given migration version. 149 | func (d *MySQL) Delete(val uint64) error { 150 | query := fmt.Sprintf(`DELETE FROM %s WHERE version = %d`, 151 | versionTableName, val) 152 | 153 | _, err := d.Exec(query) 154 | if err != nil { 155 | return err 156 | } 157 | return nil 158 | } 159 | 160 | // Count counts number of row the given migration version. 161 | func (d *MySQL) Count(val uint64) int { 162 | query := fmt.Sprintf(`SELECT count(version) count FROM %s WHERE version = %d`, 163 | versionTableName, val) 164 | 165 | var count int 166 | if err := d.db.QueryRow(query).Scan(&count); err != nil { 167 | return 0 168 | } 169 | return count 170 | } 171 | 172 | // Current returns the current migration version. 173 | func (d *MySQL) Current() (uint64, error) { 174 | const query = `SELECT version FROM ` + 175 | versionTableName + ` ORDER BY version DESC LIMIT 1` 176 | 177 | var version uint64 178 | err := d.db.QueryRow(query).Scan(&version) 179 | switch { 180 | case err == sql.ErrNoRows: 181 | return 0, nil 182 | case err != nil: 183 | return 0, err 184 | } 185 | return version, nil 186 | } 187 | 188 | // Create creates 189 | func (d *MySQL) Create() error { 190 | const query = `CREATE TABLE IF NOT EXISTS ` + 191 | versionTableName + ` (version BIGINT NOT NULL PRIMARY KEY);` 192 | 193 | _, err := d.Exec(query) 194 | if err != nil { 195 | return err 196 | } 197 | return nil 198 | } 199 | 200 | // Drop drops 201 | func (d *MySQL) Drop() error { 202 | const query = `DROP TABLE IF EXISTS ` + versionTableName 203 | 204 | _, err := d.Exec(query) 205 | if err != nil { 206 | return err 207 | } 208 | return nil 209 | } 210 | -------------------------------------------------------------------------------- /core/service.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | "sort" 9 | "time" 10 | 11 | "github.com/pkg/errors" 12 | 13 | "github.com/eure/kamimai/internal/cast" 14 | "github.com/eure/kamimai/internal/direction" 15 | "github.com/eure/kamimai/internal/version" 16 | ) 17 | 18 | const ( 19 | notFoundIndex = 0xffff 20 | ) 21 | 22 | var ( 23 | errOutOfBoundsMigrations = errors.New("out of bounds migration") 24 | errDuplicateMigrationVersion = errors.New("duplicate migration version") 25 | ) 26 | 27 | type ( 28 | // A Service manages for kamimai. 29 | Service struct { 30 | config *Config 31 | driver Driver 32 | version uint64 33 | direction int 34 | data Migrations 35 | } 36 | ) 37 | 38 | func (s Service) walker(indexPath map[uint64]*Migration) func(string, os.FileInfo, error) error { 39 | wd, _ := os.Getwd() 40 | 41 | return func(path string, info os.FileInfo, err error) error { 42 | if info.IsDir() { 43 | return nil 44 | } 45 | 46 | name := info.Name() 47 | d := direction.Get(name) 48 | 49 | if s.direction == direction.Unknown { 50 | // Collect filename of up in case of unknown direction. 51 | if d != direction.Up { 52 | return nil 53 | } 54 | } else { 55 | // Collect filename of the same direction. 56 | if s.direction != d { 57 | return nil 58 | } 59 | } 60 | 61 | fullname := filepath.Clean(filepath.Join(wd, path)) 62 | ver := cast.Uint64(version.Get(name)) 63 | mig, found := indexPath[ver] 64 | if found && mig.IsValid() { 65 | return errors.Wrap(errDuplicateMigrationVersion, fmt.Sprintf("failed to read migration %s", fullname)) 66 | } 67 | 68 | mig = &Migration{ 69 | version: ver, 70 | } 71 | indexPath[ver] = mig 72 | mig.name = fullname 73 | 74 | return nil 75 | } 76 | } 77 | 78 | // NewService returns a new Service pointer that can be chained with builder methods to 79 | // set multiple configuration values inline without using pointers. 80 | func NewService(c *Config) *Service { 81 | svc := &Service{ 82 | config: c, 83 | } 84 | return svc 85 | } 86 | 87 | // WithVersion sets a config version value returning a Service pointer 88 | // for chaining. 89 | func (s *Service) WithVersion(v interface{}) *Service { 90 | s.version = cast.Uint64(v) 91 | return s 92 | } 93 | 94 | // WithDriver sets a driver returning a Service pointer for chaining. 95 | func (s *Service) WithDriver(d Driver) *Service { 96 | s.driver = d 97 | return s 98 | } 99 | 100 | // MakeMigrationsDir creates a directory named path, along with any necessary 101 | // parents, and returns nil, or else returns an error. If path is 102 | // already a directory, MkdirAll does nothing and returns nil. 103 | func (s *Service) MakeMigrationsDir() error { 104 | return os.MkdirAll(s.config.migrationsDir(), 0777) 105 | } 106 | 107 | func (s *Service) mustApply() { 108 | if err := s.apply(); err != nil { 109 | panic(err) 110 | } 111 | } 112 | 113 | func (s *Service) apply() error { 114 | index := map[uint64]*Migration{} 115 | if err := filepath.Walk(s.config.migrationsDir(), s.walker(index)); err != nil { 116 | return err 117 | } 118 | 119 | list := make([]*Migration, len(index)) 120 | i := 0 121 | for _, m := range index { 122 | list[i] = m 123 | i++ 124 | } 125 | 126 | migs := Migrations(list) 127 | sort.Sort(migs) 128 | s.data = migs 129 | return nil 130 | } 131 | 132 | func (s *Service) do(idx int) error { 133 | drv := s.driver 134 | 135 | migs := s.data 136 | if !(0 <= idx && idx < migs.Len()) { 137 | return errOutOfBoundsMigrations 138 | } 139 | 140 | mig := migs[idx] 141 | if err := drv.Migrate(mig); err != nil { 142 | return err 143 | } 144 | 145 | switch s.direction { 146 | case direction.Up: 147 | if err := drv.Version().Insert(mig.version); err != nil { 148 | return err 149 | } 150 | case direction.Down: 151 | if err := drv.Version().Delete(mig.version); err != nil { 152 | return err 153 | } 154 | } 155 | 156 | log.Println("applied", mig.Name()) 157 | 158 | return nil 159 | } 160 | 161 | func (s *Service) step(n int) error { 162 | if err := s.apply(); err != nil { 163 | return err 164 | } 165 | 166 | if s.version == 0 { 167 | // init 168 | if n > 0 { 169 | // only up 170 | for i := 0; i < n; i++ { 171 | if err := s.do(i); err != nil { 172 | return err 173 | } 174 | } 175 | } 176 | return nil 177 | } 178 | 179 | // gets current index of migrations 180 | idx := s.data.index(Migration{version: s.version}) 181 | 182 | if n > 0 { 183 | // up 184 | for i := 0; i < n; i++ { 185 | if err := s.do(idx + i + 1); err != nil { 186 | return err 187 | } 188 | } 189 | } else { 190 | // down 191 | for i := 0; i < -n; i++ { 192 | if err := s.do(idx - i); err != nil { 193 | return err 194 | } 195 | } 196 | } 197 | 198 | return nil 199 | } 200 | 201 | // Apply applies the given migration version. 202 | func (s *Service) Apply(d int, version uint64) error { 203 | s.direction = d 204 | if err := s.apply(); err != nil { 205 | return err 206 | } 207 | 208 | // gets current index of migrations 209 | idx := s.data.index(Migration{version: version}) 210 | return s.do(idx) 211 | } 212 | 213 | func (s *Service) up(n int) error { 214 | s.direction = direction.Up 215 | err := s.step(n) 216 | switch err { 217 | case errOutOfBoundsMigrations: 218 | return nil 219 | } 220 | return err 221 | } 222 | 223 | func (s *Service) down(n int) error { 224 | s.direction = direction.Down 225 | err := s.step(-n) 226 | switch err { 227 | case errOutOfBoundsMigrations: 228 | return nil 229 | } 230 | return err 231 | } 232 | 233 | // Up upgrades migration version. 234 | func (s *Service) Up() error { 235 | return s.up(notFoundIndex) 236 | } 237 | 238 | // Down downgrades migration version. 239 | func (s *Service) Down() error { 240 | return s.down(notFoundIndex) 241 | } 242 | 243 | // Next upgrades migration version. 244 | func (s *Service) Next(n int) error { 245 | return s.up(n) 246 | } 247 | 248 | // Prev downgrades migration version. 249 | func (s *Service) Prev(n int) error { 250 | return s.down(n) 251 | } 252 | 253 | // Sync applies all migrations. 254 | func (s *Service) Sync() error { 255 | s.direction = direction.Up 256 | if err := s.apply(); err != nil { 257 | return err 258 | } 259 | if len(s.data) == 0 { 260 | return nil 261 | } 262 | 263 | version := s.driver.Version() 264 | // 一番目のマイグレーションファイルが Timestamp 形式だったらそれ以降は Timestamp 扱いにする 265 | isTimeStampVer := s.data[0].IsValidTimestamp() 266 | for _, mig := range s.data { 267 | if isTimeStampVer != mig.IsValidTimestamp() { 268 | return errors.New("invalid version format") 269 | } 270 | 271 | if count := version.Count(mig.version); count == 0 { 272 | // gets current index of migrations 273 | idx := s.data.index(*mig) 274 | if err := s.do(idx); err != nil { 275 | return err 276 | } 277 | } 278 | } 279 | 280 | return nil 281 | } 282 | 283 | // NextMigration returns next version migrations. 284 | func (s *Service) NextMigration(name string) (up *Migration, down *Migration, err error) { 285 | if err := s.apply(); err != nil { 286 | return nil, nil, err 287 | } 288 | 289 | // initialize default variables for making migrations. 290 | up, down = &Migration{version: 1, name: ""}, &Migration{version: 1, name: ""} 291 | verFormat := "%03d" 292 | 293 | // gets the latest migration version file. 294 | if latest := s.data.last(); latest != nil { 295 | // check if the format of the latest version is timestamp 296 | if version.IsTimestamp(latest.version) { 297 | // for version format 298 | v := cast.Uint64(time.Now()) 299 | up.version, down.version = v, v 300 | } else { 301 | // for version number 302 | v := latest.version + 1 303 | up.version, down.version = v, v 304 | } 305 | _, file := filepath.Split(latest.name) 306 | verFormat = version.Format(file) 307 | } 308 | 309 | // [ver]_[name]_[direction-suffix][.ext] 310 | base := fmt.Sprintf("%s_%s_%%s%%s", verFormat, name) 311 | // including dot 312 | ext := s.driver.Ext() 313 | 314 | // up 315 | n := fmt.Sprintf(base, up.version, direction.Suffix(direction.Up), ext) 316 | up.name = filepath.Join(s.config.migrationsDir(), n) 317 | // down 318 | n = fmt.Sprintf(base, down.version, direction.Suffix(direction.Down), ext) 319 | down.name = filepath.Join(s.config.migrationsDir(), n) 320 | 321 | return 322 | } 323 | --------------------------------------------------------------------------------