Repository: amacneil/dbmate
Pull request #140 (2020-06-10 07:55:04)

Add ClickHouse support

Flamefork: Rebased after 1.9 release
amacneil: Thanks - I added one more comment on the URL format, and a small typo. After that should be good to merge.

Patch:
diff --git a/Makefile b/Makefile
index 9082e15..01c2372 100644
--- a/Makefile
+++ b/Makefile
@@ -19,6 +19,7 @@ lint:
 wait:
 	dist/dbmate-linux-amd64 -e MYSQL_URL wait
 	dist/dbmate-linux-amd64 -e POSTGRESQL_URL wait
+	dist/dbmate-linux-amd64 -e CLICKHOUSE_URL wait
 
 .PHONY: clean
 clean:
diff --git a/README.md b/README.md
index 0eb4ea1..b536350 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@ For a comparison between dbmate and other popular database schema migration tool
 
 ## Features
 
-* Supports MySQL, PostgreSQL, and SQLite.
+* Supports MySQL, PostgreSQL, SQLite, and ClickHouse.
 * Uses plain SQL for writing schema migrations.
 * Migrations are timestamp-versioned, to avoid version number conflicts with multiple developers.
 * Migrations are run atomically inside a transaction.
@@ -117,7 +117,7 @@ DATABASE_URL="postgres://postgres@127.0.0.1:5432/myapp_development?sslmode=disab
 protocol://username:password@host:port/database_name?options
 ```
 
-* `protocol` must be one of `mysql`, `postgres`, `postgresql`, `sqlite`, `sqlite3`
+* `protocol` must be one of `mysql`, `postgres`, `postgresql`, `sqlite`, `sqlite3`, `clickhouse`
 * `host` can be either a hostname or IP address
 * `options` are driver-specific (refer to the underlying Go SQL drivers if you wish to use these)
 
@@ -161,6 +161,20 @@ To specify an absolute path, add an additional forward slash to the path. The fo
 DATABASE_URL="sqlite:////tmp/database_name.sqlite3"
 ```
 
