Skip to content

Commit

Permalink
Add support for MySQL (#1)
Browse files Browse the repository at this point in the history
* mysql WIP

* mysql WIP

* WIP tokenize fully tested except for unicode

* WIP tokenize fully tested

* token strip

* mysql prefixed literals

* Added Skip()

* mysql WIP

* first pass of mysql tests

* mysql: check test

* mysql skip helpers

* remove remaining idempotent reference

* code review typos

* add some comments in reaction to Aaron's comments

* add some comments in reaction to Aaron's comments

* mysql: add missing file: skip.go with utility functions

* mysql: add github actions CI for mysql (#4)

* mysql: add github action

* mysql: github action: fix

* mysql: github action: fix2

* mysql: github action: fix3

* sqltoken is now its own repo

* update deps

* update test name
  • Loading branch information
muir authored Mar 30, 2022
1 parent b6c6fe7 commit 83aa21d
Show file tree
Hide file tree
Showing 14 changed files with 981 additions and 27 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/go.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Run Go tests
name: Go tests
on: [ push ]
jobs:
Build-and-test:
Expand Down
38 changes: 38 additions & 0 deletions .github/workflows/mysql.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: MySQL tests
on: [ push ]

jobs:
Test-mysql-integration:
runs-on: ubuntu-latest

services:
mysql:
image: mysql:8
env:
MYSQL_DATABASE: libschematest
MYSQL_ROOT_PASSWORD: mysql
options: >-
--health-cmd "mysqladmin ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 3306:3306

steps:
- name: Check out repository code
uses: actions/checkout@v2

- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.16

- name: Build
run: go build -v ./...

- name: Test
env:
LIBSCHEMA_MYSQL_TEST_DSN: "root:mysql@tcp(127.0.0.1:3306)/libschematest?tls=false"
run: go test ./lsmysql/... -v

33 changes: 25 additions & 8 deletions api.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,13 @@ type MigrationOption func(Migration)

// Migration defines a single database defintion update.
type MigrationBase struct {
Name MigrationName
async bool
rawAfter []MigrationName
order int // overall desired ordring across all libraries, ignores runAfter
status MigrationStatus
Name MigrationName
async bool
rawAfter []MigrationName
order int // overall desired ordring across all libraries, ignores runAfter
status MigrationStatus
skipIf func() (bool, error)
skipRemainingIf func() (bool, error)
}

func (m MigrationBase) Copy() MigrationBase {
Expand All @@ -66,9 +68,8 @@ type Migration interface {

// MigrationStatus tracks if a migration is complete or not.
type MigrationStatus struct {
Done bool
Partial string // for Mysql, the string represents the portion of multiple commands that have completed
Error string // If an attempt was made but failed, this will be set
Done bool
Error string // If an attempt was made but failed, this will be set
}

// Database tracks all of the migrations for a specific database.
Expand Down Expand Up @@ -203,6 +204,18 @@ func After(lib, migration string) MigrationOption {
}
}

func SkipIf(pred func() (bool, error)) MigrationOption {
return func(m Migration) {
m.Base().skipIf = pred
}
}

func SkipRemainingIf(pred func() (bool, error)) MigrationOption {
return func(m Migration) {
m.Base().skipRemainingIf = pred
}
}

func (d *Database) DB() *sql.DB {
return d.db
}
Expand Down Expand Up @@ -242,6 +255,10 @@ func (m *MigrationBase) SetStatus(status MigrationStatus) {
m.status = status
}

func (m *MigrationBase) HasSkipIf() bool {
return m.skipIf != nil
}

func (n MigrationName) String() string {
return n.Library + ": " + n.Name
}
32 changes: 26 additions & 6 deletions apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,27 +212,46 @@ func (d *Database) migrate(ctx context.Context) (err error) {
go d.asyncMigrate(ctx)
return nil
}
err = d.doOneMigration(ctx, m)
if err != nil {
var stop bool
stop, err = d.doOneMigration(ctx, m)
if err != nil || stop {
return err
}
}
return nil
}

func (d *Database) doOneMigration(ctx context.Context, m Migration) error {
func (d *Database) doOneMigration(ctx context.Context, m Migration) (bool, error) {
if d.Options.DebugLogging {
d.log.Debug("Starting migration", map[string]interface{}{
"database": d.name,
"library": m.Base().Name.Library,
"name": m.Base().Name.Name,
})
}
if m.Base().skipIf != nil {
skip, err := m.Base().skipIf()
if err != nil {
return false, errors.Wrapf(err, "SkipIf %s", m.Base().Name)
}
if skip {
return false, nil
}
}
if m.Base().skipRemainingIf != nil {
skip, err := m.Base().skipRemainingIf()
if err != nil {
return false, errors.Wrapf(err, "SkipRemainingIf %s", m.Base().Name)
}
if skip {
return true, nil
}
}
err := d.driver.DoOneMigration(ctx, d.log, d, m)
if err != nil && d.Options.OnMigrationFailure != nil {
d.Options.OnMigrationFailure(m.Base().Name, err)
}
return err
return false, err
}

func (d *Database) lastUnfinishedSynchrnous() int {
Expand Down Expand Up @@ -279,8 +298,9 @@ func (d *Database) asyncMigrate(ctx context.Context) {
if m.Base().Status().Done {
continue
}
err = d.doOneMigration(ctx, m)
if err != nil {
var stop bool
stop, err = d.doOneMigration(ctx, m)
if err != nil || stop {
return
}
}
Expand Down
8 changes: 5 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ module github.com/muir/libschema
go 1.16

require (
github.com/lib/pq v1.10.3
github.com/muir/testinglogur v0.0.0-20210705185900-bc47cbaaadca
github.com/go-sql-driver/mysql v1.5.0
github.com/lib/pq v1.10.4
github.com/muir/sqltoken v0.0.4
github.com/muir/testinglogur v0.0.1
github.com/pkg/errors v0.9.1
github.com/stretchr/testify v1.7.0
github.com/stretchr/testify v1.7.1
)
23 changes: 18 additions & 5 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,16 +1,29 @@
github.com/alvaroloes/enumer v1.1.2/go.mod h1:FxrjvuXoDAx9isTJrv4c+T410zFi0DtXIT0m65DJ+Wo=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/lib/pq v1.10.3 h1:v9QZf2Sn6AmjXtQeFpdoq/eaNtYP6IN+7lcrygsIAtg=
github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/muir/testinglogur v0.0.0-20210705185900-bc47cbaaadca h1:umBSRx6i2/+1gbab8wlghfL7vPhBGr8ZwlKlo1nRg04=
github.com/muir/testinglogur v0.0.0-20210705185900-bc47cbaaadca/go.mod h1:18iL5fVrQ2hu0NeXKtEE9pS5jgdaNTgqWHNl+p33g6M=
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/lib/pq v1.10.4 h1:SO9z7FRPzA03QhHKJrH5BXA6HU1rS4V2nIVrrNC1iYk=
github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/muir/sqltoken v0.0.4 h1:SioNnG90ZYXmlfnPaUxUdNC1dFkhKL64pDeS+wXZ8k8=
github.com/muir/sqltoken v0.0.4/go.mod h1:6hPsZxszMpYyNf12og4f4VShFo/Qipz6Of0cn5KGAAU=
github.com/muir/testinglogur v0.0.1 h1:k0lztrKzttiH5Pjtzj7S4tXXXBgUaxqTtVKXK4ndiI8=
github.com/muir/testinglogur v0.0.1/go.mod h1:18iL5fVrQ2hu0NeXKtEE9pS5jgdaNTgqWHNl+p33g6M=
github.com/pascaldekloe/name v0.0.0-20180628100202-0fd16699aae1/go.mod h1:eD5JxqMiuNYyFNmyY9rkJ/slN8y59oEu4Ei7F8OoKWQ=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20190524210228-3d17549cdc6b/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
Expand Down
97 changes: 97 additions & 0 deletions lsmysql/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@

# libschema/lsmysql - mysql support for libschema

[![GoDoc](https://godoc.org/github.com/muir/libschema?status.png)](https://pkg.go.dev/github.com/muir/libschema/lsmysql)

Install:

go get github.com/muir/libschema

---

## DDL Transactions

MySQL and MariaDB do not support DDL (Data Definition Language) transactions like
`CREATE TABLE`. Such commands cause the current transaction to switch to `autocommit`
mode.

The consequence of this is that it is not possible for a schema migration tool,
like libschema, to track if a migration has been applied or not by tracking the status
of a transaction.

When working with MySQL and MariaDB, schema-changing migrations should be done
separately from data-changing migrations. Schema-changing transactions that are
idempotent are safe and require no special handling.

Schema-changing transactions that are not idempotent need to be guarded with conditionals
so that they're skipped if they've already been applied.

Fortunately, `IF EXISTS` and `IF NOT EXISTS` clauses can be most of the DDL statements.

### Conditionals

The DDL statements missing `IF EXISTS` and `IF NOT EXISTS` include:

```sql
ALTER TABLE ...
ADD CONSTRAINT
ALTER COLUMN SET SET DEFAULT
ALTER COLUMN SET DROP DEFAULT
ADD FULLTEXT
ADD SPATIAL
ADD PERIOD FOR SYSTEM TIME
ADD {INDEX|KEY} index_name [NOT] INVISIBLE
DROP PRIMARY KEY
RENAME COLUMN
RENAME INDEX
RENAME KEY
DISCARD TABLESPACE
IMPORT TABLESPACE
COALESCE PARTITION
REORGANIZE PARTITION
EXCHANGE PARTITION
REMOVE PARTITIONING
DISABLE KEYS
ENABLE KEYS
```

To help make these conditional, the lsmysql provides some helper functions to easily
check the current database state.

For example:

```go
schema := libschema.NewSchema(ctx, libschema.Options{})

sqlDB, err := sql.Open("mysql", "....")

database, mysql, err := lsmysql.New(logger, "main-db", schema, sqlDB)

database.Migrations("MyLibrary",
lsmysql.Script("createUserTable", `
CREATE TABLE users (
name text,
id bigint,
PRIMARY KEY (id)
) ENGINE=InnoDB`
}),
lsmysql.Script("dropUserPK", `
ALTER TABLE users
DROP PRIMARY KEY`,
libschema.SkipIf(func() (bool, error) {
hasPK, err := mysql.HasPrimaryKey("users")
return !hasPK, err
})),
)
```

### Some notes on MySQL

While most identifiers (table names, etc) can be `"`quoted`"`, you cannot use quotes around
a schema (database really) name with `CREATE SCHEMA`.

MySQL does not support schemas. A schema is just a synonym for `DATABASE` in the MySQL world.
This means that it is easier to put migrations tracking table in the same schema (database) as
the rest of the tables. It also means that to run migration unit tests, the DSN for testing
has to give access to a user that can create and drop databases.

53 changes: 53 additions & 0 deletions lsmysql/check.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package lsmysql

import (
"regexp"
"strings"

"github.com/muir/sqltoken"
)

type CheckResult string

const (
Safe CheckResult = "safe"
DataAndDDL = "dataAndDDL"
NonIdempotentDDL = "nonIdempotentDDL"
)

var ifExistsRE = regexp.MustCompile(`(?i)\bIF (?:NOT )?EXISTS\b`)

// CheckScript attempts to validate that an SQL command does not do
// both schema changes (DDL) and data changes.
func CheckScript(s string) CheckResult {
var seenDDL int
var seenData int
var idempotent int
ts := sqltoken.TokenizeMySQL(s)
for _, cmd := range ts.Strip().CmdSplit() {
word := strings.ToLower(cmd[0].Text)
switch word {
case "alter", "rename", "create", "drop", "comment":
seenDDL++
if ifExistsRE.MatchString(cmd.String()) {
idempotent++
}
case "truncate":
seenDDL++
idempotent++
case "use", "set":
// neither
case "values", "table", "select":
// doesn't modify anything
case "call", "delete", "do", "handler", "import", "insert", "load", "replace", "update", "with":
seenData++
}
}
if seenDDL > 0 && seenData > 0 {
return DataAndDDL
}
if seenDDL > idempotent {
return NonIdempotentDDL
}
return Safe
}
Loading

0 comments on commit 83aa21d

Please sign in to comment.