diff --git a/.changes/unreleased/added-20251012-201335.yaml b/.changes/unreleased/added-20251012-201335.yaml new file mode 100644 index 0000000..4c2b7c5 --- /dev/null +++ b/.changes/unreleased/added-20251012-201335.yaml @@ -0,0 +1,5 @@ +kind: added +body: Support PostgreSQL as library or CLI +time: 2025-10-12T20:13:35.619382+02:00 +custom: + Issue: "" diff --git a/.changes/unreleased/added-20251013-003617.yaml b/.changes/unreleased/added-20251013-003617.yaml new file mode 100644 index 0000000..141ec29 --- /dev/null +++ b/.changes/unreleased/added-20251013-003617.yaml @@ -0,0 +1,5 @@ +kind: added +body: Support MySQL databases as library or via CLI +time: 2025-10-13T00:36:17.19928+02:00 +custom: + Issue: "" diff --git a/.changes/unreleased/deprecated-20251011-193908.yaml b/.changes/unreleased/deprecated-20251011-193908.yaml new file mode 100644 index 0000000..53b7228 --- /dev/null +++ b/.changes/unreleased/deprecated-20251011-193908.yaml @@ -0,0 +1,5 @@ +kind: deprecated +body: Limit Drift::Migrator to DB::Database types +time: 2025-10-11T19:39:08.945847+02:00 +custom: + Issue: "" diff --git a/.changes/unreleased/improved-20251011-195702.yaml b/.changes/unreleased/improved-20251011-195702.yaml new file mode 100644 index 0000000..ed39649 --- /dev/null +++ b/.changes/unreleased/improved-20251011-195702.yaml @@ -0,0 +1,5 @@ +kind: improved +body: Abstract Migrator SQL operations as Dialect implementation +time: 2025-10-11T19:57:02.013986+02:00 +custom: + Issue: "" diff --git a/.changes/unreleased/improved-20251012-125124.yaml b/.changes/unreleased/improved-20251012-125124.yaml new file mode 100644 index 0000000..13a5d92 --- /dev/null +++ b/.changes/unreleased/improved-20251012-125124.yaml @@ -0,0 +1,5 @@ +kind: improved +body: Optimize rollback plan calculations and memory usage +time: 2025-10-12T12:51:24.699334+02:00 +custom: + Issue: "" diff --git a/.changes/unreleased/internal-20251012-133617.yaml b/.changes/unreleased/internal-20251012-133617.yaml new file mode 100644 index 0000000..139897d --- /dev/null +++ b/.changes/unreleased/internal-20251012-133617.yaml @@ -0,0 +1,5 @@ +kind: internal +body: Adds PostgreSQL container for development/testing +time: 2025-10-12T13:36:17.161299+02:00 +custom: + Issue: "" diff --git a/.changes/unreleased/internal-20251012-151331.yaml b/.changes/unreleased/internal-20251012-151331.yaml new file mode 100644 index 0000000..41edae2 --- /dev/null +++ b/.changes/unreleased/internal-20251012-151331.yaml @@ -0,0 +1,5 @@ +kind: internal +body: Move SQLite3 dependency to only development mode +time: 2025-10-12T15:13:31.081418+02:00 +custom: + Issue: "" diff --git a/.changes/unreleased/internal-20251013-003809.yaml b/.changes/unreleased/internal-20251013-003809.yaml new file mode 100644 index 0000000..99abb92 --- /dev/null +++ b/.changes/unreleased/internal-20251013-003809.yaml @@ -0,0 +1,5 @@ +kind: internal +body: Add MySQL container for local and CI testing +time: 2025-10-13T00:38:09.800223+02:00 +custom: + Issue: "" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 62c4bb1..053c020 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,8 +29,39 @@ jobs: test: runs-on: ubuntu-latest + services: + mysql: + image: mysql:5.7 + env: + MYSQL_DATABASE: drift_test + MYSQL_USER: drift + MYSQL_PASSWORD: drift + MYSQL_ROOT_PASSWORD: drift_root + options: >- + --health-cmd="mysqladmin ping -h localhost" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + ports: + - 3306:3306 + postgres: + image: postgres:18-alpine + env: + POSTGRES_DB: drift_test + POSTGRES_USER: drift + POSTGRES_PASSWORD: drift + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 steps: - uses: actions/checkout@v4 - uses: crystal-lang/install-crystal@v1 - run: shards install - run: crystal spec + env: + MYSQL_DB_URL: mysql://drift:drift@localhost:3306/drift_test + POSTGRES_DB_URL: postgres://drift:drift@localhost:5432/drift_test diff --git a/Makefile b/Makefile index 6cf5312..83e153c 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ export FIXGID # Make `help` the default task .DEFAULT_GOAL := help -.PHONY: build console logs restart setup start stop help +.PHONY: console dev help restart setup stop console: ## start a console session @docker compose exec app sh -i 2>/dev/null || docker compose run --rm app -- sh -i diff --git a/README.md b/README.md index 0ddae0f..a92b66c 100644 --- a/README.md +++ b/README.md @@ -96,11 +96,15 @@ in reverse order using the information on the previously mentioned table. ## Requirements Drift CLI is a standalone, self-contained executable capable of connecting to -SQLite databases. +the following databases (dialects): + +* MySQL +* PostgreSQL +* SQLite3 Drift (as library) only depends on Crystal's [`db`](https://github.com/crystal-lang/crystal-db) common API. To use it with -to specific adapters, you need to add the respective dependencies and require +specific adapters, you need to add the respective dependencies and require them part of your application. See more about in the [library usage](#as-library-crystal-shard) section. @@ -189,7 +193,35 @@ application: require "sqlite3" require "drift" -db = DB.connect "sqlite3:app.db" +db = DB.open "sqlite3:app.db" + +migrator = Drift::Migrator.from_path(db, "database/migrations") +migrator.apply! + +db.close +``` + +For a MySQL database: + +```crystal +require "mysql" +require "drift" + +db = DB.open "mysql://user:password@localhost/dbname" + +migrator = Drift::Migrator.from_path(db, "database/migrations") +migrator.apply! + +db.close +``` + +Or for a PostgreSQL database: + +```crystal +require "pg" +require "drift" + +db = DB.open "postgres://user:password@localhost/dbname" migrator = Drift::Migrator.from_path(db, "database/migrations") migrator.apply! @@ -237,7 +269,7 @@ require "drift" Drift.embed_as("my_migrations", "database/migrations") -db = DB.connect "sqlite3:app.db" +db = DB.open "sqlite3:app.db" migrator = Drift::Migrator.new(db, my_migrations) migrator.apply! @@ -251,6 +283,32 @@ bundling all the migrations found in `database/migrations` directory. When using classes or modules, you can also define instance or class methods by prepending `self.` to the method name to use by Drift. +## Development + +### Running tests + +By default, tests run against all supported databases (MySQL, PostgreSQL, +SQLite3). + +To skip specific databases: + +```console +$ SKIP_MYSQL=true crystal spec # Skip MySQL tests +$ SKIP_POSTGRESQL=true crystal spec # Skip PostgreSQL tests +``` + +Start database services with Docker Compose: + +```console +$ docker compose up -d mysql postgres +``` + +Stop database services: + +```console +$ docker compose down +``` + ## Contribution policy Inspired by [Litestream](https://github.com/benbjohnson/litestream) and diff --git a/compose.yaml b/compose.yaml index 3f5f793..3c47696 100644 --- a/compose.yaml +++ b/compose.yaml @@ -3,15 +3,57 @@ services: image: ghcr.io/luislavena/hydrofoil-crystal:${CRYSTAL_VERSION:-1.16} command: overmind start -f Procfile.dev working_dir: /workspace/${COMPOSE_PROJECT_NAME} + depends_on: + - mysql + - postgres environment: # Workaround Overmind socket issues with Vite # Ref: https://github.com/luislavena/hydrofoil-crystal/issues/66 - OVERMIND_SOCKET=/tmp/overmind.sock # Disable Shards' postinstall - SHARDS_OPTS=--skip-postinstall + # Test DBs + - MYSQL_DB_URL=mysql://drift:drift@mysql:3306/drift_test + - POSTGRES_DB_URL=postgres://drift:drift@postgres:5432/drift_test # Set these env variables using `export FIXUID=$(id -u) FIXGID=$(id -g)` user: ${FIXUID:-1000}:${FIXGID:-1000} volumes: - .:/workspace/${COMPOSE_PROJECT_NAME}:cached + + mysql: + image: mysql:8.0-oracle + command: --default-authentication-plugin=mysql_native_password + environment: + MYSQL_DATABASE: drift_test + MYSQL_USER: drift + MYSQL_PASSWORD: drift + MYSQL_ROOT_PASSWORD: drift_root + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 5s + timeout: 5s + retries: 3 + volumes: + - mysql:/var/lib/mysql + + postgres: + image: postgres:18-alpine + environment: + POSTGRES_DB: drift_test + POSTGRES_USER: drift + POSTGRES_PASSWORD: drift + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 5s + timeout: 5s + retries: 3 + volumes: + - postgres:/var/lib/postgresql + +volumes: + mysql: + driver: local + postgres: + driver: local diff --git a/shard.yml b/shard.yml index 5f3cf33..b3380ef 100644 --- a/shard.yml +++ b/shard.yml @@ -11,6 +11,14 @@ dependencies: db: github: crystal-lang/crystal-db version: ~> 0.14.0 +development_dependencies: + mysql: + github: crystal-lang/crystal-mysql + version: ~> 0.17.0 + pg: + github: will/crystal-pg + # FIXME: lock until new crystal-pg release + commit: c5b8ac1ac5713fc58f974d8a4327887a0b297594 sqlite3: github: crystal-lang/crystal-sqlite3 version: ~> 0.22.0 diff --git a/spec/drift/dialect_spec.cr b/spec/drift/dialect_spec.cr new file mode 100644 index 0000000..43ccc34 --- /dev/null +++ b/spec/drift/dialect_spec.cr @@ -0,0 +1,50 @@ +# Copyright 2022 Luis Lavena +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "../spec_helper" + +require "mysql" +require "pg" +require "sqlite3" + +describe Drift::Dialect do + describe ".from_db" do + it "detects SQLite3 dialect" do + db = DB.open("sqlite3:%3Amemory%3A") + dialect = Drift::Dialect.from_db(db) + + dialect.should be_a(Drift::Dialect::SQLite3) + + db.close + end + + it "detects PostgreSQL dialect" do + db = DB.open(ENV["POSTGRES_DB_URL"]? || "postgres://drift:drift@localhost:5432/drift") + dialect = Drift::Dialect.from_db(db) + + dialect.should be_a(Drift::Dialect::PostgreSQL) + + db.close + end + + it "detects MySQL dialect" do + db = DB.open(ENV["MYSQL_DB_URL"]? || "mysql://drift:drift@localhost:3306/drift_test") + dialect = Drift::Dialect.from_db(db) + + dialect.should be_a(Drift::Dialect::MySQL) + + db.close + end + end +end diff --git a/spec/drift/migrator_spec.cr b/spec/drift/migrator_spec.cr index 4cf8f80..9acaf12 100644 --- a/spec/drift/migrator_spec.cr +++ b/spec/drift/migrator_spec.cr @@ -14,8 +14,26 @@ require "../spec_helper" +require "mysql" +require "pg" require "sqlite3" +# Configuration for dialects to test +DIALECTS = { + sqlite3: { + url: ENV["SQLITE3_DB_URL"]? || "sqlite3:%3Amemory%3A", + needs_cleanup: false, + }, + postgresql: { + url: ENV["POSTGRES_DB_URL"]? || "postgres://drift:drift@localhost:5432/drift_test", + needs_cleanup: true, + }, + mysql: { + url: ENV["MYSQL_DB_URL"]? || "mysql://drift:drift@localhost:3306/drift_test", + needs_cleanup: true, + }, +} + private struct MigrationEntry include DB::Serializable @@ -26,15 +44,27 @@ private struct MigrationEntry end private def memory_db - DB.connect "sqlite3:%3Amemory%3A" + DB.open "sqlite3:%3Amemory%3A" end private def create_dummy(db) - db.exec("CREATE TABLE IF NOT EXISTS dummy (id INTEGER PRIMARY KEY NOT NULL, value INTEGER NOT NULL);") + case Drift::Dialect.from_db(db) + when Drift::Dialect::SQLite3 + db.exec("CREATE TABLE IF NOT EXISTS dummy (id INTEGER PRIMARY KEY NOT NULL, value INTEGER NOT NULL);") + when Drift::Dialect::PostgreSQL + db.exec("CREATE TABLE IF NOT EXISTS dummy (id BIGSERIAL PRIMARY KEY NOT NULL, value BIGINT NOT NULL);") + when Drift::Dialect::MySQL + db.exec("CREATE TABLE IF NOT EXISTS dummy (id BIGINT PRIMARY KEY AUTO_INCREMENT NOT NULL, value BIGINT NOT NULL);") + end end private def fake_migration(db, id = 1, batch = 1) - db.exec("INSERT INTO drift_migrations (id, batch, applied_at, duration_ns) VALUES (?, ?, ?, ?);", id, batch, Time.utc, 100000) + case Drift::Dialect.from_db(db) + when Drift::Dialect::SQLite3, Drift::Dialect::MySQL + db.exec("INSERT INTO drift_migrations (id, batch, applied_at, duration_ns) VALUES (?, ?, ?, ?);", id, batch, Time.utc, 100000) + when Drift::Dialect::PostgreSQL + db.exec("INSERT INTO drift_migrations (id, batch, applied_at, duration_ns) VALUES ($1, $2, $3, $4);", id, batch, Time.utc, 100000) + end end private def sample_context @@ -48,768 +78,993 @@ private def sample_context ctx end -private def ready_migrator(db = memory_db) +private def cleanup_tables(db) + db.exec("DROP TABLE IF EXISTS drift_migrations CASCADE;") + db.exec("DROP TABLE IF EXISTS dummy CASCADE;") +end + +private def ready_migrator(db) ctx = sample_context migrator = Drift::Migrator.new(db, ctx) migrator end -private def prepared_migrator - db = memory_db +private def prepared_migrator(db) migrator = ready_migrator(db) migrator.prepare! {db, migrator} end +# Macro to run tests for each dialect +macro for_each_dialect + {% for name, config in DIALECTS %} + {% skip_postgresql = env("SKIP_POSTGRESQL") == "true" %} + {% skip_mysql = env("SKIP_MYSQL") == "true" %} + {% unless (name.id == "postgresql" && skip_postgresql) || (name.id == "mysql" && skip_mysql) %} + describe "with {{ name.id }}" do + # Proc to get a clean DB connection for this dialect + dialect_db = ->() { + db = DB.open({{ config[:url] }}) + {% if config[:needs_cleanup] %} + cleanup_tables(db) + {% end %} + db + } + + {{ yield }} + end + {% end %} + {% end %} +end + describe Drift::Migrator do describe ".new" do it "reuses an existing context" do + db = memory_db ctx = sample_context - migrator = Drift::Migrator.new(memory_db, ctx) + migrator = Drift::Migrator.new(db, ctx) migrator.context.should be(ctx) + + db.close end end describe ".from_path" do it "sets up a new context using a given path" do - migrator = Drift::Migrator.from_path(memory_db, fixture_path("sequence")) + db = memory_db + migrator = Drift::Migrator.from_path(db, fixture_path("sequence")) migrator.context.ids.should eq([ 20211219152312, 20211220182717, ]) + + db.close end end - describe "#prepared?" do - it "returns false on an clean database" do - migrator = ready_migrator + for_each_dialect do + describe "#prepared?" do + it "returns false on an clean database" do + db = dialect_db.call + migrator = ready_migrator(db) - migrator.prepared?.should be_false - end + migrator.prepared?.should be_false - it "returns true on a prepared database" do - db = memory_db - # dummy table - db.exec "CREATE TABLE drift_migrations (id INTEGER PRIMARY KEY, dummy TEXT);" - migrator = ready_migrator(db) + db.close + end - migrator.prepared?.should be_true - end - end + it "returns true on a prepared database" do + db = dialect_db.call + # dummy table + db.exec "CREATE TABLE drift_migrations (id INTEGER PRIMARY KEY, dummy TEXT);" + migrator = ready_migrator(db) - describe "#prepare!" do - it "prepares the migration table" do - db = memory_db - migrator = ready_migrator(db) + migrator.prepared?.should be_true - migrator.prepare! - db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(0) + db.close + end end - it "does noop if database is already prepared" do - migrator = ready_migrator + describe "#prepare!" do + it "prepares the migration table" do + db = dialect_db.call + migrator = ready_migrator(db) - migrator.prepare! - migrator.prepare! - end - end + migrator.prepare! + db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(0) - describe "#applied?" do - it "returns false when migration was not applied" do - _, migrator = prepared_migrator + db.close + end - migrator.applied?(1).should be_false - end + it "does noop if database is already prepared" do + db = dialect_db.call + migrator = ready_migrator(db) - it "returns true when migration was applied" do - db, migrator = prepared_migrator - fake_migration db + migrator.prepare! + migrator.prepare! - migrator.applied?(1).should be_true + db.close + end end end - describe "#applied_ids" do - it "returns an empty list when no migrations were applied" do - _, migrator = prepared_migrator + for_each_dialect do + describe "#applied?" do + it "returns false when migration was not applied" do + db = dialect_db.call + _, migrator = prepared_migrator(db) - migrator.applied_ids.should be_empty - end + migrator.applied?(1).should be_false - it "returns ordered list of applied migrations" do - db, migrator = prepared_migrator - fake_migration db, 1 - fake_migration db, 2 + db.close + end - ids = migrator.applied_ids - ids.should_not be_empty - ids.should eq([1, 2]) - end + it "returns true when migration was applied" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db - it "returns only known applied migrations" do - db, migrator = prepared_migrator - fake_migration db, 1 - fake_migration db, 5 + migrator.applied?(1).should be_true - ids = migrator.applied_ids - ids.should_not be_empty - ids.should eq([1]) + db.close + end end - end - describe "#apply_plan" do - context "with no migration applied" do - it "returns a list of all migrations" do - _, migrator = prepared_migrator + describe "#applied_ids" do + it "returns an empty list when no migrations were applied" do + db = dialect_db.call + _, migrator = prepared_migrator(db) - ids = migrator.apply_plan - ids.should_not be_empty - ids.should eq([1, 2, 3, 4]) + migrator.applied_ids.should be_empty + + db.close end - end - context "with some applied migrations" do - it "returns a list of non-applied migrations" do - db, migrator = prepared_migrator + it "returns ordered list of applied migrations" do + db = dialect_db.call + _, migrator = prepared_migrator(db) fake_migration db, 1 - fake_migration db, 3 + fake_migration db, 2 - ids = migrator.apply_plan + ids = migrator.applied_ids ids.should_not be_empty - ids.should eq([2, 4]) + ids.should eq([1, 2]) + + db.close end - end - context "with applied migrations not locally available" do - it "returns the list of only local non-applied ones" do - db, migrator = prepared_migrator + it "returns only known applied migrations" do + db = dialect_db.call + _, migrator = prepared_migrator(db) fake_migration db, 1 fake_migration db, 5 - ids = migrator.apply_plan - ids.should eq([2, 3, 4]) + ids = migrator.applied_ids + ids.should_not be_empty + ids.should eq([1]) + + db.close end end end - describe "#apply(id)" do - context "with no existing migrations applied" do - it "records the migration was applied" do - db, migrator = prepared_migrator + for_each_dialect do + describe "#apply_plan" do + context "with no migration applied" do + it "returns a list of all migrations" do + db = dialect_db.call + _, migrator = prepared_migrator(db) - db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(0) - migrator.apply(1) - db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(1) - - # id, batch, applied_at, duration_ns - result = db.query_one("SELECT id, batch, applied_at, duration_ns FROM drift_migrations WHERE id = ? LIMIT 1;", 1, as: MigrationEntry) + ids = migrator.apply_plan + ids.should_not be_empty + ids.should eq([1, 2, 3, 4]) - result.id.should eq(1) - result.batch.should eq(1) - result.applied_at.should be_close(Time.utc, 1.second) - result.duration_ns.should be <= 1.second.total_nanoseconds.to_i64 + db.close + end end - it "applies migration only once" do - db, migrator = prepared_migrator - migrator.apply(1) - migrator.apply(1) - db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(1) + context "with some applied migrations" do + it "returns a list of non-applied migrations" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db, 1 + fake_migration db, 3 + + ids = migrator.apply_plan + ids.should_not be_empty + ids.should eq([2, 4]) + + db.close + end end - it "executes migration statements" do - db, migrator = prepared_migrator - create_dummy db + context "with applied migrations not locally available" do + it "returns the list of only local non-applied ones" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db, 1 + fake_migration db, 5 - migration = migrator.context[1] - migration.add(:migrate, "INSERT INTO dummy (value) VALUES (10);") + ids = migrator.apply_plan + ids.should eq([2, 3, 4]) - db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) - migrator.apply(1) - db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(1) - db.scalar("SELECT MAX(value) FROM dummy;").as(Int64).should eq(10) + db.close + end end + end + end + + for_each_dialect do + describe "#apply(id)" do + context "with no existing migrations applied" do + it "records the migration was applied" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + + db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(0) + migrator.apply(1) + db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(1) + + # id, batch, applied_at, duration_ns + result = db.query_one("SELECT id, batch, applied_at, duration_ns FROM drift_migrations ORDER BY id ASC LIMIT 1;", as: MigrationEntry) + + result.id.should eq(1) + result.batch.should eq(1) + result.applied_at.should be_close(Time.utc, 1.second) + result.duration_ns.should be <= 1.second.total_nanoseconds.to_i64 - it "applies migration within a transaction to avoid partial execution" do - db, migrator = prepared_migrator - create_dummy db + db.close + end - migration = migrator.context[1] - migration.add(:migrate, "INSERT INTO dummy (value) VALUES (10);") - migration.add(:migrate, "INSERT INTO foo (value)") + it "applies migration only once" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + migrator.apply(1) + migrator.apply(1) + db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(1) + + db.close + end - db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) - expect_raises(Exception) do + it "executes migration statements" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + create_dummy db + + migration = migrator.context[1] + migration.add(:migrate, "INSERT INTO dummy (value) VALUES (10);") + + db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) migrator.apply(1) + db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(1) + db.scalar("SELECT MAX(value) FROM dummy;").as(Int64).should eq(10) + + db.close + end + + it "applies migration within a transaction to avoid partial execution" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + create_dummy db + + migration = migrator.context[1] + migration.add(:migrate, "INSERT INTO dummy (value) VALUES (10);") + migration.add(:migrate, "INSERT INTO foo (value);") + + db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) + expect_raises(Exception) do + migrator.apply(1) + end + db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) + db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(0) + + db.close end - db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) - db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(0) end - end - context "with existing migrations applied" do - it "applies other migration as a new batch" do - db, migrator = prepared_migrator - migrator.apply(1) - db.scalar("SELECT MAX(batch) FROM drift_migrations;").as(Int64).should eq(1) + context "with existing migrations applied" do + it "applies other migration as a new batch" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + migrator.apply(1) + db.scalar("SELECT MAX(batch) FROM drift_migrations;").as(Int64).should eq(1) + + migrator.apply(2) + db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(2) + db.scalar("SELECT MAX(batch) FROM drift_migrations;").as(Int64).should eq(2) - migrator.apply(2) - db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(2) - db.scalar("SELECT MAX(batch) FROM drift_migrations;").as(Int64).should eq(2) + db.close + end end end end - describe "#apply(ids)" do - context "with no migrations" do - it "applies multiple migrations as part of the same batch" do - db, migrator = prepared_migrator + for_each_dialect do + describe "#apply(ids)" do + context "with no migrations" do + it "applies multiple migrations as part of the same batch" do + db = dialect_db.call + _, migrator = prepared_migrator(db) - db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(0) - migrator.apply(1, 3) - db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(2) - db.scalar("SELECT MAX(batch) FROM drift_migrations;").as(Int64).should eq(1) - end + db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(0) + migrator.apply(1, 3) + db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(2) + db.scalar("SELECT MAX(batch) FROM drift_migrations;").as(Int64).should eq(1) - it "ignores already applied migration from the list" do - db, migrator = prepared_migrator - fake_migration db - create_dummy db + db.close + end - m1 = migrator.context[1] - m1.add(:migrate, "INSERT INTO dummy (value) VALUES (10);") + it "ignores already applied migration from the list" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db + create_dummy db - db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(1) - migrator.apply(1, 3) - db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(2) - db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) - end + m1 = migrator.context[1] + m1.add(:migrate, "INSERT INTO dummy (value) VALUES (10);") - it "increases batch number when executed multiple times for new migrations" do - db, migrator = prepared_migrator + db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(1) + migrator.apply(1, 3) + db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(2) + db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) - db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(0) - migrator.apply(1, 2) - db.scalar("SELECT MAX(batch) FROM drift_migrations;").as(Int64).should eq(1) - migrator.apply(3, 4) - db.scalar("SELECT MAX(batch) FROM drift_migrations;").as(Int64).should eq(2) - end + db.close + end - it "applies all migrations as transaction to avoid partial execution" do - db, migrator = prepared_migrator - create_dummy db + it "increases batch number when executed multiple times for new migrations" do + db = dialect_db.call + _, migrator = prepared_migrator(db) - m1 = migrator.context[1] - m1.add(:migrate, "INSERT INTO dummy (value) VALUES (10);") + db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(0) + migrator.apply(1, 2) + db.scalar("SELECT MAX(batch) FROM drift_migrations;").as(Int64).should eq(1) + migrator.apply(3, 4) + db.scalar("SELECT MAX(batch) FROM drift_migrations;").as(Int64).should eq(2) - m2 = migrator.context[3] - m2.add(:migrate, "INSERT INTO dummy (value) VALUES (20);") - m2.add(:migrate, "INSERT INTO foo (value)") + db.close + end - expect_raises(Exception) do - migrator.apply(1, 3) + it "applies all migrations as transaction to avoid partial execution" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + create_dummy db + + m1 = migrator.context[1] + m1.add(:migrate, "INSERT INTO dummy (value) VALUES (10);") + + m2 = migrator.context[3] + m2.add(:migrate, "INSERT INTO dummy (value) VALUES (20);") + m2.add(:migrate, "INSERT INTO foo (value)") + + expect_raises(Exception) do + migrator.apply(1, 3) + end + db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(0) + db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) + + db.close end - db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(0) - db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) - end - it "applies repeated migration in list only once" do - db, migrator = prepared_migrator - create_dummy db + it "applies repeated migration in list only once" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + create_dummy db + + migration = migrator.context[1] + migration.add(:migrate, "INSERT INTO dummy (value) VALUES (10);") - migration = migrator.context[1] - migration.add(:migrate, "INSERT INTO dummy (value) VALUES (10);") + migrator.apply(1, 1, 1) + db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(1) + db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(1) - migrator.apply(1, 1, 1) - db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(1) - db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(1) + db.close + end end end end - describe "#rollback(id)" do - context "with migration applied" do - it "removes migration from the list of applied" do - db, migrator = prepared_migrator - fake_migration db + for_each_dialect do + describe "#rollback(id)" do + context "with migration applied" do + it "removes migration from the list of applied" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db - db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(1) - migrator.rollback(1) - db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(0) - end + db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(1) + migrator.rollback(1) + db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(0) - it "executes migration down statements" do - db, migrator = prepared_migrator - fake_migration db - create_dummy db + db.close + end - migration = migrator.context[1] - migration.add(:rollback, "INSERT INTO dummy (value) VALUES (10);") + it "executes migration down statements" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db + create_dummy db - db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) - migrator.rollback(1) - db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(1) - end + migration = migrator.context[1] + migration.add(:rollback, "INSERT INTO dummy (value) VALUES (10);") - it "removes only applied migrations" do - db, migrator = prepared_migrator - fake_migration db - create_dummy db + db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) + migrator.rollback(1) + db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(1) - migration = migrator.context[2] - migration.add(:rollback, "INSERT INTO dummy (value) VALUES (20);") + db.close + end - db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(1) - migrator.rollback(2) - db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(1) - db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) - end + it "removes only applied migrations" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db + create_dummy db - it "applies rollback within a transaction to avoid partial execution" do - db, migrator = prepared_migrator - fake_migration db - create_dummy db + migration = migrator.context[2] + migration.add(:rollback, "INSERT INTO dummy (value) VALUES (20);") - migration = migrator.context[1] - migration.add(:rollback, "INSERT INTO dummy (value) VALUES (10);") - migration.add(:rollback, "INSERT INTO foo (value)") + db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(1) + migrator.rollback(2) + db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(1) + db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) - db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) - expect_raises(Exception) do - migrator.rollback(1) + db.close end - db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) - db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(1) - end - end - end - describe "#rollback(ids)" do - context "with no migrations applied" do - it "does not rollback non-applied migration" do - db, migrator = prepared_migrator - create_dummy db + it "applies rollback within a transaction to avoid partial execution" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db + create_dummy db - m1 = migrator.context[1] - m1.add(:rollback, "INSERT INTO dummy (value) VALUES (10);") - m3 = migrator.context[3] - m3.add(:rollback, "INSERT INTO dummy (value) VALUES (30);") + migration = migrator.context[1] + migration.add(:rollback, "INSERT INTO dummy (value) VALUES (10);") + migration.add(:rollback, "INSERT INTO foo (value);") - db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) - migrator.rollback(3, 1) - db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) + db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) + expect_raises(Exception) do + migrator.rollback(1) + end + db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) + db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(1) + + db.close + end end end + end - context "with migrations applied" do - it "removes migration from the list of applied" do - db, migrator = prepared_migrator - fake_migration db, 1 - fake_migration db, 2 + for_each_dialect do + describe "#rollback(ids)" do + context "with no migrations applied" do + it "does not rollback non-applied migration" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + create_dummy db - db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(2) - migrator.rollback(2, 1) - db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(0) + m1 = migrator.context[1] + m1.add(:rollback, "INSERT INTO dummy (value) VALUES (10);") + m3 = migrator.context[3] + m3.add(:rollback, "INSERT INTO dummy (value) VALUES (30);") + + db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) + migrator.rollback(3, 1) + db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) + + db.close + end end - it "considers migration only once" do - db, migrator = prepared_migrator - fake_migration db, 1 - create_dummy db + context "with migrations applied" do + it "removes migration from the list of applied" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db, 1 + fake_migration db, 2 - migration = migrator.context[1] - migration.add(:rollback, "INSERT INTO dummy (value) VALUES (10);") + db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(2) + migrator.rollback(2, 1) + db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(0) - db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) - migrator.rollback(1, 1, 1, 1) - db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(1) - end + db.close + end - it "executes migration down statements" do - db, migrator = prepared_migrator - fake_migration db, 1 - fake_migration db, 2 - create_dummy db + it "considers migration only once" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db, 1 + create_dummy db - m1 = migrator.context[1] - m1.add(:rollback, "INSERT INTO dummy (value) VALUES (10);") - m2 = migrator.context[2] - m2.add(:rollback, "INSERT INTO dummy (value) VALUES (20);") + migration = migrator.context[1] + migration.add(:rollback, "INSERT INTO dummy (value) VALUES (10);") - db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) - migrator.rollback(2, 1) - db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(2) - db.scalar("SELECT MAX(value) FROM dummy;").as(Int64).should eq(20) - end + db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) + migrator.rollback(1, 1, 1, 1) + db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(1) - it "applies rollback within a transaction to avoid partial execution" do - db, migrator = prepared_migrator - fake_migration db, 1 - fake_migration db, 2 - create_dummy db + db.close + end + + it "executes migration down statements" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db, 1 + fake_migration db, 2 + create_dummy db - m1 = migrator.context[1] - m1.add(:rollback, "INSERT INTO foo (value)") - m2 = migrator.context[2] - m2.add(:rollback, "INSERT INTO dummy (value) VALUES (10);") + m1 = migrator.context[1] + m1.add(:rollback, "INSERT INTO dummy (value) VALUES (10);") + m2 = migrator.context[2] + m2.add(:rollback, "INSERT INTO dummy (value) VALUES (20);") - db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) - expect_raises(Exception) do + db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) migrator.rollback(2, 1) + db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(2) + db.scalar("SELECT MAX(value) FROM dummy;").as(Int64).should eq(20) + + db.close + end + + it "applies rollback within a transaction to avoid partial execution" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db, 1 + fake_migration db, 2 + create_dummy db + + m1 = migrator.context[1] + m1.add(:rollback, "INSERT INTO foo (value);") + m2 = migrator.context[2] + m2.add(:rollback, "INSERT INTO dummy (value) VALUES (10);") + + db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) + expect_raises(Exception) do + migrator.rollback(2, 1) + end + db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) + db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(2) + + db.close end - db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) - db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(2) end end end - describe "#rollback_plan" do - context "with no migration applied" do - it "returns an empty list of migrations" do - _, migrator = prepared_migrator + for_each_dialect do + describe "#rollback_plan" do + context "with no migration applied" do + it "returns an empty list of migrations" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + + ids = migrator.rollback_plan + ids.should be_empty - ids = migrator.rollback_plan - ids.should be_empty + db.close + end end - end - context "dealing with batches" do - it "returns the list of migrations in reverse order" do - db, migrator = prepared_migrator - fake_migration db, 1 - fake_migration db, 2 + context "dealing with batches" do + it "returns the list of migrations in reverse order" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db, 1 + fake_migration db, 2 - ids = migrator.rollback_plan - ids.should_not be_empty - ids.should eq([2, 1]) - end + ids = migrator.rollback_plan + ids.should_not be_empty + ids.should eq([2, 1]) - it "returns only the list of migrations in the last batch" do - db, migrator = prepared_migrator - fake_migration db, 1, 1 - fake_migration db, 2, 1 - fake_migration db, 4, 2 + db.close + end - ids = migrator.rollback_plan - ids.should_not be_empty - ids.should eq([4]) - end - end + it "returns only the list of migrations in the last batch" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db, 1, 1 + fake_migration db, 2, 1 + fake_migration db, 4, 2 - context "migrations not available locally" do - it "excludes migrations not locally available" do - db, migrator = prepared_migrator - fake_migration db, 5 + ids = migrator.rollback_plan + ids.should_not be_empty + ids.should eq([4]) - ids = migrator.rollback_plan - ids.should be_empty + db.close + end end - end - end - describe "#reset_plan" do - context "with no migration applied" do - it "returns an empty list of migrations" do - _, migrator = prepared_migrator + context "migrations not available locally" do + it "excludes migrations not locally available" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db, 5 - ids = migrator.reset_plan - ids.should be_empty + ids = migrator.rollback_plan + ids.should be_empty + + db.close + end end end - context "with a single batch" do - it "returns a list of migrations in reverse order" do - db, migrator = prepared_migrator - fake_migration db, 1 - fake_migration db, 3 + describe "#reset_plan" do + context "with no migration applied" do + it "returns an empty list of migrations" do + db = dialect_db.call + _, migrator = prepared_migrator(db) - ids = migrator.reset_plan - ids.should_not be_empty - ids.should eq([3, 1]) + ids = migrator.reset_plan + ids.should be_empty + + db.close + end end - it "excludes migraitons not locally available" do - db, migrator = prepared_migrator - fake_migration db, 1 - fake_migration db, 5 + context "with a single batch" do + it "returns a list of migrations in reverse order" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db, 1 + fake_migration db, 3 - ids = migrator.reset_plan - ids.should_not be_empty - ids.should eq([1]) + ids = migrator.reset_plan + ids.should_not be_empty + ids.should eq([3, 1]) + + db.close + end + + it "excludes migraitons not locally available" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db, 1 + fake_migration db, 5 + + ids = migrator.reset_plan + ids.should_not be_empty + ids.should eq([1]) + + db.close + end end - end - context "with multiple batches" do - it "returns list of migrations in reverse order" do - db, migrator = prepared_migrator - fake_migration db, 1, 1 - fake_migration db, 3, 1 - fake_migration db, 2, 2 - fake_migration db, 4, 2 + context "with multiple batches" do + it "returns list of migrations in reverse order" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db, 1, 1 + fake_migration db, 3, 1 + fake_migration db, 2, 2 + fake_migration db, 4, 2 - ids = migrator.reset_plan - ids.should_not be_empty - ids.should eq([4, 2, 3, 1]) + ids = migrator.reset_plan + ids.should_not be_empty + ids.should eq([4, 2, 3, 1]) + + db.close + end end end end - describe "#pending?" do - it "returns true when no migration was applied" do - _, migrator = prepared_migrator + for_each_dialect do + describe "#pending?" do + it "returns true when no migration was applied" do + db = dialect_db.call + _, migrator = prepared_migrator(db) - migrator.pending?.should be_true - end + migrator.pending?.should be_true - it "returns false when all migrations were applied" do - db, migrator = prepared_migrator - fake_migration db, 1 - fake_migration db, 2 - fake_migration db, 3 - fake_migration db, 4 + db.close + end - migrator.pending?.should be_false - end - end + it "returns false when all migrations were applied" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db, 1 + fake_migration db, 2 + fake_migration db, 3 + fake_migration db, 4 - describe "#apply!" do - context "with completely empty database" do - it "prepares the migration table and applies migrations" do - db = memory_db - migrator = ready_migrator(db) + migrator.pending?.should be_false - migrator.apply! - db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(4) + db.close end end - context "with no existing migration applied" do - it "applies all available migrations as single batch" do - db, migrator = prepared_migrator + describe "#apply!" do + context "with completely empty database" do + it "prepares the migration table and applies migrations" do + db = dialect_db.call + migrator = ready_migrator(db) - db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(0) - migrator.apply! - db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(4) - db.scalar("SELECT MAX(batch) FROM drift_migrations;").as(Int64).should eq(1) + migrator.apply! + db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(4) + + db.close + end end - end - context "with existing batches" do - it "applies pending migrations as new batch" do - db, migrator = prepared_migrator - fake_migration db, 1 - fake_migration db, 3 + context "with no existing migration applied" do + it "applies all available migrations as single batch" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + + db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(0) + migrator.apply! + db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(4) + db.scalar("SELECT MAX(batch) FROM drift_migrations;").as(Int64).should eq(1) - db.scalar("SELECT MAX(batch) FROM drift_migrations;").as(Int64).should eq(1) - migrator.apply! - db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(4) - db.scalar("SELECT MAX(batch) FROM drift_migrations;").as(Int64).should eq(2) + db.close + end end - end - end - describe "#reset!" do - context "with no migration applied" do - it "does nothing" do - db, migrator = prepared_migrator + context "with existing batches" do + it "applies pending migrations as new batch" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db, 1 + fake_migration db, 3 - db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(0) - migrator.reset! - db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(0) + db.scalar("SELECT MAX(batch) FROM drift_migrations;").as(Int64).should eq(1) + migrator.apply! + db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(4) + db.scalar("SELECT MAX(batch) FROM drift_migrations;").as(Int64).should eq(2) + + db.close + end end end - context "with some applied migrations" do - it "resets the migration status" do - db, migrator = prepared_migrator - fake_migration db, 1 - fake_migration db, 3 + describe "#reset!" do + context "with no migration applied" do + it "does nothing" do + db = dialect_db.call + _, migrator = prepared_migrator(db) - migrator.reset! - db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(0) + db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(0) + migrator.reset! + db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(0) + + db.close + end + end + + context "with some applied migrations" do + it "resets the migration status" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db, 1 + fake_migration db, 3 + + migrator.reset! + db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(0) + + db.close + end end end end - describe "(apply callback cycle)" do - it "triggers before a migration is applied" do - _, migrator = prepared_migrator + for_each_dialect do + describe "(apply callback cycle)" do + it "triggers before a migration is applied" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + + count = 0 + migrator.before_apply do |_| + count += 1 + end - count = 0 - migrator.before_apply do |_| - count += 1 + migrator.apply(1) + count.should eq(1) + + db.close end - migrator.apply(1) - count.should eq(1) - end + it "triggers after a migration has been applied" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + + count = 0 + migrator.after_apply do |_, _| + count += 1 + end - it "triggers after a migration has been applied" do - _, migrator = prepared_migrator + migrator.apply(1) + count.should eq(1) - count = 0 - migrator.after_apply do |_, _| - count += 1 + db.close end - migrator.apply(1) - count.should eq(1) - end + it "triggers callbacks in sequence" do + db = dialect_db.call + _, migrator = prepared_migrator(db) - it "triggers callbacks in sequence" do - _, migrator = prepared_migrator + events = Array(Symbol).new - events = Array(Symbol).new + migrator.before_apply do |_| + events.push :before + end - migrator.before_apply do |_| - events.push :before - end + migrator.after_apply do |_, _| + events.push :after + end + + migrator.apply(1) + events.should eq([:before, :after]) - migrator.after_apply do |_, _| - events.push :after + db.close end - migrator.apply(1) - events.should eq([:before, :after]) - end + it "does not trigger if migration is already applied" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db, 1 - it "does not trigger if migration is already applied" do - db, migrator = prepared_migrator - fake_migration db, 1 + count = 0 + migrator.before_apply do |_| + count += 1 + end - count = 0 - migrator.before_apply do |_| - count += 1 - end + migrator.after_apply do |_, _| + count += 1 + end - migrator.after_apply do |_, _| - count += 1 - end + migrator.apply(1) + count.should eq(0) - migrator.apply(1) - count.should eq(0) + db.close + end end - end - describe "(rollback callback cycle)" do - it "triggers before a migration is rolled back" do - db, migrator = prepared_migrator - fake_migration db, 1 + describe "(rollback callback cycle)" do + it "triggers before a migration is rolled back" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db, 1 + + count = 0 + migrator.before_rollback do |_| + count += 1 + end - count = 0 - migrator.before_rollback do |_| - count += 1 + migrator.rollback(1) + count.should eq(1) + + db.close end - migrator.rollback(1) - count.should eq(1) - end + it "triggers after a migration has been rolled back" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db, 1 + + count = 0 + migrator.after_rollback do |_, _| + count += 1 + end - it "triggers after a migration has been rolled back" do - db, migrator = prepared_migrator - fake_migration db, 1 + migrator.rollback(1) + count.should eq(1) - count = 0 - migrator.after_rollback do |_, _| - count += 1 + db.close end - migrator.rollback(1) - count.should eq(1) - end + it "triggers callbacks in sequence" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db, 1 - it "triggers callbacks in sequence" do - db, migrator = prepared_migrator - fake_migration db, 1 + events = Array(Symbol).new + migrator.before_rollback do |_| + events.push :before + end - events = Array(Symbol).new - migrator.before_rollback do |_| - events.push :before - end + migrator.after_rollback do |_, _| + events.push :after + end - migrator.after_rollback do |_, _| - events.push :after + migrator.rollback(1) + events.should eq([:before, :after]) + + db.close end - migrator.rollback(1) - events.should eq([:before, :after]) - end + it "does not trigger if migration is not applied" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + + count = 0 + migrator.before_apply do |_| + count += 1 + end - it "does not trigger if migration is not applied" do - _, migrator = prepared_migrator + migrator.after_apply do |_, _| + count += 1 + end - count = 0 - migrator.before_apply do |_| - count += 1 - end + migrator.rollback(1) + count.should eq(0) - migrator.after_apply do |_, _| - count += 1 + db.close end - migrator.rollback(1) - count.should eq(0) - end + it "resets in the right order" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db, 1, 1 + fake_migration db, 3, 1 + fake_migration db, 2, 2 + fake_migration db, 4, 2 - it "resets in the right order" do - db, migrator = prepared_migrator - fake_migration db, 1, 1 - fake_migration db, 3, 1 - fake_migration db, 2, 2 - fake_migration db, 4, 2 + before_ids = Array(Int64).new + migrator.before_rollback do |id| + before_ids.push id + end - before_ids = Array(Int64).new - migrator.before_rollback do |id| - before_ids.push id - end + after_ids = Array(Int64).new + migrator.after_rollback do |id, _| + after_ids.push id + end - after_ids = Array(Int64).new - migrator.after_rollback do |id, _| - after_ids.push id - end + migrator.reset! + before_ids.size.should eq(4) + after_ids.size.should eq(4) + before_ids.should eq([4, 2, 3, 1]) + after_ids.should eq([4, 2, 3, 1]) - migrator.reset! - before_ids.size.should eq(4) - after_ids.size.should eq(4) - before_ids.should eq([4, 2, 3, 1]) - after_ids.should eq([4, 2, 3, 1]) + db.close + end end end - describe "#applied" do - it "returns an empty list when no migrations were applied" do - _, migrator = prepared_migrator + for_each_dialect do + describe "#applied" do + it "returns an empty list when no migrations were applied" do + db = dialect_db.call + _, migrator = prepared_migrator(db) - migrator.applied.should be_empty - end + migrator.applied.should be_empty + + db.close + end - it "returns ordered list of applied migrations" do - db, migrator = prepared_migrator - fake_migration db, 1 - fake_migration db, 2 + it "returns ordered list of applied migrations" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db, 1 + fake_migration db, 2 - entries = migrator.applied - entries.should_not be_empty - entries.size.should eq(2) + entries = migrator.applied + entries.should_not be_empty + entries.size.should eq(2) - mig1 = entries.first - mig1.id.should eq(1) - end + mig1 = entries.first + mig1.id.should eq(1) - it "returns only known applied migrations" do - db, migrator = prepared_migrator - fake_migration db, 2 - fake_migration db, 5 + db.close + end - entries = migrator.applied - entries.size.should eq(1) + it "returns only known applied migrations" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db, 2 + fake_migration db, 5 + + entries = migrator.applied + entries.size.should eq(1) - mig2 = entries.first - mig2.id.should eq(2) + mig2 = entries.first + mig2.id.should eq(2) + + db.close + end end end end diff --git a/src/cli.cr b/src/cli.cr index 31e4281..530b920 100644 --- a/src/cli.cr +++ b/src/cli.cr @@ -17,6 +17,8 @@ require "option_parser" require "./drift" require "./drift/commands/*" +require "mysql" +require "pg" require "sqlite3" module Drift diff --git a/src/drift/dialect.cr b/src/drift/dialect.cr new file mode 100644 index 0000000..df8d228 --- /dev/null +++ b/src/drift/dialect.cr @@ -0,0 +1,180 @@ +# Copyright 2025 Luis Lavena +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "db" + +module Drift + # Raised when an unsupported database dialect is detected + class UnsupportedDialectError < Error + end + + # Abstract interface for database-specific SQL operations + # + # Each dialect handles the SQL generation and execution for its specific + # database engine (SQLite3, PostgreSQL, MySQL, etc.) + abstract struct Dialect + # Detects the appropriate dialect from a database connection + def self.from_db(db : DB::Database) : Dialect + db.using_connection do |conn| + return from_db(conn) + end + end + + # :ditto: + def self.from_db(conn : DB::Connection) : Dialect + case conn.class.name + when .starts_with?("MySql::") + MySQL.new + when .starts_with?("PG::") + PostgreSQL.new + when .starts_with?("SQLite3") + SQLite3.new + else + raise UnsupportedDialectError.new("Unsupported database: #{conn.class.name}") + end + end + + # Check if the migrations table exists in the database + abstract def prepared?(conn : DB::Connection) : Bool + + # :ditto: + def prepared?(db : DB::Database) : Bool + db.using_connection { |conn| prepared?(conn) } + end + + # Create the migrations tracking table + abstract def create_schema!(conn : DB::Connection) : Nil + + # :ditto: + def create_schema!(db : DB::Database) : Nil + db.using_connection { |conn| create_schema!(conn) } + end + + # Find a specific migration by *id*, returns the ID if found, nil otherwise + abstract def find_migration_id(conn : DB::Connection, id : Int64) : Int64? + + # :ditto: + def find_migration_id(db : DB::Database, id : Int64) : Int64? + db.using_connection { |conn| find_migration_id(conn, id) } + end + + # Retrieve all migration IDs from the tracking table + def all_migration_ids(conn : DB::Connection) : Array(Int64) + sql_applied_ids = <<-SQL + SELECT + id + FROM + drift_migrations + ORDER BY + id ASC; + SQL + + conn.query_all(sql_applied_ids, as: Int64) + end + + # :ditto: + def all_migration_ids(db : DB::Database) : Array(Int64) + db.using_connection { |conn| all_migration_ids(conn) } + end + + # Retrieve all migration IDs in reverse order (batch DESC, id DESC) for + # reset planning. + def all_migration_ids_reverse(conn : DB::Connection) : Array(Int64) + sql_reverse_applied_plan = <<-SQL + SELECT + id + FROM + drift_migrations + ORDER BY + batch DESC, + id DESC; + SQL + + conn.query_all(sql_reverse_applied_plan, as: Int64) + end + + # :ditto: + def all_migration_ids_reverse(db : DB::Database) : Array(Int64) + db.using_connection { |conn| all_migration_ids_reverse(conn) } + end + + # Retrieve all migration entries with full metadata + def all_migrations(conn : DB::Connection) : Array(Migrator::MigrationEntry) + sql_all_applied = <<-SQL + SELECT + id, batch, applied_at, duration_ns + FROM + drift_migrations + ORDER BY + id ASC; + SQL + + conn.query_all(sql_all_applied, as: Migrator::MigrationEntry) + end + + # :ditto: + def all_migrations(db : DB::Database) : Array(Migrator::MigrationEntry) + db.using_connection { |conn| all_migrations(conn) } + end + + # Get the maximum batch number from the tracking table, or zero if no batch + # exists + def max_batch(conn : DB::Connection) : Int64 + sql_last_batch = <<-SQL + SELECT + COALESCE( + MAX(batch), + 0 + ) + FROM + drift_migrations + LIMIT + 1; + SQL + + conn.query_one(sql_last_batch, as: Int64) + end + + # :ditto: + def max_batch(db : DB::Database) : Int64 + db.using_connection { |conn| max_batch(conn) } + end + + # Retrieve migration IDs for a specific batch in reverse order (id DESC) + abstract def batch_migration_ids_reverse(conn : DB::Connection, batch : Int64) : Array(Int64) + + # :ditto: + def batch_migration_ids_reverse(db : DB::Database, batch : Int64) : Array(Int64) + db.using_connection { |conn| batch_migration_ids_reverse(conn, batch) } + end + + # Insert a new migration record into the tracking table + abstract def insert_migration(conn : DB::Connection, id : Int64, batch : Int64, applied_at : Time, duration_ns : Int64) : Nil + + # :ditto: + def insert_migration(db : DB::Database, id : Int64, batch : Int64, applied_at : Time, duration_ns : Int64) : Nil + db.using_connection { |conn| insert_migration(conn, id, batch, applied_at, duration_ns) } + end + + # Delete a migration record *id* from the tracking table + abstract def delete_migration(conn : DB::Connection, id : Int64) : Nil + + # :ditto: + def delete_migration(db : DB::Database, id : Int64) : Nil + db.using_connection { |conn| delete_migration(conn, id) } + end + end +end + +require "./dialect/*" diff --git a/src/drift/dialect/mysql.cr b/src/drift/dialect/mysql.cr new file mode 100644 index 0000000..a496ad6 --- /dev/null +++ b/src/drift/dialect/mysql.cr @@ -0,0 +1,101 @@ +# Copyright 2025 Luis Lavena +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module Drift + struct Dialect + # MySQL implementation + struct MySQL < Dialect + def prepared?(conn : DB::Connection) : Bool + sql_check_schema = <<-SQL + SELECT + TABLE_NAME + FROM + INFORMATION_SCHEMA.TABLES + WHERE + TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'drift_migrations' + LIMIT + 1; + SQL + + conn.query_one?(sql_check_schema, as: String) ? true : false + end + + def create_schema!(conn : DB::Connection) : Nil + sql_create_schema = <<-SQL + CREATE TABLE IF NOT EXISTS drift_migrations ( + id BIGINT PRIMARY KEY NOT NULL, + batch BIGINT NOT NULL, + applied_at TIMESTAMP NOT NULL, + duration_ns BIGINT NOT NULL + ); + SQL + + conn.exec(sql_create_schema) + end + + def find_migration_id(conn : DB::Connection, id : Int64) : Int64? + sql_find_migration_id = <<-SQL + SELECT + id + FROM + drift_migrations + WHERE + id = ? + LIMIT + 1; + SQL + + conn.query_one?(sql_find_migration_id, id, as: Int64) + end + + def batch_migration_ids_reverse(conn : DB::Connection, batch : Int64) : Array(Int64) + sql_batch_ids_reverse = <<-SQL + SELECT + id + FROM + drift_migrations + WHERE + batch = ? + ORDER BY + id DESC; + SQL + + conn.query_all(sql_batch_ids_reverse, batch, as: Int64) + end + + def insert_migration(conn : DB::Connection, id : Int64, batch : Int64, applied_at : Time, duration_ns : Int64) : Nil + sql_insert_migration = <<-SQL + INSERT INTO drift_migrations + (id, batch, applied_at, duration_ns) + VALUES + (?, ?, ?, ?); + SQL + + conn.exec(sql_insert_migration, id, batch, applied_at, duration_ns) + end + + def delete_migration(conn : DB::Connection, id : Int64) : Nil + sql_delete_migration = <<-SQL + DELETE FROM + drift_migrations + WHERE + id = ?; + SQL + + conn.exec(sql_delete_migration, id) + end + end + end +end diff --git a/src/drift/dialect/postgresql.cr b/src/drift/dialect/postgresql.cr new file mode 100644 index 0000000..7be1666 --- /dev/null +++ b/src/drift/dialect/postgresql.cr @@ -0,0 +1,101 @@ +# Copyright 2025 Luis Lavena +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module Drift + struct Dialect + # PostgreSQL implementation + struct PostgreSQL < Dialect + def prepared?(conn : DB::Connection) : Bool + sql_check_schema = <<-SQL + SELECT + tablename + FROM + pg_tables + WHERE + schemaname = 'public' + AND tablename = 'drift_migrations' + LIMIT + 1; + SQL + + conn.query_one?(sql_check_schema, as: String) ? true : false + end + + def create_schema!(conn : DB::Connection) : Nil + sql_create_schema = <<-SQL + CREATE TABLE IF NOT EXISTS drift_migrations ( + id BIGINT PRIMARY KEY NOT NULL, + batch BIGINT NOT NULL, + applied_at TIMESTAMP WITH TIME ZONE NOT NULL, + duration_ns BIGINT NOT NULL + ); + SQL + + conn.exec(sql_create_schema) + end + + def find_migration_id(conn : DB::Connection, id : Int64) : Int64? + sql_find_migration_id = <<-SQL + SELECT + id + FROM + drift_migrations + WHERE + id = $1 + LIMIT + 1; + SQL + + conn.query_one?(sql_find_migration_id, id, as: Int64) + end + + def batch_migration_ids_reverse(conn : DB::Connection, batch : Int64) : Array(Int64) + sql_batch_ids_reverse = <<-SQL + SELECT + id + FROM + drift_migrations + WHERE + batch = $1 + ORDER BY + id DESC; + SQL + + conn.query_all(sql_batch_ids_reverse, batch, as: Int64) + end + + def insert_migration(conn : DB::Connection, id : Int64, batch : Int64, applied_at : Time, duration_ns : Int64) : Nil + sql_insert_migration = <<-SQL + INSERT INTO drift_migrations + (id, batch, applied_at, duration_ns) + VALUES + ($1, $2, $3, $4); + SQL + + conn.exec(sql_insert_migration, id, batch, applied_at, duration_ns) + end + + def delete_migration(conn : DB::Connection, id : Int64) : Nil + sql_delete_migration = <<-SQL + DELETE FROM + drift_migrations + WHERE + id = $1; + SQL + + conn.exec(sql_delete_migration, id) + end + end + end +end diff --git a/src/drift/dialect/sqlite3.cr b/src/drift/dialect/sqlite3.cr new file mode 100644 index 0000000..e6f7754 --- /dev/null +++ b/src/drift/dialect/sqlite3.cr @@ -0,0 +1,101 @@ +# Copyright 2025 Luis Lavena +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module Drift + struct Dialect + # SQLite3-compatible SQL implementation + struct SQLite3 < Dialect + def prepared?(conn : DB::Connection) : Bool + sql_check_schema = <<-SQL + SELECT + name + FROM + sqlite_schema + WHERE + type = "table" + AND name = "drift_migrations" + LIMIT + 1; + SQL + + conn.query_one?(sql_check_schema, as: String) ? true : false + end + + def create_schema!(conn : DB::Connection) : Nil + sql_create_schema = <<-SQL + CREATE TABLE IF NOT EXISTS drift_migrations ( + id INTEGER PRIMARY KEY NOT NULL, + batch INTEGER NOT NULL, + applied_at TEXT NOT NULL, + duration_ns INTEGER NOT NULL + ); + SQL + + conn.exec(sql_create_schema) + end + + def find_migration_id(conn : DB::Connection, id : Int64) : Int64? + sql_find_migration_id = <<-SQL + SELECT + id + FROM + drift_migrations + WHERE + id = ? + LIMIT + 1; + SQL + + conn.query_one?(sql_find_migration_id, id, as: Int64) + end + + def batch_migration_ids_reverse(conn : DB::Connection, batch : Int64) : Array(Int64) + sql_batch_ids_reverse = <<-SQL + SELECT + id + FROM + drift_migrations + WHERE + batch = ? + ORDER BY + id DESC; + SQL + + conn.query_all(sql_batch_ids_reverse, batch, as: Int64) + end + + def insert_migration(conn : DB::Connection, id : Int64, batch : Int64, applied_at : Time, duration_ns : Int64) : Nil + sql_insert_migration = <<-SQL + INSERT INTO drift_migrations + (id, batch, applied_at, duration_ns) + VALUES + (?, ?, ?, ?); + SQL + + conn.exec(sql_insert_migration, id, batch, applied_at, duration_ns) + end + + def delete_migration(conn : DB::Connection, id : Int64) : Nil + sql_delete_migration = <<-SQL + DELETE FROM + drift_migrations + WHERE + id = ?; + SQL + + conn.exec(sql_delete_migration, id) + end + end + end +end diff --git a/src/drift/migrator.cr b/src/drift/migrator.cr index 68a661f..94fe1ad 100644 --- a/src/drift/migrator.cr +++ b/src/drift/migrator.cr @@ -15,6 +15,8 @@ require "./context" require "db" +require "./dialect" + module Drift class Migrator class MigrationEntry @@ -31,7 +33,7 @@ module Drift end getter context : Context - getter db : DB::Database | DB::Connection + getter db : DB::Database alias BeforeCallback = Proc(Int64, Nil) alias AfterCallback = Proc(Int64, Time::Span, Nil) @@ -42,7 +44,10 @@ module Drift @before_rollback = Array(BeforeCallback).new @after_rollback = Array(AfterCallback).new + @dialect : Dialect + def initialize(@db, @context) + @dialect = Dialect.from_db(@db) end def self.from_path(db, path : String) @@ -61,49 +66,20 @@ module Drift end def applied : Array(MigrationEntry) - sql_all_applied = <<-SQL - SELECT - id, batch, applied_at, duration_ns - FROM - drift_migrations - ORDER BY - id ASC; - SQL - - entries = db.query_all(sql_all_applied, as: MigrationEntry) + entries = @dialect.all_migrations(db) current_applied_ids = applied_ids entries.reject! { |e| !e.id.in?(current_applied_ids) } end def applied?(id : Int64) : Bool - sql_find_migration_id = <<-SQL - SELECT - id - FROM - drift_migrations - WHERE - id = ? - LIMIT - 1; - SQL - - query_id = db.query_one?(sql_find_migration_id, id, as: Int64) + query_id = @dialect.find_migration_id(db, id) query_id == id end def applied_ids - sql_applied_ids = <<-SQL - SELECT - id - FROM - drift_migrations - ORDER BY - id ASC; - SQL - - result_ids = Set{*db.query_all(sql_applied_ids, as: Int64)} + result_ids = Set{*@dialect.all_migration_ids(db)} (result_ids & Set{*context.ids}).to_a end @@ -137,35 +113,14 @@ module Drift end def prepare! - sql_create_schema = <<-SQL - CREATE TABLE IF NOT EXISTS drift_migrations ( - id INTEGER PRIMARY KEY NOT NULL, - batch INTEGER NOT NULL, - applied_at TEXT NOT NULL, - duration_ns INTEGER NOT NULL - ); - SQL - db.transaction do |tx| cnn = tx.connection - cnn.exec(sql_create_schema) + @dialect.create_schema!(cnn) end end def prepared? : Bool - sql_check_schema = <<-SQL - SELECT - name - FROM - sqlite_schema - WHERE - type = "table" - AND name = "drift_migrations" - LIMIT - 1; - SQL - - db.query_one?(sql_check_schema, as: String) ? true : false + @dialect.prepared?(db) end def reset! @@ -173,17 +128,7 @@ module Drift end def reset_plan - sql_reverse_applied_plan = <<-SQL - SELECT - id - FROM - drift_migrations - ORDER BY - batch DESC, - id DESC; - SQL - - batch_ids = Set{*db.query_all(sql_reverse_applied_plan, as: Int64)} + batch_ids = Set{*@dialect.all_migration_ids_reverse(db)} (batch_ids & Set{*context.ids}).to_a end @@ -200,60 +145,20 @@ module Drift end def rollback_plan - sql_last_batch = <<-SQL - SELECT - COALESCE( - MAX(batch), - 0 - ) - FROM - drift_migrations - LIMIT - 1; - SQL - - last_batch = db.query_one(sql_last_batch, as: Int64) - - sql_reverse_applied_batch = <<-SQL - SELECT - id - FROM - drift_migrations - WHERE - batch = ? - ORDER BY - id DESC; - SQL - - batch_ids = Set{*db.query_all(sql_reverse_applied_batch, last_batch, as: Int64)} - (batch_ids & Set{*context.ids}).to_a + last_batch = @dialect.max_batch(db) + + # Get migration IDs for last batch in reverse order + batch_ids = @dialect.batch_migration_ids_reverse(db, last_batch) + + (Set{*batch_ids} & Set{*context.ids}).to_a end private def apply_batch(ids : Array(Int64)) plan_ids = Set{*ids} - Set{*applied_ids} - sql_last_batch = <<-SQL - SELECT - COALESCE( - MAX(batch), - 0 - ) - FROM - drift_migrations - LIMIT - 1; - SQL - - sql_insert_migration = <<-SQL - INSERT INTO drift_migrations - (id, batch, applied_at, duration_ns) - VALUES - (?, ?, ?, ?); - SQL - db.transaction do |tx| cnn = tx.connection - batch = cnn.query_one(sql_last_batch, as: Int64) + 1 + batch = @dialect.max_batch(cnn) + 1 plan_ids.each do |id| migration = context[id] @@ -265,7 +170,7 @@ module Drift applied_at = Time.utc duration_ns = duration.total_nanoseconds.to_i64 - cnn.exec(sql_insert_migration, id, batch, applied_at, duration_ns) + @dialect.insert_migration(cnn, id, batch, applied_at, duration_ns) # trigger after_apply callbacks @after_apply.each &.call(id, duration) @@ -276,13 +181,6 @@ module Drift private def rollback_batch(ids : Array(Int64)) plan_ids = Set{*ids} & Set{*applied_ids} - sql_delete_migration = <<-SQL - DELETE FROM - drift_migrations - WHERE - id = ?; - SQL - db.transaction do |tx| cnn = tx.connection @@ -293,7 +191,7 @@ module Drift duration = Time.measure { migration.run(:rollback, cnn) } - cnn.exec(sql_delete_migration, id) + @dialect.delete_migration(cnn, id) @after_rollback.each &.call(id, duration) end