Skip to content

Commit

Permalink
add support for sqlite (#15)
Browse files Browse the repository at this point in the history
- adds support for `sqlite://` connections
  • Loading branch information
tlhunter authored Feb 21, 2023
1 parent b859aa7 commit 0dce92f
Show file tree
Hide file tree
Showing 15 changed files with 233 additions and 15 deletions.
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
# Output of the go coverage tool, specifically when used with LiteIDE
*.out

# SQLite Database
*.db
*.sqlite

# Binaries
/mig
/mig.exe
/mig-linux-amd64
Expand Down
21 changes: 14 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

![mig list screenshot](./docs/screenshot-mig-list.png)

`mig` currently supports **PostgreSQL** and **MySQL** with plans to add more.
`mig` currently supports **PostgreSQL**, **MySQL**, and **SQLite**. Pull requests to support other DBMS are appreciated.

`mig` isn't quite yet ready for production. When it is `mig` will be released as version 1.0.

Expand Down Expand Up @@ -33,13 +33,16 @@ mig --credentials="protocol://user:pass@host:port/dbname"
MIG_CREDENTIALS="protocol://user:pass@host:port/dbname" mig
```

Currently, `mig` supports protocols of `postgresql` and `mysql` with plans to support more. Internally `mig` loads the proper driver depending on the protocol. TLS checking can be set using query strings. Here's an example of how to connect to a local database:
Currently, `mig` supports protocols of `postgresql`, `mysql`, and `sqlite`. Internally `mig` loads the proper driver depending on the protocol. TLS checking can be set using query strings. Here's an example of how to connect to a local database for these different database systems:

```sh
mig --credentials="postgresql://user:hunter2@localhost:5432/dbname?tls=disable"
mig --credentials="mysql://user:hunter2@localhost:3306/dbname?tls=disable"
mig --credentials="sqlite://localhost/path/to/database.db"
```

> Note that SQLite is special in order to fit the URL format. Path values essentially have the first slash stripped. That means `localhost/foo.db` translates to `foo.db`, `localhost//tmp/foo.db` is `/tmp/foo.db`, `localhost/../foo.db` is `../foo.db`, etc. This is unfortunate and will likely change.
There are three connection string options for configuring secure databse connections:

* `?tls=verify`: enable secure database connection and verify the certificate
Expand Down Expand Up @@ -186,13 +189,17 @@ Run the following commands to run the different integration tests:
```sh
npm install -g zx

pushd tests/postgres
pushd tests/postgres # or mysql or sqlite
node ../test.mjs
popd
```

pushd tests/mysql
node ../test.mjs
popd
Unfortunately the integration tests currently require that Node.js also be installed. This will be fixed in the future.

For a fast local development cycle, go into the `tests/sqlite` directory and then execute the following one liner after you've made code changes:

```sh
clear ; pushd ../.. && make && popd && rm test.db ; ../test.mjs
```

Unfortunately the integration tests currently require that Node.js also be installed. This will be fixed in the future.
This recompiles the binary, deletes the sqlite database (thus resetting the state of the database), and runs the suite of acceptance tests.
42 changes: 42 additions & 0 deletions commands/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ func CommandInit(cfg config.MigConfig) result.Response {
err = postgresInit(dbox)
} else if dbox.IsMysql {
err = mysqlInit(dbox)
} else if dbox.IsSqlite {
err = sqliteInit(dbox)
} else {
panic("unknown database: " + dbox.Type)
}
Expand Down Expand Up @@ -106,3 +108,43 @@ func mysqlInit(dbox database.DbBox) error {

return nil
}

func sqliteInit(dbox database.DbBox) error {
tx, err := dbox.Db.Begin()
if err != nil {
return err
}

defer tx.Rollback()

_, err = tx.Exec(`CREATE TABLE migrations (
id serial NOT NULL,
name varchar(255) NULL,
batch int4 NULL,
migration_time timestamp NULL,
CONSTRAINT migrations_pkey PRIMARY KEY (id)
);`)
if err != nil {
return err
}

_, err = tx.Exec(`CREATE TABLE migrations_lock (
"index" serial NOT NULL,
is_locked int4 NULL,
CONSTRAINT migrations_lock_pkey PRIMARY KEY ("index")
);`)
if err != nil {
return err
}

_, err = tx.Exec(`INSERT INTO migrations_lock ("index", is_locked) VALUES(1, 0);`)
if err != nil {
return err
}

if err = tx.Commit(); err != nil {
return err
}

return nil
}
58 changes: 58 additions & 0 deletions commands/locks.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ func CommandLock(cfg config.MigConfig) result.Response {
wasLocked, err = postgresLock(dbox)
} else if dbox.IsMysql {
wasLocked, err = mysqlLock(dbox)
} else if dbox.IsSqlite {
wasLocked, err = sqliteLock(dbox)
} else {
panic("unknown database: " + dbox.Type)
}
Expand Down Expand Up @@ -73,6 +75,33 @@ func mysqlLock(dbox database.DbBox) (bool, error) {
return wasLocked > 0, nil
}

func sqliteLock(dbox database.DbBox) (bool, error) {
tx, err := dbox.Db.Begin()
if err != nil {
return false, err
}

defer tx.Rollback()

var wasLocked int

err = tx.QueryRow(`SELECT is_locked AS was_locked FROM migrations_lock WHERE "index" = 1;`).Scan(&wasLocked)
if err != nil {
return false, err
}

_, err = tx.Exec(`UPDATE migrations_lock SET is_locked = 1 WHERE "index" = 1;`)
if err != nil {
return false, err
}

if err = tx.Commit(); err != nil {
return false, err
}

return wasLocked > 0, nil
}

func CommandUnlock(cfg config.MigConfig) result.Response {
dbox, err := database.Connect(cfg.Connection)

Expand All @@ -88,6 +117,8 @@ func CommandUnlock(cfg config.MigConfig) result.Response {
wasLocked, err = postgresUnlock(dbox)
} else if dbox.IsMysql {
wasLocked, err = mysqlUnlock(dbox)
} else if dbox.IsSqlite {
wasLocked, err = sqliteUnlock(dbox)
} else {
panic("unknown database: " + dbox.Type)
}
Expand Down Expand Up @@ -140,3 +171,30 @@ func mysqlUnlock(dbox database.DbBox) (bool, error) {

return wasLocked > 0, nil
}

func sqliteUnlock(dbox database.DbBox) (bool, error) {
tx, err := dbox.Db.Begin()
if err != nil {
return false, err
}

defer tx.Rollback()

var wasLocked int

err = tx.QueryRow(`SELECT is_locked AS was_locked FROM migrations_lock WHERE "index" = 1;`).Scan(&wasLocked)
if err != nil {
return false, err
}

_, err = tx.Exec(`UPDATE migrations_lock SET is_locked = 0 WHERE "index" = 1;`)
if err != nil {
return false, err
}

if err = tx.Commit(); err != nil {
return false, err
}

return wasLocked > 0, nil
}
21 changes: 15 additions & 6 deletions commands/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ WHERE
schemaname = 'public' AND
tablename = 'migrations'
) AS table_exists;`,
Mysql: `CALL sys.table_exists(DATABASE(), 'migrations', @table_type); SELECT @table_type LIKE 'BASE TABLE';`,
Mysql: `CALL sys.table_exists(DATABASE(), 'migrations', @table_type); SELECT @table_type LIKE 'BASE TABLE';`,
Sqlite: `SELECT COUNT(name) >= 1 AS table_is_present FROM sqlite_master WHERE type='table' AND name='migrations';`,
}

var EXIST_LOCK = database.QueryBox{
Expand All @@ -30,7 +31,8 @@ var EXIST_LOCK = database.QueryBox{
schemaname = 'public' AND
tablename = 'migrations_lock'
) AS table_exists;`,
Mysql: `CALL sys.table_exists(DATABASE(), 'migrations_lock', @table_type); SELECT @table_type LIKE 'BASE TABLE';`,
Mysql: `CALL sys.table_exists(DATABASE(), 'migrations_lock', @table_type); SELECT @table_type LIKE 'BASE TABLE';`,
Sqlite: `SELECT COUNT(name) >= 1 AS table_is_present FROM sqlite_master WHERE type='table' AND name='migrations_lock';`,
}

