├── .circleci └── config.yml ├── .gitignore ├── .golangci.yml ├── .markdownlint.json ├── .travis.yml ├── LICENSE ├── README.md ├── applied-migration.go ├── applied-migration_test.go ├── dialect.go ├── doc.go ├── embed_go116.go ├── embed_go116_test.go ├── errors_test.go ├── file.go ├── file_test.go ├── go.mod ├── go.sum ├── main_test.go ├── migration.go ├── migration_test.go ├── migrator.go ├── migrator_test.go ├── mssql.go ├── mssql_test.go ├── mysql.go ├── mysql_test.go ├── options.go ├── options_test.go ├── postgres.go ├── postgres_test.go ├── schema.go ├── schema_test.go ├── sqlite.go ├── sqlite_test.go ├── test-migrations ├── contacts │ ├── 0000-00-00 001 Contacts.sql │ ├── 0000-00-00 002 Phone Numbers.sql │ └── 0000-00-00 003 Addresses.sql ├── music │ ├── 0000-00-00 001 Artists.sql │ ├── 0000-00-00 002 Albums.sql │ └── 0000-00-00 003 Tracks.sql ├── saas │ ├── 2019-01-01 0900 Create Users.sql │ └── 2019-01-03 1000 Create Affiliates.sql ├── unreadable │ ├── readable.sql │ └── unreadable.sql └── useless-ansi │ ├── 0000-00-00 001 Select 1.sql │ └── 0000-00-00 002 Select 2.sql └── testdb_test.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Golang CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-go/ for more details 4 | version: 2.1 5 | jobs: 6 | golangci-lint: 7 | docker: 8 | - image: cimg/go:1.21 9 | steps: 10 | - checkout 11 | - run: 12 | # https://golangci-lint.run/usage/install/ 13 | name: Install golangci-lint 14 | # Note: It's likely the below URL's "master" will change to "main" someday. 15 | # The version of golangci-lint being used can be changed with the vN.N.N at the end of this URL. 16 | command: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b ~/bin v1.52.2 17 | 18 | - run: 19 | name: Run Go Linters 20 | command: ~/bin/golangci-lint run . --timeout 2m 21 | 22 | go-build-and-test: 23 | machine: 24 | image: ubuntu-2204:2023.02.1 25 | steps: 26 | - checkout 27 | - run: 28 | name: Fetch Dependencies 29 | command: go get -t . 30 | - run: 31 | name: Execute Go Build 32 | command: go build . 33 | - run: 34 | name: Execute Go Tests 35 | command: go test -race -coverprofile=coverage.txt -covermode=atomic 36 | - run: 37 | name: Upload Code Coverage 38 | command: bash <(curl -s https://codecov.io/bash) 39 | 40 | workflows: 41 | version: 2 42 | build: 43 | jobs: 44 | - golangci-lint 45 | - go-build-and-test 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | .DS_Store 15 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | tests: false 3 | linters: 4 | enable: 5 | - nilerr 6 | - bodyclose 7 | - gofmt 8 | - revive 9 | - govet 10 | - gosec 11 | linters-settings: 12 | gofmt: 13 | simplify: false 14 | issues: 15 | exclude-use-default: false 16 | exclude: 17 | # errcheck: Almost all programs ignore errors on these functions and in most cases it's ok 18 | - Error return value of .((os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*printf?|os\.(Un)?Setenv). is not checked 19 | # golint 20 | - func name will be used as test\.Test.* by other packages, and that stutters; consider calling this 21 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "MD046": false 3 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.13 5 | - 1.16 6 | - 1.17 7 | - tip 8 | 9 | before_install: 10 | - go get -t -v ./... 11 | 12 | script: 13 | - go test -coverprofile=coverage.txt -covermode=atomic 14 | 15 | after_success: 16 | - bash <(curl -s https://codecov.io/bash) 17 | 18 | services: 19 | - docker 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Aaron Longwell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Schema - Database Migrations for Go 2 | 3 | An embeddable library for applying changes to your Go application's 4 | `database/sql` schema. 5 | 6 | [![go.dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=for-the-badge)](https://pkg.go.dev/github.com/adlio/schema) 7 | [![CircleCI Build Status](https://img.shields.io/circleci/build/github/adlio/schema?style=for-the-badge)](https://dl.circleci.com/status-badge/redirect/gh/adlio/schema/tree/main) 8 | [![Go Report Card](https://goreportcard.com/badge/github.com/adlio/schema?style=for-the-badge)](https://goreportcard.com/report/github.com/adlio/schema) 9 | [![Code Coverage](https://img.shields.io/codecov/c/github/adlio/schema?style=for-the-badge)](https://codecov.io/gh/adlio/schema) 10 | 11 | ## Features 12 | 13 | - Cloud-friendly design tolerates embedded use in clusters 14 | - Supports migrations in embed.FS (requires go:embed in Go 1.16+) 15 | - [Depends only on Go standard library](https://pkg.go.dev/github.com/adlio/schema?tab=imports) (Note that all go.mod dependencies are used only in tests) 16 | - Unidirectional migrations (no "down" migration complexity) 17 | 18 | # Usage Instructions 19 | 20 | Create a `schema.Migrator` in your bootstrap/config/database connection code, 21 | then call its `Apply()` method with your database connection and a slice of 22 | `*schema.Migration` structs. 23 | 24 | The `.Apply()` function figures out which of the supplied Migrations have not 25 | yet been executed in the database (based on the ID), and executes the `Script` 26 | for each in **alphabetical order by IDe**. 27 | 28 | The `[]*schema.Migration` can be created manually, but the package 29 | has some utility functions to make it easier to parse .sql files into structs 30 | with the filename as the `ID` and the file contents as the `Script`. 31 | 32 | ## Using go:embed (requires Go 1.16+) 33 | 34 | Go 1.16 added features to embed a directory of files into the binary as an 35 | embedded filesystem (`embed.FS`). 36 | 37 | Assuming you have a directory of SQL files called `my-migrations/` next to your 38 | main.go file, you'll run something like this: 39 | 40 | ```go 41 | //go:embed my-migrations 42 | var MyMigrations embed.FS 43 | 44 | func main() { 45 | db, err := sql.Open(...) // Or however you get a *sql.DB 46 | 47 | migrations, err := schema.FSMigrations(MyMigrations, "my-migrations/*.sql") 48 | migrator := schema.NewMigrator(schema.WithDialect(schema.MySQL)) 49 | err = migrator.Apply(db, migrations) 50 | } 51 | ``` 52 | 53 | The `WithDialect()` option accepts: `schema.MySQL`, `schema.Postgres`, 54 | `schema.SQLite` or `schema.MSSQL`. These dialects all use only `database/sql` 55 | calls, so you may have success with other databases which are SQL-compatible 56 | with the above dialects. 57 | 58 | You can also provide your own custom `Dialect`. See `dialect.go` for the 59 | definition of the `Dialect` interface, and the optional `Locker` interface. Note 60 | that `Locker` is critical for clustered operation to ensure that only one of 61 | many processes is attempting to run migrations simultaneously. 62 | 63 | ## Using Inline Migration Structs 64 | 65 | If you're running in an earlier version of Go, Migration{} structs will need to 66 | be created manually: 67 | 68 | ```go 69 | db, err := sql.Open(...) 70 | 71 | migrator := schema.NewMigrator() // Postgres is the default Dialect 72 | migrator.Apply(db, []*schema.Migration{ 73 | &schema.Migration{ 74 | ID: "2019-09-24 Create Albums", 75 | Script: ` 76 | CREATE TABLE albums ( 77 | id SERIAL PRIMARY KEY, 78 | title CHARACTER VARYING (255) NOT NULL 79 | ) 80 | ` 81 | }, 82 | }) 83 | ``` 84 | 85 | ## Constructor Options 86 | 87 | The `NewMigrator()` function accepts option arguments to customize the dialect 88 | and the name of the migration tracking table. By default, the tracking table 89 | will be named `schema_migrations`. To change it 90 | to `my_migrations` instead: 91 | 92 | ```go 93 | migrator := schema.NewMigrator(schema.WithTableName("my_migrations")) 94 | ``` 95 | 96 | It is theoretically possible to create multiple Migrators and to use mutliple 97 | migration tracking tables within the same application and database. 98 | 99 | It is also OK for multiple processes to run `Apply` on identically configured 100 | migrators simultaneously. The `Migrator` only creates the tracking table if it 101 | does not exist, and then locks it to modifications while building and running 102 | the migration plan. This means that the first-arriving process will **win** and 103 | will perform its migrations on the database. 104 | 105 | ## Supported Databases 106 | 107 | This package was extracted from a PostgreSQL project. Other databases have solid 108 | automated test coverage, but should be considered somewhat experimental in 109 | production use cases. [Contributions](#contributions) are welcome for 110 | additional databases or feature enhancements / bug fixes. 111 | 112 | - [x] PostgreSQL (database/sql driver only, see [adlio/pgxschema](https://github.com/adlio/pgxschema) if you use `jack/pgx`) 113 | - [x] SQLite (thanks [kalafut](https://github.com/kalafut)!) 114 | - [x] MySQL / MariaDB 115 | - [x] SQL Server 116 | - [ ] CockroachDB, Redshift, Snowflake, etc (open a Pull Request) 117 | 118 | ## Package Opinions 119 | 120 | There are many other schema migration tools. This one exists because of a 121 | particular set of opinions: 122 | 123 | 1. Database credentials are runtime configuration details, but database 124 | schema is a **build-time applicaton dependency**, which means it should be 125 | "compiled in" to the build, and should not rely on external tools. 126 | 2. Using an external command-line tool for schema migrations needlessly 127 | complicates testing and deployment. 128 | 3. SQL is the best language to use to specify changes to SQL schemas. 129 | 4. "Down" migrations add needless complication, aren't often used, and are 130 | tedious to properly test when they are used. In the unlikely event you need 131 | to migrate backwards, it's possible to write the "rollback" migration as 132 | a separate "up" migration. 133 | 5. Deep dependency chains should be avoided, especially in a compiled 134 | binary. We don't want to import an ORM into our binaries just to get SQL 135 | the features of this package. The `schema` package imports only 136 | [standard library packages](https://godoc.org/github.com/adlio/schema?imports) 137 | (**NOTE** \*We do import `ory/dockertest` in our tests). 138 | 6. Sequentially-numbered integer migration IDs will create too many unnecessary 139 | schema collisions on a distributed, asynchronously-communicating team 140 | (this is not yet strictly enforced, but may be later). 141 | 142 | ## Rules of Applying Migrations 143 | 144 | 1. **Never, ever change** the `ID` (filename) or `Script` (file contents) 145 | of a Migration which has already been executed on your database. If you've 146 | made a mistake, you'll need to correct it in a subsequent migration. 147 | 2. Use a consistent, but descriptive format for migration `ID`s/filenames. 148 | Consider prefixing them with today's timestamp. Examples: 149 | 150 | ID: "2019-01-01T13:45:00 Creates Users" 151 | ID: "2001-12-18 001 Changes the Default Value of User Affiliate ID" 152 | 153 | Do not use simple sequentialnumbers like `ID: "1"`. 154 | 155 | ## Migration Ordering 156 | 157 | Migrations **are not** executed in the order they are specified in the slice. 158 | They will be re-sorted alphabetically by their IDs before executing them. 159 | 160 | ## Contributions 161 | 162 | ... are welcome. Please include tests with your contribution. We've integrated 163 | [dockertest](https://github.com/ory/dockertest) to automate the process of 164 | creating clean test databases. 165 | 166 | Before contributing, please read the [package opinions](#package-opinions) 167 | section. If your contribution is in disagreement with those opinions, then 168 | there's a good chance a different schema migration tool is more appropriate. 169 | 170 | ## Roadmap 171 | 172 | - [x] Enhancements and documentation to facilitate asset embedding via go:embed 173 | - [ ] Add a `Validate()` method to allow checking migration names for 174 | consistency and to detect problematic changes in the migrations list. 175 | - [x] SQL Server support 176 | - [ ] SQL Server support for the Locker interface to protect against simultaneous 177 | migrations from clusters of servers. 178 | 179 | ## Version History 180 | 181 | ### 1.3.4 - Apr 9, 2023 182 | 183 | - Update downstream dependencies to address vulnerabilities in test dependencies. 184 | 185 | ### 1.3.3 - Jun 19, 2022 186 | 187 | - Update downstream dependencies of ory/dockertest due to security issues. 188 | 189 | ### 1.3.0 - Mar 25, 2022 190 | 191 | - Basic SQL Server support (no locking, not recommended for use in clusters) 192 | - Improved support for running tests on ARM64 machines (M1 Macs) 193 | 194 | ### 1.2.3 - Dec 10, 2021 195 | 196 | - BUGFIX: Restore the ability to chain NewMigrator().Apply 197 | 198 | ### 1.2.2 - Dec 9, 2021 199 | 200 | - Add support for migrations in an embed.FS (`FSMigrations(filesystem fs.FS, glob string)`) 201 | - Add MySQL/MariaDB support (experimental) 202 | - Add SQLite support (experimental) 203 | - Update go.mod to `go 1.17`. 204 | 205 | ### 1.1.14 - Nov 18, 2021 206 | 207 | Security patches in upstream dependencies. 208 | 209 | ### 1.1.13 - May 22, 2020 210 | 211 | Bugfix for error with advisory lock being held open. Improved test coverage for 212 | simultaneous execution. 213 | 214 | ### 1.1.11 - May 19, 2020 215 | 216 | Use a database-held lock for all migrations not just the initial table creation. 217 | 218 | ### 1.1.9 - May 17, 2020 219 | 220 | Add the ability to attach a logger. 221 | 222 | ### 1.1.8 - Nov 24, 2019 223 | 224 | Switch to `filepath` package for improved cross-platform filesystem support. 225 | 226 | ### 1.1.7 - Oct 1, 2019 227 | 228 | Began using pg_advisory_lock() to prevent race conditions when multiple 229 | processes/machines try to simultaneously create the migrations table. 230 | 231 | ### 1.1.1 - Sep 28, 2019 232 | 233 | First published version. 234 | -------------------------------------------------------------------------------- /applied-migration.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | // AppliedMigration represents a successfully-executed migration. It embeds 9 | // Migration, and adds fields for execution results. This type is what 10 | // records persisted in the schema_migrations table align with. 11 | type AppliedMigration struct { 12 | Migration 13 | 14 | // Checksum is the MD5 hash of the Script for this migration 15 | Checksum string 16 | 17 | // ExecutionTimeInMillis is populated after the migration is run, indicating 18 | // how much time elapsed while the Script was executing. 19 | ExecutionTimeInMillis int64 20 | 21 | // AppliedAt is the time at which this particular migration's Script began 22 | // executing (not when it completed executing). 23 | AppliedAt time.Time 24 | } 25 | 26 | // GetAppliedMigrations retrieves all already-applied migrations in a map keyed 27 | // by the migration IDs 28 | func (m Migrator) GetAppliedMigrations(db Queryer) (applied map[string]*AppliedMigration, err error) { 29 | applied = make(map[string]*AppliedMigration) 30 | 31 | // Get the raw data from the Dialect 32 | migrations, err := m.Dialect.GetAppliedMigrations(m.ctx, db, m.QuotedTableName()) 33 | if err != nil { 34 | err = fmt.Errorf("Failed to GetAppliedMigrations. Did somebody change the structure of the %s table? %w", m.QuotedTableName(), err) 35 | return applied, err 36 | } 37 | 38 | // Re-index into a map 39 | for _, migration := range migrations { 40 | applied[migration.ID] = migration 41 | } 42 | 43 | return applied, err 44 | } 45 | -------------------------------------------------------------------------------- /applied-migration_test.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/DATA-DOG/go-sqlmock" 7 | ) 8 | 9 | func TestGetAppliedMigrations(t *testing.T) { 10 | withEachTestDB(t, func(t *testing.T, tdb *TestDB) { 11 | db := tdb.Connect(t) 12 | defer func() { _ = db.Close() }() 13 | 14 | migrator := makeTestMigrator(WithDialect(tdb.Dialect)) 15 | migrations := testMigrations(t, "useless-ansi") 16 | err := migrator.Apply(db, migrations) 17 | if err != nil { 18 | t.Error(err) 19 | } 20 | 21 | expectedCount := len(migrations) 22 | applied, err := migrator.GetAppliedMigrations(db) 23 | if err != nil { 24 | t.Error(err) 25 | } 26 | if len(applied) != expectedCount { 27 | t.Errorf("Expected %d applied migrations. Got %d", expectedCount, len(applied)) 28 | } 29 | }) 30 | } 31 | 32 | func TestGetAppliedMigrationsErrorsWhenTheTableDoesntExist(t *testing.T) { 33 | withEachTestDB(t, func(t *testing.T, tdb *TestDB) { 34 | db := tdb.Connect(t) 35 | defer func() { _ = db.Close() }() 36 | 37 | migrator := makeTestMigrator() 38 | migrations, err := migrator.GetAppliedMigrations(db) 39 | if err == nil { 40 | t.Error("Expected an error. Got none.") 41 | } 42 | if len(migrations) > 0 { 43 | t.Error("Expected empty list of applied migrations") 44 | } 45 | 46 | }) 47 | } 48 | 49 | func TestGetAppliedMigrationsHasFriendlyScanError(t *testing.T) { 50 | withEachTestDB(t, func(t *testing.T, tdb *TestDB) { 51 | migrator := makeTestMigrator(WithDialect(tdb.Dialect)) 52 | 53 | db, mock, err := sqlmock.New() 54 | if err != nil { 55 | t.Error(err) 56 | } 57 | 58 | // Build a rowset that is completely different than the AppliedMigration 59 | // struct is expecting to force a Scan error 60 | rows := sqlmock.NewRows([]string{"nonsense", "column", "names"}).AddRow(1, "trash", "data") 61 | mock.ExpectQuery("^SELECT").RowsWillBeClosed().WillReturnRows(rows) 62 | 63 | _, err = migrator.GetAppliedMigrations(db) 64 | expectErrorContains(t, err, migrator.TableName) 65 | }) 66 | } 67 | -------------------------------------------------------------------------------- /dialect.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import "context" 4 | 5 | // Dialect defines the minimal interface for a database dialect. All dialects 6 | // must implement functions to create the migrations table, get all applied 7 | // migrations, insert a new migration tracking record, and perform escaping 8 | // for the tracking table's name 9 | type Dialect interface { 10 | QuotedTableName(schemaName, tableName string) string 11 | 12 | CreateMigrationsTable(ctx context.Context, tx Queryer, tableName string) error 13 | GetAppliedMigrations(ctx context.Context, tx Queryer, tableName string) (applied []*AppliedMigration, err error) 14 | InsertAppliedMigration(ctx context.Context, tx Queryer, tableName string, migration *AppliedMigration) error 15 | } 16 | 17 | // Locker defines an optional Dialect extension for obtaining and releasing 18 | // a global database lock during the running of migrations. This feature is 19 | // supported by PostgreSQL and MySQL, but not SQLite. 20 | type Locker interface { 21 | Lock(ctx context.Context, tx Queryer, tableName string) error 22 | Unlock(ctx context.Context, tx Queryer, tableName string) error 23 | } 24 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Package schema provides tools to manage database schema changes 2 | // ("migrations") as embedded functionality inside another application 3 | // which is using a database/sql 4 | // 5 | // Basic usage instructions involve creating a schema.Migrator via the 6 | // schema.NewMigrator() function, and then passing your *sql.DB 7 | // to its .Apply() method. 8 | // 9 | // See the package's README.md file for more usage instructions. 10 | package schema 11 | -------------------------------------------------------------------------------- /embed_go116.go: -------------------------------------------------------------------------------- 1 | //go:build go1.16 2 | // +build go1.16 3 | 4 | package schema 5 | 6 | import ( 7 | "fmt" 8 | "io/fs" 9 | ) 10 | 11 | // FSMigrations receives a filesystem (such as an embed.FS) and extracts all 12 | // files matching the provided glob as Migrations, with the filename (without extension) 13 | // being the ID and the file's contents being the Script. 14 | // 15 | // Example usage: 16 | // 17 | // FSMigrations(embeddedFS, "my-migrations/*.sql") 18 | func FSMigrations(filesystem fs.FS, glob string) (migrations []*Migration, err error) { 19 | migrations = make([]*Migration, 0) 20 | 21 | entries, err := fs.Glob(filesystem, glob) 22 | if err != nil { 23 | return migrations, fmt.Errorf("failed to process glob '%s' in embed.FS: %w", glob, err) 24 | } 25 | 26 | for _, entry := range entries { 27 | migration := &Migration{ 28 | ID: MigrationIDFromFilename(entry), 29 | } 30 | data, err := fs.ReadFile(filesystem, entry) 31 | if err != nil { 32 | return migrations, err 33 | } 34 | migration.Script = string(data) 35 | migrations = append(migrations, migration) 36 | } 37 | return migrations, nil 38 | } 39 | -------------------------------------------------------------------------------- /embed_go116_test.go: -------------------------------------------------------------------------------- 1 | //go:build go1.16 2 | // +build go1.16 3 | 4 | package schema 5 | 6 | import ( 7 | "embed" 8 | "io/fs" 9 | "testing" 10 | "testing/fstest" 11 | ) 12 | 13 | //go:embed test-migrations 14 | var exampleMigrations embed.FS 15 | 16 | func TestMigrationsFromEmbedFS(t *testing.T) { 17 | migrations, err := FSMigrations(exampleMigrations, "test-migrations/saas/*.sql") 18 | if err != nil { 19 | t.Error(err) 20 | } 21 | 22 | expectedCount := 2 23 | if len(migrations) != expectedCount { 24 | t.Errorf("Expected %d migrations, got %d", expectedCount, len(migrations)) 25 | } 26 | 27 | SortMigrations(migrations) 28 | expectID(t, migrations[0], "2019-01-01 0900 Create Users") 29 | expectScriptMatch(t, migrations[0], `^CREATE TABLE users`) 30 | expectID(t, migrations[1], "2019-01-03 1000 Create Affiliates") 31 | expectScriptMatch(t, migrations[1], `^CREATE TABLE affiliates`) 32 | } 33 | 34 | func TestMigrationsWithInvalidGlob(t *testing.T) { 35 | _, err := FSMigrations(exampleMigrations, "/a/path[]with/bad/glob/pattern") 36 | expectErrorContains(t, err, "/a/path[]with/bad/glob/pattern") 37 | } 38 | 39 | func TestFSMigrationsWithInvalidFiles(t *testing.T) { 40 | testfs := fstest.MapFS{ 41 | "invalid-migrations": &fstest.MapFile{ 42 | Mode: fs.ModeDir, 43 | }, 44 | "invalid-migrations/real.sql": &fstest.MapFile{ 45 | Data: []byte("File contents"), 46 | }, 47 | "invalid-migrations/fake.sql": nil, 48 | } 49 | _, err := FSMigrations(testfs, "invalid-migrations/*.sql") 50 | expectErrorContains(t, err, "fake.sql") 51 | } 52 | -------------------------------------------------------------------------------- /errors_test.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "fmt" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/DATA-DOG/go-sqlmock" 12 | ) 13 | 14 | var ( 15 | // ErrConnFailed indicates that the Conn() method failed (couldn't get a connection) 16 | ErrConnFailed = fmt.Errorf("connect failed") 17 | 18 | // ErrBeginFailed indicates that the Begin() method failed (couldn't start Tx) 19 | ErrBeginFailed = fmt.Errorf("begin failed") 20 | 21 | ErrLockFailed = fmt.Errorf("lock failed") 22 | ) 23 | 24 | // BadQueryer implements the Connection interface, but fails on every call to 25 | // Exec or Query. The error message will include the SQL statement to help 26 | // verify the "right" failure occurred. 27 | type BadQueryer struct{} 28 | 29 | func (bq BadQueryer) ExecContext(ctx context.Context, sql string, args ...interface{}) (sql.Result, error) { 30 | return nil, fmt.Errorf("FAIL: %s", strings.TrimSpace(sql)) 31 | } 32 | 33 | func (bq BadQueryer) QueryContext(ctx context.Context, sql string, args ...interface{}) (*sql.Rows, error) { 34 | return nil, fmt.Errorf("FAIL: %s", strings.TrimSpace(sql)) 35 | } 36 | 37 | // BadDB implements the interface for the *sql.DB Conn() method in a way that 38 | // always fails 39 | type BadDB struct{} 40 | 41 | func (bd BadDB) Conn(ctx context.Context) (*sql.Conn, error) { 42 | return nil, ErrConnFailed 43 | } 44 | 45 | func TestApplyWithNilDBProvidesHelpfulError(t *testing.T) { 46 | withEachDialect(t, func(t *testing.T, d Dialect) { 47 | migrator := NewMigrator(WithDialect(d)) 48 | err := migrator.Apply(nil, testMigrations(t, "useless-ansi")) 49 | if !errors.Is(err, ErrNilDB) { 50 | t.Errorf("Expected %v, got %v", ErrNilDB, err) 51 | } 52 | }) 53 | } 54 | 55 | func TestApplyWithNoMigrations(t *testing.T) { 56 | db, _, _ := sqlmock.New() 57 | migrator := NewMigrator() 58 | err := migrator.Apply(db, []*Migration{}) 59 | if err != nil { 60 | t.Errorf("Expected no error when running no migrations, got %s", err) 61 | } 62 | } 63 | func TestApplyConnFailure(t *testing.T) { 64 | bd := BadDB{} 65 | migrator := Migrator{} 66 | err := migrator.Apply(bd, testMigrations(t, "useless-ansi")) 67 | if err != ErrConnFailed { 68 | t.Errorf("Expected %v, got %v", ErrConnFailed, err) 69 | } 70 | } 71 | 72 | func TestApplyLockFailure(t *testing.T) { 73 | migrator := NewMigrator() 74 | db, mock, _ := sqlmock.New() 75 | mock.ExpectExec("^SELECT pg_advisory_lock").WillReturnError(ErrLockFailed) 76 | err := migrator.Apply(db, testMigrations(t, "useless-ansi")) 77 | if err != ErrLockFailed { 78 | t.Errorf("Expected err '%s', got '%s'", ErrLockFailed, err) 79 | } 80 | } 81 | 82 | func TestApplyBeginFailure(t *testing.T) { 83 | migrator := NewMigrator() 84 | 85 | db, mock, _ := sqlmock.New() 86 | mock.ExpectExec("^SELECT pg_advisory_lock").WillReturnResult(sqlmock.NewResult(0, 0)) 87 | mock.ExpectBegin().WillReturnError(ErrBeginFailed) 88 | mock.ExpectExec("^SELECT pg_advisory_unlock").WillReturnResult(sqlmock.NewResult(0, 0)) 89 | err := migrator.Apply(db, testMigrations(t, "useless-ansi")) 90 | if err != ErrBeginFailed { 91 | t.Errorf("Expected err '%s', got '%s'", ErrBeginFailed, err) 92 | } 93 | } 94 | 95 | func TestApplyCreateFailure(t *testing.T) { 96 | migrator := NewMigrator() 97 | 98 | db, mock, _ := sqlmock.New() 99 | mock.ExpectExec("^SELECT pg_advisory_lock").WillReturnResult(sqlmock.NewResult(0, 0)) 100 | mock.ExpectBegin() 101 | expectedErr := fmt.Errorf("CREATE TABLE statement failed") 102 | mock.ExpectExec("^CREATE TABLE").WillReturnError(expectedErr) 103 | mock.ExpectRollback() 104 | mock.ExpectExec("^SELECT pg_advisory_unlock").WillReturnResult(sqlmock.NewResult(0, 0)) 105 | err := migrator.Apply(db, testMigrations(t, "useless-ansi")) 106 | if err != expectedErr { 107 | t.Errorf("Expected err '%s', got '%s'", expectedErr, err) 108 | } 109 | } 110 | 111 | func TestLockFailure(t *testing.T) { 112 | bq := BadQueryer{} 113 | migrator := NewMigrator() 114 | err := migrator.lock(bq) 115 | expectErrorContains(t, err, "SELECT pg_advisory_lock") 116 | } 117 | 118 | func TestUnlockFailure(t *testing.T) { 119 | bq := BadQueryer{} 120 | migrator := NewMigrator() 121 | err := migrator.unlock(bq) 122 | expectErrorContains(t, err, "SELECT pg_advisory_unlock") 123 | } 124 | 125 | func TestComputeMigrationPlanFailure(t *testing.T) { 126 | bq := BadQueryer{} 127 | withEachDialect(t, func(t *testing.T, d Dialect) { 128 | migrator := NewMigrator(WithDialect(d)) 129 | _, err := migrator.computeMigrationPlan(bq, []*Migration{}) 130 | expectErrorContains(t, err, "FAIL: SELECT id, checksum, execution_time_in_millis, applied_at") 131 | }) 132 | } 133 | 134 | func expectErrorContains(t *testing.T, err error, contains string) { 135 | t.Helper() 136 | if err == nil { 137 | t.Errorf("Expected an error string containing '%s', got nil", contains) 138 | } else if !strings.Contains(err.Error(), contains) { 139 | t.Errorf("Expected an error string containing '%s', got '%s' instead", contains, err.Error()) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /file.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "path" 8 | "path/filepath" 9 | "strings" 10 | ) 11 | 12 | // MigrationIDFromFilename removes directory paths and extensions 13 | // from the filename to make a friendlier Migration ID 14 | func MigrationIDFromFilename(filename string) string { 15 | return strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename)) 16 | } 17 | 18 | // MigrationsFromDirectoryPath retrieves a slice of Migrations from the 19 | // contents of the directory. Only .sql files are read 20 | func MigrationsFromDirectoryPath(dirPath string) (migrations []*Migration, err error) { 21 | migrations = make([]*Migration, 0) 22 | 23 | // Assemble a glob of the .sql files in the directory. This can 24 | // only fail if the dirPath itself contains invalid glob characters 25 | filenames, err := filepath.Glob(filepath.Join(dirPath, "*.sql")) 26 | if err != nil { 27 | return migrations, err 28 | } 29 | 30 | // Friendly failure: if the user provides a valid-looking, but nonexistent 31 | // directory, we want to error instead of returning an empty set 32 | if _, err := os.Stat(dirPath); os.IsNotExist(err) { 33 | return migrations, fmt.Errorf("migrations directory does not exist: %w", err) 34 | } 35 | 36 | for _, filename := range filenames { 37 | migration, err := MigrationFromFilePath(filename) 38 | if err != nil { 39 | return migrations, err 40 | } 41 | migrations = append(migrations, migration) 42 | } 43 | return 44 | } 45 | 46 | // MigrationFromFilePath creates a Migration from a path on disk 47 | func MigrationFromFilePath(filename string) (migration *Migration, err error) { 48 | migration = &Migration{} 49 | migration.ID = MigrationIDFromFilename(filename) 50 | contents, err := ioutil.ReadFile(path.Clean(filename)) 51 | if err != nil { 52 | return migration, fmt.Errorf("failed to read migration from '%s': %w", filename, err) 53 | } 54 | migration.Script = string(contents) 55 | return migration, err 56 | } 57 | 58 | // File wraps the standard library io.Read and os.File.Name methods 59 | type File interface { 60 | Name() string 61 | Read(b []byte) (n int, err error) 62 | } 63 | 64 | // MigrationFromFile builds a migration by reading from an open File-like 65 | // object. The migration's ID will be based on the file's name. The file 66 | // will *not* be closed after being read. 67 | func MigrationFromFile(file File) (migration *Migration, err error) { 68 | migration = &Migration{} 69 | migration.ID = MigrationIDFromFilename(file.Name()) 70 | content, err := ioutil.ReadAll(file) 71 | if err != nil { 72 | return migration, err 73 | } 74 | migration.Script = string(content) 75 | return migration, err 76 | } 77 | -------------------------------------------------------------------------------- /file_test.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "testing" 7 | ) 8 | 9 | func TestMigrationFromFilePath(t *testing.T) { 10 | migration, err := MigrationFromFilePath("./test-migrations/saas/2019-01-01 0900 Create Users.sql") 11 | if err != nil { 12 | t.Error(err) 13 | } 14 | expectID(t, migration, "2019-01-01 0900 Create Users") 15 | expectScriptMatch(t, migration, `^CREATE TABLE users`) 16 | } 17 | 18 | func TestMigrationFromFilePathWithInvalidPath(t *testing.T) { 19 | _, err := MigrationFromFilePath("./test-migrations/saas/nonexistent-file.sql") 20 | if err == nil { 21 | t.Errorf("Expected failure when reading from nonexistent file") 22 | } 23 | } 24 | 25 | func TestMigrationFromFile(t *testing.T) { 26 | file, err := os.Open("./test-migrations/saas/2019-01-01 0900 Create Users.sql") 27 | if err != nil { 28 | t.Error(err) 29 | } 30 | migration, err := MigrationFromFile(file) 31 | if err != nil { 32 | t.Error(err) 33 | } 34 | expectID(t, migration, "2019-01-01 0900 Create Users") 35 | expectScriptMatch(t, migration, `^CREATE TABLE users`) 36 | } 37 | 38 | func TestMigrationsFromDirectoryPath(t *testing.T) { 39 | migrations, err := MigrationsFromDirectoryPath("./test-migrations/saas") 40 | if err != nil { 41 | t.Error(err) 42 | } 43 | SortMigrations(migrations) 44 | expectID(t, migrations[0], "2019-01-01 0900 Create Users") 45 | expectID(t, migrations[1], "2019-01-03 1000 Create Affiliates") 46 | } 47 | 48 | func TestMigrationsFromDirectoryPathThrowsErrorForInvalidDirectory(t *testing.T) { 49 | migrations, err := MigrationsFromDirectoryPath("/a/totally/made/up/directory/path") 50 | if err == nil { 51 | t.Error("Expected an error trying to load migrations from a fake directory") 52 | } 53 | if len(migrations) > 0 { 54 | t.Errorf("Expected an empty list of migrations. Got %d", len(migrations)) 55 | } 56 | } 57 | 58 | func TestMigrationsFromDirectoryPathThrowsErrorForInvalidGlob(t *testing.T) { 59 | _, err := MigrationsFromDirectoryPath("/a/path[]with/bad/glob/pattern") 60 | if err == nil { 61 | t.Error("Expected an error trying to load migrations from a fake directory") 62 | } 63 | } 64 | 65 | func TestMigrationsFromDirectoryPathThrowsErrorWithUnreadableFiles(t *testing.T) { 66 | err := os.Chmod("./test-migrations/unreadable/unreadable.sql", 0200) 67 | if err != nil { 68 | t.Error(err) 69 | } 70 | _, err = MigrationsFromDirectoryPath("./test-migrations/unreadable") 71 | if err == nil { 72 | t.Error("Expected a failure when trying to read unreadable file") 73 | } 74 | _ = os.Chmod("./test-migrations/unreadable/unreadable.sql", 0644) // #nosec 75 | } 76 | 77 | type failedReader int 78 | 79 | func (fr failedReader) Name() string { 80 | return "fakeFile.sql" 81 | } 82 | 83 | func (fr failedReader) Read(p []byte) (n int, err error) { 84 | return 0, errors.New("this reader always fails") 85 | } 86 | 87 | func TestMigrationFromFileWithUnreadableFile(t *testing.T) { 88 | var fr failedReader 89 | _, err := MigrationFromFile(fr) 90 | if err == nil { 91 | t.Error("Expected MigrationFromFile to fail when given erroneous file") 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/adlio/schema 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/DATA-DOG/go-sqlmock v1.5.0 7 | github.com/denisenkom/go-mssqldb v0.12.0 8 | github.com/go-sql-driver/mysql v1.7.0 9 | github.com/lib/pq v1.10.7 10 | github.com/mattn/go-sqlite3 v1.14.16 11 | github.com/ory/dockertest/v3 v3.9.1 12 | ) 13 | 14 | require ( 15 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect 16 | github.com/Microsoft/go-winio v0.5.2 // indirect 17 | github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect 18 | github.com/cenkalti/backoff/v4 v4.1.3 // indirect 19 | github.com/containerd/continuity v0.3.0 // indirect 20 | github.com/docker/cli v20.10.17+incompatible // indirect 21 | github.com/docker/docker v24.0.9+incompatible // indirect 22 | github.com/docker/go-connections v0.4.0 // indirect 23 | github.com/docker/go-units v0.4.0 // indirect 24 | github.com/gogo/protobuf v1.3.2 // indirect 25 | github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe // indirect 26 | github.com/golang-sql/sqlexp v0.0.0-20170517235910-f1bb20e5a188 // indirect 27 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect 28 | github.com/imdario/mergo v0.3.13 // indirect 29 | github.com/mitchellh/mapstructure v1.5.0 // indirect 30 | github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect 31 | github.com/opencontainers/go-digest v1.0.0 // indirect 32 | github.com/opencontainers/image-spec v1.0.2 // indirect 33 | github.com/opencontainers/runc v1.1.14 // indirect 34 | github.com/pkg/errors v0.9.1 // indirect 35 | github.com/sirupsen/logrus v1.8.1 // indirect 36 | github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect 37 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect 38 | github.com/xeipuuv/gojsonschema v1.2.0 // indirect 39 | golang.org/x/crypto v0.22.0 // indirect 40 | golang.org/x/net v0.24.0 // indirect 41 | golang.org/x/sys v0.19.0 // indirect 42 | gopkg.in/yaml.v2 v2.4.0 // indirect 43 | ) 44 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Azure/azure-sdk-for-go/sdk/azcore v0.19.0/go.mod h1:h6H6c8enJmmocHUbLiiGY6sx7f9i+X3m1CHdd5c6Rdw= 2 | github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.11.0/go.mod h1:HcM1YX14R7CJcghJGOYCgdezslRSVzqwLf/q+4Y2r/0= 3 | github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0/go.mod h1:yqy467j36fJxcRV2TzfVZ1pCb5vxm4BtZPUdYWe/Xo8= 4 | github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= 5 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= 6 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= 7 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 8 | github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= 9 | github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= 10 | github.com/Microsoft/go-winio v0.5.2 h1:a9IhgEQBCUEk6QCdml9CiJGhAws+YwffDHEMp1VMrpA= 11 | github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= 12 | github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= 13 | github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= 14 | github.com/cenkalti/backoff/v4 v4.1.3 h1:cFAlzYUlVYDysBEH2T5hyJZMh3+5+WCBvSnK6Q8UtC4= 15 | github.com/cenkalti/backoff/v4 v4.1.3/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= 16 | github.com/checkpoint-restore/go-criu/v5 v5.3.0/go.mod h1:E/eQpaFtUKGOOSEBZgmKAcn+zUUwWxqcaKZlF54wK8E= 17 | github.com/cilium/ebpf v0.7.0/go.mod h1:/oI2+1shJiTGAMgl6/RgJr36Eo1jzrRcAWbcXO2usCA= 18 | github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= 19 | github.com/containerd/continuity v0.3.0 h1:nisirsYROK15TAMVukJOUyGJjz4BNQJBVsNvAXZJ/eg= 20 | github.com/containerd/continuity v0.3.0/go.mod h1:wJEAIwKOm/pBZuBd0JmeTvnLquTB1Ag8espWhkykbPM= 21 | github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 22 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 23 | github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw= 24 | github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 25 | github.com/cyphar/filepath-securejoin v0.2.3/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= 26 | github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= 27 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 28 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 29 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 30 | github.com/denisenkom/go-mssqldb v0.12.0 h1:VtrkII767ttSPNRfFekePK3sctr+joXgO58stqQbtUA= 31 | github.com/denisenkom/go-mssqldb v0.12.0/go.mod h1:iiK0YP1ZeepvmBQk/QpLEhhTNJgfzrpArPY/aFvc9yU= 32 | github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= 33 | github.com/docker/cli v20.10.14+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= 34 | github.com/docker/cli v20.10.17+incompatible h1:eO2KS7ZFeov5UJeaDmIs1NFEDRf32PaqRpvoEkKBy5M= 35 | github.com/docker/cli v20.10.17+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= 36 | github.com/docker/docker v20.10.7+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= 37 | github.com/docker/docker v24.0.9+incompatible h1:HPGzNmwfLZWdxHqK9/II92pyi1EpYKsAqcl4G0Of9v0= 38 | github.com/docker/docker v24.0.9+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= 39 | github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= 40 | github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= 41 | github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw= 42 | github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 43 | github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= 44 | github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= 45 | github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= 46 | github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= 47 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 48 | github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 49 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 50 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 51 | github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= 52 | github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= 53 | github.com/golang-sql/sqlexp v0.0.0-20170517235910-f1bb20e5a188 h1:+eHOFJl1BaXrQxKX+T06f78590z4qA2ZzBTqahsKSE4= 54 | github.com/golang-sql/sqlexp v0.0.0-20170517235910-f1bb20e5a188/go.mod h1:vXjM/+wXQnTPR4KqTKDgJukSZ6amVRtWMPEjE6sQoK8= 55 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 56 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 57 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 58 | github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 59 | github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= 60 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 61 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= 62 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= 63 | github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= 64 | github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= 65 | github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= 66 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 67 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 68 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 69 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 70 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 71 | github.com/lib/pq v0.0.0-20180327071824-d34b9ff171c2/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 72 | github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw= 73 | github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 74 | github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= 75 | github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= 76 | github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 77 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 78 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 79 | github.com/moby/sys/mountinfo v0.5.0/go.mod h1:3bMD3Rg+zkqx8MRYPi7Pyb0Ie97QEBmdxbhnCLlSvSU= 80 | github.com/moby/term v0.0.0-20201216013528-df9cb8a40635/go.mod h1:FBS0z0QWA44HXygs7VXDUOGoN/1TV3RuWkLO04am3wc= 81 | github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 h1:dcztxKSvZ4Id8iPpHERQBbIJfabdt4wUm5qy3wOL2Zc= 82 | github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6/go.mod h1:E2VnQOmVuvZB6UYnnDB0qG5Nq/1tD9acaOpo6xmt0Kw= 83 | github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= 84 | github.com/mrunalp/fileutils v0.5.0/go.mod h1:M1WthSahJixYnrXQl/DFQuteStB1weuxD2QJNHXfbSQ= 85 | github.com/mrunalp/fileutils v0.5.1/go.mod h1:M1WthSahJixYnrXQl/DFQuteStB1weuxD2QJNHXfbSQ= 86 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 87 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 88 | github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM= 89 | github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= 90 | github.com/opencontainers/runc v1.1.2/go.mod h1:Tj1hFw6eFWp/o33uxGf5yF2BX5yz2Z6iptFpuvbbKqc= 91 | github.com/opencontainers/runc v1.1.14 h1:rgSuzbmgz5DUJjeSnw337TxDbRuqjs6iqQck/2weR6w= 92 | github.com/opencontainers/runc v1.1.14/go.mod h1:E4C2z+7BxR7GHXp0hAY53mek+x49X1LjPNeMTfRGvOA= 93 | github.com/opencontainers/runtime-spec v1.0.3-0.20210326190908-1c3f411f0417/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= 94 | github.com/opencontainers/selinux v1.10.0/go.mod h1:2i0OySw99QjzBBQByd1Gr9gSjvuho1lHsJxIJ3gGbJI= 95 | github.com/ory/dockertest/v3 v3.9.1 h1:v4dkG+dlu76goxMiTT2j8zV7s4oPPEppKT8K8p2f1kY= 96 | github.com/ory/dockertest/v3 v3.9.1/go.mod h1:42Ir9hmvaAPm0Mgibk6mBPi7SFvTXxEcnztDYOJ//uM= 97 | github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA= 98 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 99 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 100 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 101 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 102 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 103 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 104 | github.com/seccomp/libseccomp-golang v0.9.2-0.20210429002308-3879420cc921/go.mod h1:JA8cRccbGaA1s33RQf7Y1+q9gHmZX1yB/z9WDN1C6fg= 105 | github.com/seccomp/libseccomp-golang v0.9.2-0.20220502022130-f33da4d89646/go.mod h1:JA8cRccbGaA1s33RQf7Y1+q9gHmZX1yB/z9WDN1C6fg= 106 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 107 | github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 108 | github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= 109 | github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 110 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 111 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 112 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 113 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 114 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 115 | github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= 116 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 117 | github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= 118 | github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 119 | github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE= 120 | github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU= 121 | github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= 122 | github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= 123 | github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= 124 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= 125 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= 126 | github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= 127 | github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= 128 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 129 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 130 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 131 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 132 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 133 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 134 | golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 135 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 136 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 137 | golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= 138 | golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= 139 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 140 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 141 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 142 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 143 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 144 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 145 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 146 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 147 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 148 | golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 149 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 150 | golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 151 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 152 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 153 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 154 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 155 | golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= 156 | golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= 157 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 158 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 159 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 160 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 161 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 162 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 163 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 164 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 165 | golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 166 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 167 | golang.org/x/sys v0.0.0-20191115151921-52ab43148777/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 168 | golang.org/x/sys v0.0.0-20200831180312-196b9ba8737a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 169 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 170 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 171 | golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 172 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 173 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 174 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 175 | golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 176 | golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 177 | golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 178 | golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 179 | golang.org/x/sys v0.0.0-20220405210540-1e041c57c461/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 180 | golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 181 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 182 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 183 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 184 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 185 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 186 | golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= 187 | golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 188 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 189 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 190 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 191 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 192 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 193 | golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= 194 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 195 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 196 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 197 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 198 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 199 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 200 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 201 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 202 | golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 203 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 204 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 205 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 206 | golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= 207 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 208 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 209 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 210 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 211 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 212 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= 213 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 214 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 215 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 216 | google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 217 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 218 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 219 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 220 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 221 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 222 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 223 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 224 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 225 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 226 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 227 | gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA= 228 | gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 229 | gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk= 230 | gotest.tools/v3 v3.2.0 h1:I0DwBVMGAx26dttAj1BtJLAkVGncrkkUXfJLC4Flt/I= 231 | gotest.tools/v3 v3.2.0/go.mod h1:Mcr9QNxkg0uMvy/YElmo4SpXgJKWgQvYrT7Kw5RzJ1A= 232 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log" 7 | "os" 8 | "runtime" 9 | "sync" 10 | "testing" 11 | 12 | "github.com/go-sql-driver/mysql" 13 | "github.com/ory/dockertest/v3" 14 | ) 15 | 16 | // TestMain replaces the normal test runner for this package. It connects to 17 | // Docker running on the local machine and launches testing database 18 | // containers to which we then connect and store the connection in a package 19 | // global variable 20 | func TestMain(m *testing.M) { 21 | 22 | log.Printf("Running tests on GOARCH=%s", runtime.GOARCH) 23 | 24 | pool, err := dockertest.NewPool("") 25 | if err != nil { 26 | log.Fatalf("Can't run schema tests. Docker is not running: %s", err) 27 | } 28 | 29 | // Disable logging for MySQL while we await startup of the Docker containero 30 | // This avoids "[mysql] unexpected EOF" logging input during the delay 31 | // while the docker containers launch 32 | _ = mysql.SetLogger(nullMySQLLogger{}) 33 | 34 | var wg sync.WaitGroup 35 | for name := range TestDBs { 36 | testDB := TestDBs[name] 37 | wg.Add(1) 38 | go func() { 39 | if testDB.IsRunnable() { 40 | testDB.Init(pool) 41 | } 42 | wg.Done() 43 | }() 44 | } 45 | wg.Wait() 46 | 47 | // Restore the default MySQL logger after we successfully connect 48 | // So that MySQL Driver errors appear as expected 49 | _ = mysql.SetLogger(log.New(os.Stderr, "[mysql] ", log.Ldate|log.Ltime|log.Lshortfile)) 50 | 51 | code := m.Run() 52 | 53 | // Purge all the containers we created 54 | // You can't defer this because os.Exit doesn't execute defers 55 | for _, info := range TestDBs { 56 | info.Cleanup(pool) 57 | } 58 | 59 | os.Exit(code) 60 | } 61 | 62 | func withEachDialect(t *testing.T, f func(t *testing.T, d Dialect)) { 63 | dialects := []Dialect{Postgres, MySQL, SQLite, MSSQL} 64 | for _, dialect := range dialects { 65 | t.Run(fmt.Sprintf("%T", dialect), func(t *testing.T) { 66 | f(t, dialect) 67 | }) 68 | } 69 | } 70 | 71 | func withEachTestDB(t *testing.T, f func(t *testing.T, tdb *TestDB)) { 72 | for dbName, tdb := range TestDBs { 73 | t.Run(dbName, func(t *testing.T) { 74 | if tdb.IsRunnable() { 75 | f(t, tdb) 76 | } else { 77 | t.Skipf("Not runnable on %s", runtime.GOARCH) 78 | } 79 | }) 80 | } 81 | } 82 | 83 | func withTestDB(t *testing.T, name string, f func(t *testing.T, tdb *TestDB)) { 84 | tdb, exists := TestDBs[name] 85 | if !exists { 86 | t.Fatalf("Database '%s' doesn't exist. Add it to TestDBs", name) 87 | } 88 | f(t, tdb) 89 | } 90 | 91 | func connectDB(t *testing.T, name string) *sql.DB { 92 | info, exists := TestDBs[name] 93 | if !exists { 94 | t.Fatalf("Database '%s' doesn't exist.", name) 95 | } 96 | db, err := sql.Open(info.Driver, info.DSN()) 97 | if err != nil { 98 | t.Fatalf("Failed to connect to %s: %s", name, err) 99 | } 100 | return db 101 | } 102 | -------------------------------------------------------------------------------- /migration.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "crypto/md5" // #nosec MD5 only being used to fingerprint script contents, not for encryption 5 | "fmt" 6 | "sort" 7 | ) 8 | 9 | // Migration is a yet-to-be-run change to the schema. This is the type which 10 | // is provided to Migrator.Apply to request a schema change. 11 | type Migration struct { 12 | ID string 13 | Script string 14 | } 15 | 16 | // MD5 computes the MD5 hash of the Script for this migration so that it 17 | // can be uniquely identified later. 18 | func (m *Migration) MD5() string { 19 | return fmt.Sprintf("%x", md5.Sum([]byte(m.Script))) // #nosec not being used cryptographically 20 | } 21 | 22 | // SortMigrations sorts a slice of migrations by their IDs 23 | func SortMigrations(migrations []*Migration) { 24 | // Adjust execution order so that we apply by ID 25 | sort.Slice(migrations, func(i, j int) bool { 26 | return migrations[i].ID < migrations[j].ID 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /migration_test.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "regexp" 5 | "testing" 6 | ) 7 | 8 | func TestMD5(t *testing.T) { 9 | testMigration := Migration{Script: "test"} 10 | expected := "098f6bcd4621d373cade4e832627b4f6" 11 | if testMigration.MD5() != expected { 12 | t.Errorf("Expected '%s', got '%s'", expected, testMigration.MD5()) 13 | } 14 | } 15 | 16 | func TestSortMigrations(t *testing.T) { 17 | migrations := []*Migration{ 18 | {ID: "2020-01-01"}, 19 | {ID: "2021-01-01"}, 20 | {ID: "2000-01-01"}, 21 | } 22 | expectedOrder := []string{"2000-01-01", "2020-01-01", "2021-01-01"} 23 | SortMigrations(migrations) 24 | for i, migration := range migrations { 25 | if migration.ID != expectedOrder[i] { 26 | t.Errorf("Expected migration #%d to be %s, got %s", i, expectedOrder[i], migration.ID) 27 | } 28 | } 29 | } 30 | 31 | func unorderedMigrations() []*Migration { 32 | return []*Migration{ 33 | { 34 | ID: "2021-01-01 002", 35 | Script: `CREATE TABLE data_table ( 36 | id INTEGER PRIMARY KEY, 37 | name VARCHAR(255), 38 | created_at TIMESTAMP 39 | )`, 40 | }, 41 | { 42 | ID: "2021-01-01 001", 43 | Script: "CREATE TABLE first_table (first_name VARCHAR(255), last_name VARCHAR(255))", 44 | }, 45 | { 46 | ID: "2021-01-01 003", 47 | Script: `INSERT INTO first_table (first_name, last_name) VALUES ('John', 'Doe')`, 48 | }, 49 | } 50 | } 51 | 52 | func expectID(t *testing.T, migration *Migration, expectedID string) { 53 | t.Helper() 54 | if migration.ID != expectedID { 55 | t.Errorf("Expected Migration to have ID '%s', got '%s' instead", expectedID, migration.ID) 56 | } 57 | } 58 | 59 | func expectScriptMatch(t *testing.T, migration *Migration, regexpString string) { 60 | t.Helper() 61 | re, err := regexp.Compile(regexpString) 62 | if err != nil { 63 | t.Fatalf("Invalid regexp: '%s': %s", regexpString, err) 64 | } 65 | if !re.MatchString(migration.Script) { 66 | t.Errorf("Expected migration Script to match '%s', but it did not. Script was:\n%s", regexpString, migration.Script) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /migrator.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | // Migrator is an instance customized to perform migrations on a particular 10 | // database against a particular tracking table and with a particular dialect 11 | // defined. 12 | type Migrator struct { 13 | SchemaName string 14 | TableName string 15 | Dialect Dialect 16 | Logger Logger 17 | 18 | ctx context.Context 19 | } 20 | 21 | // NewMigrator creates a new Migrator with the supplied 22 | // options 23 | func NewMigrator(options ...Option) *Migrator { 24 | m := Migrator{ 25 | TableName: DefaultTableName, 26 | Dialect: Postgres, 27 | ctx: context.Background(), 28 | } 29 | for _, opt := range options { 30 | m = opt(m) 31 | } 32 | return &m 33 | } 34 | 35 | // QuotedTableName returns the dialect-quoted fully-qualified name for the 36 | // migrations tracking table 37 | func (m *Migrator) QuotedTableName() string { 38 | return m.Dialect.QuotedTableName(m.SchemaName, m.TableName) 39 | } 40 | 41 | // Apply takes a slice of Migrations and applies any which have not yet 42 | // been applied against the provided database. Apply can be re-called 43 | // sequentially with the same Migrations and different databases, but it is 44 | // not threadsafe... if concurrent applies are desired, multiple Migrators 45 | // should be used. 46 | func (m *Migrator) Apply(db DB, migrations []*Migration) (err error) { 47 | // Reset state to begin the migration 48 | if db == nil { 49 | return ErrNilDB 50 | } 51 | 52 | if len(migrations) == 0 { 53 | return nil 54 | } 55 | 56 | if m.ctx == nil { 57 | m.ctx = context.Background() 58 | } 59 | 60 | // Obtain a concrete connection to the database which will be closed 61 | // at the conclusion of Apply() 62 | conn, err := db.Conn(m.ctx) 63 | if err != nil { 64 | return err 65 | } 66 | defer func() { err = coalesceErrs(err, conn.Close()) }() 67 | 68 | // If the database supports locking, obtain a lock around this migrator's 69 | // table name with a deferred unlock. Go's defers run LIFO, so this deferred 70 | // unlock will happen before the deferred conn.Close() 71 | err = m.lock(conn) 72 | if err != nil { 73 | return err 74 | } 75 | defer func() { err = coalesceErrs(err, m.unlock(conn)) }() 76 | 77 | tx, err := conn.BeginTx(m.ctx, nil) 78 | if err != nil { 79 | return err 80 | } 81 | 82 | err = m.Dialect.CreateMigrationsTable(m.ctx, tx, m.QuotedTableName()) 83 | if err != nil { 84 | _ = tx.Rollback() 85 | return err 86 | } 87 | 88 | err = m.run(tx, migrations) 89 | if err != nil { 90 | _ = tx.Rollback() 91 | return err 92 | } 93 | 94 | err = tx.Commit() 95 | 96 | return err 97 | } 98 | 99 | func (m *Migrator) lock(tx Queryer) error { 100 | if l, isLocker := m.Dialect.(Locker); isLocker { 101 | err := l.Lock(m.ctx, tx, m.QuotedTableName()) 102 | if err != nil { 103 | return err 104 | } 105 | m.log(fmt.Sprintf("Locked %s at %s", m.QuotedTableName(), time.Now().Format(time.RFC3339Nano))) 106 | } 107 | return nil 108 | } 109 | 110 | func (m *Migrator) unlock(tx Queryer) error { 111 | if l, isLocker := m.Dialect.(Locker); isLocker { 112 | err := l.Unlock(m.ctx, tx, m.QuotedTableName()) 113 | if err != nil { 114 | return err 115 | } 116 | m.log(fmt.Sprintf("Unlocked %s at %s", m.QuotedTableName(), time.Now().Format(time.RFC3339Nano))) 117 | } 118 | return nil 119 | } 120 | 121 | func (m *Migrator) computeMigrationPlan(tx Queryer, toRun []*Migration) (plan []*Migration, err error) { 122 | applied, err := m.GetAppliedMigrations(tx) 123 | if err != nil { 124 | return plan, err 125 | } 126 | 127 | plan = make([]*Migration, 0) 128 | for _, migration := range toRun { 129 | if _, exists := applied[migration.ID]; !exists { 130 | plan = append(plan, migration) 131 | } 132 | } 133 | 134 | SortMigrations(plan) 135 | return plan, err 136 | } 137 | 138 | func (m *Migrator) run(tx Queryer, migrations []*Migration) error { 139 | if tx == nil { 140 | return ErrNilDB 141 | } 142 | 143 | plan, err := m.computeMigrationPlan(tx, migrations) 144 | if err != nil { 145 | return err 146 | } 147 | 148 | for _, migration := range plan { 149 | err = m.runMigration(tx, migration) 150 | if err != nil { 151 | return err 152 | } 153 | } 154 | 155 | return nil 156 | } 157 | 158 | func (m *Migrator) runMigration(tx Queryer, migration *Migration) error { 159 | startedAt := time.Now() 160 | _, err := tx.ExecContext(m.ctx, migration.Script) 161 | if err != nil { 162 | return fmt.Errorf("Migration '%s' Failed:\n%w", migration.ID, err) 163 | } 164 | 165 | executionTime := time.Since(startedAt) 166 | m.log(fmt.Sprintf("Migration '%s' applied in %s\n", migration.ID, executionTime)) 167 | 168 | ms := executionTime.Milliseconds() 169 | if ms == 0 && executionTime.Microseconds() > 0 { 170 | // Avoid rounding down to 0 for very, very fast migrations 171 | ms = 1 172 | } 173 | 174 | applied := AppliedMigration{} 175 | applied.ID = migration.ID 176 | applied.Script = migration.Script 177 | applied.ExecutionTimeInMillis = ms 178 | applied.AppliedAt = startedAt 179 | return m.Dialect.InsertAppliedMigration(m.ctx, tx, m.QuotedTableName(), &applied) 180 | } 181 | 182 | func (m *Migrator) log(msgs ...interface{}) { 183 | if m.Logger != nil { 184 | m.Logger.Print(msgs...) 185 | } 186 | } 187 | 188 | func coalesceErrs(errs ...error) error { 189 | for _, err := range errs { 190 | if err != nil { 191 | return err 192 | } 193 | } 194 | return nil 195 | } 196 | -------------------------------------------------------------------------------- /migrator_test.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "sync" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | // TestCreateMigrationsTable ensures that each dialect and test database can 12 | // successfully create the schema_migrations table. 13 | func TestCreateMigrationsTable(t *testing.T) { 14 | withEachTestDB(t, func(t *testing.T, tdb *TestDB) { 15 | 16 | db := tdb.Connect(t) 17 | defer func() { _ = db.Close() }() 18 | 19 | migrator := makeTestMigrator(WithDialect(tdb.Dialect)) 20 | err := tdb.Dialect.CreateMigrationsTable(migrator.ctx, db, migrator.QuotedTableName()) 21 | if err != nil { 22 | t.Errorf("Error occurred when creating migrations table: %s", err) 23 | } 24 | 25 | // Test that we can re-run it again with no error 26 | err = tdb.Dialect.CreateMigrationsTable(migrator.ctx, db, migrator.QuotedTableName()) 27 | if err != nil { 28 | t.Errorf("Calling createMigrationsTable a second time failed: %s", err) 29 | } 30 | }) 31 | } 32 | 33 | // TestLockAndUnlock tests the Lock and Unlock mechanisms of each dialect and 34 | // test database in isolation from any migrations actually being run. 35 | func TestLockAndUnlock(t *testing.T) { 36 | withEachTestDB(t, func(t *testing.T, tdb *TestDB) { 37 | 38 | db := tdb.Connect(t) 39 | defer func() { _ = db.Close() }() 40 | 41 | migrator := makeTestMigrator(WithDialect(tdb.Dialect)) 42 | 43 | if _, isLocker := tdb.Dialect.(Locker); isLocker { 44 | err := migrator.lock(db) 45 | if err != nil { 46 | t.Fatal(err) 47 | } 48 | 49 | err = migrator.unlock(db) 50 | if err != nil { 51 | t.Fatal(err) 52 | } 53 | } 54 | }) 55 | } 56 | 57 | // TestApplyInLexicalOrder ensures that each dialect runs migrations in their 58 | // lexical order rather than the order they were provided in the slice. This is 59 | // also the primary test to assert that the data in the tracking table is 60 | // all correct. 61 | func TestApplyInLexicalOrder(t *testing.T) { 62 | withEachTestDB(t, func(t *testing.T, tdb *TestDB) { 63 | 64 | db := tdb.Connect(t) 65 | defer func() { _ = db.Close() }() 66 | 67 | start := time.Now().Truncate(time.Second) // MySQL has only second accuracy, so we need start/end to span 1 second 68 | 69 | tableName := "lexical_order_migrations" 70 | migrator := NewMigrator(WithDialect(tdb.Dialect), WithTableName(tableName)) 71 | err := migrator.Apply(db, unorderedMigrations()) 72 | if err != nil { 73 | t.Error(err) 74 | } 75 | 76 | end := time.Now().Add(time.Second).Truncate(time.Second) // MySQL has only second accuracy, so we need start/end to span 1 second 77 | 78 | applied, err := migrator.GetAppliedMigrations(db) 79 | if err != nil { 80 | t.Error(err) 81 | } 82 | if len(applied) != 3 { 83 | t.Errorf("Expected exactly 2 applied migrations. Got %d", len(applied)) 84 | } 85 | 86 | firstMigration := applied["2021-01-01 001"] 87 | if firstMigration == nil { 88 | t.Fatal("Missing first migration") 89 | } 90 | if firstMigration.Checksum == "" { 91 | t.Error("Expected non-blank Checksum value after successful migration") 92 | } 93 | if firstMigration.ExecutionTimeInMillis < 1 { 94 | t.Errorf("Expected ExecutionTimeInMillis of %s to be tracked. Got %d", firstMigration.ID, firstMigration.ExecutionTimeInMillis) 95 | } 96 | // Put value in consistent timezone to aid error message readability 97 | appliedAt := firstMigration.AppliedAt.Round(time.Second) 98 | if appliedAt.IsZero() || appliedAt.Before(start) || appliedAt.After(end) { 99 | t.Errorf("Expected AppliedAt between %s and %s, got %s", start, end, appliedAt) 100 | } 101 | assertZonesMatch(t, start, appliedAt) 102 | 103 | secondMigration := applied["2021-01-01 002"] 104 | if secondMigration == nil { 105 | t.Fatal("Missing second migration") 106 | } else if secondMigration.Checksum == "" { 107 | t.Fatal("Expected checksum to get populated when migration ran") 108 | } 109 | 110 | if firstMigration.AppliedAt.After(secondMigration.AppliedAt) { 111 | t.Errorf("Expected migrations to run in lexical order, but first migration ran at %s and second one ran at %s", firstMigration.AppliedAt, secondMigration.AppliedAt) 112 | } 113 | }) 114 | } 115 | 116 | // TestFailedMigration ensures that a migration with a syntax error triggers 117 | // an expected error when Apply() is run. This test is run on every dialect 118 | // and every test database instance 119 | func TestFailedMigration(t *testing.T) { 120 | withEachTestDB(t, func(t *testing.T, tdb *TestDB) { 121 | 122 | db := tdb.Connect(t) 123 | defer func() { _ = db.Close() }() 124 | 125 | tableName := time.Now().Format(time.RFC3339Nano) 126 | migrator := NewMigrator(WithTableName(tableName), WithDialect(tdb.Dialect)) 127 | migrations := []*Migration{ 128 | { 129 | ID: "2019-01-01 Bad Migration", 130 | Script: "CREATE TIBBLE bad_table_name (id INTEGER NOT NULL PRIMARY KEY)", 131 | }, 132 | } 133 | err := migrator.Apply(db, migrations) 134 | expectErrorContains(t, err, "TIBBLE") 135 | 136 | query := "SELECT * FROM " + migrator.QuotedTableName() 137 | rows, _ := db.Query(query) 138 | 139 | // We expect either an error (because the transaction was rolled back 140 | // and the table no longer exists)... or a query with no results 141 | if rows != nil { 142 | if rows.Next() { 143 | t.Error("Record was inserted in tracking table even though the migration failed") 144 | } 145 | _ = rows.Close() 146 | } 147 | }) 148 | 149 | } 150 | 151 | // TestSimultaneousApply creates multiple Migrators and multiple distinct 152 | // connections to each test database and attempts to call .Apply() on them all 153 | // concurrently. The migrations include an INSERT statement, which allows us 154 | // to count to ensure that each unique migration was only run once. 155 | func TestSimultaneousApply(t *testing.T) { 156 | concurrency := 4 157 | dataTable := fmt.Sprintf("data%d", rand.Int()) // #nosec we don't need cryptographic security here 158 | migrationsTable := fmt.Sprintf("Migrations %s", time.Now().Format(time.RFC3339Nano)) 159 | sharedMigrations := []*Migration{ 160 | { 161 | ID: "2020-05-02 Create Data Table", 162 | Script: fmt.Sprintf(`CREATE TABLE %s (number INTEGER)`, dataTable), 163 | }, 164 | { 165 | ID: "2020-05-03 Add Initial Record", 166 | Script: fmt.Sprintf(`INSERT INTO %s (number) VALUES (1)`, dataTable), 167 | }, 168 | } 169 | 170 | withEachTestDB(t, func(t *testing.T, tdb *TestDB) { 171 | var wg sync.WaitGroup 172 | for i := 0; i < concurrency; i++ { 173 | wg.Add(1) 174 | go func(i int) { 175 | db := tdb.Connect(t) 176 | defer func() { _ = db.Close() }() 177 | 178 | migrator := NewMigrator(WithDialect(tdb.Dialect), WithTableName(migrationsTable)) 179 | err := migrator.Apply(db, sharedMigrations) 180 | if err != nil { 181 | t.Error(err) 182 | } 183 | _, err = db.Exec(fmt.Sprintf("INSERT INTO %s (number) VALUES (1)", dataTable)) 184 | if err != nil { 185 | t.Error(err) 186 | } 187 | wg.Done() 188 | }(i) 189 | } 190 | wg.Wait() 191 | 192 | // We expect concurrency + 1 rows in the data table 193 | // (1 from the migration, and one each for the 194 | // goroutines which ran Apply and then did an 195 | // insert afterwards) 196 | db := tdb.Connect(t) 197 | defer func() { _ = db.Close() }() 198 | 199 | count := 0 200 | row := db.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM %s", dataTable)) 201 | err := row.Scan(&count) 202 | if err != nil { 203 | t.Error(err) 204 | } 205 | if count != concurrency+1 { 206 | t.Errorf("Expected to get %d rows in %s table. Instead got %d", concurrency+1, dataTable, count) 207 | } 208 | 209 | }) 210 | } 211 | 212 | // TestMultiSchemaSupport ensures that each dialect and test database support 213 | // having multiple tracking tables each tracking separate sets of migrations. 214 | // 215 | // The test scenario here is one set of "music" migrations which deal with 216 | // artists, albums and tracks, and a separate set of "contacts" migrations 217 | // which deal with contacts, phone_numbers, and addresses. 218 | func TestMultiSchemaSupport(t *testing.T) { 219 | withEachTestDB(t, func(t *testing.T, tdb *TestDB) { 220 | music := NewMigrator(WithDialect(tdb.Dialect), WithTableName("music_migrations")) 221 | contacts := NewMigrator(WithDialect(tdb.Dialect), WithTableName("contacts_migrations")) 222 | 223 | // Use the same connection for both sets of migrations 224 | db := tdb.Connect(t) 225 | defer func() { _ = db.Close() }() 226 | 227 | // Apply the Music migrations 228 | err := music.Apply(db, testMigrations(t, "music")) 229 | if err != nil { 230 | t.Errorf("Failed to apply music migrations: %s", err) 231 | } 232 | 233 | // ... then the Contacts Migrations 234 | err = contacts.Apply(db, testMigrations(t, "contacts")) 235 | if err != nil { 236 | t.Errorf("Failed to apply contact migrations: %s", err) 237 | } 238 | 239 | // Then run a SELECT COUNT(*) query on each table to ensure that all of the 240 | // expected tables are co-existing in the same database and that they all 241 | // contain the expected number of rows (this approach is admittedly odd, 242 | // but it relies only on ANSI SQL code, so it should run on any SQL database). 243 | expectedRowCounts := map[string]int{ 244 | "music_migrations": 3, 245 | "contacts_migrations": 3, 246 | "contacts": 1, 247 | "phone_numbers": 3, 248 | "addresses": 2, 249 | "artists": 0, 250 | "albums": 0, 251 | "tracks": 0, 252 | } 253 | for table, expectedRowCount := range expectedRowCounts { 254 | qtn := tdb.Dialect.QuotedTableName("", table) 255 | actualCount := -1 // Don't initialize to 0 because that's an expected value 256 | query := fmt.Sprintf("SELECT COUNT(*) FROM %s", qtn) 257 | rows, err := db.Query(query) 258 | if err != nil { 259 | t.Error(err) 260 | } 261 | if rows != nil && rows.Next() { 262 | err = rows.Scan(&actualCount) 263 | if err != nil { 264 | t.Error(err) 265 | } 266 | } else { 267 | t.Errorf("Expected rows") 268 | } 269 | if actualCount != expectedRowCount { 270 | t.Errorf("Expected %d rows in table %s. Got %d", expectedRowCount, qtn, actualCount) 271 | } 272 | } 273 | }) 274 | } 275 | 276 | // TestRunFailure ensures that a low-level connection or query-related failure 277 | // triggers an expected error. 278 | func TestRunFailure(t *testing.T) { 279 | bq := BadQueryer{} 280 | m := makeTestMigrator() 281 | err := m.run(bq, testMigrations(t, "useless-ansi")) 282 | expectErrorContains(t, err, "SELECT id, checksum") 283 | 284 | err = m.run(nil, testMigrations(t, "useless-ansi")) 285 | if err != ErrNilDB { 286 | t.Errorf("Expected error '%s'. Got '%v'.", ErrNilDB, err) 287 | } 288 | } 289 | 290 | func TestNewMigratorApplyChain(t *testing.T) { 291 | // This is a compilability test... it is here to confirm that 292 | // NewMigrator()'s return value can have Apply() called on it. 293 | _ = NewMigrator().Apply(nil, testMigrations(t, "useless-ansi")) 294 | } 295 | 296 | // makeTestMigrator is a utility function which produces a migrator with an 297 | // isolated environment (isolated due to a unique name for the migration 298 | // tracking table). 299 | func makeTestMigrator(options ...Option) *Migrator { 300 | tableName := time.Now().Format(time.RFC3339Nano) 301 | options = append(options, WithTableName(tableName)) 302 | return NewMigrator(options...) 303 | } 304 | 305 | func testMigrations(t *testing.T, dirName string) []*Migration { 306 | path := fmt.Sprintf("test-migrations/%s", dirName) 307 | migrations, err := MigrationsFromDirectoryPath(path) 308 | if err != nil { 309 | t.Fatalf("Failed to load test migrations from '%s'", path) 310 | } 311 | return migrations 312 | } 313 | 314 | // assertZonesMatch accepts two Times and fails the test if their time zones 315 | // don't match. 316 | func assertZonesMatch(t *testing.T, expected, actual time.Time) { 317 | t.Helper() 318 | expectedName, expectedOffset := expected.Zone() 319 | actualName, actualOffset := actual.Zone() 320 | if expectedOffset != actualOffset { 321 | t.Errorf("Expected Zone '%s' with offset %d. Got Zone '%s' with offset %d", expectedName, expectedOffset, actualName, actualOffset) 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /mssql.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "time" 8 | "unicode" 9 | ) 10 | 11 | // MSSQL is the dialect for MS SQL-compatible databases 12 | var MSSQL = mssqlDialect{} 13 | 14 | type mssqlDialect struct{} 15 | 16 | func (s mssqlDialect) QuotedTableName(schemaName, tableName string) string { 17 | if schemaName == "" { 18 | return s.QuotedIdent(tableName) 19 | } 20 | return fmt.Sprintf("%s.%s", s.QuotedIdent(schemaName), s.QuotedIdent(tableName)) 21 | } 22 | 23 | func (s mssqlDialect) QuotedIdent(ident string) string { 24 | if ident == "" { 25 | return "" 26 | } 27 | 28 | var sb strings.Builder 29 | sb.WriteRune('[') 30 | for _, r := range ident { 31 | switch { 32 | case unicode.IsSpace(r): 33 | continue 34 | case r == ';': 35 | continue 36 | case r == ']': 37 | sb.WriteRune(r) 38 | sb.WriteRune(r) 39 | default: 40 | sb.WriteRune(r) 41 | } 42 | } 43 | sb.WriteRune(']') 44 | 45 | return sb.String() 46 | } 47 | 48 | func (s mssqlDialect) CreateMigrationsTable(ctx context.Context, tx Queryer, tableName string) error { 49 | unquotedTableName := tableName[1 : len(tableName)-1] 50 | query := fmt.Sprintf(` 51 | IF NOT EXISTS (SELECT * FROM Sysobjects WHERE NAME='%s' AND XTYPE='U') 52 | CREATE TABLE %s ( 53 | id VARCHAR(255) NOT NULL, 54 | checksum VARCHAR(32) NOT NULL DEFAULT '', 55 | execution_time_in_millis INTEGER NOT NULL DEFAULT 0, 56 | applied_at DATETIMEOFFSET NOT NULL 57 | ) 58 | `, unquotedTableName, tableName) 59 | _, err := tx.ExecContext(ctx, query) 60 | return err 61 | } 62 | 63 | func (s mssqlDialect) GetAppliedMigrations(ctx context.Context, tx Queryer, tableName string) (migrations []*AppliedMigration, err error) { 64 | migrations = make([]*AppliedMigration, 0) 65 | 66 | query := fmt.Sprintf(` 67 | SELECT id, checksum, execution_time_in_millis, applied_at 68 | FROM %s ORDER BY id ASC 69 | `, tableName) 70 | 71 | rows, err := tx.QueryContext(ctx, query) 72 | if err != nil { 73 | return migrations, err 74 | } 75 | defer rows.Close() 76 | 77 | for rows.Next() { 78 | migration := AppliedMigration{} 79 | err = rows.Scan(&migration.ID, &migration.Checksum, &migration.ExecutionTimeInMillis, &migration.AppliedAt) 80 | if err != nil { 81 | err = fmt.Errorf("failed to GetAppliedMigrations. Did somebody change the structure of the %s table?: %w", tableName, err) 82 | return migrations, err 83 | } 84 | migration.AppliedAt = migration.AppliedAt.In(time.Local) 85 | migrations = append(migrations, &migration) 86 | } 87 | 88 | return migrations, err 89 | } 90 | 91 | func (s mssqlDialect) InsertAppliedMigration(ctx context.Context, tx Queryer, tableName string, am *AppliedMigration) error { 92 | query := fmt.Sprintf(` 93 | INSERT INTO %s 94 | ( id, checksum, execution_time_in_millis, applied_at ) 95 | VALUES 96 | ( @p1, @p2, @p3, @p4 )`, 97 | tableName, 98 | ) 99 | _, err := tx.ExecContext(ctx, query, am.ID, am.MD5(), am.ExecutionTimeInMillis, am.AppliedAt) 100 | return err 101 | } 102 | -------------------------------------------------------------------------------- /mssql_test.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "testing" 5 | 6 | // MSSQL Driver 7 | _ "github.com/denisenkom/go-mssqldb" 8 | ) 9 | 10 | // Interface verification that MSSQL is a valid Dialect 11 | var ( 12 | _ Dialect = MSSQL 13 | ) 14 | 15 | func TestMSSQLQuotedTableName(t *testing.T) { 16 | type qtnTest struct { 17 | schema, table string 18 | expected string 19 | } 20 | 21 | tests := []qtnTest{ 22 | {"public", "ta[ble", `[public].[ta[ble]`}, 23 | {"pu[b]lic", "ta[ble", `[pu[b]]lic].[ta[ble]`}, 24 | {"tempdb", "users", `[tempdb].[users]`}, 25 | {"schema.with.dot", "table.with.dot", `[schema.with.dot].[table.with.dot]`}, 26 | {`public"`, `"; DROP TABLE users`, `[public"].["DROPTABLEusers]`}, 27 | } 28 | 29 | for _, tc := range tests { 30 | tc := tc 31 | t.Run(tc.expected, func(t *testing.T) { 32 | actual := MSSQL.QuotedTableName(tc.schema, tc.table) 33 | if actual != tc.expected { 34 | t.Errorf("Expected %s, got %s", tc.expected, actual) 35 | } 36 | }) 37 | } 38 | 39 | } 40 | 41 | func TestMSSQLQuotedIdent(t *testing.T) { 42 | table := map[string]string{ 43 | "": "", 44 | "MY_TABLE": "[MY_TABLE]", 45 | "users_roles": `[users_roles]`, 46 | "table.with.dot": `[table.with.dot]`, 47 | `table"with"quotes`: `[table"with"quotes]`, 48 | "table[with]brackets": "[table[with]]brackets]", 49 | } 50 | 51 | for ident, expected := range table { 52 | t.Run(expected, func(t *testing.T) { 53 | actual := MSSQL.QuotedIdent(ident) 54 | if expected != actual { 55 | t.Errorf("Expected %s, got %s", expected, actual) 56 | } 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /mysql.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "hash/crc32" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | const mysqlLockSalt uint32 = 271192482 12 | 13 | // MySQL is the dialect which should be used for MySQL/MariaDB databases 14 | var MySQL = mysqlDialect{} 15 | 16 | type mysqlDialect struct{} 17 | 18 | // Lock implements the Locker interface to obtain a global lock before the 19 | // migrations are run. 20 | func (m mysqlDialect) Lock(ctx context.Context, tx Queryer, tableName string) error { 21 | lockID := m.advisoryLockID(tableName) 22 | query := fmt.Sprintf(`SELECT GET_LOCK('%s', 10)`, lockID) 23 | _, err := tx.ExecContext(ctx, query) 24 | return err 25 | } 26 | 27 | // Unlock implements the Locker interface to release the global lock after the 28 | // migrations are run. 29 | func (m mysqlDialect) Unlock(ctx context.Context, tx Queryer, tableName string) error { 30 | lockID := m.advisoryLockID(tableName) 31 | query := fmt.Sprintf(`SELECT RELEASE_LOCK('%s')`, lockID) 32 | _, err := tx.ExecContext(ctx, query) 33 | return err 34 | } 35 | 36 | // CreateMigrationsTable implements the Dialect interface to create the 37 | // table which tracks applied migrations. It only creates the table if it 38 | // does not already exist 39 | func (m mysqlDialect) CreateMigrationsTable(ctx context.Context, tx Queryer, tableName string) error { 40 | query := fmt.Sprintf(` 41 | CREATE TABLE IF NOT EXISTS %s ( 42 | id VARCHAR(255) NOT NULL, 43 | checksum VARCHAR(32) NOT NULL DEFAULT '', 44 | execution_time_in_millis INTEGER NOT NULL DEFAULT 0, 45 | applied_at TIMESTAMP NOT NULL 46 | )`, tableName) 47 | _, err := tx.ExecContext(ctx, query) 48 | return err 49 | } 50 | 51 | // InsertAppliedMigration implements the Dialect interface to insert a record 52 | // into the migrations tracking table *after* a migration has successfully 53 | // run. 54 | func (m mysqlDialect) InsertAppliedMigration(ctx context.Context, tx Queryer, tableName string, am *AppliedMigration) error { 55 | query := fmt.Sprintf(` 56 | INSERT INTO %s 57 | ( id, checksum, execution_time_in_millis, applied_at ) 58 | VALUES 59 | ( ?, ?, ?, ? ) 60 | `, tableName, 61 | ) 62 | _, err := tx.ExecContext(ctx, query, am.ID, am.MD5(), am.ExecutionTimeInMillis, am.AppliedAt) 63 | return err 64 | } 65 | 66 | // GetAppliedMigrations retrieves all data from the migrations tracking table 67 | func (m mysqlDialect) GetAppliedMigrations(ctx context.Context, tx Queryer, tableName string) (migrations []*AppliedMigration, err error) { 68 | migrations = make([]*AppliedMigration, 0) 69 | 70 | query := fmt.Sprintf(` 71 | SELECT id, checksum, execution_time_in_millis, applied_at 72 | FROM %s 73 | ORDER BY id ASC`, tableName) 74 | rows, err := tx.QueryContext(ctx, query) 75 | if err != nil { 76 | return migrations, err 77 | } 78 | defer rows.Close() 79 | 80 | for rows.Next() { 81 | migration := AppliedMigration{} 82 | 83 | var appliedAt mysqlTime 84 | err = rows.Scan(&migration.ID, &migration.Checksum, &migration.ExecutionTimeInMillis, &appliedAt) 85 | if err != nil { 86 | err = fmt.Errorf("Failed to GetAppliedMigrations. Did somebody change the structure of the %s table?: %w", tableName, err) 87 | return migrations, err 88 | } 89 | migration.AppliedAt = appliedAt.Value 90 | migrations = append(migrations, &migration) 91 | } 92 | 93 | return migrations, err 94 | } 95 | 96 | // QuotedTableName returns the string value of the name of the migration 97 | // tracking table after it has been quoted for MySQL 98 | func (m mysqlDialect) QuotedTableName(schemaName, tableName string) string { 99 | if schemaName == "" { 100 | return m.quotedIdent(tableName) 101 | } 102 | return m.quotedIdent(schemaName) + "." + m.quotedIdent(tableName) 103 | } 104 | 105 | // quotedIdent wraps the supplied string in the MySQL identifier 106 | // quote character 107 | func (m mysqlDialect) quotedIdent(ident string) string { 108 | if ident == "" { 109 | return "" 110 | } 111 | return "`" + strings.ReplaceAll(ident, "`", "``") + "`" 112 | } 113 | 114 | // advisoryLockID generates a table-specific lock name to use 115 | func (m mysqlDialect) advisoryLockID(tableName string) string { 116 | sum := crc32.ChecksumIEEE([]byte(tableName)) 117 | sum = sum * mysqlLockSalt 118 | return fmt.Sprint(sum) 119 | } 120 | 121 | type mysqlTime struct { 122 | Value time.Time 123 | } 124 | 125 | func (t *mysqlTime) Scan(src interface{}) (err error) { 126 | if src == nil { 127 | t.Value = time.Time{} 128 | } 129 | 130 | if srcTime, isTime := src.(time.Time); isTime { 131 | t.Value = srcTime.In(time.Local) 132 | return nil 133 | } 134 | 135 | return t.ScanString(fmt.Sprintf("%s", src)) 136 | } 137 | 138 | func (t *mysqlTime) ScanString(src string) (err error) { 139 | switch len(src) { 140 | case 19: 141 | t.Value, err = time.ParseInLocation("2006-01-02 15:04:05", src, time.UTC) 142 | if err != nil { 143 | return err 144 | } 145 | } 146 | t.Value = t.Value.In(time.Local) 147 | return nil 148 | } 149 | -------------------------------------------------------------------------------- /mysql_test.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | // MySQL Driver 8 | _ "github.com/go-sql-driver/mysql" 9 | ) 10 | 11 | // Interface verification that MySQL is a valid Dialect 12 | var ( 13 | _ Dialect = MySQL 14 | _ Locker = MySQL 15 | ) 16 | 17 | func TestMySQLQuotedTableName(t *testing.T) { 18 | type qtnTest struct { 19 | schema, table string 20 | expected string 21 | } 22 | 23 | table := []qtnTest{ 24 | {"public", "users", "`public`.`users`"}, 25 | {"schema.with.dot", "table.with.dot", "`schema.with.dot`.`table.with.dot`"}, 26 | {"schema`with`tick", "table`with`tick", "`schema``with``tick`.`table``with``tick`"}, 27 | } 28 | 29 | for _, test := range table { 30 | actual := MySQL.QuotedTableName(test.schema, test.table) 31 | if actual != test.expected { 32 | t.Errorf("Expected %s, got %s", test.expected, actual) 33 | } 34 | } 35 | } 36 | 37 | func TestMySQLQuotedIdent(t *testing.T) { 38 | table := map[string]string{ 39 | "": "", 40 | "MY_TABLE": "`MY_TABLE`", 41 | "users_roles": "`users_roles`", 42 | "table.with.dot": "`table.with.dot`", 43 | `table"with"quotes`: "`table\"with\"quotes`", 44 | "table`with`ticks": "`table``with``ticks`", 45 | } 46 | 47 | for input, expected := range table { 48 | actual := MySQL.quotedIdent(input) 49 | if actual != expected { 50 | t.Errorf("Expected %s, got %s", expected, actual) 51 | } 52 | } 53 | } 54 | 55 | func TestMySQLTimeScanner(t *testing.T) { 56 | t.Run("Nil", func(t *testing.T) { 57 | v := mysqlTime{} 58 | err := v.Scan(nil) 59 | if err != nil { 60 | t.Error(err) 61 | } 62 | }) 63 | 64 | t.Run("Time In UTC", func(t *testing.T) { 65 | v := mysqlTime{} 66 | expected := time.Date(2021, 1, 1, 18, 19, 20, 0, time.UTC) 67 | src, _ := time.ParseInLocation("2006-01-02 15:04:05", "2021-01-01 18:19:20", time.UTC) 68 | err := v.Scan(src) 69 | if err != nil { 70 | t.Error(err) 71 | } 72 | assertZonesMatch(t, time.Date(2021, 1, 1, 18, 19, 20, 0, time.Local), v.Value) 73 | if expected.Unix() != v.Value.Unix() { 74 | t.Errorf("Expected %s, got %s", expected.Format(time.RFC3339), v.Value.Format(time.RFC3339)) 75 | } 76 | }) 77 | 78 | t.Run("Invalid String Time", func(t *testing.T) { 79 | v := mysqlTime{} 80 | err := v.Scan("2000-13-45 99:45:23") 81 | if err == nil { 82 | t.Errorf("Expected error scanning invalid time") 83 | } 84 | }) 85 | } 86 | 87 | type nullMySQLLogger struct{} 88 | 89 | func (nsl nullMySQLLogger) Print(v ...interface{}) { 90 | // Intentional no-op. The purpose of this class is to swallow/ignore 91 | // the MySQL driver errors which occur while we're waiting for the Docker 92 | // MySQL instance to start up. 93 | } 94 | -------------------------------------------------------------------------------- /options.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import "context" 4 | 5 | // Option supports option chaining when creating a Migrator. 6 | // An Option is a function which takes a Migrator and 7 | // returns a Migrator with an Option modified. 8 | type Option func(m Migrator) Migrator 9 | 10 | // WithDialect builds an Option which will set the supplied 11 | // dialect on a Migrator. Usage: NewMigrator(WithDialect(MySQL)) 12 | func WithDialect(dialect Dialect) Option { 13 | return func(m Migrator) Migrator { 14 | m.Dialect = dialect 15 | return m 16 | } 17 | } 18 | 19 | // WithTableName is an option which customizes the name of the schema_migrations 20 | // tracking table. It can be called with either 1 or 2 string arguments. If 21 | // called with 2 arguments, the first argument is assumed to be a schema 22 | // qualifier (for example, WithTableName("public", "schema_migrations") would 23 | // assign the table named "schema_migrations" in the the default "public" 24 | // schema for Postgres) 25 | func WithTableName(names ...string) Option { 26 | return func(m Migrator) Migrator { 27 | switch len(names) { 28 | case 0: 29 | // No-op if no customization was provided 30 | case 1: 31 | m.TableName = names[0] 32 | default: 33 | m.SchemaName = names[0] 34 | m.TableName = names[1] 35 | } 36 | return m 37 | } 38 | } 39 | 40 | // WithContext is an Option which sets the Migrator to run within the provided 41 | // Context 42 | func WithContext(ctx context.Context) Option { 43 | return func(m Migrator) Migrator { 44 | m.ctx = ctx 45 | return m 46 | } 47 | } 48 | 49 | // Logger is the interface for logging operations of the logger. 50 | // By default the migrator operates silently. Providing a Logger 51 | // enables output of the migrator's operations. 52 | type Logger interface { 53 | Print(...interface{}) 54 | } 55 | 56 | // WithLogger builds an Option which will set the supplied Logger 57 | // on a Migrator. Usage: NewMigrator(WithLogger(logrus.New())) 58 | func WithLogger(logger Logger) Option { 59 | return func(m Migrator) Migrator { 60 | m.Logger = logger 61 | return m 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /options_test.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | func TestWithTableNameOptionWithSchema(t *testing.T) { 13 | schema := "special" 14 | table := "my_migrations" 15 | m := NewMigrator(WithTableName(schema, table)) 16 | if m.SchemaName != schema { 17 | t.Errorf("Expected SchemaName to be '%s'. Got '%s' instead.", schema, m.SchemaName) 18 | } 19 | if m.TableName != table { 20 | t.Errorf("Expected TableName to be '%s'. Got '%s' instead.", table, m.TableName) 21 | } 22 | } 23 | func TestWithTableNameOptionWithoutSchema(t *testing.T) { 24 | name := "terrible_migrations_table_name" 25 | m := NewMigrator(WithTableName(name)) 26 | if m.SchemaName != "" { 27 | t.Errorf("Expected SchemaName to be blank. Got '%s' instead.", m.SchemaName) 28 | } 29 | if m.TableName != name { 30 | t.Errorf("Expected TableName to be '%s'. Got '%s' instead.", name, m.TableName) 31 | } 32 | } 33 | 34 | func TestDefaultTableName(t *testing.T) { 35 | name := "schema_migrations" 36 | m := NewMigrator() 37 | if m.SchemaName != "" { 38 | t.Errorf("Expected SchemaName to be blank by default. Got '%s' instead.", m.SchemaName) 39 | } 40 | if m.TableName != name { 41 | t.Errorf("Expected TableName to be '%s' by default. Got '%s' instead.", name, m.TableName) 42 | } 43 | } 44 | 45 | func TestDefaultDialect(t *testing.T) { 46 | m := NewMigrator() 47 | if m.Dialect != Postgres { 48 | t.Errorf("Expected Migrator to have Postgres Dialect by default. Got: %v", m.Dialect) 49 | } 50 | } 51 | 52 | func TestWithDialectOption(t *testing.T) { 53 | m := Migrator{Dialect: nil} 54 | if m.Dialect != nil { 55 | t.Errorf("Expected nil Dialect. Got '%v'", m.Dialect) 56 | } 57 | modifiedMigrator := WithDialect(Postgres)(m) 58 | if modifiedMigrator.Dialect != Postgres { 59 | t.Errorf("Expected modifiedMigrator to have Postgres dialect. Got '%v'.", modifiedMigrator.Dialect) 60 | } 61 | if m.Dialect != nil { 62 | t.Errorf("Expected Option to not modify the original Migrator's Dialect, but it changed it to '%v'.", m.Dialect) 63 | } 64 | } 65 | 66 | func TestWithContextOption(t *testing.T) { 67 | m := NewMigrator() 68 | if m.ctx == nil { 69 | t.Errorf("Expected NewMigrator to set a default .ctx") 70 | } 71 | ctx := context.WithValue(context.Background(), "key", "value") 72 | m = NewMigrator(WithContext(ctx)) 73 | if m.ctx != ctx { 74 | t.Errorf("Expected WithContext to set a custom context") 75 | } 76 | } 77 | 78 | func TestWithLoggerOption(t *testing.T) { 79 | m := Migrator{} 80 | if m.Logger != nil { 81 | t.Errorf("Expected nil Logger by default. Got '%v'", m.Logger) 82 | } 83 | modifiedMigrator := WithLogger(log.New(os.Stdout, "schema: ", log.Ldate|log.Ltime))(m) 84 | if modifiedMigrator.Logger == nil { 85 | t.Errorf("Expected logger to have been added") 86 | } 87 | } 88 | 89 | type StrLog string 90 | 91 | func (nl *StrLog) Print(msgs ...interface{}) { 92 | var sb strings.Builder 93 | for _, msg := range msgs { 94 | sb.WriteString(fmt.Sprintf("%s", msg)) 95 | } 96 | result := StrLog(sb.String()) 97 | *nl = result 98 | } 99 | 100 | func TestSimpleLogger(t *testing.T) { 101 | var str StrLog 102 | m := NewMigrator(WithLogger(&str)) 103 | m.log("Test message") 104 | if str != "Test message" { 105 | t.Errorf("Expected logger to print 'Test message'. Got '%s'", str) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /postgres.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "hash/crc32" 7 | "strings" 8 | "time" 9 | "unicode" 10 | ) 11 | 12 | const postgresAdvisoryLockSalt uint32 = 542384964 13 | 14 | // Postgres is the dialect for Postgres-compatible 15 | // databases 16 | var Postgres = postgresDialect{} 17 | 18 | type postgresDialect struct{} 19 | 20 | // Lock implements the Locker interface to obtain a global lock before the 21 | // migrations are run. 22 | func (p postgresDialect) Lock(ctx context.Context, tx Queryer, tableName string) error { 23 | lockID := p.advisoryLockID(tableName) 24 | query := fmt.Sprintf("SELECT pg_advisory_lock(%s)", lockID) 25 | _, err := tx.ExecContext(ctx, query) 26 | return err 27 | } 28 | 29 | // Unlock implements the Locker interface to release the global lock after the 30 | // migrations are run. 31 | func (p postgresDialect) Unlock(ctx context.Context, tx Queryer, tableName string) error { 32 | lockID := p.advisoryLockID(tableName) 33 | query := fmt.Sprintf("SELECT pg_advisory_unlock(%s)", lockID) 34 | _, err := tx.ExecContext(ctx, query) 35 | return err 36 | } 37 | 38 | // CreateMigrationsTable implements the Dialect interface to create the 39 | // table which tracks applied migrations. It only creates the table if it 40 | // does not already exist 41 | func (p postgresDialect) CreateMigrationsTable(ctx context.Context, tx Queryer, tableName string) error { 42 | query := fmt.Sprintf(` 43 | CREATE TABLE IF NOT EXISTS %s ( 44 | id VARCHAR(255) NOT NULL, 45 | checksum VARCHAR(32) NOT NULL DEFAULT '', 46 | execution_time_in_millis INTEGER NOT NULL DEFAULT 0, 47 | applied_at TIMESTAMP WITH TIME ZONE NOT NULL 48 | ) 49 | `, tableName) 50 | _, err := tx.ExecContext(ctx, query) 51 | return err 52 | } 53 | 54 | // InsertAppliedMigration implements the Dialect interface to insert a record 55 | // into the migrations tracking table *after* a migration has successfully 56 | // run. 57 | func (p postgresDialect) InsertAppliedMigration(ctx context.Context, tx Queryer, tableName string, am *AppliedMigration) error { 58 | query := fmt.Sprintf(` 59 | INSERT INTO %s 60 | ( id, checksum, execution_time_in_millis, applied_at ) 61 | VALUES 62 | ( $1, $2, $3, $4 )`, 63 | tableName, 64 | ) 65 | _, err := tx.ExecContext(ctx, query, am.ID, am.MD5(), am.ExecutionTimeInMillis, am.AppliedAt) 66 | return err 67 | } 68 | 69 | // GetAppliedMigrations retrieves all data from the migrations tracking table 70 | func (p postgresDialect) GetAppliedMigrations(ctx context.Context, tx Queryer, tableName string) (migrations []*AppliedMigration, err error) { 71 | migrations = make([]*AppliedMigration, 0) 72 | 73 | query := fmt.Sprintf(` 74 | SELECT id, checksum, execution_time_in_millis, applied_at 75 | FROM %s ORDER BY id ASC 76 | `, tableName) 77 | rows, err := tx.QueryContext(ctx, query) 78 | if err != nil { 79 | return migrations, err 80 | } 81 | defer rows.Close() 82 | 83 | for rows.Next() { 84 | migration := AppliedMigration{} 85 | err = rows.Scan(&migration.ID, &migration.Checksum, &migration.ExecutionTimeInMillis, &migration.AppliedAt) 86 | if err != nil { 87 | err = fmt.Errorf("failed to GetAppliedMigrations. Did somebody change the structure of the %s table?: %w", tableName, err) 88 | return migrations, err 89 | } 90 | migration.AppliedAt = migration.AppliedAt.In(time.Local) 91 | migrations = append(migrations, &migration) 92 | } 93 | 94 | return migrations, err 95 | } 96 | 97 | // QuotedTableName returns the string value of the name of the migration 98 | // tracking table after it has been quoted for Postgres 99 | func (p postgresDialect) QuotedTableName(schemaName, tableName string) string { 100 | if schemaName == "" { 101 | return p.QuotedIdent(tableName) 102 | } 103 | return p.QuotedIdent(schemaName) + "." + p.QuotedIdent(tableName) 104 | } 105 | 106 | // QuotedIdent wraps the supplied string in the Postgres identifier 107 | // quote character 108 | func (p postgresDialect) QuotedIdent(ident string) string { 109 | if ident == "" { 110 | return "" 111 | } 112 | 113 | var sb strings.Builder 114 | sb.WriteRune('"') 115 | for _, r := range ident { 116 | switch { 117 | case unicode.IsSpace(r): 118 | // Skip spaces 119 | continue 120 | case r == '"': 121 | // Escape double-quotes with repeated double-quotes 122 | sb.WriteString(`""`) 123 | case r == ';': 124 | // Ignore the command termination character 125 | continue 126 | default: 127 | sb.WriteRune(r) 128 | } 129 | } 130 | sb.WriteRune('"') 131 | return sb.String() 132 | } 133 | 134 | // advisoryLockID generates a table-specific lock name to use 135 | func (p postgresDialect) advisoryLockID(tableName string) string { 136 | sum := crc32.ChecksumIEEE([]byte(tableName)) 137 | sum = sum * postgresAdvisoryLockSalt 138 | return fmt.Sprint(sum) 139 | } 140 | -------------------------------------------------------------------------------- /postgres_test.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "testing" 5 | 6 | // Postgres Driver 7 | _ "github.com/lib/pq" 8 | ) 9 | 10 | // Interface verification that Postgres is a valid Dialect 11 | var ( 12 | _ Dialect = Postgres 13 | _ Locker = Postgres 14 | ) 15 | 16 | func TestPostgreSQLQuotedTableName(t *testing.T) { 17 | type qtnTest struct { 18 | schema, table string 19 | expected string 20 | } 21 | tests := []qtnTest{ 22 | {"public", "users", `"public"."users"`}, 23 | {"schema.with.dot", "table.with.dot", `"schema.with.dot"."table.with.dot"`}, 24 | {`public"`, `"; DROP TABLE users`, `"public"""."""DROPTABLEusers"`}, 25 | } 26 | for _, test := range tests { 27 | actual := Postgres.QuotedTableName(test.schema, test.table) 28 | if actual != test.expected { 29 | t.Errorf("Expected %s, got %s", test.expected, actual) 30 | } 31 | } 32 | } 33 | 34 | func TestPostgreSQLQuotedIdent(t *testing.T) { 35 | table := map[string]string{ 36 | "": "", 37 | "MY_TABLE": `"MY_TABLE"`, 38 | "users_roles": `"users_roles"`, 39 | "table.with.dot": `"table.with.dot"`, 40 | `table"with"quotes`: `"table""with""quotes"`, 41 | } 42 | for ident, expected := range table { 43 | actual := Postgres.QuotedIdent(ident) 44 | if expected != actual { 45 | t.Errorf("Expected %s, got %s", expected, actual) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /schema.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | ) 8 | 9 | // DefaultTableName defines the name of the database table which will 10 | // hold the status of applied migrations 11 | const DefaultTableName = "schema_migrations" 12 | 13 | // ErrNilDB is thrown when the database pointer is nil 14 | var ErrNilDB = errors.New("DB pointer is nil") 15 | 16 | // DB defines the interface for a *sql.DB, which can be used to get a concrete 17 | // connection to the database. 18 | type DB interface { 19 | Conn(ctx context.Context) (*sql.Conn, error) 20 | } 21 | 22 | // Connection defines the interface for a *sql.Conn, which can both start a new 23 | // transaction and run queries. 24 | type Connection interface { 25 | Transactor 26 | Queryer 27 | } 28 | 29 | // Queryer is something which can execute a Query (either a sql.DB 30 | // or a sql.Tx) 31 | type Queryer interface { 32 | ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) 33 | QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) 34 | } 35 | 36 | // Transactor defines the interface for the Begin method from the *sql.DB 37 | type Transactor interface { 38 | BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) 39 | } 40 | -------------------------------------------------------------------------------- /schema_test.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "database/sql" 5 | ) 6 | 7 | // Interface verification that *sql.DB satisfies our Connection interface 8 | var ( 9 | _ Connection = &sql.DB{} 10 | ) 11 | 12 | // Interface verification that *sql.DB satisfies our Transactor interface 13 | var ( 14 | _ Transactor = &sql.DB{} 15 | ) 16 | 17 | // Interface verification that *sql.Tx and *sql.DB both satisfy our 18 | // Queryer interface 19 | var ( 20 | _ Queryer = &sql.DB{} 21 | _ Queryer = &sql.Tx{} 22 | ) 23 | 24 | const ( 25 | PostgresDriverName = "postgres" 26 | SQLiteDriverName = "sqlite3" 27 | MySQLDriverName = "mysql" 28 | MSSQLDriverName = "sqlserver" 29 | ) 30 | 31 | // TestDBs holds all of the specific database instances against which tests 32 | // will run. The connectDB test helper references the keys of this map, and 33 | // the withEachDB helper runs tests against every database defined here. 34 | var TestDBs map[string]*TestDB = map[string]*TestDB{ 35 | "postgres:latest": { 36 | Dialect: Postgres, 37 | Driver: PostgresDriverName, 38 | DockerRepo: "postgres", 39 | DockerTag: "latest", 40 | }, 41 | "sqlite": { 42 | Dialect: SQLite, 43 | Driver: SQLiteDriverName, 44 | }, 45 | "mysql:latest": { 46 | Dialect: MySQL, 47 | Driver: MySQLDriverName, 48 | DockerRepo: "mysql/mysql-server", 49 | DockerTag: "latest", 50 | }, 51 | "mariadb:latest": { 52 | Dialect: MySQL, 53 | Driver: MySQLDriverName, 54 | DockerRepo: "mariadb", 55 | DockerTag: "latest", 56 | }, 57 | "mssql:latest": { 58 | Dialect: MSSQL, 59 | Driver: MSSQLDriverName, 60 | DockerRepo: "mcr.microsoft.com/mssql/server", 61 | DockerTag: "2019-latest", 62 | // SkippedArchs: []string{"amd64"}, 63 | }, 64 | } 65 | -------------------------------------------------------------------------------- /sqlite.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "time" 8 | "unicode" 9 | ) 10 | 11 | // SQLite is the dialect for sqlite3 databases 12 | var SQLite = &sqliteDialect{} 13 | 14 | type sqliteDialect struct{} 15 | 16 | // CreateMigrationsTable implements the Dialect interface to create the 17 | // table which tracks applied migrations. It only creates the table if it 18 | // does not already exist 19 | func (s sqliteDialect) CreateMigrationsTable(ctx context.Context, tx Queryer, tableName string) error { 20 | query := fmt.Sprintf(` 21 | CREATE TABLE IF NOT EXISTS %s ( 22 | id TEXT NOT NULL, 23 | checksum TEXT NOT NULL DEFAULT '', 24 | execution_time_in_millis INTEGER NOT NULL DEFAULT 0, 25 | applied_at DATETIME NOT NULL 26 | )`, tableName) 27 | _, err := tx.ExecContext(ctx, query) 28 | return err 29 | } 30 | 31 | // InsertAppliedMigration implements the Dialect interface to insert a record 32 | // into the migrations tracking table *after* a migration has successfully 33 | // run. 34 | func (s *sqliteDialect) InsertAppliedMigration(ctx context.Context, tx Queryer, tableName string, am *AppliedMigration) error { 35 | query := fmt.Sprintf(` 36 | INSERT INTO %s 37 | ( id, checksum, execution_time_in_millis, applied_at ) 38 | VALUES 39 | ( ?, ?, ?, ? ) 40 | `, tableName, 41 | ) 42 | _, err := tx.ExecContext(ctx, query, am.ID, am.MD5(), am.ExecutionTimeInMillis, am.AppliedAt) 43 | return err 44 | } 45 | 46 | // GetAppliedMigrations retrieves all data from the migrations tracking table 47 | func (s sqliteDialect) GetAppliedMigrations(ctx context.Context, tx Queryer, tableName string) (migrations []*AppliedMigration, err error) { 48 | migrations = make([]*AppliedMigration, 0) 49 | 50 | query := fmt.Sprintf(` 51 | SELECT id, checksum, execution_time_in_millis, applied_at 52 | FROM %s 53 | ORDER BY id ASC 54 | `, tableName) 55 | rows, err := tx.QueryContext(ctx, query) 56 | if err != nil { 57 | return migrations, err 58 | } 59 | defer rows.Close() 60 | 61 | for rows.Next() { 62 | migration := AppliedMigration{} 63 | err = rows.Scan(&migration.ID, &migration.Checksum, &migration.ExecutionTimeInMillis, &migration.AppliedAt) 64 | if err != nil { 65 | err = fmt.Errorf("Failed to GetAppliedMigrations. Did somebody change the structure of the %s table?: %w", tableName, err) 66 | return migrations, err 67 | } 68 | migration.AppliedAt = migration.AppliedAt.In(time.Local) 69 | migrations = append(migrations, &migration) 70 | } 71 | 72 | return migrations, err 73 | } 74 | 75 | // QuotedTableName returns the string value of the name of the migration 76 | // tracking table after it has been quoted for SQLite 77 | func (s sqliteDialect) QuotedTableName(schemaName, tableName string) string { 78 | ident := schemaName + tableName 79 | if ident == "" { 80 | return "" 81 | } 82 | 83 | var sb strings.Builder 84 | sb.WriteRune('"') 85 | for _, r := range ident { 86 | switch { 87 | case unicode.IsSpace(r): 88 | // Skip spaces 89 | continue 90 | case r == '"': 91 | // Escape double-quotes with repeated double-quotes 92 | sb.WriteString(`""`) 93 | case r == ';': 94 | // Ignore the command termination character 95 | continue 96 | default: 97 | sb.WriteRune(r) 98 | } 99 | } 100 | sb.WriteRune('"') 101 | return sb.String() 102 | 103 | } 104 | -------------------------------------------------------------------------------- /sqlite_test.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "testing" 5 | 6 | // SQLite driver 7 | _ "github.com/mattn/go-sqlite3" 8 | ) 9 | 10 | // Interface verification that SQLite is a valid Dialect 11 | var ( 12 | _ Dialect = SQLite 13 | ) 14 | 15 | func TestSQLiteQuotedTableName(t *testing.T) { 16 | table := map[string]string{ 17 | "": "", 18 | "MY_TABLE": `"MY_TABLE"`, 19 | "users_roles": `"users_roles"`, 20 | "table.with.dot": `"table.with.dot"`, 21 | `table"with"quotes`: `"table""with""quotes"`, 22 | `"; DROP TABLE users`: `"""DROPTABLEusers"`, 23 | } 24 | for ident, expected := range table { 25 | actual := SQLite.QuotedTableName("", ident) 26 | if expected != actual { 27 | t.Errorf("Expected %s, got %s", expected, actual) 28 | } 29 | actual = SQLite.QuotedTableName(ident, "") 30 | if expected != actual { 31 | t.Errorf("Expected %s, got %s when table came first", expected, actual) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /test-migrations/contacts/0000-00-00 001 Contacts.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE contacts (id INTEGER PRIMARY KEY); 2 | INSERT INTO contacts (id) VALUES (1); -------------------------------------------------------------------------------- /test-migrations/contacts/0000-00-00 002 Phone Numbers.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE phone_numbers ( 2 | id INTEGER PRIMARY KEY, 3 | contact_id INTEGER 4 | ); 5 | INSERT INTO phone_numbers (id, contact_id) VALUES (1, 1); 6 | INSERT INTO phone_numbers (id, contact_id) VALUES (2, 1); 7 | INSERT INTO phone_numbers (id, contact_id) VALUES (3, 1); -------------------------------------------------------------------------------- /test-migrations/contacts/0000-00-00 003 Addresses.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE addresses ( 2 | id INTEGER PRIMARY KEY, 3 | contact_id INTEGER 4 | ); 5 | INSERT INTO addresses (id, contact_id) VALUES (1,1); 6 | INSERT INTO addresses (id, contact_id) VALUES (2, 1); -------------------------------------------------------------------------------- /test-migrations/music/0000-00-00 001 Artists.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE artists (id INTEGER PRIMARY KEY); -------------------------------------------------------------------------------- /test-migrations/music/0000-00-00 002 Albums.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE albums ( 2 | id INTEGER PRIMARY KEY, 3 | artist_id INTEGER 4 | ); -------------------------------------------------------------------------------- /test-migrations/music/0000-00-00 003 Tracks.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE tracks ( 2 | id INTEGER PRIMARY KEY, 3 | artist_id INTEGER, 4 | album_id INTEGER 5 | ); -------------------------------------------------------------------------------- /test-migrations/saas/2019-01-01 0900 Create Users.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE users (id INTEGER NOT NULL PRIMARY KEY); -------------------------------------------------------------------------------- /test-migrations/saas/2019-01-03 1000 Create Affiliates.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE affiliates (id INTEGER NOT NULL PRIMARY KEY); -------------------------------------------------------------------------------- /test-migrations/unreadable/readable.sql: -------------------------------------------------------------------------------- 1 | CREATE fake_table (id INTEGER); -------------------------------------------------------------------------------- /test-migrations/unreadable/unreadable.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE fake (id INTEGER); -------------------------------------------------------------------------------- /test-migrations/useless-ansi/0000-00-00 001 Select 1.sql: -------------------------------------------------------------------------------- 1 | SELECT 1; -------------------------------------------------------------------------------- /test-migrations/useless-ansi/0000-00-00 002 Select 2.sql: -------------------------------------------------------------------------------- 1 | SELECT 2; -------------------------------------------------------------------------------- /testdb_test.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "os" 9 | "runtime" 10 | "testing" 11 | 12 | "github.com/ory/dockertest/v3" 13 | "github.com/ory/dockertest/v3/docker" 14 | ) 15 | 16 | // TestDB represents a specific database instance against which we would like 17 | // to run database migration tests. 18 | type TestDB struct { 19 | Dialect Dialect 20 | Driver string 21 | DockerRepo string 22 | DockerTag string 23 | Resource *dockertest.Resource 24 | SkippedArchs []string 25 | path string 26 | } 27 | 28 | func (c *TestDB) Username() string { 29 | switch c.Driver { 30 | case MSSQLDriverName: 31 | return "SA" 32 | default: 33 | return "schemauser" 34 | } 35 | } 36 | 37 | func (c *TestDB) Password() string { 38 | switch c.Driver { 39 | case MSSQLDriverName: 40 | return "Th1sI5AMor3_Compl1c4tedPasswd!" 41 | default: 42 | return "schemasecret" 43 | } 44 | } 45 | 46 | func (c *TestDB) DatabaseName() string { 47 | switch c.Driver { 48 | case MSSQLDriverName: 49 | return "master" 50 | default: 51 | return "schematests" 52 | } 53 | } 54 | 55 | // Port asks Docker for the host-side port we can use to connect to the 56 | // relevant container's database port. 57 | func (c *TestDB) Port() string { 58 | switch c.Driver { 59 | case MySQLDriverName: 60 | return c.Resource.GetPort("3306/tcp") 61 | case PostgresDriverName: 62 | return c.Resource.GetPort("5432/tcp") 63 | case MSSQLDriverName: 64 | return c.Resource.GetPort("1433/tcp") 65 | } 66 | return "" 67 | } 68 | 69 | func (c *TestDB) IsDocker() bool { 70 | return c.DockerRepo != "" && c.DockerTag != "" 71 | } 72 | 73 | func (c *TestDB) IsSQLite() bool { 74 | return c.Driver == SQLiteDriverName 75 | } 76 | 77 | // DockerEnvars computes the environment variables that are needed for a 78 | // docker instance. 79 | func (c *TestDB) DockerEnvars() []string { 80 | switch c.Driver { 81 | case PostgresDriverName: 82 | return []string{ 83 | fmt.Sprintf("POSTGRES_USER=%s", c.Username()), 84 | fmt.Sprintf("POSTGRES_PASSWORD=%s", c.Password()), 85 | fmt.Sprintf("POSTGRES_DB=%s", c.DatabaseName()), 86 | } 87 | case MySQLDriverName: 88 | return []string{ 89 | "MYSQL_RANDOM_ROOT_PASSWORD=true", 90 | fmt.Sprintf("MYSQL_USER=%s", c.Username()), 91 | fmt.Sprintf("MYSQL_PASSWORD=%s", c.Password()), 92 | fmt.Sprintf("MYSQL_DATABASE=%s", c.DatabaseName()), 93 | } 94 | case MSSQLDriverName: 95 | return []string{ 96 | "ACCEPT_EULA=Y", 97 | fmt.Sprintf("SA_USER=%s", c.Username()), 98 | fmt.Sprintf("SA_PASSWORD=%s", c.Password()), 99 | fmt.Sprintf("SA_DATABASE=%s", c.DatabaseName()), 100 | } 101 | default: 102 | return []string{} 103 | } 104 | } 105 | 106 | func (c *TestDB) IsRunnable() bool { 107 | for _, skippedArch := range c.SkippedArchs { 108 | if skippedArch == runtime.GOARCH { 109 | return false 110 | } 111 | } 112 | return true 113 | } 114 | 115 | // Path computes the full path to the database on disk (applies only to SQLite 116 | // instances). 117 | func (c *TestDB) Path() string { 118 | switch c.Driver { 119 | case SQLiteDriverName: 120 | if c.path == "" { 121 | tmpF, _ := ioutil.TempFile("", "schema.*.sqlite3") 122 | c.path = tmpF.Name() 123 | } 124 | return c.path 125 | default: 126 | return "" 127 | } 128 | } 129 | 130 | func (c *TestDB) DSN() string { 131 | switch c.Driver { 132 | case PostgresDriverName: 133 | return fmt.Sprintf("postgres://%s:%s@localhost:%s/%s?sslmode=disable", c.Username(), c.Password(), c.Port(), c.DatabaseName()) 134 | case SQLiteDriverName: 135 | return c.Path() 136 | case MySQLDriverName: 137 | /** 138 | * Since we want the system to be compatible with both parseTime=true and 139 | * not, we use different querystrings with MariaDB and MySQL. 140 | */ 141 | if c.DockerRepo == "mariadb" { 142 | return fmt.Sprintf("%s:%s@(localhost:%s)/%s?parseTime=true&multiStatements=true", c.Username(), c.Password(), c.Port(), c.DatabaseName()) 143 | } 144 | return fmt.Sprintf("%s:%s@(localhost:%s)/%s?multiStatements=true", c.Username(), c.Password(), c.Port(), c.DatabaseName()) 145 | case MSSQLDriverName: 146 | return fmt.Sprintf("sqlserver://%s:%s@localhost:%s/?database=%s", c.Username(), c.Password(), c.Port(), c.DatabaseName()) 147 | } 148 | // TODO Return error 149 | return "NoDSN" 150 | } 151 | 152 | // Init sets up a test database instance for connections. For dockertest-based 153 | // instances, this function triggers the `docker run` call. For SQLite-based 154 | // test instances, this creates the data file. In all cases, we verify that 155 | // the database is connectable via a test connection. 156 | func (c *TestDB) Init(pool *dockertest.Pool) { 157 | var err error 158 | 159 | if c.IsDocker() { 160 | // For Docker-based test databases, we send a startup signal to have Docker 161 | // launch a container for this test run. 162 | log.Printf("Starting docker container %s:%s\n", c.DockerRepo, c.DockerTag) 163 | 164 | // The container is started with AutoRemove: true, and a restart policy to 165 | // not restart 166 | c.Resource, err = pool.RunWithOptions(&dockertest.RunOptions{ 167 | Repository: c.DockerRepo, 168 | Tag: c.DockerTag, 169 | Env: c.DockerEnvars(), 170 | }, func(config *docker.HostConfig) { 171 | config.AutoRemove = true 172 | config.RestartPolicy = docker.RestartPolicy{ 173 | Name: "no", 174 | } 175 | }) 176 | 177 | if err != nil { 178 | log.Fatalf("Could not start container %s:%s: %s", c.DockerRepo, c.DockerTag, err) 179 | } 180 | 181 | // Even if everything goes OK, kill off the container after n seconds 182 | _ = c.Resource.Expire(120) 183 | } 184 | 185 | // Regardless of whether the DB is docker-based on not, we use the pool's 186 | // exponential backoff helper to wait until connections succeed for this 187 | // database 188 | err = pool.Retry(func() error { 189 | testConn, err := sql.Open(c.Driver, c.DSN()) 190 | if err != nil { 191 | return err 192 | } 193 | 194 | // We close the test connection... other code will re-open via the DSN() 195 | defer func() { _ = testConn.Close() }() 196 | return testConn.Ping() 197 | }) 198 | if err != nil { 199 | log.Fatalf("Could not connect to %s: %s", c.DSN(), err) 200 | } else { 201 | log.Printf("Successfully connected to %s", c.DSN()) 202 | } 203 | } 204 | 205 | // Connect creates an additional *database/sql.DB connection for a particular 206 | // test database. 207 | func (c *TestDB) Connect(t *testing.T) *sql.DB { 208 | db, err := sql.Open(c.Driver, c.DSN()) 209 | if err != nil { 210 | t.Error(err) 211 | } 212 | return db 213 | } 214 | 215 | // Cleanup should be called after all tests with a database instance are 216 | // complete. For dockertest-based tests, it deletes the docker containers. 217 | // For SQLite tests, it deletes the database file from the temp directory. 218 | func (c *TestDB) Cleanup(pool *dockertest.Pool) { 219 | var err error 220 | 221 | switch { 222 | case c.Driver == SQLiteDriverName: 223 | err = os.Remove(c.Path()) 224 | if os.IsNotExist(err) { 225 | // Ignore error cleaning up nonexistent file 226 | err = nil 227 | } 228 | 229 | case c.IsDocker() && c.Resource != nil: 230 | err = pool.Purge(c.Resource) 231 | } 232 | 233 | if err != nil { 234 | log.Fatalf("Could not cleanup %s: %s", c.DSN(), err) 235 | } 236 | } 237 | --------------------------------------------------------------------------------