Skip to content
Open
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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,17 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.25.x"
go-version: "1.26.x"
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.6.2
version: v2.11.3

test:
runs-on: ubuntu-latest
strategy:
matrix:
go: ["1.24.x", "1.25.x"]
go: ["1.25.x", "1.26.x"]
steps:
- uses: actions/checkout@v4

Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
SOURCE ?= file go_bindata github github_ee bitbucket aws_s3 google_cloud_storage godoc_vfs gitlab
DATABASE ?= postgres mysql redshift cassandra spanner cockroachdb yugabytedb clickhouse mongodb sqlserver firebird neo4j pgx pgx5 rqlite
DATABASE ?= postgres mysql redshift cassandra spanner cockroachdb yugabytedb clickhouse mongodb sqlserver firebird neo4j pgx pgx5 rqlite hana
DATABASE_TEST ?= $(DATABASE) sqlite sqlite3 sqlcipher
VERSION ?= $(shell git describe --tags 2>/dev/null | cut -c 2-)
TEST_FLAGS ?=
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[![Coverage Status](https://img.shields.io/coveralls/github/golang-migrate/migrate/master.svg)](https://coveralls.io/github/golang-migrate/migrate?branch=master)
[![packagecloud.io](https://img.shields.io/badge/deb-packagecloud.io-844fec.svg)](https://packagecloud.io/golang-migrate/migrate?filter=debs)
[![Docker Pulls](https://img.shields.io/docker/pulls/migrate/migrate.svg)](https://hub.docker.com/r/migrate/migrate/)
![Supported Go Versions](https://img.shields.io/badge/Go-1.24%2C%201.25-lightgrey.svg)
![Supported Go Versions](https://img.shields.io/badge/Go-1.25%2C%201.26-lightgrey.svg)
[![GitHub Release](https://img.shields.io/github/release/golang-migrate/migrate.svg)](https://github.com/golang-migrate/migrate/releases)
[![Go Report Card](https://goreportcard.com/badge/github.com/golang-migrate/migrate/v4)](https://goreportcard.com/report/github.com/golang-migrate/migrate/v4)

Expand Down
12 changes: 12 additions & 0 deletions database/hana/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# SAP HANA

`hdb://user:password@host:port?TLSServerName=host&x-migrations-schema=MYSCHEMA`

## URL Parameters

| URL Query | WithInstance Config | Description |
|------------------------|----------------------|-------------|
| `x-migrations-schema` | | **Required.** The schema in which the migrations table is created and migrations are applied. |
| `x-migrations-table` | `MigrationsTable` | Name of the migrations table. (default: `schema_migrations`) |
| `x-statement-timeout` | `StatementTimeout` | Abort any statement that takes longer than this duration (e.g. `30s`, `1m`). |
| `x-isolation-level` | `IsolationLevel` | Transaction isolation level as an integer corresponding to [`sql.IsolationLevel`](https://pkg.go.dev/database/sql#IsolationLevel). (default: `0`) |
1 change: 1 addition & 0 deletions database/hana/examples/migrations/1_init.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE TEST;
4 changes: 4 additions & 0 deletions database/hana/examples/migrations/1_init.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
CREATE ROW TABLE TEST (
id INTEGER PRIMARY KEY,
name NVARCHAR(255) NOT NULL
);
286 changes: 286 additions & 0 deletions database/hana/hana.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
package hana

import (
"context"
"database/sql"
"errors"
"fmt"
"io"
nurl "net/url"
"strconv"
"sync/atomic"
"time"

hdbDriver "github.com/SAP/go-hdb/driver"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database"
)

func init() {
database.Register("hdb", &Hana{})
}

var _ database.Driver = (*Hana)(nil)

var (
DefaultMigrationsTable = "schema_migrations"

ErrNilConfig = fmt.Errorf("no config")
ErrNoSchemaName = fmt.Errorf("no schema name")
ErrInvalidStatementTimeout = fmt.Errorf("invalid x-statement-timeout")
ErrInvalidIsolationLevel = fmt.Errorf("invalid x-isolation-level")
)

type Config struct {
SchemaName string
MigrationsTable string
StatementTimeout time.Duration
IsolationLevel sql.IsolationLevel
}

type Hana struct {
db *sql.DB
config *Config
isLocked atomic.Bool
}

func (h *Hana) Open(url string) (database.Driver, error) {
purl, err := nurl.Parse(url)
if err != nil {
return nil, err
}

schemaName := purl.Query().Get("x-migrations-schema")
if schemaName == "" {
return nil, ErrNoSchemaName
}

migrationsTable := purl.Query().Get("x-migrations-table")

statementTimeoutParam := purl.Query().Get("x-statement-timeout")
var statementTimeout time.Duration
if statementTimeoutParam != "" {
statementTimeout, err = time.ParseDuration(statementTimeoutParam)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrInvalidStatementTimeout, err)
}
}

isolationLevel := sql.LevelDefault
if isolationLevelParam := purl.Query().Get("x-isolation-level"); isolationLevelParam != "" {
isolationLevelInt, err := strconv.Atoi(isolationLevelParam)
if err != nil {
return nil, fmt.Errorf("could not parse x-isolation-level: %w", err)
}

if isolationLevelInt < int(sql.LevelDefault) || isolationLevelInt > int(sql.LevelLinearizable) {
return nil, fmt.Errorf("%w: %d", ErrInvalidIsolationLevel, isolationLevelInt)
}

isolationLevel = sql.IsolationLevel(isolationLevelInt)
}

dsn := migrate.FilterCustomQuery(purl).String()
connector, err := hdbDriver.NewDSNConnector(dsn)
if err != nil {
return nil, err
}

connector.SetDefaultSchema(schemaName)
db := sql.OpenDB(connector)

return WithInstance(db, &Config{
MigrationsTable: migrationsTable,
SchemaName: schemaName,
StatementTimeout: statementTimeout,
IsolationLevel: isolationLevel,
})
}

func (h *Hana) Close() error {
return h.db.Close()
}

func (h *Hana) Lock() error {
if !h.isLocked.CompareAndSwap(false, true) {
return database.ErrLocked
}

return nil
}

func (h *Hana) Unlock() error {
if !h.isLocked.CompareAndSwap(true, false) {
return database.ErrNotLocked
}

return nil
}

func (h *Hana) Run(migration io.Reader) error {
migr, err := io.ReadAll(migration)
if err != nil {
return err
}

ctx := context.Background()
if h.config.StatementTimeout != 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, h.config.StatementTimeout)
defer cancel()
}

if _, err := h.db.ExecContext(ctx, string(migr)); err != nil {
return database.Error{OrigErr: err, Err: "migration failed", Query: migr}
}

return nil
}

func (h *Hana) SetVersion(version int, dirty bool) error {
tx, err := h.db.BeginTx(context.Background(), &sql.TxOptions{Isolation: h.config.IsolationLevel})
if err != nil {
return &database.Error{OrigErr: err, Err: "transaction start failed"}
}

query := `DELETE FROM "` + h.config.SchemaName + `"."` + h.config.MigrationsTable + `"`
if _, err := tx.ExecContext(context.Background(), query); err != nil {
if errRollback := tx.Rollback(); errRollback != nil {
err = errors.Join(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 = `INSERT INTO "` + h.config.SchemaName + `"."` + h.config.MigrationsTable + `" (version, dirty) VALUES (?, ?)`
if _, err := tx.ExecContext(context.Background(), query, version, dirty); err != nil {
if errRollback := tx.Rollback(); errRollback != nil {
err = errors.Join(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 (h *Hana) Version() (version int, dirty bool, err error) {
query := `SELECT version, dirty FROM "` + h.config.SchemaName + `"."` + h.config.MigrationsTable + `" LIMIT 1`

err = h.db.QueryRowContext(context.Background(), query).Scan(&version, &dirty)
switch {
case err == sql.ErrNoRows:
return database.NilVersion, false, nil
case err != nil:
return 0, false, &database.Error{OrigErr: err, Query: []byte(query)}
default:
return version, dirty, nil
}
}

func (h *Hana) Drop() (err error) {
query := `SELECT TABLE_NAME FROM SYS.TABLES WHERE SCHEMA_NAME = ?`

tables, err := h.db.QueryContext(context.Background(), query, h.config.SchemaName)
if err != nil {
return &database.Error{OrigErr: err, Query: []byte(query)}
}

defer func() {
if errClose := tables.Close(); errClose != nil {
err = errors.Join(err, errClose)
}
}()

tableNames := make([]string, 0)
for tables.Next() {
var tableName string
if err := tables.Scan(&tableName); err != nil {
return err
}

if tableName != "" {
tableNames = append(tableNames, tableName)
}
}

if err := tables.Err(); err != nil {
return &database.Error{OrigErr: err, Query: []byte(query)}
}

for _, t := range tableNames {
query = `DROP TABLE "` + h.config.SchemaName + `"."` + t + `"`
if _, err := h.db.ExecContext(context.Background(), query); err != nil {
return &database.Error{OrigErr: err, Query: []byte(query)}
}
}

return nil
}

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

if err := instance.PingContext(context.Background()); err != nil {
return nil, err
}

hx := &Hana{
db: instance,
config: config,
}

if config.MigrationsTable == "" {
config.MigrationsTable = DefaultMigrationsTable
}

if err := hx.ensureVersionTable(); err != nil {
return nil, err
}

return hx, nil
}

// ensureVersionTable checks if the migrations table exists and creates it if not.
func (h *Hana) ensureVersionTable() (err error) {
if err = h.Lock(); err != nil {
return err
}

defer func() {
if e := h.Unlock(); e != nil {
err = errors.Join(err, e)
}
}()

var count int
query := `SELECT COUNT(*) FROM SYS.TABLES WHERE SCHEMA_NAME = ? AND TABLE_NAME = ?`
if err := h.db.QueryRowContext(context.Background(), query,
h.config.SchemaName,
h.config.MigrationsTable,
).Scan(&count); err != nil {
return &database.Error{OrigErr: err, Query: []byte(query)}
}

if count == 1 {
return nil
}

query = `CREATE ROW TABLE "` + h.config.SchemaName + `"."` + h.config.MigrationsTable + `" (version BIGINT NOT NULL PRIMARY KEY, dirty BOOLEAN NOT NULL)`
if _, err = h.db.ExecContext(context.Background(), query); err != nil {
return &database.Error{OrigErr: err, Query: []byte(query)}
}

return nil
}
45 changes: 45 additions & 0 deletions database/hana/hana_int_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package hana

import (
"os"
"testing"

"github.com/golang-migrate/migrate/v4"
dt "github.com/golang-migrate/migrate/v4/database/testing"
_ "github.com/golang-migrate/migrate/v4/source/file"
)

const envKey = "HANA_DATABASE_URL"

func TestMigrate(t *testing.T) {
url := getURL(t)

p := &Hana{}
d, err := p.Open(url)
if err != nil {
t.Fatal(err)
}

defer func() {
if err := d.Close(); err != nil {
t.Error(err)
}
}()

m, err := migrate.NewWithDatabaseInstance("file://./examples/migrations", "hdb", d)
if err != nil {
t.Fatal(err)
}

dt.TestMigrate(t, m)
}

func getURL(t *testing.T) string {
t.Helper()
url := os.Getenv(envKey)
if url == "" {
t.Skipf("skipping integration test: %s not set", envKey)
}

return url
}
Loading
Loading