var DESCRIBE = database.QueryBox{
Expand All @@ -44,12 +46,14 @@ WHERE
table_name = 'migrations' OR table_name = 'migrations_lock'
ORDER BY
table_name, column_name;`,
Mysql: `DESC migrations;`,
Mysql: `DESC migrations;`, // unused
Sqlite: `pragma table_info('migrations');`, // unused
}

var LOCK_STATUS = database.QueryBox{
Postgres: `SELECT is_locked FROM migrations_lock WHERE index = 1;`,
Mysql: `SELECT is_locked FROM migrations_lock WHERE ` + "`index`" + ` = 1;`,
Sqlite: `SELECT is_locked FROM migrations_lock WHERE "index" = 1;`,
}

type StatusResponse struct {
Expand Down Expand Up @@ -121,10 +125,13 @@ func CommandStatus(cfg config.MigConfig) result.Response {

res := result.NewSerializable("", "")

if dbox.Type == "mysql" {
// The following gnarly comparison checks need to be rebuilt first
if dbox.IsMysql {
// TODO: rebuild the gnarly checks
res.AddSuccessLn(color.YellowString("migration table description check is currently unimplemented for mysql."))
} else {
} else if dbox.IsSqlite {
// TODO: rebuild the gnarly checks
res.AddSuccessLn(color.YellowString("migration table description check is currently unimplemented for sqlite."))
} else if dbox.IsPostgres {
rows, err := dbox.Query(DESCRIBE)
if err != nil {
return *result.NewErrorWithDetails("unable to describe the migration tables!", "unable_describe", err)
Expand Down Expand Up @@ -170,6 +177,8 @@ func CommandStatus(cfg config.MigConfig) result.Response {
if table != "migrations_lock" || column != "is_locked" || data != "integer" {
return *result.NewError("expected migrations_lock.is_locked of type integer", "invalid_locked_type")
}
} else {
panic("unknown database: " + dbox.Type)
}

// Check if locked
Expand Down
20 changes: 20 additions & 0 deletions database/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import (
"errors"
"fmt"
"net/url"
"strings"

_ "github.com/go-sql-driver/mysql"
_ "github.com/lib/pq"
_ "github.com/mattn/go-sqlite3"
)

type DbBox struct {
Expand All @@ -16,6 +18,7 @@ type DbBox struct {

IsPostgres bool // indicates this connection is for PostgreSQL
IsMysql bool // indicates this connection is for MySQL
IsSqlite bool // indicates this connection is for Sqlite
}

func (dbox DbBox) GetQuery(qb QueryBox) string {
Expand Down Expand Up @@ -136,6 +139,23 @@ func Connect(connection string) (DbBox, error) {
if err != nil {
return dbox, errors.New("unable to connect to mysql database!")
}
} else if u.Scheme == "sqlite" { // or sqlite3?
dbox.IsSqlite = true

// TODO: The connection format for sqlite sucks. Maybe use "sqlite:/path.db" instead?

hostname := u.Hostname()
if hostname != "localhost" && hostname != "127.0.0.1" && hostname != "::1" {
return dbox, errors.New("sqlite connection requires a host name of localhost!")
}

path := strings.TrimPrefix(u.Path, "/") // /foo -> foo, //foo -> /foo

dbox.Db, err = sql.Open("sqlite3", path)

if err != nil {
return dbox, errors.New("unable to connect to sqlite database!")
}
} else {
return dbox, errors.New(fmt.Sprintf("mig doesn't support the '%s' database", u.Scheme))
}
Expand Down
32 changes: 32 additions & 0 deletions database/locking.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ func ObtainLock(dbox DbBox) (bool, error) {
return postgresObtainLock(dbox)
} else if dbox.IsMysql {
return mysqlObtainLock(dbox)
} else if dbox.IsSqlite {
return sqliteObtainLock(dbox)
} else {
panic("unknown database: " + dbox.Type)
}
Expand Down Expand Up @@ -38,11 +40,27 @@ func mysqlObtainLock(dbox DbBox) (bool, error) {
return affected == 1, nil
}

func sqliteObtainLock(dbox DbBox) (bool, error) {
result, err := dbox.Db.Exec(`UPDATE migrations_lock SET is_locked = 1 WHERE "index" = 1 AND is_locked = 0;`)
if err != nil {
return false, err
}

affected, err := result.RowsAffected()
if err != nil {
return false, err
}

return affected == 1, nil
}

func ReleaseLock(dbox DbBox) (bool, error) {
if dbox.IsPostgres {
return postgresReleaseLock(dbox)
} else if dbox.IsMysql {
return mysqlReleaseLock(dbox)
} else if dbox.IsSqlite {
return sqliteReleaseLock(dbox)
} else {
panic("unknown database: " + dbox.Type)
}
Expand Down Expand Up @@ -75,3 +93,17 @@ func mysqlReleaseLock(dbox DbBox) (bool, error) {

return affected == 1, nil
}

func sqliteReleaseLock(dbox DbBox) (bool, error) {
result, err := dbox.Db.Exec(`UPDATE migrations_lock SET is_locked = 0 WHERE "index" = 1 AND is_locked = 1;`)
if err != nil {
return false, err
}

affected, err := result.RowsAffected()
if err != nil {
return false, err
}

return affected == 1, nil
}
3 changes: 3 additions & 0 deletions database/querybox.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@ import "fmt"
type QueryBox struct {
Postgres string
Mysql string
Sqlite string
}

func (qb QueryBox) For(driver string) string {
if driver == "mysql" {
return qb.Mysql
} else if driver == "postgresql" {
return qb.Postgres
} else if driver == "sqlite" {
return qb.Sqlite
}

panic(fmt.Sprintf("requested query for unknown driver %s", driver))
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.9 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/mattn/go-sqlite3 v1.14.16 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/stretchr/testify v1.8.1 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
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=
Expand Down
Loading

0 comments on commit 0dce92f

Please sign in to comment.