├── internal ├── sqlparser │ ├── testdata │ │ ├── invalid │ │ │ └── up │ │ │ │ ├── d.sql │ │ │ │ ├── c.sql │ │ │ │ ├── b.sql │ │ │ │ └── a.sql │ │ ├── envsub │ │ │ ├── test01 │ │ │ │ ├── 02.up.golden.sql │ │ │ │ ├── 03.up.golden.sql │ │ │ │ ├── 04.up.golden.sql │ │ │ │ ├── 01.down.golden.sql │ │ │ │ ├── 01.up.golden.sql │ │ │ │ └── input.sql │ │ │ ├── test02 │ │ │ │ ├── 01.up.golden.sql │ │ │ │ ├── 03.up.golden.sql │ │ │ │ ├── 02.up.golden.sql │ │ │ │ └── input.sql │ │ │ └── test03 │ │ │ │ ├── 01.up.golden.sql │ │ │ │ └── input.sql │ │ ├── valid-up │ │ │ ├── test07 │ │ │ │ ├── 01.up.golden.sql │ │ │ │ └── input.sql │ │ │ ├── test08 │ │ │ │ ├── 04.up.golden.sql │ │ │ │ ├── 05.up.golden.sql │ │ │ │ ├── 06.up.golden.sql │ │ │ │ ├── 01.up.golden.sql │ │ │ │ ├── 02.up.golden.sql │ │ │ │ ├── 03.up.golden.sql │ │ │ │ └── input.sql │ │ │ ├── test06 │ │ │ │ ├── 01.up.golden.sql │ │ │ │ ├── 02.up.golden.sql │ │ │ │ ├── 03.up.golden.sql │ │ │ │ ├── 05.up.golden.sql │ │ │ │ ├── 04.up.golden.sql │ │ │ │ └── input.sql │ │ │ ├── test04 │ │ │ │ ├── 01.up.golden.sql │ │ │ │ ├── 02.up.golden.sql │ │ │ │ ├── 03.up.golden.sql │ │ │ │ └── input.sql │ │ │ ├── test01 │ │ │ │ ├── 03.up.golden.sql │ │ │ │ ├── 01.up.golden.sql │ │ │ │ ├── 02.up.golden.sql │ │ │ │ └── input.sql │ │ │ ├── test05 │ │ │ │ ├── 01.up.golden.sql │ │ │ │ ├── 02.up.golden.sql │ │ │ │ └── input.sql │ │ │ ├── test09 │ │ │ │ ├── 01.up.golden.sql │ │ │ │ └── input.sql │ │ │ ├── test02 │ │ │ │ ├── 01.up.golden.sql │ │ │ │ └── input.sql │ │ │ └── test03 │ │ │ │ ├── 01.up.golden.sql │ │ │ │ └── input.sql │ │ └── valid-txn │ │ │ ├── 00003_no_transaction.sql │ │ │ ├── 00002_rename_root.sql │ │ │ └── 00001_create_users_table.sql │ ├── parse.go │ └── parse_test.go ├── testing │ ├── integration │ │ ├── testdata │ │ │ └── migrations │ │ │ │ ├── mysql │ │ │ │ ├── 00004_empty.sql │ │ │ │ ├── 00005_no_tx.sql │ │ │ │ ├── 00003_alter.sql │ │ │ │ ├── 00002_insert.sql │ │ │ │ ├── 00001_table.sql │ │ │ │ └── 00006_complex.sql │ │ │ │ ├── turso │ │ │ │ ├── 00004_empty.sql │ │ │ │ ├── 00005_no_tx.sql │ │ │ │ ├── 00003_alter.sql │ │ │ │ ├── 00002_insert.sql │ │ │ │ └── 00001_table.sql │ │ │ │ ├── postgres │ │ │ │ ├── 00004_empty.sql │ │ │ │ ├── 00005_no_tx.sql │ │ │ │ ├── 00003_alter.sql │ │ │ │ ├── 00002_insert.sql │ │ │ │ ├── 00001_table.sql │ │ │ │ └── 00006_complex.sql │ │ │ │ ├── spanner │ │ │ │ ├── 00004_empty.sql │ │ │ │ ├── 00003_alter.sql │ │ │ │ ├── 00005_no_tx.sql │ │ │ │ ├── 00006_view.sql │ │ │ │ ├── 00001_table.sql │ │ │ │ └── 00002_insert.sql │ │ │ │ ├── starrocks │ │ │ │ ├── 00001_a.sql │ │ │ │ ├── 00003_c.sql │ │ │ │ └── 00002_b.sql │ │ │ │ ├── ydb │ │ │ │ ├── 00008_h.sql │ │ │ │ ├── 00002_b.sql │ │ │ │ ├── 00004_d.sql │ │ │ │ ├── 00005_e.sql │ │ │ │ ├── 00007_g.sql │ │ │ │ ├── 00006_f.sql │ │ │ │ ├── 00001_a.sql │ │ │ │ └── 00003_c.sql │ │ │ │ ├── clickhouse │ │ │ │ ├── 00002_b.sql │ │ │ │ ├── 00003_c.sql │ │ │ │ └── 00001_a.sql │ │ │ │ └── clickhouse-remote │ │ │ │ └── 00001_a.sql │ │ ├── README.md │ │ └── integration.go │ └── testdb │ │ ├── options.go │ │ ├── container_healthcheck.go │ │ ├── testdb.go │ │ ├── postgres.go │ │ ├── mariadb.go │ │ ├── turso.go │ │ └── starrocks.go ├── dialects │ ├── turso.go │ ├── spanner.go │ ├── tidb.go │ ├── sqlite3.go │ ├── redshift.go │ ├── sqlserver.go │ ├── vertica.go │ ├── clickhouse.go │ ├── starrocks.go │ ├── mysql.go │ ├── dsql.go │ ├── ydb.go │ └── postgres.go ├── migrationstats │ ├── migrationstats_walker.go │ ├── migration_sql.go │ └── migrationstats.go └── controller │ └── store.go ├── assets └── goose_logo.png ├── examples ├── README.md ├── sql-migrations │ ├── 00003_no_transaction.sql │ ├── 00002_rename_root.sql │ ├── 00001_create_users_table.sql │ └── README.md └── go-migrations │ ├── 00001_create_users_table.sql │ ├── 00002_rename_root.go │ ├── 00003_add_user_no_tx.go │ ├── main.go │ └── README.md ├── cmd └── goose │ ├── driver_mssql.go │ ├── driver_postgres.go │ ├── driver_vertica.go │ ├── driver_ydb.go │ ├── driver_sqlite3.go │ ├── driver_clickhouse.go │ ├── driver_turso.go │ ├── driver_no_mysql.go │ ├── main_test.go │ └── driver_mysql.go ├── tests └── gomigrations │ ├── success │ ├── testdata │ │ ├── 004_empty.go │ │ ├── 008_empty_no_tx.go │ │ ├── 012_empty_ctx.go │ │ ├── 016_empty_no_tx_ctx.go │ │ ├── 002_up_only.go │ │ ├── 003_down_only.go │ │ ├── 006_up_only_no_tx.go │ │ ├── 007_down_only_no_tx.go │ │ ├── 010_up_only_ctx.go │ │ ├── 011_down_only_ctx.go │ │ ├── 014_up_only_no_tx_ctx.go │ │ ├── 015_down_only_no_tx_ctx.go │ │ ├── 005_up_down_no_tx.go │ │ ├── 009_up_down_ctx.go │ │ ├── 013_up_down_no_tx_ctx.go │ │ └── 001_up_down.go │ └── gomigrations_success_test.go │ ├── register │ └── testdata │ │ ├── 001_addmigration.go │ │ ├── 002_addmigrationnotx.go │ │ ├── 003_addmigrationcontext.go │ │ └── 004_addmigrationnotxcontext.go │ └── error │ ├── testdata │ ├── 003_truncate.go │ ├── 001_up_no_tx.go │ ├── 004_ERROR_insert.go │ └── 002_ERROR_insert_no_tx.go │ └── gomigrations_error_test.go ├── testdata ├── no-versioning │ ├── migrations │ │ ├── 00001_a.sql │ │ ├── 00002_b.sql │ │ └── 00003_c.sql │ └── seed │ │ ├── 00002_b.sql │ │ └── 00001_a.sql ├── migrations │ ├── 00001_users_table.sql │ ├── 00005_posts_view.sql │ ├── 00002_posts_table.sql │ ├── 00003_comments_table.sql │ └── 00004_insert_data.sql └── testdata.go ├── .gitignore ├── .github ├── dependabot.yaml └── workflows │ ├── release.yaml │ ├── integration.yaml │ ├── lint.yaml │ └── ci.yaml ├── .golangci.yaml ├── database ├── doc.go ├── sql_extended.go ├── dialect │ ├── querier_extended.go │ └── querier.go ├── store_extended.go └── store.go ├── lock ├── internal │ ├── table │ │ └── config.go │ └── store │ │ └── store.go ├── locker.go ├── table_locker_options_test.go └── session_locker_options.go ├── helpers_test.go ├── osfs.go ├── up_test.go ├── log.go ├── .goreleaser.yaml ├── install.sh ├── LICENSE ├── fix.go ├── redo.go ├── version.go ├── create_test.go ├── scripts └── release-notes.sh ├── db.go ├── reset.go ├── goose_embed_test.go ├── provider_errors.go ├── helpers.go ├── status.go ├── dialect.go ├── provider_test.go ├── fix_test.go ├── provider_types.go ├── provider_options_test.go ├── create.go ├── migration_sql.go └── down.go /internal/sqlparser/testdata/invalid/up/d.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | SELECT * FROM bar 3 | -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/mysql/00004_empty.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/turso/00004_empty.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -------------------------------------------------------------------------------- /assets/goose_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pressly/goose/HEAD/assets/goose_logo.png -------------------------------------------------------------------------------- /internal/sqlparser/testdata/envsub/test01/02.up.golden.sql: -------------------------------------------------------------------------------- 1 | SELECT 2; -- 2nd stmt -------------------------------------------------------------------------------- /internal/sqlparser/testdata/envsub/test01/03.up.golden.sql: -------------------------------------------------------------------------------- 1 | SELECT 3; SELECT 3; -- 3rd stmt -------------------------------------------------------------------------------- /internal/sqlparser/testdata/envsub/test01/04.up.golden.sql: -------------------------------------------------------------------------------- 1 | SELECT 4; -- 4th stmt -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/postgres/00004_empty.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # 1. [SQL migrations](sql-migrations) 2 | # 2. [Go migrations](go-migrations) -------------------------------------------------------------------------------- /internal/sqlparser/testdata/envsub/test01/01.down.golden.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE us_east_post; -- 1st stmt -------------------------------------------------------------------------------- /internal/sqlparser/testdata/invalid/up/c.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | SELECT * FROM bar 3 | -- +goose Down 4 | -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-up/test07/01.up.golden.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX ON public.users (user_id); -------------------------------------------------------------------------------- /internal/sqlparser/testdata/invalid/up/b.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | SELECT * FROM bar 3 | -- +goose Down 4 | SELECT * FROM baz; 5 | -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-up/test08/04.up.golden.sql: -------------------------------------------------------------------------------- 1 | /*!80031 ALTER TABLE `table_a` MODIFY `column_1` TEXT NOT NULL */; -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-up/test08/05.up.golden.sql: -------------------------------------------------------------------------------- 1 | /*!80031 ALTER TABLE `table_b` MODIFY `column_2` TEXT NOT NULL */; -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-up/test08/06.up.golden.sql: -------------------------------------------------------------------------------- 1 | /*!80033 ALTER TABLE `table_c` MODIFY `column_3` TEXT NOT NULL */; -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-up/test06/01.up.golden.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE article ( 2 | id text, 3 | content text); -------------------------------------------------------------------------------- /cmd/goose/driver_mssql.go: -------------------------------------------------------------------------------- 1 | //go:build !no_mssql 2 | 3 | package main 4 | 5 | import ( 6 | _ "github.com/microsoft/go-mssqldb" 7 | ) 8 | -------------------------------------------------------------------------------- /cmd/goose/driver_postgres.go: -------------------------------------------------------------------------------- 1 | //go:build !no_postgres 2 | 3 | package main 4 | 5 | import ( 6 | _ "github.com/jackc/pgx/v5/stdlib" 7 | ) 8 | -------------------------------------------------------------------------------- /cmd/goose/driver_vertica.go: -------------------------------------------------------------------------------- 1 | //go:build !no_vertica 2 | 3 | package main 4 | 5 | import ( 6 | _ "github.com/vertica/vertica-sql-go" 7 | ) 8 | -------------------------------------------------------------------------------- /cmd/goose/driver_ydb.go: -------------------------------------------------------------------------------- 1 | //go:build !no_ydb 2 | 3 | package main 4 | 5 | import ( 6 | _ "github.com/ydb-platform/ydb-go-sdk/v3" 7 | ) 8 | -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/spanner/00004_empty.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- no-op 3 | 4 | -- +goose Down 5 | -- no-op 6 | -------------------------------------------------------------------------------- /internal/sqlparser/testdata/invalid/up/a.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | SELECT * FROM foo; 3 | SELECT * FROM bar 4 | -- +goose Down 5 | SELECT * FROM baz; 6 | -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-up/test04/01.up.golden.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE ssh_keys ( 2 | id integer NOT NULL, 3 | "publicKey" text 4 | ); -------------------------------------------------------------------------------- /cmd/goose/driver_sqlite3.go: -------------------------------------------------------------------------------- 1 | //go:build !no_sqlite3 && !(windows && arm64) 2 | 3 | package main 4 | 5 | import ( 6 | _ "modernc.org/sqlite" 7 | ) 8 | -------------------------------------------------------------------------------- /cmd/goose/driver_clickhouse.go: -------------------------------------------------------------------------------- 1 | //go:build !no_clickhouse 2 | 3 | package main 4 | 5 | import ( 6 | _ "github.com/ClickHouse/clickhouse-go/v2" 7 | ) 8 | -------------------------------------------------------------------------------- /cmd/goose/driver_turso.go: -------------------------------------------------------------------------------- 1 | //go:build !no_libsql 2 | 3 | package main 4 | 5 | import ( 6 | _ "github.com/tursodatabase/libsql-client-go/libsql" 7 | ) 8 | -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-up/test01/03.up.golden.sql: -------------------------------------------------------------------------------- 1 | CREATE TRIGGER emp_stamp BEFORE INSERT OR UPDATE ON emp 2 | FOR EACH ROW EXECUTE FUNCTION emp_stamp(); -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-up/test05/01.up.golden.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE ssh_keys ( 2 | id integer NOT NULL, 3 | "publicKey" text 4 | -- insert comment there 5 | ); -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-up/test01/01.up.golden.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE emp ( 2 | empname text, 3 | salary integer, 4 | last_date timestamp, 5 | last_user text 6 | ); -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-up/test06/02.up.golden.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO article (id, content) VALUES ('id_0001', E'# My markdown doc 2 | 3 | first paragraph 4 | 5 | second paragraph'); -------------------------------------------------------------------------------- /internal/sqlparser/testdata/envsub/test01/01.up.golden.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE us_east_post ( 2 | id int NOT NULL, 3 | title text, 4 | body text, 5 | PRIMARY KEY(id) 6 | ); -- 1st stmt -------------------------------------------------------------------------------- /internal/sqlparser/testdata/envsub/test02/01.up.golden.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE post ( 2 | id int NOT NULL, 3 | title text, 4 | foo text, 5 | footitle3 text, 6 | defaulttitle4 text, 7 | title5 text, 8 | ); -------------------------------------------------------------------------------- /tests/gomigrations/success/testdata/004_empty.go: -------------------------------------------------------------------------------- 1 | package gomigrations 2 | 3 | import ( 4 | "github.com/pressly/goose/v3" 5 | ) 6 | 7 | func init() { 8 | goose.AddMigration(nil, nil) 9 | } 10 | -------------------------------------------------------------------------------- /cmd/goose/driver_no_mysql.go: -------------------------------------------------------------------------------- 1 | //go:build no_mysql 2 | 3 | package main 4 | 5 | func normalizeDBString(driver string, str string, certfile string, sslcert string, sslkey string) string { 6 | return str 7 | } 8 | -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-up/test05/02.up.golden.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE ssh_keys_backup ( 2 | id integer NOT NULL, 3 | -- insert comment here 4 | "publicKey" text 5 | -- insert comment there 6 | ); -------------------------------------------------------------------------------- /tests/gomigrations/success/testdata/008_empty_no_tx.go: -------------------------------------------------------------------------------- 1 | package gomigrations 2 | 3 | import ( 4 | "github.com/pressly/goose/v3" 5 | ) 6 | 7 | func init() { 8 | goose.AddMigrationNoTx(nil, nil) 9 | } 10 | -------------------------------------------------------------------------------- /tests/gomigrations/success/testdata/012_empty_ctx.go: -------------------------------------------------------------------------------- 1 | package gomigrations 2 | 3 | import ( 4 | "github.com/pressly/goose/v3" 5 | ) 6 | 7 | func init() { 8 | goose.AddMigrationContext(nil, nil) 9 | } 10 | -------------------------------------------------------------------------------- /internal/sqlparser/testdata/envsub/test02/03.up.golden.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION test_func() 2 | RETURNS void AS $$ 3 | BEGIN 4 | RAISE NOTICE 'foo $GOOSE_ENV_NAME $GOOSE_ENV_NAME'; 5 | END; 6 | $$ LANGUAGE plpgsql; -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-up/test09/01.up.golden.sql: -------------------------------------------------------------------------------- 1 | create table t ( id int ); 2 | update rows set value = now() -- missing semicolon. valid statement because wrapped in goose annotation, but will fail when executed. -------------------------------------------------------------------------------- /tests/gomigrations/success/testdata/016_empty_no_tx_ctx.go: -------------------------------------------------------------------------------- 1 | package gomigrations 2 | 3 | import ( 4 | "github.com/pressly/goose/v3" 5 | ) 6 | 7 | func init() { 8 | goose.AddMigrationNoTxContext(nil, nil) 9 | } 10 | -------------------------------------------------------------------------------- /testdata/no-versioning/migrations/00001_a.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | CREATE TABLE owners ( 3 | owner_id INTEGER PRIMARY KEY AUTOINCREMENT, 4 | owner_name TEXT NOT NULL 5 | ); 6 | 7 | -- +goose Down 8 | DROP TABLE IF EXISTS owners; 9 | -------------------------------------------------------------------------------- /internal/sqlparser/testdata/envsub/test03/01.up.golden.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE post ( 2 | id int NOT NULL, 3 | title text, 4 | $NAME text, 5 | ${NAME}title3 text, 6 | ${ANOTHER_VAR:-default}title4 text, 7 | ${SET_BUT_EMPTY_VALUE-default}title5 text, 8 | ); -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/postgres/00005_no_tx.sql: -------------------------------------------------------------------------------- 1 | -- +goose NO TRANSACTION 2 | 3 | -- +goose Up 4 | CREATE UNIQUE INDEX CONCURRENTLY ON owners(owner_name); 5 | 6 | -- +goose Down 7 | DROP INDEX IF EXISTS owners_owner_name_idx; 8 | -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-up/test08/01.up.golden.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `table_a` ( 2 | `column_1` DATETIME DEFAULT NOW(), 3 | `column_2` DATETIME DEFAULT NOW(), 4 | `column_3` DATETIME DEFAULT NOW() 5 | ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-up/test08/02.up.golden.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `table_b` ( 2 | `column_1` DATETIME DEFAULT NOW(), 3 | `column_2` DATETIME DEFAULT NOW(), 4 | `column_3` DATETIME DEFAULT NOW() 5 | ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-up/test08/03.up.golden.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `table_c` ( 2 | `column_1` DATETIME DEFAULT NOW(), 3 | `column_2` DATETIME DEFAULT NOW(), 4 | `column_3` DATETIME DEFAULT NOW() 5 | ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; -------------------------------------------------------------------------------- /examples/sql-migrations/00003_no_transaction.sql: -------------------------------------------------------------------------------- 1 | -- +goose NO TRANSACTION 2 | -- +goose Up 3 | CREATE TABLE post ( 4 | id int NOT NULL, 5 | title text, 6 | body text, 7 | PRIMARY KEY(id) 8 | ); 9 | 10 | -- +goose Down 11 | DROP TABLE post; 12 | -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-up/test06/03.up.golden.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO article (id, content) VALUES ('id_0002', E'# My second 2 | 3 | markdown doc 4 | 5 | first paragraph 6 | 7 | -- with a comment 8 | -- with an indent comment 9 | 10 | second paragraph'); -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/mysql/00005_no_tx.sql: -------------------------------------------------------------------------------- 1 | -- +goose NO TRANSACTION 2 | 3 | -- +goose Up 4 | CREATE UNIQUE INDEX owners_owner_name_idx ON owners(owner_name); 5 | 6 | -- +goose Down 7 | DROP INDEX IF EXISTS owners_owner_name_idx ON owners; 8 | 9 | -------------------------------------------------------------------------------- /internal/sqlparser/testdata/envsub/test03/input.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | CREATE TABLE post ( 3 | id int NOT NULL, 4 | title text, 5 | $NAME text, 6 | ${NAME}title3 text, 7 | ${ANOTHER_VAR:-default}title4 text, 8 | ${SET_BUT_EMPTY_VALUE-default}title5 text, 9 | ); 10 | -------------------------------------------------------------------------------- /internal/sqlparser/testdata/envsub/test02/02.up.golden.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE post ( 2 | id int NOT NULL, 3 | title text, 4 | $GOOSE_ENV_NAME text, 5 | ${GOOSE_ENV_NAME}title3 text, 6 | ${ANOTHER_VAR:-default}title4 text, 7 | ${GOOSE_ENV_SET_BUT_EMPTY_VALUE-default}title5 text, 8 | ); -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-txn/00003_no_transaction.sql: -------------------------------------------------------------------------------- 1 | -- +goose NO TRANSACTION 2 | -- +goose Up 3 | CREATE TABLE post ( 4 | id int NOT NULL, 5 | title text, 6 | body text, 7 | PRIMARY KEY(id) 8 | ); 9 | 10 | -- +goose Down 11 | DROP TABLE post; 12 | -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-up/test06/05.up.golden.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO post (id, title, body) 2 | VALUES ('id_01', 'my_title', ' 3 | this is an insert statement including empty lines. 4 | 5 | empty (blank) lines can be meaningful. 6 | 7 | leave the lines to keep the text syntax. 8 | '); -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/turso/00005_no_tx.sql: -------------------------------------------------------------------------------- 1 | -- +goose NO TRANSACTION 2 | 3 | -- +goose Up 4 | CREATE UNIQUE INDEX IF NOT EXISTS idx_owners_owner_name ON owners(owner_name); 5 | 6 | 7 | -- +goose Down 8 | DROP INDEX IF EXISTS idx_owners_owner_name; 9 | 10 | -------------------------------------------------------------------------------- /testdata/migrations/00001_users_table.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | CREATE TABLE users ( 3 | id INTEGER PRIMARY KEY, 4 | username TEXT NOT NULL, 5 | email TEXT NOT NULL, 6 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 7 | ); 8 | 9 | -- +goose Down 10 | DROP TABLE users; 11 | -------------------------------------------------------------------------------- /testdata/no-versioning/migrations/00002_b.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | INSERT INTO owners(owner_name) VALUES ('lucas'), ('ocean'); 4 | -- +goose StatementEnd 5 | 6 | -- +goose Down 7 | -- +goose StatementBegin 8 | DELETE FROM owners; 9 | -- +goose StatementEnd 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | .DS_Store 4 | *.swp 5 | *.test 6 | 7 | # Files output by tests 8 | /bin 9 | 10 | # Coverage files 11 | coverage.out 12 | coverage.html 13 | 14 | # Local testing 15 | .envrc 16 | *.FAIL 17 | 18 | dist/ 19 | release_notes.txt 20 | 21 | go.work 22 | go.work.sum 23 | -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/starrocks/00001_a.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE SCHEMA IF NOT EXISTS testing; 4 | -- +goose StatementEnd 5 | 6 | -- +goose Down 7 | -- +goose StatementBegin 8 | DROP SCHEMA IF EXISTS testing; 9 | -- +goose StatementEnd 10 | -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-up/test07/input.sql: -------------------------------------------------------------------------------- 1 | 2 | -- +goose Up 3 | -- +goose StatementBegin 4 | CREATE INDEX ON public.users (user_id); 5 | -- +goose StatementEnd 6 | 7 | -- +goose Down 8 | -- +goose StatementBegin 9 | DROP INDEX IF EXISTS users_user_id_idx; 10 | -- +goose StatementEnd 11 | -------------------------------------------------------------------------------- /tests/gomigrations/success/testdata/002_up_only.go: -------------------------------------------------------------------------------- 1 | package gomigrations 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/pressly/goose/v3" 7 | ) 8 | 9 | func init() { 10 | goose.AddMigration(up002, nil) 11 | } 12 | 13 | func up002(tx *sql.Tx) error { 14 | return createTable(tx, "bravo") 15 | } 16 | -------------------------------------------------------------------------------- /tests/gomigrations/register/testdata/001_addmigration.go: -------------------------------------------------------------------------------- 1 | package register 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/pressly/goose/v3" 7 | ) 8 | 9 | func init() { 10 | goose.AddMigration( 11 | func(_ *sql.Tx) error { return nil }, 12 | func(_ *sql.Tx) error { return nil }, 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /tests/gomigrations/success/testdata/003_down_only.go: -------------------------------------------------------------------------------- 1 | package gomigrations 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/pressly/goose/v3" 7 | ) 8 | 9 | func init() { 10 | goose.AddMigration(nil, down003) 11 | } 12 | 13 | func down003(tx *sql.Tx) error { 14 | return dropTable(tx, "bravo") 15 | } 16 | -------------------------------------------------------------------------------- /examples/sql-migrations/00002_rename_root.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | UPDATE users SET username='admin' WHERE username='root'; 4 | -- +goose StatementEnd 5 | 6 | -- +goose Down 7 | -- +goose StatementBegin 8 | UPDATE users SET username='root' WHERE username='admin'; 9 | -- +goose StatementEnd 10 | -------------------------------------------------------------------------------- /tests/gomigrations/register/testdata/002_addmigrationnotx.go: -------------------------------------------------------------------------------- 1 | package register 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/pressly/goose/v3" 7 | ) 8 | 9 | func init() { 10 | goose.AddMigrationNoTx( 11 | func(_ *sql.DB) error { return nil }, 12 | func(_ *sql.DB) error { return nil }, 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /tests/gomigrations/success/testdata/006_up_only_no_tx.go: -------------------------------------------------------------------------------- 1 | package gomigrations 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/pressly/goose/v3" 7 | ) 8 | 9 | func init() { 10 | goose.AddMigrationNoTx(up006, nil) 11 | } 12 | 13 | func up006(db *sql.DB) error { 14 | return createTable(db, "delta") 15 | } 16 | -------------------------------------------------------------------------------- /testdata/no-versioning/migrations/00003_c.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | INSERT INTO owners(owner_name) VALUES ('james'), ('space'); 4 | -- +goose StatementEnd 5 | 6 | -- +goose Down 7 | -- +goose StatementBegin 8 | DELETE FROM owners WHERE owner_name IN ('james', 'space'); 9 | -- +goose StatementEnd 10 | -------------------------------------------------------------------------------- /tests/gomigrations/success/testdata/007_down_only_no_tx.go: -------------------------------------------------------------------------------- 1 | package gomigrations 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/pressly/goose/v3" 7 | ) 8 | 9 | func init() { 10 | goose.AddMigrationNoTx(nil, down007) 11 | } 12 | 13 | func down007(db *sql.DB) error { 14 | return dropTable(db, "delta") 15 | } 16 | -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-txn/00002_rename_root.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | UPDATE users SET username='admin' WHERE username='root'; 4 | -- +goose StatementEnd 5 | 6 | -- +goose Down 7 | -- +goose StatementBegin 8 | UPDATE users SET username='root' WHERE username='admin'; 9 | -- +goose StatementEnd 10 | -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-up/test09/input.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | create table t ( id int ); 4 | update rows set value = now() -- missing semicolon. valid statement because wrapped in goose annotation, but will fail when executed. 5 | -- +goose StatementEnd 6 | 7 | -- +goose Down 8 | DROP TABLE IF EXISTS t; 9 | -------------------------------------------------------------------------------- /internal/dialects/turso.go: -------------------------------------------------------------------------------- 1 | package dialects 2 | 3 | import "github.com/pressly/goose/v3/database/dialect" 4 | 5 | // NewTurso returns a [dialect.Querier] for Turso dialect. 6 | func NewTurso() dialect.Querier { 7 | return &turso{} 8 | } 9 | 10 | type turso struct { 11 | sqlite3 12 | } 13 | 14 | var _ dialect.Querier = (*turso)(nil) 15 | -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/ydb/00008_h.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | ALTER TABLE stargazers DROP COLUMN stargazer_location; 4 | -- +goose StatementEnd 5 | 6 | -- +goose Down 7 | -- +goose StatementBegin 8 | ALTER TABLE stargazers ADD COLUMN stargazer_location Utf8; 9 | -- +goose StatementEnd 10 | -------------------------------------------------------------------------------- /tests/gomigrations/error/testdata/003_truncate.go: -------------------------------------------------------------------------------- 1 | package gomigrations 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/pressly/goose/v3" 7 | ) 8 | 9 | func init() { 10 | goose.AddMigration(up003, nil) 11 | } 12 | 13 | func up003(tx *sql.Tx) error { 14 | q := "DELETE FROM foo" 15 | _, err := tx.Exec(q) 16 | return err 17 | } 18 | -------------------------------------------------------------------------------- /examples/sql-migrations/00001_create_users_table.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | CREATE TABLE users ( 3 | id int NOT NULL PRIMARY KEY, 4 | username text, 5 | name text, 6 | surname text 7 | ); 8 | 9 | INSERT INTO users VALUES 10 | (0, 'root', '', ''), 11 | (1, 'vojtechvitek', 'Vojtech', 'Vitek'); 12 | 13 | -- +goose Down 14 | DROP TABLE users; 15 | -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/spanner/00003_alter.sql: -------------------------------------------------------------------------------- 1 | -- +goose NO TRANSACTION 2 | -- +goose Up 3 | -- +goose StatementBegin 4 | ALTER TABLE owners ADD COLUMN homepage_url STRING(255) 5 | -- +goose StatementEnd 6 | 7 | -- +goose Down 8 | -- +goose StatementBegin 9 | ALTER TABLE owners DROP COLUMN homepage_url 10 | -- +goose StatementEnd 11 | -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/ydb/00002_b.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | INSERT INTO owners(owner_id, owner_name, owner_type) 4 | VALUES (1, 'lucas', 'user'), (2, 'space', 'organization'); 5 | -- +goose StatementEnd 6 | 7 | -- +goose Down 8 | -- +goose StatementBegin 9 | DELETE FROM owners; 10 | -- +goose StatementEnd 11 | -------------------------------------------------------------------------------- /tests/gomigrations/success/testdata/010_up_only_ctx.go: -------------------------------------------------------------------------------- 1 | package gomigrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | "github.com/pressly/goose/v3" 8 | ) 9 | 10 | func init() { 11 | goose.AddMigrationContext(up010, nil) 12 | } 13 | 14 | func up010(ctx context.Context, tx *sql.Tx) error { 15 | return createTable(tx, "foxtrot") 16 | } 17 | -------------------------------------------------------------------------------- /examples/go-migrations/00001_create_users_table.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | CREATE TABLE users ( 3 | id INTEGER PRIMARY KEY AUTOINCREMENT, 4 | username TEXT, 5 | name TEXT, 6 | surname TEXT 7 | ); 8 | 9 | INSERT INTO users VALUES 10 | (0, 'root', '', ''), 11 | (1, 'vojtechvitek', 'Vojtech', 'Vitek'); 12 | 13 | -- +goose Down 14 | DROP TABLE users; 15 | -------------------------------------------------------------------------------- /tests/gomigrations/error/testdata/001_up_no_tx.go: -------------------------------------------------------------------------------- 1 | package gomigrations 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/pressly/goose/v3" 7 | ) 8 | 9 | func init() { 10 | goose.AddMigrationNoTx(up001, nil) 11 | } 12 | 13 | func up001(db *sql.DB) error { 14 | q := "CREATE TABLE foo (id INTEGER)" 15 | _, err := db.Exec(q) 16 | return err 17 | } 18 | -------------------------------------------------------------------------------- /tests/gomigrations/success/testdata/011_down_only_ctx.go: -------------------------------------------------------------------------------- 1 | package gomigrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | "github.com/pressly/goose/v3" 8 | ) 9 | 10 | func init() { 11 | goose.AddMigrationContext(nil, down011) 12 | } 13 | 14 | func down011(ctx context.Context, tx *sql.Tx) error { 15 | return dropTable(tx, "foxtrot") 16 | } 17 | -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-txn/00001_create_users_table.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | CREATE TABLE users ( 3 | id int NOT NULL PRIMARY KEY, 4 | username text, 5 | name text, 6 | surname text 7 | ); 8 | 9 | INSERT INTO users VALUES 10 | (0, 'root', '', ''), 11 | (1, 'vojtechvitek', 'Vojtech', 'Vitek'); 12 | 13 | -- +goose Down 14 | DROP TABLE users; 15 | -------------------------------------------------------------------------------- /tests/gomigrations/success/testdata/014_up_only_no_tx_ctx.go: -------------------------------------------------------------------------------- 1 | package gomigrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | "github.com/pressly/goose/v3" 8 | ) 9 | 10 | func init() { 11 | goose.AddMigrationNoTxContext(up014, nil) 12 | } 13 | 14 | func up014(ctx context.Context, db *sql.DB) error { 15 | return createTable(db, "hotel") 16 | } 17 | -------------------------------------------------------------------------------- /internal/testing/testdb/options.go: -------------------------------------------------------------------------------- 1 | package testdb 2 | 3 | type options struct { 4 | bindPort int 5 | debug bool 6 | } 7 | 8 | type OptionsFunc func(o *options) 9 | 10 | func WithBindPort(n int) OptionsFunc { 11 | return func(o *options) { o.bindPort = n } 12 | } 13 | 14 | func WithDebug(b bool) OptionsFunc { 15 | return func(o *options) { o.debug = b } 16 | } 17 | -------------------------------------------------------------------------------- /testdata/migrations/00005_posts_view.sql: -------------------------------------------------------------------------------- 1 | -- +goose NO TRANSACTION 2 | 3 | -- +goose Up 4 | CREATE VIEW posts_view AS 5 | SELECT 6 | p.id, 7 | p.title, 8 | p.content, 9 | p.created_at, 10 | u.username AS author 11 | FROM posts p 12 | JOIN users u ON p.author_id = u.id; 13 | 14 | -- +goose Down 15 | DROP VIEW posts_view; 16 | -------------------------------------------------------------------------------- /tests/gomigrations/success/testdata/015_down_only_no_tx_ctx.go: -------------------------------------------------------------------------------- 1 | package gomigrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | "github.com/pressly/goose/v3" 8 | ) 9 | 10 | func init() { 11 | goose.AddMigrationNoTxContext(nil, down015) 12 | } 13 | 14 | func down015(ctx context.Context, db *sql.DB) error { 15 | return dropTable(db, "hotel") 16 | } 17 | -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/spanner/00005_no_tx.sql: -------------------------------------------------------------------------------- 1 | -- +goose NO TRANSACTION 2 | 3 | -- +goose Up 4 | -- +goose StatementBegin 5 | CREATE UNIQUE NULL_FILTERED INDEX owners_owner_name_idx ON owners (owner_name) 6 | -- +goose StatementEnd 7 | 8 | -- +goose Down 9 | -- +goose StatementBegin 10 | DROP INDEX owners_owner_name_idx 11 | -- +goose StatementEnd 12 | -------------------------------------------------------------------------------- /tests/gomigrations/register/testdata/003_addmigrationcontext.go: -------------------------------------------------------------------------------- 1 | package register 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | "github.com/pressly/goose/v3" 8 | ) 9 | 10 | func init() { 11 | goose.AddMigrationContext( 12 | func(_ context.Context, _ *sql.Tx) error { return nil }, 13 | func(_ context.Context, _ *sql.Tx) error { return nil }, 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /tests/gomigrations/register/testdata/004_addmigrationnotxcontext.go: -------------------------------------------------------------------------------- 1 | package register 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | "github.com/pressly/goose/v3" 8 | ) 9 | 10 | func init() { 11 | goose.AddMigrationNoTxContext( 12 | func(_ context.Context, _ *sql.DB) error { return nil }, 13 | func(_ context.Context, _ *sql.DB) error { return nil }, 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/ydb/00004_d.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | ALTER TABLE repos 4 | ADD COLUMN homepage_url Utf8, 5 | ADD COLUMN is_private Bool; 6 | -- +goose StatementEnd 7 | 8 | -- +goose Down 9 | -- +goose StatementBegin 10 | ALTER TABLE repos 11 | DROP COLUMN homepage_url, 12 | DROP COLUMN is_private; 13 | -- +goose StatementEnd 14 | -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/ydb/00005_e.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | -- NOTE: intentionally left blank to verify migration logic. 4 | SELECT 'up SQL query'; 5 | -- +goose StatementEnd 6 | 7 | -- +goose Down 8 | -- +goose StatementBegin 9 | -- NOTE: intentionally left blank to verify migration logic. 10 | SELECT 'down SQL query'; 11 | -- +goose StatementEnd 12 | -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/clickhouse/00002_b.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | CREATE TABLE IF NOT EXISTS clickstream ( 3 | customer_id String, 4 | time_stamp Date, 5 | click_event_type String, 6 | country_code FixedString(2), 7 | source_id UInt64 8 | ) 9 | ENGINE = MergeTree() 10 | ORDER BY (time_stamp); 11 | 12 | -- +goose Down 13 | DROP TABLE IF EXISTS clickstream; 14 | -------------------------------------------------------------------------------- /tests/gomigrations/success/testdata/005_up_down_no_tx.go: -------------------------------------------------------------------------------- 1 | package gomigrations 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/pressly/goose/v3" 7 | ) 8 | 9 | func init() { 10 | goose.AddMigrationNoTx(up005, down005) 11 | } 12 | 13 | func up005(db *sql.DB) error { 14 | return createTable(db, "charlie") 15 | } 16 | 17 | func down005(db *sql.DB) error { 18 | return dropTable(db, "charlie") 19 | } 20 | -------------------------------------------------------------------------------- /testdata/migrations/00002_posts_table.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE TABLE posts ( 4 | id INTEGER PRIMARY KEY, 5 | title TEXT NOT NULL, 6 | content TEXT NOT NULL, 7 | author_id INTEGER NOT NULL, 8 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 9 | FOREIGN KEY (author_id) REFERENCES users(id) 10 | ); 11 | -- +goose StatementEnd 12 | 13 | -- +goose Down 14 | DROP TABLE posts; 15 | -------------------------------------------------------------------------------- /testdata/migrations/00003_comments_table.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | CREATE TABLE comments ( 3 | id INTEGER PRIMARY KEY, 4 | post_id INTEGER NOT NULL, 5 | user_id INTEGER NOT NULL, 6 | content TEXT NOT NULL, 7 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 8 | FOREIGN KEY (post_id) REFERENCES posts(id), 9 | FOREIGN KEY (user_id) REFERENCES users(id) 10 | ); 11 | 12 | -- +goose Down 13 | DROP TABLE comments; 14 | -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/clickhouse/00003_c.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | INSERT INTO clickstream VALUES ('customer1', '2021-10-02', 'add_to_cart', 'US', 568239 ); 3 | 4 | INSERT INTO clickstream (customer_id, time_stamp, click_event_type) VALUES ('customer2', '2021-10-30', 'remove_from_cart' ); 5 | 6 | INSERT INTO clickstream (* EXCEPT(country_code)) VALUES ('customer3', '2021-11-07', 'checkout', 307493 ); 7 | 8 | -- +goose Down 9 | -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/spanner/00006_view.sql: -------------------------------------------------------------------------------- 1 | -- +goose NO TRANSACTION 2 | 3 | -- +goose Up 4 | -- +goose StatementBegin 5 | CREATE VIEW view_owners 6 | SQL SECURITY INVOKER AS 7 | SELECT 8 | owners.owner_id, 9 | owners.owner_name, 10 | owners.owner_type 11 | FROM owners 12 | -- +goose StatementEnd 13 | 14 | -- +goose Down 15 | -- +goose StatementBegin 16 | DROP VIEW view_owners 17 | -- +goose StatementEnd 18 | -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-up/test06/04.up.golden.sql: -------------------------------------------------------------------------------- 1 | CREATE FUNCTION do_something(sql TEXT) RETURNS INTEGER AS $$ 2 | BEGIN 3 | -- initiate technology 4 | PERFORM something_or_other(sql); 5 | 6 | -- increase technology 7 | PERFORM some_other_thing(sql); 8 | 9 | -- technology was successful 10 | RETURN 1; 11 | END; 12 | $$ LANGUAGE plpgsql; 13 | 14 | -- 3 this comment WILL BE preserved 15 | -- 4 this comment WILL BE preserved -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/spanner/00001_table.sql: -------------------------------------------------------------------------------- 1 | -- +goose NO TRANSACTION 2 | -- +goose Up 3 | -- +goose StatementBegin 4 | CREATE TABLE owners ( 5 | owner_id INT64 NOT NULL, 6 | owner_name STRING(255) NOT NULL, 7 | owner_type STRING(50) NOT NULL, 8 | ) PRIMARY KEY(owner_id) 9 | -- +goose StatementEnd 10 | 11 | -- +goose Down 12 | -- +goose StatementBegin 13 | DROP TABLE owners 14 | -- +goose StatementEnd 15 | -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/postgres/00003_alter.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | ALTER TABLE repos 4 | ADD COLUMN IF NOT EXISTS homepage_url text, 5 | ADD COLUMN is_private boolean NOT NULL DEFAULT false; 6 | -- +goose StatementEnd 7 | 8 | -- +goose Down 9 | -- +goose StatementBegin 10 | ALTER TABLE repos 11 | DROP COLUMN IF EXISTS homepage_url, 12 | DROP COLUMN is_private; 13 | -- +goose StatementEnd 14 | -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/turso/00003_alter.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | ALTER TABLE repos 4 | ADD COLUMN homepage_url TEXT; 5 | 6 | ALTER TABLE repos 7 | ADD COLUMN is_private BOOLEAN DEFAULT 0; 8 | -- +goose StatementEnd 9 | 10 | -- +goose Down 11 | -- +goose StatementBegin 12 | ALTER TABLE repos 13 | DROP COLUMN homepage_url; 14 | 15 | ALTER TABLE repos 16 | DROP COLUMN is_private; 17 | -- +goose StatementEnd 18 | -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/ydb/00007_g.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE TABLE issues ( 4 | issue_id Uint64, 5 | issue_created_by Uint64, 6 | issue_repo_id Uint64, 7 | issue_created_at Timestamp, 8 | issue_description Utf8, 9 | PRIMARY KEY (issue_id) 10 | ); 11 | -- +goose StatementEnd 12 | 13 | -- +goose Down 14 | -- +goose StatementBegin 15 | DROP TABLE issues; 16 | -- +goose StatementEnd 17 | -------------------------------------------------------------------------------- /tests/gomigrations/success/testdata/009_up_down_ctx.go: -------------------------------------------------------------------------------- 1 | package gomigrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | "github.com/pressly/goose/v3" 8 | ) 9 | 10 | func init() { 11 | goose.AddMigrationContext(up009, down009) 12 | } 13 | 14 | func up009(ctx context.Context, tx *sql.Tx) error { 15 | return createTable(tx, "echo") 16 | } 17 | 18 | func down009(ctx context.Context, tx *sql.Tx) error { 19 | return dropTable(tx, "echo") 20 | } 21 | -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/spanner/00002_insert.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | INSERT INTO owners (owner_id, owner_name, owner_type) VALUES 4 | (1, 'lucas', 'user'), 5 | (2, 'space', 'organization'), 6 | (3, 'james', 'user'), 7 | (4, 'pressly', 'organization'); 8 | -- +goose StatementEnd 9 | 10 | -- +goose Down 11 | -- +goose StatementBegin 12 | DELETE FROM owners WHERE owner_id IS NOT NULL 13 | -- +goose StatementEnd 14 | -------------------------------------------------------------------------------- /internal/sqlparser/testdata/envsub/test01/input.sql: -------------------------------------------------------------------------------- 1 | -- +goose ENVSUB ON 2 | -- +goose Up 3 | CREATE TABLE ${GOOSE_ENV_REGION}post ( 4 | id int NOT NULL, 5 | title text, 6 | body text, 7 | PRIMARY KEY(id) 8 | ); -- 1st stmt 9 | 10 | -- comment 11 | SELECT 2; -- 2nd stmt 12 | SELECT 3; SELECT 3; -- 3rd stmt 13 | SELECT 4; -- 4th stmt 14 | 15 | -- +goose Down 16 | -- comment 17 | DROP TABLE ${GOOSE_ENV_REGION}post; -- 1st stmt 18 | -------------------------------------------------------------------------------- /tests/gomigrations/success/testdata/013_up_down_no_tx_ctx.go: -------------------------------------------------------------------------------- 1 | package gomigrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | "github.com/pressly/goose/v3" 8 | ) 9 | 10 | func init() { 11 | goose.AddMigrationNoTxContext(up013, down013) 12 | } 13 | 14 | func up013(ctx context.Context, db *sql.DB) error { 15 | return createTable(db, "golf") 16 | } 17 | 18 | func down013(ctx context.Context, db *sql.DB) error { 19 | return dropTable(db, "golf") 20 | } 21 | -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/ydb/00006_f.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE TABLE stargazers ( 4 | stargazer_repo_id Uint64, 5 | stargazer_owner_id UInt64, 6 | stargazer_starred_at Timestamp, 7 | stargazer_location Utf8, 8 | PRIMARY KEY (stargazer_repo_id, stargazer_owner_id) 9 | ); 10 | -- +goose StatementEnd 11 | 12 | -- +goose Down 13 | -- +goose StatementBegin 14 | DROP TABLE stargazers; 15 | -- +goose StatementEnd 16 | -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-up/test05/input.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | 3 | CREATE TABLE ssh_keys ( 4 | id integer NOT NULL, 5 | "publicKey" text 6 | -- insert comment there 7 | ); 8 | -- insert comment there 9 | 10 | -- This is a dangling comment 11 | -- Another comment 12 | -- Foo comment 13 | 14 | CREATE TABLE ssh_keys_backup ( 15 | id integer NOT NULL, 16 | -- insert comment here 17 | "publicKey" text 18 | -- insert comment there 19 | ); 20 | 21 | 22 | -- +goose Down 23 | -------------------------------------------------------------------------------- /testdata/no-versioning/seed/00002_b.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | 3 | -- Insert 150 more owners. 4 | INSERT INTO owners (owner_name) 5 | WITH numbers AS ( 6 | SELECT 101 AS n 7 | UNION ALL 8 | SELECT n + 1 FROM numbers WHERE n < 250 9 | ) 10 | SELECT 'seed-user-' || n FROM numbers; 11 | 12 | -- +goose Down 13 | 14 | -- NOTE: there are 4 migration owners and 100 seed owners, that's why owner_id starts at 105 15 | DELETE FROM owners WHERE owner_name LIKE 'seed-user-%' AND owner_id BETWEEN 105 AND 254; 16 | -------------------------------------------------------------------------------- /testdata/no-versioning/seed/00001_a.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | -- Insert 100 owners. 4 | INSERT INTO owners (owner_name) 5 | WITH numbers AS ( 6 | SELECT 1 AS n 7 | UNION ALL 8 | SELECT n + 1 FROM numbers WHERE n < 100 9 | ) 10 | SELECT 'seed-user-' || n FROM numbers; 11 | -- +goose StatementEnd 12 | 13 | -- +goose Down 14 | -- +goose StatementBegin 15 | -- Delete the previously inserted data. 16 | DELETE FROM owners WHERE owner_name LIKE 'seed-user-%'; 17 | -- +goose StatementEnd 18 | -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/mysql/00003_alter.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | ALTER TABLE repos 4 | ADD COLUMN IF NOT EXISTS homepage_url TEXT; 5 | 6 | ALTER TABLE repos 7 | ADD COLUMN is_private BOOLEAN NOT NULL DEFAULT FALSE; 8 | -- +goose StatementEnd 9 | 10 | -- +goose Down 11 | -- +goose StatementBegin 12 | ALTER TABLE repos 13 | DROP COLUMN IF EXISTS homepage_url; 14 | 15 | ALTER TABLE repos 16 | DROP COLUMN IF EXISTS is_private; 17 | -- +goose StatementEnd 18 | -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-up/test04/02.up.golden.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO ssh_keys (id, "publicKey") VALUES (1, '-----BEGIN RSA PUBLIC KEY----- 2 | MIIBCgKCAQEAqAo9QORIXMPMa/qv8908Z2sH2+Xa/wITYTJQ2ojTZlgsQiQf85ifw3dgvVZK 3 | M7Zifl2NyVVCPb0hELr2JJla1u/1CgiuqDpcjP2cCu2YxB/JGyCvcon+3tETUz3Ri9NGzHCZ 4 | fkuWRZjkUvy7nfPLjzM+t6SEvY4lbn3ihLPumZjwgvuCY3vDZY8V1/NMoP8MKATGR+S7D7gv 5 | I6KD9jkiSsTJMiotb/dRkXE3bG0nmjchhhLzMG551G8IZEpWBHDqEisCIl8yCd9YZV69BZTu 6 | L48zPl/CFvA+KJJ6LklxfwWeVDQ+ve2OIW0B1uLhR/MsoYbDQztbgIayg6ieMO/KlQIDAQAB 7 | -----END RSA PUBLIC KEY----- 8 | '); -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/ydb/00001_a.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE TABLE owners ( 4 | owner_id Uint64, 5 | owner_name Utf8, 6 | owner_type Utf8, 7 | PRIMARY KEY (owner_id) 8 | ); 9 | CREATE TABLE repos ( 10 | repo_id Uint64, 11 | repo_owner_id Uint64, 12 | repo_full_name Utf8, 13 | PRIMARY KEY (repo_id) 14 | ); 15 | -- +goose StatementEnd 16 | 17 | -- +goose Down 18 | -- +goose StatementBegin 19 | DROP TABLE repos; 20 | DROP TABLE owners; 21 | -- +goose StatementEnd 22 | -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/clickhouse-remote/00001_a.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | CREATE DICTIONARY taxi_zone_dictionary ( 3 | LocationID UInt16 DEFAULT 0, 4 | Borough String, 5 | Zone String, 6 | service_zone String 7 | ) 8 | PRIMARY KEY LocationID 9 | SOURCE(HTTP( 10 | url 'https://datasets-documentation.s3.eu-west-3.amazonaws.com/nyc-taxi/taxi_zone_lookup.csv' 11 | format 'CSVWithNames' 12 | )) 13 | LIFETIME(0) 14 | LAYOUT(HASHED()); 15 | 16 | -- +goose Down 17 | DROP DICTIONARY IF EXISTS taxi_zone_dictionary; 18 | -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/ydb/00003_c.sql: -------------------------------------------------------------------------------- 1 | 2 | -- +goose Up 3 | -- +goose StatementBegin 4 | INSERT INTO owners(owner_id, owner_name, owner_type) 5 | VALUES (3, 'james', 'user'), (4, 'pressly', 'organization'); 6 | INSERT INTO repos(repo_id, repo_full_name, repo_owner_id) 7 | VALUES (1, 'james/rover', 3), (2, 'pressly/goose', 4); 8 | -- +goose StatementEnd 9 | 10 | -- +goose Down 11 | -- +goose StatementBegin 12 | DELETE FROM owners WHERE (owner_id = 3 OR owner_id = 4); 13 | DELETE FROM repos WHERE (repo_id = 1 OR repo_id = 2); 14 | -- +goose StatementEnd 15 | -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/postgres/00002_insert.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | INSERT INTO owners(owner_name, owner_type) 4 | VALUES ('lucas', 'user'), ('space', 'organization'); 5 | -- +goose StatementEnd 6 | 7 | INSERT INTO owners(owner_name, owner_type) 8 | VALUES ('james', 'user'), ('pressly', 'organization'); 9 | 10 | INSERT INTO repos(repo_full_name, repo_owner_id) 11 | VALUES ('james/rover', 3), ('pressly/goose', 4); 12 | 13 | -- +goose Down 14 | -- +goose StatementBegin 15 | DELETE FROM owners; 16 | -- +goose StatementEnd 17 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | day: "saturday" 9 | assignees: 10 | - "mfridman" 11 | - package-ecosystem: "gomod" 12 | directory: "/" 13 | schedule: 14 | interval: "weekly" 15 | day: "saturday" 16 | groups: 17 | gomod: 18 | patterns: 19 | - "*" 20 | assignees: 21 | - "mfridman" 22 | ignore: 23 | - dependency-name: "*" 24 | update-types: ["version-update:semver-patch"] 25 | -------------------------------------------------------------------------------- /testdata/testdata.go: -------------------------------------------------------------------------------- 1 | package testdata 2 | 3 | import ( 4 | "embed" 5 | "io/fs" 6 | ) 7 | 8 | //go:embed migrations/*.sql 9 | var migrationsFS embed.FS 10 | 11 | // MustMigrationsFS returns the embedded migrations filesystem. 12 | func MustMigrationsFS() fs.FS { 13 | fsys, err := fs.Sub(migrationsFS, "migrations") 14 | if err != nil { 15 | // This should never happen, since the subdirectory is hardcoded. If the layout of the 16 | // embedded files changes, this will panic to alert the developer to update the code 17 | // accordingly. 18 | panic(err) 19 | } 20 | return fsys 21 | } 22 | -------------------------------------------------------------------------------- /examples/go-migrations/00002_rename_root.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | "github.com/pressly/goose/v3" 8 | ) 9 | 10 | func init() { 11 | goose.AddMigrationContext(Up00002, Down00002) 12 | } 13 | 14 | func Up00002(ctx context.Context, tx *sql.Tx) error { 15 | _, err := tx.ExecContext(ctx, "UPDATE users SET username='admin' WHERE username='root';") 16 | return err 17 | } 18 | 19 | func Down00002(ctx context.Context, tx *sql.Tx) error { 20 | _, err := tx.ExecContext(ctx, "UPDATE users SET username='root' WHERE username='admin';") 21 | return err 22 | } 23 | -------------------------------------------------------------------------------- /tests/gomigrations/error/testdata/004_ERROR_insert.go: -------------------------------------------------------------------------------- 1 | package gomigrations 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | 7 | "github.com/pressly/goose/v3" 8 | ) 9 | 10 | func init() { 11 | goose.AddMigration(up004, nil) 12 | } 13 | 14 | func up004(tx *sql.Tx) error { 15 | for i := 1; i <= 100; i++ { 16 | // Simulate an error when no tx. We should have 50 rows 17 | // inserted in the DB. 18 | if i == 50 { 19 | return fmt.Errorf("simulate error: too many inserts") 20 | } 21 | q := "INSERT INTO foo VALUES ($1)" 22 | if _, err := tx.Exec(q); err != nil { 23 | return err 24 | } 25 | } 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | default: none 4 | enable: 5 | - errcheck 6 | - govet 7 | - ineffassign 8 | - misspell 9 | - staticcheck 10 | - testifylint 11 | - unused 12 | exclusions: 13 | generated: lax 14 | presets: 15 | - comments 16 | - common-false-positives 17 | - legacy 18 | - std-error-handling 19 | paths: 20 | - third_party$ 21 | - builtin$ 22 | - examples$ 23 | formatters: 24 | enable: 25 | - gofmt 26 | exclusions: 27 | generated: lax 28 | paths: 29 | - third_party$ 30 | - builtin$ 31 | - examples$ 32 | -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/starrocks/00003_c.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | INSERT INTO testing.test_migrations_1 (version_id, is_applied) VALUES (1, true); 4 | -- +goose StatementEnd 5 | -- +goose StatementBegin 6 | INSERT INTO testing.test_migrations_1 (version_id, is_applied) VALUES (2, true); 7 | -- +goose StatementEnd 8 | -- +goose StatementBegin 9 | INSERT INTO testing.test_migrations_1 (version_id, is_applied) VALUES (3, true); 10 | -- +goose StatementEnd 11 | 12 | -- +goose Down 13 | -- +goose StatementBegin 14 | DELETE FROM testing.test_migrations_1 WHERE version_id < 10; 15 | -- +goose StatementEnd 16 | -------------------------------------------------------------------------------- /tests/gomigrations/error/testdata/002_ERROR_insert_no_tx.go: -------------------------------------------------------------------------------- 1 | package gomigrations 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | 7 | "github.com/pressly/goose/v3" 8 | ) 9 | 10 | func init() { 11 | goose.AddMigrationNoTx(up002, nil) 12 | } 13 | 14 | func up002(db *sql.DB) error { 15 | for i := 1; i <= 100; i++ { 16 | q := "INSERT INTO foo VALUES ($1)" 17 | if _, err := db.Exec(q, i); err != nil { 18 | return err 19 | } 20 | // Simulate an error when no tx. We should have 50 rows 21 | // inserted in the DB. 22 | if i == 50 { 23 | return fmt.Errorf("simulate error: too many inserts") 24 | } 25 | } 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/turso/00002_insert.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | INSERT INTO owners(owner_name, owner_type) 4 | VALUES 5 | ('lucas', 'user'), 6 | ('space', 'organization'), 7 | ('james', 'user'), 8 | ('pressly', 'organization'); 9 | -- +goose StatementEnd 10 | 11 | INSERT INTO repos(repo_full_name, repo_owner_id) 12 | VALUES 13 | ('james/rover', (SELECT owner_id FROM owners WHERE owner_name = 'james')), 14 | ('pressly/goose', (SELECT owner_id FROM owners WHERE owner_name = 'pressly')); 15 | 16 | -- +goose Down 17 | -- +goose StatementBegin 18 | DELETE FROM owners; 19 | -- +goose StatementEnd 20 | -------------------------------------------------------------------------------- /database/doc.go: -------------------------------------------------------------------------------- 1 | // Package database defines a generic [Store] interface for goose to use when interacting with the 2 | // database. It is meant to be generic and not tied to any specific database technology. 3 | // 4 | // At a high level, a [Store] is responsible for: 5 | // - Creating a version table 6 | // - Inserting and deleting a version 7 | // - Getting a specific version 8 | // - Listing all applied versions 9 | // 10 | // Use the [NewStore] function to create a [Store] for one of the supported dialects. 11 | // 12 | // For more advanced use cases, it's possible to implement a custom [Store] for a database that 13 | // goose does not support. 14 | package database 15 | -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/turso/00001_table.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE TABLE owners ( 4 | owner_id INTEGER PRIMARY KEY AUTOINCREMENT, 5 | owner_name TEXT NOT NULL, 6 | owner_type TEXT CHECK(owner_type IN ('user', 'organization')) NOT NULL 7 | ); 8 | 9 | CREATE TABLE IF NOT EXISTS repos ( 10 | repo_id INTEGER PRIMARY KEY AUTOINCREMENT, 11 | repo_full_name TEXT NOT NULL, 12 | repo_owner_id INTEGER NOT NULL REFERENCES owners(owner_id) ON DELETE CASCADE 13 | ); 14 | -- +goose StatementEnd 15 | 16 | -- +goose Down 17 | -- +goose StatementBegin 18 | DROP TABLE IF EXISTS repos; 19 | DROP TABLE IF EXISTS owners; 20 | -- +goose StatementEnd 21 | -------------------------------------------------------------------------------- /lock/internal/table/config.go: -------------------------------------------------------------------------------- 1 | package table 2 | 3 | import ( 4 | "log/slog" 5 | "time" 6 | ) 7 | 8 | // Config holds configuration for table locker. 9 | type Config struct { 10 | TableName string 11 | LockID int64 12 | LeaseDuration time.Duration 13 | HeartbeatInterval time.Duration 14 | LockTimeout ProbeConfig 15 | UnlockTimeout ProbeConfig 16 | 17 | // Optional logger for lock operations 18 | Logger *slog.Logger 19 | 20 | // Optional custom retry policy for database errors 21 | RetryPolicy RetryPolicyFunc 22 | } 23 | 24 | // ProbeConfig holds retry configuration. 25 | type ProbeConfig struct { 26 | IntervalDuration time.Duration 27 | FailureThreshold uint64 28 | } 29 | -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/mysql/00002_insert.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | INSERT INTO owners (owner_name, owner_type) 4 | VALUES 5 | ('lucas', 'user'), 6 | ('space', 'organization'); 7 | -- +goose StatementEnd 8 | 9 | INSERT INTO owners (owner_name, owner_type) 10 | VALUES 11 | ('james', 'user'), 12 | ('pressly', 'organization'); 13 | 14 | INSERT INTO repos (repo_full_name, repo_owner_id) 15 | VALUES 16 | ('james/rover', (SELECT owner_id FROM owners WHERE owner_name = 'james')), 17 | ('pressly/goose', (SELECT owner_id FROM owners WHERE owner_name = 'pressly')); 18 | 19 | -- +goose Down 20 | -- +goose StatementBegin 21 | DELETE FROM owners; 22 | -- +goose StatementEnd 23 | -------------------------------------------------------------------------------- /database/sql_extended.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | // DBTxConn is a thin interface for common methods that is satisfied by *sql.DB, *sql.Tx and 9 | // *sql.Conn. 10 | // 11 | // There is a long outstanding issue to formalize a std lib interface, but alas. See: 12 | // https://github.com/golang/go/issues/14468 13 | type DBTxConn interface { 14 | ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) 15 | QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) 16 | QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row 17 | } 18 | 19 | var ( 20 | _ DBTxConn = (*sql.DB)(nil) 21 | _ DBTxConn = (*sql.Tx)(nil) 22 | _ DBTxConn = (*sql.Conn)(nil) 23 | ) 24 | -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/postgres/00001_table.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE TYPE owner_type as ENUM('user', 'organization'); 4 | 5 | CREATE TABLE owners ( 6 | owner_id BIGSERIAL PRIMARY KEY, 7 | owner_name text NOT NULL, 8 | owner_type owner_type NOT NULL 9 | ); 10 | 11 | CREATE TABLE IF NOT EXISTS repos ( 12 | repo_id BIGSERIAL NOT NULL, 13 | repo_full_name text NOT NULL, 14 | repo_owner_id bigint NOT NULL REFERENCES owners(owner_id) ON DELETE CASCADE, 15 | 16 | PRIMARY KEY (repo_id) 17 | ); 18 | -- +goose StatementEnd 19 | 20 | -- +goose Down 21 | -- +goose StatementBegin 22 | DROP TABLE IF EXISTS repos; 23 | DROP TABLE IF EXISTS owners; 24 | DROP TYPE owner_type; 25 | -- +goose StatementEnd 26 | -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/mysql/00001_table.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE TABLE owners ( 4 | owner_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, 5 | owner_name VARCHAR(255) NOT NULL, 6 | owner_type ENUM('user', 'organization') NOT NULL 7 | ); 8 | 9 | CREATE TABLE IF NOT EXISTS repos ( 10 | repo_id BIGINT UNSIGNED AUTO_INCREMENT NOT NULL, 11 | repo_full_name VARCHAR(255) NOT NULL, 12 | repo_owner_id BIGINT UNSIGNED NOT NULL, 13 | 14 | PRIMARY KEY (repo_id), 15 | FOREIGN KEY (repo_owner_id) REFERENCES owners(owner_id) ON DELETE CASCADE 16 | ); 17 | -- +goose StatementEnd 18 | 19 | -- +goose Down 20 | -- +goose StatementBegin 21 | DROP TABLE IF EXISTS repos; 22 | DROP TABLE IF EXISTS owners; 23 | -- +goose StatementEnd 24 | -------------------------------------------------------------------------------- /helpers_test.go: -------------------------------------------------------------------------------- 1 | package goose 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestCamelSnake(t *testing.T) { 8 | t.Parallel() 9 | 10 | tt := []struct { 11 | in string 12 | camel string 13 | snake string 14 | }{ 15 | {in: "Add updated_at to users table", camel: "AddUpdatedAtToUsersTable", snake: "add_updated_at_to_users_table"}, 16 | {in: "$()&^%(_--crazy__--input$)", camel: "CrazyInput", snake: "crazy_input"}, 17 | } 18 | 19 | for _, test := range tt { 20 | if got := camelCase(test.in); got != test.camel { 21 | t.Errorf("unexpected CamelCase for input(%q), got %q, want %q", test.in, got, test.camel) 22 | } 23 | if got := snakeCase(test.in); got != test.snake { 24 | t.Errorf("unexpected snake_case for input(%q), got %q, want %q", test.in, got, test.snake) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/gomigrations/success/testdata/001_up_down.go: -------------------------------------------------------------------------------- 1 | package gomigrations 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | 8 | "github.com/pressly/goose/v3" 9 | "github.com/pressly/goose/v3/database" 10 | ) 11 | 12 | func init() { 13 | goose.AddMigration(up001, down001) 14 | } 15 | 16 | func up001(tx *sql.Tx) error { 17 | return createTable(tx, "alpha") 18 | } 19 | 20 | func down001(tx *sql.Tx) error { 21 | return dropTable(tx, "alpha") 22 | } 23 | 24 | func createTable(db database.DBTxConn, name string) error { 25 | _, err := db.ExecContext(context.Background(), fmt.Sprintf("CREATE TABLE %s (id INTEGER)", name)) 26 | return err 27 | } 28 | 29 | func dropTable(db database.DBTxConn, name string) error { 30 | _, err := db.ExecContext(context.Background(), fmt.Sprintf("DROP TABLE %s", name)) 31 | return err 32 | } 33 | -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-up/test01/02.up.golden.sql: -------------------------------------------------------------------------------- 1 | CREATE FUNCTION emp_stamp() RETURNS trigger AS $emp_stamp$ 2 | BEGIN 3 | -- Check that empname and salary are given 4 | IF NEW.empname IS NULL THEN 5 | RAISE EXCEPTION 'empname cannot be null'; 6 | END IF; 7 | IF NEW.salary IS NULL THEN 8 | RAISE EXCEPTION '% cannot have null salary', NEW.empname; 9 | END IF; 10 | 11 | -- Who works for us when they must pay for it? 12 | IF NEW.salary < 0 THEN 13 | RAISE EXCEPTION '% cannot have a negative salary', NEW.empname; 14 | END IF; 15 | 16 | -- Remember who changed the payroll when 17 | NEW.last_date := current_timestamp; 18 | NEW.last_user := current_user; 19 | RETURN NEW; 20 | END; 21 | $emp_stamp$ LANGUAGE plpgsql; -------------------------------------------------------------------------------- /internal/sqlparser/testdata/envsub/test02/input.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | 3 | -- +goose ENVSUB ON 4 | CREATE TABLE post ( 5 | id int NOT NULL, 6 | title text, 7 | $GOOSE_ENV_NAME text, 8 | ${GOOSE_ENV_NAME}title3 text, 9 | ${ANOTHER_VAR:-default}title4 text, 10 | ${GOOSE_ENV_SET_BUT_EMPTY_VALUE-default}title5 text, 11 | ); 12 | -- +goose ENVSUB OFF 13 | 14 | CREATE TABLE post ( 15 | id int NOT NULL, 16 | title text, 17 | $GOOSE_ENV_NAME text, 18 | ${GOOSE_ENV_NAME}title3 text, 19 | ${ANOTHER_VAR:-default}title4 text, 20 | ${GOOSE_ENV_SET_BUT_EMPTY_VALUE-default}title5 text, 21 | ); 22 | 23 | -- +goose StatementBegin 24 | CREATE OR REPLACE FUNCTION test_func() 25 | RETURNS void AS $$ 26 | -- +goose ENVSUB ON 27 | BEGIN 28 | RAISE NOTICE '${GOOSE_ENV_NAME} \$GOOSE_ENV_NAME \$GOOSE_ENV_NAME'; 29 | END; 30 | -- +goose ENVSUB OFF 31 | $$ LANGUAGE plpgsql; 32 | -- +goose StatementEnd 33 | -------------------------------------------------------------------------------- /examples/sql-migrations/README.md: -------------------------------------------------------------------------------- 1 | # SQL migrations only 2 | 3 | See [this example](../go-migrations) for Go migrations. 4 | 5 | ```bash 6 | $ go install github.com/pressly/goose/v3/cmd/goose@latest 7 | ``` 8 | 9 | ```bash 10 | $ goose sqlite3 ./foo.db status 11 | Applied At Migration 12 | ======================================= 13 | Pending -- 00001_create_users_table.sql 14 | Pending -- 00002_rename_root.sql 15 | 16 | $ goose sqlite3 ./foo.db up 17 | OK 00001_create_users_table.sql 18 | OK 00002_rename_root.sql 19 | goose: no migrations to run. current version: 2 20 | 21 | $ goose sqlite3 ./foo.db status 22 | Applied At Migration 23 | ======================================= 24 | Mon Jun 19 21:56:00 2017 -- 00001_create_users_table.sql 25 | Mon Jun 19 21:56:00 2017 -- 00002_rename_root.sql 26 | ``` 27 | -------------------------------------------------------------------------------- /osfs.go: -------------------------------------------------------------------------------- 1 | package goose 2 | 3 | import ( 4 | "io/fs" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | // osFS wraps functions working with os filesystem to implement fs.FS interfaces. 10 | type osFS struct{} 11 | 12 | func (osFS) Open(name string) (fs.File, error) { return os.Open(filepath.FromSlash(name)) } 13 | 14 | func (osFS) ReadDir(name string) ([]fs.DirEntry, error) { return os.ReadDir(filepath.FromSlash(name)) } 15 | 16 | func (osFS) Stat(name string) (fs.FileInfo, error) { return os.Stat(filepath.FromSlash(name)) } 17 | 18 | func (osFS) ReadFile(name string) ([]byte, error) { return os.ReadFile(filepath.FromSlash(name)) } 19 | 20 | func (osFS) Glob(pattern string) ([]string, error) { return filepath.Glob(filepath.FromSlash(pattern)) } 21 | 22 | type noopFS struct{} 23 | 24 | var _ fs.FS = noopFS{} 25 | 26 | func (f noopFS) Open(name string) (fs.File, error) { 27 | return nil, os.ErrNotExist 28 | } 29 | -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-up/test08/input.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | 3 | CREATE TABLE `table_a` ( 4 | `column_1` DATETIME DEFAULT NOW(), 5 | `column_2` DATETIME DEFAULT NOW(), 6 | `column_3` DATETIME DEFAULT NOW() 7 | ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; 8 | 9 | CREATE TABLE `table_b` ( 10 | `column_1` DATETIME DEFAULT NOW(), 11 | `column_2` DATETIME DEFAULT NOW(), 12 | `column_3` DATETIME DEFAULT NOW() 13 | ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; 14 | 15 | CREATE TABLE `table_c` ( 16 | `column_1` DATETIME DEFAULT NOW(), 17 | `column_2` DATETIME DEFAULT NOW(), 18 | `column_3` DATETIME DEFAULT NOW() 19 | ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; 20 | 21 | /*!80031 ALTER TABLE `table_a` MODIFY `column_1` TEXT NOT NULL */; 22 | /*!80031 ALTER TABLE `table_b` MODIFY `column_2` TEXT NOT NULL */; 23 | /*!80033 ALTER TABLE `table_c` MODIFY `column_3` TEXT NOT NULL */; 24 | -------------------------------------------------------------------------------- /up_test.go: -------------------------------------------------------------------------------- 1 | package goose 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestFindMissingMigrations(t *testing.T) { 8 | known := Migrations{ 9 | {Version: 1}, 10 | {Version: 3}, 11 | {Version: 4}, 12 | {Version: 5}, 13 | {Version: 7}, // <-- database max version_id 14 | } 15 | new := Migrations{ 16 | {Version: 1}, 17 | {Version: 2}, // missing migration 18 | {Version: 3}, 19 | {Version: 4}, 20 | {Version: 5}, 21 | {Version: 6}, // missing migration 22 | {Version: 7}, // <-- database max version_id 23 | {Version: 8}, // new migration 24 | } 25 | got := findMissingMigrations(known, new, 7) 26 | if len(got) != 2 { 27 | t.Fatalf("invalid migration count: got:%d want:%d", len(got), 2) 28 | } 29 | if got[0].Version != 2 { 30 | t.Errorf("expecting first migration: got:%d want:%d", got[0].Version, 2) 31 | } 32 | if got[1].Version != 6 { 33 | t.Errorf("expecting second migration: got:%d want:%d", got[0].Version, 6) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | permissions: 7 | contents: write 8 | jobs: 9 | goreleaser: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v6 13 | with: 14 | fetch-depth: 0 15 | - run: git fetch --force --tags 16 | - uses: actions/setup-go@v6 17 | with: 18 | go-version: stable 19 | - name: Generate release notes 20 | continue-on-error: true 21 | run: ./scripts/release-notes.sh ${{github.ref_name}} > ${{runner.temp}}/release_notes.txt 22 | - run: make add-gowork 23 | - name: Run GoReleaser 24 | uses: goreleaser/goreleaser-action@v6 25 | with: 26 | distribution: goreleaser 27 | version: "~> v2" 28 | args: release --clean --release-notes=${{runner.temp}}/release_notes.txt 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | -------------------------------------------------------------------------------- /testdata/migrations/00004_insert_data.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | INSERT INTO users (id, username, email) 3 | VALUES 4 | (1, 'john_doe', 'john@example.com'), 5 | (2, 'jane_smith', 'jane@example.com'), 6 | (3, 'alice_wonderland', 'alice@example.com'); 7 | 8 | INSERT INTO posts (id, title, content, author_id) 9 | VALUES 10 | (1, 'Introduction to SQL', 'SQL is a powerful language for managing databases...', 1), 11 | (2, 'Data Modeling Techniques', 'Choosing the right data model is crucial...', 2), 12 | (3, 'Advanced Query Optimization', 'Optimizing queries can greatly improve...', 1); 13 | 14 | INSERT INTO comments (id, post_id, user_id, content) 15 | VALUES 16 | (1, 1, 3, 'Great introduction! Looking forward to more.'), 17 | (2, 1, 2, 'SQL can be a bit tricky at first, but practice helps.'), 18 | (3, 2, 1, 'You covered normalization really well in this post.'); 19 | 20 | -- +goose Down 21 | DELETE FROM comments; 22 | DELETE FROM posts; 23 | DELETE FROM users; 24 | -------------------------------------------------------------------------------- /database/dialect/querier_extended.go: -------------------------------------------------------------------------------- 1 | package dialect 2 | 3 | // QuerierExtender extends the [Querier] interface with optional database-specific optimizations. 4 | // While not required, implementing these methods can improve performance. 5 | // 6 | // IMPORTANT: This interface may be expanded in future versions. Implementors must be prepared to 7 | // update their implementations when new methods are added. 8 | // 9 | // Example compile-time check: 10 | // 11 | // var _ QuerierExtender = (*CustomQuerierExtended)(nil) 12 | // 13 | // In short, it's exported to allow implementors to have a compile-time check that they are 14 | // implementing the interface correctly. 15 | type QuerierExtender interface { 16 | Querier 17 | 18 | // TableExists returns a database-specific SQL query to check if a table exists. For example, 19 | // implementations might query system catalogs like pg_tables or sqlite_master. Return empty 20 | // string if not supported. 21 | TableExists(tableName string) string 22 | } 23 | -------------------------------------------------------------------------------- /log.go: -------------------------------------------------------------------------------- 1 | package goose 2 | 3 | import ( 4 | std "log" 5 | ) 6 | 7 | var log Logger = &stdLogger{} 8 | 9 | // Logger is standard logger interface 10 | type Logger interface { 11 | Fatalf(format string, v ...interface{}) 12 | Printf(format string, v ...interface{}) 13 | } 14 | 15 | // SetLogger sets the logger for package output 16 | func SetLogger(l Logger) { 17 | log = l 18 | } 19 | 20 | // stdLogger is a default logger that outputs to a stdlib's log.std logger. 21 | type stdLogger struct{} 22 | 23 | func (*stdLogger) Fatalf(format string, v ...interface{}) { std.Fatalf(format, v...) } 24 | func (*stdLogger) Printf(format string, v ...interface{}) { std.Printf(format, v...) } 25 | 26 | // NopLogger returns a logger that discards all logged output. 27 | func NopLogger() Logger { 28 | return &nopLogger{} 29 | } 30 | 31 | type nopLogger struct{} 32 | 33 | var _ Logger = (*nopLogger)(nil) 34 | 35 | func (*nopLogger) Fatalf(format string, v ...interface{}) {} 36 | func (*nopLogger) Printf(format string, v ...interface{}) {} 37 | -------------------------------------------------------------------------------- /.github/workflows/integration.yaml: -------------------------------------------------------------------------------- 1 | name: Goose integration tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | test: 16 | name: Run integration tests 17 | timeout-minutes: 10 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v6 23 | - name: Install Go 24 | uses: actions/setup-go@v6 25 | with: 26 | go-version: "stable" 27 | - name: Install tparse 28 | run: | 29 | mkdir -p $HOME/.local/bin 30 | curl -L -o $HOME/.local/bin/tparse https://github.com/mfridman/tparse/releases/latest/download/tparse_linux_x86_64 31 | chmod +x $HOME/.local/bin/tparse 32 | echo "$HOME/.local/bin" >> "$GITHUB_PATH" 33 | - name: Run full integration tests 34 | run: | 35 | make test-integration 36 | -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/starrocks/00002_b.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE TABLE testing.test_migrations_1 ( 4 | version_id bigint NOT NULL, 5 | id bigint NOT NULL AUTO_INCREMENT, 6 | is_applied boolean NOT NULL, 7 | tstamp datetime NULL default CURRENT_TIMESTAMP 8 | ) 9 | PRIMARY KEY (version_id,id) 10 | DISTRIBUTED BY HASH (id) 11 | ORDER BY (version_id); 12 | -- +goose StatementEnd 13 | -- +goose StatementBegin 14 | CREATE TABLE testing.test_migrations_2 ( 15 | version_id bigint NOT NULL, 16 | id bigint NOT NULL AUTO_INCREMENT, 17 | is_applied boolean NOT NULL, 18 | tstamp datetime NULL default CURRENT_TIMESTAMP 19 | ) 20 | PRIMARY KEY (version_id,id) 21 | DISTRIBUTED BY HASH (id) 22 | ORDER BY (version_id); 23 | -- +goose StatementEnd 24 | 25 | -- +goose Down 26 | -- +goose StatementBegin 27 | DROP TABLE IF EXISTS testing.test_migrations_1; 28 | -- +goose StatementEnd 29 | -- +goose StatementBegin 30 | DROP TABLE IF EXISTS testing.test_migrations_2; 31 | -- +goose StatementEnd 32 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 2 | # 3 | # See https://goreleaser.com/customization/ for more information. 4 | version: 2 5 | project_name: goose 6 | 7 | before: 8 | hooks: 9 | - go mod tidy 10 | builds: 11 | - env: 12 | - CGO_ENABLED=0 13 | binary: goose 14 | main: ./cmd/goose 15 | goos: 16 | - linux 17 | - windows 18 | - darwin 19 | goarch: 20 | - amd64 21 | - arm64 22 | ldflags: 23 | # The v prefix is stripped by goreleaser, so we need to add it back. 24 | # https://goreleaser.com/customization/templates/#fnref:version-prefix 25 | - "-s -w -X main.version=v{{ .Version }}" 26 | 27 | archives: 28 | - formats: 29 | - binary 30 | name_template: >- 31 | {{ .ProjectName }}_{{- tolower .Os }}_{{- if eq .Arch "amd64" }}x86_64{{- else }}{{ .Arch }}{{ end }} 32 | checksum: 33 | name_template: "checksums.txt" 34 | snapshot: 35 | version_template: "{{ incpatch .Version }}-next" 36 | changelog: 37 | use: github-native 38 | -------------------------------------------------------------------------------- /internal/testing/testdb/container_healthcheck.go: -------------------------------------------------------------------------------- 1 | package testdb 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/ory/dockertest/v3" 8 | "github.com/ory/dockertest/v3/docker/types" 9 | ) 10 | 11 | // containerWaitHealthy waits until docker container with specified id is healthy 12 | func containerWaitHealthy(ctx context.Context, pool *dockertest.Pool, id string) error { 13 | for { 14 | select { 15 | case <-ctx.Done(): 16 | return ctx.Err() 17 | default: 18 | attemptCtx, attemptCancel := context.WithTimeout(ctx, time.Second) 19 | status, err := containerHealthStatus(attemptCtx, pool, id) 20 | attemptCancel() 21 | if err != nil { 22 | return err 23 | } 24 | if status == types.Healthy { 25 | return nil 26 | } 27 | } 28 | } 29 | } 30 | 31 | func containerHealthStatus(ctx context.Context, pool *dockertest.Pool, id string) (string, error) { 32 | currentContainer, err := pool.Client.InspectContainerWithContext(id, ctx) 33 | if err != nil { 34 | return "", err 35 | } 36 | 37 | return currentContainer.State.Health.Status, nil 38 | 39 | } 40 | -------------------------------------------------------------------------------- /internal/migrationstats/migrationstats_walker.go: -------------------------------------------------------------------------------- 1 | package migrationstats 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | // NewFileWalker returns a new FileWalker for the given filenames. 10 | // 11 | // Filenames without a .sql or .go extension are ignored. 12 | func NewFileWalker(filenames ...string) FileWalker { 13 | return &fileWalker{ 14 | filenames: filenames, 15 | } 16 | } 17 | 18 | type fileWalker struct { 19 | filenames []string 20 | } 21 | 22 | var _ FileWalker = (*fileWalker)(nil) 23 | 24 | func (f *fileWalker) Walk(fn func(filename string, r io.Reader) error) error { 25 | for _, filename := range f.filenames { 26 | ext := filepath.Ext(filename) 27 | if ext != ".sql" && ext != ".go" { 28 | continue 29 | } 30 | if err := walk(filename, fn); err != nil { 31 | return err 32 | } 33 | } 34 | return nil 35 | } 36 | 37 | func walk(filename string, fn func(filename string, r io.Reader) error) error { 38 | file, err := os.Open(filename) 39 | if err != nil { 40 | return err 41 | } 42 | defer file.Close() 43 | return fn(filename, file) 44 | } 45 | -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/mysql/00006_complex.sql: -------------------------------------------------------------------------------- 1 | -- +goose up 2 | 3 | -- +goose statementbegin 4 | CREATE OR REPLACE PROCEDURE insert_repository( 5 | IN p_repo_full_name VARCHAR(255), 6 | IN p_owner_name VARCHAR(255), 7 | IN p_owner_type VARCHAR(20) 8 | ) 9 | BEGIN 10 | DECLARE v_owner_id BIGINT; 11 | DECLARE v_repo_id BIGINT; 12 | 13 | -- Check if the owner already exists 14 | SELECT owner_id INTO v_owner_id 15 | FROM owners 16 | WHERE owner_name = p_owner_name AND owner_type = p_owner_type; 17 | 18 | -- If the owner does not exist, insert a new owner 19 | IF v_owner_id IS NULL THEN 20 | INSERT INTO owners (owner_name, owner_type) 21 | VALUES (p_owner_name, p_owner_type); 22 | 23 | SET v_owner_id = LAST_INSERT_ID(); 24 | END IF; 25 | 26 | -- Insert the repository using the obtained owner_id 27 | INSERT INTO repos (repo_full_name, repo_owner_id) 28 | VALUES (p_repo_full_name, v_owner_id); 29 | 30 | -- No explicit return needed in procedures 31 | 32 | END; 33 | -- +goose statementend 34 | 35 | -- +goose down 36 | DROP PROCEDURE IF EXISTS insert_repository; 37 | -------------------------------------------------------------------------------- /examples/go-migrations/00003_add_user_no_tx.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | 8 | "github.com/pressly/goose/v3" 9 | ) 10 | 11 | func init() { 12 | goose.AddMigrationNoTxContext(Up00003, Down00003) 13 | } 14 | 15 | func Up00003(ctx context.Context, db *sql.DB) error { 16 | id, err := getUserID(db, "jamesbond") 17 | if err != nil { 18 | return err 19 | } 20 | if id == 0 { 21 | query := "INSERT INTO users (username, name, surname) VALUES ($1, $2, $3)" 22 | if _, err := db.ExecContext(ctx, query, "jamesbond", "James", "Bond"); err != nil { 23 | return err 24 | } 25 | } 26 | return nil 27 | } 28 | 29 | func getUserID(db *sql.DB, username string) (int, error) { 30 | var id int 31 | err := db.QueryRow("SELECT id FROM users WHERE username = $1", username).Scan(&id) 32 | if err != nil && !errors.Is(err, sql.ErrNoRows) { 33 | return 0, err 34 | } 35 | return id, nil 36 | } 37 | 38 | func Down00003(ctx context.Context, db *sql.DB) error { 39 | query := "DELETE FROM users WHERE username = $1" 40 | if _, err := db.ExecContext(ctx, query, "jamesbond"); err != nil { 41 | return err 42 | } 43 | return nil 44 | } 45 | -------------------------------------------------------------------------------- /internal/migrationstats/migration_sql.go: -------------------------------------------------------------------------------- 1 | package migrationstats 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | 8 | "github.com/pressly/goose/v3/internal/sqlparser" 9 | ) 10 | 11 | type sqlMigration struct { 12 | useTx bool 13 | upCount, downCount int 14 | } 15 | 16 | func parseSQLFile(r io.Reader, debug bool) (*sqlMigration, error) { 17 | by, err := io.ReadAll(r) 18 | if err != nil { 19 | return nil, err 20 | } 21 | upStatements, txUp, err := sqlparser.ParseSQLMigration( 22 | bytes.NewReader(by), 23 | sqlparser.DirectionUp, 24 | debug, 25 | ) 26 | if err != nil { 27 | return nil, err 28 | } 29 | downStatements, txDown, err := sqlparser.ParseSQLMigration( 30 | bytes.NewReader(by), 31 | sqlparser.DirectionDown, 32 | debug, 33 | ) 34 | if err != nil { 35 | return nil, err 36 | } 37 | // This is a sanity check to ensure that the parser is behaving as expected. 38 | if txUp != txDown { 39 | return nil, fmt.Errorf("up and down statements must have the same transaction mode") 40 | } 41 | return &sqlMigration{ 42 | useTx: txUp, 43 | upCount: len(upStatements), 44 | downCount: len(downStatements), 45 | }, nil 46 | } 47 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Adapted from the Deno installer: Copyright 2019 the Deno authors. All rights reserved. MIT license. 3 | # Ref: https://github.com/denoland/deno_install 4 | # TODO(everyone): Keep this script simple and easily auditable. 5 | 6 | # TODO(mf): this should work on Linux and macOS. Not intended for Windows. 7 | 8 | set -e 9 | 10 | os=$(uname -s | tr '[:upper:]' '[:lower:]') 11 | arch=$(uname -m) 12 | 13 | if [ "$arch" = "aarch64" ]; then 14 | arch="arm64" 15 | fi 16 | 17 | if [ $# -eq 0 ]; then 18 | goose_uri="https://github.com/pressly/goose/releases/latest/download/goose_${os}_${arch}" 19 | else 20 | goose_uri="https://github.com/pressly/goose/releases/download/${1}/goose_${os}_${arch}" 21 | fi 22 | 23 | goose_install="${GOOSE_INSTALL:-/usr/local}" 24 | bin_dir="${goose_install}/bin" 25 | exe="${bin_dir}/goose" 26 | 27 | if [ ! -d "${bin_dir}" ]; then 28 | mkdir -p "${bin_dir}" 29 | fi 30 | 31 | curl --silent --show-error --location --fail --location --output "${exe}" "$goose_uri" 32 | chmod +x "${exe}" 33 | 34 | echo "Goose was installed successfully to ${exe}" 35 | if command -v goose >/dev/null; then 36 | echo "Run 'goose --help' to get started" 37 | fi 38 | -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-up/test01/input.sql: -------------------------------------------------------------------------------- 1 | -- +goose UP 2 | CREATE TABLE emp ( 3 | empname text, 4 | salary integer, 5 | last_date timestamp, 6 | last_user text 7 | ); 8 | 9 | -- +goose statementBegin 10 | CREATE FUNCTION emp_stamp() RETURNS trigger AS $emp_stamp$ 11 | BEGIN 12 | -- Check that empname and salary are given 13 | IF NEW.empname IS NULL THEN 14 | RAISE EXCEPTION 'empname cannot be null'; 15 | END IF; 16 | IF NEW.salary IS NULL THEN 17 | RAISE EXCEPTION '% cannot have null salary', NEW.empname; 18 | END IF; 19 | 20 | -- Who works for us when they must pay for it? 21 | IF NEW.salary < 0 THEN 22 | RAISE EXCEPTION '% cannot have a negative salary', NEW.empname; 23 | END IF; 24 | 25 | -- Remember who changed the payroll when 26 | NEW.last_date := current_timestamp; 27 | NEW.last_user := current_user; 28 | RETURN NEW; 29 | END; 30 | $emp_stamp$ LANGUAGE plpgsql; 31 | -- +goose StatementEnd 32 | 33 | CREATE TRIGGER emp_stamp BEFORE INSERT OR UPDATE ON emp 34 | FOR EACH ROW EXECUTE FUNCTION emp_stamp(); 35 | 36 | -- +goose Down 37 | -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/postgres/00006_complex.sql: -------------------------------------------------------------------------------- 1 | -- +goose up 2 | -- +goose statementbegin 3 | CREATE OR REPLACE FUNCTION insert_repository( 4 | p_repo_full_name TEXT, 5 | p_owner_name TEXT, 6 | p_owner_type OWNER_TYPE 7 | ) RETURNS VOID AS $$ 8 | DECLARE 9 | v_owner_id BIGINT; 10 | v_repo_id BIGINT; 11 | BEGIN 12 | -- Check if the owner already exists 13 | SELECT owner_id INTO v_owner_id 14 | FROM owners 15 | WHERE owner_name = p_owner_name AND owner_type = p_owner_type; 16 | 17 | -- If the owner does not exist, insert a new owner 18 | IF v_owner_id IS NULL THEN 19 | INSERT INTO owners (owner_name, owner_type) 20 | VALUES (p_owner_name, p_owner_type) 21 | RETURNING owner_id INTO v_owner_id; 22 | END IF; 23 | 24 | -- Insert the repository using the obtained owner_id 25 | INSERT INTO repos (repo_full_name, repo_owner_id) 26 | VALUES (p_repo_full_name, v_owner_id) 27 | RETURNING repo_id INTO v_repo_id; 28 | 29 | -- Commit the transaction 30 | COMMIT; 31 | END; 32 | $$ LANGUAGE plpgsql; 33 | -- +goose statementend 34 | 35 | -- +goose down 36 | DROP FUNCTION IF EXISTS insert_repository(TEXT, TEXT, OWNER_TYPE); 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Original work Copyright (c) 2012 Liam Staskawicz 4 | Modified work Copyright (c) 2016 Vojtech Vitek 5 | Modified work Copyright (c) 2021 Michael Fridman, Vojtech Vitek 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy of 8 | this software and associated documentation files (the "Software"), to deal in 9 | the Software without restriction, including without limitation the rights to 10 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 11 | the Software, and to permit persons to whom the Software is furnished to do so, 12 | subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 19 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 20 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 21 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 22 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /examples/go-migrations/main.go: -------------------------------------------------------------------------------- 1 | // This is custom goose binary with sqlite3 support only. 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | "flag" 8 | "log" 9 | "os" 10 | 11 | "github.com/pressly/goose/v3" 12 | _ "modernc.org/sqlite" 13 | ) 14 | 15 | var ( 16 | flags = flag.NewFlagSet("goose", flag.ExitOnError) 17 | dir = flags.String("dir", ".", "directory with migration files") 18 | ) 19 | 20 | func main() { 21 | if err := flags.Parse(os.Args[1:]); err != nil { 22 | log.Fatalf("goose: failed to parse flags: %v", err) 23 | } 24 | args := flags.Args() 25 | 26 | if len(args) < 3 { 27 | flags.Usage() 28 | return 29 | } 30 | 31 | dbstring, command := args[1], args[2] 32 | 33 | db, err := goose.OpenDBWithDriver("sqlite", dbstring) 34 | if err != nil { 35 | log.Fatalf("goose: failed to open DB: %v", err) 36 | } 37 | 38 | defer func() { 39 | if err := db.Close(); err != nil { 40 | log.Fatalf("goose: failed to close DB: %v", err) 41 | } 42 | }() 43 | 44 | arguments := []string{} 45 | if len(args) > 3 { 46 | arguments = append(arguments, args[3:]...) 47 | } 48 | 49 | ctx := context.Background() 50 | if err := goose.RunContext(ctx, command, db, *dir, arguments...); err != nil { 51 | log.Fatalf("goose %v: %v", command, err) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /lock/locker.go: -------------------------------------------------------------------------------- 1 | // Package lock defines the Locker interface and implements the locking logic. 2 | package lock 3 | 4 | import ( 5 | "context" 6 | "database/sql" 7 | "errors" 8 | ) 9 | 10 | var ( 11 | // ErrLockNotImplemented is returned when the database does not support locking. 12 | ErrLockNotImplemented = errors.New("lock not implemented") 13 | // ErrUnlockNotImplemented is returned when the database does not support unlocking. 14 | ErrUnlockNotImplemented = errors.New("unlock not implemented") 15 | ) 16 | 17 | // SessionLocker is the interface to lock and unlock the database for the duration of a session. The 18 | // session is defined as the duration of a single connection and both methods must be called on the 19 | // same connection. 20 | type SessionLocker interface { 21 | SessionLock(ctx context.Context, conn *sql.Conn) error 22 | SessionUnlock(ctx context.Context, conn *sql.Conn) error 23 | } 24 | 25 | // Locker is the interface to lock and unlock the database. 26 | // 27 | // Unlike [SessionLocker], the Lock and Unlock methods are called on a [*sql.DB] and do not require 28 | // the same connection to be used for both methods. 29 | type Locker interface { 30 | Lock(ctx context.Context, db *sql.DB) error 31 | Unlock(ctx context.Context, db *sql.DB) error 32 | } 33 | -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-up/test02/01.up.golden.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE emp ( 2 | empname text NOT NULL, 3 | salary integer 4 | ); 5 | 6 | CREATE TABLE emp_audit( 7 | operation char(1) NOT NULL, 8 | stamp timestamp NOT NULL, 9 | userid text NOT NULL, 10 | empname text NOT NULL, 11 | salary integer 12 | ); 13 | 14 | CREATE OR REPLACE FUNCTION process_emp_audit() RETURNS TRIGGER AS $emp_audit$ 15 | BEGIN 16 | -- 17 | -- Create a row in emp_audit to reflect the operation performed on emp, 18 | -- making use of the special variable TG_OP to work out the operation. 19 | -- 20 | IF (TG_OP = 'DELETE') THEN 21 | INSERT INTO emp_audit SELECT 'D', now(), user, OLD.*; 22 | ELSIF (TG_OP = 'UPDATE') THEN 23 | INSERT INTO emp_audit SELECT 'U', now(), user, NEW.*; 24 | ELSIF (TG_OP = 'INSERT') THEN 25 | INSERT INTO emp_audit SELECT 'I', now(), user, NEW.*; 26 | END IF; 27 | RETURN NULL; -- result is ignored since this is an AFTER trigger 28 | END; 29 | $emp_audit$ LANGUAGE plpgsql; 30 | 31 | CREATE TRIGGER emp_audit 32 | AFTER INSERT OR UPDATE OR DELETE ON emp 33 | FOR EACH ROW EXECUTE FUNCTION process_emp_audit(); -------------------------------------------------------------------------------- /fix.go: -------------------------------------------------------------------------------- 1 | package goose 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | ) 9 | 10 | const seqVersionTemplate = "%05v" 11 | 12 | func Fix(dir string) error { 13 | // always use osFS here because it's modifying operation 14 | migrations, err := collectMigrationsFS(osFS{}, dir, minVersion, maxVersion, registeredGoMigrations) 15 | if err != nil { 16 | return err 17 | } 18 | 19 | // split into timestamped and versioned migrations 20 | tsMigrations, err := migrations.timestamped() 21 | if err != nil { 22 | return err 23 | } 24 | 25 | vMigrations, err := migrations.versioned() 26 | if err != nil { 27 | return err 28 | } 29 | // Initial version. 30 | version := int64(1) 31 | if last, err := vMigrations.Last(); err == nil { 32 | version = last.Version + 1 33 | } 34 | 35 | // fix filenames by replacing timestamps with sequential versions 36 | for _, tsm := range tsMigrations { 37 | oldPath := tsm.Source 38 | newPath := strings.Replace( 39 | oldPath, 40 | fmt.Sprintf("%d", tsm.Version), 41 | fmt.Sprintf(seqVersionTemplate, version), 42 | 1, 43 | ) 44 | 45 | if err := os.Rename(oldPath, newPath); err != nil { 46 | return err 47 | } 48 | 49 | log.Printf("RENAMED %s => %s", filepath.Base(oldPath), filepath.Base(newPath)) 50 | version++ 51 | } 52 | 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /redo.go: -------------------------------------------------------------------------------- 1 | package goose 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | // Redo rolls back the most recently applied migration, then runs it again. 9 | func Redo(db *sql.DB, dir string, opts ...OptionsFunc) error { 10 | ctx := context.Background() 11 | return RedoContext(ctx, db, dir, opts...) 12 | } 13 | 14 | // RedoContext rolls back the most recently applied migration, then runs it again. 15 | func RedoContext(ctx context.Context, db *sql.DB, dir string, opts ...OptionsFunc) error { 16 | option := &options{} 17 | for _, f := range opts { 18 | f(option) 19 | } 20 | migrations, err := CollectMigrations(dir, minVersion, maxVersion) 21 | if err != nil { 22 | return err 23 | } 24 | var ( 25 | currentVersion int64 26 | ) 27 | if option.noVersioning { 28 | if len(migrations) == 0 { 29 | return nil 30 | } 31 | currentVersion = migrations[len(migrations)-1].Version 32 | } else { 33 | if currentVersion, err = GetDBVersionContext(ctx, db); err != nil { 34 | return err 35 | } 36 | } 37 | 38 | current, err := migrations.Current(currentVersion) 39 | if err != nil { 40 | return err 41 | } 42 | current.noVersioning = option.noVersioning 43 | 44 | if err := current.DownContext(ctx, db); err != nil { 45 | return err 46 | } 47 | if err := current.UpContext(ctx, db); err != nil { 48 | return err 49 | } 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /database/dialect/querier.go: -------------------------------------------------------------------------------- 1 | package dialect 2 | 3 | // Querier is the interface that wraps the basic methods to create a dialect specific query. 4 | // 5 | // It is intended tio be using with [database.NewStoreFromQuerier] to create a new [database.Store] 6 | // implementation based on a custom querier. 7 | type Querier interface { 8 | // CreateTable returns the SQL query string to create the db version table. 9 | CreateTable(tableName string) string 10 | // InsertVersion returns the SQL query string to insert a new version into the db version table. 11 | InsertVersion(tableName string) string 12 | // DeleteVersion returns the SQL query string to delete a version from the db version table. 13 | DeleteVersion(tableName string) string 14 | // GetMigrationByVersion returns the SQL query string to get a single migration by version. 15 | // 16 | // The query should return the timestamp and is_applied columns. 17 | GetMigrationByVersion(tableName string) string 18 | // ListMigrations returns the SQL query string to list all migrations in descending order by id. 19 | // 20 | // The query should return the version_id and is_applied columns. 21 | ListMigrations(tableName string) string 22 | // GetLatestVersion returns the SQL query string to get the last version_id from the db version 23 | // table. Returns a nullable int64 value. 24 | GetLatestVersion(tableName string) string 25 | } 26 | -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-up/test06/input.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | 3 | CREATE TABLE article ( 4 | id text, 5 | content text); 6 | 7 | INSERT INTO article (id, content) VALUES ('id_0001', E'# My markdown doc 8 | 9 | first paragraph 10 | 11 | second paragraph'); 12 | 13 | INSERT INTO article (id, content) VALUES ('id_0002', E'# My second 14 | 15 | markdown doc 16 | 17 | first paragraph 18 | 19 | -- with a comment 20 | -- with an indent comment 21 | 22 | second paragraph'); 23 | 24 | -- +goose StatementBegin 25 | 26 | 27 | 28 | 29 | -- 1 this comment will NOT be preserved 30 | -- 2 this comment will NOT be preserved 31 | 32 | 33 | CREATE FUNCTION do_something(sql TEXT) RETURNS INTEGER AS $$ 34 | BEGIN 35 | -- initiate technology 36 | PERFORM something_or_other(sql); 37 | 38 | -- increase technology 39 | PERFORM some_other_thing(sql); 40 | 41 | -- technology was successful 42 | RETURN 1; 43 | END; 44 | $$ LANGUAGE plpgsql; 45 | 46 | -- 3 this comment WILL BE preserved 47 | -- 4 this comment WILL BE preserved 48 | 49 | 50 | -- +goose StatementEnd 51 | 52 | INSERT INTO post (id, title, body) 53 | VALUES ('id_01', 'my_title', ' 54 | this is an insert statement including empty lines. 55 | 56 | empty (blank) lines can be meaningful. 57 | 58 | leave the lines to keep the text syntax. 59 | '); 60 | 61 | -- +goose Down 62 | TRUNCATE TABLE post; 63 | -------------------------------------------------------------------------------- /internal/controller/store.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/pressly/goose/v3/database" 8 | ) 9 | 10 | // A StoreController is used by the goose package to interact with a database. This type is a 11 | // wrapper around the Store interface, but can be extended to include additional (optional) methods 12 | // that are not part of the core Store interface. 13 | type StoreController struct{ database.Store } 14 | 15 | var _ database.StoreExtender = (*StoreController)(nil) 16 | 17 | // NewStoreController returns a new StoreController that wraps the given Store. 18 | // 19 | // If the Store implements the following optional methods, the StoreController will call them as 20 | // appropriate: 21 | // 22 | // - TableExists(context.Context, DBTxConn) (bool, error) 23 | // 24 | // If the Store does not implement a method, it will either return a [errors.ErrUnsupported] error 25 | // or fall back to the default behavior. 26 | func NewStoreController(store database.Store) *StoreController { 27 | return &StoreController{store} 28 | } 29 | 30 | func (c *StoreController) TableExists(ctx context.Context, db database.DBTxConn) (bool, error) { 31 | if t, ok := c.Store.(interface { 32 | TableExists(ctx context.Context, db database.DBTxConn) (bool, error) 33 | }); ok { 34 | return t.TableExists(ctx, db) 35 | } 36 | return false, errors.ErrUnsupported 37 | } 38 | -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-up/test02/input.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE TABLE emp ( 4 | empname text NOT NULL, 5 | salary integer 6 | ); 7 | 8 | CREATE TABLE emp_audit( 9 | operation char(1) NOT NULL, 10 | stamp timestamp NOT NULL, 11 | userid text NOT NULL, 12 | empname text NOT NULL, 13 | salary integer 14 | ); 15 | 16 | CREATE OR REPLACE FUNCTION process_emp_audit() RETURNS TRIGGER AS $emp_audit$ 17 | BEGIN 18 | -- 19 | -- Create a row in emp_audit to reflect the operation performed on emp, 20 | -- making use of the special variable TG_OP to work out the operation. 21 | -- 22 | IF (TG_OP = 'DELETE') THEN 23 | INSERT INTO emp_audit SELECT 'D', now(), user, OLD.*; 24 | ELSIF (TG_OP = 'UPDATE') THEN 25 | INSERT INTO emp_audit SELECT 'U', now(), user, NEW.*; 26 | ELSIF (TG_OP = 'INSERT') THEN 27 | INSERT INTO emp_audit SELECT 'I', now(), user, NEW.*; 28 | END IF; 29 | RETURN NULL; -- result is ignored since this is an AFTER trigger 30 | END; 31 | $emp_audit$ LANGUAGE plpgsql; 32 | 33 | CREATE TRIGGER emp_audit 34 | AFTER INSERT OR UPDATE OR DELETE ON emp 35 | FOR EACH ROW EXECUTE FUNCTION process_emp_audit(); 36 | -- +goose StatementEnd 37 | 38 | -- +goose Down 39 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package goose 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | ) 8 | 9 | // Version prints the current version of the database. 10 | func Version(db *sql.DB, dir string, opts ...OptionsFunc) error { 11 | ctx := context.Background() 12 | return VersionContext(ctx, db, dir, opts...) 13 | } 14 | 15 | // VersionContext prints the current version of the database. 16 | func VersionContext(ctx context.Context, db *sql.DB, dir string, opts ...OptionsFunc) error { 17 | option := &options{} 18 | for _, f := range opts { 19 | f(option) 20 | } 21 | if option.noVersioning { 22 | var current int64 23 | migrations, err := CollectMigrations(dir, minVersion, maxVersion) 24 | if err != nil { 25 | return fmt.Errorf("failed to collect migrations: %w", err) 26 | } 27 | if len(migrations) > 0 { 28 | current = migrations[len(migrations)-1].Version 29 | } 30 | log.Printf("goose: file version %v", current) 31 | return nil 32 | } 33 | 34 | current, err := GetDBVersionContext(ctx, db) 35 | if err != nil { 36 | return err 37 | } 38 | log.Printf("goose: version %v", current) 39 | return nil 40 | } 41 | 42 | var tableName = "goose_db_version" 43 | 44 | // TableName returns goose db version table name 45 | func TableName() string { 46 | return tableName 47 | } 48 | 49 | // SetTableName set goose db version table name 50 | func SetTableName(n string) { 51 | tableName = n 52 | } 53 | -------------------------------------------------------------------------------- /create_test.go: -------------------------------------------------------------------------------- 1 | package goose 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "strings" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func TestSequential(t *testing.T) { 13 | t.Parallel() 14 | if testing.Short() { 15 | t.Skip("skip long running test") 16 | } 17 | 18 | dir := t.TempDir() 19 | defer os.Remove("./bin/create-goose") // clean up 20 | 21 | commands := []string{ 22 | "go build -o ./bin/create-goose ./cmd/goose", 23 | fmt.Sprintf("./bin/create-goose -s -dir=%s create create_table", dir), 24 | fmt.Sprintf("./bin/create-goose -s -dir=%s create add_users", dir), 25 | fmt.Sprintf("./bin/create-goose -s -dir=%s create add_indices", dir), 26 | fmt.Sprintf("./bin/create-goose -s -dir=%s create update_users", dir), 27 | } 28 | 29 | for _, cmd := range commands { 30 | args := strings.Split(cmd, " ") 31 | time.Sleep(1 * time.Second) 32 | cmd := exec.Command(args[0], args[1:]...) 33 | cmd.Env = os.Environ() 34 | out, err := cmd.CombinedOutput() 35 | if err != nil { 36 | t.Fatalf("%s:\n%v\n\n%s", err, cmd, out) 37 | } 38 | } 39 | 40 | files, err := os.ReadDir(dir) 41 | if err != nil { 42 | t.Fatal(err) 43 | } 44 | 45 | // check that the files are in order 46 | for i, f := range files { 47 | expected := fmt.Sprintf("%05v", i+1) 48 | if !strings.HasPrefix(f.Name(), expected) { 49 | t.Errorf("failed to find %s prefix in %s", expected, f.Name()) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-up/test03/01.up.golden.sql: -------------------------------------------------------------------------------- 1 | CREATE FUNCTION cs_update_referrer_type_proc() RETURNS INTEGER AS ' 2 | DECLARE 3 | referrer_keys RECORD; -- Declare a generic record to be used in a FOR 4 | a_output varchar(4000); 5 | BEGIN 6 | a_output := ''CREATE FUNCTION cs_find_referrer_type(varchar,varchar,varchar) 7 | RETURNS VARCHAR AS '''' 8 | DECLARE 9 | v_host ALIAS FOR $1; 10 | v_domain ALIAS FOR $2; 11 | v_url ALIAS FOR $3; 12 | BEGIN ''; 13 | 14 | -- 15 | -- Notice how we scan through the results of a query in a FOR loop 16 | -- using the FOR construct. 17 | -- 18 | 19 | FOR referrer_keys IN SELECT * FROM cs_referrer_keys ORDER BY try_order LOOP 20 | a_output := a_output || '' IF v_'' || referrer_keys.kind || '' LIKE '''''''''' 21 | || referrer_keys.key_string || '''''''''' THEN RETURN '''''' 22 | || referrer_keys.referrer_type || ''''''; END IF;''; 23 | END LOOP; 24 | 25 | a_output := a_output || '' RETURN NULL; END; '''' LANGUAGE ''''plpgsql'''';''; 26 | 27 | -- This works because we are not substituting any variables 28 | -- Otherwise it would fail. Look at PERFORM for another way to run functions 29 | 30 | EXECUTE a_output; 31 | END; 32 | ' LANGUAGE 'plpgsql'; -- This comment WILL BE preserved. 33 | -- And so will this one. -------------------------------------------------------------------------------- /internal/testing/testdb/testdb.go: -------------------------------------------------------------------------------- 1 | package testdb 2 | 3 | import "database/sql" 4 | 5 | // NewClickHouse starts a ClickHouse docker container. Returns db connection and a docker cleanup function. 6 | func NewClickHouse(options ...OptionsFunc) (db *sql.DB, cleanup func(), err error) { 7 | return newClickHouse(options...) 8 | } 9 | 10 | // NewPostgres starts a PostgreSQL docker container. Returns db connection and a docker cleanup function. 11 | func NewPostgres(options ...OptionsFunc) (db *sql.DB, cleanup func(), err error) { 12 | return newPostgres(options...) 13 | } 14 | 15 | // NewSpanner starts a Spanner docker container. Returns db connection and a docker cleanup function. 16 | func NewSpanner(options ...OptionsFunc) (db *sql.DB, cleanup func(), err error) { 17 | return newSpanner(options...) 18 | } 19 | 20 | // NewMariaDB starts a MariaDB docker container. Returns a db connection and a docker cleanup function. 21 | func NewMariaDB(options ...OptionsFunc) (db *sql.DB, cleanup func(), err error) { 22 | return newMariaDB(options...) 23 | } 24 | 25 | // NewYdb starts a YDB docker container. Returns db connection and a docker cleanup function. 26 | func NewYdb(options ...OptionsFunc) (db *sql.DB, cleanup func(), err error) { 27 | return newYdb(options...) 28 | } 29 | 30 | // NewStarrocks starts a Starrocks docker container. Returns db connection and a docker cleanup function. 31 | func NewStarrocks(options ...OptionsFunc) (db *sql.DB, cleanup func(), err error) { 32 | return newStarrocks(options...) 33 | } 34 | -------------------------------------------------------------------------------- /examples/go-migrations/README.md: -------------------------------------------------------------------------------- 1 | # SQL + Go migrations 2 | 3 | ## This example: Custom goose binary with built-in Go migrations 4 | 5 | ```bash 6 | $ go build -o goose-custom *.go 7 | ``` 8 | 9 | ```bash 10 | $ ./goose-custom sqlite3 ./foo.db status 11 | Applied At Migration 12 | ======================================= 13 | Pending -- 00001_create_users_table.sql 14 | Pending -- 00002_rename_root.go 15 | Pending -- 00003_add_user_no_tx.go 16 | 17 | $ ./goose-custom sqlite3 ./foo.db up 18 | OK 00001_create_users_table.sql (711.58µs) 19 | OK 00002_rename_root.go (302.08µs) 20 | OK 00003_add_user_no_tx.go (648.71µs) 21 | goose: no migrations to run. current version: 3 22 | 23 | $ ./goose-custom sqlite3 ./foo.db status 24 | Applied At Migration 25 | ======================================= 26 | 00001_create_users_table.sql 27 | 00002_rename_root.go 28 | 00003_add_user_no_tx.go 29 | ``` 30 | 31 | ## Best practice: Split migrations into a standalone package 32 | 33 | 1. Move [main.go](main.go) into your `cmd/` directory 34 | 35 | 2. Rename package name in all `*_.go` migration files from `main` to `migrations`. 36 | 37 | 3. Import this `migrations` package from your custom [cmd/main.go](main.go) file: 38 | 39 | ```go 40 | import ( 41 | // Invoke init() functions within migrations pkg. 42 | _ "github.com/pressly/goose/example/migrations-go" 43 | ) 44 | ``` 45 | -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-up/test04/03.up.golden.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO ssh_keys (id, "publicKey") VALUES (2, '-----BEGIN OPENSSH PRIVATE KEY----- 2 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABsQAAAAdzc2gtZH 3 | NzAAAAgQDKYlGRv+Ul4WYKN3F83zchQC24ZV17EJCKwakASf//u2p1+K4iuTQbPgcUWpET 4 | w4HkCRlOVwYkM4ZL2QncUDyEX3o0UDEgrnhWhLJJR2yCMSsqTNffME3X7YdP2LcE0OM9ZP 5 | 9vb5+TwLr6c4edwlu3QYc4VStWSstdR9DD7vun9QAAABUA+DRjW9u+5IEPHyx+FhEoKsRm 6 | 8aEAAACAU2auArSDuTVbJBE/oP6x+4vrud2qFtALsuFaLPfqN5gVBofRJlIOpQobyi022R 7 | 6SPtX/DEbWkVuHfiyxkjb0Gb0obBzGM+RNqjOF6OE9sh7OuBfLaWW44OZasg1VXzkRcoWv 8 | 7jClfKYi2Q/LxHGhZoqy1uYlHPYP5CmCpiELrUMAAACAfp0KpTVyYQjO2nLPuBhnsepfxQ 9 | kT+FDqDBp1rZfB4uKx4q466Aq0jeev1OeQEYZpj3+q4b2XX54zXDwvJLuiD9WSmC7jvT0+ 10 | EUmF55PHW4inloG9pMUzeQnx3k8WDcRJbcAMalpoCCsb0jEPIiyBGBtQu0gOoLL+N+G2Cl 11 | U+/FEAAAHgOKSlwjikpcIAAAAHc3NoLWRzcwAAAIEAymJRkb/lJeFmCjdxfN83IUAtuGVd 12 | exCQisGpAEn//7tqdfiuIrk0Gz4HFFqRE8OB5AkZTlcGJDOGS9kJ3FA8hF96NFAxIK54Vo 13 | SySUdsgjErKkzX3zBN1+2HT9i3BNDjPWT/b2+fk8C6+nOHncJbt0GHOFUrVkrLXUfQw+77 14 | p/UAAAAVAPg0Y1vbvuSBDx8sfhYRKCrEZvGhAAAAgFNmrgK0g7k1WyQRP6D+sfuL67ndqh 15 | bQC7LhWiz36jeYFQaH0SZSDqUKG8otNtkekj7V/wxG1pFbh34ssZI29Bm9KGwcxjPkTaoz 16 | hejhPbIezrgXy2lluODmWrINVV85EXKFr+4wpXymItkPy8RxoWaKstbmJRz2D+QpgqYhC6 17 | 1DAAAAgH6dCqU1cmEIztpyz7gYZ7HqX8UJE/hQ6gwada2XweLiseKuOugKtI3nr9TnkBGG 18 | aY9/quG9l1+eM1w8LyS7og/Vkpgu4709PhFJheeTx1uIp5aBvaTFM3kJ8d5PFg3ESW3ADG 19 | paaAgrG9IxDyIsgRgbULtIDqCy/jfhtgpVPvxRAAAAFQCPXzpVtY5yJTN1zBo9pTGeg+f3 20 | EgAAAAZub25hbWUBAgME 21 | -----END OPENSSH PRIVATE KEY----- 22 | '); -------------------------------------------------------------------------------- /internal/dialects/spanner.go: -------------------------------------------------------------------------------- 1 | package dialects 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pressly/goose/v3/database/dialect" 7 | ) 8 | 9 | // NewSpanner returns a [dialect.Querier] for Spanner dialect. 10 | func NewSpanner() dialect.Querier { 11 | return &spanner{} 12 | } 13 | 14 | type spanner struct{} 15 | 16 | var _ dialect.Querier = (*spanner)(nil) 17 | 18 | func (s *spanner) CreateTable(tableName string) string { 19 | q := `CREATE TABLE %s ( 20 | version_id INT64 NOT NULL, 21 | is_applied BOOL NOT NULL, 22 | tstamp TIMESTAMP DEFAULT (CURRENT_TIMESTAMP()), 23 | ) PRIMARY KEY(version_id)` 24 | return fmt.Sprintf(q, tableName) 25 | } 26 | 27 | func (s *spanner) InsertVersion(tableName string) string { 28 | q := `INSERT INTO %s (version_id, is_applied) VALUES (?, ?)` 29 | return fmt.Sprintf(q, tableName) 30 | } 31 | 32 | func (s *spanner) DeleteVersion(tableName string) string { 33 | q := `DELETE FROM %s WHERE version_id=?` 34 | return fmt.Sprintf(q, tableName) 35 | } 36 | 37 | func (s *spanner) GetMigrationByVersion(tableName string) string { 38 | q := `SELECT tstamp, is_applied FROM %s WHERE version_id=? ORDER BY tstamp DESC LIMIT 1` 39 | return fmt.Sprintf(q, tableName) 40 | } 41 | 42 | func (s *spanner) ListMigrations(tableName string) string { 43 | q := `SELECT version_id, is_applied from %s ORDER BY version_id DESC` 44 | return fmt.Sprintf(q, tableName) 45 | } 46 | 47 | func (s *spanner) GetLatestVersion(tableName string) string { 48 | q := `SELECT MAX(version_id) FROM %s` 49 | return fmt.Sprintf(q, tableName) 50 | } 51 | -------------------------------------------------------------------------------- /internal/dialects/tidb.go: -------------------------------------------------------------------------------- 1 | package dialects 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pressly/goose/v3/database/dialect" 7 | ) 8 | 9 | // NewTidb returns a [dialect.Querier] for TiDB dialect. 10 | func NewTidb() dialect.Querier { 11 | return &Tidb{} 12 | } 13 | 14 | type Tidb struct{} 15 | 16 | var _ dialect.Querier = (*Tidb)(nil) 17 | 18 | func (t *Tidb) CreateTable(tableName string) string { 19 | q := `CREATE TABLE %s ( 20 | id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT UNIQUE, 21 | version_id bigint NOT NULL, 22 | is_applied boolean NOT NULL, 23 | tstamp timestamp NULL default now(), 24 | PRIMARY KEY(id) 25 | )` 26 | return fmt.Sprintf(q, tableName) 27 | } 28 | 29 | func (t *Tidb) InsertVersion(tableName string) string { 30 | q := `INSERT INTO %s (version_id, is_applied) VALUES (?, ?)` 31 | return fmt.Sprintf(q, tableName) 32 | } 33 | 34 | func (t *Tidb) DeleteVersion(tableName string) string { 35 | q := `DELETE FROM %s WHERE version_id=?` 36 | return fmt.Sprintf(q, tableName) 37 | } 38 | 39 | func (t *Tidb) GetMigrationByVersion(tableName string) string { 40 | q := `SELECT tstamp, is_applied FROM %s WHERE version_id=? ORDER BY tstamp DESC LIMIT 1` 41 | return fmt.Sprintf(q, tableName) 42 | } 43 | 44 | func (t *Tidb) ListMigrations(tableName string) string { 45 | q := `SELECT version_id, is_applied from %s ORDER BY id DESC` 46 | return fmt.Sprintf(q, tableName) 47 | } 48 | 49 | func (t *Tidb) GetLatestVersion(tableName string) string { 50 | q := `SELECT MAX(version_id) FROM %s` 51 | return fmt.Sprintf(q, tableName) 52 | } 53 | -------------------------------------------------------------------------------- /internal/dialects/sqlite3.go: -------------------------------------------------------------------------------- 1 | package dialects 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pressly/goose/v3/database/dialect" 7 | ) 8 | 9 | // NewSqlite3 returns a [dialect.Querier] for SQLite3 dialect. 10 | func NewSqlite3() dialect.Querier { 11 | return &sqlite3{} 12 | } 13 | 14 | type sqlite3 struct{} 15 | 16 | var _ dialect.Querier = (*sqlite3)(nil) 17 | 18 | func (s *sqlite3) CreateTable(tableName string) string { 19 | q := `CREATE TABLE %s ( 20 | id INTEGER PRIMARY KEY AUTOINCREMENT, 21 | version_id INTEGER NOT NULL, 22 | is_applied INTEGER NOT NULL, 23 | tstamp TIMESTAMP DEFAULT (datetime('now')) 24 | )` 25 | return fmt.Sprintf(q, tableName) 26 | } 27 | 28 | func (s *sqlite3) InsertVersion(tableName string) string { 29 | q := `INSERT INTO %s (version_id, is_applied) VALUES (?, ?)` 30 | return fmt.Sprintf(q, tableName) 31 | } 32 | 33 | func (s *sqlite3) DeleteVersion(tableName string) string { 34 | q := `DELETE FROM %s WHERE version_id=?` 35 | return fmt.Sprintf(q, tableName) 36 | } 37 | 38 | func (s *sqlite3) GetMigrationByVersion(tableName string) string { 39 | q := `SELECT tstamp, is_applied FROM %s WHERE version_id=? ORDER BY tstamp DESC LIMIT 1` 40 | return fmt.Sprintf(q, tableName) 41 | } 42 | 43 | func (s *sqlite3) ListMigrations(tableName string) string { 44 | q := `SELECT version_id, is_applied from %s ORDER BY id DESC` 45 | return fmt.Sprintf(q, tableName) 46 | } 47 | 48 | func (s *sqlite3) GetLatestVersion(tableName string) string { 49 | q := `SELECT MAX(version_id) FROM %s` 50 | return fmt.Sprintf(q, tableName) 51 | } 52 | -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-up/test03/input.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- +goose StatementBegin 3 | CREATE FUNCTION cs_update_referrer_type_proc() RETURNS INTEGER AS ' 4 | DECLARE 5 | referrer_keys RECORD; -- Declare a generic record to be used in a FOR 6 | a_output varchar(4000); 7 | BEGIN 8 | a_output := ''CREATE FUNCTION cs_find_referrer_type(varchar,varchar,varchar) 9 | RETURNS VARCHAR AS '''' 10 | DECLARE 11 | v_host ALIAS FOR $1; 12 | v_domain ALIAS FOR $2; 13 | v_url ALIAS FOR $3; 14 | BEGIN ''; 15 | 16 | -- 17 | -- Notice how we scan through the results of a query in a FOR loop 18 | -- using the FOR construct. 19 | -- 20 | 21 | FOR referrer_keys IN SELECT * FROM cs_referrer_keys ORDER BY try_order LOOP 22 | a_output := a_output || '' IF v_'' || referrer_keys.kind || '' LIKE '''''''''' 23 | || referrer_keys.key_string || '''''''''' THEN RETURN '''''' 24 | || referrer_keys.referrer_type || ''''''; END IF;''; 25 | END LOOP; 26 | 27 | a_output := a_output || '' RETURN NULL; END; '''' LANGUAGE ''''plpgsql'''';''; 28 | 29 | -- This works because we are not substituting any variables 30 | -- Otherwise it would fail. Look at PERFORM for another way to run functions 31 | 32 | EXECUTE a_output; 33 | END; 34 | ' LANGUAGE 'plpgsql'; -- This comment WILL BE preserved. 35 | -- And so will this one. 36 | -- +goose StatementEnd 37 | 38 | -- +goose Down 39 | -------------------------------------------------------------------------------- /scripts/release-notes.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | # Check if the required argument is provided 6 | if [ $# -lt 1 ]; then 7 | echo "Usage: $0 []" 8 | exit 1 9 | fi 10 | 11 | version="$1" 12 | changelog_file="${2:-CHANGELOG.md}" 13 | 14 | # Check if the changelog file exists 15 | if [ ! -f "$changelog_file" ]; then 16 | echo "Error: $changelog_file does not exist" 17 | exit 1 18 | fi 19 | 20 | CAPTURE=0 21 | items="" 22 | # Read the changelog file line by line 23 | while IFS= read -r LINE; do 24 | # Stop capturing when we reach the next version sections 25 | if [[ "${LINE}" == "##"* ]] && [[ "${CAPTURE}" -eq 1 ]]; then 26 | break 27 | fi 28 | # Stop capturing when we reach the Unreleased section 29 | if [[ "${LINE}" == "[Unreleased]"* ]]; then 30 | break 31 | fi 32 | # Start capturing when we reach the specified version section 33 | if [[ "${LINE}" == "## [${version}]"* ]] && [[ "${CAPTURE}" -eq 0 ]]; then 34 | CAPTURE=1 35 | continue 36 | fi 37 | # Capture the lines between the specified version and the next version 38 | if [[ "${CAPTURE}" -eq 1 ]]; then 39 | # Ignore empty lines 40 | if [[ -z "${LINE}" ]]; then 41 | continue 42 | fi 43 | items+="$(echo "${LINE}" | xargs -0)" 44 | # Add a newline between each item 45 | if [[ -n "$items" ]]; then 46 | items+=$'\n' 47 | fi 48 | fi 49 | done <"${changelog_file}" 50 | 51 | if [[ -n "$items" ]]; then 52 | echo "${items%$'\n'}" 53 | else 54 | echo "No changelog items found for version $version" 55 | fi 56 | -------------------------------------------------------------------------------- /internal/dialects/redshift.go: -------------------------------------------------------------------------------- 1 | package dialects 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pressly/goose/v3/database/dialect" 7 | ) 8 | 9 | // Redshift returns a new [dialect.Querier] for Redshift dialect. 10 | func NewRedshift() dialect.Querier { 11 | return &redshift{} 12 | } 13 | 14 | type redshift struct{} 15 | 16 | var _ dialect.Querier = (*redshift)(nil) 17 | 18 | func (r *redshift) CreateTable(tableName string) string { 19 | q := `CREATE TABLE %s ( 20 | id integer NOT NULL identity(1, 1), 21 | version_id bigint NOT NULL, 22 | is_applied boolean NOT NULL, 23 | tstamp timestamp NULL default sysdate, 24 | PRIMARY KEY(id) 25 | )` 26 | return fmt.Sprintf(q, tableName) 27 | } 28 | 29 | func (r *redshift) InsertVersion(tableName string) string { 30 | q := `INSERT INTO %s (version_id, is_applied) VALUES ($1, $2)` 31 | return fmt.Sprintf(q, tableName) 32 | } 33 | 34 | func (r *redshift) DeleteVersion(tableName string) string { 35 | q := `DELETE FROM %s WHERE version_id=$1` 36 | return fmt.Sprintf(q, tableName) 37 | } 38 | 39 | func (r *redshift) GetMigrationByVersion(tableName string) string { 40 | q := `SELECT tstamp, is_applied FROM %s WHERE version_id=$1 ORDER BY tstamp DESC LIMIT 1` 41 | return fmt.Sprintf(q, tableName) 42 | } 43 | 44 | func (r *redshift) ListMigrations(tableName string) string { 45 | q := `SELECT version_id, is_applied from %s ORDER BY id DESC` 46 | return fmt.Sprintf(q, tableName) 47 | } 48 | 49 | func (r *redshift) GetLatestVersion(tableName string) string { 50 | q := `SELECT max(version_id) FROM %s` 51 | return fmt.Sprintf(q, tableName) 52 | } 53 | -------------------------------------------------------------------------------- /internal/dialects/sqlserver.go: -------------------------------------------------------------------------------- 1 | package dialects 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pressly/goose/v3/database/dialect" 7 | ) 8 | 9 | // NewSqlserver returns a [dialect.Querier] for SQL Server dialect. 10 | func NewSqlserver() dialect.Querier { 11 | return &sqlserver{} 12 | } 13 | 14 | type sqlserver struct{} 15 | 16 | var _ dialect.Querier = (*sqlserver)(nil) 17 | 18 | func (s *sqlserver) CreateTable(tableName string) string { 19 | q := `CREATE TABLE %s ( 20 | id INT NOT NULL IDENTITY(1,1) PRIMARY KEY, 21 | version_id BIGINT NOT NULL, 22 | is_applied BIT NOT NULL, 23 | tstamp DATETIME NULL DEFAULT CURRENT_TIMESTAMP 24 | )` 25 | return fmt.Sprintf(q, tableName) 26 | } 27 | 28 | func (s *sqlserver) InsertVersion(tableName string) string { 29 | q := `INSERT INTO %s (version_id, is_applied) VALUES (@p1, @p2)` 30 | return fmt.Sprintf(q, tableName) 31 | } 32 | 33 | func (s *sqlserver) DeleteVersion(tableName string) string { 34 | q := `DELETE FROM %s WHERE version_id=@p1` 35 | return fmt.Sprintf(q, tableName) 36 | } 37 | 38 | func (s *sqlserver) GetMigrationByVersion(tableName string) string { 39 | q := `SELECT TOP 1 tstamp, is_applied FROM %s WHERE version_id=@p1 ORDER BY tstamp DESC` 40 | return fmt.Sprintf(q, tableName) 41 | } 42 | 43 | func (s *sqlserver) ListMigrations(tableName string) string { 44 | q := `SELECT version_id, is_applied FROM %s ORDER BY id DESC` 45 | return fmt.Sprintf(q, tableName) 46 | } 47 | 48 | func (s *sqlserver) GetLatestVersion(tableName string) string { 49 | q := `SELECT MAX(version_id) FROM %s` 50 | return fmt.Sprintf(q, tableName) 51 | } 52 | -------------------------------------------------------------------------------- /lock/table_locker_options_test.go: -------------------------------------------------------------------------------- 1 | package lock 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestTableLockerOptions(t *testing.T) { 11 | // Test that options are applied correctly 12 | locker, err := NewPostgresTableLocker( 13 | WithTableName("custom_locks"), 14 | WithTableLockID(999), 15 | WithTableLeaseDuration(10*time.Second), 16 | WithTableHeartbeatInterval(3*time.Second), 17 | ) 18 | require.NoError(t, err) 19 | require.NotNil(t, locker) 20 | // Test invalid lease duration 21 | _, err = NewPostgresTableLocker(WithTableLeaseDuration(-1 * time.Second)) 22 | require.Error(t, err) 23 | // Test invalid heartbeat interval 24 | _, err = NewPostgresTableLocker(WithTableHeartbeatInterval(0)) 25 | require.Error(t, err) 26 | // Test empty table name 27 | _, err = NewPostgresTableLocker(WithTableName("")) 28 | require.Error(t, err) 29 | // Test invalid lock ID 30 | _, err = NewPostgresTableLocker(WithTableLockID(0)) 31 | require.Error(t, err) 32 | // Test invalid lock timeout interval duration 33 | _, err = NewPostgresTableLocker(WithTableLockTimeout(0, 10)) 34 | require.Error(t, err) 35 | // Test invalid lock timeout failure threshold 36 | _, err = NewPostgresTableLocker(WithTableLockTimeout(5*time.Second, 0)) 37 | require.Error(t, err) 38 | // Test invalid unlock timeout interval duration 39 | _, err = NewPostgresTableLocker(WithTableUnlockTimeout(0, 10)) 40 | require.Error(t, err) 41 | // Test invalid unlock timeout failure threshold 42 | _, err = NewPostgresTableLocker(WithTableUnlockTimeout(5*time.Second, 0)) 43 | require.Error(t, err) 44 | } 45 | -------------------------------------------------------------------------------- /cmd/goose/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestFirstNonEmpty(t *testing.T) { 8 | tests := []struct { 9 | name string 10 | input []string 11 | expected string 12 | }{ 13 | { 14 | name: "no values", 15 | input: []string{}, 16 | expected: "", 17 | }, 18 | { 19 | name: "all empty values", 20 | input: []string{"", "", ""}, 21 | expected: "", 22 | }, 23 | { 24 | name: "single non-empty value at start", 25 | input: []string{"value", "", ""}, 26 | expected: "value", 27 | }, 28 | { 29 | name: "single non-empty value in middle", 30 | input: []string{"", "value", ""}, 31 | expected: "value", 32 | }, 33 | { 34 | name: "single non-empty value at end", 35 | input: []string{"", "", "value"}, 36 | expected: "value", 37 | }, 38 | { 39 | name: "multiple non-empty values", 40 | input: []string{"first", "second", "third"}, 41 | expected: "first", 42 | }, 43 | { 44 | name: "mixed empty and non-empty values", 45 | input: []string{"", "value1", "", "value2"}, 46 | expected: "value1", 47 | }, 48 | { 49 | name: "only one value, empty", 50 | input: []string{""}, 51 | expected: "", 52 | }, 53 | { 54 | name: "only one value, non-empty", 55 | input: []string{"value"}, 56 | expected: "value", 57 | }, 58 | } 59 | 60 | for _, tt := range tests { 61 | t.Run(tt.name, func(t *testing.T) { 62 | result := firstNonEmpty(tt.input...) 63 | if result != tt.expected { 64 | t.Errorf("expected %q, got %q", tt.expected, result) 65 | } 66 | }) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /internal/dialects/vertica.go: -------------------------------------------------------------------------------- 1 | package dialects 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pressly/goose/v3/database/dialect" 7 | ) 8 | 9 | // NewVertica returns a new [dialect.Querier] for Vertica dialect. 10 | // 11 | // DEPRECATED: Vertica support is deprecated and will be removed in a future release. 12 | func NewVertica() dialect.Querier { 13 | return &vertica{} 14 | } 15 | 16 | type vertica struct{} 17 | 18 | var _ dialect.Querier = (*vertica)(nil) 19 | 20 | func (v *vertica) CreateTable(tableName string) string { 21 | q := `CREATE TABLE %s ( 22 | id identity(1,1) NOT NULL, 23 | version_id bigint NOT NULL, 24 | is_applied boolean NOT NULL, 25 | tstamp timestamp NULL default now(), 26 | PRIMARY KEY(id) 27 | )` 28 | return fmt.Sprintf(q, tableName) 29 | } 30 | 31 | func (v *vertica) InsertVersion(tableName string) string { 32 | q := `INSERT INTO %s (version_id, is_applied) VALUES (?, ?)` 33 | return fmt.Sprintf(q, tableName) 34 | } 35 | 36 | func (v *vertica) DeleteVersion(tableName string) string { 37 | q := `DELETE FROM %s WHERE version_id=?` 38 | return fmt.Sprintf(q, tableName) 39 | } 40 | 41 | func (v *vertica) GetMigrationByVersion(tableName string) string { 42 | q := `SELECT tstamp, is_applied FROM %s WHERE version_id=? ORDER BY tstamp DESC LIMIT 1` 43 | return fmt.Sprintf(q, tableName) 44 | } 45 | 46 | func (v *vertica) ListMigrations(tableName string) string { 47 | q := `SELECT version_id, is_applied from %s ORDER BY id DESC` 48 | return fmt.Sprintf(q, tableName) 49 | } 50 | 51 | func (v *vertica) GetLatestVersion(tableName string) string { 52 | q := `SELECT MAX(version_id) FROM %s` 53 | return fmt.Sprintf(q, tableName) 54 | } 55 | -------------------------------------------------------------------------------- /database/store_extended.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import "context" 4 | 5 | // StoreExtender is an extension of the Store interface that provides optional optimizations and 6 | // database-specific features. While not required by the core goose package, implementing these 7 | // methods can improve performance and functionality for specific databases. 8 | // 9 | // IMPORTANT: This interface may be expanded in future versions. Implementors MUST be prepared to 10 | // update their implementations when new methods are added, either by implementing the new 11 | // functionality or returning [errors.ErrUnsupported]. 12 | // 13 | // The goose package handles these extended capabilities through a [controller.StoreController], 14 | // which automatically uses optimized methods when available while falling back to default behavior 15 | // when they're not implemented. 16 | // 17 | // Example usage to verify implementation: 18 | // 19 | // var _ StoreExtender = (*CustomStoreExtended)(nil) 20 | // 21 | // In short, it's exported to allows implementors to have a compile-time check that they are 22 | // implementing the interface correctly. 23 | type StoreExtender interface { 24 | Store 25 | 26 | // TableExists checks if the migrations table exists in the database. Implementing this method 27 | // allows goose to optimize table existence checks by using database-specific system catalogs 28 | // (e.g., pg_tables for PostgreSQL, sqlite_master for SQLite) instead of generic SQL queries. 29 | // 30 | // Return [errors.ErrUnsupported] if the database does not provide an efficient way to check 31 | // table existence. 32 | TableExists(ctx context.Context, db DBTxConn) (bool, error) 33 | } 34 | -------------------------------------------------------------------------------- /internal/dialects/clickhouse.go: -------------------------------------------------------------------------------- 1 | package dialects 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pressly/goose/v3/database/dialect" 7 | ) 8 | 9 | // NewClickhouse returns a new [dialect.Querier] for Clickhouse dialect. 10 | func NewClickhouse() dialect.Querier { 11 | return &clickhouse{} 12 | } 13 | 14 | type clickhouse struct{} 15 | 16 | var _ dialect.Querier = (*clickhouse)(nil) 17 | 18 | func (c *clickhouse) CreateTable(tableName string) string { 19 | q := `CREATE TABLE IF NOT EXISTS %s ( 20 | version_id Int64, 21 | is_applied UInt8, 22 | date Date default now(), 23 | tstamp DateTime default now() 24 | ) 25 | ENGINE = MergeTree() 26 | ORDER BY (date)` 27 | return fmt.Sprintf(q, tableName) 28 | } 29 | 30 | func (c *clickhouse) InsertVersion(tableName string) string { 31 | q := `INSERT INTO %s (version_id, is_applied) VALUES ($1, $2)` 32 | return fmt.Sprintf(q, tableName) 33 | } 34 | 35 | func (c *clickhouse) DeleteVersion(tableName string) string { 36 | q := `ALTER TABLE %s DELETE WHERE version_id = $1 SETTINGS mutations_sync = 2` 37 | return fmt.Sprintf(q, tableName) 38 | } 39 | 40 | func (c *clickhouse) GetMigrationByVersion(tableName string) string { 41 | q := `SELECT tstamp, is_applied FROM %s WHERE version_id = $1 ORDER BY tstamp DESC LIMIT 1` 42 | return fmt.Sprintf(q, tableName) 43 | } 44 | 45 | func (c *clickhouse) ListMigrations(tableName string) string { 46 | q := `SELECT version_id, is_applied FROM %s ORDER BY version_id DESC` 47 | return fmt.Sprintf(q, tableName) 48 | } 49 | 50 | func (c *clickhouse) GetLatestVersion(tableName string) string { 51 | q := `SELECT max(version_id) FROM %s` 52 | return fmt.Sprintf(q, tableName) 53 | } 54 | -------------------------------------------------------------------------------- /internal/dialects/starrocks.go: -------------------------------------------------------------------------------- 1 | package dialects 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pressly/goose/v3/database/dialect" 7 | ) 8 | 9 | // NewStarrocks returns a [dialect.Querier] for StarRocks dialect. 10 | func NewStarrocks() dialect.Querier { 11 | return &starrocks{} 12 | } 13 | 14 | type starrocks struct{} 15 | 16 | var _ dialect.Querier = (*starrocks)(nil) 17 | 18 | func (m *starrocks) CreateTable(tableName string) string { 19 | q := `CREATE TABLE IF NOT EXISTS %s ( 20 | id bigint NOT NULL AUTO_INCREMENT, 21 | version_id bigint NOT NULL, 22 | is_applied boolean NOT NULL, 23 | tstamp datetime NULL default CURRENT_TIMESTAMP 24 | ) 25 | PRIMARY KEY (id) 26 | DISTRIBUTED BY HASH (id) 27 | ORDER BY (id,version_id)` 28 | return fmt.Sprintf(q, tableName) 29 | } 30 | 31 | func (m *starrocks) InsertVersion(tableName string) string { 32 | q := `INSERT INTO %s (version_id, is_applied) VALUES (?, ?)` 33 | return fmt.Sprintf(q, tableName) 34 | } 35 | 36 | func (m *starrocks) DeleteVersion(tableName string) string { 37 | q := `DELETE FROM %s WHERE version_id=?` 38 | return fmt.Sprintf(q, tableName) 39 | } 40 | 41 | func (m *starrocks) GetMigrationByVersion(tableName string) string { 42 | q := `SELECT tstamp, is_applied FROM %s WHERE version_id=? ORDER BY tstamp DESC LIMIT 1` 43 | return fmt.Sprintf(q, tableName) 44 | } 45 | 46 | func (m *starrocks) ListMigrations(tableName string) string { 47 | q := `SELECT version_id, is_applied from %s ORDER BY id DESC` 48 | return fmt.Sprintf(q, tableName) 49 | } 50 | 51 | func (m *starrocks) GetLatestVersion(tableName string) string { 52 | q := `SELECT MAX(version_id) FROM %s` 53 | return fmt.Sprintf(q, tableName) 54 | } 55 | -------------------------------------------------------------------------------- /internal/sqlparser/parse.go: -------------------------------------------------------------------------------- 1 | package sqlparser 2 | 3 | import ( 4 | "fmt" 5 | "io/fs" 6 | 7 | "go.uber.org/multierr" 8 | "golang.org/x/sync/errgroup" 9 | ) 10 | 11 | type ParsedSQL struct { 12 | UseTx bool 13 | Up, Down []string 14 | } 15 | 16 | func ParseAllFromFS(fsys fs.FS, filename string, debug bool) (*ParsedSQL, error) { 17 | parsedSQL := new(ParsedSQL) 18 | // TODO(mf): parse is called twice, once for up and once for down. This is inefficient. It 19 | // should be possible to parse both directions in one pass. Also, UseTx is set once (but 20 | // returned twice), which is unnecessary and potentially error-prone if the two calls to 21 | // parseSQL disagree based on direction. 22 | var g errgroup.Group 23 | g.Go(func() error { 24 | up, useTx, err := parse(fsys, filename, DirectionUp, debug) 25 | if err != nil { 26 | return err 27 | } 28 | parsedSQL.Up = up 29 | parsedSQL.UseTx = useTx 30 | return nil 31 | }) 32 | g.Go(func() error { 33 | down, _, err := parse(fsys, filename, DirectionDown, debug) 34 | if err != nil { 35 | return err 36 | } 37 | parsedSQL.Down = down 38 | return nil 39 | }) 40 | if err := g.Wait(); err != nil { 41 | return nil, err 42 | } 43 | return parsedSQL, nil 44 | } 45 | 46 | func parse(fsys fs.FS, filename string, direction Direction, debug bool) (_ []string, _ bool, retErr error) { 47 | r, err := fsys.Open(filename) 48 | if err != nil { 49 | return nil, false, err 50 | } 51 | defer func() { 52 | retErr = multierr.Append(retErr, r.Close()) 53 | }() 54 | stmts, useTx, err := ParseSQLMigration(r, direction, debug) 55 | if err != nil { 56 | return nil, false, fmt.Errorf("failed to parse %s: %w", filename, err) 57 | } 58 | return stmts, useTx, nil 59 | } 60 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: golangci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | golangci: 16 | name: lint 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout code 20 | uses: actions/checkout@v6 21 | - uses: actions/setup-go@v6 22 | with: 23 | go-version: "stable" 24 | - run: make add-gowork 25 | - name: golangci-lint 26 | uses: golangci/golangci-lint-action@v9 27 | with: 28 | # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version 29 | version: latest 30 | github-token: ${{ secrets.GITHUB_TOKEN }} 31 | args: --timeout=2m --verbose 32 | 33 | # Optional: working directory, useful for monorepos 34 | # working-directory: somedir 35 | 36 | # Optional: golangci-lint command line arguments. 37 | # args: --issues-exit-code=0 38 | 39 | # Optional: show only new issues if it's a pull request. The default value is `false`. 40 | # only-new-issues: true 41 | 42 | # Optional: if set to true then the all caching functionality will be complete disabled, 43 | # takes precedence over all other caching options. 44 | # skip-cache: true 45 | 46 | # Optional: if set to true then the action don't cache or restore ~/go/pkg. 47 | # skip-pkg-cache: true 48 | 49 | # Optional: if set to true then the action don't cache or restore ~/.cache/go-build. 50 | # skip-build-cache: true 51 | -------------------------------------------------------------------------------- /db.go: -------------------------------------------------------------------------------- 1 | package goose 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | ) 7 | 8 | // OpenDBWithDriver creates a connection to a database, and modifies goose internals to be 9 | // compatible with the supplied driver by calling SetDialect. 10 | func OpenDBWithDriver(driver string, dbstring string) (*sql.DB, error) { 11 | if err := SetDialect(driver); err != nil { 12 | return nil, err 13 | } 14 | 15 | // The Go ecosystem has added more and more drivers over the years. As a result, there's no 16 | // longer a one-to-one match between the driver name and the dialect name. For instance, there's 17 | // no "redshift" driver, but that's the internal dialect name within goose. Hence, we need to 18 | // convert the dialect name to a supported driver name. This conversion is a best-effort 19 | // attempt, as we can't support both lib/pq and pgx, which some users might have. 20 | // 21 | // We recommend users to create a [NewProvider] with the desired dialect, open a connection 22 | // using their preferred driver, and provide the *sql.DB to goose. This approach removes the 23 | // need for mapping dialects to drivers, rendering this function unnecessary. 24 | 25 | switch driver { 26 | case "mssql": 27 | driver = "sqlserver" 28 | case "tidb": 29 | driver = "mysql" 30 | case "spanner": 31 | driver = "spanner" 32 | case "turso": 33 | driver = "libsql" 34 | case "sqlite3": 35 | driver = "sqlite" 36 | case "postgres", "redshift": 37 | driver = "pgx" 38 | case "starrocks": 39 | driver = "mysql" 40 | } 41 | 42 | switch driver { 43 | case "postgres", "pgx", "sqlite3", "sqlite", "spanner", "mysql", "sqlserver", "clickhouse", "vertica", "azuresql", "ydb", "libsql", "starrocks": 44 | return sql.Open(driver, dbstring) 45 | default: 46 | return nil, fmt.Errorf("unsupported driver %s", driver) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /reset.go: -------------------------------------------------------------------------------- 1 | package goose 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "sort" 8 | ) 9 | 10 | // Reset rolls back all migrations 11 | func Reset(db *sql.DB, dir string, opts ...OptionsFunc) error { 12 | ctx := context.Background() 13 | return ResetContext(ctx, db, dir, opts...) 14 | } 15 | 16 | // ResetContext rolls back all migrations 17 | func ResetContext(ctx context.Context, db *sql.DB, dir string, opts ...OptionsFunc) error { 18 | option := &options{} 19 | for _, f := range opts { 20 | f(option) 21 | } 22 | migrations, err := CollectMigrations(dir, minVersion, maxVersion) 23 | if err != nil { 24 | return fmt.Errorf("failed to collect migrations: %w", err) 25 | } 26 | if option.noVersioning { 27 | return DownToContext(ctx, db, dir, minVersion, opts...) 28 | } 29 | 30 | statuses, err := dbMigrationsStatus(ctx, db) 31 | if err != nil { 32 | return fmt.Errorf("failed to get status of migrations: %w", err) 33 | } 34 | sort.Sort(sort.Reverse(migrations)) 35 | 36 | for _, migration := range migrations { 37 | if !statuses[migration.Version] { 38 | continue 39 | } 40 | if err = migration.DownContext(ctx, db); err != nil { 41 | return fmt.Errorf("failed to db-down: %w", err) 42 | } 43 | } 44 | 45 | return nil 46 | } 47 | 48 | func dbMigrationsStatus(ctx context.Context, db *sql.DB) (map[int64]bool, error) { 49 | dbMigrations, err := store.ListMigrations(ctx, db, TableName()) 50 | if err != nil { 51 | return nil, err 52 | } 53 | // The most recent record for each migration specifies 54 | // whether it has been applied or rolled back. 55 | results := make(map[int64]bool) 56 | 57 | for _, m := range dbMigrations { 58 | if _, ok := results[m.VersionID]; ok { 59 | continue 60 | } 61 | results[m.VersionID] = m.IsApplied 62 | } 63 | return results, nil 64 | } 65 | -------------------------------------------------------------------------------- /goose_embed_test.go: -------------------------------------------------------------------------------- 1 | package goose_test 2 | 3 | import ( 4 | "database/sql" 5 | "embed" 6 | "io/fs" 7 | "os" 8 | "path/filepath" 9 | "testing" 10 | 11 | "github.com/pressly/goose/v3" 12 | "github.com/stretchr/testify/require" 13 | _ "modernc.org/sqlite" 14 | ) 15 | 16 | //go:embed testdata/migrations/*.sql 17 | var embedMigrations embed.FS 18 | 19 | func TestEmbeddedMigrations(t *testing.T) { 20 | dir := t.TempDir() 21 | // not using t.Parallel here to avoid races 22 | db, err := sql.Open("sqlite", filepath.Join(dir, "sql_embed.db")) 23 | require.NoError(t, err) 24 | 25 | db.SetMaxOpenConns(1) 26 | 27 | migrationFiles, err := fs.ReadDir(embedMigrations, "testdata/migrations") 28 | require.NoError(t, err) 29 | total := len(migrationFiles) 30 | 31 | // decouple from existing structure 32 | fsys, err := fs.Sub(embedMigrations, "testdata/migrations") 33 | require.NoError(t, err) 34 | 35 | goose.SetBaseFS(fsys) 36 | t.Cleanup(func() { goose.SetBaseFS(nil) }) 37 | require.NoError(t, goose.SetDialect("sqlite3")) 38 | 39 | t.Run("migration_cycle", func(t *testing.T) { 40 | err := goose.Up(db, ".") 41 | require.NoError(t, err) 42 | ver, err := goose.GetDBVersion(db) 43 | require.NoError(t, err) 44 | require.EqualValues(t, ver, total) 45 | err = goose.Reset(db, ".") 46 | require.NoError(t, err) 47 | ver, err = goose.GetDBVersion(db) 48 | require.NoError(t, err) 49 | require.EqualValues(t, 0, ver) 50 | }) 51 | t.Run("create_uses_os_fs", func(t *testing.T) { 52 | dir := t.TempDir() 53 | err := goose.Create(db, dir, "test", "sql") 54 | require.NoError(t, err) 55 | paths, _ := filepath.Glob(filepath.Join(dir, "*test.sql")) 56 | require.NotEmpty(t, paths) 57 | err = goose.Fix(dir) 58 | require.NoError(t, err) 59 | _, err = os.Stat(filepath.Join(dir, "00001_test.sql")) 60 | require.NoError(t, err) 61 | }) 62 | } 63 | -------------------------------------------------------------------------------- /provider_errors.go: -------------------------------------------------------------------------------- 1 | package goose 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | var ( 9 | // ErrVersionNotFound is returned when a specific migration version is not located. This can 10 | // occur if a .sql file or a Go migration function for the specified version is missing. 11 | ErrVersionNotFound = errors.New("version not found") 12 | 13 | // ErrNoMigrations is returned by [NewProvider] when no migrations are found. 14 | ErrNoMigrations = errors.New("no migrations found") 15 | 16 | // ErrAlreadyApplied indicates that the migration cannot be applied because it has already been 17 | // executed. This error is returned by [Provider.Apply]. 18 | ErrAlreadyApplied = errors.New("migration already applied") 19 | 20 | // ErrNotApplied indicates that the rollback cannot be performed because the migration has not 21 | // yet been applied. This error is returned by [Provider.Apply]. 22 | ErrNotApplied = errors.New("migration not applied") 23 | 24 | // errInvalidVersion is returned when a migration version is invalid. 25 | errInvalidVersion = errors.New("version must be greater than 0") 26 | ) 27 | 28 | // PartialError is returned when a migration fails, but some migrations already got applied. 29 | type PartialError struct { 30 | // Applied are migrations that were applied successfully before the error occurred. May be 31 | // empty. 32 | Applied []*MigrationResult 33 | // Failed contains the result of the migration that failed. Cannot be nil. 34 | Failed *MigrationResult 35 | // Err is the error that occurred while running the migration and caused the failure. 36 | Err error 37 | } 38 | 39 | func (e *PartialError) Error() string { 40 | return fmt.Sprintf( 41 | "partial migration error (type:%s,version:%d): %v", 42 | e.Failed.Source.Type, e.Failed.Source.Version, e.Err, 43 | ) 44 | } 45 | 46 | func (e *PartialError) Unwrap() error { 47 | return e.Err 48 | } 49 | -------------------------------------------------------------------------------- /internal/testing/integration/testdata/migrations/clickhouse/00001_a.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | CREATE TABLE IF NOT EXISTS trips 3 | ( 4 | `trip_id` UInt32, 5 | `vendor_id` Enum8('1' = 1, '2' = 2, '3' = 3, '4' = 4, 'CMT' = 5, 'VTS' = 6, 'DDS' = 7, 'B02512' = 10, 'B02598' = 11, 'B02617' = 12, 'B02682' = 13, 'B02764' = 14, '' = 15), 6 | `pickup_date` Date, 7 | `pickup_datetime` DateTime, 8 | `dropoff_date` Date, 9 | `dropoff_datetime` DateTime, 10 | `store_and_fwd_flag` UInt8, 11 | `rate_code_id` UInt8, 12 | `pickup_longitude` Float64, 13 | `pickup_latitude` Float64, 14 | `dropoff_longitude` Float64, 15 | `dropoff_latitude` Float64, 16 | `passenger_count` UInt8, 17 | `trip_distance` Float64, 18 | `fare_amount` Float32, 19 | `extra` Float32, 20 | `mta_tax` Float32, 21 | `tip_amount` Float32, 22 | `tolls_amount` Float32, 23 | `ehail_fee` Float32, 24 | `improvement_surcharge` Float32, 25 | `total_amount` Float32, 26 | `payment_type` Enum8('UNK' = 0, 'CSH' = 1, 'CRE' = 2, 'NOC' = 3, 'DIS' = 4), 27 | `trip_type` UInt8, 28 | `pickup` FixedString(25), 29 | `dropoff` FixedString(25), 30 | `cab_type` Enum8('yellow' = 1, 'green' = 2, 'uber' = 3), 31 | `pickup_nyct2010_gid` Int8, 32 | `pickup_ctlabel` Float32, 33 | `pickup_borocode` Int8, 34 | `pickup_ct2010` String, 35 | `pickup_boroct2010` FixedString(7), 36 | `pickup_cdeligibil` String, 37 | `pickup_ntacode` FixedString(4), 38 | `pickup_ntaname` String, 39 | `pickup_puma` UInt16, 40 | `dropoff_nyct2010_gid` UInt8, 41 | `dropoff_ctlabel` Float32, 42 | `dropoff_borocode` UInt8, 43 | `dropoff_ct2010` String, 44 | `dropoff_boroct2010` FixedString(7), 45 | `dropoff_cdeligibil` String, 46 | `dropoff_ntacode` FixedString(4), 47 | `dropoff_ntaname` String, 48 | `dropoff_puma` UInt16 49 | ) 50 | ENGINE = MergeTree 51 | PARTITION BY toYYYYMM(pickup_date) 52 | ORDER BY pickup_datetime; 53 | 54 | -- +goose Down 55 | DROP TABLE IF EXISTS trips; 56 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: Goose CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | test: 16 | name: Run unit tests 17 | timeout-minutes: 10 18 | 19 | strategy: 20 | matrix: 21 | go-version: [oldstable, stable] 22 | os: [ubuntu-latest] 23 | 24 | runs-on: ${{ matrix.os }} 25 | 26 | steps: 27 | - name: Checkout code 28 | uses: actions/checkout@v6 29 | - name: Install Go 30 | uses: actions/setup-go@v6 31 | with: 32 | go-version: ${{ matrix.go-version }} 33 | - name: Check Go code formatting 34 | run: | 35 | if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then 36 | gofmt -s -l . 37 | echo "Please format Go code by running: go fmt ./..." 38 | exit 1 39 | fi 40 | - name: Install tparse 41 | run: | 42 | mkdir -p $HOME/.local/bin 43 | curl -L -o $HOME/.local/bin/tparse https://github.com/mfridman/tparse/releases/latest/download/tparse_linux_x86_64 44 | chmod +x $HOME/.local/bin/tparse 45 | echo "$HOME/.local/bin" >> "$GITHUB_PATH" 46 | - name: Run tests 47 | run: | 48 | make add-gowork 49 | mkdir -p bin 50 | go vet ./... 51 | go build ./... 52 | make test-packages 53 | - name: Install GoReleaser 54 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' && matrix.go-version == 'stable' 55 | uses: goreleaser/goreleaser-action@v6 56 | with: 57 | install-only: true 58 | distribution: goreleaser 59 | version: "~> v2" 60 | - name: Gorelease dry-run 61 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' && matrix.go-version == 'stable' 62 | run: | 63 | goreleaser release --skip=publish --snapshot --fail-fast --clean 64 | -------------------------------------------------------------------------------- /internal/dialects/mysql.go: -------------------------------------------------------------------------------- 1 | package dialects 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pressly/goose/v3/database/dialect" 7 | ) 8 | 9 | // NewMysql returns a new [dialect.Querier] for MySQL dialect. 10 | func NewMysql() dialect.QuerierExtender { 11 | return &mysql{} 12 | } 13 | 14 | type mysql struct{} 15 | 16 | var _ dialect.QuerierExtender = (*mysql)(nil) 17 | 18 | func (m *mysql) CreateTable(tableName string) string { 19 | q := `CREATE TABLE %s ( 20 | id bigint(20) unsigned NOT NULL AUTO_INCREMENT, 21 | version_id bigint NOT NULL, 22 | is_applied boolean NOT NULL, 23 | tstamp timestamp NULL default now(), 24 | PRIMARY KEY(id) 25 | )` 26 | return fmt.Sprintf(q, tableName) 27 | } 28 | 29 | func (m *mysql) InsertVersion(tableName string) string { 30 | q := `INSERT INTO %s (version_id, is_applied) VALUES (?, ?)` 31 | return fmt.Sprintf(q, tableName) 32 | } 33 | 34 | func (m *mysql) DeleteVersion(tableName string) string { 35 | q := `DELETE FROM %s WHERE version_id=?` 36 | return fmt.Sprintf(q, tableName) 37 | } 38 | 39 | func (m *mysql) GetMigrationByVersion(tableName string) string { 40 | q := `SELECT tstamp, is_applied FROM %s WHERE version_id=? ORDER BY tstamp DESC LIMIT 1` 41 | return fmt.Sprintf(q, tableName) 42 | } 43 | 44 | func (m *mysql) ListMigrations(tableName string) string { 45 | q := `SELECT version_id, is_applied from %s ORDER BY id DESC` 46 | return fmt.Sprintf(q, tableName) 47 | } 48 | 49 | func (m *mysql) GetLatestVersion(tableName string) string { 50 | q := `SELECT MAX(version_id) FROM %s` 51 | return fmt.Sprintf(q, tableName) 52 | } 53 | 54 | func (m *mysql) TableExists(tableName string) string { 55 | schemaName, tableName := parseTableIdentifier(tableName) 56 | if schemaName != "" { 57 | q := `SELECT EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema = '%s' AND table_name = '%s' )` 58 | return fmt.Sprintf(q, schemaName, tableName) 59 | } 60 | q := `SELECT EXISTS ( SELECT 1 FROM information_schema.tables WHERE (database() IS NULL OR table_schema = database()) AND table_name = '%s' )` 61 | return fmt.Sprintf(q, tableName) 62 | } 63 | -------------------------------------------------------------------------------- /cmd/goose/driver_mysql.go: -------------------------------------------------------------------------------- 1 | //go:build !no_mysql 2 | 3 | package main 4 | 5 | import ( 6 | "crypto/tls" 7 | "crypto/x509" 8 | "fmt" 9 | "log" 10 | "os" 11 | 12 | "github.com/go-sql-driver/mysql" 13 | _ "github.com/ziutek/mymysql/godrv" 14 | ) 15 | 16 | // normalizeMySQLDSN parses the dsn used with the mysql driver to always have 17 | // the parameter `parseTime` set to true. This allows internal goose logic 18 | // to assume that DATETIME/DATE/TIMESTAMP can be scanned into the time.Time 19 | // type. 20 | func normalizeDBString(driver string, str string, certfile string, sslcert string, sslkey string) string { 21 | if driver == "mysql" { 22 | isTLS := certfile != "" 23 | if isTLS { 24 | if err := registerTLSConfig(certfile, sslcert, sslkey); err != nil { 25 | log.Fatalf("goose run: %v", err) 26 | } 27 | } 28 | var err error 29 | str, err = normalizeMySQLDSN(str, isTLS) 30 | if err != nil { 31 | log.Fatalf("failed to normalize MySQL connection string: %v", err) 32 | } 33 | } 34 | return str 35 | } 36 | 37 | const tlsConfigKey = "custom" 38 | 39 | func normalizeMySQLDSN(dsn string, tls bool) (string, error) { 40 | config, err := mysql.ParseDSN(dsn) 41 | if err != nil { 42 | return "", err 43 | } 44 | config.ParseTime = true 45 | if tls { 46 | config.TLSConfig = tlsConfigKey 47 | } 48 | return config.FormatDSN(), nil 49 | } 50 | 51 | func registerTLSConfig(pemfile string, sslcert string, sslkey string) error { 52 | rootCertPool := x509.NewCertPool() 53 | pem, err := os.ReadFile(pemfile) 54 | if err != nil { 55 | return err 56 | } 57 | if ok := rootCertPool.AppendCertsFromPEM(pem); !ok { 58 | return fmt.Errorf("failed to append PEM: %q", pemfile) 59 | } 60 | 61 | tlsConfig := &tls.Config{ 62 | RootCAs: rootCertPool, 63 | } 64 | if sslcert != "" && sslkey != "" { 65 | cert, err := tls.LoadX509KeyPair(sslcert, sslkey) 66 | if err != nil { 67 | return fmt.Errorf("failed to load x509 keypair: %w", err) 68 | } 69 | tlsConfig.Certificates = append(tlsConfig.Certificates, cert) 70 | } 71 | return mysql.RegisterTLSConfig(tlsConfigKey, tlsConfig) 72 | } 73 | -------------------------------------------------------------------------------- /helpers.go: -------------------------------------------------------------------------------- 1 | package goose 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "unicode" 7 | "unicode/utf8" 8 | ) 9 | 10 | type camelSnakeStateMachine int 11 | 12 | const ( // _$$_This is some text, OK?! 13 | idle camelSnakeStateMachine = iota // 0 ↑ ↑ ↑ 14 | firstAlphaNum // 1 ↑ ↑ ↑ ↑ ↑ 15 | alphaNum // 2 ↑↑↑ ↑ ↑↑↑ ↑↑↑ ↑ 16 | delimiter // 3 ↑ ↑ ↑ ↑ ↑ 17 | ) 18 | 19 | func (s camelSnakeStateMachine) next(r rune) camelSnakeStateMachine { 20 | switch s { 21 | case idle: 22 | if isAlphaNum(r) { 23 | return firstAlphaNum 24 | } 25 | case firstAlphaNum: 26 | if isAlphaNum(r) { 27 | return alphaNum 28 | } 29 | return delimiter 30 | case alphaNum: 31 | if !isAlphaNum(r) { 32 | return delimiter 33 | } 34 | case delimiter: 35 | if isAlphaNum(r) { 36 | return firstAlphaNum 37 | } 38 | return idle 39 | } 40 | return s 41 | } 42 | 43 | func camelCase(str string) string { 44 | var b strings.Builder 45 | 46 | stateMachine := idle 47 | for i := 0; i < len(str); { 48 | r, size := utf8.DecodeRuneInString(str[i:]) 49 | i += size 50 | stateMachine = stateMachine.next(r) 51 | switch stateMachine { 52 | case firstAlphaNum: 53 | b.WriteRune(unicode.ToUpper(r)) 54 | case alphaNum: 55 | b.WriteRune(unicode.ToLower(r)) 56 | } 57 | } 58 | return b.String() 59 | } 60 | 61 | func snakeCase(str string) string { 62 | var b bytes.Buffer 63 | 64 | stateMachine := idle 65 | for i := 0; i < len(str); { 66 | r, size := utf8.DecodeRuneInString(str[i:]) 67 | i += size 68 | stateMachine = stateMachine.next(r) 69 | switch stateMachine { 70 | case firstAlphaNum, alphaNum: 71 | b.WriteRune(unicode.ToLower(r)) 72 | case delimiter: 73 | b.WriteByte('_') 74 | } 75 | } 76 | if stateMachine == idle { 77 | return string(bytes.TrimSuffix(b.Bytes(), []byte{'_'})) 78 | } 79 | return b.String() 80 | } 81 | 82 | func isAlphaNum(r rune) bool { 83 | return unicode.IsLetter(r) || unicode.IsNumber(r) 84 | } 85 | -------------------------------------------------------------------------------- /internal/dialects/dsql.go: -------------------------------------------------------------------------------- 1 | package dialects 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pressly/goose/v3/database/dialect" 7 | ) 8 | 9 | // NewAuroraDSQL returns a new [dialect.Querier] for Aurora DSQL dialect. 10 | func NewAuroraDSQL() dialect.QuerierExtender { 11 | return &dsql{} 12 | } 13 | 14 | type dsql struct{} 15 | 16 | var _ dialect.QuerierExtender = (*dsql)(nil) 17 | 18 | func (d *dsql) CreateTable(tableName string) string { 19 | q := `CREATE TABLE %s ( 20 | id integer PRIMARY KEY, 21 | version_id bigint NOT NULL, 22 | is_applied boolean NOT NULL, 23 | tstamp timestamp NOT NULL DEFAULT now() 24 | )` 25 | return fmt.Sprintf(q, tableName) 26 | } 27 | 28 | func (d *dsql) InsertVersion(tableName string) string { 29 | q := `INSERT INTO %s (id, version_id, is_applied) 30 | VALUES ( 31 | COALESCE((SELECT MAX(id) FROM %s), 0) + 1, 32 | $1, 33 | $2 34 | )` 35 | return fmt.Sprintf(q, tableName, tableName) 36 | } 37 | 38 | func (d *dsql) DeleteVersion(tableName string) string { 39 | q := `DELETE FROM %s WHERE version_id=$1` 40 | return fmt.Sprintf(q, tableName) 41 | } 42 | 43 | func (d *dsql) GetMigrationByVersion(tableName string) string { 44 | q := `SELECT tstamp, is_applied FROM %s WHERE version_id=$1 ORDER BY tstamp DESC LIMIT 1` 45 | return fmt.Sprintf(q, tableName) 46 | } 47 | 48 | func (d *dsql) ListMigrations(tableName string) string { 49 | q := `SELECT version_id, is_applied from %s ORDER BY id DESC` 50 | return fmt.Sprintf(q, tableName) 51 | } 52 | 53 | func (d *dsql) GetLatestVersion(tableName string) string { 54 | q := `SELECT max(version_id) FROM %s` 55 | return fmt.Sprintf(q, tableName) 56 | } 57 | 58 | func (d *dsql) TableExists(tableName string) string { 59 | schemaName, tableName := parseTableIdentifier(tableName) 60 | if schemaName != "" { 61 | q := `SELECT EXISTS ( SELECT 1 FROM pg_tables WHERE schemaname = '%s' AND tablename = '%s' )` 62 | return fmt.Sprintf(q, schemaName, tableName) 63 | } 64 | q := `SELECT EXISTS ( SELECT 1 FROM pg_tables WHERE (current_schema() IS NULL OR schemaname = current_schema()) AND tablename = '%s' )` 65 | return fmt.Sprintf(q, tableName) 66 | } 67 | -------------------------------------------------------------------------------- /internal/dialects/ydb.go: -------------------------------------------------------------------------------- 1 | package dialects 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pressly/goose/v3/database/dialect" 7 | ) 8 | 9 | // NewYDB returns a new [dialect.Querier] for Vertica dialect. 10 | func NewYDB() dialect.Querier { 11 | return &ydb{} 12 | } 13 | 14 | type ydb struct{} 15 | 16 | var _ dialect.Querier = (*ydb)(nil) 17 | 18 | func formatYDBTableName(tableName string) string { 19 | return fmt.Sprintf("`%s`", tableName) 20 | } 21 | 22 | func (c *ydb) CreateTable(tableName string) string { 23 | formatedYDBTableName := formatYDBTableName(tableName) 24 | q := `CREATE TABLE %s ( 25 | version_id Uint64, 26 | is_applied Bool, 27 | tstamp Timestamp, 28 | 29 | PRIMARY KEY(version_id) 30 | )` 31 | return fmt.Sprintf(q, formatedYDBTableName) 32 | } 33 | 34 | func (c *ydb) InsertVersion(tableName string) string { 35 | formatedYDBTableName := formatYDBTableName(tableName) 36 | q := `INSERT INTO %s ( 37 | version_id, 38 | is_applied, 39 | tstamp 40 | ) VALUES ( 41 | CAST($1 AS Uint64), 42 | $2, 43 | CurrentUtcTimestamp() 44 | )` 45 | return fmt.Sprintf(q, formatedYDBTableName) 46 | } 47 | 48 | func (c *ydb) DeleteVersion(tableName string) string { 49 | formatedYDBTableName := formatYDBTableName(tableName) 50 | q := `DELETE FROM %s WHERE version_id = $1` 51 | return fmt.Sprintf(q, formatedYDBTableName) 52 | } 53 | 54 | func (c *ydb) GetMigrationByVersion(tableName string) string { 55 | formatedYDBTableName := formatYDBTableName(tableName) 56 | q := `SELECT tstamp, is_applied FROM %s WHERE version_id = $1 ORDER BY tstamp DESC LIMIT 1` 57 | return fmt.Sprintf(q, formatedYDBTableName) 58 | } 59 | 60 | func (c *ydb) ListMigrations(tableName string) string { 61 | formatedYDBTableName := formatYDBTableName(tableName) 62 | q := ` 63 | SELECT version_id, is_applied, tstamp AS __discard_column_tstamp 64 | FROM %s ORDER BY __discard_column_tstamp DESC` 65 | return fmt.Sprintf(q, formatedYDBTableName) 66 | } 67 | 68 | func (c *ydb) GetLatestVersion(tableName string) string { 69 | formatedYDBTableName := formatYDBTableName(tableName) 70 | q := `SELECT MAX(version_id) FROM %s` 71 | return fmt.Sprintf(q, formatedYDBTableName) 72 | } 73 | -------------------------------------------------------------------------------- /lock/internal/store/store.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "time" 7 | ) 8 | 9 | // LockStore defines the interface for storing and managing database locks. 10 | type LockStore interface { 11 | // CreateLockTable creates the lock table if it doesn't exist. Implementations should ensure 12 | // that this operation is idempotent. 13 | CreateLockTable(ctx context.Context, db *sql.DB) error 14 | // TableExists checks if the lock table exists. 15 | TableExists(ctx context.Context, db *sql.DB) (bool, error) 16 | // AcquireLock attempts to acquire a lock for the given lockID. 17 | AcquireLock(ctx context.Context, db *sql.DB, lockID int64, lockedBy string, leaseDuration time.Duration) (*AcquireLockResult, error) 18 | // ReleaseLock releases a lock held by the current instance. 19 | ReleaseLock(ctx context.Context, db *sql.DB, lockID int64, lockedBy string) (*ReleaseLockResult, error) 20 | // UpdateLease updates the lease expiration time for a lock (heartbeat). 21 | UpdateLease(ctx context.Context, db *sql.DB, lockID int64, lockedBy string, leaseDuration time.Duration) (*UpdateLeaseResult, error) 22 | // CheckLockStatus checks the current status of a lock. 23 | CheckLockStatus(ctx context.Context, db *sql.DB, lockID int64) (*LockStatus, error) 24 | // CleanupStaleLocks removes any locks that have expired using server time. Returns the list of 25 | // lock IDs that were cleaned up, if any. 26 | CleanupStaleLocks(ctx context.Context, db *sql.DB) ([]int64, error) 27 | } 28 | 29 | // LockStatus represents the current status of a lock. 30 | type LockStatus struct { 31 | Locked bool 32 | LockedBy *string 33 | LeaseExpiresAt *time.Time 34 | UpdatedAt *time.Time 35 | } 36 | 37 | // AcquireLockResult contains the result of a lock acquisition attempt. 38 | type AcquireLockResult struct { 39 | LockedBy string 40 | LeaseExpiresAt time.Time 41 | } 42 | 43 | // ReleaseLockResult contains the result of a lock release. 44 | type ReleaseLockResult struct { 45 | LockID int64 46 | } 47 | 48 | // UpdateLeaseResult contains the result of a lease update. 49 | type UpdateLeaseResult struct { 50 | LeaseExpiresAt time.Time 51 | } 52 | -------------------------------------------------------------------------------- /status.go: -------------------------------------------------------------------------------- 1 | package goose 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "fmt" 8 | "path/filepath" 9 | "time" 10 | ) 11 | 12 | // Status prints the status of all migrations. 13 | func Status(db *sql.DB, dir string, opts ...OptionsFunc) error { 14 | ctx := context.Background() 15 | return StatusContext(ctx, db, dir, opts...) 16 | } 17 | 18 | // StatusContext prints the status of all migrations. 19 | func StatusContext(ctx context.Context, db *sql.DB, dir string, opts ...OptionsFunc) error { 20 | option := &options{} 21 | for _, f := range opts { 22 | f(option) 23 | } 24 | migrations, err := CollectMigrations(dir, minVersion, maxVersion) 25 | if err != nil { 26 | return fmt.Errorf("failed to collect migrations: %w", err) 27 | } 28 | if option.noVersioning { 29 | log.Printf(" Applied At Migration") 30 | log.Printf(" =======================================") 31 | for _, current := range migrations { 32 | log.Printf(" %-24s -- %v", "no versioning", filepath.Base(current.Source)) 33 | } 34 | return nil 35 | } 36 | 37 | // must ensure that the version table exists if we're running on a pristine DB 38 | if _, err := EnsureDBVersionContext(ctx, db); err != nil { 39 | return fmt.Errorf("failed to ensure DB version: %w", err) 40 | } 41 | 42 | log.Printf(" Applied At Migration") 43 | log.Printf(" =======================================") 44 | for _, migration := range migrations { 45 | if err := printMigrationStatus(ctx, db, migration.Version, filepath.Base(migration.Source)); err != nil { 46 | return fmt.Errorf("failed to print status: %w", err) 47 | } 48 | } 49 | 50 | return nil 51 | } 52 | 53 | func printMigrationStatus(ctx context.Context, db *sql.DB, version int64, script string) error { 54 | m, err := store.GetMigration(ctx, db, TableName(), version) 55 | if err != nil && !errors.Is(err, sql.ErrNoRows) { 56 | return fmt.Errorf("failed to query the latest migration: %w", err) 57 | } 58 | appliedAt := "Pending" 59 | if m != nil && m.IsApplied { 60 | appliedAt = m.Timestamp.Format(time.ANSIC) 61 | } 62 | log.Printf(" %-24s -- %v", appliedAt, script) 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /internal/migrationstats/migrationstats.go: -------------------------------------------------------------------------------- 1 | package migrationstats 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "path/filepath" 7 | 8 | "github.com/pressly/goose/v3" 9 | ) 10 | 11 | // FileWalker walks all files for GatherStats. 12 | type FileWalker interface { 13 | // Walk invokes fn for each file. 14 | Walk(fn func(filename string, r io.Reader) error) error 15 | } 16 | 17 | // Stats contains the stats for a migration file. 18 | type Stats struct { 19 | // FileName is the name of the file. 20 | FileName string 21 | // Version is the version of the migration. 22 | Version int64 23 | // Tx is true if the .sql migration file has a +goose NO TRANSACTION annotation 24 | // or the .go migration file calls AddMigrationNoTx. 25 | Tx bool 26 | // UpCount is the number of statements in the Up migration. 27 | UpCount int 28 | // DownCount is the number of statements in the Down migration. 29 | DownCount int 30 | } 31 | 32 | // GatherStats returns the migration file stats. 33 | func GatherStats(fw FileWalker, debug bool) ([]*Stats, error) { 34 | var stats []*Stats 35 | err := fw.Walk(func(filename string, r io.Reader) error { 36 | version, err := goose.NumericComponent(filename) 37 | if err != nil { 38 | return fmt.Errorf("failed to get version from file %q: %w", filename, err) 39 | } 40 | var up, down int 41 | var tx bool 42 | switch filepath.Ext(filename) { 43 | case ".sql": 44 | m, err := parseSQLFile(r, debug) 45 | if err != nil { 46 | return fmt.Errorf("failed to parse file %q: %w", filename, err) 47 | } 48 | up, down = m.upCount, m.downCount 49 | tx = m.useTx 50 | case ".go": 51 | m, err := parseGoFile(r) 52 | if err != nil { 53 | return fmt.Errorf("failed to parse file %q: %w", filename, err) 54 | } 55 | up, down = nilAsNumber(m.upFuncName), nilAsNumber(m.downFuncName) 56 | tx = *m.useTx 57 | } 58 | stats = append(stats, &Stats{ 59 | FileName: filename, 60 | Version: version, 61 | Tx: tx, 62 | UpCount: up, 63 | DownCount: down, 64 | }) 65 | return nil 66 | }) 67 | if err != nil { 68 | return nil, err 69 | } 70 | return stats, nil 71 | } 72 | 73 | func nilAsNumber(s string) int { 74 | if s != "nil" { 75 | return 1 76 | } 77 | return 0 78 | } 79 | -------------------------------------------------------------------------------- /internal/sqlparser/testdata/valid-up/test04/input.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | 3 | CREATE TABLE ssh_keys ( 4 | id integer NOT NULL, 5 | "publicKey" text 6 | ); 7 | 8 | -- +goose StatementBegin 9 | INSERT INTO ssh_keys (id, "publicKey") VALUES (1, '-----BEGIN RSA PUBLIC KEY----- 10 | MIIBCgKCAQEAqAo9QORIXMPMa/qv8908Z2sH2+Xa/wITYTJQ2ojTZlgsQiQf85ifw3dgvVZK 11 | M7Zifl2NyVVCPb0hELr2JJla1u/1CgiuqDpcjP2cCu2YxB/JGyCvcon+3tETUz3Ri9NGzHCZ 12 | fkuWRZjkUvy7nfPLjzM+t6SEvY4lbn3ihLPumZjwgvuCY3vDZY8V1/NMoP8MKATGR+S7D7gv 13 | I6KD9jkiSsTJMiotb/dRkXE3bG0nmjchhhLzMG551G8IZEpWBHDqEisCIl8yCd9YZV69BZTu 14 | L48zPl/CFvA+KJJ6LklxfwWeVDQ+ve2OIW0B1uLhR/MsoYbDQztbgIayg6ieMO/KlQIDAQAB 15 | -----END RSA PUBLIC KEY----- 16 | '); 17 | -- +goose StatementEnd 18 | 19 | INSERT INTO ssh_keys (id, "publicKey") VALUES (2, '-----BEGIN OPENSSH PRIVATE KEY----- 20 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABsQAAAAdzc2gtZH 21 | NzAAAAgQDKYlGRv+Ul4WYKN3F83zchQC24ZV17EJCKwakASf//u2p1+K4iuTQbPgcUWpET 22 | w4HkCRlOVwYkM4ZL2QncUDyEX3o0UDEgrnhWhLJJR2yCMSsqTNffME3X7YdP2LcE0OM9ZP 23 | 9vb5+TwLr6c4edwlu3QYc4VStWSstdR9DD7vun9QAAABUA+DRjW9u+5IEPHyx+FhEoKsRm 24 | 8aEAAACAU2auArSDuTVbJBE/oP6x+4vrud2qFtALsuFaLPfqN5gVBofRJlIOpQobyi022R 25 | 6SPtX/DEbWkVuHfiyxkjb0Gb0obBzGM+RNqjOF6OE9sh7OuBfLaWW44OZasg1VXzkRcoWv 26 | 7jClfKYi2Q/LxHGhZoqy1uYlHPYP5CmCpiELrUMAAACAfp0KpTVyYQjO2nLPuBhnsepfxQ 27 | kT+FDqDBp1rZfB4uKx4q466Aq0jeev1OeQEYZpj3+q4b2XX54zXDwvJLuiD9WSmC7jvT0+ 28 | EUmF55PHW4inloG9pMUzeQnx3k8WDcRJbcAMalpoCCsb0jEPIiyBGBtQu0gOoLL+N+G2Cl 29 | U+/FEAAAHgOKSlwjikpcIAAAAHc3NoLWRzcwAAAIEAymJRkb/lJeFmCjdxfN83IUAtuGVd 30 | exCQisGpAEn//7tqdfiuIrk0Gz4HFFqRE8OB5AkZTlcGJDOGS9kJ3FA8hF96NFAxIK54Vo 31 | SySUdsgjErKkzX3zBN1+2HT9i3BNDjPWT/b2+fk8C6+nOHncJbt0GHOFUrVkrLXUfQw+77 32 | p/UAAAAVAPg0Y1vbvuSBDx8sfhYRKCrEZvGhAAAAgFNmrgK0g7k1WyQRP6D+sfuL67ndqh 33 | bQC7LhWiz36jeYFQaH0SZSDqUKG8otNtkekj7V/wxG1pFbh34ssZI29Bm9KGwcxjPkTaoz 34 | hejhPbIezrgXy2lluODmWrINVV85EXKFr+4wpXymItkPy8RxoWaKstbmJRz2D+QpgqYhC6 35 | 1DAAAAgH6dCqU1cmEIztpyz7gYZ7HqX8UJE/hQ6gwada2XweLiseKuOugKtI3nr9TnkBGG 36 | aY9/quG9l1+eM1w8LyS7og/Vkpgu4709PhFJheeTx1uIp5aBvaTFM3kJ8d5PFg3ESW3ADG 37 | paaAgrG9IxDyIsgRgbULtIDqCy/jfhtgpVPvxRAAAAFQCPXzpVtY5yJTN1zBo9pTGeg+f3 38 | EgAAAAZub25hbWUBAgME 39 | -----END OPENSSH PRIVATE KEY----- 40 | '); 41 | 42 | -- +goose Down 43 | -------------------------------------------------------------------------------- /dialect.go: -------------------------------------------------------------------------------- 1 | package goose 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pressly/goose/v3/database" 7 | "github.com/pressly/goose/v3/internal/legacystore" 8 | ) 9 | 10 | // Dialect is the type of database dialect. It is an alias for [database.Dialect]. 11 | type Dialect = database.Dialect 12 | 13 | const ( 14 | DialectCustom Dialect = database.DialectCustom 15 | DialectClickHouse Dialect = database.DialectClickHouse 16 | DialectMSSQL Dialect = database.DialectMSSQL 17 | DialectMySQL Dialect = database.DialectMySQL 18 | DialectPostgres Dialect = database.DialectPostgres 19 | DialectRedshift Dialect = database.DialectRedshift 20 | DialectSQLite3 Dialect = database.DialectSQLite3 21 | DialectSpanner Dialect = database.DialectSpanner 22 | DialectStarrocks Dialect = database.DialectStarrocks 23 | DialectTiDB Dialect = database.DialectTiDB 24 | DialectTurso Dialect = database.DialectTurso 25 | DialectYdB Dialect = database.DialectYdB 26 | 27 | // Dialects only available to the [Provider]. 28 | DialectAuroraDSQL Dialect = database.DialectAuroraDSQL 29 | 30 | // DEPRECATED: Vertica support is deprecated and will be removed in a future release. 31 | DialectVertica Dialect = database.DialectVertica 32 | ) 33 | 34 | func init() { 35 | store, _ = legacystore.NewStore(DialectPostgres) 36 | } 37 | 38 | var store legacystore.Store 39 | 40 | // SetDialect sets the dialect to use for the goose package. 41 | func SetDialect(s string) error { 42 | var d Dialect 43 | switch s { 44 | case "postgres", "pgx": 45 | d = DialectPostgres 46 | case "mysql": 47 | d = DialectMySQL 48 | case "sqlite3", "sqlite": 49 | d = DialectSQLite3 50 | case "spanner": 51 | d = DialectSpanner 52 | case "mssql", "azuresql", "sqlserver": 53 | d = DialectMSSQL 54 | case "redshift": 55 | d = DialectRedshift 56 | case "tidb": 57 | d = DialectTiDB 58 | case "clickhouse": 59 | d = DialectClickHouse 60 | case "vertica": 61 | d = DialectVertica 62 | case "ydb": 63 | d = DialectYdB 64 | case "turso": 65 | d = DialectTurso 66 | case "starrocks": 67 | d = DialectStarrocks 68 | default: 69 | return fmt.Errorf("%q: unknown dialect", s) 70 | } 71 | var err error 72 | store, err = legacystore.NewStore(d) 73 | return err 74 | } 75 | -------------------------------------------------------------------------------- /provider_test.go: -------------------------------------------------------------------------------- 1 | package goose_test 2 | 3 | import ( 4 | "database/sql" 5 | "io/fs" 6 | "path/filepath" 7 | "testing" 8 | "testing/fstest" 9 | 10 | "github.com/pressly/goose/v3" 11 | "github.com/stretchr/testify/require" 12 | _ "modernc.org/sqlite" 13 | ) 14 | 15 | func TestProvider(t *testing.T) { 16 | dir := t.TempDir() 17 | db, err := sql.Open("sqlite", filepath.Join(dir, "sql_embed.db")) 18 | require.NoError(t, err) 19 | t.Run("empty", func(t *testing.T) { 20 | _, err := goose.NewProvider(goose.DialectSQLite3, db, fstest.MapFS{}) 21 | require.Error(t, err) 22 | require.ErrorIs(t, err, goose.ErrNoMigrations) 23 | }) 24 | 25 | mapFS := fstest.MapFS{ 26 | "migrations/001_foo.sql": {Data: []byte(`-- +goose Up`)}, 27 | "migrations/002_bar.sql": {Data: []byte(`-- +goose Up`)}, 28 | } 29 | fsys, err := fs.Sub(mapFS, "migrations") 30 | require.NoError(t, err) 31 | p, err := goose.NewProvider(goose.DialectSQLite3, db, fsys) 32 | require.NoError(t, err) 33 | sources := p.ListSources() 34 | require.Len(t, sources, 2) 35 | require.Equal(t, sources[0], newSource(goose.TypeSQL, "001_foo.sql", 1)) 36 | require.Equal(t, sources[1], newSource(goose.TypeSQL, "002_bar.sql", 2)) 37 | } 38 | 39 | var ( 40 | migration1 = ` 41 | -- +goose Up 42 | CREATE TABLE foo (id INTEGER PRIMARY KEY); 43 | -- +goose Down 44 | DROP TABLE foo; 45 | ` 46 | migration2 = ` 47 | -- +goose Up 48 | ALTER TABLE foo ADD COLUMN name TEXT; 49 | -- +goose Down 50 | ALTER TABLE foo DROP COLUMN name; 51 | ` 52 | migration3 = ` 53 | -- +goose Up 54 | CREATE TABLE bar ( 55 | id INTEGER PRIMARY KEY, 56 | description TEXT 57 | ); 58 | -- +goose Down 59 | DROP TABLE bar; 60 | ` 61 | migration4 = ` 62 | -- +goose Up 63 | -- Rename the 'foo' table to 'my_foo' 64 | ALTER TABLE foo RENAME TO my_foo; 65 | 66 | -- Add a new column 'timestamp' to 'my_foo' 67 | ALTER TABLE my_foo ADD COLUMN timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP; 68 | 69 | -- +goose Down 70 | -- Remove the 'timestamp' column from 'my_foo' 71 | ALTER TABLE my_foo DROP COLUMN timestamp; 72 | 73 | -- Rename the 'my_foo' table back to 'foo' 74 | ALTER TABLE my_foo RENAME TO foo; 75 | ` 76 | ) 77 | 78 | func TestPartialErrorUnwrap(t *testing.T) { 79 | err := &goose.PartialError{Err: goose.ErrNoCurrentVersion} 80 | require.ErrorIs(t, err, goose.ErrNoCurrentVersion) 81 | } 82 | -------------------------------------------------------------------------------- /internal/dialects/postgres.go: -------------------------------------------------------------------------------- 1 | package dialects 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/pressly/goose/v3/database/dialect" 8 | ) 9 | 10 | // NewPostgres returns a new [dialect.Querier] for PostgreSQL dialect. 11 | func NewPostgres() dialect.QuerierExtender { 12 | return &postgres{} 13 | } 14 | 15 | type postgres struct{} 16 | 17 | var _ dialect.QuerierExtender = (*postgres)(nil) 18 | 19 | func (p *postgres) CreateTable(tableName string) string { 20 | q := `CREATE TABLE %s ( 21 | id integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, 22 | version_id bigint NOT NULL, 23 | is_applied boolean NOT NULL, 24 | tstamp timestamp NOT NULL DEFAULT now() 25 | )` 26 | return fmt.Sprintf(q, tableName) 27 | } 28 | 29 | func (p *postgres) InsertVersion(tableName string) string { 30 | q := `INSERT INTO %s (version_id, is_applied) VALUES ($1, $2)` 31 | return fmt.Sprintf(q, tableName) 32 | } 33 | 34 | func (p *postgres) DeleteVersion(tableName string) string { 35 | q := `DELETE FROM %s WHERE version_id=$1` 36 | return fmt.Sprintf(q, tableName) 37 | } 38 | 39 | func (p *postgres) GetMigrationByVersion(tableName string) string { 40 | q := `SELECT tstamp, is_applied FROM %s WHERE version_id=$1 ORDER BY tstamp DESC LIMIT 1` 41 | return fmt.Sprintf(q, tableName) 42 | } 43 | 44 | func (p *postgres) ListMigrations(tableName string) string { 45 | q := `SELECT version_id, is_applied from %s ORDER BY id DESC` 46 | return fmt.Sprintf(q, tableName) 47 | } 48 | 49 | func (p *postgres) GetLatestVersion(tableName string) string { 50 | q := `SELECT max(version_id) FROM %s` 51 | return fmt.Sprintf(q, tableName) 52 | } 53 | 54 | func (p *postgres) TableExists(tableName string) string { 55 | schemaName, tableName := parseTableIdentifier(tableName) 56 | if schemaName != "" { 57 | q := `SELECT EXISTS ( SELECT 1 FROM pg_tables WHERE schemaname = '%s' AND tablename = '%s' )` 58 | return fmt.Sprintf(q, schemaName, tableName) 59 | } 60 | q := `SELECT EXISTS ( SELECT 1 FROM pg_tables WHERE (current_schema() IS NULL OR schemaname = current_schema()) AND tablename = '%s' )` 61 | return fmt.Sprintf(q, tableName) 62 | } 63 | 64 | func parseTableIdentifier(name string) (schema, table string) { 65 | schema, table, found := strings.Cut(name, ".") 66 | if !found { 67 | return "", name 68 | } 69 | return schema, table 70 | } 71 | -------------------------------------------------------------------------------- /fix_test.go: -------------------------------------------------------------------------------- 1 | package goose 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "strings" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func TestFix(t *testing.T) { 13 | t.Parallel() 14 | if testing.Short() { 15 | t.Skip("skip long running test") 16 | } 17 | 18 | dir := t.TempDir() 19 | defer os.Remove("./bin/fix-goose") // clean up 20 | 21 | commands := []string{ 22 | "go build -o ./bin/fix-goose ./cmd/goose", 23 | fmt.Sprintf("./bin/fix-goose -dir=%s create create_table", dir), 24 | fmt.Sprintf("./bin/fix-goose -dir=%s create add_users", dir), 25 | fmt.Sprintf("./bin/fix-goose -dir=%s create add_indices", dir), 26 | fmt.Sprintf("./bin/fix-goose -dir=%s create update_users", dir), 27 | fmt.Sprintf("./bin/fix-goose -dir=%s fix", dir), 28 | } 29 | 30 | for _, cmd := range commands { 31 | args := strings.Split(cmd, " ") 32 | time.Sleep(1 * time.Second) 33 | cmd := exec.Command(args[0], args[1:]...) 34 | cmd.Env = os.Environ() 35 | out, err := cmd.CombinedOutput() 36 | if err != nil { 37 | t.Fatalf("%s:\n%v\n\n%s", err, cmd, out) 38 | } 39 | } 40 | 41 | files, err := os.ReadDir(dir) 42 | if err != nil { 43 | t.Fatal(err) 44 | } 45 | 46 | // check that the files are in order 47 | for i, f := range files { 48 | expected := fmt.Sprintf("%05v", i+1) 49 | if !strings.HasPrefix(f.Name(), expected) { 50 | t.Errorf("failed to find %s prefix in %s", expected, f.Name()) 51 | } 52 | } 53 | 54 | // add more migrations and then fix it 55 | commands = []string{ 56 | fmt.Sprintf("./bin/fix-goose -dir=%s create remove_column", dir), 57 | fmt.Sprintf("./bin/fix-goose -dir=%s create create_books_table", dir), 58 | fmt.Sprintf("./bin/fix-goose -dir=%s fix", dir), 59 | } 60 | 61 | for _, cmd := range commands { 62 | args := strings.Split(cmd, " ") 63 | time.Sleep(1 * time.Second) 64 | out, err := exec.Command(args[0], args[1:]...).CombinedOutput() 65 | if err != nil { 66 | t.Fatalf("%s:\n%v\n\n%s", err, cmd, out) 67 | } 68 | } 69 | 70 | files, err = os.ReadDir(dir) 71 | if err != nil { 72 | t.Fatal(err) 73 | } 74 | 75 | // check that the files still in order 76 | for i, f := range files { 77 | expected := fmt.Sprintf("%05v", i+1) 78 | if !strings.HasPrefix(f.Name(), expected) { 79 | t.Errorf("failed to find %s prefix in %s", expected, f.Name()) 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /internal/sqlparser/parse_test.go: -------------------------------------------------------------------------------- 1 | package sqlparser_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | "testing/fstest" 7 | 8 | "github.com/pressly/goose/v3/internal/sqlparser" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestParseAllFromFS(t *testing.T) { 13 | t.Parallel() 14 | t.Run("file_not_exist", func(t *testing.T) { 15 | mapFS := fstest.MapFS{} 16 | _, err := sqlparser.ParseAllFromFS(mapFS, "001_foo.sql", false) 17 | require.Error(t, err) 18 | require.ErrorIs(t, err, os.ErrNotExist) 19 | }) 20 | t.Run("empty_file", func(t *testing.T) { 21 | mapFS := fstest.MapFS{ 22 | "001_foo.sql": &fstest.MapFile{}, 23 | } 24 | _, err := sqlparser.ParseAllFromFS(mapFS, "001_foo.sql", false) 25 | require.Error(t, err) 26 | require.Contains(t, err.Error(), "failed to parse migration") 27 | require.Contains(t, err.Error(), "must start with '-- +goose Up' annotation") 28 | }) 29 | t.Run("all_statements", func(t *testing.T) { 30 | mapFS := fstest.MapFS{ 31 | "001_foo.sql": newFile(` 32 | -- +goose Up 33 | `), 34 | "002_bar.sql": newFile(` 35 | -- +goose Up 36 | -- +goose Down 37 | `), 38 | "003_baz.sql": newFile(` 39 | -- +goose Up 40 | CREATE TABLE foo (id int); 41 | CREATE TABLE bar (id int); 42 | 43 | -- +goose Down 44 | DROP TABLE bar; 45 | `), 46 | "004_qux.sql": newFile(` 47 | -- +goose NO TRANSACTION 48 | -- +goose Up 49 | CREATE TABLE foo (id int); 50 | -- +goose Down 51 | DROP TABLE foo; 52 | `), 53 | } 54 | parsedSQL, err := sqlparser.ParseAllFromFS(mapFS, "001_foo.sql", false) 55 | require.NoError(t, err) 56 | assertParsedSQL(t, parsedSQL, true, 0, 0) 57 | parsedSQL, err = sqlparser.ParseAllFromFS(mapFS, "002_bar.sql", false) 58 | require.NoError(t, err) 59 | assertParsedSQL(t, parsedSQL, true, 0, 0) 60 | parsedSQL, err = sqlparser.ParseAllFromFS(mapFS, "003_baz.sql", false) 61 | require.NoError(t, err) 62 | assertParsedSQL(t, parsedSQL, true, 2, 1) 63 | parsedSQL, err = sqlparser.ParseAllFromFS(mapFS, "004_qux.sql", false) 64 | require.NoError(t, err) 65 | assertParsedSQL(t, parsedSQL, false, 1, 1) 66 | }) 67 | } 68 | 69 | func assertParsedSQL(t *testing.T, got *sqlparser.ParsedSQL, useTx bool, up, down int) { 70 | t.Helper() 71 | require.NotNil(t, got) 72 | require.Equal(t, len(got.Up), up) 73 | require.Equal(t, len(got.Down), down) 74 | require.Equal(t, got.UseTx, useTx) 75 | } 76 | 77 | func newFile(data string) *fstest.MapFile { 78 | return &fstest.MapFile{ 79 | Data: []byte(data), 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /internal/testing/integration/README.md: -------------------------------------------------------------------------------- 1 | # Integration tests 2 | 3 | This directory contains integration tests for the [pressly/goose/v3][goose_module] Go module. An 4 | integration test is a test that runs against a real database (docker container) and exercises the 5 | same driver used by the CLI. 6 | 7 | ## Why is this a separate module? 8 | 9 | There are separate `go.mod` and `go.sum` files in this directory to allow for the use of different 10 | dependencies. We leverage [multi-module workspaces](https://go.dev/doc/tutorial/workspaces) to glue 11 | things together. 12 | 13 | Namely, we want to avoid dependencies on docker, sql drivers, and other dependencies **that are** 14 | not necessary for the core functionality of the goose library. 15 | 16 | ## Overview 17 | 18 | There are separate migration files for each database that we support, see the [migrations 19 | directory][migrations_dir]. Databases typically have different SQL syntax and features, so the 20 | migration files are different. 21 | 22 | A good set of migrations should be representative of the types of migrations users will write 23 | typically write. This should include: 24 | 25 | - Creating and dropping tables 26 | - Adding and removing columns 27 | - Creating and dropping indexes 28 | - Inserting and deleting data 29 | - Complex SQL statements that require special handling with `StatementBegin` and `StatementEnd` 30 | annotations 31 | - Statements that must run outside a transaction, annotated with `-- +goose NO TRANSACTION` 32 | 33 | There is a common test function that applies migrations up, down and then up again. 34 | 35 | The gold standard is the PostgreSQL migration files. We try to make other migration files as close 36 | to the PostgreSQL files as possible, but this is not always possible or desirable. 37 | 38 | Lastly, some tests will assert for database state after migrations are applied. 39 | 40 | To add a new `.sql` file, you can use the following command: 41 | 42 | ``` 43 | goose -s -dir testdata/migrations/ create sql 44 | ``` 45 | 46 | - Update the database name (e.g. `postgres`) 47 | - Update the filename name (e.g. `b`) as needed 48 | 49 | ## Limitation 50 | 51 | Note, the integration tests are not exhaustive. 52 | 53 | They are meantto ensure that the goose library works with the various databases that we support and 54 | the chosen drivers. We do not test every possible combination of operations, nor do we test every 55 | possible edge case. We rely on the unit tests in the goose package to cover library-specific logic. 56 | 57 | [goose_module]: https://pkg.go.dev/github.com/pressly/goose/v3 58 | [migrations_dir]: ./testdata/migrations 59 | -------------------------------------------------------------------------------- /tests/gomigrations/error/gomigrations_error_test.go: -------------------------------------------------------------------------------- 1 | package gomigrations 2 | 3 | import ( 4 | "database/sql" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/pressly/goose/v3" 9 | _ "github.com/pressly/goose/v3/tests/gomigrations/error/testdata" 10 | "github.com/stretchr/testify/require" 11 | _ "modernc.org/sqlite" 12 | ) 13 | 14 | func TestGoMigrationByOne(t *testing.T) { 15 | tempDir := t.TempDir() 16 | db, err := sql.Open("sqlite", filepath.Join(tempDir, "test.db")) 17 | require.NoError(t, err) 18 | err = goose.SetDialect(string(goose.DialectSQLite3)) 19 | require.NoError(t, err) 20 | // Create goose table. 21 | current, err := goose.EnsureDBVersion(db) 22 | require.NoError(t, err) 23 | require.EqualValues(t, 0, current) 24 | // Collect migrations. 25 | dir := "testdata" 26 | migrations, err := goose.CollectMigrations(dir, 0, goose.MaxVersion) 27 | require.NoError(t, err) 28 | require.Len(t, migrations, 4) 29 | 30 | // Setup table. 31 | err = migrations[0].Up(db) 32 | require.NoError(t, err) 33 | version, err := goose.GetDBVersion(db) 34 | require.NoError(t, err) 35 | require.EqualValues(t, 1, version) 36 | 37 | // Registered Go migration run outside a goose tx using *sql.DB. 38 | err = migrations[1].Up(db) 39 | require.Error(t, err) 40 | require.Contains(t, err.Error(), "failed to run go migration") 41 | version, err = goose.GetDBVersion(db) 42 | require.NoError(t, err) 43 | require.EqualValues(t, 1, version) 44 | 45 | // This migration was inserting 100 rows, but fails at 50, and 46 | // because it's run outside a goose tx then we expect 50 rows. 47 | var count int 48 | err = db.QueryRow("SELECT COUNT(*) FROM foo").Scan(&count) 49 | require.NoError(t, err) 50 | require.Equal(t, 50, count) 51 | 52 | // Truncate table so we have 0 rows. 53 | err = migrations[2].Up(db) 54 | require.NoError(t, err) 55 | version, err = goose.GetDBVersion(db) 56 | require.NoError(t, err) 57 | // We're at version 3, but keep in mind 2 was never applied because it failed. 58 | require.EqualValues(t, 3, version) 59 | 60 | // Registered Go migration run within a tx. 61 | err = migrations[3].Up(db) 62 | require.Error(t, err) 63 | require.Contains(t, err.Error(), "failed to run go migration") 64 | version, err = goose.GetDBVersion(db) 65 | require.NoError(t, err) 66 | require.EqualValues(t, 3, version) // This migration failed, so we're still at 3. 67 | // This migration was inserting 100 rows, but fails at 50. However, since it's 68 | // running within a tx we expect none of the inserts to persist. 69 | err = db.QueryRow("SELECT COUNT(*) FROM foo").Scan(&count) 70 | require.NoError(t, err) 71 | require.Equal(t, 0, count) 72 | 73 | } 74 | -------------------------------------------------------------------------------- /provider_types.go: -------------------------------------------------------------------------------- 1 | package goose 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "time" 7 | ) 8 | 9 | // MigrationType is the type of migration. 10 | type MigrationType string 11 | 12 | const ( 13 | TypeGo MigrationType = "go" 14 | TypeSQL MigrationType = "sql" 15 | ) 16 | 17 | // Source represents a single migration source. 18 | // 19 | // The Path field may be empty if the migration was registered manually. This is typically the case 20 | // for Go migrations registered using the [WithGoMigration] option. 21 | type Source struct { 22 | Type MigrationType 23 | Path string 24 | Version int64 25 | } 26 | 27 | // MigrationResult is the result of a single migration operation. 28 | type MigrationResult struct { 29 | Source *Source 30 | Duration time.Duration 31 | Direction string 32 | // Empty indicates no action was taken during the migration, but it was still versioned. For 33 | // SQL, it means no statements; for Go, it's a nil function. 34 | Empty bool 35 | // Error is only set if the migration failed. 36 | Error error 37 | } 38 | 39 | // String returns a string representation of the migration result. 40 | // 41 | // Example down: 42 | // 43 | // EMPTY down 00006_posts_view-copy.sql (607.83µs) 44 | // OK down 00005_posts_view.sql (646.25µs) 45 | // 46 | // Example up: 47 | // 48 | // OK up 00005_posts_view.sql (727.5µs) 49 | // EMPTY up 00006_posts_view-copy.sql (378.33µs) 50 | func (m *MigrationResult) String() string { 51 | var format string 52 | if m.Direction == "up" { 53 | format = "%-5s %-2s %s (%s)" 54 | } else { 55 | format = "%-5s %-4s %s (%s)" 56 | } 57 | var state string 58 | if m.Empty { 59 | state = "EMPTY" 60 | } else { 61 | state = "OK" 62 | } 63 | return fmt.Sprintf(format, 64 | state, 65 | m.Direction, 66 | filepath.Base(m.Source.Path), 67 | truncateDuration(m.Duration), 68 | ) 69 | } 70 | 71 | // State represents the state of a migration. 72 | type State string 73 | 74 | const ( 75 | // StatePending is a migration that exists on the filesystem, but not in the database. 76 | StatePending State = "pending" 77 | // StateApplied is a migration that has been applied to the database and exists on the 78 | // filesystem. 79 | StateApplied State = "applied" 80 | 81 | // TODO(mf): we could also add a third state for untracked migrations. This would be useful for 82 | // migrations that were manually applied to the database, but not versioned. Or the Source was 83 | // deleted, but the migration still exists in the database. StateUntracked State = "untracked" 84 | ) 85 | 86 | // MigrationStatus represents the status of a single migration. 87 | type MigrationStatus struct { 88 | Source *Source 89 | State State 90 | AppliedAt time.Time 91 | } 92 | -------------------------------------------------------------------------------- /tests/gomigrations/success/gomigrations_success_test.go: -------------------------------------------------------------------------------- 1 | package gomigrations_test 2 | 3 | import ( 4 | "database/sql" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/pressly/goose/v3" 9 | "github.com/stretchr/testify/require" 10 | 11 | _ "github.com/pressly/goose/v3/tests/gomigrations/success/testdata" 12 | _ "modernc.org/sqlite" 13 | ) 14 | 15 | func TestGoMigrationByOne(t *testing.T) { 16 | t.Parallel() 17 | 18 | require.NoError(t, goose.SetDialect("sqlite3")) 19 | db, err := sql.Open("sqlite", ":memory:") 20 | require.NoError(t, err) 21 | dir := "testdata" 22 | files, err := filepath.Glob(dir + "/*.go") 23 | require.NoError(t, err) 24 | 25 | upByOne := func(t *testing.T) int64 { 26 | t.Helper() 27 | err = goose.UpByOne(db, dir) 28 | t.Logf("err: %v %s", err, dir) 29 | require.NoError(t, err) 30 | version, err := goose.GetDBVersion(db) 31 | require.NoError(t, err) 32 | return version 33 | } 34 | downByOne := func(t *testing.T) int64 { 35 | t.Helper() 36 | err = goose.Down(db, dir) 37 | require.NoError(t, err) 38 | version, err := goose.GetDBVersion(db) 39 | require.NoError(t, err) 40 | return version 41 | } 42 | // Migrate all files up-by-one. 43 | for i := 1; i <= len(files); i++ { 44 | require.EqualValues(t, upByOne(t), i) 45 | } 46 | version, err := goose.GetDBVersion(db) 47 | require.NoError(t, err) 48 | require.Len(t, files, int(version)) 49 | 50 | tables, err := ListTables(db) 51 | require.NoError(t, err) 52 | require.Equal(t, 53 | []string{ 54 | "alpha", 55 | "bravo", 56 | "charlie", 57 | "delta", 58 | "echo", 59 | "foxtrot", 60 | "golf", 61 | "goose_db_version", 62 | "hotel", 63 | "sqlite_sequence", 64 | }, 65 | tables, 66 | ) 67 | 68 | // Migrate all files down-by-one. 69 | for i := len(files) - 1; i >= 0; i-- { 70 | require.EqualValues(t, downByOne(t), i) 71 | } 72 | version, err = goose.GetDBVersion(db) 73 | require.NoError(t, err) 74 | require.EqualValues(t, 0, version) 75 | 76 | tables, err = ListTables(db) 77 | require.NoError(t, err) 78 | require.Equal(t, 79 | []string{ 80 | "goose_db_version", 81 | "sqlite_sequence", 82 | }, 83 | tables, 84 | ) 85 | } 86 | 87 | func ListTables(db *sql.DB) ([]string, error) { 88 | rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`) 89 | if err != nil { 90 | return nil, err 91 | } 92 | defer rows.Close() 93 | var tables []string 94 | for rows.Next() { 95 | var name string 96 | if err := rows.Scan(&name); err != nil { 97 | return nil, err 98 | } 99 | tables = append(tables, name) 100 | } 101 | if err := rows.Err(); err != nil { 102 | return nil, err 103 | } 104 | return tables, nil 105 | } 106 | -------------------------------------------------------------------------------- /internal/testing/integration/integration.go: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "os" 8 | "path/filepath" 9 | "testing" 10 | 11 | "github.com/pressly/goose/v3" 12 | "github.com/pressly/goose/v3/database" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | type collected struct { 17 | fullpath string 18 | version int64 19 | } 20 | 21 | func collectMigrations(t *testing.T, dir string) []collected { 22 | t.Helper() 23 | 24 | files, err := os.ReadDir(dir) 25 | require.NoError(t, err) 26 | all := make([]collected, 0, len(files)) 27 | for _, f := range files { 28 | require.False(t, f.IsDir()) 29 | v, err := goose.NumericComponent(f.Name()) 30 | require.NoError(t, err) 31 | all = append(all, collected{ 32 | fullpath: filepath.Base(f.Name()), 33 | version: v, 34 | }) 35 | } 36 | return all 37 | } 38 | 39 | func testDatabase(t *testing.T, dialect database.Dialect, db *sql.DB, migrationsDir string, opts ...goose.ProviderOption) { 40 | t.Helper() 41 | 42 | ctx := context.Background() 43 | // collect all migration files from the testdata directory 44 | wantFiles := collectMigrations(t, migrationsDir) 45 | // initialize a new goose provider 46 | p, err := goose.NewProvider(dialect, db, os.DirFS(migrationsDir), opts...) 47 | require.NoError(t, err) 48 | require.Equal(t, len(wantFiles), len(p.ListSources()), "number of migrations") 49 | // run all up migrations 50 | results, err := p.Up(ctx) 51 | require.NoError(t, err) 52 | require.Equal(t, len(wantFiles), len(results), "number of migrations applied") 53 | for i, r := range results { 54 | require.Equal(t, wantFiles[i].fullpath, r.Source.Path, "migration file") 55 | require.Equal(t, wantFiles[i].version, r.Source.Version, "migration version") 56 | } 57 | // check the current version 58 | currentVersion, err := p.GetDBVersion(ctx) 59 | require.NoError(t, err) 60 | require.Equal(t, len(wantFiles), int(currentVersion), "current version") 61 | // run all down migrations 62 | results, err = p.DownTo(ctx, 0) 63 | require.NoError(t, err) 64 | require.Equal(t, len(wantFiles), len(results), "number of migrations rolled back") 65 | // check the current version 66 | currentVersion, err = p.GetDBVersion(ctx) 67 | require.NoError(t, err) 68 | require.Equal(t, 0, int(currentVersion), "current version") 69 | // run all up migrations one by one 70 | for i := range len(wantFiles) { 71 | result, err := p.UpByOne(ctx) 72 | require.NoError(t, err) 73 | if errors.Is(err, goose.ErrNoNextVersion) { 74 | break 75 | } 76 | require.Equal(t, wantFiles[i].fullpath, result.Source.Path, "migration file") 77 | } 78 | // check the current version 79 | currentVersion, err = p.GetDBVersion(ctx) 80 | require.NoError(t, err) 81 | require.Equal(t, len(wantFiles), int(currentVersion), "current version") 82 | } 83 | -------------------------------------------------------------------------------- /internal/testing/testdb/postgres.go: -------------------------------------------------------------------------------- 1 | package testdb 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log" 7 | "strconv" 8 | 9 | _ "github.com/jackc/pgx/v5/stdlib" 10 | "github.com/ory/dockertest/v3" 11 | "github.com/ory/dockertest/v3/docker" 12 | ) 13 | 14 | const ( 15 | // https://hub.docker.com/_/postgres 16 | POSTGRES_IMAGE = "postgres" 17 | POSTGRES_VERSION = "16-alpine" 18 | 19 | POSTGRES_DB = "testdb" 20 | POSTGRES_USER = "postgres" 21 | POSTGRES_PASSWORD = "password1" 22 | ) 23 | 24 | func newPostgres(opts ...OptionsFunc) (*sql.DB, func(), error) { 25 | option := &options{} 26 | for _, f := range opts { 27 | f(option) 28 | } 29 | // Uses a sensible default on windows (tcp/http) and linux/osx (socket). 30 | pool, err := dockertest.NewPool("") 31 | if err != nil { 32 | return nil, nil, fmt.Errorf("failed to connect to docker: %v", err) 33 | } 34 | options := &dockertest.RunOptions{ 35 | Repository: POSTGRES_IMAGE, 36 | Tag: POSTGRES_VERSION, 37 | Env: []string{ 38 | "POSTGRES_USER=" + POSTGRES_USER, 39 | "POSTGRES_PASSWORD=" + POSTGRES_PASSWORD, 40 | "POSTGRES_DB=" + POSTGRES_DB, 41 | "listen_addresses = '*'", 42 | }, 43 | Labels: map[string]string{"goose_test": "1"}, 44 | PortBindings: make(map[docker.Port][]docker.PortBinding), 45 | } 46 | if option.bindPort > 0 { 47 | options.PortBindings[docker.Port("5432/tcp")] = []docker.PortBinding{ 48 | {HostPort: strconv.Itoa(option.bindPort)}, 49 | } 50 | } 51 | container, err := pool.RunWithOptions( 52 | options, 53 | func(config *docker.HostConfig) { 54 | // Set AutoRemove to true so that stopped container goes away by itself. 55 | config.AutoRemove = true 56 | config.RestartPolicy = docker.RestartPolicy{Name: "no"} 57 | }, 58 | ) 59 | if err != nil { 60 | return nil, nil, fmt.Errorf("failed to create docker container: %v", err) 61 | } 62 | cleanup := func() { 63 | if option.debug { 64 | // User must manually delete the Docker container. 65 | return 66 | } 67 | if err := pool.Purge(container); err != nil { 68 | log.Printf("failed to purge resource: %v", err) 69 | } 70 | } 71 | psqlInfo := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", 72 | "localhost", 73 | container.GetPort("5432/tcp"), // Fetch port dynamically assigned to container 74 | POSTGRES_USER, 75 | POSTGRES_PASSWORD, 76 | POSTGRES_DB, 77 | ) 78 | var db *sql.DB 79 | // Exponential backoff-retry, because the application in the container 80 | // might not be ready to accept connections yet. 81 | if err := pool.Retry( 82 | func() error { 83 | var err error 84 | db, err = sql.Open("pgx", psqlInfo) 85 | if err != nil { 86 | return err 87 | } 88 | return db.Ping() 89 | }, 90 | ); err != nil { 91 | return nil, cleanup, fmt.Errorf("could not connect to docker database: %v", err) 92 | } 93 | return db, cleanup, nil 94 | } 95 | -------------------------------------------------------------------------------- /database/store.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | ) 8 | 9 | var ( 10 | // ErrVersionNotFound must be returned by [GetMigration] or [GetLatestVersion] when a migration 11 | // does not exist. 12 | ErrVersionNotFound = errors.New("version not found") 13 | 14 | // ErrNotImplemented must be returned by methods that are not implemented. 15 | ErrNotImplemented = errors.New("not implemented") 16 | ) 17 | 18 | // Store is an interface that defines methods for tracking and managing migrations. It is used by 19 | // the goose package to interact with a database. By defining a Store interface, multiple 20 | // implementations can be created to support different databases without reimplementing the 21 | // migration logic. 22 | // 23 | // This package provides several dialects that implement the Store interface. While most users won't 24 | // need to create their own Store, if you need to support a database that isn't currently supported, 25 | // you can implement your own! 26 | type Store interface { 27 | // Tablename is the name of the version table. This table is used to record applied migrations 28 | // and must not be an empty string. 29 | Tablename() string 30 | // CreateVersionTable creates the version table, which is used to track migrations. 31 | CreateVersionTable(ctx context.Context, db DBTxConn) error 32 | // Insert a version id into the version table. 33 | Insert(ctx context.Context, db DBTxConn, req InsertRequest) error 34 | // Delete a version id from the version table. 35 | Delete(ctx context.Context, db DBTxConn, version int64) error 36 | // GetMigration retrieves a single migration by version id. If the query succeeds, but the 37 | // version is not found, this method must return [ErrVersionNotFound]. 38 | GetMigration(ctx context.Context, db DBTxConn, version int64) (*GetMigrationResult, error) 39 | // GetLatestVersion retrieves the last applied migration version. If no migrations exist, this 40 | // method must return [ErrVersionNotFound]. 41 | GetLatestVersion(ctx context.Context, db DBTxConn) (int64, error) 42 | // ListMigrations retrieves all migrations sorted in descending order by id or timestamp. If 43 | // there are no migrations, return empty slice with no error. Typically this method will return 44 | // at least one migration, because the initial version (0) is always inserted into the version 45 | // table when it is created. 46 | ListMigrations(ctx context.Context, db DBTxConn) ([]*ListMigrationsResult, error) 47 | } 48 | 49 | type InsertRequest struct { 50 | Version int64 51 | 52 | // TODO(mf): in the future, we maybe want to expand this struct so implementors can store 53 | // additional information. See the following issues for more information: 54 | // - https://github.com/pressly/goose/issues/422 55 | // - https://github.com/pressly/goose/issues/288 56 | } 57 | 58 | type GetMigrationResult struct { 59 | Timestamp time.Time 60 | IsApplied bool 61 | } 62 | 63 | type ListMigrationsResult struct { 64 | Version int64 65 | IsApplied bool 66 | } 67 | -------------------------------------------------------------------------------- /provider_options_test.go: -------------------------------------------------------------------------------- 1 | package goose_test 2 | 3 | import ( 4 | "database/sql" 5 | "path/filepath" 6 | "testing" 7 | "testing/fstest" 8 | 9 | "github.com/pressly/goose/v3" 10 | "github.com/pressly/goose/v3/database" 11 | "github.com/stretchr/testify/require" 12 | _ "modernc.org/sqlite" 13 | ) 14 | 15 | func TestNewProvider(t *testing.T) { 16 | dir := t.TempDir() 17 | db, err := sql.Open("sqlite", filepath.Join(dir, "sql_embed.db")) 18 | require.NoError(t, err) 19 | fsys := fstest.MapFS{ 20 | "1_foo.sql": {Data: []byte(migration1)}, 21 | "2_bar.sql": {Data: []byte(migration2)}, 22 | "3_baz.sql": {Data: []byte(migration3)}, 23 | "4_qux.sql": {Data: []byte(migration4)}, 24 | } 25 | t.Run("invalid", func(t *testing.T) { 26 | // Empty dialect not allowed 27 | _, err = goose.NewProvider(goose.DialectCustom, db, fsys) 28 | require.Error(t, err) 29 | // Invalid dialect not allowed 30 | _, err = goose.NewProvider("unknown-dialect", db, fsys) 31 | require.Error(t, err) 32 | // Nil db not allowed 33 | _, err = goose.NewProvider(goose.DialectSQLite3, nil, fsys) 34 | require.Error(t, err) 35 | // Nil store not allowed 36 | _, err = goose.NewProvider(goose.DialectSQLite3, db, nil, goose.WithStore(nil)) 37 | require.Error(t, err) 38 | // Cannot set both dialect and store 39 | store, err := database.NewStore(goose.DialectSQLite3, "custom_table") 40 | require.NoError(t, err) 41 | _, err = goose.NewProvider(goose.DialectSQLite3, db, nil, goose.WithStore(store)) 42 | require.Error(t, err) 43 | // Multiple stores not allowed 44 | _, err = goose.NewProvider(goose.DialectSQLite3, db, nil, 45 | goose.WithStore(store), 46 | goose.WithStore(store), 47 | ) 48 | require.Error(t, err) 49 | // Cannot set empty table name 50 | _, err = goose.NewProvider(goose.DialectSQLite3, db, nil, goose.WithTableName("")) 51 | require.Error(t, err) 52 | // Cannot set table name when custom store is set 53 | _, err = goose.NewProvider(goose.DialectCustom, db, nil, 54 | goose.WithStore(store), 55 | goose.WithTableName("custom_table"), 56 | ) 57 | require.Error(t, err) 58 | }) 59 | t.Run("valid", func(t *testing.T) { 60 | // Valid dialect, db, and fsys allowed 61 | _, err = goose.NewProvider(goose.DialectSQLite3, db, fsys) 62 | require.NoError(t, err) 63 | // Valid dialect, db, fsys, and verbose allowed 64 | _, err = goose.NewProvider(goose.DialectSQLite3, db, fsys, 65 | goose.WithVerbose(testing.Verbose()), 66 | ) 67 | require.NoError(t, err) 68 | // Custom store allowed 69 | const tableName = "custom_table" 70 | store, err := database.NewStore(goose.DialectSQLite3, tableName) 71 | require.NoError(t, err) 72 | require.Equal(t, tableName, store.Tablename()) 73 | _, err = goose.NewProvider(goose.DialectCustom, db, fsys, goose.WithStore(store)) 74 | require.NoError(t, err) 75 | // Custom table name allowed on dialect-based store 76 | _, err = goose.NewProvider(goose.DialectSQLite3, db, fsys, goose.WithTableName("some_table")) 77 | require.NoError(t, err) 78 | }) 79 | } 80 | -------------------------------------------------------------------------------- /internal/testing/testdb/mariadb.go: -------------------------------------------------------------------------------- 1 | package testdb 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log" 7 | "strconv" 8 | "time" 9 | 10 | _ "github.com/go-sql-driver/mysql" 11 | "github.com/ory/dockertest/v3" 12 | "github.com/ory/dockertest/v3/docker" 13 | ) 14 | 15 | const ( 16 | // https://hub.docker.com/_/mariadb 17 | MARIADB_IMAGE = "mariadb" 18 | MARIADB_VERSION = "11" 19 | 20 | MARIADB_DB = "testdb" 21 | MARIADB_USER = "tester" 22 | MARIADB_PASSWORD = "password1" 23 | ) 24 | 25 | func newMariaDB(opts ...OptionsFunc) (*sql.DB, func(), error) { 26 | option := &options{} 27 | for _, f := range opts { 28 | f(option) 29 | } 30 | // Uses a sensible default on windows (tcp/http) and linux/osx (socket). 31 | pool, err := dockertest.NewPool("") 32 | if err != nil { 33 | return nil, nil, fmt.Errorf("failed to connect to docker: %v", err) 34 | } 35 | options := &dockertest.RunOptions{ 36 | Repository: MARIADB_IMAGE, 37 | Tag: MARIADB_VERSION, 38 | Env: []string{ 39 | "MARIADB_USER=" + MARIADB_USER, 40 | "MARIADB_PASSWORD=" + MARIADB_PASSWORD, 41 | "MARIADB_ROOT_PASSWORD=" + MARIADB_PASSWORD, 42 | "MARIADB_DATABASE=" + MARIADB_DB, 43 | }, 44 | Labels: map[string]string{"goose_test": "1"}, 45 | PortBindings: make(map[docker.Port][]docker.PortBinding), 46 | } 47 | if option.bindPort > 0 { 48 | options.PortBindings[docker.Port("3306/tcp")] = []docker.PortBinding{ 49 | {HostPort: strconv.Itoa(option.bindPort)}, 50 | } 51 | } 52 | container, err := pool.RunWithOptions( 53 | options, 54 | func(config *docker.HostConfig) { 55 | // Set AutoRemove to true so that stopped container goes away by itself. 56 | config.AutoRemove = true 57 | // config.PortBindings = options.PortBindings 58 | config.RestartPolicy = docker.RestartPolicy{Name: "no"} 59 | }, 60 | ) 61 | if err != nil { 62 | return nil, nil, fmt.Errorf("failed to create docker container: %v", err) 63 | } 64 | cleanup := func() { 65 | if option.debug { 66 | // User must manually delete the Docker container. 67 | return 68 | } 69 | if err := pool.Purge(container); err != nil { 70 | log.Printf("failed to purge resource: %v", err) 71 | } 72 | } 73 | // MySQL DSN: username:password@protocol(address)/dbname?param=value 74 | dsn := fmt.Sprintf("%s:%s@(%s:%s)/%s?parseTime=true&multiStatements=true", 75 | MARIADB_USER, 76 | MARIADB_PASSWORD, 77 | "localhost", 78 | container.GetPort("3306/tcp"), // Fetch port dynamically assigned to container 79 | MARIADB_DB, 80 | ) 81 | var db *sql.DB 82 | // Exponential backoff-retry, because the application in the container 83 | // might not be ready to accept connections yet. Add an extra sleep 84 | // because mariadb containers take much longer to startup. 85 | time.Sleep(5 * time.Second) 86 | if err := pool.Retry(func() error { 87 | var err error 88 | db, err = sql.Open("mysql", dsn) 89 | if err != nil { 90 | return err 91 | } 92 | return db.Ping() 93 | }, 94 | ); err != nil { 95 | return nil, cleanup, fmt.Errorf("could not connect to docker database: %v", err) 96 | } 97 | return db, cleanup, nil 98 | } 99 | -------------------------------------------------------------------------------- /internal/testing/testdb/turso.go: -------------------------------------------------------------------------------- 1 | package testdb 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log" 7 | "strconv" 8 | 9 | "github.com/ory/dockertest/v3" 10 | "github.com/ory/dockertest/v3/docker" 11 | _ "github.com/tursodatabase/libsql-client-go/libsql" 12 | ) 13 | 14 | const ( 15 | // ghcr.io/tursodatabase/libsql-server:v0.23.7 16 | TURSO_IMAGE = "ghcr.io/tursodatabase/libsql-server" 17 | TURSO_VERSION = "v0.24.7" 18 | TURSO_PORT = "8080" 19 | ) 20 | 21 | // NewTurso starts a Turso docker container. Returns db connection and a docker cleanup function. 22 | func NewTurso(options ...OptionsFunc) (db *sql.DB, cleanup func(), err error) { 23 | return newTurso(options...) 24 | } 25 | 26 | func newTurso(opts ...OptionsFunc) (*sql.DB, func(), error) { 27 | option := &options{} 28 | for _, f := range opts { 29 | f(option) 30 | } 31 | // Uses a sensible default on windows (tcp/http) and linux/osx (socket). 32 | pool, err := dockertest.NewPool("") 33 | if err != nil { 34 | return nil, nil, err 35 | } 36 | runOptions := &dockertest.RunOptions{ 37 | Repository: TURSO_IMAGE, 38 | Tag: TURSO_VERSION, 39 | Labels: map[string]string{"goose_test": "1"}, 40 | PortBindings: make(map[docker.Port][]docker.PortBinding), 41 | } 42 | if option.debug { 43 | runOptions.Env = append(runOptions.Env, "RUST=trace") 44 | } else { 45 | runOptions.Env = append(runOptions.Env, "RUST=error") 46 | } 47 | if option.bindPort > 0 { 48 | runOptions.PortBindings[TURSO_PORT+"/tcp"] = []docker.PortBinding{ 49 | {HostPort: strconv.Itoa(option.bindPort)}, 50 | } 51 | } 52 | container, err := pool.RunWithOptions( 53 | runOptions, 54 | func(config *docker.HostConfig) { 55 | // Set AutoRemove to true so that stopped container goes away by itself. 56 | config.AutoRemove = true 57 | config.RestartPolicy = docker.RestartPolicy{Name: "no"} 58 | }, 59 | ) 60 | if err != nil { 61 | return nil, nil, err 62 | } 63 | cleanup := func() { 64 | if option.debug { 65 | // User must manually delete the Docker container. 66 | return 67 | } 68 | if err := pool.Purge(container); err != nil { 69 | log.Printf("failed to purge resource: %v", err) 70 | } 71 | } 72 | // Fetch port assigned to container 73 | 74 | var db *sql.DB 75 | // Exponential backoff-retry, because the application in the container 76 | // might not be ready to accept connections yet. 77 | if err := pool.Retry(func() error { 78 | db, err = tursoOpenDB(container) 79 | return err 80 | }); err != nil { 81 | return nil, cleanup, fmt.Errorf("could not connect to docker database: %w", err) 82 | } 83 | return db, cleanup, nil 84 | } 85 | 86 | func tursoOpenDB(container *dockertest.Resource) (*sql.DB, error) { 87 | address := fmt.Sprintf("http://127.0.0.1:%s", container.GetPort(TURSO_PORT+"/tcp")) 88 | db, err := sql.Open("libsql", address) 89 | if err != nil { 90 | return db, err 91 | } 92 | // let's do a ping to be sure we are connected 93 | var result int 94 | err = db.QueryRow("SELECT 1").Scan(&result) 95 | if err != nil { 96 | return nil, err 97 | } 98 | return db, nil 99 | } 100 | -------------------------------------------------------------------------------- /internal/testing/testdb/starrocks.go: -------------------------------------------------------------------------------- 1 | package testdb 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log" 7 | "strconv" 8 | "time" 9 | 10 | _ "github.com/go-sql-driver/mysql" 11 | "github.com/ory/dockertest/v3" 12 | "github.com/ory/dockertest/v3/docker" 13 | ) 14 | 15 | const ( 16 | // https://hub.docker.com/r/starrocks/allin1-ubuntu 17 | STARROCKS_IMAGE = "starrocks/allin1-ubuntu" 18 | STARROCKS_VERSION = "3.2-latest" 19 | 20 | STARROCKS_USER = "root" 21 | STARROCKS_INIT_DB = "migrations" 22 | ) 23 | 24 | func newStarrocks(opts ...OptionsFunc) (*sql.DB, func(), error) { 25 | option := &options{} 26 | for _, f := range opts { 27 | f(option) 28 | } 29 | // Uses a sensible default on windows (tcp/http) and linux/osx (socket). 30 | pool, err := dockertest.NewPool("") 31 | if err != nil { 32 | return nil, nil, fmt.Errorf("failed to connect to docker: %v", err) 33 | } 34 | 35 | options := &dockertest.RunOptions{ 36 | Repository: STARROCKS_IMAGE, 37 | Tag: STARROCKS_VERSION, 38 | Labels: map[string]string{"goose_test": "1"}, 39 | PortBindings: make(map[docker.Port][]docker.PortBinding), 40 | ExposedPorts: []string{"9030/tcp"}, 41 | } 42 | if option.bindPort > 0 { 43 | options.PortBindings[docker.Port("9030/tcp")] = []docker.PortBinding{ 44 | {HostPort: strconv.Itoa(option.bindPort)}, 45 | } 46 | } 47 | container, err := pool.RunWithOptions( 48 | options, 49 | func(config *docker.HostConfig) { 50 | // Set AutoRemove to true so that stopped container goes away by itself. 51 | config.AutoRemove = true 52 | config.RestartPolicy = docker.RestartPolicy{Name: "no"} 53 | }, 54 | ) 55 | if err != nil { 56 | return nil, nil, fmt.Errorf("failed to create docker container: %v", err) 57 | } 58 | cleanup := func() { 59 | if option.debug { 60 | // User must manually delete the Docker container. 61 | return 62 | } 63 | if err := pool.Purge(container); err != nil { 64 | log.Printf("failed to purge resource: %v", err) 65 | } 66 | } 67 | dsn := fmt.Sprintf("%s:%s@(%s:%s)/%s?parseTime=true&interpolateParams=true", 68 | STARROCKS_USER, 69 | "", 70 | "localhost", 71 | container.GetPort("9030/tcp"), // Fetch port dynamically assigned to container, 72 | "", 73 | ) 74 | var db *sql.DB 75 | 76 | // Exponential backoff-retry, because the application in the container 77 | // might not be ready to accept connections yet. Add an extra sleep 78 | // because container take much longer to startup. 79 | pool.MaxWait = time.Minute * 2 80 | if err := pool.Retry(func() error { 81 | var err error 82 | db, err = sql.Open("mysql", dsn) 83 | if err != nil { 84 | return err 85 | } 86 | 87 | _, err = db.Exec("CREATE DATABASE IF NOT EXISTS " + STARROCKS_INIT_DB) 88 | if err != nil { 89 | return fmt.Errorf("could not create initial database: %v", err) 90 | } 91 | _, err = db.Exec("USE " + STARROCKS_INIT_DB) 92 | if err != nil { 93 | return fmt.Errorf("could not set default initial database: %v", err) 94 | } 95 | 96 | return db.Ping() 97 | }, 98 | ); err != nil { 99 | return nil, cleanup, fmt.Errorf("could not connect to docker database: %v", err) 100 | } 101 | 102 | return db, cleanup, nil 103 | } 104 | -------------------------------------------------------------------------------- /create.go: -------------------------------------------------------------------------------- 1 | package goose 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | "text/template" 10 | "time" 11 | ) 12 | 13 | type tmplVars struct { 14 | Version string 15 | CamelName string 16 | } 17 | 18 | var ( 19 | sequential = false 20 | ) 21 | 22 | // SetSequential set whether to use sequential versioning instead of timestamp based versioning 23 | func SetSequential(s bool) { 24 | sequential = s 25 | } 26 | 27 | // Create writes a new blank migration file. 28 | func CreateWithTemplate(db *sql.DB, dir string, tmpl *template.Template, name, migrationType string) error { 29 | version := time.Now().UTC().Format(timestampFormat) 30 | 31 | if sequential { 32 | // always use DirFS here because it's modifying operation 33 | migrations, err := collectMigrationsFS(osFS{}, dir, minVersion, maxVersion, registeredGoMigrations) 34 | if err != nil && !errors.Is(err, ErrNoMigrationFiles) { 35 | return err 36 | } 37 | 38 | vMigrations, err := migrations.versioned() 39 | if err != nil { 40 | return err 41 | } 42 | 43 | if last, err := vMigrations.Last(); err == nil { 44 | version = fmt.Sprintf(seqVersionTemplate, last.Version+1) 45 | } else { 46 | version = fmt.Sprintf(seqVersionTemplate, int64(1)) 47 | } 48 | } 49 | 50 | filename := fmt.Sprintf("%v_%v.%v", version, snakeCase(name), migrationType) 51 | 52 | if tmpl == nil { 53 | if migrationType == "go" { 54 | tmpl = goSQLMigrationTemplate 55 | } else { 56 | tmpl = sqlMigrationTemplate 57 | } 58 | } 59 | 60 | path := filepath.Join(dir, filename) 61 | if _, err := os.Stat(path); !os.IsNotExist(err) { 62 | return fmt.Errorf("failed to create migration file: %w", err) 63 | } 64 | 65 | f, err := os.Create(path) 66 | if err != nil { 67 | return fmt.Errorf("failed to create migration file: %w", err) 68 | } 69 | defer f.Close() 70 | 71 | vars := tmplVars{ 72 | Version: version, 73 | CamelName: camelCase(name), 74 | } 75 | if err := tmpl.Execute(f, vars); err != nil { 76 | return fmt.Errorf("failed to execute tmpl: %w", err) 77 | } 78 | 79 | log.Printf("Created new file: %s", f.Name()) 80 | return nil 81 | } 82 | 83 | // Create writes a new blank migration file. 84 | func Create(db *sql.DB, dir, name, migrationType string) error { 85 | return CreateWithTemplate(db, dir, nil, name, migrationType) 86 | } 87 | 88 | var sqlMigrationTemplate = template.Must(template.New("goose.sql-migration").Parse(`-- +goose Up 89 | SELECT 'up SQL query'; 90 | 91 | -- +goose Down 92 | SELECT 'down SQL query'; 93 | `)) 94 | 95 | var goSQLMigrationTemplate = template.Must(template.New("goose.go-migration").Parse(`package migrations 96 | 97 | import ( 98 | "context" 99 | "database/sql" 100 | "github.com/pressly/goose/v3" 101 | ) 102 | 103 | func init() { 104 | goose.AddMigrationContext(up{{.CamelName}}, down{{.CamelName}}) 105 | } 106 | 107 | func up{{.CamelName}}(ctx context.Context, tx *sql.Tx) error { 108 | // This code is executed when the migration is applied. 109 | return nil 110 | } 111 | 112 | func down{{.CamelName}}(ctx context.Context, tx *sql.Tx) error { 113 | // This code is executed when the migration is rolled back. 114 | return nil 115 | } 116 | `)) 117 | -------------------------------------------------------------------------------- /migration_sql.go: -------------------------------------------------------------------------------- 1 | package goose 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "regexp" 8 | ) 9 | 10 | // Run a migration specified in raw SQL. 11 | // 12 | // Sections of the script can be annotated with a special comment, 13 | // starting with "-- +goose" to specify whether the section should 14 | // be applied during an Up or Down migration 15 | // 16 | // All statements following an Up or Down annotation are grouped together 17 | // until another direction annotation is found. 18 | func runSQLMigration( 19 | ctx context.Context, 20 | db *sql.DB, 21 | statements []string, 22 | useTx bool, 23 | v int64, 24 | direction bool, 25 | noVersioning bool, 26 | ) error { 27 | if useTx { 28 | // TRANSACTION. 29 | 30 | verboseInfo("Begin transaction") 31 | 32 | tx, err := db.BeginTx(ctx, nil) 33 | if err != nil { 34 | return fmt.Errorf("failed to begin transaction: %w", err) 35 | } 36 | 37 | for _, query := range statements { 38 | verboseInfo("Executing statement: %s\n", clearStatement(query)) 39 | if _, err := tx.ExecContext(ctx, query); err != nil { 40 | verboseInfo("Rollback transaction") 41 | _ = tx.Rollback() 42 | return fmt.Errorf("failed to execute SQL query %q: %w", clearStatement(query), err) 43 | } 44 | } 45 | 46 | if !noVersioning { 47 | if direction { 48 | if err := store.InsertVersion(ctx, tx, TableName(), v); err != nil { 49 | verboseInfo("Rollback transaction") 50 | _ = tx.Rollback() 51 | return fmt.Errorf("failed to insert new goose version: %w", err) 52 | } 53 | } else { 54 | if err := store.DeleteVersion(ctx, tx, TableName(), v); err != nil { 55 | verboseInfo("Rollback transaction") 56 | _ = tx.Rollback() 57 | return fmt.Errorf("failed to delete goose version: %w", err) 58 | } 59 | } 60 | } 61 | 62 | verboseInfo("Commit transaction") 63 | if err := tx.Commit(); err != nil { 64 | return fmt.Errorf("failed to commit transaction: %w", err) 65 | } 66 | 67 | return nil 68 | } 69 | 70 | // NO TRANSACTION. 71 | for _, query := range statements { 72 | verboseInfo("Executing statement: %s", clearStatement(query)) 73 | if _, err := db.ExecContext(ctx, query); err != nil { 74 | return fmt.Errorf("failed to execute SQL query %q: %w", clearStatement(query), err) 75 | } 76 | } 77 | if !noVersioning { 78 | if direction { 79 | if err := store.InsertVersionNoTx(ctx, db, TableName(), v); err != nil { 80 | return fmt.Errorf("failed to insert new goose version: %w", err) 81 | } 82 | } else { 83 | if err := store.DeleteVersionNoTx(ctx, db, TableName(), v); err != nil { 84 | return fmt.Errorf("failed to delete goose version: %w", err) 85 | } 86 | } 87 | } 88 | 89 | return nil 90 | } 91 | 92 | const ( 93 | grayColor = "\033[90m" 94 | resetColor = "\033[00m" 95 | ) 96 | 97 | func verboseInfo(s string, args ...interface{}) { 98 | if verbose { 99 | if noColor { 100 | log.Printf(s, args...) 101 | } else { 102 | log.Printf(grayColor+s+resetColor, args...) 103 | } 104 | } 105 | } 106 | 107 | var ( 108 | matchSQLComments = regexp.MustCompile(`(?m)^--.*$[\r\n]*`) 109 | matchEmptyEOL = regexp.MustCompile(`(?m)^$[\r\n]*`) // TODO: Duplicate 110 | ) 111 | 112 | func clearStatement(s string) string { 113 | s = matchSQLComments.ReplaceAllString(s, ``) 114 | return matchEmptyEOL.ReplaceAllString(s, ``) 115 | } 116 | -------------------------------------------------------------------------------- /down.go: -------------------------------------------------------------------------------- 1 | package goose 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | ) 8 | 9 | // Down rolls back a single migration from the current version. 10 | func Down(db *sql.DB, dir string, opts ...OptionsFunc) error { 11 | ctx := context.Background() 12 | return DownContext(ctx, db, dir, opts...) 13 | } 14 | 15 | // DownContext rolls back a single migration from the current version. 16 | func DownContext(ctx context.Context, db *sql.DB, dir string, opts ...OptionsFunc) error { 17 | option := &options{} 18 | for _, f := range opts { 19 | f(option) 20 | } 21 | migrations, err := CollectMigrations(dir, minVersion, maxVersion) 22 | if err != nil { 23 | return err 24 | } 25 | if option.noVersioning { 26 | if len(migrations) == 0 { 27 | return nil 28 | } 29 | currentVersion := migrations[len(migrations)-1].Version 30 | // Migrate only the latest migration down. 31 | return downToNoVersioning(ctx, db, migrations, currentVersion-1) 32 | } 33 | currentVersion, err := GetDBVersionContext(ctx, db) 34 | if err != nil { 35 | return err 36 | } 37 | current, err := migrations.Current(currentVersion) 38 | if err != nil { 39 | return fmt.Errorf("migration %v: %w", currentVersion, err) 40 | } 41 | return current.DownContext(ctx, db) 42 | } 43 | 44 | // DownTo rolls back migrations to a specific version. 45 | func DownTo(db *sql.DB, dir string, version int64, opts ...OptionsFunc) error { 46 | ctx := context.Background() 47 | return DownToContext(ctx, db, dir, version, opts...) 48 | } 49 | 50 | // DownToContext rolls back migrations to a specific version. 51 | func DownToContext(ctx context.Context, db *sql.DB, dir string, version int64, opts ...OptionsFunc) error { 52 | option := &options{} 53 | for _, f := range opts { 54 | f(option) 55 | } 56 | migrations, err := CollectMigrations(dir, minVersion, maxVersion) 57 | if err != nil { 58 | return err 59 | } 60 | if option.noVersioning { 61 | return downToNoVersioning(ctx, db, migrations, version) 62 | } 63 | 64 | for { 65 | currentVersion, err := GetDBVersionContext(ctx, db) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | if currentVersion == 0 { 71 | log.Printf("goose: no migrations to run. current version: %d", currentVersion) 72 | return nil 73 | } 74 | current, err := migrations.Current(currentVersion) 75 | if err != nil { 76 | log.Printf("goose: migration file not found for current version (%d), error: %s", currentVersion, err) 77 | return err 78 | } 79 | 80 | if current.Version <= version { 81 | log.Printf("goose: no migrations to run. current version: %d", currentVersion) 82 | return nil 83 | } 84 | 85 | if err = current.DownContext(ctx, db); err != nil { 86 | return err 87 | } 88 | } 89 | } 90 | 91 | // downToNoVersioning applies down migrations down to, but not including, the 92 | // target version. 93 | func downToNoVersioning(ctx context.Context, db *sql.DB, migrations Migrations, version int64) error { 94 | var finalVersion int64 95 | for i := len(migrations) - 1; i >= 0; i-- { 96 | if version >= migrations[i].Version { 97 | finalVersion = migrations[i].Version 98 | break 99 | } 100 | migrations[i].noVersioning = true 101 | if err := migrations[i].DownContext(ctx, db); err != nil { 102 | return err 103 | } 104 | } 105 | log.Printf("goose: down to current file version: %d", finalVersion) 106 | return nil 107 | } 108 | -------------------------------------------------------------------------------- /lock/session_locker_options.go: -------------------------------------------------------------------------------- 1 | package lock 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | ) 7 | 8 | const ( 9 | // DefaultLockID is the id used to lock the database for migrations. It is a crc64 hash of the 10 | // string "goose". This is used to ensure that the lock is unique to goose. 11 | // 12 | // crc32.Checksum([]byte("goose"), crc32.MakeTable(crc32.IEEE)) 13 | DefaultLockID int64 = 4097083626 14 | ) 15 | 16 | // SessionLockerOption is used to configure a SessionLocker. 17 | type SessionLockerOption interface { 18 | apply(*sessionLockerConfig) error 19 | } 20 | 21 | // WithLockID sets the lock ID to use when locking the database. 22 | // 23 | // If WithLockID is not called, the DefaultLockID is used. 24 | func WithLockID(lockID int64) SessionLockerOption { 25 | return sessionLockerConfigFunc(func(c *sessionLockerConfig) error { 26 | c.lockID = lockID 27 | return nil 28 | }) 29 | } 30 | 31 | // WithLockTimeout sets the max duration to wait for the lock to be acquired. The total duration 32 | // will be the period times the failure threshold. 33 | // 34 | // By default, the lock timeout is 300s (5min), where the lock is retried every 5 seconds (period) 35 | // up to 60 times (failure threshold). 36 | // 37 | // The minimum period is 1 second, and the minimum failure threshold is 1. 38 | func WithLockTimeout(period, failureThreshold uint64) SessionLockerOption { 39 | return sessionLockerConfigFunc(func(c *sessionLockerConfig) error { 40 | if period < 1 { 41 | return errors.New("period must be greater than 0, minimum is 1") 42 | } 43 | if failureThreshold < 1 { 44 | return errors.New("failure threshold must be greater than 0, minimum is 1") 45 | } 46 | c.lockProbe = probe{ 47 | intervalDuration: time.Duration(period) * time.Second, 48 | failureThreshold: failureThreshold, 49 | } 50 | return nil 51 | }) 52 | } 53 | 54 | // WithUnlockTimeout sets the max duration to wait for the lock to be released. The total duration 55 | // will be the period times the failure threshold. 56 | // 57 | // By default, the lock timeout is 60s, where the lock is retried every 2 seconds (period) up to 30 58 | // times (failure threshold). 59 | // 60 | // The minimum period is 1 second, and the minimum failure threshold is 1. 61 | func WithUnlockTimeout(period, failureThreshold uint64) SessionLockerOption { 62 | return sessionLockerConfigFunc(func(c *sessionLockerConfig) error { 63 | if period < 1 { 64 | return errors.New("period must be greater than 0, minimum is 1") 65 | } 66 | if failureThreshold < 1 { 67 | return errors.New("failure threshold must be greater than 0, minimum is 1") 68 | } 69 | c.unlockProbe = probe{ 70 | intervalDuration: time.Duration(period) * time.Second, 71 | failureThreshold: failureThreshold, 72 | } 73 | return nil 74 | }) 75 | } 76 | 77 | type sessionLockerConfig struct { 78 | lockID int64 79 | lockProbe probe 80 | unlockProbe probe 81 | } 82 | 83 | // probe is used to configure how often and how many times to retry a lock or unlock operation. The 84 | // total timeout will be the period times the failure threshold. 85 | type probe struct { 86 | // How often (in seconds) to perform the probe. 87 | intervalDuration time.Duration 88 | // Number of times to retry the probe. 89 | failureThreshold uint64 90 | } 91 | 92 | var _ SessionLockerOption = (sessionLockerConfigFunc)(nil) 93 | 94 | type sessionLockerConfigFunc func(*sessionLockerConfig) error 95 | 96 | func (f sessionLockerConfigFunc) apply(cfg *sessionLockerConfig) error { 97 | return f(cfg) 98 | } 99 | --------------------------------------------------------------------------------