8000 Add LibSQL Support by doorknob88 · Pull Request #1273 · golang-migrate/migrate · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

Add LibSQL Support #1273

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions database/libsql/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# libsql

`libsql://file:path/to/database?query`
`libsql://your-turso-db.turso.io?authToken=your-auth-token`

Unlike other migrate database drivers, the libsql driver will automatically wrap each migration in an implicit transaction by default. Migrations must not contain explicit `BEGIN` or `COMMIT` statements. This behavior may change in a future major release. (See below for a workaround.)

Refer to [upstream documentation](https://github.com/tursodatabase/go-libsql#usage) for a complete list of query parameters supported by the libsql database driver. The auxiliary query parameters listed below may be supplied to tailor migrate behavior. All auxiliary query parameters are optional.

| URL Query | WithInstance Config | Description |
|------------|---------------------|-------------|
| `x-migrations-table` | `MigrationsTable` | Name of the migrations table. Defaults to `schema_migrations`. |
| `x-no-tx-wrap` | `NoTxWrap` | Disable implicit transactions when `true`. Migrations may, and should, contain explicit `BEGIN` and `COMMIT` statements. |

## Notes

* Uses the `github.com/tursodatabase/go-libsql` libsql db driver.
* Supports local file databases, in-memory databases (`libsql://file::memory:`) and remote Turso databases.
```
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE IF EXISTS pets;
3 changes: 3 additions & 0 deletions database/libsql/examples/migrations/33_create_table.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
CREATE TABLE pets (
name string
);
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE IF EXISTS pets;
1 change: 1 addition & 0 deletions database/libsql/examples/migrations/44_alter_table.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE pets ADD predator bool;
297 changes: 297 additions & 0 deletions database/libsql/libsql.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
package libsql

import (
"database/sql"
"fmt"
"io"
nurl "net/url"
"strconv"
"strings"

"go.uber.org/atomic"

"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database"
"github.com/hashicorp/go-multierror"
_ "github.com/tursodatabase/go-libsql"
)

func init() {
database.Register("libsql", &Sqlite{})
}

var DefaultMigrationsTable = "schema_migrations"
var (
ErrDatabaseDirty = fmt.Errorf("database is dirty")
ErrNilConfig = fmt.Errorf("no config")
ErrNoDatabaseName = fmt.Errorf("no database name")
)

type Config struct {
MigrationsTable string
DatabaseName string
NoTxWrap bool
}

type Sqlite struct {
db *sql.DB
isLocked atomic.Bool

config *Config
}

func WithInstance(instance *sql.DB, config *Config) (database.Driver, error) {
if config == nil {
return nil, ErrNilConfig
}

if err := instance.Ping(); err != nil {
return nil, err
}

if len(config.MigrationsTable) == 0 {
config.MigrationsTable = DefaultMigrationsTable
}

mx := &Sqlite{
db: instance,
config: config,
}
if err := mx.ensureVersionTable(); err != nil {
return nil, err
}
return mx, nil
}

// ensureVersionTable checks if versions table exists and, if not, creates it.
// Note that this function locks the database, which deviates from the usual
// convention of "caller locks" in the Sqlite type.
func (m *Sqlite) ensureVersionTable() (err error) {
if err = m.Lock(); err != nil {
return err
}

defer func() {
if e := m.Unlock(); e != nil {
if err == nil {
err = e
} else {
err = multierror.Append(err, e)
}
}
}()

query := fmt.Sprintf(`
CREATE TABLE IF NOT EXISTS %s (version uint64,dirty bool);
CREATE UNIQUE INDEX IF NOT EXISTS version_unique ON %s (version);
`, m.config.MigrationsTable, m.config.MigrationsTable)

if _, err := m.db.Exec(query); err != nil {
return err
}
return nil
}

func (m *Sqlite) Open(url string) (database.Driver, error) {
purl, err := nurl.Parse(url)
if err != nil {
return nil, err
}
dbfile := strings.Replace(migrate.FilterCustomQuery(purl).String(), "libsql://", "", 1)
db, err := sql.Open("libsql", dbfile)
if err != nil {
return nil, err
}

qv := purl.Query()

migrationsTable := qv.Get("x-migrations-table")
if len(migrationsTable) == 0 {
migrationsTable = DefaultMigrationsTable
}

noTxWrap := false
if v := qv.Get("x-no-tx-wrap"); v != "" {
noTxWrap, err = strconv.ParseBool(v)
if err != nil {
return nil, fmt.Errorf("x-no-tx-wrap: %s", err)
}
}

mx, err := WithInstance(db, &Config{
DatabaseName: purl.Path,
MigrationsTable: migrationsTable,
NoTxWrap: noTxWrap,
})
if err != nil {
return nil, err
}
return mx, nil
}

func (m *Sqlite) Close() error {
return m.db.Close()
}

func (m *Sqlite) Drop() (err error) {
query := `SELECT name FROM sqlite_master WHERE type = 'table';`
tables, err := m.db.Query(query)
if err != nil {
return &database.Error{OrigErr: err, Query: []byte(query)}
}
defer func() {
if errClose := tables.Close(); errClose != nil {
err = multierror.Append(err, errClose)
}
}()

tableNames := make([]string, 0)
for tables.Next() {
var tableName string
if err := tables.Scan(&tableName); err != nil {
return err
}
if len(tableName) > 0 {
tableNames = append(tableNames, tableName)
}
}
if err := tables.Err(); err != nil {
return &database.Error{OrigErr: err, Query: []byte(query)}
}

if len(tableNames) > 0 {
for _, t := range tableNames {
query := "DROP TABLE " + t
if m.config.NoTxWrap {
_, err = m.db.Exec(query)
} else {
err = m.executeQuery(query)
}
if err != nil {
return &database.Error{OrigErr: err, Query: []byte(query)}
}
}

if m.config.NoTxWrap {
_, _ = m.db.Exec("ROLLBACK;")
}

query := "VACUUM"
// VACUUM cannot be run inside a transaction
_, err = m.db.Exec(query)
if err != nil {
return &database.Error{OrigErr: err, Query: []byte(query)}
}
}

return nil
}

func (m *Sqlite) Lock() error {
if !m.isLocked.CAS(false, true) {
return database.ErrLocked
}
return nil
}

func (m *Sqlite) Unlock() error {
if !m.isLocked.CAS(true, false) {
return database.ErrNotLocked
}
return nil
}

func (m *Sqlite) Run(migration io.Reader) error {
migr, err := io.ReadAll(migration)
if err != nil {
return err
}
query := string(migr[:])

if m.config.NoTxWrap {
return m.executeQueryNoTx(query)
}
return m.executeQuery(query)
}

func (m *Sqlite) executeQuery(query string) error {
tx, err := m.db.Begin()
if err != nil {
return &database.Error{OrigErr: err, Err: "transaction start failed"}
}
if _, err := tx.Exec(query); err != nil {
if errRollback := tx.Rollback(); errRollback != nil {
err = multierror.Append(err, errRollback)
}
return &database.Error{OrigErr: err, Query: []byte(query)}
}
if err := tx.Commit(); err != nil {
return &database.Error{OrigErr: err, Err: "transaction commit failed"}
}
return nil
}

func (m *Sqlite) executeQueryNoTx(query string) error {
if _, err := m.db.Exec(query); err != nil {
return &database.Error{OrigErr: err, Query: []byte(query)}
}
return nil
}

func (m *Sqlite) SetVersion(version int, dirty bool) error {
query := "DELETE FROM " + m.config.MigrationsTable

if m.config.NoTxWrap {
if _, err := m.db.Exec(query); err != nil {
return &database.Error{OrigErr: err, Query: []byte(query)}
}

if version >= 0 || (version == database.NilVersion && dirty) {
insertQuery := fmt.Sprintf(`INSERT INTO %s (version, dirty) VALUES (?, ?)`, m.config.MigrationsTable)
if _, err := m.db.Exec(insertQuery, version, dirty); err != nil {
return &database.Error{OrigErr: err, Query: []byte(insertQuery)}
}
}
return nil
}

tx, err := m.db.Begin()
if err != nil {
return &database.Error{OrigErr: err, Err: "transaction start failed"}
}

if _, err := tx.Exec(query); err != nil {
if errRollback := tx.Rollback(); errRollback != nil {
err = multierror.Append(err, errRollback)
}
return &database.Error{OrigErr: err, Query: []byte(query)}
}

// Also re-write the schema version for nil dirty versions to prevent
// empty schema version for failed down migration on the first migration
// See: https://github.com/golang-migrate/migrate/issues/330
if version >= 0 || (version == database.NilVersion && dirty) {
query := fmt.Sprintf(`INSERT INTO %s (version, dirty) VALUES (?, ?)`, m.config.MigrationsTable)
if _, err := tx.Exec(query, version, dirty); err != nil {
if errRollback := tx.Rollback(); errRollback != nil {
err = multierror.Append(err, errRollback)
}
return &database.Error{OrigErr: err, Query: []byte(query)}
}
}

if err := tx.Commit(); err != nil {
return &database.Error{OrigErr: err, Err: "transaction commit failed"}
}

return nil
}

func (m *Sqlite) Version() (version int, dirty bool, err error) {
query := "SELECT version, dirty FROM " + m.config.MigrationsTable + " LIMIT 1"
err = m.db.QueryRow(query).Scan(&version, &dirty)
if err != nil {
return database.NilVersion, false, nil
}
return version, dirty, nil
}
Loading
0