├── docs ├── static │ ├── .nojekyll │ └── img │ │ ├── favicon.ico │ │ ├── docusaurus.png │ │ ├── social-card.png │ │ └── logo.svg ├── .gitignore ├── docs │ ├── 03-cli │ │ ├── _category_.json │ │ ├── 07-schema.md │ │ ├── 06-status.md │ │ ├── 02-context.md │ │ ├── 05-rollback.md │ │ ├── 04-migrate.md │ │ └── 01-init.md │ ├── 04-api │ │ ├── _category_.json │ │ ├── 02-sqlite │ │ │ ├── 04-transformative.md │ │ │ ├── 03-informative.md │ │ │ ├── 01-constructive.md │ │ │ ├── 05-others.md │ │ │ └── 02-destructive.md │ │ ├── 01-postgres │ │ │ ├── 05-others.md │ │ │ ├── 04-transformative.md │ │ │ ├── 03-informative.md │ │ │ ├── 02-destructive.md │ │ │ └── 01-constructive.md │ │ ├── 101-return-error.md │ │ └── 100-migrating-in-go.md │ └── 02-quick-start │ │ ├── _category_.json │ │ ├── 01-installation.md │ │ ├── 07-working-with-not-supported-db.md │ │ ├── 05-create-sql-migration.md │ │ ├── 06-working-with-existing-schema.md │ │ ├── 02-initialize.md │ │ ├── 03-running-your-first-migration.md │ │ └── 04-create-go-migration.md ├── babel.config.js ├── README.md ├── Dockerfile ├── sidebars.js ├── src │ └── css │ │ └── custom.css ├── package.json └── docusaurus.config.js ├── example ├── pg │ ├── .gitignore │ ├── .amigo │ │ ├── config.yml │ │ └── main.go │ ├── migrations │ │ ├── migrations.go │ │ ├── 20240530063940_create_user_table.go │ │ └── 20240530063939_create_table_schema_version.go │ ├── go.mod │ └── main.go ├── mysql │ ├── .gitignore │ ├── .amigo │ │ ├── config.yml │ │ └── main.go │ ├── migrations │ │ ├── migrations.go │ │ └── 20240529125357_create_table_schema_version.go │ └── go.mod └── sqlite │ ├── go.mod │ ├── go.sum │ ├── .amigo │ ├── config.yml │ └── main.go │ └── migrations │ ├── migrations.go │ ├── 20240602081304_add_index.go │ ├── 20240602081806_drop_index.go │ └── 20240602080728_create_table_schema_version.go ├── pkg ├── utils │ ├── testutils │ │ ├── recorder.go │ │ └── env.go │ ├── logger │ │ └── slog_test.go │ ├── ptr.go │ ├── error.go │ ├── colors │ │ └── colors.go │ ├── slices.go │ ├── text_test.go │ ├── mig.go │ ├── orderedmap │ │ ├── orderedmap.go │ │ └── orderedmap_test.go │ ├── files_test.go │ ├── gomod.go │ ├── files.go │ ├── cmdexec │ │ └── command.go │ ├── events │ │ └── events.go │ ├── text.go │ └── dblog │ │ └── handler.go ├── schema │ ├── sqlite │ │ ├── testdata │ │ │ ├── TestSQLite_DropIndex │ │ │ │ ├── simple_index.snap.txt │ │ │ │ ├── if_exists.snap.txt │ │ │ │ └── with_custom_name.snap.txt │ │ │ └── TestSQLite_AddIndex │ │ │ │ ├── simple_index.snap.txt │ │ │ │ ├── with_custom_name.snap.txt │ │ │ │ ├── with_order.snap.txt │ │ │ │ ├── with_multiple_Columns.snap.txt │ │ │ │ ├── with_unique_index.snap.txt │ │ │ │ ├── with_order_per_column.snap.txt │ │ │ │ └── with_predicate.snap.txt │ │ ├── exists.go │ │ ├── column_test.go │ │ ├── sqlite_test.go │ │ └── sqlite.go │ ├── pg │ │ ├── testdata │ │ │ ├── TestPostgres_DropIndex │ │ │ │ ├── simple_index.snap.txt │ │ │ │ ├── if_exists.snap.txt │ │ │ │ └── with_custom_name.snap.txt │ │ │ ├── TestPostgres_DropColumn │ │ │ │ ├── simple_drop.snap.txt │ │ │ │ └── drop_if_exists.snap.txt │ │ │ ├── TestPostgres_AddColumn │ │ │ │ ├── simple_column.snap.txt │ │ │ │ ├── varchar_limit.snap.txt │ │ │ │ ├── with_not_null.snap.txt │ │ │ │ ├── with_default_value.snap.txt │ │ │ │ ├── with_type_primary_key.snap.txt │ │ │ │ ├── with_array.snap.txt │ │ │ │ ├── with_comment.snap.txt │ │ │ │ ├── custom_column_type.snap.txt │ │ │ │ ├── with_type_serial.snap.txt │ │ │ │ ├── with_timestamps.snap.txt │ │ │ │ └── with_precision_and_or_scale.snap.txt │ │ │ ├── TestPostgres_AddEnum │ │ │ │ ├── add_enum_with_no_values.snap.txt │ │ │ │ └── add_enum.snap.txt │ │ │ ├── TestPostgres_AddIndex │ │ │ │ ├── with_custom_name.snap.txt │ │ │ │ ├── simple_index.snap.txt │ │ │ │ ├── with_order.snap.txt │ │ │ │ ├── with_a_method.snap.txt │ │ │ │ ├── with_unique_index.snap.txt │ │ │ │ ├── with_concurrently.snap.txt │ │ │ │ ├── with_multiple_Columns.snap.txt │ │ │ │ ├── with_predicate.snap.txt │ │ │ │ └── with_order_per_column.snap.txt │ │ │ ├── TestPostgres_AddColumnComment │ │ │ │ ├── null_comment.snap.txt │ │ │ │ └── simple_comment.snap.txt │ │ │ ├── TestPostgres_AddExtension │ │ │ │ ├── without_schema.snap.txt │ │ │ │ └── with_schema.snap.txt │ │ │ ├── TestPostgres_RenameColumn │ │ │ │ └── simple_rename.snap.txt │ │ │ ├── TestPostgres_ChangeColumn │ │ │ │ ├── simple_change.snap.txt │ │ │ │ └── change_column_type_with_using.snap.txt │ │ │ ├── TestPostgres_ChangeColumnDefault │ │ │ │ ├── null_change.snap.txt │ │ │ │ └── simple_change.snap.txt │ │ │ ├── TestPostgres_AddPrimaryKeyConstraint │ │ │ │ ├── simple_primary_key.snap.txt │ │ │ │ └── composite_primary_key.snap.txt │ │ │ ├── TestPostgres_DropEnum │ │ │ │ ├── drop_enum_with_if_exists.snap.txt │ │ │ │ └── drop_enum.snap.txt │ │ │ ├── TestPostgres_DropForeignKeyConstraint │ │ │ │ ├── nominal.snap.txt │ │ │ │ └── with_custom_name.snap.txt │ │ │ ├── TestPostgres_DropTable │ │ │ │ ├── drop_table_with_if_exists.snap.txt │ │ │ │ └── drop_table.snap.txt │ │ │ ├── TestPostgres_AddCheckConstraint │ │ │ │ ├── with_custom_name.snap.txt │ │ │ │ ├── with_Table_prefix.snap.txt │ │ │ │ └── with_no_validate.snap.txt │ │ │ ├── TestPostgres_DropCheckConstraint │ │ │ │ ├── with_Table_prefix.snap.txt │ │ │ │ └── with_custom_name.snap.txt │ │ │ ├── TestPostgres_DropPrimaryKeyConstraint │ │ │ │ └── simple_primary_key.snap.txt │ │ │ ├── TestPostgres_CreateTable │ │ │ │ ├── with_custom_primary_key_name.snap.txt │ │ │ │ ├── create_basic_table.snap.txt │ │ │ │ ├── composite_primary_key.snap.txt │ │ │ │ ├── without_primary_key.snap.txt │ │ │ │ ├── indexes.snap.txt │ │ │ │ └── foreign_keys.snap.txt │ │ │ ├── TestPostgres_RenameEnum │ │ │ │ └── rename_enum.snap.txt │ │ │ ├── TestPostgres_RenameValue │ │ │ │ ├── add_enum_value.snap.txt │ │ │ │ └── rename_enum_value.snap.txt │ │ │ ├── TestPostgres_AddEnumValue │ │ │ │ ├── add_enum_value.snap.txt │ │ │ │ └── add_enum_value_after │ │ │ │ │ └── before_a_value.snap.txt │ │ │ ├── TestPostgres_RenameTable │ │ │ │ └── rename_table.snap.txt │ │ │ ├── TestPostgres_AddTableComment │ │ │ │ ├── add_table_comment_null.snap.txt │ │ │ │ └── add_table_comment.snap.txt │ │ │ └── TestPostgres_AddForeignKeyConstraint │ │ │ │ ├── with_custom_name.snap.txt │ │ │ │ ├── custom_column_name.snap.txt │ │ │ │ ├── custom_primary_key.snap.txt │ │ │ │ ├── with_no_validate.snap.txt │ │ │ │ ├── with_on_delete.snap.txt │ │ │ │ ├── with_on_update.snap.txt │ │ │ │ ├── composite_pk.snap.txt │ │ │ │ └── with_deferrable.snap.txt │ │ ├── utils.go │ │ ├── exists_test.go │ │ ├── utils_test.go │ │ └── exists.go │ ├── schema.go │ ├── helpers.go │ ├── helpers_test.go │ ├── table_name_test.go │ ├── db.go │ ├── reversible.go │ ├── table_name.go │ ├── sql_migration_impl.go │ ├── base │ │ └── index.go │ ├── run_migration_schema_dump.go │ ├── run_migration.go │ └── detect_migrations.go ├── templates │ ├── migration.sql.tmpl │ ├── init_create_table_base.go.tmpl │ ├── init_create_table.go.tmpl │ ├── migrations.go.tmpl │ ├── main.go.tmpl │ ├── types.go │ ├── migration.go.tmpl │ └── util.go ├── amigo │ ├── skip_migration_file.go │ ├── get_status.go │ ├── setup_slog.go │ ├── gen_main_file.go │ ├── gen_migration_file.go │ ├── amigo.go │ ├── run_migration.go │ └── gen_migrations_file.go ├── entrypoint │ ├── main.go │ ├── schema.go │ ├── status.go │ ├── context.go │ └── migration.go └── types │ └── types.go ├── go.work ├── main.go ├── .gitignore ├── testdata ├── e2e │ └── pg │ │ ├── migrations_with_change │ │ ├── migrations.go │ │ ├── 20240527192300_enum.go │ │ ├── 20240518071842_add_index_user_email.go │ │ ├── 20240518071740_create_user.go │ │ ├── 20240517080505_schema_version.go │ │ └── 20240518071938_custom_seed.go │ │ └── migrations_with_classic │ │ ├── migrations.go │ │ ├── 20240518071938_custom_seed.go │ │ ├── 20240527192355_enum.go │ │ ├── 20240518071842_add_index_user_email.go │ │ ├── 20240518071740_create_user.go │ │ └── 20240517080505_schema_version.go └── Test_2e2_postgres │ ├── migration_with_change_migrations_with_change │ ├── 20240517080505_schema_version.snap.sql │ ├── 20240518071740_create_user.snap.sql │ ├── 20240518071938_custom_seed.snap.sql │ ├── 20240518071842_add_index_user_email.snap.sql │ └── 20240527192300_enum.snap.sql │ └── migration_with_classic_migrations_with_classic │ ├── 20240517080505_schema_version.snap.sql │ ├── 20240518071740_create_user.snap.sql │ ├── 20240518071938_custom_seed.snap.sql │ ├── 20240518071842_add_index_user_email.snap.sql │ └── 20240527192355_enum.snap.sql ├── docker-compose.yml ├── LICENSE ├── .github └── workflows │ └── tests.yml ├── go.mod └── cmd └── root.go /docs/static/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/pg/.gitignore: -------------------------------------------------------------------------------- 1 | .amigo/main 2 | -------------------------------------------------------------------------------- /example/mysql/.gitignore: -------------------------------------------------------------------------------- 1 | .amigo/main 2 | -------------------------------------------------------------------------------- /pkg/utils/testutils/recorder.go: -------------------------------------------------------------------------------- 1 | package testutils 2 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | .docusaurus 2 | build 3 | node_modules 4 | -------------------------------------------------------------------------------- /docs/docs/03-cli/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "position": 3, 3 | "label": "CLI" 4 | } 5 | -------------------------------------------------------------------------------- /docs/docs/04-api/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "position": 4, 3 | "label": "API" 4 | } 5 | -------------------------------------------------------------------------------- /docs/docs/02-quick-start/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "position": 2, 3 | "label": "Quick start" 4 | } 5 | -------------------------------------------------------------------------------- /go.work: -------------------------------------------------------------------------------- 1 | go 1.22 2 | 3 | use ( 4 | example/pg 5 | example/mysql 6 | example/sqlite 7 | . 8 | ) 9 | -------------------------------------------------------------------------------- /pkg/schema/sqlite/testdata/TestSQLite_DropIndex/simple_index.snap.txt: -------------------------------------------------------------------------------- 1 | DROP INDEX idx_articles_name 2 | -------------------------------------------------------------------------------- /docs/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexisvisco/amigo/HEAD/docs/static/img/favicon.ico -------------------------------------------------------------------------------- /docs/static/img/docusaurus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexisvisco/amigo/HEAD/docs/static/img/docusaurus.png -------------------------------------------------------------------------------- /docs/static/img/social-card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexisvisco/amigo/HEAD/docs/static/img/social-card.png -------------------------------------------------------------------------------- /docs/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /example/sqlite/go.mod: -------------------------------------------------------------------------------- 1 | module sqlite 2 | 3 | go 1.22 4 | 5 | require github.com/mattn/go-sqlite3 v1.14.22 // indirect 6 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/alexisvisco/amigo/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_DropIndex/simple_index.snap.txt: -------------------------------------------------------------------------------- 1 | DROP INDEX tst_pg_drop_index_0.idx_articles_name 2 | -------------------------------------------------------------------------------- /pkg/schema/sqlite/testdata/TestSQLite_AddIndex/simple_index.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE INDEX idx_articles_name ON articles (name) 2 | -------------------------------------------------------------------------------- /pkg/schema/sqlite/testdata/TestSQLite_AddIndex/with_custom_name.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE INDEX lalalalala ON articles (name) 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_DropColumn/simple_drop.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_drop_column_0.articles DROP COLUMN id 2 | -------------------------------------------------------------------------------- /pkg/schema/sqlite/testdata/TestSQLite_AddIndex/with_order.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE INDEX idx_articles_name ON articles (name DESC) 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddColumn/simple_column.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_add_column_0.articles ADD "name" TEXT 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddEnum/add_enum_with_no_values.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE TYPE "tst_pg_add_enum_1"."status" AS ENUM () 2 | -------------------------------------------------------------------------------- /pkg/templates/migration.sql.tmpl: -------------------------------------------------------------------------------- 1 | -- todo: write up migrations here 2 | -- migrate:down 3 | -- todo: write down migrations here 4 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddColumn/varchar_limit.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_add_column_2.articles ADD "name" varchar(255) 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddColumn/with_not_null.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_add_column_9.articles ADD "name" TEXT NOT NULL 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddEnum/add_enum.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE TYPE "tst_pg_add_enum_0"."status" AS ENUM ('active', 'inactive') 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddIndex/with_custom_name.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE INDEX lalalalala ON tst_pg_add_index_2.articles (name) 2 | -------------------------------------------------------------------------------- /pkg/schema/sqlite/testdata/TestSQLite_AddIndex/with_multiple_Columns.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE INDEX idx_articles_name_id ON articles (name, id) 2 | -------------------------------------------------------------------------------- /pkg/schema/sqlite/testdata/TestSQLite_AddIndex/with_unique_index.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE UNIQUE INDEX idx_articles_name ON articles (name) 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddIndex/simple_index.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE INDEX idx_articles_name ON tst_pg_add_index_0.articles (name) 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddIndex/with_order.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE INDEX idx_articles_name ON tst_pg_add_index_5.articles (name DESC) 2 | -------------------------------------------------------------------------------- /pkg/templates/init_create_table_base.go.tmpl: -------------------------------------------------------------------------------- 1 | s.Exec(`CREATE TABLE IF NOT EXISTS {{ .Name }} ( "version" VARCHAR(255) NOT NULL PRIMARY KEY )`) -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddColumnComment/null_comment.snap.txt: -------------------------------------------------------------------------------- 1 | COMMENT ON COLUMN tst_pg_add_column_comment_1.articles.id IS NULL 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddExtension/without_schema.snap.txt: -------------------------------------------------------------------------------- 1 | DROP EXTENSION IF EXISTS "hstore" CASCADE 2 | CREATE EXTENSION "hstore" 3 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_RenameColumn/simple_rename.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_rename_column_0.articles RENAME COLUMN id TO new_id 2 | -------------------------------------------------------------------------------- /pkg/schema/sqlite/testdata/TestSQLite_AddIndex/with_order_per_column.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE INDEX idx_articles_name_id ON articles (name DESC, id ASC) 2 | -------------------------------------------------------------------------------- /pkg/schema/sqlite/testdata/TestSQLite_AddIndex/with_predicate.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE INDEX idx_articles_name ON articles (name) WHERE name IS NOT NULL 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.out.sql 2 | *.out.txt 3 | *.data 4 | *.iml 5 | .idea 6 | migrations 7 | .amigo 8 | !example/*/.amigo 9 | !example/*/migrations 10 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddIndex/with_a_method.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE INDEX idx_articles_name ON tst_pg_add_index_3.articles USING btree (name) 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddIndex/with_unique_index.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE UNIQUE INDEX idx_articles_name ON tst_pg_add_index_1.articles (name) 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddColumn/with_default_value.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_add_column_1.articles ADD "name" TEXT DEFAULT 'default_name' 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddColumn/with_type_primary_key.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_add_column_3.articles ADD "id" SERIAL NOT NULL PRIMARY KEY 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddIndex/with_concurrently.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE INDEX CONCURRENTLY idx_articles_name ON tst_pg_add_index_4.articles (name) 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddIndex/with_multiple_Columns.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE INDEX idx_articles_name_id ON tst_pg_add_index_6.articles (name, id) 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_ChangeColumn/simple_change.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_change_column_0.articles ALTER COLUMN name TYPE varchar(255) 2 | -------------------------------------------------------------------------------- /pkg/schema/sqlite/testdata/TestSQLite_DropIndex/if_exists.snap.txt: -------------------------------------------------------------------------------- 1 | DROP INDEX IF EXISTS idx_articles_name_custom 2 | DROP INDEX idx_articles_name_custom 3 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | This website is built using [Docusaurus 3](https://docusaurus.io/), a modern static website generator. 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /docs/docs/04-api/02-sqlite/04-transformative.md: -------------------------------------------------------------------------------- 1 | # Transformative operations 2 | 3 | They are the operations that change the data in the database. 4 | 5 | 6 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddColumnComment/simple_comment.snap.txt: -------------------------------------------------------------------------------- 1 | COMMENT ON COLUMN tst_pg_add_column_comment_0.articles.id IS 'this is a comment' 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddIndex/with_predicate.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE INDEX idx_articles_name ON tst_pg_add_index_8.articles (name) WHERE name IS NOT NULL 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_ChangeColumnDefault/null_change.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_add_column_with_default_1.articles ALTER COLUMN id SET DEFAULT null 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_ChangeColumnDefault/simple_change.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_add_column_with_default_0.articles ALTER COLUMN id SET DEFAULT 4 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddExtension/with_schema.snap.txt: -------------------------------------------------------------------------------- 1 | DROP EXTENSION IF EXISTS "hstore" CASCADE 2 | CREATE EXTENSION "hstore" SCHEMA tst_pg_add_extension 3 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddPrimaryKeyConstraint/simple_primary_key.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_add_primary_key_constraint_0.articles ADD PRIMARY KEY (id) 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddIndex/with_order_per_column.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE INDEX idx_articles_name_id ON tst_pg_add_index_7.articles (name DESC, id ASC NULLS LAST) 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddPrimaryKeyConstraint/composite_primary_key.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_add_primary_key_constraint_1.articles ADD PRIMARY KEY (id, name) 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_DropEnum/drop_enum_with_if_exists.snap.txt: -------------------------------------------------------------------------------- 1 | DROP TYPE "tst_pg_drop_enum_1"."status" 2 | DROP TYPE IF EXISTS "tst_pg_drop_enum_1"."status" 3 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_DropForeignKeyConstraint/nominal.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_drop_foreign_key_constraint_0.articles DROP CONSTRAINT fk_articles_authors 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_DropTable/drop_table_with_if_exists.snap.txt: -------------------------------------------------------------------------------- 1 | DROP TABLE tst_pg_drop_table_1.articles 2 | DROP TABLE IF EXISTS tst_pg_drop_table_1.articles 3 | -------------------------------------------------------------------------------- /pkg/schema/sqlite/testdata/TestSQLite_DropIndex/with_custom_name.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE INDEX idx_articles_name_custom ON articles (name) 2 | DROP INDEX idx_articles_name_custom 3 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddCheckConstraint/with_custom_name.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_add_check_constraint_2.test_table ADD CONSTRAINT lalalalala CHECK (name <> '') 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_DropCheckConstraint/with_Table_prefix.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_drop_check_constraint_0.test_table DROP CONSTRAINT ck_test_table_constraint_1 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_DropCheckConstraint/with_custom_name.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_drop_check_constraint_1.test_table DROP CONSTRAINT ck_test_table_constraint_1 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_DropPrimaryKeyConstraint/simple_primary_key.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_drop_primary_key_constraint_0.articles DROP CONSTRAINT articles_pkey 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_ChangeColumn/change_column_type_with_using.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_change_column_1.articles ALTER COLUMN name TYPE INTEGER USING length(name) 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_CreateTable/with_custom_primary_key_name.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE TABLE tst_pg_create_table_1.articles ( 2 | "custom_id" BIGSERIAL NOT NULL PRIMARY KEY 3 | ) 4 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_DropEnum/drop_enum.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE TYPE "tst_pg_drop_enum_0"."status" AS ENUM ('active', 'inactive') 2 | DROP TYPE "tst_pg_drop_enum_0"."status" 3 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_DropForeignKeyConstraint/with_custom_name.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_drop_foreign_key_constraint_1.articles DROP CONSTRAINT fk_articles_authors 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_DropIndex/if_exists.snap.txt: -------------------------------------------------------------------------------- 1 | DROP INDEX IF EXISTS tst_pg_drop_index_2.idx_articles_name_custom 2 | DROP INDEX tst_pg_drop_index_2.idx_articles_name_custom 3 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddCheckConstraint/with_Table_prefix.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_add_check_constraint_1.test_table ADD CONSTRAINT ck_test_table_constraint_1 CHECK (name <> '') 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_DropTable/drop_table.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE TABLE tst_pg_drop_table_0.articles ( 2 | "id" SERIAL NOT NULL PRIMARY KEY 3 | ) 4 | DROP TABLE tst_pg_drop_table_0.articles 5 | -------------------------------------------------------------------------------- /example/sqlite/go.sum: -------------------------------------------------------------------------------- 1 | github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 2 | github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 3 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddCheckConstraint/with_no_validate.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_add_check_constraint_3.test_table ADD CONSTRAINT ck_test_table_constraint_3 CHECK (name <> '') NOT VALID 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddColumn/with_array.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_add_column_8.articles ADD "name" TEXT[] 2 | ALTER TABLE tst_pg_add_column_8.articles ADD "tetarraydec" DECIMAL(10, 2)[] 3 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddColumn/with_comment.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_add_column_11.articles ADD "name" TEXT 2 | COMMENT ON COLUMN tst_pg_add_column_11.articles.name IS 'this is a comment' 3 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_DropColumn/drop_if_exists.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_drop_column_1.articles DROP COLUMN IF EXISTS id 2 | ALTER TABLE tst_pg_drop_column_1.articles DROP COLUMN IF EXISTS id 3 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_DropIndex/with_custom_name.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE INDEX idx_articles_name_custom ON tst_pg_drop_index_1.articles (name) 2 | DROP INDEX tst_pg_drop_index_1.idx_articles_name_custom 3 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_RenameEnum/rename_enum.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE TYPE "tst_pg_rename_enum_0"."status" AS ENUM ('active', 'inactive') 2 | ALTER TYPE "tst_pg_rename_enum_0"."status" RENAME TO "status2" 3 | -------------------------------------------------------------------------------- /pkg/templates/init_create_table.go.tmpl: -------------------------------------------------------------------------------- 1 | s.CreateTable("{{ .Name }}", func(s *pg.PostgresTableDef) { 2 | s.String("version", schema.ColumnOptions{ PrimaryKey: true }) 3 | }, schema.TableOptions{ IfNotExists: true }) -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_RenameValue/add_enum_value.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE TYPE "tst_pg_add_enum_value_0"."status" AS ENUM ('active', 'inactive') 2 | ALTER TYPE "tst_pg_add_enum_value_0"."status" ADD VALUE 'pending' 3 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddEnumValue/add_enum_value.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE TYPE "tst_pg_add_enum_value_0"."status" AS ENUM ('active', 'inactive') 2 | ALTER TYPE "tst_pg_add_enum_value_0"."status" ADD VALUE 'pending' 3 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_RenameTable/rename_table.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE TABLE tst_pg_rename_table_0.articles ( 2 | "id" SERIAL NOT NULL PRIMARY KEY 3 | ) 4 | ALTER TABLE tst_pg_rename_table_0.articles RENAME TO posts 5 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddColumn/custom_column_type.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_add_column_7.articles ADD "id" SERIAL 2 | ALTER TABLE tst_pg_add_column_7.articles ADD "id_plus_1" numeric GENERATED ALWAYS AS (id + 1) STORED 3 | -------------------------------------------------------------------------------- /pkg/utils/testutils/env.go: -------------------------------------------------------------------------------- 1 | package testutils 2 | 3 | import "os" 4 | 5 | func EnvOrDefault(key, defaultValue string) string { 6 | v := os.Getenv(key) 7 | if v == "" { 8 | return defaultValue 9 | } 10 | return v 11 | } 12 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddTableComment/add_table_comment_null.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE TABLE tst_pg_add_table_comment_1.articles ( 2 | "id" SERIAL NOT NULL PRIMARY KEY 3 | ) 4 | COMMENT ON TABLE tst_pg_add_table_comment_1.articles IS NULL 5 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddForeignKeyConstraint/with_custom_name.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_add_foreign_key_constraint_1.articles ADD CONSTRAINT lalalalala FOREIGN KEY (author_id) REFERENCES tst_pg_add_foreign_key_constraint_1.authors (id) 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddColumn/with_type_serial.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_add_column_4.articles ADD "a" SERIAL 2 | ALTER TABLE tst_pg_add_column_4.articles ADD "b" BIGSERIAL 3 | ALTER TABLE tst_pg_add_column_4.articles ADD "c" SMALLSERIAL 4 | -------------------------------------------------------------------------------- /example/sqlite/.amigo/config.yml: -------------------------------------------------------------------------------- 1 | debug: false 2 | dsn: sqlite:data.db 3 | folder: migrations 4 | json: false 5 | package: migrations 6 | schema-version-table: mig_schema_versions 7 | shell-path: /bin/bash 8 | sql: false 9 | sql-syntax-highlighting: true 10 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddColumn/with_timestamps.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_add_column_12.articles ADD "created_at" TIMESTAMP(6) DEFAULT now() NOT NULL 2 | ALTER TABLE tst_pg_add_column_12.articles ADD "updated_at" TIMESTAMP(6) DEFAULT now() NOT NULL 3 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddForeignKeyConstraint/custom_column_name.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_add_foreign_key_constraint_2.articles ADD CONSTRAINT fk_articles_authors FOREIGN KEY (user_id) REFERENCES tst_pg_add_foreign_key_constraint_2.authors (id) 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddForeignKeyConstraint/custom_primary_key.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_add_foreign_key_constraint_3.articles ADD CONSTRAINT fk_articles_authors FOREIGN KEY (user_id) REFERENCES tst_pg_add_foreign_key_constraint_3.authors (ref) 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddForeignKeyConstraint/with_no_validate.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_add_foreign_key_constraint_8.articles ADD CONSTRAINT fk_articles_authors FOREIGN KEY (author_id) REFERENCES tst_pg_add_foreign_key_constraint_8.authors (id) 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddTableComment/add_table_comment.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE TABLE tst_pg_add_table_comment_0.articles ( 2 | "id" SERIAL NOT NULL PRIMARY KEY 3 | ) 4 | COMMENT ON TABLE tst_pg_add_table_comment_0.articles IS 'This is a table of articles' 5 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_CreateTable/create_basic_table.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE TABLE tst_pg_create_table_0.articles ( 2 | "id" SERIAL NOT NULL PRIMARY KEY, 3 | "title" TEXT CONSTRAINT title_not_empty CHECK (title <> ''), 4 | "content" TEXT, 5 | "views" INTEGER 6 | ) 7 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddForeignKeyConstraint/with_on_delete.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_add_foreign_key_constraint_5.articles ADD CONSTRAINT fk_articles_authors FOREIGN KEY (author_id) REFERENCES tst_pg_add_foreign_key_constraint_5.authors (id) ON DELETE CASCADE 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddForeignKeyConstraint/with_on_update.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_add_foreign_key_constraint_6.articles ADD CONSTRAINT fk_articles_authors FOREIGN KEY (author_id) REFERENCES tst_pg_add_foreign_key_constraint_6.authors (id) ON UPDATE CASCADE 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddForeignKeyConstraint/composite_pk.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_add_foreign_key_constraint_4.orders ADD CONSTRAINT fk_orders_carts FOREIGN KEY (cart_shop_id, cart_user_id) REFERENCES tst_pg_add_foreign_key_constraint_4.carts (shop_id, user_id) 2 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddForeignKeyConstraint/with_deferrable.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_add_foreign_key_constraint_7.articles ADD CONSTRAINT fk_articles_authors FOREIGN KEY (author_id) REFERENCES tst_pg_add_foreign_key_constraint_7.authors (id) DEFERRABLE INITIALLY DEFERRED 2 | -------------------------------------------------------------------------------- /example/mysql/.amigo/config.yml: -------------------------------------------------------------------------------- 1 | amigo-folder: .amigo 2 | debug: false 3 | dsn: root:root@tcp(localhost:6668)/mysql 4 | folder: migrations 5 | json: false 6 | package: migrations 7 | schema-version-table: mig_schema_versions 8 | shell-path: /bin/bash 9 | sql: false 10 | sql-syntax-highlighting: true 11 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_CreateTable/composite_primary_key.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE TABLE tst_pg_create_table_2.articles ( 2 | "id" SERIAL NOT NULL, 3 | "author_id" TEXT NOT NULL, 4 | "content" TEXT, 5 | "views" INTEGER 6 | ) 7 | ALTER TABLE tst_pg_create_table_2.articles ADD PRIMARY KEY (id, author_id) 8 | -------------------------------------------------------------------------------- /example/mysql/migrations/migrations.go: -------------------------------------------------------------------------------- 1 | // Package migrations 2 | // /!\ File is auto-generated DO NOT EDIT. 3 | package migrations 4 | 5 | import ( 6 | "github.com/alexisvisco/amigo/pkg/schema" 7 | ) 8 | 9 | var Migrations = []schema.Migration{ 10 | &Migration20240529125357CreateTableSchemaVersion{}, 11 | } 12 | -------------------------------------------------------------------------------- /example/pg/.amigo/config.yml: -------------------------------------------------------------------------------- 1 | debug: false 2 | dsn: postgres://postgres:postgres@localhost:6666/postgres?sslmode=disable 3 | folder: migrations 4 | json: false 5 | package: migrations 6 | schema-version-table: public.mig_schema_versions 7 | shell-path: /bin/bash 8 | sql: false 9 | sql-syntax-highlighting: true 10 | -------------------------------------------------------------------------------- /docs/docs/02-quick-start/01-installation.md: -------------------------------------------------------------------------------- 1 | ## Installation 2 | 3 | To install the library, run the following command: 4 | 5 | ```sh 6 | go install github.com/alexisvisco/amigo@latest 7 | ``` 8 | 9 | To check if the installation was successful, run the following command: 10 | 11 | ```sh 12 | amigo --help 13 | ``` 14 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_CreateTable/without_primary_key.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE TABLE tst_pg_create_table_5.articles ( 2 | "id" SERIAL, 3 | "title" TEXT 4 | ) 5 | COMMENT ON TABLE tst_pg_create_table_5.articles IS 'This is a table without primary key' 6 | CREATE TABLE tst_pg_create_table_5.articles_without_id ( 7 | "title" TEXT 8 | ) 9 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddColumn/with_precision_and_or_scale.snap.txt: -------------------------------------------------------------------------------- 1 | ALTER TABLE tst_pg_add_column_5.articles ADD "a" DECIMAL(10, 2) 2 | ALTER TABLE tst_pg_add_column_5.articles ADD "b" DECIMAL(10) 3 | ALTER TABLE tst_pg_add_column_5.articles ADD "c" TIMESTAMP(6) 4 | ALTER TABLE tst_pg_add_column_5.articles ADD "d" TIMESTAMP(8) 5 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_AddEnumValue/add_enum_value_after/before_a_value.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE TYPE "tst_pg_add_enum_value_1"."status" AS ENUM ('active', 'inactive') 2 | ALTER TYPE "tst_pg_add_enum_value_1"."status" ADD VALUE 'pending' BEFORE 'active' 3 | ALTER TYPE "tst_pg_add_enum_value_1"."status" ADD VALUE 'rejected' AFTER 'inactive' 4 | -------------------------------------------------------------------------------- /docs/docs/04-api/02-sqlite/03-informative.md: -------------------------------------------------------------------------------- 1 | # Informative operations 2 | 3 | They are the operations that give you information about the database schema. 4 | 5 | - [IndexExist(tableName schema.TableName, indexName string) bool](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/sqlite#Schema.IndexExist) 6 | 7 | These functions are not reversible. 8 | -------------------------------------------------------------------------------- /example/pg/migrations/migrations.go: -------------------------------------------------------------------------------- 1 | // Package migrations 2 | // /!\ File is auto-generated DO NOT EDIT. 3 | package migrations 4 | 5 | import ( 6 | "github.com/alexisvisco/amigo/pkg/schema" 7 | ) 8 | 9 | var Migrations = []schema.Migration{ 10 | &Migration20240530063939CreateTableSchemaVersion{}, 11 | &Migration20240530063940CreateUserTable{}, 12 | } 13 | -------------------------------------------------------------------------------- /docs/docs/02-quick-start/07-working-with-not-supported-db.md: -------------------------------------------------------------------------------- 1 | # Working with not supported database 2 | 3 | If you want to use a database that is not supported by amigo, you can. 4 | 5 | If the DSN is not recognized you will be using the base interface which is `base.Schema` (it only implement methods to manipulate the versions table) but you have access to the `*sql.DB`. -------------------------------------------------------------------------------- /pkg/utils/logger/slog_test.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "github.com/alexisvisco/amigo/pkg/utils/events" 5 | "log/slog" 6 | "os" 7 | "testing" 8 | ) 9 | 10 | func TestLog(t *testing.T) { 11 | slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stderr, nil))) 12 | 13 | evt := events.FileAddedEvent{FileName: "test.txt"} 14 | 15 | Info(evt) 16 | } 17 | -------------------------------------------------------------------------------- /docs/docs/03-cli/07-schema.md: -------------------------------------------------------------------------------- 1 | # Schema 2 | 3 | The `schema` command allow to dump the schema to `db/schema.sql` (default path) 4 | 5 | Database supported: 6 | - postgres (via pg_dump) 7 | 8 | ## Flags 9 | 10 | - `--schema-db-dump-schema` will change the schema (default public) to dump 11 | - `--schema-out-path` will change the path to output the file (db/schema.sql) -------------------------------------------------------------------------------- /docs/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine as build 2 | 3 | WORKDIR /app 4 | 5 | COPY package.json ./ 6 | COPY package-lock.json ./ 7 | COPY babel.config.js ./ 8 | 9 | RUN npm install 10 | 11 | COPY src ./src 12 | COPY static ./static 13 | COPY docs ./docs 14 | COPY docusaurus.config.js ./ 15 | COPY sidebars.js ./ 16 | 17 | RUN npm run build 18 | 19 | EXPOSE 3000 20 | 21 | CMD ["npm", "run", "serve"] 22 | -------------------------------------------------------------------------------- /pkg/amigo/skip_migration_file.go: -------------------------------------------------------------------------------- 1 | package amigo 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | ) 8 | 9 | func (a Amigo) SkipMigrationFile(ctx context.Context, db *sql.DB) error { 10 | schema, err := a.GetSchema(ctx, db) 11 | if err != nil { 12 | return fmt.Errorf("unable to get schema: %w", err) 13 | } 14 | 15 | schema.AddVersion(a.Config.Create.Version) 16 | 17 | return nil 18 | } 19 | -------------------------------------------------------------------------------- /pkg/utils/ptr.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "reflect" 4 | 5 | // Ptr returns a pointer to the value passed as argument. 6 | func Ptr[T any](t T) *T { return &t } 7 | 8 | // NilOrValue If the value is a default value for the type, it returns nil else it returns the value. 9 | func NilOrValue[T any](t T) *T { 10 | var zero T 11 | if reflect.DeepEqual(t, zero) { 12 | return nil 13 | } 14 | return &t 15 | } 16 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_RenameValue/rename_enum_value.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE TYPE "tst_pg_rename_enum_value_0"."status" AS ENUM ('active', 'inactive') 2 | CREATE TABLE tst_pg_rename_enum_value_0.articles ( 3 | "status" tst_pg_rename_enum_value_0.status 4 | ) 5 | INSERT INTO tst_pg_rename_enum_value_0.articles (status) VALUES ('active'); 6 | ALTER TYPE "tst_pg_rename_enum_value_0"."status" RENAME VALUE 'active' TO 'pending' 7 | -------------------------------------------------------------------------------- /pkg/amigo/get_status.go: -------------------------------------------------------------------------------- 1 | package amigo 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | ) 8 | 9 | // GetStatus return the state of the database 10 | func (a Amigo) GetStatus(ctx context.Context, db *sql.DB) ([]string, error) { 11 | schema, err := a.GetSchema(ctx, db) 12 | if err != nil { 13 | return nil, fmt.Errorf("unable to get schema: %w", err) 14 | } 15 | 16 | return schema.FindAppliedVersions(), nil 17 | } 18 | -------------------------------------------------------------------------------- /pkg/schema/schema.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import "database/sql" 4 | 5 | // Schema is the interface that need to be implemented to support migrations. 6 | type Schema interface { 7 | AddVersion(version string) 8 | AddVersions(versions []string) 9 | RemoveVersion(version string) 10 | FindAppliedVersions() []string 11 | 12 | Exec(query string, args ...interface{}) 13 | Query(query string, args []any, rowHandler func(row *sql.Rows) error) 14 | } 15 | -------------------------------------------------------------------------------- /docs/docs/03-cli/06-status.md: -------------------------------------------------------------------------------- 1 | # Status 2 | 3 | The `status` command will show the current status of the migrations. 4 | 5 | It show the 10 most recent migrations and their status between applied and not applied. 6 | 7 | The last line of the output will be the most recent migration. 8 | 9 | 10 | ```sh 11 | amigo status 12 | 13 | (20240530063939) create_table_schema_version applied 14 | (20240524090434) create_user_table not applied 15 | ``` 16 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_CreateTable/indexes.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE TABLE tst_pg_create_table_4.articles ( 2 | "id" SERIAL NOT NULL PRIMARY KEY, 3 | "title" TEXT, 4 | "content" TEXT, 5 | "views" INTEGER, 6 | "created_at" TIMESTAMP(6) DEFAULT now() NOT NULL, 7 | "updated_at" TIMESTAMP(6) DEFAULT now() NOT NULL 8 | ) 9 | CREATE INDEX idx_articles_title ON tst_pg_create_table_4.articles (title) 10 | CREATE INDEX idx_articles_content_views ON tst_pg_create_table_4.articles (content, views) 11 | -------------------------------------------------------------------------------- /testdata/e2e/pg/migrations_with_change/migrations.go: -------------------------------------------------------------------------------- 1 | // Package migrations 2 | // /!\ File is auto-generated DO NOT EDIT. 3 | package migrations 4 | 5 | import ( 6 | "github.com/alexisvisco/amigo/pkg/schema" 7 | ) 8 | 9 | var Migrations = []schema.Migration{ 10 | &Migration20240517080505SchemaVersion{}, 11 | &Migration20240518071740CreateUser{}, 12 | &Migration20240518071842AddIndexUserEmail{}, 13 | &Migration20240518071938CustomSeed{}, 14 | &Migration20240527192300Enum{}, 15 | } 16 | -------------------------------------------------------------------------------- /testdata/e2e/pg/migrations_with_classic/migrations.go: -------------------------------------------------------------------------------- 1 | // Package migrations 2 | // /!\ File is auto-generated DO NOT EDIT. 3 | package migrations 4 | 5 | import ( 6 | "github.com/alexisvisco/amigo/pkg/schema" 7 | ) 8 | 9 | var Migrations = []schema.Migration{ 10 | &Migration20240517080505SchemaVersion{}, 11 | &Migration20240518071740CreateUser{}, 12 | &Migration20240518071842AddIndexUserEmail{}, 13 | &Migration20240518071938CustomSeed{}, 14 | &Migration20240527192355Enum{}, 15 | } 16 | -------------------------------------------------------------------------------- /pkg/entrypoint/main.go: -------------------------------------------------------------------------------- 1 | package entrypoint 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/alexisvisco/amigo/pkg/amigo" 7 | "github.com/alexisvisco/amigo/pkg/amigoconfig" 8 | "github.com/alexisvisco/amigo/pkg/schema" 9 | ) 10 | 11 | type Provider func(cfg amigoconfig.Config) (*sql.DB, []schema.Migration, error) 12 | 13 | func Main(resourceProvider Provider, opts ...amigo.OptionFn) { 14 | provider = resourceProvider 15 | amigoOptions = opts 16 | 17 | _ = rootCmd.Execute() 18 | } 19 | -------------------------------------------------------------------------------- /pkg/schema/pg/testdata/TestPostgres_CreateTable/foreign_keys.snap.txt: -------------------------------------------------------------------------------- 1 | CREATE TABLE tst_pg_create_table_3.articles ( 2 | "id" SERIAL NOT NULL PRIMARY KEY, 3 | "author_id" TEXT, 4 | "content" TEXT, 5 | "views" INTEGER 6 | ) 7 | CREATE TABLE tst_pg_create_table_3.authors ( 8 | "id" SERIAL NOT NULL PRIMARY KEY, 9 | "name" TEXT, 10 | "article_id" INTEGER 11 | ) 12 | ALTER TABLE tst_pg_create_table_3.authors ADD CONSTRAINT fk_authors_articles FOREIGN KEY (article_id) REFERENCES tst_pg_create_table_3.articles (id) 13 | -------------------------------------------------------------------------------- /pkg/templates/migrations.go.tmpl: -------------------------------------------------------------------------------- 1 | // Package {{ .Package }} 2 | // /!\ File is auto-generated DO NOT EDIT. 3 | package {{ .Package }} 4 | 5 | import ( 6 | "github.com/alexisvisco/amigo/pkg/schema" 7 | {{if .ImportSchemaPackage}} "embed" 8 | "{{ .ImportSchemaPackage }}"{{end}} 9 | ) 10 | {{if .ImportSchemaPackage}} 11 | //go:embed *.sql 12 | var sqlMigrationsFS embed.FS 13 | {{end}} 14 | 15 | var Migrations = []schema.Migration{ 16 | {{- range .Migrations }} 17 | {{ . }}, 18 | {{- end }} 19 | } 20 | -------------------------------------------------------------------------------- /example/mysql/go.mod: -------------------------------------------------------------------------------- 1 | module mysql 2 | 3 | go 1.22 4 | 5 | replace github.com/alexisvisco/amigo => ../../ 6 | 7 | require ( 8 | github.com/alexisvisco/amigo v0.0.0-00010101000000-000000000000 9 | github.com/go-sql-driver/mysql v1.8.1 10 | ) 11 | 12 | require ( 13 | filippo.io/edwards25519 v1.1.0 // indirect 14 | github.com/alecthomas/chroma/v2 v2.14.0 // indirect 15 | github.com/dlclark/regexp2 v1.11.0 // indirect 16 | github.com/gobuffalo/flect v1.0.2 // indirect 17 | github.com/simukti/sqldb-logger v0.0.0-20230108155151-646c1a075551 // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /pkg/templates/main.go.tmpl: -------------------------------------------------------------------------------- 1 | {{/* gotype: github.com/alexisvisco/amigo/pkg/templates.MainData */ -}} 2 | package main 3 | 4 | import ( 5 | "database/sql" 6 | 7 | "github.com/alexisvisco/amigo/pkg/amigoconfig" 8 | "github.com/alexisvisco/amigo/pkg/entrypoint" 9 | migrations "{{ .PackagePath }}" 10 | _ "github.com/jackc/pgx/v5/stdlib" 11 | ) 12 | 13 | func main() { 14 | databaseProvider := func(cfg amigoconfig.Config) (*sql.DB, error) { 15 | return sql.Open("pgx", cfg.GetRealDSN()) 16 | } 17 | 18 | entrypoint.Main(databaseProvider, migrations.Migrations) 19 | } 20 | -------------------------------------------------------------------------------- /docs/docs/04-api/02-sqlite/01-constructive.md: -------------------------------------------------------------------------------- 1 | # Constructive operations 2 | 3 | They are the operations that create, alter, or drop tables, columns, indexes, constraints, and so on. 4 | 5 | - [AddIndex(table schema.TableName, columns []string, option ...schema.IndexOptions)](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/sqlite#Schema.AddIndex) 6 | 7 | 8 | Each of this functions are reversible, it means that in a migration that implement the `change` function, when you 9 | rollback the migration you don't have to write manually the rollback operation, the library will do it for you. 10 | -------------------------------------------------------------------------------- /pkg/schema/helpers.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import "regexp" 4 | 5 | func isTableDoesNotExists(err error) bool { 6 | if err == nil { 7 | return false 8 | } 9 | 10 | re := []*regexp.Regexp{ 11 | regexp.MustCompile(`Error 1146 \(42S02\): Table '.*' doesn't exist`), 12 | regexp.MustCompile(`ERROR: relation ".*" does not exist \(SQLSTATE 42P01\)`), 13 | regexp.MustCompile(`no such table: .*`), 14 | regexp.MustCompile(`.*does not exist \(SQLSTATE=42P01\).*`), 15 | } 16 | 17 | for _, r := range re { 18 | if r.MatchString(err.Error()) { 19 | return true 20 | } 21 | } 22 | 23 | return false 24 | } 25 | -------------------------------------------------------------------------------- /pkg/utils/error.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "fmt" 4 | 5 | func PanicToError(fn func()) (err error) { 6 | defer func() { 7 | if r := recover(); r != nil { 8 | if e, ok := r.(error); ok { 9 | err = e 10 | } else { 11 | err = fmt.Errorf("%v", r) 12 | } 13 | } 14 | }() 15 | fn() 16 | return 17 | } 18 | 19 | func PanicToError1[Out any](fn func() Out) (out Out, err error) { 20 | defer func() { 21 | if r := recover(); r != nil { 22 | if e, ok := r.(error); ok { 23 | err = e 24 | } else { 25 | err = fmt.Errorf("%v", r) 26 | } 27 | } 28 | }() 29 | out = fn() 30 | return 31 | } 32 | -------------------------------------------------------------------------------- /example/mysql/.amigo/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "github.com/alexisvisco/amigo/pkg/entrypoint" 6 | "github.com/alexisvisco/amigo/pkg/utils/events" 7 | "github.com/alexisvisco/amigo/pkg/utils/logger" 8 | _ "github.com/go-sql-driver/mysql" 9 | migrations "mysql/migrations" 10 | "os" 11 | ) 12 | 13 | func main() { 14 | opts, arg := entrypoint.AmigoContextFromFlags() 15 | 16 | db, err := sql.Open("mysql", opts.GetRealDSN()) 17 | if err != nil { 18 | logger.Error(events.MessageEvent{Message: err.Error()}) 19 | os.Exit(1) 20 | } 21 | 22 | entrypoint.Main(db, arg, migrations.Migrations, opts) 23 | } 24 | -------------------------------------------------------------------------------- /example/pg/.amigo/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | migrations "example/pg/migrations" 6 | "github.com/alexisvisco/amigo/pkg/entrypoint" 7 | "github.com/alexisvisco/amigo/pkg/utils/events" 8 | "github.com/alexisvisco/amigo/pkg/utils/logger" 9 | _ "github.com/jackc/pgx/v5/stdlib" 10 | "os" 11 | ) 12 | 13 | func main() { 14 | opts, arg := entrypoint.AmigoContextFromFlags() 15 | 16 | db, err := sql.Open("pgx", opts.GetRealDSN()) 17 | if err != nil { 18 | logger.Error(events.MessageEvent{Message: err.Error()}) 19 | os.Exit(1) 20 | } 21 | 22 | entrypoint.Main(db, arg, migrations.Migrations, opts) 23 | } 24 | -------------------------------------------------------------------------------- /example/sqlite/.amigo/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "github.com/alexisvisco/amigo/pkg/entrypoint" 6 | "github.com/alexisvisco/amigo/pkg/utils/events" 7 | "github.com/alexisvisco/amigo/pkg/utils/logger" 8 | _ "github.com/mattn/go-sqlite3" 9 | "os" 10 | migrations "sqlite/migrations" 11 | ) 12 | 13 | func main() { 14 | opts, arg := entrypoint.AmigoContextFromFlags() 15 | 16 | db, err := sql.Open("sqlite3", opts.GetRealDSN()) 17 | if err != nil { 18 | logger.Error(events.MessageEvent{Message: err.Error()}) 19 | os.Exit(1) 20 | } 21 | 22 | entrypoint.Main(db, arg, migrations.Migrations, opts) 23 | } 24 | -------------------------------------------------------------------------------- /pkg/schema/pg/utils.go: -------------------------------------------------------------------------------- 1 | package pg 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | func QuoteIdent(name string) string { 8 | end := strings.IndexRune(name, 0) 9 | if end > -1 { 10 | name = name[:end] 11 | } 12 | return `"` + strings.ReplaceAll(name, `"`, `""`) + `"` 13 | } 14 | 15 | func QuoteValue(v interface{}) string { 16 | switch v := v.(type) { 17 | default: 18 | panic("unsupported value") 19 | case string: 20 | v = strings.ReplaceAll(v, `'`, `''`) 21 | if strings.Contains(v, `\`) { 22 | v = strings.ReplaceAll(v, `\`, `\\`) 23 | v = ` E'` + v + `'` 24 | } else { 25 | v = `'` + v + `'` 26 | } 27 | return v 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /pkg/utils/colors/colors.go: -------------------------------------------------------------------------------- 1 | package colors 2 | 3 | import "fmt" 4 | 5 | var ( 6 | Black = Color("\033[1;30m%s\033[0m") 7 | Red = Color("\033[1;31m%s\033[0m") 8 | Green = Color("\033[1;32m%s\033[0m") 9 | Yellow = Color("\033[1;33m%s\033[0m") 10 | Purple = Color("\033[1;34m%s\033[0m") 11 | Magenta = Color("\033[1;35m%s\033[0m") 12 | Teal = Color("\033[1;36m%s\033[0m") 13 | White = Color("\033[1;37m%s\033[0m") 14 | ) 15 | 16 | func Color(colorString string) func(...interface{}) string { 17 | sprint := func(args ...interface{}) string { 18 | return fmt.Sprintf(colorString, fmt.Sprint(args...)) 19 | } 20 | 21 | return sprint 22 | } 23 | -------------------------------------------------------------------------------- /example/sqlite/migrations/migrations.go: -------------------------------------------------------------------------------- 1 | // Package migrations 2 | // /!\ File is auto-generated DO NOT EDIT. 3 | package migrations 4 | 5 | import ( 6 | "github.com/alexisvisco/amigo/pkg/schema" 7 | "github.com/alexisvisco/amigo/pkg/schema/pg" 8 | 9 | "embed" 10 | ) 11 | 12 | //go:embed *.sql 13 | var sqlMigrationsFS embed.FS 14 | 15 | var Migrations = []schema.Migration{ 16 | &Migration20240602080728CreateTableSchemaVersion{}, 17 | &Migration20240602081304AddIndex{}, 18 | &Migration20240602081806DropIndex{}, 19 | schema.NewSQLMigration[*pg.Schema](sqlMigrationsFS, "20240602081806_drop_index.sql", "2024-06-02T10:18:06+02:00", 20 | "-- migrate:down"), 21 | } 22 | -------------------------------------------------------------------------------- /docs/docs/02-quick-start/05-create-sql-migration.md: -------------------------------------------------------------------------------- 1 | # SQL Migration 2 | 3 | If you have a custom SQL to execute like materialized view, procedures and you want autocompletion and IDE support from 4 | SQL, you can use the `--type sql` flag. 5 | 6 | ```sh 7 | amigo create my_migration --type sql 8 | ``` 9 | 10 | ### Example of a `sql` migration file: 11 | 12 | ```sql 13 | -- todo: write up migrations here 14 | -- migrate:down 15 | -- todo: write down migrations here 16 | ``` 17 | 18 | It act as a classic migration, you can write your SQL in the `-- todo: write up migrations here` section. 19 | 20 | All code below the `-- migrate:down` comment will be used to rollback the migration. -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres_mig: 3 | image: postgres:16-alpine 4 | environment: 5 | POSTGRES_DB: postgres 6 | POSTGRES_PASSWORD: postgres 7 | POSTGRES_USER: postgres 8 | ports: 9 | - 6666:5432 10 | volumes: 11 | - pg_data:/var/lib/postgresql/data 12 | mysql_mig: 13 | image: mysql:8 14 | environment: 15 | MYSQL_ROOT_PASSWORD: root 16 | MYSQL_DATABASE: mysql 17 | MYSQL_USER: mysql 18 | MYSQL_PASSWORD: mysql 19 | ports: 20 | - 6668:3306 21 | volumes: 22 | - mysql_data:/var/lib/mysql 23 | volumes: 24 | pg_data: 25 | name: pg_data 26 | mysql_data: 27 | name: mysql_data 28 | -------------------------------------------------------------------------------- /docs/docs/04-api/01-postgres/05-others.md: -------------------------------------------------------------------------------- 1 | # Others operations 2 | 3 | They are functions that are not in the constructive, destructive, or informative categories. 4 | 5 | - [Exec(query string, args ...interface{})](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.Exec) 6 | 7 | If you want to reverse a query in a `change` function you should use the Reversible method. 8 | 9 | 10 | ```go 11 | s.Reversible(schema.Directions{ 12 | Up: func() { 13 | s.Exec("INSERT INTO public.mig_schema_versions (id) VALUES ('1')") 14 | }, 15 | 16 | Down: func() { 17 | s.Exec("DELETE FROM public.mig_schema_versions WHERE id = '1'") 18 | }, 19 | }) 20 | ``` 21 | -------------------------------------------------------------------------------- /docs/docs/04-api/02-sqlite/05-others.md: -------------------------------------------------------------------------------- 1 | # Others operations 2 | 3 | They are functions that are not in the constructive, destructive, or informative categories. 4 | 5 | - [Exec(query string, args ...interface{})](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/sqlite#Schema.Exec) 6 | 7 | If you want to reverse a query in a `change` function you should use the Reversible method. 8 | 9 | 10 | ```go 11 | s.Reversible(schema.Directions{ 12 | Up: func() { 13 | s.Exec("INSERT INTO public.mig_schema_versions (id) VALUES ('1')") 14 | }, 15 | 16 | Down: func() { 17 | s.Exec("DELETE FROM public.mig_schema_versions WHERE id = '1'") 18 | }, 19 | }) 20 | ``` 21 | -------------------------------------------------------------------------------- /example/sqlite/migrations/20240602081304_add_index.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/alexisvisco/amigo/pkg/schema" 5 | "github.com/alexisvisco/amigo/pkg/schema/sqlite" 6 | "time" 7 | ) 8 | 9 | type Migration20240602081304AddIndex struct{} 10 | 11 | func (m Migration20240602081304AddIndex) Change(s *sqlite.Schema) { 12 | s.AddIndex("mig_schema_versions", []string{"id"}, schema.IndexOptions{ 13 | IfNotExists: true, 14 | }) 15 | } 16 | 17 | func (m Migration20240602081304AddIndex) Name() string { 18 | return "add_index" 19 | } 20 | 21 | func (m Migration20240602081304AddIndex) Date() time.Time { 22 | t, _ := time.Parse(time.RFC3339, "2024-06-02T10:13:04+02:00") 23 | return t 24 | } 25 | -------------------------------------------------------------------------------- /testdata/e2e/pg/migrations_with_change/20240527192300_enum.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/alexisvisco/amigo/pkg/schema" 5 | "github.com/alexisvisco/amigo/pkg/schema/pg" 6 | "time" 7 | ) 8 | 9 | type Migration20240527192300Enum struct{} 10 | 11 | func (m Migration20240527192300Enum) Change(s *pg.Schema) { 12 | s.CreateEnum("status", []string{"active", "inactive"}, schema.CreateEnumOptions{ 13 | Schema: "migrations_with_change", 14 | }) 15 | } 16 | 17 | func (m Migration20240527192300Enum) Name() string { 18 | return "enum" 19 | } 20 | 21 | func (m Migration20240527192300Enum) Date() time.Time { 22 | t, _ := time.Parse(time.RFC3339, "2024-05-27T21:23:00+02:00") 23 | return t 24 | } 25 | -------------------------------------------------------------------------------- /docs/docs/03-cli/02-context.md: -------------------------------------------------------------------------------- 1 | # Context 2 | 3 | The `context` command allow you to save root flags in a configuration file. This file will be used by mig to avoid passing flags each time you run a command. 4 | 5 | Example: 6 | ```sh 7 | amigo context --dsn "postgres://user:password@localhost:5432/dbname" --folder mgs --package mgs 8 | ``` 9 | 10 | Will create a `config.yml` file in the `.mig` folder with the following content: 11 | ```yaml 12 | dsn: postgres://user:password@localhost:5432/dbname 13 | folder: mgs 14 | json: false 15 | amigo-folder: .mig 16 | package: mgs 17 | pg-dump-path: pg_dump 18 | schema-version-table: public.mig_schema_versions 19 | shell-path: /bin/bash 20 | debug: false 21 | show-sql: false 22 | ``` 23 | -------------------------------------------------------------------------------- /pkg/utils/slices.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | func StringJoin[T any](slice []T, sep string) string { 9 | if len(slice) == 0 { 10 | return "" 11 | } 12 | 13 | if len(slice) == 1 { 14 | return fmt.Sprint(slice[0]) 15 | } 16 | 17 | var b strings.Builder 18 | b.WriteString(fmt.Sprint(slice[0])) 19 | for _, s := range slice[1:] { 20 | b.WriteString(sep) 21 | b.WriteString(fmt.Sprint(s)) 22 | } 23 | 24 | return b.String() 25 | } 26 | 27 | func Map[T any, U any](slice []T, f func(T) U) []U { 28 | if len(slice) == 0 { 29 | return nil 30 | } 31 | 32 | result := make([]U, len(slice)) 33 | for i, s := range slice { 34 | result[i] = f(s) 35 | } 36 | 37 | return result 38 | } 39 | -------------------------------------------------------------------------------- /pkg/schema/helpers_test.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | ) 7 | 8 | func Test_isTableDoesNotExists(t *testing.T) { 9 | type args struct { 10 | err error 11 | } 12 | tests := []struct { 13 | name string 14 | err error 15 | want bool 16 | }{ 17 | { 18 | name: "bun driver", 19 | err: errors.New("panic: error while fetching applied versions: ERROR: relation \"gwt.mig_schema_versions\" does not exist (SQLSTATE=42P01)\n"), 20 | want: true, 21 | }, 22 | } 23 | for _, tt := range tests { 24 | t.Run(tt.name, func(t *testing.T) { 25 | if got := isTableDoesNotExists(tt.err); got != tt.want { 26 | t.Errorf("isTableDoesNotExists() = %v, want %v", got, tt.want) 27 | } 28 | }) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /example/sqlite/migrations/20240602081806_drop_index.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/alexisvisco/amigo/pkg/schema" 5 | "github.com/alexisvisco/amigo/pkg/schema/sqlite" 6 | "time" 7 | ) 8 | 9 | type Migration20240602081806DropIndex struct{} 10 | 11 | func (m Migration20240602081806DropIndex) Change(s *sqlite.Schema) { 12 | s.DropIndex("mig_schema_versions", []string{"id"}, schema.DropIndexOptions{ 13 | Reversible: &schema.IndexOptions{IfNotExists: true}, 14 | }) 15 | } 16 | 17 | func (m Migration20240602081806DropIndex) Name() string { 18 | return "drop_index" 19 | } 20 | 21 | func (m Migration20240602081806DropIndex) Date() time.Time { 22 | t, _ := time.Parse(time.RFC3339, "2024-06-02T10:18:06+02:00") 23 | return t 24 | } 25 | -------------------------------------------------------------------------------- /testdata/e2e/pg/migrations_with_change/20240518071842_add_index_user_email.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/alexisvisco/amigo/pkg/schema" 5 | "github.com/alexisvisco/amigo/pkg/schema/pg" 6 | "time" 7 | ) 8 | 9 | type Migration20240518071842AddIndexUserEmail struct{} 10 | 11 | func (m Migration20240518071842AddIndexUserEmail) Change(s *pg.Schema) { 12 | s.AddIndex("migrations_with_change.users", []string{"email"}, schema.IndexOptions{Unique: true}) 13 | } 14 | 15 | func (m Migration20240518071842AddIndexUserEmail) Name() string { 16 | return "add_index_user_email" 17 | } 18 | 19 | func (m Migration20240518071842AddIndexUserEmail) Date() time.Time { 20 | t, _ := time.Parse(time.RFC3339, "2024-05-18T09:18:42+02:00") 21 | return t 22 | } 23 | -------------------------------------------------------------------------------- /pkg/schema/sqlite/exists.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "fmt" 7 | "github.com/alexisvisco/amigo/pkg/schema" 8 | ) 9 | 10 | // IndexExist checks if the specified index exists for the given table 11 | func (p *Schema) IndexExist(tableName schema.TableName, indexName string) bool { 12 | query := `SELECT 1 FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name = ?` 13 | var exists int 14 | err := p.TX.QueryRowContext(p.Context.Context, query, tableName.Name(), indexName).Scan(&exists) 15 | if err != nil { 16 | if errors.Is(err, sql.ErrNoRows) { 17 | return false 18 | } 19 | p.Context.RaiseError(fmt.Errorf("error while checking if index exists: %w", err)) 20 | return false 21 | } 22 | return exists == 1 23 | } 24 | -------------------------------------------------------------------------------- /testdata/e2e/pg/migrations_with_change/20240518071740_create_user.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/alexisvisco/amigo/pkg/schema/pg" 5 | "time" 6 | ) 7 | 8 | type Migration20240518071740CreateUser struct{} 9 | 10 | func (m Migration20240518071740CreateUser) Change(s *pg.Schema) { 11 | s.CreateTable("migrations_with_change.users", func(def *pg.PostgresTableDef) { 12 | def.Serial("id") 13 | def.String("name") 14 | def.String("email") 15 | def.Timestamps() 16 | def.Index([]string{"name"}) 17 | }) 18 | } 19 | 20 | func (m Migration20240518071740CreateUser) Name() string { 21 | return "create_user" 22 | } 23 | 24 | func (m Migration20240518071740CreateUser) Date() time.Time { 25 | t, _ := time.Parse(time.RFC3339, "2024-05-18T09:17:40+02:00") 26 | return t 27 | } 28 | -------------------------------------------------------------------------------- /example/pg/go.mod: -------------------------------------------------------------------------------- 1 | module example/pg 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/alexisvisco/amigo v0.0.2-alpha 7 | github.com/jackc/pgx/v5 v5.6.0 8 | ) 9 | 10 | require ( 11 | github.com/alecthomas/chroma/v2 v2.14.0 // indirect 12 | github.com/dlclark/regexp2 v1.11.0 // indirect 13 | github.com/gobuffalo/flect v1.0.2 // indirect 14 | github.com/jackc/pgpassfile v1.0.0 // indirect 15 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 16 | github.com/jackc/puddle/v2 v2.2.1 // indirect 17 | github.com/simukti/sqldb-logger v0.0.0-20230108155151-646c1a075551 // indirect 18 | golang.org/x/crypto v0.17.0 // indirect 19 | golang.org/x/sync v0.7.0 // indirect 20 | golang.org/x/text v0.14.0 // indirect 21 | ) 22 | 23 | replace github.com/alexisvisco/amigo => ../../ 24 | -------------------------------------------------------------------------------- /example/pg/migrations/20240530063940_create_user_table.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/alexisvisco/amigo/pkg/schema/pg" 5 | "time" 6 | ) 7 | 8 | type Migration20240530063940CreateUserTable struct{} 9 | 10 | func (m Migration20240530063940CreateUserTable) Change(s *pg.Schema) { 11 | s.CreateTable("users", func(def *pg.PostgresTableDef) { 12 | def.Column("id", "bigserial") 13 | def.String("name") 14 | def.String("email") 15 | def.Timestamps() 16 | def.Index([]string{"name"}) 17 | }) 18 | } 19 | 20 | func (m Migration20240530063940CreateUserTable) Name() string { 21 | return "create_user_table" 22 | } 23 | 24 | func (m Migration20240530063940CreateUserTable) Date() time.Time { 25 | t, _ := time.Parse(time.RFC3339, "2024-05-24T11:04:34+02:00") 26 | return t 27 | } 28 | -------------------------------------------------------------------------------- /testdata/e2e/pg/migrations_with_classic/20240518071938_custom_seed.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/alexisvisco/amigo/pkg/schema/pg" 5 | "time" 6 | ) 7 | 8 | type Migration20240518071938CustomSeed struct{} 9 | 10 | func (m Migration20240518071938CustomSeed) Up(s *pg.Schema) { 11 | s.Exec("INSERT INTO migrations_with_classic.users (name, email) VALUES ('alexis', 'alexs')") 12 | } 13 | 14 | func (m Migration20240518071938CustomSeed) Down(s *pg.Schema) { 15 | s.Exec("DELETE FROM migrations_with_classic.users WHERE name = 'alexis'") 16 | } 17 | 18 | func (m Migration20240518071938CustomSeed) Name() string { 19 | return "custom_seed" 20 | } 21 | 22 | func (m Migration20240518071938CustomSeed) Date() time.Time { 23 | t, _ := time.Parse(time.RFC3339, "2024-05-18T09:19:38+02:00") 24 | return t 25 | } 26 | -------------------------------------------------------------------------------- /docs/docs/03-cli/05-rollback.md: -------------------------------------------------------------------------------- 1 | # Rollback 2 | 3 | The `rollback` command allows you to rollback the last migration. 4 | 5 | To rollback the last migration, run the following command: 6 | 7 | ```sh 8 | amigo rollback 9 | 10 | ------> rollback: create_user_table version: 20240524110434 11 | -- drop_table(table: users) 12 | ------> version rolled back: 20240524090434 13 | ``` 14 | 15 | ## Flags 16 | - `--dry-run` will run the migrations without applying them. 17 | - `--timeout` is the timeout for the migration (default is 2m0s). 18 | - `--version` will rollback a specific version. The format is `20240502083700` or `20240502083700_name.go`. 19 | - `--steps` will rollback the last `n` migrations. (default is 1) 20 | - `--continue-on-error` will not rollback the migration if an error occurs. 21 | - `-d` dump the schema after migrating -------------------------------------------------------------------------------- /example/pg/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "example/pg/migrations" 6 | "github.com/alexisvisco/amigo/pkg/amigo" 7 | "github.com/alexisvisco/amigo/pkg/amigoconfig" 8 | "github.com/alexisvisco/amigo/pkg/types" 9 | _ "github.com/jackc/pgx/v5/stdlib" 10 | "os" 11 | ) 12 | 13 | // this is an example to run migration in a codebase 14 | func main() { 15 | dsn := "postgres://postgres:postgres@localhost:6666/postgres" 16 | db, err := sql.Open("pgx", dsn) 17 | if err != nil { 18 | panic(err) 19 | } 20 | 21 | err = amigo.NewAmigo(amigoconfig.NewContext().WithDSN(dsn)).RunMigrations(amigo.RunMigrationParams{ 22 | DB: db, 23 | Direction: types.MigrationDirectionDown, 24 | Migrations: migrations.Migrations, 25 | LogOutput: os.Stdout, 26 | }) 27 | if err != nil { 28 | panic(err) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /testdata/e2e/pg/migrations_with_change/20240517080505_schema_version.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/alexisvisco/amigo/pkg/schema" 7 | "github.com/alexisvisco/amigo/pkg/schema/pg" 8 | ) 9 | 10 | type Migration20240517080505SchemaVersion struct{} 11 | 12 | func (m Migration20240517080505SchemaVersion) Change(s *pg.Schema) { 13 | s.CreateTable("migrations_with_change.mig_schema_versions", func(s *pg.PostgresTableDef) { 14 | s.String("version", schema.ColumnOptions{PrimaryKey: true}) 15 | }, schema.TableOptions{IfNotExists: true}) 16 | } 17 | 18 | func (m Migration20240517080505SchemaVersion) Name() string { 19 | return "schema_version" 20 | } 21 | 22 | func (m Migration20240517080505SchemaVersion) Date() time.Time { 23 | t, _ := time.Parse(time.RFC3339, "2024-05-17T10:05:05+02:00") 24 | return t 25 | } 26 | -------------------------------------------------------------------------------- /pkg/amigo/setup_slog.go: -------------------------------------------------------------------------------- 1 | package amigo 2 | 3 | import ( 4 | "io" 5 | "log/slog" 6 | 7 | "github.com/alexisvisco/amigo/pkg/utils/logger" 8 | ) 9 | 10 | func (a Amigo) SetupSlog(writer io.Writer, mayLogger *slog.Logger) { 11 | logger.ShowSQLEvents = a.Config.ShowSQL 12 | if writer == nil && mayLogger == nil { 13 | logger.Logger = slog.New(slog.NewJSONHandler(writer, &slog.HandlerOptions{Level: slog.LevelError})) 14 | return 15 | } 16 | 17 | if mayLogger != nil { 18 | logger.Logger = mayLogger 19 | return 20 | } 21 | 22 | level := slog.LevelInfo 23 | if a.Config.Debug { 24 | level = slog.LevelDebug 25 | } 26 | 27 | if a.Config.JSON { 28 | logger.Logger = slog.New(slog.NewJSONHandler(writer, &slog.HandlerOptions{Level: level})) 29 | } else { 30 | logger.Logger = slog.New(logger.NewHandler(writer, &logger.Options{Level: level})) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /testdata/e2e/pg/migrations_with_classic/20240527192355_enum.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/alexisvisco/amigo/pkg/schema" 5 | "github.com/alexisvisco/amigo/pkg/schema/pg" 6 | "time" 7 | ) 8 | 9 | type Migration20240527192355Enum struct{} 10 | 11 | func (m Migration20240527192355Enum) Up(s *pg.Schema) { 12 | s.CreateEnum("status", []string{"active", "inactive"}, schema.CreateEnumOptions{ 13 | Schema: "migrations_with_classic", 14 | }) 15 | } 16 | 17 | func (m Migration20240527192355Enum) Down(s *pg.Schema) { 18 | s.DropEnum("status", schema.DropEnumOptions{Schema: "migrations_with_classic"}) 19 | } 20 | 21 | func (m Migration20240527192355Enum) Name() string { 22 | return "enum" 23 | } 24 | 25 | func (m Migration20240527192355Enum) Date() time.Time { 26 | t, _ := time.Parse(time.RFC3339, "2024-05-27T21:23:55+02:00") 27 | return t 28 | } 29 | -------------------------------------------------------------------------------- /docs/docs/03-cli/04-migrate.md: -------------------------------------------------------------------------------- 1 | # Migrate 2 | 3 | The `migrate` command allows you to apply the migrations. 4 | 5 | To apply the migrations, run the following command: 6 | 7 | ```sh 8 | amigo migrate 9 | 10 | ------> migrating: create_user_table version: 20240524110434 11 | -- create_table(table: users, {columns: id, name, email, created_at, updated_at}, {pk: id}) 12 | -- add_index(table: users, name: idx_users_name, columns: [name]) 13 | ------> version migrated: 20240524090434 14 | ``` 15 | 16 | ## Flags 17 | - `--dry-run` will run the migrations without applying them. 18 | - `--timeout` is the timeout for the migration (default is 2m0s). 19 | - `--version` will apply a specific version. The format is `20240502083700` or `20240502083700_name.go`. 20 | - `--continue-on-error` will not rollback the migration if an error occurs. 21 | - `-d` dump the schema after migrating 22 | 23 | -------------------------------------------------------------------------------- /pkg/templates/types.go: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import "github.com/alexisvisco/amigo/pkg/types" 4 | 5 | type ( 6 | MigrationsData struct { 7 | Package string 8 | Migrations []string 9 | ImportSchemaPackage *string 10 | } 11 | 12 | MigrationData struct { 13 | IsSQL bool 14 | Package string 15 | StructName string 16 | Name string 17 | 18 | Type types.MigrationFileType 19 | 20 | Imports []string 21 | 22 | InChange string 23 | InUp string 24 | InDown string 25 | 26 | CreatedAt string // RFC3339 27 | 28 | PackageDriverName string 29 | PackageDriverPath string 30 | 31 | UseSchemaImport bool 32 | UseFmtImport bool 33 | } 34 | 35 | CreateTableData struct { 36 | Name string 37 | } 38 | 39 | MainData struct { 40 | PackagePath string 41 | DriverPath string 42 | DriverName string 43 | } 44 | ) 45 | -------------------------------------------------------------------------------- /testdata/e2e/pg/migrations_with_change/20240518071938_custom_seed.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/alexisvisco/amigo/pkg/schema" 5 | "github.com/alexisvisco/amigo/pkg/schema/pg" 6 | "time" 7 | ) 8 | 9 | type Migration20240518071938CustomSeed struct{} 10 | 11 | func (m Migration20240518071938CustomSeed) Change(s *pg.Schema) { 12 | s.Reversible(schema.Directions{ 13 | Up: func() { 14 | s.Exec("INSERT INTO migrations_with_change.users (name, email) VALUES ('alexis', 'alexs')") 15 | }, 16 | Down: func() { 17 | s.Exec("DELETE FROM migrations_with_change.users WHERE id = '1'") 18 | }, 19 | }) 20 | } 21 | 22 | func (m Migration20240518071938CustomSeed) Name() string { 23 | return "custom_seed" 24 | } 25 | 26 | func (m Migration20240518071938CustomSeed) Date() time.Time { 27 | t, _ := time.Parse(time.RFC3339, "2024-05-18T09:19:38+02:00") 28 | return t 29 | } 30 | -------------------------------------------------------------------------------- /example/sqlite/migrations/20240602080728_create_table_schema_version.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/alexisvisco/amigo/pkg/schema/sqlite" 5 | "time" 6 | ) 7 | 8 | type Migration20240602080728CreateTableSchemaVersion struct{} 9 | 10 | func (m Migration20240602080728CreateTableSchemaVersion) Up(s *sqlite.Schema) { 11 | s.Exec("CREATE TABLE IF NOT EXISTS mig_schema_versions ( id VARCHAR(255) NOT NULL PRIMARY KEY )") 12 | } 13 | 14 | func (m Migration20240602080728CreateTableSchemaVersion) Down(s *sqlite.Schema) { 15 | // nothing to do to keep the schema version table 16 | } 17 | 18 | func (m Migration20240602080728CreateTableSchemaVersion) Name() string { 19 | return "create_table_schema_version" 20 | } 21 | 22 | func (m Migration20240602080728CreateTableSchemaVersion) Date() time.Time { 23 | t, _ := time.Parse(time.RFC3339, "2024-06-02T10:07:28+02:00") 24 | return t 25 | } 26 | -------------------------------------------------------------------------------- /pkg/schema/table_name_test.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "github.com/stretchr/testify/require" 5 | "testing" 6 | ) 7 | 8 | func TestTableName_Name(t *testing.T) { 9 | tts := []struct { 10 | name string 11 | expectedSchema string 12 | expectedTable string 13 | }{ 14 | { 15 | name: "public.table", 16 | expectedSchema: "public", 17 | expectedTable: "table", 18 | }, 19 | { 20 | name: "table", 21 | expectedSchema: "public", 22 | expectedTable: "table", 23 | }, 24 | { 25 | name: "schema.table", 26 | expectedSchema: "schema", 27 | expectedTable: "table", 28 | }, 29 | } 30 | 31 | for _, tt := range tts { 32 | t.Run(tt.name, func(t *testing.T) { 33 | tn := TableName(tt.name) 34 | require.Equal(t, tt.expectedSchema, tn.Schema()) 35 | require.Equal(t, tt.expectedTable, tn.Name()) 36 | }) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /testdata/e2e/pg/migrations_with_classic/20240518071842_add_index_user_email.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/alexisvisco/amigo/pkg/schema" 5 | "github.com/alexisvisco/amigo/pkg/schema/pg" 6 | "time" 7 | ) 8 | 9 | type Migration20240518071842AddIndexUserEmail struct{} 10 | 11 | func (m Migration20240518071842AddIndexUserEmail) Up(s *pg.Schema) { 12 | s.AddIndex("migrations_with_classic.users", []string{"email"}, schema.IndexOptions{Unique: true}) 13 | } 14 | 15 | func (m Migration20240518071842AddIndexUserEmail) Down(s *pg.Schema) { 16 | s.DropIndex("migrations_with_classic.users", []string{"email"}) 17 | } 18 | 19 | func (m Migration20240518071842AddIndexUserEmail) Name() string { 20 | return "add_index_user_email" 21 | } 22 | 23 | func (m Migration20240518071842AddIndexUserEmail) Date() time.Time { 24 | t, _ := time.Parse(time.RFC3339, "2024-05-18T09:18:42+02:00") 25 | return t 26 | } 27 | -------------------------------------------------------------------------------- /docs/docs/02-quick-start/06-working-with-existing-schema.md: -------------------------------------------------------------------------------- 1 | # Working with existing schema 2 | 3 | If you have an existing schema and you want to use mig to manage it, you can do it by following these steps: 4 | 5 | ``` 6 | mig create origin_schema --dump --skip 7 | ``` 8 | 9 | This command will create a new migration file `_origin_schema.go` in the `migrations` folder. 10 | 11 | This flag will dump the schema of your database with `pg_dump` (make sure you have it). 12 | 13 | The `--skip` flag will insert the current version of the schema into the `mig_schema_versions` table without running the migration (because the schema already exists). 14 | 15 | You can specify some flags: 16 | - `--dump-schema` to specify the schema to dump. (default is `public`) 17 | - `--pg-dump-path` to specify the path to the `pg_dump` command. 18 | - `--shell-path` to specify the path to the shell command. (default is `/bin/bash`) 19 | -------------------------------------------------------------------------------- /pkg/templates/migration.go.tmpl: -------------------------------------------------------------------------------- 1 | {{/* gotype: github.com/alexisvisco/amigo/pkg/templates.MigrationData */ -}} 2 | package {{ .Package }} 3 | 4 | import ( 5 | {{- range .Imports }} 6 | "{{ . }}" 7 | {{- end }} 8 | ) 9 | 10 | type {{ .StructName }} struct {} 11 | {{ if eq .Type "change" }} 12 | func (m {{ .StructName }}) Change(s *{{ .PackageDriverName }}.Schema) { 13 | {{ indent 1 .InChange }} 14 | } 15 | {{ end -}} 16 | {{ if eq .Type "classic" }} 17 | func (m {{ .StructName }}) Up(s *{{ .PackageDriverName }}.Schema) { 18 | {{ indent 1 .InUp }} 19 | } 20 | 21 | func (m {{ .StructName }}) Down(s *{{ .PackageDriverName }}.Schema) { 22 | {{ indent 1 .InDown }} 23 | } 24 | {{ end }} 25 | func (m {{ .StructName }}) Name() string { 26 | return "{{ .Name }}" 27 | } 28 | 29 | func (m {{ .StructName }}) Date() time.Time { 30 | t, _ := time.Parse(time.RFC3339, "{{ .CreatedAt }}") 31 | return t 32 | } 33 | -------------------------------------------------------------------------------- /testdata/e2e/pg/migrations_with_classic/20240518071740_create_user.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/alexisvisco/amigo/pkg/schema/pg" 5 | "time" 6 | ) 7 | 8 | type Migration20240518071740CreateUser struct{} 9 | 10 | func (m Migration20240518071740CreateUser) Up(s *pg.Schema) { 11 | s.CreateTable("migrations_with_classic.users", func(def *pg.PostgresTableDef) { 12 | def.Serial("id") 13 | def.String("name") 14 | def.String("email") 15 | def.Timestamps() 16 | def.Index([]string{"name"}) 17 | }) 18 | } 19 | 20 | func (m Migration20240518071740CreateUser) Down(s *pg.Schema) { 21 | s.DropTable("migrations_with_classic.users") 22 | } 23 | 24 | func (m Migration20240518071740CreateUser) Name() string { 25 | return "create_user" 26 | } 27 | 28 | func (m Migration20240518071740CreateUser) Date() time.Time { 29 | t, _ := time.Parse(time.RFC3339, "2024-05-18T09:17:40+02:00") 30 | return t 31 | } 32 | -------------------------------------------------------------------------------- /docs/sidebars.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creating a sidebar enables you to: 3 | - create an ordered group of docs 4 | - render a sidebar for each doc of that group 5 | - provide next/previous navigation 6 | 7 | The sidebars can be generated from the filesystem, or explicitly defined here. 8 | 9 | Create as many sidebars as you want. 10 | */ 11 | 12 | // @ts-check 13 | 14 | /** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ 15 | const sidebars = { 16 | // By default, Docusaurus generates a sidebar from the docs folder structure 17 | tutorialSidebar: [{type: 'autogenerated', dirName: '.'}], 18 | 19 | // But you can create a sidebar manually 20 | /* 21 | tutorialSidebar: [ 22 | 'intro', 23 | 'hello', 24 | { 25 | type: 'category', 26 | label: 'Tutorial', 27 | items: ['tutorial-basics/create-a-document'], 28 | }, 29 | ], 30 | */ 31 | }; 32 | 33 | module.exports = sidebars; 34 | -------------------------------------------------------------------------------- /docs/docs/03-cli/01-init.md: -------------------------------------------------------------------------------- 1 | # Init 2 | 3 | The `init` command allow you to boilerplate some files: 4 | - A `migrations` folder where you will write your migrations. 5 | - A `.mig` folder where mig stores its configuration and the main file to run migrations. 6 | - A migration file to setup the table that will store the migration versions. 7 | 8 | ## Flags 9 | - `--mig-folder` is the folder where mig stores its configuration and the main file to run migrations. Default is `.mig`. 10 | - `--package` is the package name of the migrations. Default is `migrations`. 11 | - `--folder` is the folder where you will write your migrations. Default is `migrations`. 12 | - `--schema-version-table` is the table that will store the migration versions. Default is `public.mig_schema_versions`. 13 | 14 | ## Note 15 | 16 | When you set a flags with the `mig init` command, it can be useful to add them in the `context` command to avoid passing them each time you run a command. 17 | -------------------------------------------------------------------------------- /example/pg/migrations/20240530063939_create_table_schema_version.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/alexisvisco/amigo/pkg/schema" 5 | "github.com/alexisvisco/amigo/pkg/schema/pg" 6 | "time" 7 | ) 8 | 9 | type Migration20240530063939CreateTableSchemaVersion struct{} 10 | 11 | func (m Migration20240530063939CreateTableSchemaVersion) Up(s *pg.Schema) { 12 | s.CreateTable("public.mig_schema_versions", func(s *pg.PostgresTableDef) { 13 | s.String("id") 14 | }, schema.TableOptions{IfNotExists: true}) 15 | } 16 | 17 | func (m Migration20240530063939CreateTableSchemaVersion) Down(s *pg.Schema) { 18 | // nothing to do to keep the schema version table 19 | } 20 | 21 | func (m Migration20240530063939CreateTableSchemaVersion) Name() string { 22 | return "create_table_schema_version" 23 | } 24 | 25 | func (m Migration20240530063939CreateTableSchemaVersion) Date() time.Time { 26 | t, _ := time.Parse(time.RFC3339, "2024-05-30T08:39:39+02:00") 27 | return t 28 | } 29 | -------------------------------------------------------------------------------- /pkg/amigo/gen_main_file.go: -------------------------------------------------------------------------------- 1 | package amigo 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "path" 7 | 8 | "github.com/alexisvisco/amigo/pkg/templates" 9 | "github.com/alexisvisco/amigo/pkg/utils" 10 | ) 11 | 12 | func (a Amigo) GenerateMainFile(writer io.Writer) error { 13 | var ( 14 | migrationFolder = a.Config.MigrationFolder 15 | ) 16 | 17 | name, err := utils.GetModuleName() 18 | if err != nil { 19 | return fmt.Errorf("unable to get module name: %w", err) 20 | } 21 | 22 | packagePath := path.Join(name, migrationFolder) 23 | 24 | template, err := templates.GetMainTemplate(templates.MainData{ 25 | PackagePath: packagePath, 26 | DriverPath: a.Driver.PackagePath(), 27 | DriverName: a.Driver.String(), 28 | }) 29 | if err != nil { 30 | return fmt.Errorf("unable to get main template: %w", err) 31 | } 32 | 33 | _, err = writer.Write([]byte(template)) 34 | if err != nil { 35 | return fmt.Errorf("unable to write main file: %w", err) 36 | } 37 | 38 | return nil 39 | } 40 | -------------------------------------------------------------------------------- /testdata/e2e/pg/migrations_with_classic/20240517080505_schema_version.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/alexisvisco/amigo/pkg/schema" 7 | "github.com/alexisvisco/amigo/pkg/schema/pg" 8 | ) 9 | 10 | type Migration20240517080505SchemaVersion struct{} 11 | 12 | func (m Migration20240517080505SchemaVersion) Up(s *pg.Schema) { 13 | s.CreateTable("migrations_with_classic.mig_schema_versions", func(s *pg.PostgresTableDef) { 14 | s.String("version", schema.ColumnOptions{PrimaryKey: true}) 15 | }, schema.TableOptions{IfNotExists: true}) 16 | } 17 | 18 | func (m Migration20240517080505SchemaVersion) Down(s *pg.Schema) { 19 | s.DropTable("migrations_with_classic.mig_schema_versions") 20 | } 21 | 22 | func (m Migration20240517080505SchemaVersion) Name() string { 23 | return "schema_version" 24 | } 25 | 26 | func (m Migration20240517080505SchemaVersion) Date() time.Time { 27 | t, _ := time.Parse(time.RFC3339, "2024-05-17T10:05:05+02:00") 28 | return t 29 | } 30 | -------------------------------------------------------------------------------- /example/mysql/migrations/20240529125357_create_table_schema_version.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "fmt" 5 | "github.com/alexisvisco/amigo/pkg/schema/base" 6 | "time" 7 | ) 8 | 9 | type Migration20240529125357CreateTableSchemaVersion struct{} 10 | 11 | func (m Migration20240529125357CreateTableSchemaVersion) Up(s *base.Schema) { 12 | query := `CREATE TABLE IF NOT EXISTS mig_schema_versions ( id VARCHAR(255) NOT NULL PRIMARY KEY )` 13 | _, err := s.TX.ExecContext(s.Context.Context, query) 14 | if err != nil { 15 | s.Context.RaiseError(fmt.Errorf("unable to create table schema version: %w", err)) 16 | } 17 | } 18 | 19 | func (m Migration20240529125357CreateTableSchemaVersion) Down(s *base.Schema) { 20 | // nothing to do to keep the schema version table 21 | } 22 | 23 | func (m Migration20240529125357CreateTableSchemaVersion) Name() string { 24 | return "create_table_schema_version" 25 | } 26 | 27 | func (m Migration20240529125357CreateTableSchemaVersion) Date() time.Time { 28 | t, _ := time.Parse(time.RFC3339, "2024-05-29T14:53:57+02:00") 29 | return t 30 | } 31 | -------------------------------------------------------------------------------- /pkg/schema/pg/exists_test.go: -------------------------------------------------------------------------------- 1 | package pg 2 | 3 | import ( 4 | "github.com/alexisvisco/amigo/pkg/schema" 5 | "github.com/alexisvisco/amigo/pkg/utils" 6 | "testing" 7 | ) 8 | 9 | func TestPostgresSchema_ConstraintExist(t *testing.T) { 10 | t.Parallel() 11 | 12 | sc := "tst_pg_constraint_exist" 13 | 14 | query := `CREATE TABLE IF NOT EXISTS {schema}.{table_name} ( 15 | id serial PRIMARY KEY, 16 | name text 17 | constraint {constraint_name} CHECK (name <> '') 18 | );` 19 | replacer := utils.Replacer{ 20 | "schema": utils.StrFunc(sc), 21 | "table_name": utils.StrFunc("test_table"), 22 | "constraint_name": utils.StrFunc("test_constraint"), 23 | } 24 | 25 | p, _, sc := baseTest(t, replacer.Replace(query), sc) 26 | 27 | t.Run("must have a constraint", func(t *testing.T) { 28 | assertConstraintExist(t, p, schema.Table("test_table", sc), "test_constraint") 29 | }) 30 | 31 | t.Run("must not have a constraint", func(t *testing.T) { 32 | assertConstraintNotExist(t, p, schema.Table("test_table", sc), "test_constraint_not_exists") 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /docs/docs/04-api/02-sqlite/02-destructive.md: -------------------------------------------------------------------------------- 1 | # Destructive operations 2 | 3 | They are the operations that drop tables, columns, indexes, constraints, and so on. 4 | 5 | - [DropIndex(table schema.TableName, columns []string, opts ...schema.DropIndexOptions)](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/sqlite#Schema.DropIndex) 6 | 7 | Usually you will use these functions in the `down` function of a migration, but you can use them in the `up` function too. 8 | If you want to have the reverse operation of a destructive operation, you can use the `reversible` options. 9 | 10 | Example: 11 | 12 | ```go 13 | p.DropTable("users", schema.DropTableOptions{ 14 | Reversible: &TableOption{ 15 | schema.TableName: "users", 16 | PostgresTableDefinition: Innerschema.Tablefunc(t *PostgresTableDef) { 17 | t.Serial("id") 18 | t.String("name") 19 | }), 20 | }}) 21 | ``` 22 | 23 | In that case, if you are in a `change` function in your migration, the library will at the up operation drop the table `users` and at the down operation re-create the table `users` with the columns `id` and `name`. 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Alexis Viscogliosi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pkg/utils/text_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "github.com/stretchr/testify/require" 5 | "testing" 6 | ) 7 | 8 | func TestRemoveConsecutiveSpace(t *testing.T) { 9 | testCases := []struct { 10 | input, expectedOutput string 11 | err require.ErrorAssertionFunc 12 | }{ 13 | { 14 | input: `SELECT * FROM "table" WHERE "column" = 'value '`, 15 | expectedOutput: `SELECT * FROM "table" WHERE "column" = 'value '`, 16 | }, 17 | { 18 | input: `Hello "world "`, 19 | expectedOutput: `Hello "world "`, 20 | }, 21 | { 22 | input: `a b c`, 23 | expectedOutput: `a b c`, 24 | }, 25 | { 26 | input: `"a b" "c d"`, 27 | expectedOutput: `"a b" "c d"`, 28 | }, 29 | { 30 | input: `abc 'a`, 31 | err: require.Error, 32 | }, 33 | } 34 | 35 | for _, testCase := range testCases { 36 | result, err := RemoveConsecutiveSpaces(testCase.input) 37 | 38 | if testCase.err != nil { 39 | testCase.err(t, err) 40 | } else { 41 | require.NoError(t, err) 42 | require.Equal(t, testCase.expectedOutput, result) 43 | } 44 | 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /pkg/utils/mig.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | "time" 9 | 10 | "github.com/gobuffalo/flect" 11 | ) 12 | 13 | func ParseMigrationVersion(f string) (string, error) { 14 | if TimeRegexp.MatchString(f) { 15 | return f, nil 16 | } 17 | 18 | if MigrationFileRegexp.MatchString(f) { 19 | // get the prefix and remove underscore 20 | return strings.ReplaceAll(MigrationFileRegexp.FindStringSubmatch(f)[1], "_", ""), nil 21 | } 22 | 23 | return "", errors.New("invalid version format, should be of form: 20060102150405_migration_name.{go,sql}, 20060102150405") 24 | } 25 | 26 | var MigrationFileRegexp = regexp.MustCompile(`(\d{14})_(.*)\.(go|sql)`) 27 | var TimeRegexp = regexp.MustCompile(`\d{14}`) 28 | 29 | const FormatTime = "20060102150405" 30 | 31 | func MigrationStructName(t time.Time, name string) string { 32 | return fmt.Sprintf("Migration%s%s", t.UTC().Truncate(time.Second).Format(FormatTime), flect.Pascalize(name)) 33 | } 34 | 35 | func MigrationFileFormat(t time.Time, name string) string { 36 | return fmt.Sprintf("%s_%s.go", t.UTC().Truncate(time.Second).Format(FormatTime), flect.Underscore(name)) 37 | } 38 | -------------------------------------------------------------------------------- /pkg/utils/orderedmap/orderedmap.go: -------------------------------------------------------------------------------- 1 | package orderedmap 2 | 3 | import "encoding/json" 4 | 5 | type OrderedMap[T any] struct { 6 | container map[string]T 7 | keys []string 8 | } 9 | 10 | func (om *OrderedMap[T]) MarshalJSON() ([]byte, error) { 11 | return json.Marshal(om.container) 12 | } 13 | 14 | func New[T any]() *OrderedMap[T] { 15 | return &OrderedMap[T]{ 16 | container: make(map[string]T), 17 | keys: make([]string, 0), 18 | } 19 | } 20 | 21 | func (om *OrderedMap[T]) Set(key string, value T) { 22 | if _, ok := om.container[key]; !ok { 23 | om.keys = append(om.keys, key) 24 | } 25 | 26 | om.container[key] = value 27 | } 28 | 29 | func (om *OrderedMap[T]) Get(key string) (T, bool) { 30 | value, ok := om.container[key] 31 | return value, ok 32 | } 33 | 34 | func (om *OrderedMap[T]) Iterate() <-chan struct { 35 | Key string 36 | Value T 37 | } { 38 | ch := make(chan struct { 39 | Key string 40 | Value T 41 | }) 42 | 43 | go func() { 44 | for _, key := range om.keys { 45 | ch <- struct { 46 | Key string 47 | Value T 48 | }{ 49 | Key: key, 50 | Value: om.container[key], 51 | } 52 | } 53 | 54 | close(ch) 55 | }() 56 | 57 | return ch 58 | } 59 | -------------------------------------------------------------------------------- /pkg/schema/db.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "net/url" 7 | "strings" 8 | ) 9 | 10 | // DB is the interface that describes a database connection. 11 | type DB interface { 12 | ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) 13 | QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) 14 | QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row 15 | } 16 | 17 | type DBTX interface { 18 | DB 19 | BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) 20 | } 21 | 22 | // DatabaseCredentials is the struct that holds the database credentials. 23 | type DatabaseCredentials struct { 24 | Host, Port, User, Pass, DB string 25 | } 26 | 27 | // ExtractCredentials extracts the database credentials from the DSN. 28 | func ExtractCredentials(dsn string) (DatabaseCredentials, error) { 29 | u, err := url.Parse(dsn) 30 | if err != nil { 31 | return DatabaseCredentials{}, err 32 | } 33 | 34 | pass, _ := u.User.Password() 35 | 36 | return DatabaseCredentials{ 37 | Host: u.Hostname(), 38 | Port: u.Port(), 39 | User: u.User.Username(), 40 | Pass: pass, 41 | DB: strings.TrimLeft(u.Path, "/"), 42 | }, nil 43 | } 44 | -------------------------------------------------------------------------------- /pkg/schema/reversible.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import "github.com/alexisvisco/amigo/pkg/types" 4 | 5 | // ReversibleMigrationExecutor is a helper to execute reversible migrations in a change method. 6 | // Since you may have custom code in it, you must provide a way to up and down the user defined code. 7 | type ReversibleMigrationExecutor struct { 8 | migratorContext *MigratorContext 9 | } 10 | 11 | func NewReversibleMigrationExecutor(ctx *MigratorContext) *ReversibleMigrationExecutor { 12 | return &ReversibleMigrationExecutor{migratorContext: ctx} 13 | } 14 | 15 | type Directions struct { 16 | Up func() 17 | Down func() 18 | } 19 | 20 | func (r *ReversibleMigrationExecutor) Reversible(directions Directions) { 21 | currentMigrationDirection := r.migratorContext.MigrationDirection 22 | r.migratorContext.MigrationDirection = types.MigrationDirectionNotReversible 23 | defer func() { 24 | r.migratorContext.MigrationDirection = currentMigrationDirection 25 | }() 26 | 27 | switch currentMigrationDirection { 28 | case types.MigrationDirectionUp: 29 | if directions.Up != nil { 30 | directions.Up() 31 | } 32 | case types.MigrationDirectionDown: 33 | if directions.Down != nil { 34 | directions.Down() 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /testdata/Test_2e2_postgres/migration_with_change_migrations_with_change/20240517080505_schema_version.snap.sql: -------------------------------------------------------------------------------- 1 | SET statement_timeout = 0; 2 | SET lock_timeout = 0; 3 | SET idle_in_transaction_session_timeout = 0; 4 | SET client_encoding = 'UTF8'; 5 | SET standard_conforming_strings = on; 6 | SELECT pg_catalog.set_config('search_path', '', false); 7 | SET check_function_bodies = false; 8 | SET xmloption = content; 9 | SET client_min_messages = warning; 10 | SET row_security = off; 11 | 12 | -- 13 | -- Name: migrations_with_change; Type: SCHEMA; Schema: -; Owner: - 14 | -- 15 | 16 | CREATE SCHEMA migrations_with_change; 17 | 18 | 19 | SET default_table_access_method = heap; 20 | 21 | -- 22 | -- Name: mig_schema_versions; Type: TABLE; Schema: migrations_with_change; Owner: - 23 | -- 24 | 25 | CREATE TABLE migrations_with_change.mig_schema_versions ( 26 | version text NOT NULL 27 | ); 28 | 29 | 30 | -- 31 | -- Name: mig_schema_versions mig_schema_versions_pkey; Type: CONSTRAINT; Schema: migrations_with_change; Owner: - 32 | -- 33 | 34 | ALTER TABLE ONLY migrations_with_change.mig_schema_versions 35 | ADD CONSTRAINT mig_schema_versions_pkey PRIMARY KEY (version); 36 | 37 | 38 | -- 39 | -- PostgreSQL database dump complete 40 | -- 41 | 42 | -------------------------------------------------------------------------------- /testdata/Test_2e2_postgres/migration_with_classic_migrations_with_classic/20240517080505_schema_version.snap.sql: -------------------------------------------------------------------------------- 1 | SET statement_timeout = 0; 2 | SET lock_timeout = 0; 3 | SET idle_in_transaction_session_timeout = 0; 4 | SET client_encoding = 'UTF8'; 5 | SET standard_conforming_strings = on; 6 | SELECT pg_catalog.set_config('search_path', '', false); 7 | SET check_function_bodies = false; 8 | SET xmloption = content; 9 | SET client_min_messages = warning; 10 | SET row_security = off; 11 | 12 | -- 13 | -- Name: migrations_with_classic; Type: SCHEMA; Schema: -; Owner: - 14 | -- 15 | 16 | CREATE SCHEMA migrations_with_classic; 17 | 18 | 19 | SET default_table_access_method = heap; 20 | 21 | -- 22 | -- Name: mig_schema_versions; Type: TABLE; Schema: migrations_with_classic; Owner: - 23 | -- 24 | 25 | CREATE TABLE migrations_with_classic.mig_schema_versions ( 26 | version text NOT NULL 27 | ); 28 | 29 | 30 | -- 31 | -- Name: mig_schema_versions mig_schema_versions_pkey; Type: CONSTRAINT; Schema: migrations_with_classic; Owner: - 32 | -- 33 | 34 | ALTER TABLE ONLY migrations_with_classic.mig_schema_versions 35 | ADD CONSTRAINT mig_schema_versions_pkey PRIMARY KEY (version); 36 | 37 | 38 | -- 39 | -- PostgreSQL database dump complete 40 | -- 41 | 42 | -------------------------------------------------------------------------------- /docs/docs/04-api/01-postgres/04-transformative.md: -------------------------------------------------------------------------------- 1 | # Transformative operations 2 | 3 | They are the operations that change the data in the database. 4 | 5 | - [RenameColumn(tableName schema.TableName, oldColumnName, newColumnName string)](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.RenameColumn) 6 | 7 | - [RenameTable(tableName schema.TableName, newTableName string)](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.RenameTable) 8 | 9 | - [ChangeColumnType(tableName schema.TableName, columnName string, columnType schema.ColumnType, opts ...schema.ChangeColumnTypeOptions)](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.ChangeColumnType) 10 | 11 | - [ChangeColumnDefault(tableName schema.TableName, columnName string, defaultValue string, opts ...schema.ChangeColumnDefaultOptions)](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.ChangeColumnDefault) 12 | 13 | - [RenameEnum(oldName, newName string, opts ...schema.RenameEnumOptions)](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.RenameEnum) 14 | 15 | - [RenameEnumValue(name, oldName, newName string, opts ...schema.RenameEnumValueOptions)](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.RenameEnumValue) 16 | -------------------------------------------------------------------------------- /pkg/schema/table_name.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type TableName string 9 | 10 | // Schema returns the schema part of the table name. 11 | func (t TableName) Schema() string { 12 | index := strings.IndexByte(string(t), '.') 13 | if index != -1 { 14 | return string(t[:index]) 15 | } 16 | return "public" 17 | } 18 | 19 | // HasSchema returns if the table name has a schema. 20 | func (t TableName) HasSchema() bool { 21 | return strings.Contains(string(t), ".") 22 | } 23 | 24 | // Name returns the name part of the table name. 25 | func (t TableName) Name() string { 26 | index := strings.IndexByte(string(t), '.') 27 | if index != -1 { 28 | return string(t[index+1:]) 29 | } 30 | return string(t) 31 | } 32 | 33 | // String returns the string representation of the table name. 34 | func (t TableName) String() string { 35 | return string(t) 36 | } 37 | 38 | // Table returns a new TableName with the schema and table name. 39 | // But you can also use regular string, this function is when you have dynamic schema names. (like in the tests) 40 | func Table(name string, schema ...string) TableName { 41 | if len(schema) == 0 { 42 | return TableName(name) 43 | } 44 | return TableName(fmt.Sprintf("%s.%s", schema[0], name)) 45 | } 46 | -------------------------------------------------------------------------------- /docs/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Any CSS included here will be global. The classic template 3 | * bundles Infima by default. Infima is a CSS framework designed to 4 | * work well for content-centric websites. 5 | */ 6 | 7 | /* You can override the default Infima variables here. */ 8 | :root { 9 | --ifm-color-primary: #008391; 10 | --ifm-color-primary-dark: #007380; 11 | --ifm-color-primary-darker: #006570; 12 | --ifm-color-primary-darkest: #005761; 13 | --ifm-color-primary-light: #0093A3; 14 | --ifm-color-primary-lighter: #00A5B8; 15 | --ifm-color-primary-lightest: #00B3C7; 16 | --ifm-code-font-size: 95%; 17 | --ifm-footer-background-color: #003238; 18 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); 19 | } 20 | 21 | .footer--dark { 22 | --ifm-footer-background-color: #173E42; 23 | } 24 | /* For readability concerns, you should choose a lighter palette in dark mode. */ 25 | [data-theme='dark'] { 26 | --ifm-color-primary: #DBBC30; 27 | --ifm-color-primary-dark: #D0B125; 28 | --ifm-color-primary-darker: #BA9E21; 29 | --ifm-color-primary-darkest: #A0881C; 30 | --ifm-color-primary-light: #E7C946; 31 | --ifm-color-primary-lighter: #F1D65F; 32 | --ifm-color-primary-lightest: #F7E078; 33 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); 34 | } 35 | -------------------------------------------------------------------------------- /pkg/entrypoint/schema.go: -------------------------------------------------------------------------------- 1 | package entrypoint 2 | 3 | import ( 4 | "fmt" 5 | "path" 6 | 7 | "github.com/alexisvisco/amigo/pkg/amigo" 8 | "github.com/alexisvisco/amigo/pkg/utils" 9 | "github.com/alexisvisco/amigo/pkg/utils/events" 10 | "github.com/alexisvisco/amigo/pkg/utils/logger" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | var schemaCmd = &cobra.Command{ 15 | Use: "schema", 16 | Short: "Dump the schema of the database using appropriate tool", 17 | Long: `Dump the schema of the database using appropriate tool. 18 | Supported databases: 19 | - postgres with pg_dump`, 20 | Run: wrapCobraFunc(func(cmd *cobra.Command, am amigo.Amigo, args []string) error { 21 | if err := config.ValidateDSN(); err != nil { 22 | return err 23 | } 24 | 25 | return dumpSchema(am) 26 | }), 27 | } 28 | 29 | func dumpSchema(am amigo.Amigo) error { 30 | file, err := utils.CreateOrOpenFile(config.SchemaOutPath) 31 | if err != nil { 32 | return fmt.Errorf("unable to open/create file: %w", err) 33 | } 34 | 35 | defer file.Close() 36 | 37 | err = am.DumpSchema(file, false) 38 | if err != nil { 39 | return fmt.Errorf("unable to dump schema: %w", err) 40 | } 41 | 42 | logger.Info(events.FileModifiedEvent{FileName: path.Join(config.SchemaOutPath)}) 43 | return nil 44 | } 45 | 46 | func init() { 47 | rootCmd.AddCommand(schemaCmd) 48 | } 49 | -------------------------------------------------------------------------------- /docs/docs/02-quick-start/02-initialize.md: -------------------------------------------------------------------------------- 1 | # Initialize mig 2 | 3 | To start using mig, you need to initialize it. This process creates few things: 4 | - A `db/migrations` folder where you will write your migrations. 5 | - A `db` folder where mig stores its configuration and the main file to run migrations. 6 | - A migrations inside `db/migrations` file to setup the table that will store the migration versions. 7 | 8 | To initialize mig, run the following command: 9 | 10 | ```sh 11 | amigo init 12 | ``` 13 | 14 | You can also create a context to the repository to avoid passing flags each time you run a command. To do so, run the following command: 15 | 16 | 17 | ### Postgres: 18 | ```sh 19 | amigo context --dsn "postgres://user:password@localhost:5432/dbname" 20 | ``` 21 | 22 | ### SQLite: 23 | ```sh 24 | amigo context --dsn "sqlite:/path/to/db.sqlite" 25 | ``` 26 | 27 | ### Configuration 28 | 29 | A config.yml file will be created in the $amigo_folder folder. You can edit it to add more configurations. 30 | 31 | It contains the following fields: 32 | ```yaml 33 | dsn: postgres://user:password@localhost:5432/dbname 34 | folder: db/migrations 35 | json: false 36 | package: migrations 37 | pg-dump-path: pg_dump 38 | schema-version-table: public.mig_schema_versions 39 | shell-path: /bin/bash 40 | debug: false 41 | show-sql: false 42 | show-sql-highlighting: true 43 | ``` 44 | -------------------------------------------------------------------------------- /docs/docs/04-api/01-postgres/03-informative.md: -------------------------------------------------------------------------------- 1 | # Informative operations 2 | 3 | They are the operations that give you information about the database schema. 4 | 5 | 6 | - [TableExist(tableName schema.TableName) bool](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.TableExist) 7 | 8 | - [ColumnExist(tableName schema.TableName, columnName string) bool](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.ColumnExist) 9 | 10 | - [ConstraintExist(tableName schema.TableName, constraintName string) bool](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.ConstraintExist) 11 | 12 | - [IndexExist(tableName schema.TableName, indexName string) bool](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.IndexExist) 13 | 14 | - [PrimaryKeyExist(tableName schema.TableName) bool](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.PrimaryKeyExist) 15 | 16 | - [FindAppliedVersions() []string](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/base#Schema.FindAppliedVersions) 17 | 18 | - [FindEnumUsage(name string, schemaName *string) []schema.EnumUsage](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.FindEnumUsage) 19 | 20 | - [ListEnumValues(name string, schemaName *string) []string](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.ListEnumValues) 21 | 22 | These functions are not reversible. 23 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids" 15 | }, 16 | "dependencies": { 17 | "@docusaurus/core": "^3.3.0", 18 | "@docusaurus/preset-classic": "^3.3.0", 19 | "@docusaurus/theme-mermaid": "^3.3.0", 20 | "@mdx-js/react": "^3.0.1", 21 | "clsx": "2.0.0", 22 | "docusaurus-lunr-search": "^3.3.2", 23 | "prism-react-renderer": "^2.3.1", 24 | "react": "^18.2.0", 25 | "react-dom": "^18.2.0" 26 | }, 27 | "devDependencies": { 28 | "@docusaurus/module-type-aliases": "^3.3.0", 29 | "@docusaurus/tsconfig": "^3.3.0", 30 | "@types/react": "^18.2.58", 31 | "typescript": "~5.2.2" 32 | }, 33 | "browserslist": { 34 | "production": [ 35 | ">0.5%", 36 | "not dead", 37 | "not op_mini all" 38 | ], 39 | "development": [ 40 | "last 1 chrome version", 41 | "last 1 firefox version", 42 | "last 1 safari version" 43 | ] 44 | }, 45 | "engines": { 46 | "node": ">=18.2" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /pkg/schema/sql_migration_impl.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "bytes" 5 | "embed" 6 | "fmt" 7 | "time" 8 | ) 9 | 10 | type SQLMigration[T Schema] struct { 11 | fs embed.FS 12 | name string 13 | time string // RFC3339 14 | delimiter string 15 | } 16 | 17 | func NewSQLMigration[T Schema](fs embed.FS, name string, time string, delimiter string) DetailedMigration[T] { 18 | return &SQLMigration[T]{fs: fs, name: name, time: time, delimiter: delimiter} 19 | } 20 | 21 | func (s SQLMigration[T]) Up(x T) { 22 | up, _, err := s.parseContent() 23 | 24 | if err != nil { 25 | panic(err) 26 | } 27 | 28 | x.Exec(up) 29 | } 30 | 31 | func (s SQLMigration[T]) Down(x T) { 32 | _, down, err := s.parseContent() 33 | 34 | if err != nil { 35 | panic(err) 36 | } 37 | 38 | x.Exec(down) 39 | } 40 | 41 | func (s SQLMigration[T]) Name() string { 42 | return s.name 43 | } 44 | 45 | func (s SQLMigration[T]) Date() time.Time { 46 | t, _ := time.Parse(time.RFC3339, s.time) 47 | return t 48 | } 49 | 50 | func (s SQLMigration[T]) parseContent() (string, string, error) { 51 | file, err := s.fs.ReadFile(s.name) 52 | if err != nil { 53 | return "", "", fmt.Errorf("unable to read file %s: %w", s.name, err) 54 | } 55 | 56 | split := bytes.Split(file, []byte(s.delimiter)) 57 | if len(split) != 2 { 58 | return "", "", fmt.Errorf("invalid content, expected 2 parts, got %d", len(split)) 59 | } 60 | 61 | return string(split[0]), string(split[1]), nil 62 | } 63 | -------------------------------------------------------------------------------- /pkg/utils/files_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "github.com/stretchr/testify/require" 5 | "os" 6 | "testing" 7 | ) 8 | 9 | func TestCreateOrOpenFile(t *testing.T) { 10 | type args struct { 11 | } 12 | tests := []struct { 13 | name string 14 | path string 15 | prepare func() 16 | assert func(*os.File, error) 17 | }{ 18 | { 19 | name: "Test with fresh new file and directory not exists", 20 | path: os.TempDir() + "/abc/test.txt", 21 | assert: func(file *os.File, err error) { 22 | require.NoError(t, err) 23 | 24 | // check that path dir exists 25 | _, err = os.Stat(os.TempDir() + "/abc") 26 | require.NoError(t, err) 27 | }, 28 | }, 29 | { 30 | name: "Test with a file that exists", 31 | path: os.TempDir() + "/abc/test.txt", 32 | prepare: func() { 33 | f, _ := os.Create(os.TempDir() + "/efg/test.txt") 34 | f.WriteString("bonjour") 35 | }, 36 | assert: func(file *os.File, err error) { 37 | require.NoError(t, err) 38 | 39 | // check that path dir exists 40 | _, err = os.Stat(os.TempDir() + "/abc") 41 | require.NoError(t, err) 42 | 43 | // check that file is empty 44 | stat, _ := file.Stat() 45 | require.Equal(t, int64(0), stat.Size()) 46 | }, 47 | }, 48 | } 49 | for _, tt := range tests { 50 | t.Run(tt.name, func(t *testing.T) { 51 | if tt.prepare != nil { 52 | tt.prepare() 53 | } 54 | file, err := CreateOrOpenFile(tt.path) 55 | tt.assert(file, err) 56 | }) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /pkg/utils/gomod.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "strconv" 8 | ) 9 | 10 | func GetModuleName() (string, error) { 11 | goModBytes, err := os.ReadFile("go.mod") 12 | if err != nil { 13 | return "", fmt.Errorf("failed to read go.mod file: %w", err) 14 | } 15 | 16 | modName := modulePath(goModBytes) 17 | 18 | return modName, nil 19 | } 20 | 21 | var ( 22 | slashSlash = []byte("//") 23 | moduleStr = []byte("module") 24 | ) 25 | 26 | // modulePath returns the module path from the gomod file text. 27 | // If it cannot find a module path, it returns an empty string. 28 | // It is tolerant of unrelated problems in the go.mod file. 29 | func modulePath(mod []byte) string { 30 | for len(mod) > 0 { 31 | line := mod 32 | mod = nil 33 | if i := bytes.IndexByte(line, '\n'); i >= 0 { 34 | line, mod = line[:i], line[i+1:] 35 | } 36 | if i := bytes.Index(line, slashSlash); i >= 0 { 37 | line = line[:i] 38 | } 39 | line = bytes.TrimSpace(line) 40 | if !bytes.HasPrefix(line, moduleStr) { 41 | continue 42 | } 43 | line = line[len(moduleStr):] 44 | n := len(line) 45 | line = bytes.TrimSpace(line) 46 | if len(line) == n || len(line) == 0 { 47 | continue 48 | } 49 | 50 | if line[0] == '"' || line[0] == '`' { 51 | p, err := strconv.Unquote(string(line)) 52 | if err != nil { 53 | return "" // malformed quoted string or multiline module path 54 | } 55 | return p 56 | } 57 | 58 | return string(line) 59 | } 60 | return "" // missing module path 61 | } 62 | -------------------------------------------------------------------------------- /pkg/schema/sqlite/column_test.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "github.com/alexisvisco/amigo/pkg/schema" 5 | "github.com/alexisvisco/amigo/pkg/utils/testutils" 6 | "github.com/stretchr/testify/require" 7 | "testing" 8 | ) 9 | 10 | func TestSQLite_AddColumn(t *testing.T) { 11 | t.Parallel() 12 | 13 | testutils.EnableSnapshotForAll() 14 | 15 | base := ` 16 | CREATE TABLE IF NOT EXISTS articles 17 | ( 18 | name text 19 | );` 20 | 21 | t.Run("simple column", func(t *testing.T) { 22 | t.Parallel() 23 | p, r := baseTest(t, base) 24 | 25 | p.AddColumn("articles", "content", schema.ColumnTypeText) 26 | 27 | testutils.AssertSnapshotDiff(t, r.FormatRecords()) 28 | }) 29 | 30 | t.Run("with default value", func(t *testing.T) { 31 | t.Parallel() 32 | p, r := baseTest(t, base) 33 | 34 | p.AddColumn("articles", "content", schema.ColumnTypeText, schema.ColumnOptions{Default: "'default content'"}) 35 | 36 | testutils.AssertSnapshotDiff(t, r.FormatRecords()) 37 | }) 38 | 39 | t.Run("with varchar limit", func(t *testing.T) { 40 | t.Parallel() 41 | p, r := baseTest(t, base) 42 | 43 | p.AddColumn("articles", "content", "varchar", schema.ColumnOptions{Limit: 255}) 44 | 45 | testutils.AssertSnapshotDiff(t, r.FormatRecords()) 46 | }) 47 | 48 | t.Run("with primary key", func(t *testing.T) { 49 | t.Parallel() 50 | p, r := baseTest(t, base) 51 | 52 | require.Panics(t, func() { 53 | p.AddColumn("articles", "id", schema.ColumnTypePrimaryKey, schema.ColumnOptions{PrimaryKey: true}) 54 | }) 55 | testutils.AssertSnapshotDiff(t, r.FormatRecords()) 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /docs/docs/04-api/101-return-error.md: -------------------------------------------------------------------------------- 1 | # Return error 2 | 3 | As you can see in a migration file the functions Up, Down or Change cannot return an error. 4 | If you want to raise an error you can use the `RaiseError` function from the context. 5 | 6 | ```go 7 | package migrations 8 | 9 | import ( 10 | "github.com/alexisvisco/amigo/pkg/schema/pg" 11 | "...../repositories/userrepo" 12 | "time" 13 | ) 14 | 15 | type Migration20240517135429Droptable struct{} 16 | 17 | func (m Migration20240517135429Droptable) Change(s *pg.Schema) { 18 | s.CreateTable("test", func(def *pg.PostgresTableDef) { 19 | def.String("name") 20 | def.JSON("data") 21 | }) 22 | 23 | _, err := userrepo.New(s.DB).GetUser(5) 24 | if err != nil { 25 | s.Context.RaiseError(fmt.Errorf("error: %w", err)) 26 | } 27 | } 28 | 29 | func (m Migration20240517135429Droptable) Name() string { 30 | return "droptable" 31 | } 32 | 33 | func (m Migration20240517135429Droptable) Date() time.Time { 34 | t, _ := time.Parse(time.RFC3339, "2024-05-17T15:54:29+02:00") 35 | return t 36 | } 37 | 38 | ``` 39 | 40 | In this example, if the `GetUser` function returns an error, the migration will fail and the error will be displayed in the logs. 41 | 42 | 43 | The only way to not crash the migration when a RaiseError is called is to use the `--continue-on-error` flag. 44 | 45 | And the only way to crash when this flag is used is to use a `schema.ForceStopError` error. 46 | 47 | ```go 48 | s.Context.RaiseError(schema.NewForceStopError(errors.New("force stop"))) 49 | ``` 50 | 51 | This will crash the migration EVEN if the `--continue-on-error` flag is used. 52 | -------------------------------------------------------------------------------- /pkg/schema/base/index.go: -------------------------------------------------------------------------------- 1 | package base 2 | 3 | import ( 4 | "fmt" 5 | "github.com/alexisvisco/amigo/pkg/schema" 6 | "github.com/alexisvisco/amigo/pkg/types" 7 | "github.com/alexisvisco/amigo/pkg/utils" 8 | "github.com/alexisvisco/amigo/pkg/utils/events" 9 | "github.com/alexisvisco/amigo/pkg/utils/logger" 10 | ) 11 | 12 | // BaseDropIndex drops an index from the table. Generic method to drop an index across all databases. 13 | func (p *Schema) BaseDropIndex( 14 | options schema.DropIndexOptions, 15 | onRollback func(table schema.TableName, columns []string, opts schema.IndexOptions), 16 | ) { 17 | 18 | if p.Context.MigrationDirection == types.MigrationDirectionDown { 19 | if options.Reversible != nil { 20 | onRollback(options.Table, options.Columns, *options.Reversible) 21 | } else { 22 | logger.Warn(events.MessageEvent{ 23 | Message: fmt.Sprintf("unable re-creating index %s", options.IndexName), 24 | }) 25 | } 26 | return 27 | } 28 | 29 | sql := `DROP INDEX {if_exists} {index_name}` 30 | replacer := utils.Replacer{ 31 | "if_exists": func() string { 32 | if options.IfExists { 33 | return "IF EXISTS" 34 | } 35 | return "" 36 | }, 37 | 38 | "index_name": func() string { 39 | if options.Table.HasSchema() { 40 | return fmt.Sprintf(`%s.%s`, options.Table.Schema(), options.IndexName) 41 | } 42 | 43 | return options.IndexName 44 | }, 45 | } 46 | 47 | _, err := p.TX.ExecContext(p.Context.Context, replacer.Replace(sql)) 48 | if err != nil { 49 | p.Context.RaiseError(fmt.Errorf("error while dropping index: %w", err)) 50 | return 51 | } 52 | 53 | p.Context.AddIndexDropped(options) 54 | } 55 | -------------------------------------------------------------------------------- /pkg/utils/files.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/ioutil" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | ) 11 | 12 | func CreateOrOpenFile(path string) (*os.File, error) { 13 | if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { 14 | if !os.IsExist(err) { 15 | return nil, fmt.Errorf("unable to create parent directory: %w", err) 16 | } 17 | } 18 | 19 | // create or open file 20 | return os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644) 21 | } 22 | 23 | func IsFileExists(path string) bool { 24 | _, err := os.Stat(path) 25 | return !os.IsNotExist(err) 26 | } 27 | 28 | func IsDirExists(path string) bool { 29 | info, err := os.Stat(path) 30 | return err == nil && info.IsDir() 31 | } 32 | 33 | func GetFileContent(path string) ([]byte, error) { 34 | file, err := os.Open(path) 35 | if err != nil { 36 | return nil, err 37 | } 38 | defer file.Close() 39 | 40 | content, err := io.ReadAll(file) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | return content, nil 46 | } 47 | 48 | func EnsurePrentDirExists(path string) error { 49 | if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { 50 | if !os.IsExist(err) { 51 | return fmt.Errorf("unable to create parent directory: %w", err) 52 | } 53 | } 54 | 55 | return nil 56 | } 57 | 58 | func HasFilesWithExtension(folder string, ext ...string) bool { 59 | files, err := ioutil.ReadDir(folder) 60 | if err != nil { 61 | return false 62 | } 63 | for _, file := range files { 64 | for _, e := range ext { 65 | if strings.HasSuffix(file.Name(), e) { 66 | return true 67 | } 68 | } 69 | return false 70 | } 71 | return false 72 | } 73 | -------------------------------------------------------------------------------- /docs/docs/02-quick-start/03-running-your-first-migration.md: -------------------------------------------------------------------------------- 1 | # Running your first migration 2 | 3 | Since you have initialized mig, you can now run your first migration. 4 | 5 | Before that make sure to import the driver that amigo have added in the imports of the main file in `migrations/db/main.go`. 6 | 7 | Here is an example of amigo main: 8 | 9 | ```go 10 | package main 11 | 12 | import ( 13 | "database/sql" 14 | migrations "github.com/alexisvisco/gwt/migrations" 15 | "github.com/alexisvisco/amigo/pkg/entrypoint" 16 | "github.com/alexisvisco/amigo/pkg/utils/events" 17 | "github.com/alexisvisco/amigo/pkg/utils/logger" 18 | _ "github.com/jackc/pgx/v5/stdlib" // <- you can switch to any driver that support the database/sql package 19 | "os" 20 | ) 21 | 22 | func main() { 23 | opts, arg := entrypoint.AmigoContextFromFlags() 24 | 25 | db, err := sql.Open("pgx", opts.GetRealDSN()) // <- change this line too, the dsn is what you provided in the parameter or context configuration 26 | if err != nil { 27 | logger.Error(events.MessageEvent{Message: err.Error()}) 28 | os.Exit(1) 29 | } 30 | 31 | entrypoint.Main(db, arg, migrations.Migrations, opts) 32 | } 33 | ``` 34 | 35 | By default for postgres it imports `github.com/jackc/pgx/v5/stdlib` but you can change it and it will works. 36 | 37 | Amigo is driver agnostic and works with the `database/sql` package. 38 | `pgx` provide a support for the `database/sql` package and is a good choice for postgres, but you can use any driver that support the `database/sql` package. 39 | 40 | 41 | When you have installed the driver on you project, run the migration, execute the following command: 42 | 43 | ```sh 44 | amigo migrate 45 | ``` 46 | -------------------------------------------------------------------------------- /pkg/schema/run_migration_schema_dump.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/alexisvisco/amigo/pkg/utils" 9 | "github.com/alexisvisco/amigo/pkg/utils/logger" 10 | ) 11 | 12 | // tryMigrateWithSchemaDump tries to migrate with schema dump. 13 | // this might be executed when the user arrives on a repo with a schema.sql, instead of running 14 | // all the migrations we will try to dump the schema and apply it. Then tell we applied all versions. 15 | func (m *Migrator[T]) tryMigrateWithSchemaDump(migrations []Migration) error { 16 | if m.migratorContext.Config.PGDumpPath == "" { 17 | return errors.New("no schema dump file path provided") 18 | } 19 | 20 | file, err := os.ReadFile(m.migratorContext.Config.PGDumpPath) 21 | if err != nil { 22 | return fmt.Errorf("unable to read schema dump file: %w", err) 23 | } 24 | 25 | logger.ShowSQLEvents = false 26 | 27 | tx, err := m.db.BeginTx(m.migratorContext.Context, nil) 28 | if err != nil { 29 | return fmt.Errorf("unable to start transaction: %w", err) 30 | } 31 | 32 | defer tx.Rollback() 33 | 34 | tx.ExecContext(m.migratorContext.Context, "SET search_path TO public") 35 | _, err = tx.ExecContext(m.migratorContext.Context, string(file)) 36 | if err != nil { 37 | return fmt.Errorf("unable to apply schema dump: %w", err) 38 | } 39 | 40 | tx.Commit() 41 | 42 | schema := m.GetSchema() 43 | 44 | versions := make([]string, 0, len(migrations)) 45 | for _, migration := range migrations { 46 | versions = append(versions, fmt.Sprint(migration.Date().UTC().Format(utils.FormatTime))) 47 | } 48 | 49 | logger.ShowSQLEvents = false 50 | 51 | schema.AddVersions(versions) 52 | 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /pkg/schema/sqlite/sqlite_test.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "log/slog" 7 | "os" 8 | "path" 9 | "testing" 10 | 11 | "github.com/alexisvisco/amigo/pkg/amigoconfig" 12 | "github.com/alexisvisco/amigo/pkg/schema" 13 | "github.com/alexisvisco/amigo/pkg/utils/dblog" 14 | "github.com/alexisvisco/amigo/pkg/utils/logger" 15 | _ "github.com/mattn/go-sqlite3" 16 | sqldblogger "github.com/simukti/sqldb-logger" 17 | "github.com/stretchr/testify/require" 18 | ) 19 | 20 | func connect(t *testing.T) (*sql.DB, dblog.DatabaseLogger) { 21 | dbFile := path.Join("testdata", t.Name()) + ".data" 22 | err := os.MkdirAll(path.Dir(dbFile), 0755) 23 | require.NoError(t, err) 24 | 25 | err = os.Remove(dbFile) 26 | if err != nil && !os.IsNotExist(err) { 27 | require.NoError(t, err) 28 | } 29 | 30 | conn, err := sql.Open("sqlite3", dbFile) 31 | require.NoError(t, err) 32 | 33 | logger.ShowSQLEvents = true 34 | slog.SetDefault(slog.New(logger.NewHandler(os.Stdout, &logger.Options{}))) 35 | recorder := dblog.NewHandler(true) 36 | 37 | conn = sqldblogger.OpenDriver(dbFile, conn.Driver(), recorder) 38 | 39 | return conn, recorder 40 | } 41 | 42 | func baseTest(t *testing.T, init string) (postgres *Schema, rec dblog.DatabaseLogger) { 43 | db, rec := connect(t) 44 | 45 | m := schema.NewMigrator(context.Background(), db, NewSQLite, &amigoconfig.Config{}) 46 | 47 | if init != "" { 48 | _, err := db.ExecContext(context.Background(), init) 49 | require.NoError(t, err) 50 | } 51 | 52 | rec.ToggleLogger(true) 53 | rec.SetRecord(true) 54 | 55 | return m.NewSchema(), rec 56 | } 57 | 58 | func asserIndexExist(t *testing.T, p *Schema, tableName schema.TableName, indexName string) { 59 | require.True(t, p.IndexExist(tableName, indexName)) 60 | } 61 | -------------------------------------------------------------------------------- /pkg/utils/cmdexec/command.go: -------------------------------------------------------------------------------- 1 | package cmdexec 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "os/exec" 8 | "strings" 9 | ) 10 | 11 | func Exec(cmd string, args []string, env map[string]string) (string, string, error) { 12 | co := exec.Command(cmd, args...) 13 | 14 | for k, v := range env { 15 | co.Env = append(co.Env, k+"="+v) 16 | } 17 | 18 | co.Env = append(co.Env, os.Environ()...) 19 | 20 | addToPath := []string{"/opt/homebrew/opt/libpq/bin", "/usr/local/opt/libpq/bin"} 21 | for i, key := range co.Env { 22 | if strings.HasPrefix(key, "PATH=") { 23 | co.Env[i] = key + ":" + strings.Join(addToPath, ":") 24 | break 25 | } 26 | } 27 | 28 | bufferStdout := new(strings.Builder) 29 | bufferStderr := new(strings.Builder) 30 | 31 | co.Stdout = bufferStdout 32 | co.Stderr = bufferStderr 33 | err := co.Run() 34 | if err != nil { 35 | return bufferStdout.String(), bufferStderr.String(), fmt.Errorf("unable to execute command: %w", err) 36 | } 37 | 38 | return bufferStdout.String(), bufferStderr.String(), nil 39 | } 40 | 41 | func ExecToWriter(cmd string, args []string, env map[string]string, stdout io.Writer, stderr io.Writer) error { 42 | co := exec.Command(cmd, args...) 43 | 44 | for k, v := range env { 45 | co.Env = append(co.Env, k+"="+v) 46 | } 47 | 48 | co.Env = append(co.Env, os.Environ()...) 49 | 50 | addToPath := []string{"/opt/homebrew/opt/libpq/bin", "/usr/local/opt/libpq/bin"} 51 | for i, key := range co.Env { 52 | if strings.HasPrefix(key, "PATH=") { 53 | co.Env[i] = key + ":" + strings.Join(addToPath, ":") 54 | break 55 | } 56 | } 57 | 58 | co.Stdout = stdout 59 | co.Stderr = stderr 60 | err := co.Run() 61 | if err != nil { 62 | return fmt.Errorf("unable to execute command: %w", err) 63 | } 64 | 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Go Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | 9 | services: 10 | postgres: 11 | image: postgres:16-alpine 12 | env: 13 | POSTGRES_DB: postgres 14 | POSTGRES_PASSWORD: postgres 15 | POSTGRES_USER: postgres 16 | ports: 17 | - 6666:5432 18 | options: >- 19 | --health-cmd="pg_isready -U postgres" 20 | --health-interval=10s 21 | --health-timeout=5s 22 | --health-retries=5 23 | 24 | steps: 25 | - name: Checkout code 26 | uses: actions/checkout@v4 27 | 28 | - name: Set up Go 29 | uses: actions/setup-go@v5 30 | with: 31 | go-version: '1.22' 32 | 33 | - name: Setup Golang with cache 34 | uses: magnetikonline/action-golang-cache@v5 35 | with: 36 | go-version-file: go.mod 37 | 38 | - name: Setup pg_dump 39 | run: | 40 | sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/postgres.list' 41 | wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add - 42 | sudo apt-get update 43 | sudo apt-get install postgresql-client-16 44 | which pg_dump ; pg_dump --version 45 | 46 | 47 | - name: Wait for PostgreSQL to be ready 48 | run: | 49 | while ! pg_isready -h 127.0.0.1 -p 6666 -U postgres; do 50 | echo "Waiting for PostgreSQL..." 51 | sleep 1 52 | done 53 | 54 | - name: Run tests 55 | env: 56 | PG_DUMP_PATH: /usr/bin/pg_dump 57 | run: | 58 | go test -v ./... 59 | -------------------------------------------------------------------------------- /pkg/schema/sqlite/sqlite.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "fmt" 5 | "github.com/alexisvisco/amigo/pkg/schema" 6 | "github.com/alexisvisco/amigo/pkg/schema/base" 7 | "github.com/alexisvisco/amigo/pkg/types" 8 | ) 9 | 10 | type Schema struct { 11 | // TX is the transaction to execute the queries. 12 | TX schema.DB 13 | 14 | // DB is a database connection but not in a transaction. 15 | DB schema.DB 16 | 17 | Context *schema.MigratorContext 18 | 19 | *base.Schema 20 | 21 | // ReversibleMigrationExecutor is a helper to execute reversible migrations in change method. 22 | *schema.ReversibleMigrationExecutor 23 | } 24 | 25 | func NewSQLite(ctx *schema.MigratorContext, tx schema.DB, db schema.DB) *Schema { 26 | return &Schema{ 27 | TX: tx, 28 | DB: db, 29 | Context: ctx, 30 | Schema: base.NewBase(ctx, tx, db), 31 | ReversibleMigrationExecutor: schema.NewReversibleMigrationExecutor(ctx), 32 | } 33 | } 34 | 35 | // rollbackMode will allow to execute migration without getting a infinite loop by checking the migration direction. 36 | func (p *Schema) rollbackMode() *Schema { 37 | ctx := *p.Context 38 | ctx.MigrationDirection = types.MigrationDirectionNotReversible 39 | return &Schema{ 40 | TX: p.TX, 41 | DB: p.DB, 42 | Context: &ctx, 43 | Schema: base.NewBase(&ctx, p.TX, p.DB), 44 | ReversibleMigrationExecutor: schema.NewReversibleMigrationExecutor(&ctx), 45 | } 46 | } 47 | 48 | func (p *Schema) Exec(query string, args ...interface{}) { 49 | _, err := p.TX.ExecContext(p.Context.Context, query, args...) 50 | if err != nil { 51 | p.Context.RaiseError(fmt.Errorf("error while executing query: %w", err)) 52 | return 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /docs/docs/04-api/100-migrating-in-go.md: -------------------------------------------------------------------------------- 1 | # Migrating in go 2 | 3 | Usually you will need to run migration in your Go application, to do so you can use the `amigo` package. 4 | 5 | ```go 6 | package main 7 | 8 | import ( 9 | "database/sql" 10 | "example/pg/db/migrations" 11 | "github.com/alexisvisco/amigo/pkg/amigo" 12 | "github.com/alexisvisco/amigo/pkg/amigoconfig" 13 | "github.com/alexisvisco/amigo/pkg/types" 14 | _ "github.com/jackc/pgx/v5/stdlib" 15 | "os" 16 | ) 17 | 18 | // this is an example to run migration in a codebase 19 | func main() { 20 | dsn := "postgres://postgres:postgres@localhost:6666/postgres" 21 | db, err := sql.Open("pgx", dsn) 22 | if err != nil { 23 | panic(err) 24 | } 25 | 26 | err = migrateDatabase(dsn, db) 27 | if err != nil { 28 | panic(err) 29 | } 30 | } 31 | 32 | func migrateDatabase(databaseURL string, rawDB *sql.DB) error { 33 | dumpSchemaAfterMigrating := os.Getenv("DUMP_SCHEMA_AFTER_MIGRATING") == "true" 34 | 35 | a := amigoctx.NewContext(). 36 | WithDSN(databaseURL). // you need to provide the dsn too, in order for amigo to detect the driver 37 | WithShowSQL(true). // will show you sql queries 38 | WithDumpSchemaAfterMigrating(dumpSchemaAfterMigrating) // will create/modify the schema.sql in db folder (only in local is suitable) 39 | 40 | err := amigo.NewAmigo(a).RunMigrations(amigo.RunMigrationParams{ 41 | DB: rawDB, 42 | Direction: amigotypes.MigrationDirectionUp, // will migrate the database up 43 | Migrations: migrations.Migrations, // where migrations are located (db/migrations) 44 | Logger: slog.Default(), // you can also only specify the LogOutput and it will use the default amigo logger at your desired output (io.Writer) 45 | }) 46 | if err != nil { 47 | return fmt.Errorf("failed to migrate database: %w", err) 48 | } 49 | 50 | return nil 51 | } 52 | ``` 53 | 54 | You can specify all the options the cli can take in the `RunMigrationOptions` struct (steps, version, dryrun ...) 55 | -------------------------------------------------------------------------------- /pkg/schema/run_migration.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/alexisvisco/amigo/pkg/types" 7 | "github.com/alexisvisco/amigo/pkg/utils/events" 8 | "github.com/alexisvisco/amigo/pkg/utils/logger" 9 | ) 10 | 11 | // run runs the migration. 12 | func (m *Migrator[T]) run(migrationType types.MigrationDirection, version string, f func(T)) (ok bool) { 13 | currentContext := m.migratorContext 14 | currentContext.MigrationDirection = migrationType 15 | 16 | tx, err := m.db.BeginTx(currentContext.Context, nil) 17 | if err != nil { 18 | logger.Error(events.MessageEvent{Message: "unable to start transaction"}) 19 | return false 20 | } 21 | 22 | schema := m.schemaFactory(currentContext, tx, m.db) 23 | 24 | handleError := func(err any) { 25 | if err != nil { 26 | logger.Error(events.MessageEvent{Message: fmt.Sprintf("migration failed, rollback due to: %v", err)}) 27 | 28 | err := tx.Rollback() 29 | if err != nil { 30 | logger.Error(events.MessageEvent{Message: "unable to rollback transaction"}) 31 | } 32 | 33 | ok = false 34 | } 35 | } 36 | 37 | defer func() { 38 | if r := recover(); r != nil { 39 | handleError(r) 40 | } 41 | }() 42 | 43 | f(schema) 44 | 45 | switch migrationType { 46 | case types.MigrationDirectionUp: 47 | schema.AddVersion(version) 48 | case types.MigrationDirectionDown, types.MigrationDirectionNotReversible: 49 | schema.RemoveVersion(version) 50 | } 51 | 52 | if m.migratorContext.Config.Migration.DryRun { 53 | logger.Info(events.MessageEvent{Message: "migration in dry run mode, rollback transaction..."}) 54 | err := tx.Rollback() 55 | if err != nil { 56 | logger.Error(events.MessageEvent{Message: "unable to rollback transaction"}) 57 | } 58 | return true 59 | } else { 60 | err := tx.Commit() 61 | if err != nil { 62 | logger.Error(events.MessageEvent{Message: "unable to commit transaction"}) 63 | return false 64 | } 65 | } 66 | 67 | return true 68 | } 69 | -------------------------------------------------------------------------------- /pkg/amigo/gen_migration_file.go: -------------------------------------------------------------------------------- 1 | package amigo 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "time" 7 | 8 | "github.com/alexisvisco/amigo/pkg/templates" 9 | "github.com/alexisvisco/amigo/pkg/types" 10 | "github.com/alexisvisco/amigo/pkg/utils" 11 | "github.com/gobuffalo/flect" 12 | ) 13 | 14 | type GenerateMigrationFileParams struct { 15 | Name string 16 | Up string 17 | Down string 18 | Change string 19 | Type types.MigrationFileType 20 | Now time.Time 21 | UseSchemaImport bool 22 | UseFmtImport bool 23 | Writer io.Writer 24 | } 25 | 26 | func (a Amigo) GenerateMigrationFile(params *GenerateMigrationFileParams) error { 27 | var ( 28 | migrationPackageName = a.Config.MigrationPackageName 29 | ) 30 | 31 | structName := utils.MigrationStructName(params.Now, params.Name) 32 | 33 | orDefault := func(s string) string { 34 | if s == "" { 35 | return "// TODO: implement the migration" 36 | } 37 | return s 38 | } 39 | 40 | fileContent, err := templates.GetMigrationTemplate(templates.MigrationData{ 41 | IsSQL: params.Type == types.MigrationFileTypeSQL, 42 | Package: migrationPackageName, 43 | StructName: structName, 44 | Name: flect.Underscore(params.Name), 45 | Type: params.Type, 46 | InChange: orDefault(params.Change), 47 | InUp: orDefault(params.Up), 48 | InDown: orDefault(params.Down), 49 | CreatedAt: params.Now.Format(time.RFC3339), 50 | PackageDriverName: a.Driver.PackageName(), 51 | PackageDriverPath: a.Driver.PackageSchemaPath(), 52 | UseSchemaImport: params.UseSchemaImport, 53 | UseFmtImport: params.UseFmtImport, 54 | }) 55 | 56 | if err != nil { 57 | return fmt.Errorf("unable to get migration template: %w", err) 58 | } 59 | 60 | _, err = params.Writer.Write([]byte(fileContent)) 61 | if err != nil { 62 | return fmt.Errorf("unable to write migration file: %w", err) 63 | } 64 | 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /docs/docs/02-quick-start/04-create-go-migration.md: -------------------------------------------------------------------------------- 1 | # Go migration 2 | Now you can create your own migration, to do that you can run the following command: 3 | 4 | ```sh 5 | amigo create create_users_table 6 | ``` 7 | 8 | This command will create a new migration file `{DATE}_create_users_table.go` in the `migrations` folder. 9 | 10 | This migration is in go, you have two kinds of migration files: 11 | - classic: they are up and down migrations as they implement [DetailedMigration](https://github.com/alexisvisco/amigo/blob/main/pkg/schema/migrator.go#L40) interface 12 | - change: they are up migrations as they implement [ChangeMigration](https://github.com/alexisvisco/amigo/blob/main/pkg/schema/migrator.go#L48) interface 13 | 14 | (more information on the cli [here](../03-cli/03-create.md)) 15 | 16 | 17 | ### Example of a `change` migration file: 18 | 19 | ```go 20 | package migrations 21 | 22 | import ( 23 | "github.com/alexisvisco/amigo/pkg/schema/pg" 24 | ) 25 | 26 | type Migration20240524090434CreateUserTable struct {} 27 | 28 | func (m Migration20240524090434CreateUserTable) Change(s *pg.Schema) { 29 | s.CreateTable("users", func(def *pg.PostgresTableDef) { 30 | def.Serial("id") 31 | def.String("name") 32 | def.String("email") 33 | def.Timestamps() 34 | def.Index([]string{"name"}) 35 | }) 36 | } 37 | ``` 38 | 39 | ### Example of a `classic` migration file: 40 | 41 | ```go 42 | package migrations 43 | 44 | import ( 45 | "github.com/alexisvisco/amigo/pkg/schema/pg" 46 | ) 47 | 48 | type Migration20240524090434CreateUserTable struct {} 49 | 50 | func (m Migration20240524090434CreateUserTable) Up(s *pg.Schema) { 51 | s.CreateTable("users", func(def *pg.PostgresTableDef) { 52 | def.Serial("id") 53 | def.String("name") 54 | def.String("email") 55 | def.Timestamps() 56 | def.Index([]string{"name"}) 57 | }) 58 | } 59 | 60 | func (m Migration20240524090434CreateUserTable) Down(s *pg.Schema) { 61 | s.DropTable("users") 62 | } 63 | ``` 64 | 65 | 66 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/alexisvisco/amigo 2 | 3 | go 1.22 4 | 5 | require ( 6 | // used for the logger to print nice sql query with syntax hilighting 7 | github.com/alecthomas/chroma/v2 v2.14.0 8 | 9 | // used to print sql query (could be internalized but it has 0 dependencies) 10 | github.com/simukti/sqldb-logger v0.0.0-20230108155151-646c1a075551 11 | 12 | // user for the cli (only imported in cmd) 13 | github.com/spf13/cobra v1.8.1 14 | github.com/spf13/viper v1.19.0 15 | ) 16 | 17 | // todo: remove 18 | require github.com/gobuffalo/flect v1.0.2 19 | 20 | // Dependencies for testing purpose 21 | require ( 22 | github.com/jackc/pgx/v5 v5.6.0 23 | github.com/mattn/go-sqlite3 v1.14.22 24 | github.com/sergi/go-diff v1.3.1 25 | github.com/stretchr/testify v1.9.0 26 | ) 27 | 28 | require ( 29 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 30 | github.com/dlclark/regexp2 v1.11.0 // indirect 31 | github.com/fsnotify/fsnotify v1.7.0 // indirect 32 | github.com/hashicorp/hcl v1.0.0 // indirect 33 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 34 | github.com/jackc/pgpassfile v1.0.0 // indirect 35 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 36 | github.com/jackc/puddle/v2 v2.2.1 // indirect 37 | github.com/magiconair/properties v1.8.7 // indirect 38 | github.com/mitchellh/mapstructure v1.5.0 // indirect 39 | github.com/pelletier/go-toml/v2 v2.2.2 // indirect 40 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 41 | github.com/sagikazarmark/locafero v0.4.0 // indirect 42 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 43 | github.com/sourcegraph/conc v0.3.0 // indirect 44 | github.com/spf13/afero v1.11.0 // indirect 45 | github.com/spf13/cast v1.6.0 // indirect 46 | github.com/spf13/pflag v1.0.5 // indirect 47 | github.com/subosito/gotenv v1.6.0 // indirect 48 | go.uber.org/multierr v1.11.0 // indirect 49 | golang.org/x/crypto v0.23.0 // indirect 50 | golang.org/x/exp v0.0.0-20240531132922-fd00a4e0eefc // indirect 51 | golang.org/x/sync v0.7.0 // indirect 52 | golang.org/x/sys v0.20.0 // indirect 53 | golang.org/x/text v0.15.0 // indirect 54 | gopkg.in/ini.v1 v1.67.0 // indirect 55 | gopkg.in/yaml.v3 v3.0.1 // indirect 56 | ) 57 | -------------------------------------------------------------------------------- /pkg/entrypoint/status.go: -------------------------------------------------------------------------------- 1 | package entrypoint 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "text/tabwriter" 8 | 9 | "github.com/alexisvisco/amigo/pkg/amigo" 10 | "github.com/alexisvisco/amigo/pkg/utils" 11 | "github.com/alexisvisco/amigo/pkg/utils/colors" 12 | "github.com/alexisvisco/amigo/pkg/utils/events" 13 | "github.com/alexisvisco/amigo/pkg/utils/logger" 14 | "github.com/spf13/cobra" 15 | ) 16 | 17 | var statusCmd = &cobra.Command{ 18 | Use: "status", 19 | Short: "Status explain the current state of the database.", 20 | Run: wrapCobraFunc(func(cmd *cobra.Command, am amigo.Amigo, args []string) error { 21 | if err := config.ValidateDSN(); err != nil { 22 | return err 23 | } 24 | 25 | db, migrations, err := provider(*am.Config) 26 | if err != nil { 27 | return fmt.Errorf("unable to get provided resources from main: %w", err) 28 | } 29 | 30 | ctx, cancelFunc := context.WithTimeout(context.Background(), am.Config.Migration.Timeout) 31 | defer cancelFunc() 32 | 33 | versions, err := am.GetStatus(ctx, db) 34 | if err != nil { 35 | return fmt.Errorf("unable to get status: %w", err) 36 | } 37 | 38 | hasVersion := func(version string) bool { 39 | for _, v := range versions { 40 | if v == version { 41 | return true 42 | } 43 | } 44 | return false 45 | } 46 | 47 | // show status of 10 last migrations 48 | b := &strings.Builder{} 49 | tw := tabwriter.NewWriter(b, 2, 0, 1, ' ', 0) 50 | 51 | defaultMigrations := sliceArrayOrDefault(migrations, 10) 52 | for i, m := range defaultMigrations { 53 | key := fmt.Sprintf("(%s) %s", m.Date().UTC().Format(utils.FormatTime), m.Name()) 54 | value := colors.Red("not applied") 55 | if hasVersion(m.Date().UTC().Format(utils.FormatTime)) { 56 | value = colors.Green("applied") 57 | } 58 | fmt.Fprintf(tw, "%s\t\t%s", key, value) 59 | if i != len(defaultMigrations)-1 { 60 | fmt.Fprintln(tw) 61 | } 62 | } 63 | tw.Flush() 64 | logger.Info(events.MessageEvent{Message: b.String()}) 65 | 66 | return nil 67 | }), 68 | } 69 | 70 | func sliceArrayOrDefault[T any](array []T, x int) []T { 71 | defaultMigrations := array 72 | if len(array) >= x { 73 | defaultMigrations = array[len(array)-x:] 74 | } 75 | return defaultMigrations 76 | } 77 | 78 | func init() { 79 | rootCmd.AddCommand(statusCmd) 80 | } 81 | -------------------------------------------------------------------------------- /pkg/utils/orderedmap/orderedmap_test.go: -------------------------------------------------------------------------------- 1 | package orderedmap 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestOrderedMap_SetGet(t *testing.T) { 9 | om := New[int]() 10 | 11 | // Set some key-value pairs 12 | om.Set("key1", 10) 13 | om.Set("key2", 20) 14 | om.Set("key3", 30) 15 | 16 | // Get values and check if they match the expected ones 17 | value, ok := om.Get("key1") 18 | assert.True(t, ok) 19 | assert.Equal(t, 10, value) 20 | 21 | value, ok = om.Get("key2") 22 | assert.True(t, ok) 23 | assert.Equal(t, 20, value) 24 | 25 | value, ok = om.Get("key3") 26 | assert.True(t, ok) 27 | assert.Equal(t, 30, value) 28 | 29 | // Try to get a non-existent key 30 | _, ok = om.Get("nonexistent") 31 | assert.False(t, ok) 32 | } 33 | 34 | func TestOrderedMap_Iterate(t *testing.T) { 35 | om := New[string]() 36 | 37 | // Set some key-value pairs 38 | om.Set("a", "alpha") 39 | om.Set("b", "beta") 40 | om.Set("c", "gamma") 41 | 42 | // Iterate through the map and check the order and values 43 | expectedOrder := []struct { 44 | Key string 45 | Value string 46 | }{ 47 | {"a", "alpha"}, 48 | {"b", "beta"}, 49 | {"c", "gamma"}, 50 | } 51 | 52 | index := 0 53 | for entry := range om.Iterate() { 54 | assert.Equal(t, expectedOrder[index].Key, entry.Key) 55 | assert.Equal(t, expectedOrder[index].Value, entry.Value) 56 | index++ 57 | } 58 | } 59 | 60 | func TestOrderedMap_DuplicateKeys(t *testing.T) { 61 | om := New[string]() 62 | 63 | // Set a key-value pair 64 | om.Set("key1", "value1") 65 | 66 | // Set the same key with a different value 67 | om.Set("key1", "value2") 68 | 69 | // The value for the key should be updated, but the order should remain the same 70 | value, ok := om.Get("key1") 71 | assert.True(t, ok) 72 | assert.Equal(t, "value2", value) 73 | 74 | // Iterate through the map and check that there is still only one key 75 | count := 0 76 | for range om.Iterate() { 77 | count++ 78 | } 79 | assert.Equal(t, 1, count) 80 | } 81 | 82 | func TestOrderedMap_EmptyMap(t *testing.T) { 83 | om := New[string]() 84 | 85 | // Test empty map for Get 86 | _, ok := om.Get("nonexistent") 87 | assert.False(t, ok) 88 | 89 | // Test empty map for Iterate 90 | count := 0 91 | for range om.Iterate() { 92 | count++ 93 | } 94 | assert.Equal(t, 0, count) 95 | } 96 | -------------------------------------------------------------------------------- /pkg/utils/events/events.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "fmt" 5 | "github.com/alexisvisco/amigo/pkg/utils" 6 | "time" 7 | ) 8 | 9 | type EventName interface { 10 | EventName() string 11 | } 12 | 13 | type FileAddedEvent struct{ FileName string } 14 | 15 | func (p FileAddedEvent) String() string { return fmt.Sprintf("+ file: %s", p.FileName) } 16 | 17 | type FileModifiedEvent struct{ FileName string } 18 | 19 | func (p FileModifiedEvent) String() string { return fmt.Sprintf("~ file: %s", p.FileName) } 20 | 21 | type FolderAddedEvent struct{ FolderName string } 22 | 23 | func (p FolderAddedEvent) String() string { return fmt.Sprintf("+ folder: %s", p.FolderName) } 24 | 25 | type MessageEvent struct{ Message string } 26 | 27 | func (p MessageEvent) String() string { return fmt.Sprintf("%s", p.Message) } 28 | 29 | type RawEvent struct{ Message string } 30 | 31 | func (p RawEvent) String() string { return p.Message } 32 | 33 | type MeasurementEvent struct{ TimeElapsed time.Duration } 34 | 35 | func (m MeasurementEvent) String() string { return fmt.Sprintf(" done in %s", m.TimeElapsed) } 36 | 37 | type MigrateUpEvent struct { 38 | MigrationName string 39 | Time time.Time 40 | } 41 | 42 | func (m MigrateUpEvent) String() string { 43 | return fmt.Sprintf("------> migrating: %s version: %s", m.MigrationName, m.Time.Format(utils.FormatTime)) 44 | } 45 | 46 | type MigrateDownEvent struct { 47 | MigrationName string 48 | Time time.Time 49 | } 50 | 51 | func (m MigrateDownEvent) String() string { 52 | return fmt.Sprintf("------> rollback: %s version: %s", m.MigrationName, m.Time.Format(utils.FormatTime)) 53 | } 54 | 55 | type SkipMigrationEvent struct { 56 | MigrationVersion string 57 | } 58 | 59 | func (s SkipMigrationEvent) String() string { 60 | return fmt.Sprintf("------> skip migration: %s", s.MigrationVersion) 61 | } 62 | 63 | type SQLQueryEvent struct { 64 | Query string 65 | } 66 | 67 | func (s SQLQueryEvent) String() string { 68 | return fmt.Sprintf(s.Query) 69 | } 70 | 71 | type VersionAddedEvent struct { 72 | Version string 73 | } 74 | 75 | func (v VersionAddedEvent) String() string { 76 | return fmt.Sprintf("------> version migrated: %s", v.Version) 77 | } 78 | 79 | type VersionDeletedEvent struct { 80 | Version string 81 | } 82 | 83 | func (v VersionDeletedEvent) String() string { 84 | return fmt.Sprintf("------> version rolled back: %s", v.Version) 85 | } 86 | -------------------------------------------------------------------------------- /pkg/schema/pg/utils_test.go: -------------------------------------------------------------------------------- 1 | package pg 2 | 3 | import ( 4 | "context" 5 | "github.com/alexisvisco/amigo/pkg/schema" 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | "testing" 9 | ) 10 | 11 | func assertTableExist(t *testing.T, p *Schema, table schema.TableName) { 12 | var exists bool 13 | err := p.TX.QueryRowContext(context.Background(), `SELECT EXISTS ( 14 | SELECT 1 15 | FROM information_schema.tables 16 | WHERE table_schema = $1 17 | AND table_name = $2 18 | );`, table.Schema(), table.Name()).Scan(&exists) 19 | 20 | require.NoError(t, err) 21 | require.True(t, exists) 22 | } 23 | 24 | func assertTableNotExist(t *testing.T, p *Schema, table schema.TableName) { 25 | var exists bool 26 | err := p.TX.QueryRowContext(context.Background(), `SELECT EXISTS ( 27 | SELECT 1 28 | FROM information_schema.tables 29 | WHERE table_schema = $1 30 | AND table_name = $2 31 | );`, table.Schema(), table.Name()).Scan(&exists) 32 | 33 | require.NoError(t, err) 34 | require.False(t, exists) 35 | } 36 | 37 | func TestQuote_Panic(t *testing.T) { 38 | assert.PanicsWithValue(t, "unsupported value", func() { 39 | QuoteValue(1) 40 | }) 41 | } 42 | 43 | func TestQuote_ID(t *testing.T) { 44 | cases := []struct { 45 | input string 46 | want string 47 | }{ 48 | {`foo`, `"foo"`}, 49 | {`foo bar baz`, `"foo bar baz"`}, 50 | {`foo"bar`, `"foo""bar"`}, 51 | {"foo\x00bar", `"foo"`}, 52 | {"\x00foo", `""`}, 53 | } 54 | 55 | for _, test := range cases { 56 | assert.Equal(t, test.want, QuoteIdent(test.input)) 57 | } 58 | } 59 | 60 | func TestQuote_Value(t *testing.T) { 61 | cases := []struct { 62 | input string 63 | want string 64 | }{ 65 | {`foo`, `'foo'`}, 66 | {`foo bar baz`, `'foo bar baz'`}, 67 | {`foo'bar`, `'foo''bar'`}, 68 | {`foo\bar`, ` E'foo\\bar'`}, 69 | {`foo\ba'r`, ` E'foo\\ba''r'`}, 70 | {`foo"bar`, `'foo"bar'`}, 71 | {`foo\x00bar`, ` E'foo\\x00bar'`}, 72 | {`\x00foo`, ` E'\\x00foo'`}, 73 | {`'`, `''''`}, 74 | {`''`, `''''''`}, 75 | {`\`, ` E'\\'`}, 76 | {`'abc'; DROP TABLE users;`, `'''abc''; DROP TABLE users;'`}, 77 | {`\'`, ` E'\\'''`}, 78 | {`E'\''`, ` E'E''\\'''''`}, 79 | {`e'\''`, ` E'e''\\'''''`}, 80 | {`E'\'abc\'; DROP TABLE users;'`, ` E'E''\\''abc\\''; DROP TABLE users;'''`}, 81 | {`e'\'abc\'; DROP TABLE users;'`, ` E'e''\\''abc\\''; DROP TABLE users;'''`}, 82 | } 83 | 84 | for _, test := range cases { 85 | assert.Equal(t, test.want, QuoteValue(test.input)) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /pkg/amigo/amigo.go: -------------------------------------------------------------------------------- 1 | package amigo 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | "github.com/alexisvisco/amigo/pkg/amigoconfig" 8 | "github.com/alexisvisco/amigo/pkg/schema" 9 | "github.com/alexisvisco/amigo/pkg/schema/base" 10 | "github.com/alexisvisco/amigo/pkg/schema/pg" 11 | "github.com/alexisvisco/amigo/pkg/schema/sqlite" 12 | "github.com/alexisvisco/amigo/pkg/types" 13 | "github.com/alexisvisco/amigo/pkg/utils/dblog" 14 | sqldblogger "github.com/simukti/sqldb-logger" 15 | ) 16 | 17 | type Amigo struct { 18 | Config *amigoconfig.Config 19 | Driver types.Driver 20 | CustomSchemaFactory schema.Factory[schema.Schema] 21 | } 22 | 23 | type OptionFn func(*Amigo) 24 | 25 | // WithCustomSchemaFactory returns an option function that sets a custom schema factory 26 | // based on the config 27 | func WithCustomSchemaFactory(factoryFn func(cfg amigoconfig.Config) schema.Factory[schema.Schema]) OptionFn { 28 | return func(a *Amigo) { 29 | a.CustomSchemaFactory = factoryFn(*a.Config) 30 | } 31 | } 32 | 33 | // NewAmigo create a new amigo instance 34 | func NewAmigo(ctx *amigoconfig.Config, opts ...OptionFn) Amigo { 35 | a := Amigo{ 36 | Config: ctx, 37 | Driver: types.GetDriver(ctx.DSN), 38 | } 39 | 40 | for _, opt := range opts { 41 | opt(&a) 42 | } 43 | 44 | return a 45 | } 46 | 47 | type MigrationApplier interface { 48 | Apply(direction types.MigrationDirection, version *string, steps *int, migrations []schema.Migration) bool 49 | GetSchema() schema.Schema 50 | } 51 | 52 | func (a Amigo) GetMigrationApplier( 53 | ctx context.Context, 54 | conn *sql.DB, 55 | ) (MigrationApplier, error) { 56 | recorder := dblog.NewHandler(a.Config.ShowSQLSyntaxHighlighting) 57 | recorder.ToggleLogger(true) 58 | 59 | if a.Config.ValidateDSN() == nil { 60 | conn = sqldblogger.OpenDriver(a.Config.GetRealDSN(), conn.Driver(), recorder) 61 | } 62 | 63 | if a.CustomSchemaFactory != nil { 64 | return schema.NewMigrator(ctx, conn, a.CustomSchemaFactory, a.Config), nil 65 | } 66 | 67 | switch a.Driver { 68 | case types.DriverPostgres: 69 | return schema.NewMigrator(ctx, conn, pg.NewPostgres, a.Config), nil 70 | case types.DriverSQLite: 71 | return schema.NewMigrator(ctx, conn, sqlite.NewSQLite, a.Config), nil 72 | } 73 | 74 | return schema.NewMigrator(ctx, conn, base.NewBase, a.Config), nil 75 | } 76 | 77 | func (a Amigo) GetSchema(ctx context.Context, conn *sql.DB) (schema.Schema, error) { 78 | applier, err := a.GetMigrationApplier(ctx, conn) 79 | if err != nil { 80 | return nil, err 81 | } 82 | 83 | return applier.GetSchema(), nil 84 | } 85 | -------------------------------------------------------------------------------- /docs/docs/04-api/01-postgres/02-destructive.md: -------------------------------------------------------------------------------- 1 | # Destructive operations 2 | 3 | They are the operations that drop tables, columns, indexes, constraints, and so on. 4 | 5 | 6 | - [DropCheckConstraint(tableName schema.TableName, constraintName string, opts ...schema.DropCheckConstraintOptions)](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.DropCheckConstraint) 7 | 8 | - [DropColumn(tableName schema.TableName, columnName string, opts ...schema.DropColumnOptions)](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.DropColumn) 9 | 10 | - [DropExtension(name string, opts ...schema.DropExtensionOptions)](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.DropExtension) 11 | 12 | - [DropForeignKeyConstraint(fromTable, toTable schema.TableName, opts ...schema.DropForeignKeyConstraintOptions)](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.DropForeignKeyConstraint) 13 | 14 | - [DropIndex(table schema.TableName, columns []string, opts ...schema.DropIndexOptions)](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.DropIndex) 15 | 16 | - [DropPrimaryKeyConstraint(tableName schema.TableName, opts ...schema.DropPrimaryKeyConstraintOptions)](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.DropPrimaryKeyConstraint) 17 | 18 | - [DropTable(tableName schema.TableName, opts ...schema.DropTableOptions)](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.DropTable) 19 | 20 | - [RemoveVersion(version string)](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/base#Schema.RemoveVersion) 21 | 22 | - [RenameColumn(tableName schema.TableName, oldColumnName, newColumnName string)](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.RenameColumn) 23 | 24 | - [DropEnum(name string, opts ...schema.DropEnumOptions)](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.DropEnum) 25 | 26 | Usually you will use these functions in the `down` function of a migration, but you can use them in the `up` function too. 27 | If you want to have the reverse operation of a destructive operation, you can use the `reversible` options. 28 | 29 | Example: 30 | 31 | ```go 32 | p.DropTable("users", schema.DropTableOptions{ 33 | Reversible: &TableOption{ 34 | schema.TableName: "users", 35 | PostgresTableDefinition: Innerschema.Tablefunc(t *PostgresTableDef) { 36 | t.Serial("id") 37 | t.String("name") 38 | }), 39 | }}) 40 | ``` 41 | 42 | In that case, if you are in a `change` function in your migration, the library will at the up operation drop the table `users` and at the down operation re-create the table `users` with the columns `id` and `name`. 43 | -------------------------------------------------------------------------------- /docs/docs/04-api/01-postgres/01-constructive.md: -------------------------------------------------------------------------------- 1 | # Constructive operations 2 | 3 | They are the operations that create, alter, or drop tables, columns, indexes, constraints, and so on. 4 | 5 | - [CreateTable(tableName schema.TableName, f func(*PostgresTableDef), opts ...schema.TableOptions)](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.CreateTable) 6 | 7 | - [AddColumn(tableName schema.TableName, columnName string, columnType schema.ColumnType, opts ...schema.ColumnOptions)](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.AddColumn) 8 | 9 | - [AddTimestamps(tableName schema.TableName](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.AddTimestamps) 10 | 11 | - [AddColumnComment(tableName schema.TableName, columnName string, comment *string, opts ...schema.ColumnCommentOptions)](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.AddColumnComment) 12 | 13 | - [AddTableComment(tableName schema.TableName, comment *string, opts ...schema.TableCommentOptions)](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.AddTableComment) 14 | 15 | - [AddCheckConstraint(tableName schema.TableName, constraintName string, expression string, opts ...schema.CheckConstraintOptions)](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.AddCheckConstraint) 16 | 17 | - [AddExtension(name string, option ...schema.ExtensionOptions)](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.AddExtension) 18 | 19 | - [AddForeignKey(fromTable, toTable schema.TableName, opts ...schema.AddForeignKeyConstraintOptions)](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.AddForeignKeyConstraint) 20 | 21 | - [AddIndex(table schema.TableName, columns []string, option ...schema.IndexOptions)](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.AddIndex) 22 | 23 | - [AddPrimaryKeyConstraint(tableName schema.TableName, columns []string, opts ...schema.PrimaryKeyConstraintOptions)](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.AddPrimaryKeyConstraint) 24 | 25 | - [AddVersion(version string)](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/base#Schema.AddVersion) 26 | 27 | - [CreateEnum(name string, values []string, opts ...schema.CreateEnumOptions)](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.CreateEnum) 28 | 29 | - [AddEnumValue(name string, value string, opts ...schema.AddEnumValueOptions)](https://pkg.go.dev/github.com/alexisvisco/amigo/pkg/schema/pg#Schema.AddEnumValue) 30 | 31 | Each of this functions are reversible, it means that in a migration that implement the `change` function, when you 32 | rollback the migration you don't have to write manually the rollback operation, the library will do it for you. 33 | -------------------------------------------------------------------------------- /testdata/Test_2e2_postgres/migration_with_change_migrations_with_change/20240518071740_create_user.snap.sql: -------------------------------------------------------------------------------- 1 | SET statement_timeout = 0; 2 | SET lock_timeout = 0; 3 | SET idle_in_transaction_session_timeout = 0; 4 | SET client_encoding = 'UTF8'; 5 | SET standard_conforming_strings = on; 6 | SELECT pg_catalog.set_config('search_path', '', false); 7 | SET check_function_bodies = false; 8 | SET xmloption = content; 9 | SET client_min_messages = warning; 10 | SET row_security = off; 11 | 12 | -- 13 | -- Name: migrations_with_change; Type: SCHEMA; Schema: -; Owner: - 14 | -- 15 | 16 | CREATE SCHEMA migrations_with_change; 17 | 18 | 19 | SET default_table_access_method = heap; 20 | 21 | -- 22 | -- Name: mig_schema_versions; Type: TABLE; Schema: migrations_with_change; Owner: - 23 | -- 24 | 25 | CREATE TABLE migrations_with_change.mig_schema_versions ( 26 | version text NOT NULL 27 | ); 28 | 29 | 30 | -- 31 | -- Name: users; Type: TABLE; Schema: migrations_with_change; Owner: - 32 | -- 33 | 34 | CREATE TABLE migrations_with_change.users ( 35 | id integer NOT NULL, 36 | name text, 37 | email text, 38 | created_at timestamp(6) without time zone DEFAULT now() NOT NULL, 39 | updated_at timestamp(6) without time zone DEFAULT now() NOT NULL 40 | ); 41 | 42 | 43 | -- 44 | -- Name: users_id_seq; Type: SEQUENCE; Schema: migrations_with_change; Owner: - 45 | -- 46 | 47 | CREATE SEQUENCE migrations_with_change.users_id_seq 48 | AS integer 49 | START WITH 1 50 | INCREMENT BY 1 51 | NO MINVALUE 52 | NO MAXVALUE 53 | CACHE 1; 54 | 55 | 56 | -- 57 | -- Name: users_id_seq; Type: SEQUENCE OWNED BY; Schema: migrations_with_change; Owner: - 58 | -- 59 | 60 | ALTER SEQUENCE migrations_with_change.users_id_seq OWNED BY migrations_with_change.users.id; 61 | 62 | 63 | -- 64 | -- Name: users id; Type: DEFAULT; Schema: migrations_with_change; Owner: - 65 | -- 66 | 67 | ALTER TABLE ONLY migrations_with_change.users ALTER COLUMN id SET DEFAULT nextval('migrations_with_change.users_id_seq'::regclass); 68 | 69 | 70 | -- 71 | -- Name: mig_schema_versions mig_schema_versions_pkey; Type: CONSTRAINT; Schema: migrations_with_change; Owner: - 72 | -- 73 | 74 | ALTER TABLE ONLY migrations_with_change.mig_schema_versions 75 | ADD CONSTRAINT mig_schema_versions_pkey PRIMARY KEY (version); 76 | 77 | 78 | -- 79 | -- Name: users users_pkey; Type: CONSTRAINT; Schema: migrations_with_change; Owner: - 80 | -- 81 | 82 | ALTER TABLE ONLY migrations_with_change.users 83 | ADD CONSTRAINT users_pkey PRIMARY KEY (id); 84 | 85 | 86 | -- 87 | -- Name: idx_users_name; Type: INDEX; Schema: migrations_with_change; Owner: - 88 | -- 89 | 90 | CREATE INDEX idx_users_name ON migrations_with_change.users USING btree (name); 91 | 92 | 93 | -- 94 | -- PostgreSQL database dump complete 95 | -- 96 | 97 | -------------------------------------------------------------------------------- /pkg/templates/util.go: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import ( 4 | "bytes" 5 | _ "embed" 6 | "sort" 7 | "strings" 8 | "text/template" 9 | ) 10 | 11 | //go:embed migrations.go.tmpl 12 | var migrationsList string 13 | 14 | //go:embed migration.go.tmpl 15 | var migration string 16 | 17 | //go:embed migration.sql.tmpl 18 | var migrationSQL string 19 | 20 | //go:embed init_create_table.go.tmpl 21 | var initCreateTable string 22 | 23 | //go:embed init_create_table_base.go.tmpl 24 | var initCreateTableBase string 25 | 26 | //go:embed main.go.tmpl 27 | var main string 28 | 29 | func GetMigrationsTemplate(t MigrationsData) (string, error) { 30 | parse, err := template.New("migrationsList").Parse(migrationsList) 31 | if err != nil { 32 | return "", err 33 | } 34 | 35 | var tpl bytes.Buffer 36 | if err := parse.Execute(&tpl, t); err != nil { 37 | return "", err 38 | } 39 | 40 | return tpl.String(), nil 41 | } 42 | 43 | func GetMigrationTemplate(t MigrationData) (string, error) { 44 | if t.IsSQL { 45 | return migrationSQL, nil 46 | } 47 | 48 | t.Imports = append(t.Imports, "time") 49 | t.Imports = append(t.Imports, "github.com/alexisvisco/amigo/pkg/schema/"+t.PackageDriverName) 50 | 51 | if t.UseSchemaImport { 52 | t.Imports = append(t.Imports, "github.com/alexisvisco/amigo/pkg/schema") 53 | } 54 | 55 | if t.UseFmtImport { 56 | t.Imports = append(t.Imports, "fmt") 57 | } 58 | 59 | sort.Strings(t.Imports) 60 | 61 | parse, err := template.New("migration").Funcs(funcMap).Parse(migration) 62 | if err != nil { 63 | return "", err 64 | } 65 | 66 | var buf bytes.Buffer 67 | if err := parse.Execute(&buf, t); err != nil { 68 | return "", err 69 | } 70 | 71 | return buf.String(), nil 72 | } 73 | 74 | func GetInitCreateTableTemplate(t CreateTableData, base bool) (string, error) { 75 | 76 | tmpl := initCreateTable 77 | if base { 78 | tmpl = initCreateTableBase 79 | } 80 | parse, err := template.New("initCreateTable").Parse(tmpl) 81 | if err != nil { 82 | return "", err 83 | } 84 | 85 | var tpl bytes.Buffer 86 | if err := parse.Execute(&tpl, t); err != nil { 87 | return "", err 88 | } 89 | 90 | return tpl.String(), nil 91 | } 92 | 93 | func GetMainTemplate(t MainData) (string, error) { 94 | parse, err := template.New("main").Funcs(funcMap).Parse(main) 95 | if err != nil { 96 | return "", err 97 | } 98 | 99 | var tpl bytes.Buffer 100 | if err := parse.Execute(&tpl, t); err != nil { 101 | return "", err 102 | } 103 | 104 | return tpl.String(), nil 105 | } 106 | 107 | var funcMap = template.FuncMap{ 108 | // indent the string with n tabs 109 | "indent": func(n int, s string) string { 110 | return strings.ReplaceAll(s, "\n", "\n"+strings.Repeat("\t", n)) 111 | }, 112 | } 113 | -------------------------------------------------------------------------------- /testdata/Test_2e2_postgres/migration_with_classic_migrations_with_classic/20240518071740_create_user.snap.sql: -------------------------------------------------------------------------------- 1 | SET statement_timeout = 0; 2 | SET lock_timeout = 0; 3 | SET idle_in_transaction_session_timeout = 0; 4 | SET client_encoding = 'UTF8'; 5 | SET standard_conforming_strings = on; 6 | SELECT pg_catalog.set_config('search_path', '', false); 7 | SET check_function_bodies = false; 8 | SET xmloption = content; 9 | SET client_min_messages = warning; 10 | SET row_security = off; 11 | 12 | -- 13 | -- Name: migrations_with_classic; Type: SCHEMA; Schema: -; Owner: - 14 | -- 15 | 16 | CREATE SCHEMA migrations_with_classic; 17 | 18 | 19 | SET default_table_access_method = heap; 20 | 21 | -- 22 | -- Name: mig_schema_versions; Type: TABLE; Schema: migrations_with_classic; Owner: - 23 | -- 24 | 25 | CREATE TABLE migrations_with_classic.mig_schema_versions ( 26 | version text NOT NULL 27 | ); 28 | 29 | 30 | -- 31 | -- Name: users; Type: TABLE; Schema: migrations_with_classic; Owner: - 32 | -- 33 | 34 | CREATE TABLE migrations_with_classic.users ( 35 | id integer NOT NULL, 36 | name text, 37 | email text, 38 | created_at timestamp(6) without time zone DEFAULT now() NOT NULL, 39 | updated_at timestamp(6) without time zone DEFAULT now() NOT NULL 40 | ); 41 | 42 | 43 | -- 44 | -- Name: users_id_seq; Type: SEQUENCE; Schema: migrations_with_classic; Owner: - 45 | -- 46 | 47 | CREATE SEQUENCE migrations_with_classic.users_id_seq 48 | AS integer 49 | START WITH 1 50 | INCREMENT BY 1 51 | NO MINVALUE 52 | NO MAXVALUE 53 | CACHE 1; 54 | 55 | 56 | -- 57 | -- Name: users_id_seq; Type: SEQUENCE OWNED BY; Schema: migrations_with_classic; Owner: - 58 | -- 59 | 60 | ALTER SEQUENCE migrations_with_classic.users_id_seq OWNED BY migrations_with_classic.users.id; 61 | 62 | 63 | -- 64 | -- Name: users id; Type: DEFAULT; Schema: migrations_with_classic; Owner: - 65 | -- 66 | 67 | ALTER TABLE ONLY migrations_with_classic.users ALTER COLUMN id SET DEFAULT nextval('migrations_with_classic.users_id_seq'::regclass); 68 | 69 | 70 | -- 71 | -- Name: mig_schema_versions mig_schema_versions_pkey; Type: CONSTRAINT; Schema: migrations_with_classic; Owner: - 72 | -- 73 | 74 | ALTER TABLE ONLY migrations_with_classic.mig_schema_versions 75 | ADD CONSTRAINT mig_schema_versions_pkey PRIMARY KEY (version); 76 | 77 | 78 | -- 79 | -- Name: users users_pkey; Type: CONSTRAINT; Schema: migrations_with_classic; Owner: - 80 | -- 81 | 82 | ALTER TABLE ONLY migrations_with_classic.users 83 | ADD CONSTRAINT users_pkey PRIMARY KEY (id); 84 | 85 | 86 | -- 87 | -- Name: idx_users_name; Type: INDEX; Schema: migrations_with_classic; Owner: - 88 | -- 89 | 90 | CREATE INDEX idx_users_name ON migrations_with_classic.users USING btree (name); 91 | 92 | 93 | -- 94 | -- PostgreSQL database dump complete 95 | -- 96 | 97 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | "path/filepath" 8 | "slices" 9 | "strings" 10 | 11 | "github.com/alexisvisco/amigo/pkg/amigoconfig" 12 | "github.com/alexisvisco/amigo/pkg/utils/cmdexec" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | var cmdConfig = &amigoconfig.Config{} 17 | 18 | // rootCmd represents the base command when called without any subcommands 19 | var rootCmd = &cobra.Command{ 20 | Use: "amigo", 21 | Short: "Tool to manage database migrations with go files", 22 | SilenceUsage: true, 23 | DisableFlagParsing: true, 24 | RunE: func(cmd *cobra.Command, args []string) error { 25 | shellPath := amigoconfig.DefaultShellPath 26 | defaultAmigoFolder := amigoconfig.DefaultAmigoFolder 27 | 28 | if env, ok := os.LookupEnv("AMIGO_FOLDER"); ok { 29 | defaultAmigoFolder = env 30 | } 31 | 32 | schemaVersionTable := amigoconfig.DefaultSchemaVersionTable 33 | mainFilePath := path.Join(defaultAmigoFolder, "main.go") 34 | mainBinaryPath := path.Join(defaultAmigoFolder, "main") 35 | migrationFolder := amigoconfig.DefaultMigrationFolder 36 | 37 | config, err := amigoconfig.LoadYamlConfig(filepath.Join(defaultAmigoFolder, amigoconfig.FileName)) 38 | if err == nil { 39 | currentConfig := config.Contexts[config.CurrentContext] 40 | if currentConfig.SchemaVersionTable != "" { 41 | schemaVersionTable = currentConfig.SchemaVersionTable 42 | } 43 | if currentConfig.MigrationFolder != "" { 44 | migrationFolder = currentConfig.MigrationFolder 45 | } 46 | } 47 | 48 | if slices.Contains(args, "init") { 49 | return executeInit(mainFilePath, defaultAmigoFolder, schemaVersionTable, migrationFolder) 50 | } 51 | 52 | return executeMain(shellPath, mainFilePath, mainBinaryPath, args) 53 | }, 54 | } 55 | 56 | func Execute() { 57 | _ = rootCmd.Execute() 58 | } 59 | 60 | func executeMain(shellPath, mainFilePath, mainBinaryPath string, restArgs []string) error { 61 | _, err := os.Stat(mainFilePath) 62 | if os.IsNotExist(err) { 63 | return fmt.Errorf("%s file does not exist, please run 'amigo init' to create it", mainFilePath) 64 | } 65 | 66 | // build binary 67 | args := []string{ 68 | "go", "build", 69 | "-o", mainBinaryPath, 70 | mainFilePath, 71 | } 72 | 73 | err = cmdexec.ExecToWriter(shellPath, []string{"-c", strings.Join(args, " ")}, nil, os.Stdout, os.Stderr) 74 | if err != nil { 75 | return err 76 | } 77 | 78 | args = []string{ 79 | "./" + mainBinaryPath, 80 | } 81 | 82 | if len(restArgs) > 0 { 83 | args = append(args, restArgs...) 84 | } 85 | 86 | err = cmdexec.ExecToWriter(shellPath, []string{"-c", strings.Join(args, " ")}, nil, os.Stdout, os.Stderr) 87 | if err != nil { 88 | return fmt.Errorf("%s throw an error: %w", mainFilePath, err) 89 | } 90 | 91 | return nil 92 | } 93 | -------------------------------------------------------------------------------- /pkg/schema/detect_migrations.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "fmt" 5 | "slices" 6 | 7 | "github.com/alexisvisco/amigo/pkg/types" 8 | "github.com/alexisvisco/amigo/pkg/utils" 9 | ) 10 | 11 | func (m *Migrator[T]) detectMigrationsToExec( 12 | s Schema, 13 | migrationDirection types.MigrationDirection, 14 | allMigrations []Migration, 15 | version *string, 16 | steps *int, // only used for rollback 17 | ) (migrationsToApply []Migration, firstRun bool) { 18 | appliedVersions, err := utils.PanicToError1(s.FindAppliedVersions) 19 | if isTableDoesNotExists(err) { 20 | firstRun = true 21 | appliedVersions = []string{} 22 | } else if err != nil { 23 | m.migratorContext.RaiseError(err) 24 | } 25 | 26 | var versionsToApply []Migration 27 | var migrationsTimeFormat []string 28 | var versionToMigration = make(map[string]Migration) 29 | 30 | for _, migration := range allMigrations { 31 | migrationsTimeFormat = append(migrationsTimeFormat, migration.Date().UTC().Format(utils.FormatTime)) 32 | versionToMigration[migrationsTimeFormat[len(migrationsTimeFormat)-1]] = migration 33 | } 34 | 35 | switch migrationDirection { 36 | case types.MigrationDirectionUp: 37 | if version != nil && *version != "" { 38 | if _, ok := versionToMigration[*version]; !ok { 39 | m.migratorContext.RaiseError(fmt.Errorf("version %s not found", *version)) 40 | } 41 | 42 | if slices.Contains(appliedVersions, *version) { 43 | m.migratorContext.RaiseError(fmt.Errorf("version %s already applied", *version)) 44 | } 45 | 46 | versionsToApply = append(versionsToApply, versionToMigration[*version]) 47 | break 48 | } 49 | 50 | for _, currentMigrationVersion := range migrationsTimeFormat { 51 | if !slices.Contains(appliedVersions, currentMigrationVersion) { 52 | versionsToApply = append(versionsToApply, versionToMigration[currentMigrationVersion]) 53 | } 54 | } 55 | case types.MigrationDirectionDown: 56 | if version != nil && *version != "" { 57 | if _, ok := versionToMigration[*version]; !ok { 58 | m.migratorContext.RaiseError(fmt.Errorf("version %s not found", *version)) 59 | } 60 | 61 | if !slices.Contains(appliedVersions, *version) { 62 | m.migratorContext.RaiseError(fmt.Errorf("version %s not applied", *version)) 63 | } 64 | 65 | versionsToApply = append(versionsToApply, versionToMigration[*version]) 66 | break 67 | } 68 | 69 | step := 1 70 | if steps != nil && *steps > 0 { 71 | step = *steps 72 | } 73 | 74 | for i := len(allMigrations) - 1; i >= 0; i-- { 75 | if slices.Contains(appliedVersions, migrationsTimeFormat[i]) { 76 | versionsToApply = append(versionsToApply, versionToMigration[migrationsTimeFormat[i]]) 77 | } 78 | 79 | if len(versionsToApply) == step { 80 | break 81 | } 82 | } 83 | } 84 | 85 | return versionsToApply, firstRun 86 | } 87 | -------------------------------------------------------------------------------- /pkg/utils/text.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | "unicode" 8 | ) 9 | 10 | // RemoveConsecutiveSpaces Remove all whitespace not between matching unescaped quotes. 11 | // Example: SELECT * FROM "table" WHERE "column" = 'value ' 12 | // Result: SELECT * FROM "table" WHERE "column" = 'value ' 13 | func RemoveConsecutiveSpaces(s string) (string, error) { 14 | rs := make([]rune, 0, len(s)) 15 | for i := 0; i < len(s); i++ { 16 | r := rune(s[i]) 17 | if r == '\'' || r == '"' { 18 | prevChar := ' ' 19 | matchedChar := uint8(r) 20 | 21 | // if the text remaining is 'value \' ' 22 | // then the quoteText will be 'value \' ' 23 | // if there is no end quote then it will return an error 24 | quoteText := string(s[i]) 25 | 26 | // jump until the next matching quote character 27 | for n := i + 1; n < len(s); n++ { 28 | if s[n] == matchedChar && prevChar != '\\' { 29 | i = n 30 | quoteText += string(s[n]) 31 | break 32 | } 33 | quoteText += string(s[n]) 34 | prevChar = rune(s[n]) 35 | } 36 | 37 | if quoteText[len(quoteText)-1] != matchedChar || len(quoteText) == 1 { 38 | err := fmt.Errorf("unmatched unescaped quote: %q", quoteText) 39 | return "", err 40 | } 41 | 42 | rs = append(rs, []rune(quoteText)...) 43 | continue 44 | } 45 | 46 | if unicode.IsSpace(r) { 47 | rs = append(rs, r) 48 | 49 | // jump until the next non-space character 50 | for n := i + 1; n < len(s); n++ { 51 | if !unicode.IsSpace(rune(s[n])) { 52 | i = n - 1 // -1 because the loop will increment it 53 | break 54 | } 55 | } 56 | 57 | continue 58 | } 59 | 60 | if !unicode.IsSpace(r) { 61 | rs = append(rs, r) 62 | } 63 | 64 | } 65 | 66 | return strings.Trim(string(rs), " "), nil 67 | } 68 | 69 | func Parentheses(in string) string { return fmt.Sprintf("(%s)", in) } 70 | 71 | type Replacer map[string]func() string 72 | 73 | // Replace replaces the string with the given values {} to the value of the function 74 | func (r Replacer) Replace(str string) string { 75 | for k, v := range r { 76 | str = strings.ReplaceAll(str, "{"+k+"}", v()) 77 | } 78 | res, err := RemoveConsecutiveSpaces(str) 79 | if err != nil { 80 | res = str 81 | } 82 | 83 | return res 84 | } 85 | 86 | // StrFunc returns a function that returns the given value as a string 87 | func StrFunc[T any](val T) func() string { return func() string { return fmt.Sprint(val) } } 88 | 89 | func StrFuncPredicate[T any](condition bool, val T) func() string { 90 | return func() string { 91 | if condition { 92 | return fmt.Sprint(val) 93 | } 94 | return "" 95 | } 96 | } 97 | 98 | func MustJSON(v interface{}) string { 99 | b, _ := json.Marshal(v) 100 | return string(b) 101 | } 102 | -------------------------------------------------------------------------------- /pkg/amigo/run_migration.go: -------------------------------------------------------------------------------- 1 | package amigo 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "log/slog" 10 | "path" 11 | "time" 12 | 13 | "github.com/alexisvisco/amigo/pkg/amigoconfig" 14 | "github.com/alexisvisco/amigo/pkg/schema" 15 | "github.com/alexisvisco/amigo/pkg/types" 16 | "github.com/alexisvisco/amigo/pkg/utils" 17 | "github.com/alexisvisco/amigo/pkg/utils/events" 18 | "github.com/alexisvisco/amigo/pkg/utils/logger" 19 | ) 20 | 21 | var ( 22 | ErrConnectionNil = errors.New("connection is nil") 23 | ErrMigrationFailed = errors.New("migration failed") 24 | ) 25 | 26 | type RunMigrationParams struct { 27 | DB *sql.DB 28 | Direction types.MigrationDirection 29 | Migrations []schema.Migration 30 | LogOutput io.Writer 31 | Context context.Context 32 | Logger *slog.Logger 33 | } 34 | 35 | // RunMigrations migrates the database, it is launched via the generated main file or manually in a codebase. 36 | func (a Amigo) RunMigrations(params RunMigrationParams) error { 37 | err := a.validateRunMigration(params.DB, ¶ms.Direction) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | originCtx := context.Background() 43 | if params.Context != nil { 44 | originCtx = params.Context 45 | } 46 | 47 | ctx, cancel := context.WithDeadline(originCtx, time.Now().Add(a.Config.Migration.Timeout)) 48 | defer cancel() 49 | 50 | a.SetupSlog(params.LogOutput, params.Logger) 51 | 52 | migrator, err := a.GetMigrationApplier(ctx, params.DB) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | ok := migrator.Apply( 58 | params.Direction, 59 | utils.NilOrValue(a.Config.Migration.Version), 60 | utils.NilOrValue(a.Config.Migration.Steps), 61 | params.Migrations, 62 | ) 63 | 64 | if !ok { 65 | return ErrMigrationFailed 66 | } 67 | 68 | if a.Config.Migration.DumpSchemaAfter { 69 | file, err := utils.CreateOrOpenFile(a.Config.SchemaOutPath) 70 | if err != nil { 71 | return fmt.Errorf("unable to open/create file: %w", err) 72 | } 73 | 74 | defer file.Close() 75 | 76 | err = a.DumpSchema(file, false) 77 | if err != nil { 78 | return fmt.Errorf("unable to dump schema after migrating: %w", err) 79 | } 80 | 81 | logger.Info(events.FileModifiedEvent{FileName: path.Join(a.Config.SchemaOutPath)}) 82 | } 83 | 84 | return nil 85 | } 86 | 87 | func (a Amigo) validateRunMigration(conn *sql.DB, direction *types.MigrationDirection) error { 88 | if a.Config.SchemaVersionTable == "" { 89 | a.Config.SchemaVersionTable = amigoconfig.DefaultSchemaVersionTable 90 | } 91 | 92 | if direction == nil || *direction == "" { 93 | *direction = types.MigrationDirectionUp 94 | } 95 | 96 | if a.Config.Migration.Timeout == 0 { 97 | a.Config.Migration.Timeout = amigoconfig.DefaultTimeout 98 | } 99 | 100 | if conn == nil { 101 | return ErrConnectionNil 102 | } 103 | 104 | return nil 105 | } 106 | -------------------------------------------------------------------------------- /docs/static/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /testdata/Test_2e2_postgres/migration_with_change_migrations_with_change/20240518071938_custom_seed.snap.sql: -------------------------------------------------------------------------------- 1 | SET statement_timeout = 0; 2 | SET lock_timeout = 0; 3 | SET idle_in_transaction_session_timeout = 0; 4 | SET client_encoding = 'UTF8'; 5 | SET standard_conforming_strings = on; 6 | SELECT pg_catalog.set_config('search_path', '', false); 7 | SET check_function_bodies = false; 8 | SET xmloption = content; 9 | SET client_min_messages = warning; 10 | SET row_security = off; 11 | 12 | -- 13 | -- Name: migrations_with_change; Type: SCHEMA; Schema: -; Owner: - 14 | -- 15 | 16 | CREATE SCHEMA migrations_with_change; 17 | 18 | 19 | SET default_table_access_method = heap; 20 | 21 | -- 22 | -- Name: mig_schema_versions; Type: TABLE; Schema: migrations_with_change; Owner: - 23 | -- 24 | 25 | CREATE TABLE migrations_with_change.mig_schema_versions ( 26 | version text NOT NULL 27 | ); 28 | 29 | 30 | -- 31 | -- Name: users; Type: TABLE; Schema: migrations_with_change; Owner: - 32 | -- 33 | 34 | CREATE TABLE migrations_with_change.users ( 35 | id integer NOT NULL, 36 | name text, 37 | email text, 38 | created_at timestamp(6) without time zone DEFAULT now() NOT NULL, 39 | updated_at timestamp(6) without time zone DEFAULT now() NOT NULL 40 | ); 41 | 42 | 43 | -- 44 | -- Name: users_id_seq; Type: SEQUENCE; Schema: migrations_with_change; Owner: - 45 | -- 46 | 47 | CREATE SEQUENCE migrations_with_change.users_id_seq 48 | AS integer 49 | START WITH 1 50 | INCREMENT BY 1 51 | NO MINVALUE 52 | NO MAXVALUE 53 | CACHE 1; 54 | 55 | 56 | -- 57 | -- Name: users_id_seq; Type: SEQUENCE OWNED BY; Schema: migrations_with_change; Owner: - 58 | -- 59 | 60 | ALTER SEQUENCE migrations_with_change.users_id_seq OWNED BY migrations_with_change.users.id; 61 | 62 | 63 | -- 64 | -- Name: users id; Type: DEFAULT; Schema: migrations_with_change; Owner: - 65 | -- 66 | 67 | ALTER TABLE ONLY migrations_with_change.users ALTER COLUMN id SET DEFAULT nextval('migrations_with_change.users_id_seq'::regclass); 68 | 69 | 70 | -- 71 | -- Name: mig_schema_versions mig_schema_versions_pkey; Type: CONSTRAINT; Schema: migrations_with_change; Owner: - 72 | -- 73 | 74 | ALTER TABLE ONLY migrations_with_change.mig_schema_versions 75 | ADD CONSTRAINT mig_schema_versions_pkey PRIMARY KEY (version); 76 | 77 | 78 | -- 79 | -- Name: users users_pkey; Type: CONSTRAINT; Schema: migrations_with_change; Owner: - 80 | -- 81 | 82 | ALTER TABLE ONLY migrations_with_change.users 83 | ADD CONSTRAINT users_pkey PRIMARY KEY (id); 84 | 85 | 86 | -- 87 | -- Name: idx_users_email; Type: INDEX; Schema: migrations_with_change; Owner: - 88 | -- 89 | 90 | CREATE UNIQUE INDEX idx_users_email ON migrations_with_change.users USING btree (email); 91 | 92 | 93 | -- 94 | -- Name: idx_users_name; Type: INDEX; Schema: migrations_with_change; Owner: - 95 | -- 96 | 97 | CREATE INDEX idx_users_name ON migrations_with_change.users USING btree (name); 98 | 99 | 100 | -- 101 | -- PostgreSQL database dump complete 102 | -- 103 | 104 | -------------------------------------------------------------------------------- /testdata/Test_2e2_postgres/migration_with_change_migrations_with_change/20240518071842_add_index_user_email.snap.sql: -------------------------------------------------------------------------------- 1 | SET statement_timeout = 0; 2 | SET lock_timeout = 0; 3 | SET idle_in_transaction_session_timeout = 0; 4 | SET client_encoding = 'UTF8'; 5 | SET standard_conforming_strings = on; 6 | SELECT pg_catalog.set_config('search_path', '', false); 7 | SET check_function_bodies = false; 8 | SET xmloption = content; 9 | SET client_min_messages = warning; 10 | SET row_security = off; 11 | 12 | -- 13 | -- Name: migrations_with_change; Type: SCHEMA; Schema: -; Owner: - 14 | -- 15 | 16 | CREATE SCHEMA migrations_with_change; 17 | 18 | 19 | SET default_table_access_method = heap; 20 | 21 | -- 22 | -- Name: mig_schema_versions; Type: TABLE; Schema: migrations_with_change; Owner: - 23 | -- 24 | 25 | CREATE TABLE migrations_with_change.mig_schema_versions ( 26 | version text NOT NULL 27 | ); 28 | 29 | 30 | -- 31 | -- Name: users; Type: TABLE; Schema: migrations_with_change; Owner: - 32 | -- 33 | 34 | CREATE TABLE migrations_with_change.users ( 35 | id integer NOT NULL, 36 | name text, 37 | email text, 38 | created_at timestamp(6) without time zone DEFAULT now() NOT NULL, 39 | updated_at timestamp(6) without time zone DEFAULT now() NOT NULL 40 | ); 41 | 42 | 43 | -- 44 | -- Name: users_id_seq; Type: SEQUENCE; Schema: migrations_with_change; Owner: - 45 | -- 46 | 47 | CREATE SEQUENCE migrations_with_change.users_id_seq 48 | AS integer 49 | START WITH 1 50 | INCREMENT BY 1 51 | NO MINVALUE 52 | NO MAXVALUE 53 | CACHE 1; 54 | 55 | 56 | -- 57 | -- Name: users_id_seq; Type: SEQUENCE OWNED BY; Schema: migrations_with_change; Owner: - 58 | -- 59 | 60 | ALTER SEQUENCE migrations_with_change.users_id_seq OWNED BY migrations_with_change.users.id; 61 | 62 | 63 | -- 64 | -- Name: users id; Type: DEFAULT; Schema: migrations_with_change; Owner: - 65 | -- 66 | 67 | ALTER TABLE ONLY migrations_with_change.users ALTER COLUMN id SET DEFAULT nextval('migrations_with_change.users_id_seq'::regclass); 68 | 69 | 70 | -- 71 | -- Name: mig_schema_versions mig_schema_versions_pkey; Type: CONSTRAINT; Schema: migrations_with_change; Owner: - 72 | -- 73 | 74 | ALTER TABLE ONLY migrations_with_change.mig_schema_versions 75 | ADD CONSTRAINT mig_schema_versions_pkey PRIMARY KEY (version); 76 | 77 | 78 | -- 79 | -- Name: users users_pkey; Type: CONSTRAINT; Schema: migrations_with_change; Owner: - 80 | -- 81 | 82 | ALTER TABLE ONLY migrations_with_change.users 83 | ADD CONSTRAINT users_pkey PRIMARY KEY (id); 84 | 85 | 86 | -- 87 | -- Name: idx_users_email; Type: INDEX; Schema: migrations_with_change; Owner: - 88 | -- 89 | 90 | CREATE UNIQUE INDEX idx_users_email ON migrations_with_change.users USING btree (email); 91 | 92 | 93 | -- 94 | -- Name: idx_users_name; Type: INDEX; Schema: migrations_with_change; Owner: - 95 | -- 96 | 97 | CREATE INDEX idx_users_name ON migrations_with_change.users USING btree (name); 98 | 99 | 100 | -- 101 | -- PostgreSQL database dump complete 102 | -- 103 | 104 | -------------------------------------------------------------------------------- /pkg/entrypoint/context.go: -------------------------------------------------------------------------------- 1 | package entrypoint 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | 7 | "github.com/alexisvisco/amigo/pkg/amigo" 8 | "github.com/alexisvisco/amigo/pkg/amigoconfig" 9 | "github.com/alexisvisco/amigo/pkg/utils" 10 | "github.com/alexisvisco/amigo/pkg/utils/events" 11 | "github.com/alexisvisco/amigo/pkg/utils/logger" 12 | "github.com/spf13/cobra" 13 | "gopkg.in/yaml.v3" 14 | ) 15 | 16 | // contextCmd represents the context command 17 | var contextCmd = &cobra.Command{ 18 | Use: "context", 19 | Short: "show the current context yaml file", 20 | Long: `A context is a file inside the amigo folder that contains the flags that you use in the command line. 21 | 22 | Example: 23 | amigo context --dsn "postgres://user:password@host:port/dbname?sslmode=disable" 24 | 25 | This command will create a file $amigo_folder/context.yaml with the content: 26 | dsn: "postgres://user:password@host:port/dbname?sslmode=disable" 27 | `, 28 | Run: wrapCobraFunc(func(cmd *cobra.Command, a amigo.Amigo, args []string) error { 29 | content, err := utils.GetFileContent(filepath.Join(a.Config.AmigoFolderPath, amigoconfig.FileName)) 30 | if err != nil { 31 | return fmt.Errorf("unable to read contexts file: %w", err) 32 | } 33 | 34 | fmt.Println(string(content)) 35 | 36 | return nil 37 | }), 38 | } 39 | 40 | var ContextSetCmd = &cobra.Command{ 41 | Use: "set", 42 | Short: "Set the current context", 43 | Run: wrapCobraFunc(func(cmd *cobra.Command, a amigo.Amigo, args []string) error { 44 | yamlConfig, err := amigoconfig.LoadYamlConfig(filepath.Join(a.Config.AmigoFolderPath, amigoconfig.FileName)) 45 | if err != nil { 46 | return fmt.Errorf("unable to read contexts file: %w", err) 47 | } 48 | 49 | if len(args) == 0 { 50 | return fmt.Errorf("missing context name") 51 | } 52 | 53 | if _, ok := yamlConfig.Contexts[args[0]]; !ok { 54 | return fmt.Errorf("context %s not found", args[0]) 55 | } 56 | 57 | yamlConfig.CurrentContext = args[0] 58 | 59 | file, err := utils.CreateOrOpenFile(filepath.Join(a.Config.AmigoFolderPath, amigoconfig.FileName)) 60 | if err != nil { 61 | return fmt.Errorf("unable to open contexts file: %w", err) 62 | } 63 | defer file.Close() 64 | 65 | err = file.Truncate(0) 66 | if err != nil { 67 | return fmt.Errorf("unable to truncate contexts file: %w", err) 68 | } 69 | 70 | _, err = file.Seek(0, 0) 71 | if err != nil { 72 | return fmt.Errorf("unable to seek file: %w", err) 73 | } 74 | 75 | yamlOut, err := yaml.Marshal(yamlConfig) 76 | if err != nil { 77 | return fmt.Errorf("unable to marshal yaml: %w", err) 78 | } 79 | 80 | _, err = file.WriteString(string(yamlOut)) 81 | if err != nil { 82 | return fmt.Errorf("unable to write contexts file: %w", err) 83 | } 84 | 85 | logger.Info(events.FileModifiedEvent{FileName: filepath.Join(a.Config.AmigoFolderPath, amigoconfig.FileName)}) 86 | logger.Info(events.MessageEvent{Message: "context set to " + args[0]}) 87 | 88 | return nil 89 | }), 90 | } 91 | 92 | func init() { 93 | rootCmd.AddCommand(contextCmd) 94 | contextCmd.AddCommand(ContextSetCmd) 95 | } 96 | -------------------------------------------------------------------------------- /testdata/Test_2e2_postgres/migration_with_classic_migrations_with_classic/20240518071938_custom_seed.snap.sql: -------------------------------------------------------------------------------- 1 | SET statement_timeout = 0; 2 | SET lock_timeout = 0; 3 | SET idle_in_transaction_session_timeout = 0; 4 | SET client_encoding = 'UTF8'; 5 | SET standard_conforming_strings = on; 6 | SELECT pg_catalog.set_config('search_path', '', false); 7 | SET check_function_bodies = false; 8 | SET xmloption = content; 9 | SET client_min_messages = warning; 10 | SET row_security = off; 11 | 12 | -- 13 | -- Name: migrations_with_classic; Type: SCHEMA; Schema: -; Owner: - 14 | -- 15 | 16 | CREATE SCHEMA migrations_with_classic; 17 | 18 | 19 | SET default_table_access_method = heap; 20 | 21 | -- 22 | -- Name: mig_schema_versions; Type: TABLE; Schema: migrations_with_classic; Owner: - 23 | -- 24 | 25 | CREATE TABLE migrations_with_classic.mig_schema_versions ( 26 | version text NOT NULL 27 | ); 28 | 29 | 30 | -- 31 | -- Name: users; Type: TABLE; Schema: migrations_with_classic; Owner: - 32 | -- 33 | 34 | CREATE TABLE migrations_with_classic.users ( 35 | id integer NOT NULL, 36 | name text, 37 | email text, 38 | created_at timestamp(6) without time zone DEFAULT now() NOT NULL, 39 | updated_at timestamp(6) without time zone DEFAULT now() NOT NULL 40 | ); 41 | 42 | 43 | -- 44 | -- Name: users_id_seq; Type: SEQUENCE; Schema: migrations_with_classic; Owner: - 45 | -- 46 | 47 | CREATE SEQUENCE migrations_with_classic.users_id_seq 48 | AS integer 49 | START WITH 1 50 | INCREMENT BY 1 51 | NO MINVALUE 52 | NO MAXVALUE 53 | CACHE 1; 54 | 55 | 56 | -- 57 | -- Name: users_id_seq; Type: SEQUENCE OWNED BY; Schema: migrations_with_classic; Owner: - 58 | -- 59 | 60 | ALTER SEQUENCE migrations_with_classic.users_id_seq OWNED BY migrations_with_classic.users.id; 61 | 62 | 63 | -- 64 | -- Name: users id; Type: DEFAULT; Schema: migrations_with_classic; Owner: - 65 | -- 66 | 67 | ALTER TABLE ONLY migrations_with_classic.users ALTER COLUMN id SET DEFAULT nextval('migrations_with_classic.users_id_seq'::regclass); 68 | 69 | 70 | -- 71 | -- Name: mig_schema_versions mig_schema_versions_pkey; Type: CONSTRAINT; Schema: migrations_with_classic; Owner: - 72 | -- 73 | 74 | ALTER TABLE ONLY migrations_with_classic.mig_schema_versions 75 | ADD CONSTRAINT mig_schema_versions_pkey PRIMARY KEY (version); 76 | 77 | 78 | -- 79 | -- Name: users users_pkey; Type: CONSTRAINT; Schema: migrations_with_classic; Owner: - 80 | -- 81 | 82 | ALTER TABLE ONLY migrations_with_classic.users 83 | ADD CONSTRAINT users_pkey PRIMARY KEY (id); 84 | 85 | 86 | -- 87 | -- Name: idx_users_email; Type: INDEX; Schema: migrations_with_classic; Owner: - 88 | -- 89 | 90 | CREATE UNIQUE INDEX idx_users_email ON migrations_with_classic.users USING btree (email); 91 | 92 | 93 | -- 94 | -- Name: idx_users_name; Type: INDEX; Schema: migrations_with_classic; Owner: - 95 | -- 96 | 97 | CREATE INDEX idx_users_name ON migrations_with_classic.users USING btree (name); 98 | 99 | 100 | -- 101 | -- PostgreSQL database dump complete 102 | -- 103 | 104 | -------------------------------------------------------------------------------- /testdata/Test_2e2_postgres/migration_with_classic_migrations_with_classic/20240518071842_add_index_user_email.snap.sql: -------------------------------------------------------------------------------- 1 | SET statement_timeout = 0; 2 | SET lock_timeout = 0; 3 | SET idle_in_transaction_session_timeout = 0; 4 | SET client_encoding = 'UTF8'; 5 | SET standard_conforming_strings = on; 6 | SELECT pg_catalog.set_config('search_path', '', false); 7 | SET check_function_bodies = false; 8 | SET xmloption = content; 9 | SET client_min_messages = warning; 10 | SET row_security = off; 11 | 12 | -- 13 | -- Name: migrations_with_classic; Type: SCHEMA; Schema: -; Owner: - 14 | -- 15 | 16 | CREATE SCHEMA migrations_with_classic; 17 | 18 | 19 | SET default_table_access_method = heap; 20 | 21 | -- 22 | -- Name: mig_schema_versions; Type: TABLE; Schema: migrations_with_classic; Owner: - 23 | -- 24 | 25 | CREATE TABLE migrations_with_classic.mig_schema_versions ( 26 | version text NOT NULL 27 | ); 28 | 29 | 30 | -- 31 | -- Name: users; Type: TABLE; Schema: migrations_with_classic; Owner: - 32 | -- 33 | 34 | CREATE TABLE migrations_with_classic.users ( 35 | id integer NOT NULL, 36 | name text, 37 | email text, 38 | created_at timestamp(6) without time zone DEFAULT now() NOT NULL, 39 | updated_at timestamp(6) without time zone DEFAULT now() NOT NULL 40 | ); 41 | 42 | 43 | -- 44 | -- Name: users_id_seq; Type: SEQUENCE; Schema: migrations_with_classic; Owner: - 45 | -- 46 | 47 | CREATE SEQUENCE migrations_with_classic.users_id_seq 48 | AS integer 49 | START WITH 1 50 | INCREMENT BY 1 51 | NO MINVALUE 52 | NO MAXVALUE 53 | CACHE 1; 54 | 55 | 56 | -- 57 | -- Name: users_id_seq; Type: SEQUENCE OWNED BY; Schema: migrations_with_classic; Owner: - 58 | -- 59 | 60 | ALTER SEQUENCE migrations_with_classic.users_id_seq OWNED BY migrations_with_classic.users.id; 61 | 62 | 63 | -- 64 | -- Name: users id; Type: DEFAULT; Schema: migrations_with_classic; Owner: - 65 | -- 66 | 67 | ALTER TABLE ONLY migrations_with_classic.users ALTER COLUMN id SET DEFAULT nextval('migrations_with_classic.users_id_seq'::regclass); 68 | 69 | 70 | -- 71 | -- Name: mig_schema_versions mig_schema_versions_pkey; Type: CONSTRAINT; Schema: migrations_with_classic; Owner: - 72 | -- 73 | 74 | ALTER TABLE ONLY migrations_with_classic.mig_schema_versions 75 | ADD CONSTRAINT mig_schema_versions_pkey PRIMARY KEY (version); 76 | 77 | 78 | -- 79 | -- Name: users users_pkey; Type: CONSTRAINT; Schema: migrations_with_classic; Owner: - 80 | -- 81 | 82 | ALTER TABLE ONLY migrations_with_classic.users 83 | ADD CONSTRAINT users_pkey PRIMARY KEY (id); 84 | 85 | 86 | -- 87 | -- Name: idx_users_email; Type: INDEX; Schema: migrations_with_classic; Owner: - 88 | -- 89 | 90 | CREATE UNIQUE INDEX idx_users_email ON migrations_with_classic.users USING btree (email); 91 | 92 | 93 | -- 94 | -- Name: idx_users_name; Type: INDEX; Schema: migrations_with_classic; Owner: - 95 | -- 96 | 97 | CREATE INDEX idx_users_name ON migrations_with_classic.users USING btree (name); 98 | 99 | 100 | -- 101 | -- PostgreSQL database dump complete 102 | -- 103 | 104 | -------------------------------------------------------------------------------- /pkg/schema/pg/exists.go: -------------------------------------------------------------------------------- 1 | package pg 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/alexisvisco/amigo/pkg/schema" 7 | ) 8 | 9 | // ConstraintExist checks if a constraint exists in the Table. 10 | func (p *Schema) ConstraintExist(tableName schema.TableName, constraintName string) bool { 11 | var result bool 12 | query := "SELECT EXISTS(SELECT 1 FROM information_schema.table_constraints WHERE table_name = $1 AND constraint_name = $2 and constraint_schema = $3)" 13 | 14 | row := p.TX.QueryRowContext(p.Context.Context, query, tableName.Name(), constraintName, tableName.Schema()) 15 | if err := row.Scan(&result); err != nil { 16 | p.Context.RaiseError(fmt.Errorf("error while checking if constraint exists: %w", err)) 17 | return false 18 | } 19 | 20 | return result 21 | } 22 | 23 | // ColumnExist checks if a column exists in the Table. 24 | func (p *Schema) ColumnExist(tableName schema.TableName, columnName string) bool { 25 | var result bool 26 | query := "SELECT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_name = $1 AND column_name = $2 and table_schema = $3)" 27 | 28 | row, err := p.TX.QueryContext(p.Context.Context, query, tableName.Name(), columnName, tableName.Schema()) 29 | if err != nil { 30 | p.Context.RaiseError(fmt.Errorf("error while checking if column exists: %w", err)) 31 | return false 32 | } 33 | 34 | if err := row.Scan(&result); err != nil { 35 | p.Context.RaiseError(fmt.Errorf("error while checking if column exists: %w", err)) 36 | return false 37 | } 38 | 39 | return result 40 | } 41 | 42 | // TableExist checks if a table exists in the database. 43 | func (p *Schema) TableExist(tableName schema.TableName) bool { 44 | var result bool 45 | query := "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = $1 AND table_schema = $2)" 46 | 47 | row := p.TX.QueryRowContext(p.Context.Context, query, tableName.Name(), tableName.Schema()) 48 | if err := row.Scan(&result); err != nil { 49 | p.Context.RaiseError(fmt.Errorf("error while checking if table exists: %w", err)) 50 | return false 51 | } 52 | 53 | return result 54 | } 55 | 56 | // IndexExist checks if an index exists in the Table. 57 | func (p *Schema) IndexExist(tableName schema.TableName, indexName string) bool { 58 | var result bool 59 | query := "SELECT EXISTS(SELECT 1 FROM pg_indexes WHERE tablename = $1 AND indexname = $2 and schemaname = $3)" 60 | 61 | row := p.TX.QueryRowContext(p.Context.Context, query, tableName.Name(), indexName, tableName.Schema()) 62 | if err := row.Scan(&result); err != nil { 63 | p.Context.RaiseError(fmt.Errorf("error while checking if index exists: %w", err)) 64 | return false 65 | } 66 | 67 | return result 68 | } 69 | 70 | func (p *Schema) PrimaryKeyExist(tableName schema.TableName) bool { 71 | var result bool 72 | query := "SELECT EXISTS(SELECT 1 FROM information_schema.table_constraints WHERE table_name = $1 AND constraint_type = 'PRIMARY KEY')" 73 | 74 | row := p.TX.QueryRowContext(context.Background(), query, tableName.Name()) 75 | if err := row.Scan(&result); err != nil { 76 | p.Context.RaiseError(fmt.Errorf("error while checking if primary key exists: %w", err)) 77 | return false 78 | } 79 | 80 | return result 81 | } 82 | -------------------------------------------------------------------------------- /testdata/Test_2e2_postgres/migration_with_change_migrations_with_change/20240527192300_enum.snap.sql: -------------------------------------------------------------------------------- 1 | SET statement_timeout = 0; 2 | SET lock_timeout = 0; 3 | SET idle_in_transaction_session_timeout = 0; 4 | SET client_encoding = 'UTF8'; 5 | SET standard_conforming_strings = on; 6 | SELECT pg_catalog.set_config('search_path', '', false); 7 | SET check_function_bodies = false; 8 | SET xmloption = content; 9 | SET client_min_messages = warning; 10 | SET row_security = off; 11 | 12 | -- 13 | -- Name: migrations_with_change; Type: SCHEMA; Schema: -; Owner: - 14 | -- 15 | 16 | CREATE SCHEMA migrations_with_change; 17 | 18 | 19 | -- 20 | -- Name: status; Type: TYPE; Schema: migrations_with_change; Owner: - 21 | -- 22 | 23 | CREATE TYPE migrations_with_change.status AS ENUM ( 24 | 'active', 25 | 'inactive' 26 | ); 27 | 28 | 29 | SET default_table_access_method = heap; 30 | 31 | -- 32 | -- Name: mig_schema_versions; Type: TABLE; Schema: migrations_with_change; Owner: - 33 | -- 34 | 35 | CREATE TABLE migrations_with_change.mig_schema_versions ( 36 | version text NOT NULL 37 | ); 38 | 39 | 40 | -- 41 | -- Name: users; Type: TABLE; Schema: migrations_with_change; Owner: - 42 | -- 43 | 44 | CREATE TABLE migrations_with_change.users ( 45 | id integer NOT NULL, 46 | name text, 47 | email text, 48 | created_at timestamp(6) without time zone DEFAULT now() NOT NULL, 49 | updated_at timestamp(6) without time zone DEFAULT now() NOT NULL 50 | ); 51 | 52 | 53 | -- 54 | -- Name: users_id_seq; Type: SEQUENCE; Schema: migrations_with_change; Owner: - 55 | -- 56 | 57 | CREATE SEQUENCE migrations_with_change.users_id_seq 58 | AS integer 59 | START WITH 1 60 | INCREMENT BY 1 61 | NO MINVALUE 62 | NO MAXVALUE 63 | CACHE 1; 64 | 65 | 66 | -- 67 | -- Name: users_id_seq; Type: SEQUENCE OWNED BY; Schema: migrations_with_change; Owner: - 68 | -- 69 | 70 | ALTER SEQUENCE migrations_with_change.users_id_seq OWNED BY migrations_with_change.users.id; 71 | 72 | 73 | -- 74 | -- Name: users id; Type: DEFAULT; Schema: migrations_with_change; Owner: - 75 | -- 76 | 77 | ALTER TABLE ONLY migrations_with_change.users ALTER COLUMN id SET DEFAULT nextval('migrations_with_change.users_id_seq'::regclass); 78 | 79 | 80 | -- 81 | -- Name: mig_schema_versions mig_schema_versions_pkey; Type: CONSTRAINT; Schema: migrations_with_change; Owner: - 82 | -- 83 | 84 | ALTER TABLE ONLY migrations_with_change.mig_schema_versions 85 | ADD CONSTRAINT mig_schema_versions_pkey PRIMARY KEY (version); 86 | 87 | 88 | -- 89 | -- Name: users users_pkey; Type: CONSTRAINT; Schema: migrations_with_change; Owner: - 90 | -- 91 | 92 | ALTER TABLE ONLY migrations_with_change.users 93 | ADD CONSTRAINT users_pkey PRIMARY KEY (id); 94 | 95 | 96 | -- 97 | -- Name: idx_users_email; Type: INDEX; Schema: migrations_with_change; Owner: - 98 | -- 99 | 100 | CREATE UNIQUE INDEX idx_users_email ON migrations_with_change.users USING btree (email); 101 | 102 | 103 | -- 104 | -- Name: idx_users_name; Type: INDEX; Schema: migrations_with_change; Owner: - 105 | -- 106 | 107 | CREATE INDEX idx_users_name ON migrations_with_change.users USING btree (name); 108 | 109 | 110 | -- 111 | -- PostgreSQL database dump complete 112 | -- 113 | 114 | -------------------------------------------------------------------------------- /testdata/Test_2e2_postgres/migration_with_classic_migrations_with_classic/20240527192355_enum.snap.sql: -------------------------------------------------------------------------------- 1 | SET statement_timeout = 0; 2 | SET lock_timeout = 0; 3 | SET idle_in_transaction_session_timeout = 0; 4 | SET client_encoding = 'UTF8'; 5 | SET standard_conforming_strings = on; 6 | SELECT pg_catalog.set_config('search_path', '', false); 7 | SET check_function_bodies = false; 8 | SET xmloption = content; 9 | SET client_min_messages = warning; 10 | SET row_security = off; 11 | 12 | -- 13 | -- Name: migrations_with_classic; Type: SCHEMA; Schema: -; Owner: - 14 | -- 15 | 16 | CREATE SCHEMA migrations_with_classic; 17 | 18 | 19 | -- 20 | -- Name: status; Type: TYPE; Schema: migrations_with_classic; Owner: - 21 | -- 22 | 23 | CREATE TYPE migrations_with_classic.status AS ENUM ( 24 | 'active', 25 | 'inactive' 26 | ); 27 | 28 | 29 | SET default_table_access_method = heap; 30 | 31 | -- 32 | -- Name: mig_schema_versions; Type: TABLE; Schema: migrations_with_classic; Owner: - 33 | -- 34 | 35 | CREATE TABLE migrations_with_classic.mig_schema_versions ( 36 | version text NOT NULL 37 | ); 38 | 39 | 40 | -- 41 | -- Name: users; Type: TABLE; Schema: migrations_with_classic; Owner: - 42 | -- 43 | 44 | CREATE TABLE migrations_with_classic.users ( 45 | id integer NOT NULL, 46 | name text, 47 | email text, 48 | created_at timestamp(6) without time zone DEFAULT now() NOT NULL, 49 | updated_at timestamp(6) without time zone DEFAULT now() NOT NULL 50 | ); 51 | 52 | 53 | -- 54 | -- Name: users_id_seq; Type: SEQUENCE; Schema: migrations_with_classic; Owner: - 55 | -- 56 | 57 | CREATE SEQUENCE migrations_with_classic.users_id_seq 58 | AS integer 59 | START WITH 1 60 | INCREMENT BY 1 61 | NO MINVALUE 62 | NO MAXVALUE 63 | CACHE 1; 64 | 65 | 66 | -- 67 | -- Name: users_id_seq; Type: SEQUENCE OWNED BY; Schema: migrations_with_classic; Owner: - 68 | -- 69 | 70 | ALTER SEQUENCE migrations_with_classic.users_id_seq OWNED BY migrations_with_classic.users.id; 71 | 72 | 73 | -- 74 | -- Name: users id; Type: DEFAULT; Schema: migrations_with_classic; Owner: - 75 | -- 76 | 77 | ALTER TABLE ONLY migrations_with_classic.users ALTER COLUMN id SET DEFAULT nextval('migrations_with_classic.users_id_seq'::regclass); 78 | 79 | 80 | -- 81 | -- Name: mig_schema_versions mig_schema_versions_pkey; Type: CONSTRAINT; Schema: migrations_with_classic; Owner: - 82 | -- 83 | 84 | ALTER TABLE ONLY migrations_with_classic.mig_schema_versions 85 | ADD CONSTRAINT mig_schema_versions_pkey PRIMARY KEY (version); 86 | 87 | 88 | -- 89 | -- Name: users users_pkey; Type: CONSTRAINT; Schema: migrations_with_classic; Owner: - 90 | -- 91 | 92 | ALTER TABLE ONLY migrations_with_classic.users 93 | ADD CONSTRAINT users_pkey PRIMARY KEY (id); 94 | 95 | 96 | -- 97 | -- Name: idx_users_email; Type: INDEX; Schema: migrations_with_classic; Owner: - 98 | -- 99 | 100 | CREATE UNIQUE INDEX idx_users_email ON migrations_with_classic.users USING btree (email); 101 | 102 | 103 | -- 104 | -- Name: idx_users_name; Type: INDEX; Schema: migrations_with_classic; Owner: - 105 | -- 106 | 107 | CREATE INDEX idx_users_name ON migrations_with_classic.users USING btree (name); 108 | 109 | 110 | -- 111 | -- PostgreSQL database dump complete 112 | -- 113 | 114 | -------------------------------------------------------------------------------- /docs/docusaurus.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // Note: type annotations allow type checking and IDEs autocompletion 3 | 4 | const lightCodeTheme = require('prism-react-renderer').themes.github; 5 | const darkCodeTheme = require('prism-react-renderer').themes.dracula; 6 | 7 | /** @type {import('@docusaurus/types').Config} */ 8 | const config = { 9 | title: 'amigo docs', 10 | tagline: 'A migration tool in Golang with a powerful API', 11 | favicon: 'img/favicon.ico', 12 | 13 | // Set the production url of your site here 14 | url: 'https://amigo.alexisvis.co', 15 | // Set the // pathname under which your site is served 16 | // For GitHub pages deployment, it is often '//' 17 | baseUrl: '/', 18 | 19 | // GitHub pages deployment config. 20 | // If you aren't using GitHub pages, you don't need these. 21 | organizationName: 'alexisvisco', // Usually your GitHub org/user name. 22 | projectName: 'amigo', // Usually your repo name. 23 | 24 | onBrokenLinks: 'throw', 25 | onBrokenMarkdownLinks: 'warn', 26 | 27 | // Even if you don't use internalization, you can use this field to set useful 28 | // metadata like html lang. For example, if your site is Chinese, you may want 29 | // to replace "en" with "zh-Hans". 30 | i18n: { 31 | defaultLocale: 'en', 32 | locales: ['en'], 33 | }, 34 | 35 | markdown: { 36 | mermaid: true, 37 | }, 38 | themes: ['@docusaurus/theme-mermaid'], 39 | 40 | plugins: [[ require.resolve('docusaurus-lunr-search'), { 41 | languages: ['en'], 42 | indexBaseUrl: true, 43 | highlightResult: true, 44 | }]], 45 | 46 | presets: [ 47 | [ 48 | 'classic', 49 | /** @type {import('@docusaurus/preset-classic').Options} */ 50 | ({ 51 | docs: { 52 | sidebarPath: require.resolve('./sidebars.js'), 53 | routeBasePath: '/', 54 | // Please change this to your repo. 55 | // Remove this to remove the "edit this page" links. 56 | editUrl: 57 | 'https://github.com/alexisvisco/amigo/tree/main/docs/', 58 | }, 59 | blog: false, 60 | theme: { 61 | customCss: require.resolve('./src/css/custom.css'), 62 | }, 63 | }), 64 | ], 65 | ], 66 | 67 | themeConfig: 68 | /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ 69 | ({ 70 | // Replace with your project's social card 71 | image: 'img/social-card.png', 72 | navbar: { 73 | logo: { 74 | alt: 'Mig Logo', 75 | src: 'img/logo.svg', 76 | }, 77 | items: [ 78 | { 79 | type: 'docSidebar', 80 | sidebarId: 'tutorialSidebar', 81 | position: 'left', 82 | label: 'Docs', 83 | }, 84 | { 85 | href: 'https://github.com/alexisvisco/amigo', 86 | label: 'GitHub', 87 | position: 'right', 88 | }, 89 | ], 90 | }, 91 | footer: { 92 | style: 'dark', 93 | copyright: `Copyright © ${new Date().getFullYear()} Alexis Viscogliosi, Built with Docusaurus.`, 94 | }, 95 | prism: { 96 | theme: lightCodeTheme, 97 | darkTheme: darkCodeTheme, 98 | }, 99 | }), 100 | }; 101 | 102 | module.exports = config; 103 | -------------------------------------------------------------------------------- /pkg/amigo/gen_migrations_file.go: -------------------------------------------------------------------------------- 1 | package amigo 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | "sort" 9 | "time" 10 | 11 | "github.com/alexisvisco/amigo/pkg/templates" 12 | "github.com/alexisvisco/amigo/pkg/utils" 13 | ) 14 | 15 | // GenerateMigrationsFiles generate the migrations file in the migrations folder 16 | // It's used to keep track of all migrations 17 | func (a Amigo) GenerateMigrationsFiles(writer io.Writer) error { 18 | migrationFiles, keys, err := a.getMigrationFiles(true) 19 | if err != nil { 20 | return err 21 | } 22 | 23 | var migrations []string 24 | var mustImportSchemaPackage *string 25 | for _, k := range keys { 26 | if migrationFiles[k].isSQL { 27 | // schema.NewSQLMigration[*pg.Schema](sqlMigrationsFS, "20240602081806_drop_index.sql", "2024-06-02T10:18:06+02:00", "---- down:"), 28 | line := fmt.Sprintf("schema.NewSQLMigration[%s](sqlMigrationsFS, \"%s\", \"%s\", \"%s\")", 29 | a.Driver.StructName(), 30 | migrationFiles[k].fulName, 31 | k.Format(time.RFC3339), 32 | a.Config.Create.SQLSeparator, 33 | ) 34 | 35 | migrations = append(migrations, line) 36 | 37 | if mustImportSchemaPackage == nil { 38 | v := a.Driver.PackageSchemaPath() 39 | mustImportSchemaPackage = &v 40 | } 41 | } else { 42 | migrations = append(migrations, fmt.Sprintf("&%s{}", utils.MigrationStructName(k, migrationFiles[k].Name))) 43 | 44 | } 45 | } 46 | 47 | content, err := templates.GetMigrationsTemplate(templates.MigrationsData{ 48 | Package: a.Config.MigrationPackageName, 49 | Migrations: migrations, 50 | ImportSchemaPackage: mustImportSchemaPackage, 51 | }) 52 | 53 | if err != nil { 54 | return fmt.Errorf("unable to get migrations template: %w", err) 55 | } 56 | 57 | _, err = writer.Write([]byte(content)) 58 | if err != nil { 59 | return fmt.Errorf("unable to write migrations file: %w", err) 60 | } 61 | 62 | return nil 63 | } 64 | 65 | type migrationFile struct { 66 | Name string 67 | fulName string 68 | isSQL bool 69 | } 70 | 71 | func (a Amigo) getMigrationFiles(ascending bool) (map[time.Time]migrationFile, []time.Time, error) { 72 | migrationFiles := make(map[time.Time]migrationFile) 73 | 74 | // get the list of structs by the file name 75 | err := filepath.Walk(a.Config.MigrationFolder, func(path string, info os.FileInfo, err error) error { 76 | if err != nil { 77 | return err 78 | } 79 | 80 | if !info.IsDir() { 81 | if utils.MigrationFileRegexp.MatchString(info.Name()) { 82 | matches := utils.MigrationFileRegexp.FindStringSubmatch(info.Name()) 83 | fileTime := matches[1] 84 | migrationName := matches[2] 85 | ext := matches[3] 86 | 87 | t, _ := time.Parse(utils.FormatTime, fileTime) 88 | migrationFiles[t] = migrationFile{Name: migrationName, isSQL: ext == "sql", fulName: info.Name()} 89 | } 90 | } 91 | 92 | return nil 93 | }) 94 | if err != nil { 95 | return nil, nil, fmt.Errorf("unable to walk through the migration folder: %w", err) 96 | } 97 | 98 | // sort the files 99 | var keys []time.Time 100 | for k := range migrationFiles { 101 | keys = append(keys, k) 102 | } 103 | 104 | sort.Slice(keys, func(i, j int) bool { 105 | if ascending { 106 | return keys[i].Unix() < keys[j].Unix() 107 | } else { 108 | return keys[i].Unix() > keys[j].Unix() 109 | } 110 | }) 111 | 112 | return migrationFiles, keys, nil 113 | } 114 | -------------------------------------------------------------------------------- /pkg/entrypoint/migration.go: -------------------------------------------------------------------------------- 1 | package entrypoint 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/alexisvisco/amigo/pkg/amigo" 9 | "github.com/alexisvisco/amigo/pkg/amigoconfig" 10 | "github.com/alexisvisco/amigo/pkg/types" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | // migrateCmd represents the up command 15 | var migrateCmd = &cobra.Command{ 16 | Use: "migrate", 17 | Short: "Apply the database", 18 | Run: wrapCobraFunc(func(cmd *cobra.Command, am amigo.Amigo, args []string) error { 19 | if err := config.ValidateDSN(); err != nil { 20 | return err 21 | } 22 | 23 | db, migrations, err := provider(*am.Config) 24 | if err != nil { 25 | return fmt.Errorf("unable to get provided resources from main: %w", err) 26 | } 27 | 28 | ctx, cancelFunc := context.WithTimeout(context.Background(), am.Config.Migration.Timeout) 29 | defer cancelFunc() 30 | 31 | err = am.RunMigrations(amigo.RunMigrationParams{ 32 | DB: db, 33 | Direction: types.MigrationDirectionUp, 34 | Migrations: migrations, 35 | LogOutput: os.Stdout, 36 | Context: ctx, 37 | }) 38 | 39 | if err != nil { 40 | return fmt.Errorf("failed to migrate database: %w", err) 41 | } 42 | 43 | return nil 44 | }), 45 | } 46 | 47 | // rollbackCmd represents the down command 48 | var rollbackCmd = &cobra.Command{ 49 | Use: "rollback", 50 | Short: "Rollback the database", 51 | Run: wrapCobraFunc(func(cmd *cobra.Command, am amigo.Amigo, args []string) error { 52 | if err := config.ValidateDSN(); err != nil { 53 | return err 54 | } 55 | 56 | db, migrations, err := provider(*am.Config) 57 | if err != nil { 58 | return fmt.Errorf("unable to get provided resources from main: %w", err) 59 | } 60 | 61 | ctx, cancelFunc := context.WithTimeout(context.Background(), am.Config.Migration.Timeout) 62 | defer cancelFunc() 63 | 64 | err = am.RunMigrations(amigo.RunMigrationParams{ 65 | DB: db, 66 | Direction: types.MigrationDirectionDown, 67 | Migrations: migrations, 68 | LogOutput: os.Stdout, 69 | Context: ctx, 70 | }) 71 | 72 | if err != nil { 73 | return fmt.Errorf("failed to migrate database: %w", err) 74 | } 75 | 76 | return nil 77 | }), 78 | } 79 | 80 | func init() { 81 | rootCmd.AddCommand(rollbackCmd) 82 | rootCmd.AddCommand(migrateCmd) 83 | 84 | registerBase := func(cmd *cobra.Command, m *amigoconfig.MigrationConfig) { 85 | cmd.Flags().StringVar(&m.Version, "version", "", 86 | "Apply a specific version format: 20240502083700 or 20240502083700_name.go") 87 | cmd.Flags().BoolVar(&m.DryRun, "dry-run", false, "Run the migrations without applying them") 88 | cmd.Flags().BoolVar(&m.ContinueOnError, "continue-on-error", false, 89 | "Will not rollback the migration if an error occurs") 90 | cmd.Flags().DurationVar(&m.Timeout, "timeout", amigoconfig.DefaultTimeout, "The timeout for the migration") 91 | cmd.Flags().BoolVarP(&m.DumpSchemaAfter, "dump-schema-after", "d", false, 92 | "Dump schema after migrate/rollback (not compatible with --use-schema-dump)") 93 | } 94 | 95 | registerBase(migrateCmd, config.Migration) 96 | migrateCmd.Flags().BoolVar(&config.Migration.UseSchemaDump, "use-schema-dump", false, 97 | "Use the schema file to apply the migration (for fresh install without any migration)") 98 | 99 | registerBase(rollbackCmd, config.Migration) 100 | rollbackCmd.Flags().IntVar(&config.Migration.Steps, "steps", 1, "The number of steps to rollback") 101 | 102 | } 103 | -------------------------------------------------------------------------------- /pkg/utils/dblog/handler.go: -------------------------------------------------------------------------------- 1 | package dblog 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/alecthomas/chroma/v2/quick" 7 | "github.com/alexisvisco/amigo/pkg/utils/colors" 8 | "github.com/alexisvisco/amigo/pkg/utils/events" 9 | "github.com/alexisvisco/amigo/pkg/utils/logger" 10 | sqldblogger "github.com/simukti/sqldb-logger" 11 | "strings" 12 | "time" 13 | ) 14 | 15 | type DatabaseLogger interface { 16 | Log(context.Context, sqldblogger.Level, string, map[string]interface{}) 17 | Record(func()) string 18 | SetRecord(bool) 19 | FormatRecords() string 20 | ToggleLogger(bool) 21 | } 22 | 23 | type Handler struct { 24 | record bool 25 | log bool 26 | queries []string 27 | params [][]any 28 | syntaxHighlighting bool 29 | } 30 | 31 | func NewHandler(syntaxHighlighting bool) *Handler { 32 | return &Handler{ 33 | syntaxHighlighting: syntaxHighlighting, 34 | } 35 | } 36 | 37 | func (l *Handler) Record(f func()) string { 38 | l.record = true 39 | l.queries = nil 40 | l.params = nil 41 | f() 42 | l.record = false 43 | 44 | str := l.FormatRecords() 45 | 46 | return str 47 | } 48 | 49 | func (l *Handler) ToggleLogger(b bool) { 50 | l.log = b 51 | } 52 | 53 | func (l *Handler) FormatRecords() string { 54 | str := "" 55 | 56 | for i, query := range l.queries { 57 | str += query 58 | if l.params[i] != nil { 59 | str += "\n[" 60 | for j, param := range l.params[i] { 61 | if j > 0 { 62 | str += ", " 63 | } 64 | str += fmt.Sprintf("%v", param) 65 | } 66 | str += "]\n" 67 | } 68 | str += "\n" 69 | } 70 | return str 71 | } 72 | 73 | func (l *Handler) Reset() { 74 | l.queries = nil 75 | l.params = nil 76 | } 77 | 78 | func (l *Handler) SetRecord(v bool) { 79 | l.record = v 80 | } 81 | 82 | func (l *Handler) Log(_ context.Context, _ sqldblogger.Level, _ string, data map[string]interface{}) { 83 | if !l.log { 84 | return 85 | } 86 | 87 | if log, ok := data["query"]; ok && l.record { 88 | l.queries = append(l.queries, log.(string)) 89 | 90 | if args, ok := data["args"]; ok { 91 | l.params = append(l.params, args.([]any)) 92 | } else { 93 | l.params = append(l.params, nil) 94 | } 95 | } 96 | 97 | mayDuration := data["duration"] 98 | mayQuery := data["query"] 99 | mayArgs := data["args"] 100 | 101 | s := &strings.Builder{} 102 | 103 | if mayQuery == nil || mayQuery.(string) == "" { 104 | return 105 | } 106 | 107 | if l.syntaxHighlighting { 108 | durLenght := 0 109 | if mayDuration != nil { 110 | str := fmt.Sprintf("(%s) ", time.Millisecond*time.Duration(mayDuration.(float64))) 111 | s.WriteString(colors.Magenta(str)) 112 | durLenght = len(str) 113 | } 114 | 115 | if mayQuery != nil { 116 | if err := quick.Highlight(s, strings.ReplaceAll(mayQuery.(string), "\n", " "), "sql", "terminal256", 117 | "native"); err != nil { 118 | return 119 | } 120 | } 121 | 122 | if mayArgs != nil { 123 | s.WriteString("\n") 124 | padding := strings.Repeat(" ", durLenght) 125 | s.WriteString(colors.Teal(padding + fmt.Sprintf("args: %v", mayArgs))) 126 | } 127 | 128 | } else { 129 | if mayDuration != nil { 130 | s.WriteString(fmt.Sprintf("(%s) ", time.Millisecond*time.Duration(mayDuration.(float64)))) 131 | } 132 | 133 | if mayQuery != nil { 134 | s.WriteString(mayQuery.(string)) 135 | } 136 | 137 | if mayArgs != nil { 138 | s.WriteString(fmt.Sprintf("\nargs: %v", mayArgs)) 139 | } 140 | } 141 | 142 | logger.Info(events.SQLQueryEvent{Query: s.String()}) 143 | } 144 | -------------------------------------------------------------------------------- /pkg/types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "path/filepath" 5 | "strings" 6 | ) 7 | 8 | type MigrationDirection string 9 | 10 | const ( 11 | MigrationDirectionUp MigrationDirection = "UP" 12 | MigrationDirectionDown MigrationDirection = "DOWN" 13 | 14 | // MigrationDirectionNotReversible is used to indicate that the migration is reversed or is in a down type. 15 | // This is used to avoid infinite loop when executing a migration. 16 | // This is not a real migration direction. DO NOT ADD IT TO MigrationDirectionValues. 17 | MigrationDirectionNotReversible MigrationDirection = "NOT_REVERSIBLE" 18 | ) 19 | 20 | var MigrationDirectionValues = []MigrationDirection{ 21 | MigrationDirectionUp, 22 | MigrationDirectionDown, 23 | } 24 | 25 | func (m MigrationDirection) String() string { 26 | return strings.ToLower(string(m)) 27 | } 28 | 29 | func (m MigrationDirection) IsValid() bool { 30 | for _, v := range MigrationDirectionValues { 31 | if v == m { 32 | return true 33 | } 34 | } 35 | 36 | return false 37 | } 38 | 39 | type Driver string 40 | 41 | const ( 42 | DriverUnknown Driver = "" 43 | DriverPostgres Driver = "postgres" 44 | DriverSQLite Driver = "sqlite" 45 | ) 46 | 47 | func (d Driver) PackageSchemaPath() string { 48 | switch d { 49 | case DriverPostgres: 50 | return "github.com/alexisvisco/amigo/pkg/schema/pg" 51 | case DriverSQLite: 52 | return "github.com/alexisvisco/amigo/pkg/schema/sqlite" 53 | default: 54 | return "github.com/alexisvisco/amigo/pkg/schema/base" 55 | } 56 | } 57 | 58 | func (d Driver) StructName() string { 59 | switch d { 60 | case DriverPostgres: 61 | return "*pg.Schema" 62 | case DriverSQLite: 63 | return "*sqlite.Schema" 64 | default: 65 | return "*base.Schema" 66 | } 67 | } 68 | 69 | func GetDriver(dsn string) Driver { 70 | switch { 71 | case strings.HasPrefix(dsn, "postgres"): 72 | return DriverPostgres 73 | case strings.HasPrefix(dsn, "sqlite:"): 74 | return DriverSQLite 75 | } 76 | 77 | return DriverUnknown 78 | } 79 | 80 | func (d Driver) PackagePath() string { 81 | switch d { 82 | case DriverPostgres: 83 | return "github.com/jackc/pgx/v5/stdlib" 84 | case DriverSQLite: 85 | return "github.com/mattn/go-sqlite3" 86 | default: 87 | return "your_driver_here" 88 | } 89 | } 90 | 91 | func (d Driver) PackageName() string { 92 | return filepath.Base(d.PackageSchemaPath()) 93 | } 94 | 95 | func (d Driver) String() string { 96 | switch d { 97 | case DriverPostgres: 98 | return "pgx" 99 | case DriverSQLite: 100 | return "sqlite3" 101 | default: 102 | return "your_driver_here" 103 | } 104 | } 105 | 106 | type MigrationFileType string 107 | 108 | const ( 109 | MigrationFileTypeChange MigrationFileType = "change" 110 | MigrationFileTypeClassic MigrationFileType = "classic" 111 | MigrationFileTypeSQL MigrationFileType = "sql" 112 | ) 113 | 114 | var MigrationFileTypeValues = []MigrationFileType{ 115 | MigrationFileTypeChange, 116 | MigrationFileTypeClassic, 117 | } 118 | 119 | func (m MigrationFileType) String() string { 120 | return string(m) 121 | } 122 | 123 | func (m MigrationFileType) IsValid() bool { 124 | for _, v := range MigrationFileTypeValues { 125 | if v == m { 126 | return true 127 | } 128 | } 129 | 130 | return false 131 | } 132 | 133 | type MIGConfig struct { 134 | RootDSN string `yaml:"root_dsn"` 135 | JSON bool `yaml:"json"` 136 | MigrationFolder string `yaml:"migration_folder"` 137 | Package string `yaml:"package"` 138 | SchemaVersionTable string `yaml:"schema_version_table"` 139 | ShellPath string `yaml:"shell_path"` 140 | Verbose bool `yaml:"verbose"` 141 | MIGFolderPath string `yaml:"mig_folder"` 142 | } 143 | --------------------------------------------------------------------------------