From c27c936af96d613daceeaef2014946b5972fc1e0 Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Sat, 11 Oct 2025 19:39:14 +0200 Subject: [PATCH 01/34] Restricts Migrator to Database types only Drop support for `DB::Connection` for `db` in Migrator in order to simplify the types used by it. This helps to reduce the confusion on what to pass to Migrator but also helps with type resolution as `DB::Database | DB::Connection` was presenting some issues when dealing with DB::Database and DB::Connection method overload. --- .changes/unreleased/deprecated-20251011-193908.yaml | 5 +++++ README.md | 4 ++-- spec/drift/migrator_spec.cr | 2 +- src/drift/migrator.cr | 2 +- 4 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 .changes/unreleased/deprecated-20251011-193908.yaml 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/README.md b/README.md index 0ddae0f..01d0999 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ 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! @@ -237,7 +237,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! diff --git a/spec/drift/migrator_spec.cr b/spec/drift/migrator_spec.cr index 4cf8f80..8f78c6b 100644 --- a/spec/drift/migrator_spec.cr +++ b/spec/drift/migrator_spec.cr @@ -26,7 +26,7 @@ private struct MigrationEntry end private def memory_db - DB.connect "sqlite3:%3Amemory%3A" + DB.open "sqlite3:%3Amemory%3A" end private def create_dummy(db) diff --git a/src/drift/migrator.cr b/src/drift/migrator.cr index 68a661f..c66a9ce 100644 --- a/src/drift/migrator.cr +++ b/src/drift/migrator.cr @@ -31,7 +31,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) From 3cc1e9e39aaa5183df414b7fd41851934413e235 Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Sat, 11 Oct 2025 19:57:08 +0200 Subject: [PATCH 02/34] Refactor Migrator's SQL operations as Dialect Abstract away SQL-specific elements from Migrator logic into Drift::Dialect-derived implementations. Do this to prepare for other dialects/drivers (MySQL, PostgreSQL, etc). --- .../unreleased/improved-20251011-195702.yaml | 5 + spec/drift/dialect_spec.cr | 30 ++++ src/drift/dialect.cr | 168 ++++++++++++++++++ src/drift/dialect/sqlite3.cr | 86 +++++++++ src/drift/migrator.cr | 146 +++------------ 5 files changed, 313 insertions(+), 122 deletions(-) create mode 100644 .changes/unreleased/improved-20251011-195702.yaml create mode 100644 spec/drift/dialect_spec.cr create mode 100644 src/drift/dialect.cr create mode 100644 src/drift/dialect/sqlite3.cr 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/spec/drift/dialect_spec.cr b/spec/drift/dialect_spec.cr new file mode 100644 index 0000000..fdb4619 --- /dev/null +++ b/spec/drift/dialect_spec.cr @@ -0,0 +1,30 @@ +# 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 "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 + end +end diff --git a/src/drift/dialect.cr b/src/drift/dialect.cr new file mode 100644 index 0000000..3029c44 --- /dev/null +++ b/src/drift/dialect.cr @@ -0,0 +1,168 @@ +# 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?("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 + + # 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/sqlite3.cr b/src/drift/dialect/sqlite3.cr new file mode 100644 index 0000000..43cb5cf --- /dev/null +++ b/src/drift/dialect/sqlite3.cr @@ -0,0 +1,86 @@ +# 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 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 c66a9ce..fecaaf7 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 @@ -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,24 @@ 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 all migration IDs and filter by last batch + all_ids = @dialect.all_migrations(db) + + batch_ids = all_ids.select { |entry| entry.batch == last_batch } + .map(&.id) + .reverse + + (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 +174,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 +185,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 +195,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 From cfe6a2a27ce126d84fca7174720b3fecae430cf3 Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Sun, 12 Oct 2025 00:38:10 +0200 Subject: [PATCH 03/34] Reduces data exchange between SQLite/Crystal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Perform sorting and filtering on SQLite side to avoid allocating that response and performing the filtering in Crystal side (which is way less optimized that SQLite VM) - Before: 2.25k ops/sec (445µs per operation, 253kB memory) - After: 24.65k ops/sec (40.57µs per operation, 12.2kB memory) Benchmark script: * https://gist.github.com/luislavena/460de5a9cb1e8a4085de14aa6644c64a --- .changes/unreleased/improved-20251012-125124.yaml | 5 +++++ src/drift/dialect.cr | 8 ++++++++ src/drift/dialect/sqlite3.cr | 15 +++++++++++++++ src/drift/migrator.cr | 8 ++------ 4 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 .changes/unreleased/improved-20251012-125124.yaml 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/src/drift/dialect.cr b/src/drift/dialect.cr index 3029c44..a62c0ec 100644 --- a/src/drift/dialect.cr +++ b/src/drift/dialect.cr @@ -147,6 +147,14 @@ module Drift 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 diff --git a/src/drift/dialect/sqlite3.cr b/src/drift/dialect/sqlite3.cr index 43cb5cf..e6f7754 100644 --- a/src/drift/dialect/sqlite3.cr +++ b/src/drift/dialect/sqlite3.cr @@ -60,6 +60,21 @@ module Drift 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 diff --git a/src/drift/migrator.cr b/src/drift/migrator.cr index fecaaf7..94fe1ad 100644 --- a/src/drift/migrator.cr +++ b/src/drift/migrator.cr @@ -147,12 +147,8 @@ module Drift def rollback_plan last_batch = @dialect.max_batch(db) - # Get all migration IDs and filter by last batch - all_ids = @dialect.all_migrations(db) - - batch_ids = all_ids.select { |entry| entry.batch == last_batch } - .map(&.id) - .reverse + # 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 From 5439f0900bb25eb8aed55dd16550cdd280168588 Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Sun, 12 Oct 2025 13:16:37 +0200 Subject: [PATCH 04/34] Cleanup make task --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 77e3644adfadc7571a32cc3e5b5895ea69c480f3 Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Sun, 12 Oct 2025 13:36:52 +0200 Subject: [PATCH 05/34] Adds PostgreSQL for local development/testing --- .../unreleased/internal-20251012-133617.yaml | 5 +++++ compose.yaml | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 .changes/unreleased/internal-20251012-133617.yaml 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/compose.yaml b/compose.yaml index 3f5f793..7d1869a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -3,15 +3,37 @@ 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: + - 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 + - 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 + + 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: + postgres: + driver: local From efe08399e504c887b130aba9ce395c36ffd8c5ec Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Sun, 12 Oct 2025 15:13:36 +0200 Subject: [PATCH 06/34] Makes SQLite3 dependency development-only As we prepare Drift to be DB-agnostic, move out the SQLite3 dependency to be only used during development. --- .changes/unreleased/internal-20251012-151331.yaml | 5 +++++ shard.yml | 1 + 2 files changed, 6 insertions(+) create mode 100644 .changes/unreleased/internal-20251012-151331.yaml 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/shard.yml b/shard.yml index 5f3cf33..78928ec 100644 --- a/shard.yml +++ b/shard.yml @@ -11,6 +11,7 @@ dependencies: db: github: crystal-lang/crystal-db version: ~> 0.14.0 +development_dependencies: sqlite3: github: crystal-lang/crystal-sqlite3 version: ~> 0.22.0 From f1eeac8224bb8ea4e6ba43e5c0c45fa157032f03 Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Sun, 12 Oct 2025 15:43:24 +0200 Subject: [PATCH 07/34] Initial PostgreSQL dialect implementation Almost direct translation from SQLite3 implementation with adjustments for table and arguments placeholders --- shard.yml | 4 ++ spec/drift/dialect_spec.cr | 10 ++++ src/drift/dialect.cr | 2 + src/drift/dialect/postgresql.cr | 101 ++++++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+) create mode 100644 src/drift/dialect/postgresql.cr diff --git a/shard.yml b/shard.yml index 78928ec..760e997 100644 --- a/shard.yml +++ b/shard.yml @@ -12,6 +12,10 @@ dependencies: github: crystal-lang/crystal-db version: ~> 0.14.0 development_dependencies: + 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 index fdb4619..36a72c7 100644 --- a/spec/drift/dialect_spec.cr +++ b/spec/drift/dialect_spec.cr @@ -14,6 +14,7 @@ require "../spec_helper" +require "pg" require "sqlite3" describe Drift::Dialect do @@ -26,5 +27,14 @@ describe Drift::Dialect do 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 end end diff --git a/src/drift/dialect.cr b/src/drift/dialect.cr index a62c0ec..552a20f 100644 --- a/src/drift/dialect.cr +++ b/src/drift/dialect.cr @@ -36,6 +36,8 @@ module Drift case conn.class.name when .starts_with?("SQLite3") SQLite3.new + when .starts_with?("PG::") + PostgreSQL.new else raise UnsupportedDialectError.new("Unsupported database: #{conn.class.name}") 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 From 8cf086d33d557dfd26089cd83ab7b0e424d199b5 Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Sun, 12 Oct 2025 15:55:10 +0200 Subject: [PATCH 08/34] Add dialect configuration for specs Introduces DIALECTS hash to support testing against multiple database dialects (SQLite3 and PostgreSQL). Each dialect can be skipped via environment variable. Also adds cleanup_tables helper to drop test tables between PostgreSQL test runs to ensure clean state. --- spec/drift/migrator_spec.cr | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/spec/drift/migrator_spec.cr b/spec/drift/migrator_spec.cr index 8f78c6b..c023413 100644 --- a/spec/drift/migrator_spec.cr +++ b/spec/drift/migrator_spec.cr @@ -16,6 +16,20 @@ require "../spec_helper" require "sqlite3" +# Configuration for dialects to test +DIALECTS = { + sqlite3: { + url: ENV["SQLITE3_DB_URL"]? || "sqlite3:%3Amemory%3A", + needs_cleanup: false, + skip: false, + }, + postgresql: { + url: ENV["POSTGRES_DB_URL"]? || "postgres://drift:drift@localhost:5432/drift_test", + needs_cleanup: true, + skip: ENV["SKIP_POSTGRESQL"]? == "true", + }, +} + private struct MigrationEntry include DB::Serializable @@ -48,6 +62,11 @@ private def sample_context ctx end +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 = memory_db) ctx = sample_context migrator = Drift::Migrator.new(db, ctx) From ad3aaa6cdaa805379eb08e9bfa0ea1f340068211 Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Sun, 12 Oct 2025 15:58:51 +0200 Subject: [PATCH 09/34] Add for_each_dialect macro for tests Introduces a macro to run parameterized tests across multiple database dialects. The macro generates test blocks for each configured dialect (SQLite3 and PostgreSQL), providing a clean database connection via a Proc. PostgreSQL connections trigger automatic cleanup of test tables before each test. The macro is defined but not yet used, ensuring no behavior changes to the existing test suite. --- spec/drift/migrator_spec.cr | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/spec/drift/migrator_spec.cr b/spec/drift/migrator_spec.cr index c023413..edef340 100644 --- a/spec/drift/migrator_spec.cr +++ b/spec/drift/migrator_spec.cr @@ -82,6 +82,26 @@ private def prepared_migrator {db, migrator} end +# Macro to run tests for each dialect +macro for_each_dialect + {% for name, config in DIALECTS %} + {% unless config[:skip] %} + 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 From 4779a19e43df07ccb9e2397f8ecea17e4c5e196e Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Sun, 12 Oct 2025 16:01:56 +0200 Subject: [PATCH 10/34] Update helper methods to require db parameter Modified ready_migrator to require db parameter instead of using a default value. Updated prepared_migrator to accept db parameter and removed internal database creation. This prepares the helpers for parameterized dialect testing. Tests now fail with compilation errors as expected. These will be resolved in subsequent tasks. --- spec/drift/migrator_spec.cr | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/spec/drift/migrator_spec.cr b/spec/drift/migrator_spec.cr index edef340..d4b5010 100644 --- a/spec/drift/migrator_spec.cr +++ b/spec/drift/migrator_spec.cr @@ -44,11 +44,21 @@ private def memory_db 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);") + 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 + 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 @@ -67,15 +77,14 @@ private def cleanup_tables(db) db.exec("DROP TABLE IF EXISTS dummy CASCADE;") end -private def ready_migrator(db = memory_db) +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! From e5f5bbdb3a98711289634da5a58e852ad46ee408 Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Sun, 12 Oct 2025 16:06:00 +0200 Subject: [PATCH 11/34] Update .new and .from_path tests to manage db connections --- spec/drift/migrator_spec.cr | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/spec/drift/migrator_spec.cr b/spec/drift/migrator_spec.cr index d4b5010..0e60440 100644 --- a/spec/drift/migrator_spec.cr +++ b/spec/drift/migrator_spec.cr @@ -114,21 +114,27 @@ 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 From 1d0094c902ef6b53e546ad77fdeb63dbb2d42f8c Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Sun, 12 Oct 2025 16:08:33 +0200 Subject: [PATCH 12/34] Refactor #prepared? and #prepare! tests Tests for #prepared? and #prepare! methods now run against both SQLite3 and PostgreSQL dialects using the for_each_dialect macro. Each test properly manages database connections using dialect_db.call and closes connections after assertions complete. --- spec/drift/migrator_spec.cr | 58 ++++++++++++++++++++++--------------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/spec/drift/migrator_spec.cr b/spec/drift/migrator_spec.cr index 0e60440..141ad43 100644 --- a/spec/drift/migrator_spec.cr +++ b/spec/drift/migrator_spec.cr @@ -138,37 +138,49 @@ describe Drift::Migrator do 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! + db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(0) + + db.close + end - migrator.prepare! - migrator.prepare! + it "does noop if database is already prepared" do + db = dialect_db.call + migrator = ready_migrator(db) + + migrator.prepare! + migrator.prepare! + + db.close + end end end From 66e42da74adc121e19a84b13e6a6461c6485e0d3 Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Sun, 12 Oct 2025 16:44:50 +0200 Subject: [PATCH 13/34] Fix migrator spec compilation errors Updates all prepared_migrator calls to pass the required db parameter and adds proper db.close cleanup to prevent resource leaks. This change is part of the multi-dialect testing refactoring. --- spec/drift/migrator_spec.cr | 267 +++++++++++++++++++++++++++++------- 1 file changed, 214 insertions(+), 53 deletions(-) diff --git a/spec/drift/migrator_spec.cr b/spec/drift/migrator_spec.cr index 141ad43..816b3fc 100644 --- a/spec/drift/migrator_spec.cr +++ b/spec/drift/migrator_spec.cr @@ -186,78 +186,102 @@ describe Drift::Migrator do describe "#applied?" do it "returns false when migration was not applied" do - _, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) migrator.applied?(1).should be_false + + db.close end it "returns true when migration was applied" do - db, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) fake_migration db migrator.applied?(1).should be_true + + db.close end end describe "#applied_ids" do it "returns an empty list when no migrations were applied" do - _, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) migrator.applied_ids.should be_empty + + db.close end it "returns ordered list of applied migrations" do - db, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) fake_migration db, 1 fake_migration db, 2 ids = migrator.applied_ids ids.should_not be_empty ids.should eq([1, 2]) + + db.close end it "returns only known applied migrations" do - db, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) fake_migration db, 1 fake_migration db, 5 ids = migrator.applied_ids ids.should_not be_empty ids.should eq([1]) + + db.close end end describe "#apply_plan" do context "with no migration applied" do it "returns a list of all migrations" do - _, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) ids = migrator.apply_plan ids.should_not be_empty ids.should eq([1, 2, 3, 4]) + + db.close end end context "with some applied migrations" do it "returns a list of non-applied migrations" do - db, migrator = prepared_migrator + db = memory_db + _, 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 context "with applied migrations not locally available" do it "returns the list of only local non-applied ones" do - db, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) fake_migration db, 1 fake_migration db, 5 ids = migrator.apply_plan ids.should eq([2, 3, 4]) + + db.close end end end @@ -265,7 +289,8 @@ describe Drift::Migrator do describe "#apply(id)" do context "with no existing migrations applied" do it "records the migration was applied" do - db, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(0) migrator.apply(1) @@ -278,17 +303,23 @@ describe Drift::Migrator do 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 it "applies migration only once" do - db, migrator = prepared_migrator + db = memory_db + _, 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 it "executes migration statements" do - db, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) create_dummy db migration = migrator.context[1] @@ -298,10 +329,13 @@ describe Drift::Migrator do 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, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) create_dummy db migration = migrator.context[1] @@ -314,18 +348,23 @@ describe Drift::Migrator do 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 end context "with existing migrations applied" do it "applies other migration as a new batch" do - db, migrator = prepared_migrator + db = memory_db + _, 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) + + db.close end end end @@ -333,16 +372,20 @@ describe Drift::Migrator do describe "#apply(ids)" do context "with no migrations" do it "applies multiple migrations as part of the same batch" do - db, migrator = prepared_migrator + db = memory_db + _, 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) + + db.close end it "ignores already applied migration from the list" do - db, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) fake_migration db create_dummy db @@ -353,20 +396,26 @@ describe Drift::Migrator do 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.close end it "increases batch number when executed multiple times for new migrations" do - db, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) 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) + + db.close end it "applies all migrations as transaction to avoid partial execution" do - db, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) create_dummy db m1 = migrator.context[1] @@ -381,10 +430,13 @@ describe Drift::Migrator do 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 it "applies repeated migration in list only once" do - db, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) create_dummy db migration = migrator.context[1] @@ -393,6 +445,8 @@ describe Drift::Migrator do 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 @@ -400,16 +454,20 @@ describe Drift::Migrator do describe "#rollback(id)" do context "with migration applied" do it "removes migration from the list of applied" do - db, migrator = prepared_migrator + db = memory_db + _, 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) + + db.close end it "executes migration down statements" do - db, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) fake_migration db create_dummy db @@ -419,10 +477,13 @@ describe Drift::Migrator do 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) + + db.close end it "removes only applied migrations" do - db, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) fake_migration db create_dummy db @@ -433,10 +494,13 @@ describe Drift::Migrator do 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.close end it "applies rollback within a transaction to avoid partial execution" do - db, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) fake_migration db create_dummy db @@ -450,6 +514,8 @@ describe Drift::Migrator do 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 @@ -457,7 +523,8 @@ describe Drift::Migrator do describe "#rollback(ids)" do context "with no migrations applied" do it "does not rollback non-applied migration" do - db, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) create_dummy db m1 = migrator.context[1] @@ -468,22 +535,28 @@ describe Drift::Migrator do 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 context "with migrations applied" do it "removes migration from the list of applied" do - db, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) fake_migration db, 1 fake_migration db, 2 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.close end it "considers migration only once" do - db, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) fake_migration db, 1 create_dummy db @@ -493,10 +566,13 @@ describe Drift::Migrator do 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) + + db.close end it "executes migration down statements" do - db, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) fake_migration db, 1 fake_migration db, 2 create_dummy db @@ -510,10 +586,13 @@ describe Drift::Migrator do 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, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) fake_migration db, 1 fake_migration db, 2 create_dummy db @@ -529,6 +608,8 @@ describe Drift::Migrator do 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 end end @@ -536,26 +617,33 @@ describe Drift::Migrator do describe "#rollback_plan" do context "with no migration applied" do it "returns an empty list of migrations" do - _, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) ids = migrator.rollback_plan ids.should be_empty + + db.close end end context "dealing with batches" do it "returns the list of migrations in reverse order" do - db, migrator = prepared_migrator + db = memory_db + _, 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]) + + db.close end it "returns only the list of migrations in the last batch" do - db, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) fake_migration db, 1, 1 fake_migration db, 2, 1 fake_migration db, 4, 2 @@ -563,16 +651,21 @@ describe Drift::Migrator do ids = migrator.rollback_plan ids.should_not be_empty ids.should eq([4]) + + db.close end end context "migrations not available locally" do it "excludes migrations not locally available" do - db, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) fake_migration db, 5 ids = migrator.rollback_plan ids.should be_empty + + db.close end end end @@ -580,38 +673,48 @@ describe Drift::Migrator do describe "#reset_plan" do context "with no migration applied" do it "returns an empty list of migrations" do - _, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) ids = migrator.reset_plan ids.should be_empty + + db.close end end context "with a single batch" do it "returns a list of migrations in reverse order" do - db, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) fake_migration db, 1 fake_migration db, 3 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, migrator = prepared_migrator + db = memory_db + _, 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 context "with multiple batches" do it "returns list of migrations in reverse order" do - db, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) fake_migration db, 1, 1 fake_migration db, 3, 1 fake_migration db, 2, 2 @@ -620,25 +723,33 @@ describe Drift::Migrator do ids = migrator.reset_plan ids.should_not be_empty ids.should eq([4, 2, 3, 1]) + + db.close end end end describe "#pending?" do it "returns true when no migration was applied" do - _, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) migrator.pending?.should be_true + + db.close end it "returns false when all migrations were applied" do - db, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) fake_migration db, 1 fake_migration db, 2 fake_migration db, 3 fake_migration db, 4 migrator.pending?.should be_false + + db.close end end @@ -650,23 +761,29 @@ describe Drift::Migrator do 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 + db = memory_db + _, 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.close end end context "with existing batches" do it "applies pending migrations as new batch" do - db, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) fake_migration db, 1 fake_migration db, 3 @@ -674,6 +791,8 @@ describe Drift::Migrator do 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 @@ -681,29 +800,36 @@ describe Drift::Migrator do describe "#reset!" do context "with no migration applied" do it "does nothing" do - db, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) 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, migrator = prepared_migrator + db = memory_db + _, 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 describe "(apply callback cycle)" do it "triggers before a migration is applied" do - _, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) count = 0 migrator.before_apply do |_| @@ -712,10 +838,13 @@ describe Drift::Migrator do migrator.apply(1) count.should eq(1) + + db.close end it "triggers after a migration has been applied" do - _, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) count = 0 migrator.after_apply do |_, _| @@ -724,10 +853,13 @@ describe Drift::Migrator do migrator.apply(1) count.should eq(1) + + db.close end it "triggers callbacks in sequence" do - _, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) events = Array(Symbol).new @@ -741,10 +873,13 @@ describe Drift::Migrator do migrator.apply(1) events.should eq([:before, :after]) + + db.close end it "does not trigger if migration is already applied" do - db, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) fake_migration db, 1 count = 0 @@ -758,12 +893,15 @@ describe Drift::Migrator do migrator.apply(1) count.should eq(0) + + db.close end end describe "(rollback callback cycle)" do it "triggers before a migration is rolled back" do - db, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) fake_migration db, 1 count = 0 @@ -773,10 +911,13 @@ describe Drift::Migrator do migrator.rollback(1) count.should eq(1) + + db.close end it "triggers after a migration has been rolled back" do - db, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) fake_migration db, 1 count = 0 @@ -786,10 +927,13 @@ describe Drift::Migrator do migrator.rollback(1) count.should eq(1) + + db.close end it "triggers callbacks in sequence" do - db, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) fake_migration db, 1 events = Array(Symbol).new @@ -803,10 +947,13 @@ describe Drift::Migrator do migrator.rollback(1) events.should eq([:before, :after]) + + db.close end it "does not trigger if migration is not applied" do - _, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) count = 0 migrator.before_apply do |_| @@ -819,10 +966,13 @@ describe Drift::Migrator do migrator.rollback(1) count.should eq(0) + + db.close end it "resets in the right order" do - db, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) fake_migration db, 1, 1 fake_migration db, 3, 1 fake_migration db, 2, 2 @@ -843,18 +993,24 @@ describe Drift::Migrator do 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 describe "#applied" do it "returns an empty list when no migrations were applied" do - _, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) migrator.applied.should be_empty + + db.close end it "returns ordered list of applied migrations" do - db, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) fake_migration db, 1 fake_migration db, 2 @@ -864,10 +1020,13 @@ describe Drift::Migrator do mig1 = entries.first mig1.id.should eq(1) + + db.close end it "returns only known applied migrations" do - db, migrator = prepared_migrator + db = memory_db + _, migrator = prepared_migrator(db) fake_migration db, 2 fake_migration db, 5 @@ -876,6 +1035,8 @@ describe Drift::Migrator do mig2 = entries.first mig2.id.should eq(2) + + db.close end end end From 326c8eec953ff42e753e34393d20d0c1d002e638 Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Sun, 12 Oct 2025 16:52:26 +0200 Subject: [PATCH 14/34] Refactor apply-related tests for dialect support Wrap #applied?, #applied_ids, #apply_plan, #apply(id), and #apply(ids) test blocks in for_each_dialect macro to run against both SQLite3 and PostgreSQL databases. --- spec/drift/migrator_spec.cr | 384 ++++++++++++++++++------------------ 1 file changed, 196 insertions(+), 188 deletions(-) diff --git a/spec/drift/migrator_spec.cr b/spec/drift/migrator_spec.cr index 816b3fc..3ad36e1 100644 --- a/spec/drift/migrator_spec.cr +++ b/spec/drift/migrator_spec.cr @@ -184,269 +184,277 @@ describe Drift::Migrator do end end - describe "#applied?" do - it "returns false when migration was not applied" do - db = memory_db - _, migrator = prepared_migrator(db) - - migrator.applied?(1).should be_false - - db.close - end - - it "returns true when migration was applied" do - db = memory_db - _, migrator = prepared_migrator(db) - fake_migration db - - migrator.applied?(1).should be_true - - db.close - end - end - - describe "#applied_ids" do - it "returns an empty list when no migrations were applied" do - db = memory_db - _, migrator = prepared_migrator(db) - - migrator.applied_ids.should be_empty - - db.close - end - - it "returns ordered list of applied migrations" do - db = memory_db - _, migrator = prepared_migrator(db) - fake_migration db, 1 - fake_migration db, 2 + for_each_dialect do + describe "#applied?" do + it "returns false when migration was not applied" do + db = dialect_db.call + _, migrator = prepared_migrator(db) - ids = migrator.applied_ids - ids.should_not be_empty - ids.should eq([1, 2]) + migrator.applied?(1).should be_false - db.close - end + db.close + end - it "returns only known applied migrations" do - db = memory_db - _, migrator = prepared_migrator(db) - fake_migration db, 1 - fake_migration db, 5 + it "returns true when migration was applied" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db - ids = migrator.applied_ids - ids.should_not be_empty - ids.should eq([1]) + migrator.applied?(1).should be_true - db.close + db.close + end end - end - describe "#apply_plan" do - context "with no migration applied" do - it "returns a list of all migrations" do - db = memory_db + 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 = memory_db + 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 = memory_db + 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 = memory_db - _, migrator = prepared_migrator(db) + 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) + ids = migrator.apply_plan + ids.should_not be_empty + ids.should eq([1, 2, 3, 4]) - # 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) + db.close + end + end - 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 + 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 - db.close + ids = migrator.apply_plan + ids.should_not be_empty + ids.should eq([2, 4]) + + db.close + end end - it "applies migration only once" do - db = memory_db - _, migrator = prepared_migrator(db) - migrator.apply(1) - migrator.apply(1) - db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(1) + 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 - db.close + ids = migrator.apply_plan + ids.should eq([2, 3, 4]) + + db.close + end end + end + end - it "executes migration statements" do - db = memory_db - _, migrator = prepared_migrator(db) - create_dummy db + 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) - migration = migrator.context[1] - migration.add(:migrate, "INSERT INTO dummy (value) VALUES (10);") + 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) - 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) + # 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) - db.close - end + 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 = memory_db - _, migrator = prepared_migrator(db) - 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.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) - expect_raises(Exception) do + db.close + end + + 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 - 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 + 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 end - end - context "with existing migrations applied" do - it "applies other migration as a new batch" do - db = memory_db - _, migrator = prepared_migrator(db) - 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 + 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 = memory_db - _, migrator = prepared_migrator(db) + 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) + 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) - db.close - end + db.close + end - it "ignores already applied migration from the list" do - db = memory_db - _, migrator = prepared_migrator(db) - fake_migration db - create_dummy db + it "ignores already applied migration from the list" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db + create_dummy db - m1 = migrator.context[1] - m1.add(:migrate, "INSERT INTO dummy (value) VALUES (10);") + 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(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(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.close - end + db.close + end - it "increases batch number when executed multiple times for new migrations" do - db = memory_db - _, migrator = prepared_migrator(db) + it "increases batch number when executed multiple times for new 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, 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) + 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) - db.close - end + db.close + end - it "applies all migrations as transaction to avoid partial execution" do - db = memory_db - _, migrator = prepared_migrator(db) - create_dummy db + 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);") + 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)") + 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) + 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.close + end - it "applies repeated migration in list only once" do - db = memory_db - _, migrator = prepared_migrator(db) - 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 + db.close + end end end end From 81b47e1cdc9c62e845c178066fc58fb4d432e1fc Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Sun, 12 Oct 2025 16:53:32 +0200 Subject: [PATCH 15/34] Refactor rollback tests for dialect support Wrap #rollback(id) and #rollback(ids) test blocks in for_each_dialect macro to run against both SQLite3 and PostgreSQL databases. --- spec/drift/migrator_spec.cr | 248 ++++++++++++++++++------------------ 1 file changed, 126 insertions(+), 122 deletions(-) diff --git a/spec/drift/migrator_spec.cr b/spec/drift/migrator_spec.cr index 3ad36e1..f753c47 100644 --- a/spec/drift/migrator_spec.cr +++ b/spec/drift/migrator_spec.cr @@ -459,165 +459,169 @@ describe Drift::Migrator do end end - describe "#rollback(id)" do - context "with migration applied" do - it "removes migration from the list of applied" do - db = memory_db - _, migrator = prepared_migrator(db) - 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) + 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) - db.close - end + db.close + end - it "executes migration down statements" do - db = memory_db - _, migrator = prepared_migrator(db) - fake_migration db - create_dummy db + it "executes migration down statements" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db + create_dummy db - migration = migrator.context[1] - migration.add(:rollback, "INSERT INTO dummy (value) VALUES (10);") + 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(1) - db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(1) + 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) - db.close - end + db.close + end - it "removes only applied migrations" do - db = memory_db - _, migrator = prepared_migrator(db) - fake_migration db - create_dummy db + it "removes only applied migrations" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db + create_dummy db - migration = migrator.context[2] - migration.add(:rollback, "INSERT INTO dummy (value) VALUES (20);") + migration = migrator.context[2] + migration.add(:rollback, "INSERT INTO dummy (value) VALUES (20);") - 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 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.close - end + db.close + end - it "applies rollback within a transaction to avoid partial execution" do - db = memory_db - _, migrator = prepared_migrator(db) - fake_migration db - 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 + + migration = migrator.context[1] + migration.add(:rollback, "INSERT INTO dummy (value) VALUES (10);") + migration.add(:rollback, "INSERT INTO foo (value)") - 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) + 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.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) - - db.close end end end - describe "#rollback(ids)" do - context "with no migrations applied" do - it "does not rollback non-applied migration" do - db = memory_db - _, migrator = prepared_migrator(db) - create_dummy db + 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 - 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);") + 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.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 + db.close + end end - end - context "with migrations applied" do - it "removes migration from the list of applied" do - db = memory_db - _, migrator = prepared_migrator(db) - fake_migration db, 1 - fake_migration db, 2 + 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 - 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 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.close - end + db.close + end - it "considers migration only once" do - db = memory_db - _, migrator = prepared_migrator(db) - fake_migration db, 1 - create_dummy db + it "considers migration only once" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db, 1 + create_dummy db - migration = migrator.context[1] - migration.add(:rollback, "INSERT INTO dummy (value) VALUES (10);") + 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(1, 1, 1, 1) - db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(1) + 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) - db.close - end + db.close + end - it "executes migration down statements" do - db = memory_db - _, migrator = prepared_migrator(db) - fake_migration db, 1 - fake_migration db, 2 - create_dummy db + 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 dummy (value) VALUES (10);") - m2 = migrator.context[2] - m2.add(:rollback, "INSERT INTO dummy (value) VALUES (20);") + 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) - 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.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 + db.close + end - it "applies rollback within a transaction to avoid partial execution" do - db = memory_db - _, migrator = prepared_migrator(db) - fake_migration db, 1 - fake_migration db, 2 - 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, 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 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.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 + db.close + end end end end From 2ace165f078438c353532978ab4f2e24bb707a16 Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Sun, 12 Oct 2025 17:03:12 +0200 Subject: [PATCH 16/34] Fix for_each_dialect macro to run PostgreSQL tests Updates the macro to properly evaluate SKIP_POSTGRESQL environment variable at compile time using env(). Removes skip flag from DIALECTS configuration hash as it's now handled directly in the macro. PostgreSQL tests now run but fail due to incomplete Dialect implementation (all methods return TODO/empty values). --- spec/drift/migrator_spec.cr | 616 ++++++++++++++++++------------------ 1 file changed, 312 insertions(+), 304 deletions(-) diff --git a/spec/drift/migrator_spec.cr b/spec/drift/migrator_spec.cr index f753c47..07e4715 100644 --- a/spec/drift/migrator_spec.cr +++ b/spec/drift/migrator_spec.cr @@ -14,19 +14,18 @@ require "../spec_helper" +require "pg" require "sqlite3" # Configuration for dialects to test DIALECTS = { sqlite3: { - url: ENV["SQLITE3_DB_URL"]? || "sqlite3:%3Amemory%3A", + url: ENV["SQLITE3_DB_URL"]? || "sqlite3:%3Amemory%3A", needs_cleanup: false, - skip: false, }, postgresql: { - url: ENV["POSTGRES_DB_URL"]? || "postgres://drift:drift@localhost:5432/drift_test", + url: ENV["POSTGRES_DB_URL"]? || "postgres://drift:drift@localhost:5432/drift_test", needs_cleanup: true, - skip: ENV["SKIP_POSTGRESQL"]? == "true", }, } @@ -94,7 +93,8 @@ end # Macro to run tests for each dialect macro for_each_dialect {% for name, config in DIALECTS %} - {% unless config[:skip] %} + {% skip_postgresql = env("SKIP_POSTGRESQL") == "true" %} + {% unless name.id == "postgresql" && skip_postgresql %} describe "with {{ name.id }}" do # Proc to get a clean DB connection for this dialect dialect_db = ->() { @@ -626,429 +626,437 @@ describe Drift::Migrator do end end - describe "#rollback_plan" do - context "with no migration applied" do - it "returns an empty list of migrations" do - db = memory_db - _, migrator = prepared_migrator(db) + 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 + db.close + end end - end - context "dealing with batches" do - it "returns the list of migrations in reverse order" do - db = memory_db - _, migrator = prepared_migrator(db) - 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]) + ids = migrator.rollback_plan + ids.should_not be_empty + ids.should eq([2, 1]) - db.close + db.close + 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 + + ids = migrator.rollback_plan + ids.should_not be_empty + ids.should eq([4]) + + db.close + end end - it "returns only the list of migrations in the last batch" do - db = memory_db - _, 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 = dialect_db.call + _, migrator = prepared_migrator(db) + 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 + db.close + end end end - context "migrations not available locally" do - it "excludes migrations not locally available" do - db = memory_db - _, migrator = prepared_migrator(db) - fake_migration db, 5 + 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.rollback_plan - ids.should be_empty + ids = migrator.reset_plan + ids.should be_empty - db.close + db.close + end end - end - end - describe "#reset_plan" do - context "with no migration applied" do - it "returns an empty list of migrations" do - db = memory_db - _, migrator = prepared_migrator(db) + 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 be_empty + ids = migrator.reset_plan + ids.should_not be_empty + ids.should eq([3, 1]) - db.close + 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 a single batch" do - it "returns a list of migrations in reverse order" do - db = memory_db - _, migrator = prepared_migrator(db) - fake_migration db, 1 - fake_migration db, 3 + 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([3, 1]) + ids = migrator.reset_plan + ids.should_not be_empty + ids.should eq([4, 2, 3, 1]) - db.close + db.close + end end + end + end - it "excludes migraitons not locally available" do - db = memory_db + for_each_dialect do + describe "#pending?" do + it "returns true when no migration was applied" 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]) + migrator.pending?.should be_true db.close end - end - context "with multiple batches" do - it "returns list of migrations in reverse order" do - db = memory_db + it "returns false when all migrations were applied" 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 + fake_migration db, 1 + fake_migration db, 2 + fake_migration db, 3 + fake_migration db, 4 - ids = migrator.reset_plan - ids.should_not be_empty - ids.should eq([4, 2, 3, 1]) + migrator.pending?.should be_false db.close end end - end - describe "#pending?" do - it "returns true when no migration was applied" do - db = memory_db - _, migrator = prepared_migrator(db) + 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) - migrator.pending?.should be_true + migrator.apply! + db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(4) - db.close - end + db.close + end + end - it "returns false when all migrations were applied" do - db = memory_db - _, migrator = prepared_migrator(db) - fake_migration db, 1 - fake_migration db, 2 - fake_migration db, 3 - fake_migration db, 4 + context "with no existing migration applied" do + it "applies all available migrations as single batch" do + db = dialect_db.call + _, migrator = prepared_migrator(db) - migrator.pending?.should be_false + 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.close - end - end + db.close + end + end - 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) + 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 - 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.scalar("SELECT MAX(batch) FROM drift_migrations;").as(Int64).should eq(2) - db.close + db.close + end end end - context "with no existing migration applied" do - it "applies all available migrations as single batch" do - db = memory_db - _, migrator = prepared_migrator(db) + describe "#reset!" do + context "with no migration applied" do + it "does nothing" 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 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 + db.close + end end - end - context "with existing batches" do - it "applies pending migrations as new batch" do - db = memory_db - _, migrator = prepared_migrator(db) - fake_migration db, 1 - fake_migration db, 3 + 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 - 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) + migrator.reset! + db.scalar("SELECT COUNT(id) FROM drift_migrations;").as(Int64).should eq(0) - db.close + db.close + end end end end - describe "#reset!" do - context "with no migration applied" do - it "does nothing" do - db = memory_db + 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) - 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) + count = 0 + migrator.before_apply do |_| + count += 1 + end + + migrator.apply(1) + count.should eq(1) db.close end - end - context "with some applied migrations" do - it "resets the migration status" do - db = memory_db + it "triggers after a migration has been applied" 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) + count = 0 + migrator.after_apply do |_, _| + count += 1 + end + + migrator.apply(1) + count.should eq(1) db.close end - end - end - describe "(apply callback cycle)" do - it "triggers before a migration is applied" do - db = memory_db - _, migrator = prepared_migrator(db) + it "triggers callbacks in sequence" do + db = dialect_db.call + _, migrator = prepared_migrator(db) - count = 0 - migrator.before_apply do |_| - count += 1 - end + events = Array(Symbol).new - migrator.apply(1) - count.should eq(1) + migrator.before_apply do |_| + events.push :before + end - db.close - end + migrator.after_apply do |_, _| + events.push :after + end - it "triggers after a migration has been applied" do - db = memory_db - _, migrator = prepared_migrator(db) + migrator.apply(1) + events.should eq([:before, :after]) - count = 0 - migrator.after_apply do |_, _| - count += 1 + db.close end - migrator.apply(1) - count.should eq(1) - - db.close - end + it "does not trigger if migration is already applied" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db, 1 - it "triggers callbacks in sequence" do - db = memory_db - _, migrator = prepared_migrator(db) + count = 0 + migrator.before_apply do |_| + count += 1 + end - events = Array(Symbol).new + migrator.after_apply do |_, _| + count += 1 + end - migrator.before_apply do |_| - events.push :before - end + migrator.apply(1) + count.should eq(0) - migrator.after_apply do |_, _| - events.push :after + db.close end - - migrator.apply(1) - events.should eq([:before, :after]) - - db.close end - it "does not trigger if migration is already applied" do - db = memory_db - _, migrator = prepared_migrator(db) - fake_migration db, 1 - - count = 0 - migrator.before_apply do |_| - count += 1 - end - - migrator.after_apply do |_, _| - count += 1 - end - - migrator.apply(1) - count.should eq(0) + 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 - db.close - end - end + count = 0 + migrator.before_rollback do |_| + count += 1 + end - describe "(rollback callback cycle)" do - it "triggers before a migration is rolled back" do - db = memory_db - _, migrator = prepared_migrator(db) - fake_migration db, 1 + migrator.rollback(1) + count.should eq(1) - count = 0 - migrator.before_rollback do |_| - count += 1 + db.close end - migrator.rollback(1) - count.should eq(1) + it "triggers after a migration has been rolled back" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db, 1 - db.close - end + count = 0 + migrator.after_rollback do |_, _| + count += 1 + end - it "triggers after a migration has been rolled back" do - db = memory_db - _, migrator = prepared_migrator(db) - 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) + it "triggers callbacks in sequence" do + db = dialect_db.call + _, migrator = prepared_migrator(db) + fake_migration db, 1 - db.close - end + events = Array(Symbol).new + migrator.before_rollback do |_| + events.push :before + end - it "triggers callbacks in sequence" do - db = memory_db - _, migrator = prepared_migrator(db) - fake_migration db, 1 + migrator.after_rollback do |_, _| + events.push :after + end - events = Array(Symbol).new - migrator.before_rollback do |_| - events.push :before - end + migrator.rollback(1) + events.should eq([:before, :after]) - migrator.after_rollback do |_, _| - events.push :after + db.close end - migrator.rollback(1) - events.should eq([:before, :after]) + it "does not trigger if migration is not applied" do + db = dialect_db.call + _, migrator = prepared_migrator(db) - db.close - end + count = 0 + migrator.before_apply do |_| + count += 1 + end - it "does not trigger if migration is not applied" do - db = memory_db - _, migrator = prepared_migrator(db) + 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) - - db.close - 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 = memory_db - _, migrator = prepared_migrator(db) - 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 + db.close + end end end - describe "#applied" do - it "returns an empty list when no migrations were applied" do - db = memory_db - _, migrator = prepared_migrator(db) + 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 + migrator.applied.should be_empty - db.close - end + db.close + end - it "returns ordered list of applied migrations" do - db = memory_db - _, migrator = prepared_migrator(db) - 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) + mig1 = entries.first + mig1.id.should eq(1) - db.close - end + db.close + end - it "returns only known applied migrations" do - db = memory_db - _, migrator = prepared_migrator(db) - fake_migration db, 2 - fake_migration db, 5 + 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) + 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 + db.close + end end end end From 777e80eb8a72a31f190dd98c87645e4c3d6f6aa2 Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Sun, 12 Oct 2025 19:34:55 +0200 Subject: [PATCH 17/34] Generalize in-spec SQL to avoid dialect specific issues Slightly adjust used queries inside specs to avoid having to write different queries per-dialect. --- spec/drift/migrator_spec.cr | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/drift/migrator_spec.cr b/spec/drift/migrator_spec.cr index 07e4715..3fb9fc9 100644 --- a/spec/drift/migrator_spec.cr +++ b/spec/drift/migrator_spec.cr @@ -302,7 +302,7 @@ describe Drift::Migrator do 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) + 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) @@ -345,7 +345,7 @@ describe Drift::Migrator do migration = migrator.context[1] migration.add(:migrate, "INSERT INTO dummy (value) VALUES (10);") - migration.add(:migrate, "INSERT INTO foo (value)") + migration.add(:migrate, "INSERT INTO foo (value);") db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) expect_raises(Exception) do @@ -515,7 +515,7 @@ describe Drift::Migrator do migration = migrator.context[1] migration.add(:rollback, "INSERT INTO dummy (value) VALUES (10);") - migration.add(:rollback, "INSERT INTO foo (value)") + migration.add(:rollback, "INSERT INTO foo (value);") db.scalar("SELECT COUNT(id) FROM dummy;").as(Int64).should eq(0) expect_raises(Exception) do @@ -609,7 +609,7 @@ describe Drift::Migrator do create_dummy db m1 = migrator.context[1] - m1.add(:rollback, "INSERT INTO foo (value)") + m1.add(:rollback, "INSERT INTO foo (value);") m2 = migrator.context[2] m2.add(:rollback, "INSERT INTO dummy (value) VALUES (10);") From f2a777ebc8515c4bd856e6a9172918fb36d7af1e Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Sun, 12 Oct 2025 19:56:28 +0200 Subject: [PATCH 18/34] Adds PostgreSQL for CI tests --- .github/workflows/test.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 62c4bb1..5942e9b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,8 +29,22 @@ jobs: test: runs-on: ubuntu-latest + services: + 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 steps: - uses: actions/checkout@v4 - uses: crystal-lang/install-crystal@v1 - run: shards install - run: crystal spec + env: + POSTGRES_DB_URL: postgres://drift:drift@postgres:5432/drift_test From b691604831a27db978d01ad4e5f57ee878e558c3 Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Sun, 12 Oct 2025 19:56:47 +0200 Subject: [PATCH 19/34] Includes PostgreSQL in the CLI Updates documentation to reflect the new database support/compatibility. --- README.md | 19 ++++++++++++++++++- src/cli.cr | 1 + 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 01d0999..d23e5ae 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,10 @@ 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): + +* PostgreSQL +* SQLite3 Drift (as library) only depends on Crystal's [`db`](https://github.com/crystal-lang/crystal-db) common API. To use it with @@ -197,6 +200,20 @@ 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! + +db.close +``` + The above is a simplified version of what happens when doing `drift migrate` in the CLI. For example, you could apply these migrations as part of your application start process. diff --git a/src/cli.cr b/src/cli.cr index 31e4281..8a5b495 100644 --- a/src/cli.cr +++ b/src/cli.cr @@ -17,6 +17,7 @@ require "option_parser" require "./drift" require "./drift/commands/*" +require "pg" require "sqlite3" module Drift From 7b24ddfc1b5005c6ba3a11d51bde3d311ed7dd7d Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Sun, 12 Oct 2025 20:09:56 +0200 Subject: [PATCH 20/34] Try mounting service as localhost --- .github/workflows/test.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5942e9b..69ce53d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,10 +41,12 @@ jobs: --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: - POSTGRES_DB_URL: postgres://drift:drift@postgres:5432/drift_test + POSTGRES_DB_URL: postgres://drift:drift@localhost:5432/drift_test From adf31a298d4a1ca71c49e747a2694adf37c9b48d Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Sun, 12 Oct 2025 20:13:39 +0200 Subject: [PATCH 21/34] Adds CHANGELOG entry for new feature --- .changes/unreleased/added-20251012-201335.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/unreleased/added-20251012-201335.yaml 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: "" From a89bd137fa7d8321a14e648351e609e9cfe2c3f5 Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Mon, 13 Oct 2025 00:09:00 +0200 Subject: [PATCH 22/34] Add MySQL driver dependency Adds crystal-lang/crystal-mysql to support MySQL dialect implementation. --- shard.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/shard.yml b/shard.yml index 760e997..b3380ef 100644 --- a/shard.yml +++ b/shard.yml @@ -12,6 +12,9 @@ dependencies: 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 From 4998f6361f16867f29b2de6a13a54d22197e98e3 Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Mon, 13 Oct 2025 00:10:01 +0200 Subject: [PATCH 23/34] Add MySQL container to Docker Compose Adds MySQL 8.0 service for local development and testing. Configures health checks and persistent volume storage. --- compose.yaml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/compose.yaml b/compose.yaml index 7d1869a..e387e1e 100644 --- a/compose.yaml +++ b/compose.yaml @@ -4,6 +4,7 @@ services: command: overmind start -f Procfile.dev working_dir: /workspace/${COMPOSE_PROJECT_NAME} depends_on: + - mysql - postgres environment: # Workaround Overmind socket issues with Vite @@ -12,6 +13,7 @@ services: # 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)` @@ -20,6 +22,21 @@ services: volumes: - .:/workspace/${COMPOSE_PROJECT_NAME}:cached + mysql: + image: mysql:8-oracle + 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: @@ -35,5 +52,7 @@ services: - postgres:/var/lib/postgresql volumes: + mysql: + driver: local postgres: driver: local From 17118f0005a24cc3e193a0826bb43b8caa68fee5 Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Mon, 13 Oct 2025 00:10:40 +0200 Subject: [PATCH 24/34] Implement MySQL dialect Adds MySQL dialect with INFORMATION_SCHEMA-based schema checking, question mark parameter placeholders, and BIGINT column types. --- src/drift/dialect.cr | 6 ++- src/drift/dialect/mysql.cr | 101 +++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 src/drift/dialect/mysql.cr diff --git a/src/drift/dialect.cr b/src/drift/dialect.cr index 552a20f..f73e2ca 100644 --- a/src/drift/dialect.cr +++ b/src/drift/dialect.cr @@ -34,10 +34,12 @@ module Drift # :ditto: def self.from_db(conn : DB::Connection) : Dialect case conn.class.name - when .starts_with?("SQLite3") - SQLite3.new + 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 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 From bcd317297918df149d3fc5160da65aa8682ac063 Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Mon, 13 Oct 2025 00:15:55 +0200 Subject: [PATCH 25/34] Add MySQL support to migrator specs Adds MySQL to DIALECTS configuration with opt-out via SKIP_MYSQL environment variable. Updates helper functions to handle MySQL-specific SQL syntax. Also fixes MySQL Docker image to 8.0 with native password authentication for compatibility with crystal-mysql driver, and corrects dialect detection for MySql:: namespace. --- compose.yaml | 3 ++- spec/drift/migrator_spec.cr | 12 ++++++++++-- src/drift/dialect.cr | 2 +- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/compose.yaml b/compose.yaml index e387e1e..3c47696 100644 --- a/compose.yaml +++ b/compose.yaml @@ -23,7 +23,8 @@ services: - .:/workspace/${COMPOSE_PROJECT_NAME}:cached mysql: - image: mysql:8-oracle + image: mysql:8.0-oracle + command: --default-authentication-plugin=mysql_native_password environment: MYSQL_DATABASE: drift_test MYSQL_USER: drift diff --git a/spec/drift/migrator_spec.cr b/spec/drift/migrator_spec.cr index 3fb9fc9..9acaf12 100644 --- a/spec/drift/migrator_spec.cr +++ b/spec/drift/migrator_spec.cr @@ -14,6 +14,7 @@ require "../spec_helper" +require "mysql" require "pg" require "sqlite3" @@ -27,6 +28,10 @@ DIALECTS = { 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 @@ -48,12 +53,14 @@ private def create_dummy(db) 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) case Drift::Dialect.from_db(db) - when Drift::Dialect::SQLite3 + 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) @@ -94,7 +101,8 @@ end macro for_each_dialect {% for name, config in DIALECTS %} {% skip_postgresql = env("SKIP_POSTGRESQL") == "true" %} - {% unless name.id == "postgresql" && skip_postgresql %} + {% 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 = ->() { diff --git a/src/drift/dialect.cr b/src/drift/dialect.cr index f73e2ca..df8d228 100644 --- a/src/drift/dialect.cr +++ b/src/drift/dialect.cr @@ -34,7 +34,7 @@ module Drift # :ditto: def self.from_db(conn : DB::Connection) : Dialect case conn.class.name - when .starts_with?("MySQL::") + when .starts_with?("MySql::") MySQL.new when .starts_with?("PG::") PostgreSQL.new From a64bda5a20a63bd34fe6f681db491ea74f93f176 Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Mon, 13 Oct 2025 00:16:20 +0200 Subject: [PATCH 26/34] Add MySQL dialect detection test Tests that MySQL connections are properly detected and return Drift::Dialect::MySQL instance. --- spec/drift/dialect_spec.cr | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/spec/drift/dialect_spec.cr b/spec/drift/dialect_spec.cr index 36a72c7..43ccc34 100644 --- a/spec/drift/dialect_spec.cr +++ b/spec/drift/dialect_spec.cr @@ -14,6 +14,7 @@ require "../spec_helper" +require "mysql" require "pg" require "sqlite3" @@ -36,5 +37,14 @@ describe Drift::Dialect do 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 From 0fb16ea72b0f3e4a2c0a2f633e5a190e7a170ae5 Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Mon, 13 Oct 2025 00:16:42 +0200 Subject: [PATCH 27/34] Add MySQL service to CI pipeline Configures MySQL 8.0 container in GitHub Actions workflow to run full test suite against all supported databases. --- .github/workflows/test.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 69ce53d..ddd24ad 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,6 +30,21 @@ jobs: test: runs-on: ubuntu-latest services: + mysql: + image: mysql:8.0-oracle + 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 + --default-authentication-plugin=mysql_native_password + ports: + - 3306:3306 postgres: image: postgres:18-alpine env: @@ -49,4 +64,5 @@ jobs: - 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 From 6798dcc788098772e97799da19254ddbbc67933f Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Mon, 13 Oct 2025 00:20:21 +0200 Subject: [PATCH 28/34] Add MySQL driver to CLI Requires mysql driver so CLI can connect to MySQL databases. --- src/cli.cr | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cli.cr b/src/cli.cr index 8a5b495..530b920 100644 --- a/src/cli.cr +++ b/src/cli.cr @@ -17,6 +17,7 @@ require "option_parser" require "./drift" require "./drift/commands/*" +require "mysql" require "pg" require "sqlite3" From dabef47d92901a7eb9eb65b7a5d0a00776b955aa Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Mon, 13 Oct 2025 00:21:13 +0200 Subject: [PATCH 29/34] Document MySQL support in README Updates supported databases list, adds MySQL usage example, and documents testing with multiple databases. --- README.md | 43 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d23e5ae..a92b66c 100644 --- a/README.md +++ b/README.md @@ -98,12 +98,13 @@ in reverse order using the information on the previously mentioned table. Drift CLI is a standalone, self-contained executable capable of connecting to 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. @@ -200,6 +201,20 @@ 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 @@ -268,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 From 64f3050973668f58500361e3623a4627dea18b23 Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Mon, 13 Oct 2025 00:36:22 +0200 Subject: [PATCH 30/34] Adds CHANGELOG entry for MySQL support --- .changes/unreleased/added-20251013-003617.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/unreleased/added-20251013-003617.yaml 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: "" From 2f69fd637244df3b8e8f9a6e6602c597574f91ac Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Mon, 13 Oct 2025 00:38:13 +0200 Subject: [PATCH 31/34] Adds MySQL container CHANGELOG entry --- .changes/unreleased/internal-20251013-003809.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/unreleased/internal-20251013-003809.yaml 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: "" From 0495b73747195cb5726e88b1695dd79efd043782 Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Mon, 13 Oct 2025 01:23:52 +0200 Subject: [PATCH 32/34] Downgrade MySQL used in tests to support old authentication Use MySQL 5.7 instead of 8.0 in CI as crystal-mysql does not support modern authentication plugin and fails regular one. --- .github/workflows/test.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ddd24ad..053c020 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,7 +31,7 @@ jobs: runs-on: ubuntu-latest services: mysql: - image: mysql:8.0-oracle + image: mysql:5.7 env: MYSQL_DATABASE: drift_test MYSQL_USER: drift @@ -42,7 +42,6 @@ jobs: --health-interval=10s --health-timeout=5s --health-retries=5 - --default-authentication-plugin=mysql_native_password ports: - 3306:3306 postgres: From 34a68046dc8952bea19d3b6978e8ebed9cd9b7ee Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Wed, 21 Jan 2026 07:28:18 -0300 Subject: [PATCH 33/34] Fix typo in migration name --- spec/drift/migrator_spec.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/drift/migrator_spec.cr b/spec/drift/migrator_spec.cr index 9acaf12..3be36ad 100644 --- a/spec/drift/migrator_spec.cr +++ b/spec/drift/migrator_spec.cr @@ -718,7 +718,7 @@ describe Drift::Migrator do db.close end - it "excludes migraitons not locally available" do + it "excludes migrations not locally available" do db = dialect_db.call _, migrator = prepared_migrator(db) fake_migration db, 1 From 323ee6478b9663ce0b09814c446fe6866a64ddaf Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Wed, 21 Jan 2026 09:59:28 -0300 Subject: [PATCH 34/34] Add batch index and use DATETIME for MySQL Add index on batch column in drift_migrations table to improve query performance when looking up migrations by batch number. The index is created using CREATE INDEX IF NOT EXISTS for SQLite3 and PostgreSQL, while MySQL uses an INFORMATION_SCHEMA check since it lacks native IF NOT EXISTS support for CREATE INDEX. Also changes MySQL applied_at column from TIMESTAMP to DATETIME for wider date range support (1000-9999 vs 1970-2038) and more consistent behavior with PostgreSQL's TIMESTAMP WITH TIME ZONE. --- src/drift/dialect/mysql.cr | 20 +++++++++++++++++++- src/drift/dialect/postgresql.cr | 5 +++++ src/drift/dialect/sqlite3.cr | 5 +++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/drift/dialect/mysql.cr b/src/drift/dialect/mysql.cr index a496ad6..df470ea 100644 --- a/src/drift/dialect/mysql.cr +++ b/src/drift/dialect/mysql.cr @@ -37,12 +37,30 @@ module Drift CREATE TABLE IF NOT EXISTS drift_migrations ( id BIGINT PRIMARY KEY NOT NULL, batch BIGINT NOT NULL, - applied_at TIMESTAMP NOT NULL, + applied_at DATETIME NOT NULL, duration_ns BIGINT NOT NULL ); SQL + sql_check_index = <<-SQL + SELECT + COUNT(*) + FROM + INFORMATION_SCHEMA.STATISTICS + WHERE + TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'drift_migrations' + AND INDEX_NAME = 'idx_drift_migrations_batch'; + SQL + + sql_create_index = <<-SQL + CREATE INDEX idx_drift_migrations_batch ON drift_migrations(batch); + SQL + conn.exec(sql_create_schema) + if conn.query_one(sql_check_index, as: Int64) == 0 + conn.exec(sql_create_index) + end end def find_migration_id(conn : DB::Connection, id : Int64) : Int64? diff --git a/src/drift/dialect/postgresql.cr b/src/drift/dialect/postgresql.cr index 7be1666..ed6c332 100644 --- a/src/drift/dialect/postgresql.cr +++ b/src/drift/dialect/postgresql.cr @@ -42,7 +42,12 @@ module Drift ); SQL + sql_create_index = <<-SQL + CREATE INDEX IF NOT EXISTS idx_drift_migrations_batch ON drift_migrations(batch); + SQL + conn.exec(sql_create_schema) + conn.exec(sql_create_index) end def find_migration_id(conn : DB::Connection, id : Int64) : Int64? diff --git a/src/drift/dialect/sqlite3.cr b/src/drift/dialect/sqlite3.cr index e6f7754..b2a9c03 100644 --- a/src/drift/dialect/sqlite3.cr +++ b/src/drift/dialect/sqlite3.cr @@ -42,7 +42,12 @@ module Drift ); SQL + sql_create_index = <<-SQL + CREATE INDEX IF NOT EXISTS idx_drift_migrations_batch ON drift_migrations(batch); + SQL + conn.exec(sql_create_schema) + conn.exec(sql_create_index) end def find_migration_id(conn : DB::Connection, id : Int64) : Int64?