+**ClickHouse**
+
+```sh
+DATABASE_URL="clickhouse://username:password@127.0.0.1:9000/database_name"
+```
+
+or
+
+```sh
+DATABASE_URL="clickhouse://127.0.0.1:9000?username=username&password=password&database=database_name"
+```
+
+[See other supported connection options](https://github.com/ClickHouse/clickhouse-go#dsn).
+
 ### Creating Migrations
 
 To create a new migration, run `dbmate new create_users_table`. You can name the migration anything you like. This will create a file `db/migrations/20151127184807_create_users_table.sql` in the current directory:
@@ -333,7 +347,7 @@ Why another database schema migration tool? Dbmate was inspired by many other to
 
 | | [goose](https://bitbucket.org/liamstask/goose/) | [sql-migrate](https://github.com/rubenv/sql-migrate) | [golang-migrate/migrate](https://github.com/golang-migrate/migrate) | [activerecord](http://guides.rubyonrails.org/active_record_migrations.html) | [sequelize](http://docs.sequelizejs.com/manual/tutorial/migrations.html) | [dbmate](https://github.com/amacneil/dbmate) |
 | --- |:---:|:---:|:---:|:---:|:---:|:---:|
-| **Features** |||||||
+| **Features** |
 |Plain SQL migration files|:white_check_mark:|:white_check_mark:|:white_check_mark:|||:white_check_mark:|
 |Support for creating and dropping databases||||:white_check_mark:||:white_check_mark:|
 |Support for saving schema dump files||||:white_check_mark:||:white_check_mark:|
@@ -343,10 +357,11 @@ Why another database schema migration tool? Dbmate was inspired by many other to
 |Automatically load .env file||||||:white_check_mark:|
 |No separate configuration file||||:white_check_mark:|:white_check_mark:|:white_check_mark:|
 |Language/framework independent|:eight_pointed_black_star:|:eight_pointed_black_star:|:eight_pointed_black_star:|||:white_check_mark:|
-| **Drivers** |||||||
+| **Drivers** |
 |PostgreSQL|:white_check_mark:|:white_check_mark:|:white_check_mark:|:white_check_mark:|:white_check_mark:|:white_check_mark:|
 |MySQL|:white_check_mark:|:white_check_mark:|:white_check_mark:|:white_check_mark:|:white_check_mark:|:white_check_mark:|
 |SQLite|:white_check_mark:|:white_check_mark:|:white_check_mark:|:white_check_mark:|:white_check_mark:|:white_check_mark:|
+|CliсkHouse|||:white_check_mark:|:white_check_mark:|:white_check_mark:|:white_check_mark:|
 
 > :eight_pointed_black_star: In theory these tools could be used with other languages, but a Go development environment is required because binary builds are not provided.
 
diff --git a/docker-compose.yml b/docker-compose.yml
index 0f81590..57b9996 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -7,9 +7,11 @@ services:
     depends_on:
       - mysql
       - postgres
+      - clickhouse
     environment:
       MYSQL_URL: mysql://root:root@mysql/dbmate
       POSTGRESQL_URL: postgres://postgres:postgres@postgres/dbmate?sslmode=disable
+      CLICKHOUSE_URL: clickhouse://clickhouse:9000?database=dbmate
 
   mysql:
     image: mysql:5.7
@@ -20,3 +22,6 @@ services:
     image: postgres:10
     environment:
       POSTGRES_PASSWORD: postgres
+
+  clickhouse:
+    image: yandex/clickhouse-server:19.16
diff --git a/go.mod b/go.mod
index d9493c6..a67ca27 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module github.com/amacneil/dbmate
 go 1.14
 
 require (
+	github.com/ClickHouse/clickhouse-go v1.4.1
 	github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/go-sql-driver/mysql v1.5.0
diff --git a/go.sum b/go.sum
index 9719e7f..19df7c5 100644
--- a/go.sum
+++ b/go.sum
@@ -1,12 +1,20 @@
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/ClickHouse/clickhouse-go v1.4.1 h1:D9cihLg76O1ZyILLaXq1eksYzEuV010NdvucgKGGK14=
+github.com/ClickHouse/clickhouse-go v1.4.1/go.mod h1:EaI/sW7Azgz9UATzd5ZdZHRUhHgv5+JMS9NSr2smCJI=
+github.com/bkaradzic/go-lz4 v1.0.0 h1:RXc4wYsyz985CkXXeX04y4VnZFGG8Rd43pRaHsOXAKk=
+github.com/bkaradzic/go-lz4 v1.0.0/go.mod h1:0YdlkowM3VswSROI7qDxhRvJ3sLhlFrRRwjwegp5jy4=
+github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58 h1:F1EaeKL/ta07PY/k9Os/UFtwERei2/XzGemhpGnBKNg=
+github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58/go.mod h1:EOBUe0h4xcZ5GoxqC5SDxFQ8gwyZPKQoEzownBlhI80=
 github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
 github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM=
 github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
 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/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks=
 github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
 github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
 github.com/kami-zh/go-capturer v0.0.0-20171211120116-e492ea43421d h1:cVtBfNW5XTHiKQe7jDaDBSh/EVM4XLPutLAGboIXuM0=
@@ -16,10 +24,14 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.5.2 h1:yTSXVswvWUOQ3k1sd7vJfDrbSl8lKuscqFJRqjC0ifw=
 github.com/lib/pq v1.5.2/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
+github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
 github.com/mattn/go-sqlite3 v1.13.0 h1:LnJI81JidiW9r7pS/hXe6cFeO5EXNq7KbfvoJLRI69c=
 github.com/mattn/go-sqlite3 v1.13.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
+github.com/pierrec/lz4 v2.0.5+incompatible h1:2xWsjqPFWcplujydGg4WmhC/6fZqK42wMM8aXeqhl0I=
+github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
 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/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
@@ -27,6 +39,7 @@ github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD
 github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
 github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 github.com/urfave/cli v1.22.4 h1:u7tSpNPPswAFymm8IehJhy4uJMlUuU/GmqSkvJ1InXA=
diff --git a/main.go b/main.go
index 8452277..99909f7 100644
--- a/main.go
+++ b/main.go
@@ -5,6 +5,7 @@ import (
 	"log"
 	"net/url"
 	"os"
+	"regexp"
 
 	"github.com/joho/godotenv"
 	"github.com/urfave/cli"
@@ -19,7 +20,8 @@ func main() {
 	err := app.Run(os.Args)
 
 	if err != nil {
-		_, _ = fmt.Fprintf(os.Stderr, "Error: %s\n", err)
+		errText := redactLogString(fmt.Sprintf("Error: %s\n", err))
+		_, _ = fmt.Fprint(os.Stderr, errText)
 		os.Exit(2)
 	}
 }
@@ -219,3 +221,10 @@ func getDatabaseURL(c *cli.Context) (u *url.URL, err error) {
 
 	return url.Parse(value)
 }
+
+// redactLogString attempts to redact passwords from errors
+func redactLogString(in string) string {
+	re := regexp.MustCompile("([a-zA-Z]+://[^:]+:)[^@]+@")
+
+	return re.ReplaceAllString(in, "${1}********@")
+}
diff --git a/pkg/dbmate/clickhouse.go b/pkg/dbmate/clickhouse.go
new file mode 100644
index 0000000..35a0901
--- /dev/null
+++ b/pkg/dbmate/clickhouse.go
@@ -0,0 +1,289 @@
+package dbmate
+
+import (
+	"bytes"
+	"database/sql"
+	"fmt"
+	"net/url"
+	"regexp"
+	"sort"
+	"strings"
+
+	"github.com/ClickHouse/clickhouse-go"
+)
+
+func init() {
+	RegisterDriver(ClickHouseDriver{}, "clickhouse")
+}
+
+// ClickHouseDriver provides top level database functions
+type ClickHouseDriver struct {
+}
+
+func normalizeClickHouseURL(initialURL *url.URL) *url.URL {
+	u := *initialURL
+
+	u.Scheme = "tcp"
+	host := u.Host
+	if u.Port() == "" {
+		host = fmt.Sprintf("%s:9000", host)
+	}
+	u.Host = host
+
+	query := u.Query()
+	if query.Get("username") == "" && u.User.Username() != "" {
+		query.Set("username", u.User.Username())
+	}
+	password, passwordSet := u.User.Password()
+	if query.Get("password") == "" && passwordSet {
+		query.Set("password", password)
+	}
+	u.User = nil
+
+	if query.Get("database") == "" {
+		path := strings.Trim(u.Path, "/")
+		if path != "" {
+			query.Set("database", path)
+			u.Path = ""
+		}
+	}
+	u.RawQuery = query.Encode()
+
+	return &u
+}
+
+// Open creates a new database connection
+func (drv ClickHouseDriver) Open(u *url.URL) (*sql.DB, error) {
+	return sql.Open("clickhouse", normalizeClickHouseURL(u).String())
+}
+
+func (drv ClickHouseDriver) openClickHouseDB(u *url.URL) (*sql.DB, error) {
+	// connect to clickhouse database
+	clickhouseURL := normalizeClickHouseURL(u)
+	values := clickhouseURL.Query()
+	values.Set("database", "default")
+	clickhouseURL.RawQuery = values.Encode()
+
+	return drv.Open(clickhouseURL)
+}
+
+func (drv ClickHouseDriver) databaseName(u *url.URL) string {
+	name := normalizeClickHouseURL(u).Query().Get("database")
+	if name == "" {
+		name = "default"
+	}
+	return name
+}
+
+var clickhouseValidIdentifier = regexp.MustCompile(`^[a-zA-Z_][0-9a-zA-Z_]*$`)
+
+func clickhouseQuoteIdentifier(str string) string {
+	if clickhouseValidIdentifier.MatchString(str) {
+		return str
+	}
+
+	str = strings.Replace(str, `"`, `""`, -1)
+
+	return fmt.Sprintf(`"%s"`, str)
+}
+
+// CreateDatabase creates the specified database
+func (drv ClickHouseDriver) CreateDatabase(u *url.URL) error {
+	name := drv.databaseName(u)
+	fmt.Printf("Creating: %s\n", name)
+
+	db, err := drv.openClickHouseDB(u)
+	if err != nil {
+		return err
+	}
+	defer mustClose(db)
+
+	_, err = db.Exec("create database " + clickhouseQuoteIdentifier(name))
+
+	return err
+}
+
+// DropDatabase drops the specified database (if it exists)
+func (drv ClickHouseDriver) DropDatabase(u *url.URL) error {
+	name := drv.databaseName(u)
+	fmt.Printf("Dropping: %s\n", name)
+
+	db, err := drv.openClickHouseDB(u)
+	if err != nil {
+		return err
+	}
+	defer mustClose(db)
+
+	_, err = db.Exec("drop database if exists " + clickhouseQuoteIdentifier(name))
+
+	return err
+}
+
+func clickhouseSchemaDump(db *sql.DB, buf *bytes.Buffer, databaseName string) error {
+	buf.WriteString("\n--\n-- Database schema\n--\n\n")
+
+	buf.WriteString("CREATE DATABASE " + clickhouseQuoteIdentifier(databaseName) + " IF NOT EXISTS;\n\n")
+
+	tables, err := queryColumn(db, "show tables")
+	if err != nil {
+		return err
+	}
+	sort.Strings(tables)
+
+	for _, table := range tables {
+		var clause string
+		err = db.QueryRow("show create table " + clickhouseQuoteIdentifier(table)).Scan(&clause)
+		if err != nil {
+			return err
+		}
+		buf.WriteString(clause + ";\n\n")
+	}
+	return nil
+}
+
+func clickhouseSchemaMigrationsDump(db *sql.DB, buf *bytes.Buffer) error {
+	// load applied migrations
+	migrations, err := queryColumn(db,
+		"select version from schema_migrations final where applied order by version asc",
+	)
+	if err != nil {
+		return err
+	}
+
+	quoter := strings.NewReplacer(`\`, `\\`, `'`, `\'`)
+	for i := range migrations {
+		migrations[i] = "'" + quoter.Replace(migrations[i]) + "'"
+	}
+
+	// build schema_migrations table data
+	buf.WriteString("\n--\n-- Dbmate schema migrations\n--\n\n")
+
+	if len(migrations) > 0 {
+		buf.WriteString("INSERT INTO schema_migrations (version) VALUES\n    (" +
+			strings.Join(migrations, "),\n    (") +
+			");\n")
+	}
+
+	return nil
+}
+
+// DumpSchema returns the current database schema
+func (drv ClickHouseDriver) DumpSchema(u *url.URL, db *sql.DB) ([]byte, error) {
+	var buf bytes.Buffer
+	var err error
+
+	err = clickhouseSchemaDump(db, &buf, drv.databaseName(u))
+	if err != nil {
+		return nil, err
+	}
+
+	err = clickhouseSchemaMigrationsDump(db, &buf)
+	if err != nil {
+		return nil, err
+	}
+
+	return buf.Bytes(), nil
+}
+
+// DatabaseExists determines whether the database exists
+func (drv ClickHouseDriver) DatabaseExists(u *url.URL) (bool, error) {
+	name := drv.databaseName(u)
+
+	db, err := drv.openClickHouseDB(u)
+	if err != nil {
+		return false, err
+	}
+	defer mustClose(db)
+
+	exists := false
+	err = db.QueryRow("SELECT 1 FROM system.databases where name = ?", name).
+		Scan(&exists)
+	if err == sql.ErrNoRows {
+		return false, nil
+	}
+
+	return exists, err
+}
+
+// CreateMigrationsTable creates the schema_migrations table
+func (drv ClickHouseDriver) CreateMigrationsTable(db *sql.DB) error {
+	_, err := db.Exec(`
+		create table if not exists schema_migrations (
+			version String,
+			ts DateTime default now(),
+			applied UInt8 default 1
+		) engine = ReplacingMergeTree(ts)
+		primary key version
+		order by version
+	`)
+	return err
+}
+
+// SelectMigrations returns a list of applied migrations
+// with an optional limit (in descending order)
+func (drv ClickHouseDriver) SelectMigrations(db *sql.DB, limit int) (map[string]bool, error) {
+	query := "select version from schema_migrations final where applied order by version desc"
+	if limit >= 0 {
+		query = fmt.Sprintf("%s limit %d", query, limit)
+	}
+	rows, err := db.Query(query)
+	if err != nil {
+		return nil, err
+	}
+
+	defer mustClose(rows)
+
+	migrations := map[string]bool{}
+	for rows.Next() {
+		var version string
+		if err := rows.Scan(&version); err != nil {
+			return nil, err
+		}
+
+		migrations[version] = true
+	}
+
+	return migrations, nil
+}
+
+// InsertMigration adds a new migration record
+func (drv ClickHouseDriver) InsertMigration(db Transaction, version string) error {
+	_, err := db.Exec("insert into schema_migrations (version) values (?)", version)
+	return err
+}
+
+// DeleteMigration removes a migration record
+func (drv ClickHouseDriver) DeleteMigration(db Transaction, version string) error {
+	_, err := db.Exec(
+		"insert into schema_migrations (version, applied) values (?, ?)",
+		version, false,
+	)
+
+	return err
+}
+
+// Ping verifies a connection to the database server. It does not verify whether the
+// specified database exists.
+func (drv ClickHouseDriver) Ping(u *url.URL) error {
+	// attempt connection to primary database, not "clickhouse" database
+	// to support servers with no "clickhouse" database
+	// (see https://github.com/amacneil/dbmate/issues/78)
+	db, err := drv.Open(u)
+	if err != nil {
+		return err
+	}
+	defer mustClose(db)
+
+	err = db.Ping()
+	if err == nil {
+		return nil
+	}
+
+	// ignore 'Database foo doesn't exist' error
+	chErr, ok := err.(*clickhouse.Exception)
+	if ok && chErr.Code == 81 {
+		return nil
+	}
+
+	return err
+}
diff --git a/pkg/dbmate/version.go b/pkg/dbmate/version.go
index 0450345..93532b0 100644
--- a/pkg/dbmate/version.go
+++ b/pkg/dbmate/version.go
@@ -1,4 +1,4 @@
 package dbmate
 
 // Version of dbmate
-const Version = "1.9.0"
+const Version = "1.9.1"


Test patch:
diff --git a/main_test.go b/main_test.go
index 1d20d06..d6922db 100644
--- a/main_test.go
+++ b/main_test.go
@@ -35,3 +35,23 @@ func TestGetDatabaseUrl(t *testing.T) {
 	require.Equal(t, "example.org", u.Host)
 	require.Equal(t, "/db", u.Path)
 }
+
+func TestRedactLogString(t *testing.T) {
+	examples := []struct {
+		in       string
+		expected string
+	}{
+		{"normal string",
+			"normal string"},
+		// malformed URL example (note forward slash in password)
+		{"parse \"mysql://username:otS33+tb/e4=@localhost:3306/database\": invalid",
+			"parse \"mysql://username:********@localhost:3306/database\": invalid"},
+		// invalid port, but probably not a password since there is no @
+		{"parse \"mysql://localhost:abc/database\": invalid",
+			"parse \"mysql://localhost:abc/database\": invalid"},
+	}
+
+	for _, ex := range examples {
+		require.Equal(t, ex.expected, redactLogString(ex.in))
+	}
+}
diff --git a/pkg/dbmate/clickhouse_test.go b/pkg/dbmate/clickhouse_test.go
new file mode 100644
index 0000000..48285aa
--- /dev/null
+++ b/pkg/dbmate/clickhouse_test.go
@@ -0,0 +1,305 @@
+package dbmate
+
+import (
+	"database/sql"
+	"net/url"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func clickhouseTestURL(t *testing.T) *url.URL {
+	u, err := url.Parse("clickhouse://clickhouse:9000?database=dbmate")
+	require.NoError(t, err)
+
+	return u
+}
+
+func prepTestClickHouseDB(t *testing.T) *sql.DB {
+	drv := ClickHouseDriver{}
+	u := clickhouseTestURL(t)
+
+	// drop any existing database
+	err := drv.DropDatabase(u)
+	require.NoError(t, err)
+
+	// create database
+	err = drv.CreateDatabase(u)
+	require.NoError(t, err)
+
+	// connect database
+	db, err := sql.Open("clickhouse", u.String())
+	require.NoError(t, err)
+
+	return db
+}
+
+func TestNormalizeClickHouseURLSimplified(t *testing.T) {
+	u, err := url.Parse("clickhouse://user:pass@host/db")
+	require.NoError(t, err)
+
+	s := normalizeClickHouseURL(u).String()
+	require.Equal(t, "tcp://host:9000?database=db&password=pass&username=user", s)
+}
+
+func TestNormalizeClickHouseURLCanonical(t *testing.T) {
+	u, err := url.Parse("clickhouse://host:9000?database=db&password=pass&username=user")
+	require.NoError(t, err)
+
+	s := normalizeClickHouseURL(u).String()
+	require.Equal(t, "tcp://host:9000?database=db&password=pass&username=user", s)
+}
+
+func TestClickHouseCreateDropDatabase(t *testing.T) {
+	drv := ClickHouseDriver{}
+	u := clickhouseTestURL(t)
+
+	// drop any existing database
+	err := drv.DropDatabase(u)
+	require.NoError(t, err)
+
+	// create database
+	err = drv.CreateDatabase(u)
+	require.NoError(t, err)
+
+	// check that database exists and we can connect to it
+	func() {
+		db, err := sql.Open("clickhouse", u.String())
+		require.NoError(t, err)
+		defer mustClose(db)
+
+		err = db.Ping()
+		require.NoError(t, err)
+	}()
+
+	// drop the database
+	err = drv.DropDatabase(u)
+	require.NoError(t, err)
+
+	// check that database no longer exists
+	func() {
+		db, err := sql.Open("clickhouse", u.String())
+		require.NoError(t, err)
+		defer mustClose(db)
+
+		err = db.Ping()
+		require.EqualError(t, err, "code: 81, message: Database dbmate doesn't exist")
+	}()
+}
+
+func TestClickHouseDumpSchema(t *testing.T) {
+	drv := ClickHouseDriver{}
+	u := clickhouseTestURL(t)
+
+	// prepare database
+	db := prepTestClickHouseDB(t)
+	defer mustClose(db)
+	err := drv.CreateMigrationsTable(db)
+	require.NoError(t, err)
+
+	// insert migration
+	tx, err := db.Begin()
+	require.NoError(t, err)
+	err = drv.InsertMigration(tx, "abc1")
+	require.NoError(t, err)
+	err = tx.Commit()
+	require.NoError(t, err)
+	tx, err = db.Begin()
+	require.NoError(t, err)
+	err = drv.InsertMigration(tx, "abc2")
+	require.NoError(t, err)
+	err = tx.Commit()
+	require.NoError(t, err)
+
+	// DumpSchema should return schema
+	schema, err := drv.DumpSchema(u, db)
+	require.NoError(t, err)
+	require.Contains(t, string(schema), "CREATE TABLE "+drv.databaseName(u)+".schema_migrations")
+	require.Contains(t, string(schema), "--\n"+
+		"-- Dbmate schema migrations\n"+
+		"--\n\n"+
+		"INSERT INTO schema_migrations (version) VALUES\n"+
+		"    ('abc1'),\n"+
+		"    ('abc2');\n")
+
+	// DumpSchema should return error if command fails
+	values := u.Query()
+	values.Set("database", "fakedb")
+	u.RawQuery = values.Encode()
+	db, err = sql.Open("clickhouse", u.String())
+	require.NoError(t, err)
+
+	schema, err = drv.DumpSchema(u, db)
+	require.Nil(t, schema)
+	require.EqualError(t, err, "code: 81, message: Database fakedb doesn't exist")
+}
+
+func TestClickHouseDatabaseExists(t *testing.T) {
+	drv := ClickHouseDriver{}
+	u := clickhouseTestURL(t)
+
+	// drop any existing database
+	err := drv.DropDatabase(u)
+	require.NoError(t, err)
+
+	// DatabaseExists should return false
+	exists, err := drv.DatabaseExists(u)
+	require.NoError(t, err)
+	require.Equal(t, false, exists)
+
+	// create database
+	err = drv.CreateDatabase(u)
+	require.NoError(t, err)
+
+	// DatabaseExists should return true
+	exists, err = drv.DatabaseExists(u)
+	require.NoError(t, err)
+	require.Equal(t, true, exists)
+}
+
+func TestClickHouseDatabaseExists_Error(t *testing.T) {
+	drv := ClickHouseDriver{}
+	u := clickhouseTestURL(t)
+	values := u.Query()
+	values.Set("username", "invalid")
+	u.RawQuery = values.Encode()
+
+	exists, err := drv.DatabaseExists(u)
+	require.EqualError(t, err, "code: 192, message: Unknown user invalid")
+	require.Equal(t, false, exists)
+}
+
+func TestClickHouseCreateMigrationsTable(t *testing.T) {
+	drv := ClickHouseDriver{}
+	db := prepTestClickHouseDB(t)
+	defer mustClose(db)
+
+	// migrations table should not exist
+	count := 0
+	err := db.QueryRow("select count(*) from schema_migrations").Scan(&count)
+	require.EqualError(t, err, "code: 60, message: Table dbmate.schema_migrations doesn't exist.")
+
+	// create table
+	err = drv.CreateMigrationsTable(db)
+	require.NoError(t, err)
+
+	// migrations table should exist
+	err = db.QueryRow("select count(*) from schema_migrations").Scan(&count)
+	require.NoError(t, err)
+
+	// create table should be idempotent
+	err = drv.CreateMigrationsTable(db)
+	require.NoError(t, err)
+}
+
+func TestClickHouseSelectMigrations(t *testing.T) {
+	drv := ClickHouseDriver{}
+	db := prepTestClickHouseDB(t)
+	defer mustClose(db)
+
+	err := drv.CreateMigrationsTable(db)
+	require.NoError(t, err)
+
+	tx, err := db.Begin()
+	require.NoError(t, err)
+	stmt, err := tx.Prepare("insert into schema_migrations (version) values (?)")
+	require.NoError(t, err)
+	_, err = stmt.Exec("abc2")
+	require.NoError(t, err)
+	_, err = stmt.Exec("abc1")
+	require.NoError(t, err)
+	_, err = stmt.Exec("abc3")
+	require.NoError(t, err)
+	err = tx.Commit()
+	require.NoError(t, err)
+
+	migrations, err := drv.SelectMigrations(db, -1)
+	require.NoError(t, err)
+	require.Equal(t, true, migrations["abc1"])
+	require.Equal(t, true, migrations["abc2"])
+	require.Equal(t, true, migrations["abc2"])
+
+	// test limit param
+	migrations, err = drv.SelectMigrations(db, 1)
+	require.NoError(t, err)
+	require.Equal(t, true, migrations["abc3"])
+	require.Equal(t, false, migrations["abc1"])
+	require.Equal(t, false, migrations["abc2"])
+}
+
+func TestClickHouseInsertMigration(t *testing.T) {
+	drv := ClickHouseDriver{}
+	db := prepTestClickHouseDB(t)
+	defer mustClose(db)
+
+	err := drv.CreateMigrationsTable(db)
+	require.NoError(t, err)
+
+	count := 0
+	err = db.QueryRow("select count(*) from schema_migrations").Scan(&count)
+	require.NoError(t, err)
+	require.Equal(t, 0, count)
+
+	// insert migration
+	tx, err := db.Begin()
+	require.NoError(t, err)
+	err = drv.InsertMigration(tx, "abc1")
+	require.NoError(t, err)
+	err = tx.Commit()
+	require.NoError(t, err)
+
+	err = db.QueryRow("select count(*) from schema_migrations where version = 'abc1'").Scan(&count)
+	require.NoError(t, err)
+	require.Equal(t, 1, count)
+}
+
+func TestClickHouseDeleteMigration(t *testing.T) {
+	drv := ClickHouseDriver{}
+	db := prepTestClickHouseDB(t)
+	defer mustClose(db)
+
+	err := drv.CreateMigrationsTable(db)
+	require.NoError(t, err)
+
+	tx, err := db.Begin()
+	require.NoError(t, err)
+	stmt, err := tx.Prepare("insert into schema_migrations (version) values (?)")
+	require.NoError(t, err)
+	_, err = stmt.Exec("abc2")
+	require.NoError(t, err)
+	_, err = stmt.Exec("abc1")
+	require.NoError(t, err)
+	err = tx.Commit()
+	require.NoError(t, err)
+
+	tx, err = db.Begin()
+	require.NoError(t, err)
+	err = drv.DeleteMigration(tx, "abc2")
+	require.NoError(t, err)
+	err = tx.Commit()
+	require.NoError(t, err)
+
+	count := 0
+	err = db.QueryRow("select count(*) from schema_migrations final where applied").Scan(&count)
+	require.NoError(t, err)
+	require.Equal(t, 1, count)
+}
+
+func TestClickHousePing(t *testing.T) {
+	drv := ClickHouseDriver{}
+	u := clickhouseTestURL(t)
+
+	// drop any existing database
+	err := drv.DropDatabase(u)
+	require.NoError(t, err)
+
+	// ping database
+	err = drv.Ping(u)
+	require.NoError(t, err)
+
+	// ping invalid host should return error
+	u.Host = "clickhouse:404"
+	err = drv.Ping(u)
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "connect: connection refused")
+}