From 6d2de24968a9501ca10f0e763fd525a21254bf48 Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Wed, 10 Jun 2026 04:48:52 +0200 Subject: [PATCH 01/13] Ignore .worktrees directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 0051e48..34ecc84 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ /database /docs/ /lib/ +/.worktrees/ NOTES.txt # Libraries don't need dependency lock From 72156ebf206259aeb74ccea199a1a07cfffec637 Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Wed, 10 Jun 2026 04:51:43 +0200 Subject: [PATCH 02/13] Move database drivers to development dependencies The library only needs the crystal-db common API, and applications already require their own driver. Only the CLI and the specs use the real drivers, and both are built from this repository. --- shard.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/shard.yml b/shard.yml index 83920dd..e0a2aa7 100644 --- a/shard.yml +++ b/shard.yml @@ -11,6 +11,13 @@ dependencies: db: github: crystal-lang/crystal-db version: ~> 0.15.0 +development_dependencies: sqlite3: github: crystal-lang/crystal-sqlite3 version: ~> 0.24.0 + mysql: + github: crystal-lang/crystal-mysql + version: ~> 0.18.0 + pg: + github: will/crystal-pg + version: ~> 0.31.0 From 01d811ac2daf2227cad6ab085dfa01ce446ca547 Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Wed, 10 Jun 2026 04:55:28 +0200 Subject: [PATCH 03/13] Extract MigrationEntry from Migrator The upcoming Dialect abstraction returns these entries, so the type needs to live outside Migrator. --- src/drift/migration_entry.cr | 30 ++++++++++++++++++++++++++++++ src/drift/migrator.cr | 14 +------------- 2 files changed, 31 insertions(+), 13 deletions(-) create mode 100644 src/drift/migration_entry.cr diff --git a/src/drift/migration_entry.cr b/src/drift/migration_entry.cr new file mode 100644 index 0000000..a2615f8 --- /dev/null +++ b/src/drift/migration_entry.cr @@ -0,0 +1,30 @@ +# Copyright 2026 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 + class MigrationEntry + include DB::Serializable + + getter id : Int64 + getter batch : Int64 + getter duration_ns : Int64 + getter applied_at : Time + + def duration + Time::Span.new(nanoseconds: duration_ns) + end + end +end diff --git a/src/drift/migrator.cr b/src/drift/migrator.cr index 2ec446b..4ba67d7 100644 --- a/src/drift/migrator.cr +++ b/src/drift/migrator.cr @@ -13,23 +13,11 @@ # limitations under the License. require "./context" +require "./migration_entry" require "db" module Drift class Migrator - class MigrationEntry - include DB::Serializable - - getter id : Int64 - getter batch : Int64 - getter duration_ns : Int64 - getter applied_at : Time - - def duration - Time::Span.new(nanoseconds: duration_ns) - end - end - getter context : Context getter db : DB::Database | DB::Connection From 6a9ecb369821571c230dc9c734ce10c4ab281c9b Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Wed, 10 Jun 2026 05:11:19 +0200 Subject: [PATCH 04/13] Introduce Dialect abstraction Moving the SQL that tracks applied migrations behind a dialect allows supporting multiple database engines without touching the Migrator API. Crystal's DB API cannot expose the URI of a live connection, so the dialect is picked from the connection class name. --- spec/drift/dialect_spec.cr | 61 +++++++++++ src/drift.cr | 4 + src/drift/dialect.cr | 169 +++++++++++++++++++++++++++++++ src/drift/dialects/mysql.cr | 29 ++++++ src/drift/dialects/postgresql.cr | 29 ++++++ src/drift/dialects/sqlite3.cr | 50 +++++++++ 6 files changed, 342 insertions(+) create mode 100644 spec/drift/dialect_spec.cr create mode 100644 src/drift/dialect.cr create mode 100644 src/drift/dialects/mysql.cr create mode 100644 src/drift/dialects/postgresql.cr create mode 100644 src/drift/dialects/sqlite3.cr diff --git a/spec/drift/dialect_spec.cr b/spec/drift/dialect_spec.cr new file mode 100644 index 0000000..fe6ceab --- /dev/null +++ b/spec/drift/dialect_spec.cr @@ -0,0 +1,61 @@ +# Copyright 2026 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" + +# A connection class crystal-db accepts but no Drift dialect knows about, +# used to exercise the UnsupportedDialectError path without a server. +private class BogusConnection < DB::Connection + def initialize + super(DB::Connection::Options.new) + end + + def build_prepared_statement(query) : DB::Statement + raise NotImplementedError.new("build_prepared_statement") + end + + def build_unprepared_statement(query) : DB::Statement + raise NotImplementedError.new("build_unprepared_statement") + end +end + +describe Drift::Dialect do + describe ".from_db" do + it "returns SQLite3 dialect for a SQLite connection" do + conn = DB.connect "sqlite3:%3Amemory%3A" + begin + Drift::Dialect.from_db(conn).should be_a(Drift::Dialects::SQLite3) + ensure + conn.close + end + end + + it "returns SQLite3 dialect for a SQLite database pool" do + db = DB.open "sqlite3:%3Amemory%3A" + begin + Drift::Dialect.from_db(db).should be_a(Drift::Dialects::SQLite3) + ensure + db.close + end + end + + it "raises UnsupportedDialectError for unknown connection classes" do + expect_raises(Drift::UnsupportedDialectError, /BogusConnection/) do + Drift::Dialect.from_db(BogusConnection.new) + end + end + end +end diff --git a/src/drift.cr b/src/drift.cr index 0f5df44..26ec848 100644 --- a/src/drift.cr +++ b/src/drift.cr @@ -33,6 +33,10 @@ module Drift class ContextError < Error end + # :nodoc: + class UnsupportedDialectError < Error + end + def self.extract_id?(filename : String) : Int64? # extract ID from filename (ID_PATTERN.match(File.basename(filename)).try &.[1]).try &.to_i64 diff --git a/src/drift/dialect.cr b/src/drift/dialect.cr new file mode 100644 index 0000000..cf51e1a --- /dev/null +++ b/src/drift/dialect.cr @@ -0,0 +1,169 @@ +# Copyright 2026 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" + +require "./migration_entry" + +module Drift + # A `Dialect` owns all the SQL used to track migrations for one database + # engine. Each method executes a single, complete query against the given + # database handle; engines that need different SQL override the whole + # method instead of assembling queries from fragments. + # The portable queries use `?` placeholders; engines with a different + # placeholder syntax (like PostgreSQL's `$1`) must override every + # parameterized method. + abstract class Dialect + alias Queriable = DB::Database | DB::Connection + + # Crystal's DB API cannot expose URI or scheme information from a live + # connection, so the dialect is selected by connection class name. + def self.from_db(conn : DB::Connection) : Dialect + case conn.class.name + when .starts_with?("MySql::") + Dialects::MySQL.new + when .starts_with?("PG::") + Dialects::PostgreSQL.new + when .starts_with?("SQLite3") + Dialects::SQLite3.new + else + raise UnsupportedDialectError.new("Unsupported database: #{conn.class.name}") + end + end + + def self.from_db(db : DB::Database) : Dialect + db.using_connection do |conn| + from_db(conn) + end + end + + # Creates the `drift_migrations` table used to track applied migrations. + abstract def create_schema!(db : Queriable) : Nil + + # Returns `true` when the `drift_migrations` table exists. + abstract def prepared?(db : Queriable) : Bool + + def applied_migrations(db : Queriable) : Array(MigrationEntry) + sql = <<-SQL + SELECT + id, batch, applied_at, duration_ns + FROM + drift_migrations + ORDER BY + id ASC; + SQL + + db.query_all(sql, as: MigrationEntry) + end + + def applied_ids(db : Queriable) : Array(Int64) + sql = <<-SQL + SELECT + id + FROM + drift_migrations + ORDER BY + id ASC; + SQL + + db.query_all(sql, as: Int64) + end + + def applied?(db : Queriable, id : Int64) : Bool + sql = <<-SQL + SELECT + id + FROM + drift_migrations + WHERE + id = ? + LIMIT + 1; + SQL + + db.query_one?(sql, id, as: Int64) == id + end + + def last_batch(db : Queriable) : Int64 + sql = <<-SQL + SELECT + COALESCE( + MAX(batch), + 0 + ) + FROM + drift_migrations + LIMIT + 1; + SQL + + db.query_one(sql, as: Int64) + end + + # IDs of every applied migration, last batch first (used by reset). + def reverse_applied_ids(db : Queriable) : Array(Int64) + sql = <<-SQL + SELECT + id + FROM + drift_migrations + ORDER BY + batch DESC, + id DESC; + SQL + + db.query_all(sql, as: Int64) + end + + # IDs of one batch, newest migration first (used by rollback). + def batch_ids(db : Queriable, batch : Int64) : Array(Int64) + sql = <<-SQL + SELECT + id + FROM + drift_migrations + WHERE + batch = ? + ORDER BY + id DESC; + SQL + + db.query_all(sql, batch, as: Int64) + end + + def insert_migration(db : Queriable, id : Int64, batch : Int64, applied_at : Time, duration_ns : Int64) : Nil + sql = <<-SQL + INSERT INTO drift_migrations + (id, batch, applied_at, duration_ns) + VALUES + (?, ?, ?, ?); + SQL + + db.exec(sql, id, batch, applied_at, duration_ns) + end + + def delete_migration(db : Queriable, id : Int64) : Nil + sql = <<-SQL + DELETE FROM + drift_migrations + WHERE + id = ?; + SQL + + db.exec(sql, id) + end + end +end + +require "./dialects/*" diff --git a/src/drift/dialects/mysql.cr b/src/drift/dialects/mysql.cr new file mode 100644 index 0000000..2465cea --- /dev/null +++ b/src/drift/dialects/mysql.cr @@ -0,0 +1,29 @@ +# Copyright 2026 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 "../dialect" + +module Drift + module Dialects + class MySQL < Dialect + def create_schema!(db : Queriable) : Nil + raise NotImplementedError.new("MySQL#create_schema!") + end + + def prepared?(db : Queriable) : Bool + raise NotImplementedError.new("MySQL#prepared?") + end + end + end +end diff --git a/src/drift/dialects/postgresql.cr b/src/drift/dialects/postgresql.cr new file mode 100644 index 0000000..264771e --- /dev/null +++ b/src/drift/dialects/postgresql.cr @@ -0,0 +1,29 @@ +# Copyright 2026 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 "../dialect" + +module Drift + module Dialects + class PostgreSQL < Dialect + def create_schema!(db : Queriable) : Nil + raise NotImplementedError.new("PostgreSQL#create_schema!") + end + + def prepared?(db : Queriable) : Bool + raise NotImplementedError.new("PostgreSQL#prepared?") + end + end + end +end diff --git a/src/drift/dialects/sqlite3.cr b/src/drift/dialects/sqlite3.cr new file mode 100644 index 0000000..c475ef6 --- /dev/null +++ b/src/drift/dialects/sqlite3.cr @@ -0,0 +1,50 @@ +# Copyright 2026 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 "../dialect" + +module Drift + module Dialects + class SQLite3 < Dialect + def create_schema!(db : Queriable) : Nil + sql = <<-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.exec(sql) + end + + def prepared?(db : Queriable) : Bool + sql = <<-SQL + SELECT + name + FROM + sqlite_schema + WHERE + type = 'table' + AND name = 'drift_migrations' + LIMIT + 1; + SQL + + db.query_one?(sql, as: String) ? true : false + end + end + end +end From abb40348730ca87aae0584936f9dd328aad0c0b6 Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Wed, 10 Jun 2026 05:21:01 +0200 Subject: [PATCH 05/13] Delegate Migrator SQL to the Dialect The Migrator keeps orchestration only and the dialect owns every query. Behavior on SQLite stays the same. --- src/drift/migrator.cr | 147 ++++++------------------------------------ 1 file changed, 21 insertions(+), 126 deletions(-) diff --git a/src/drift/migrator.cr b/src/drift/migrator.cr index 4ba67d7..855a496 100644 --- a/src/drift/migrator.cr +++ b/src/drift/migrator.cr @@ -13,6 +13,7 @@ # limitations under the License. require "./context" +require "./dialect" require "./migration_entry" require "db" @@ -24,6 +25,8 @@ module Drift alias BeforeCallback = Proc(Int64, Nil) alias AfterCallback = Proc(Int64, Time::Span, Nil) + @dialect : Dialect + @before_apply = Array(BeforeCallback).new @after_apply = Array(AfterCallback).new @@ -31,6 +34,7 @@ module Drift @after_rollback = Array(AfterCallback).new def initialize(@db, @context) + @dialect = Dialect.from_db(@db) end def self.from_path(db, path : String) @@ -49,49 +53,18 @@ 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.applied_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 == id + @dialect.applied?(db, 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.applied_ids(db)} (result_ids & Set{*context.ids}).to_a end @@ -125,35 +98,11 @@ 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) - end + @dialect.create_schema!(db) 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! @@ -161,17 +110,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.reverse_applied_ids(db)} (batch_ids & Set{*context.ids}).to_a end @@ -188,60 +127,22 @@ 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)} + last_batch = @dialect.last_batch(db) + + batch_ids = Set{*@dialect.batch_ids(db, last_batch)} (batch_ids & Set{*context.ids}).to_a end + # NOTE: On MySQL, DDL statements issue an implicit commit, so a failed + # batch can leave earlier migrations of the batch applied even though + # the surrounding transaction rolls back. SQLite and PostgreSQL support + # transactional DDL and roll back the whole batch. 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.last_batch(cnn) + 1 plan_ids.each do |id| migration = context[id] @@ -253,7 +154,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) @@ -261,16 +162,10 @@ module Drift end end + # NOTE: see `#apply_batch` about MySQL and transactional DDL. 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 @@ -281,7 +176,7 @@ module Drift duration = Time.measure { migration.run(:down, cnn) } - cnn.exec(sql_delete_migration, id) + @dialect.delete_migration(cnn, id) @after_rollback.each &.call(id, duration) end From a0701a168c99cfcaf4d41bc3e4c3693c576b3e8b Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Wed, 10 Jun 2026 10:16:32 +0200 Subject: [PATCH 06/13] Add MySQL dialect Only schema creation and catalog inspection need MySQL specific SQL, the shared tracking queries work unchanged. Coverage against a live server is gated behind MYSQL_DATABASE_URL. --- spec/drift/dialect_spec.cr | 18 ++++++++++++++++++ src/drift/dialects/mysql.cr | 25 +++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/spec/drift/dialect_spec.cr b/spec/drift/dialect_spec.cr index fe6ceab..d6d63f8 100644 --- a/spec/drift/dialect_spec.cr +++ b/spec/drift/dialect_spec.cr @@ -15,6 +15,7 @@ require "../spec_helper" require "sqlite3" +require "mysql" # A connection class crystal-db accepts but no Drift dialect knows about, # used to exercise the UnsupportedDialectError path without a server. @@ -59,3 +60,20 @@ describe Drift::Dialect do end end end + +if mysql_url = ENV["MYSQL_DATABASE_URL"]? + describe Drift::Dialect do + it "returns MySQL dialect for a MySQL connection" do + db = DB.open(mysql_url) + begin + Drift::Dialect.from_db(db).should be_a(Drift::Dialects::MySQL) + ensure + db.close + end + end + end +else + describe Drift::Dialect do + pending "MySQL dialect selection (set MYSQL_DATABASE_URL to run)" + end +end diff --git a/src/drift/dialects/mysql.cr b/src/drift/dialects/mysql.cr index 2465cea..3d4c430 100644 --- a/src/drift/dialects/mysql.cr +++ b/src/drift/dialects/mysql.cr @@ -18,11 +18,32 @@ module Drift module Dialects class MySQL < Dialect def create_schema!(db : Queriable) : Nil - raise NotImplementedError.new("MySQL#create_schema!") + sql = <<-SQL + CREATE TABLE IF NOT EXISTS drift_migrations ( + id BIGINT PRIMARY KEY NOT NULL, + batch BIGINT NOT NULL, + applied_at DATETIME(6) NOT NULL, + duration_ns BIGINT NOT NULL + ); + SQL + + db.exec(sql) end def prepared?(db : Queriable) : Bool - raise NotImplementedError.new("MySQL#prepared?") + sql = <<-SQL + SELECT + table_name + FROM + information_schema.tables + WHERE + table_schema = DATABASE() + AND table_name = 'drift_migrations' + LIMIT + 1; + SQL + + db.query_one?(sql, as: String) ? true : false end end end From 2613c2bc4dd653b923f02b2eb6f8434c061a891b Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Wed, 10 Jun 2026 10:52:12 +0200 Subject: [PATCH 07/13] Add PostgreSQL dialect crystal-pg only accepts numbered placeholders, so the parameterized queries are redefined as complete statements instead of patching the shared SQL. Coverage against a live server is gated behind POSTGRES_DATABASE_URL. --- spec/drift/dialect_spec.cr | 18 ++++++++ src/drift/dialects/postgresql.cr | 78 +++++++++++++++++++++++++++++++- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/spec/drift/dialect_spec.cr b/spec/drift/dialect_spec.cr index d6d63f8..de54e4e 100644 --- a/spec/drift/dialect_spec.cr +++ b/spec/drift/dialect_spec.cr @@ -16,6 +16,7 @@ require "../spec_helper" require "sqlite3" require "mysql" +require "pg" # A connection class crystal-db accepts but no Drift dialect knows about, # used to exercise the UnsupportedDialectError path without a server. @@ -77,3 +78,20 @@ else pending "MySQL dialect selection (set MYSQL_DATABASE_URL to run)" end end + +if pg_url = ENV["POSTGRES_DATABASE_URL"]? + describe Drift::Dialect do + it "returns PostgreSQL dialect for a PostgreSQL connection" do + db = DB.open(pg_url) + begin + Drift::Dialect.from_db(db).should be_a(Drift::Dialects::PostgreSQL) + ensure + db.close + end + end + end +else + describe Drift::Dialect do + pending "PostgreSQL dialect selection (set POSTGRES_DATABASE_URL to run)" + end +end diff --git a/src/drift/dialects/postgresql.cr b/src/drift/dialects/postgresql.cr index 264771e..e8fa974 100644 --- a/src/drift/dialects/postgresql.cr +++ b/src/drift/dialects/postgresql.cr @@ -18,11 +18,85 @@ module Drift module Dialects class PostgreSQL < Dialect def create_schema!(db : Queriable) : Nil - raise NotImplementedError.new("PostgreSQL#create_schema!") + sql = <<-SQL + CREATE TABLE IF NOT EXISTS drift_migrations ( + id BIGINT PRIMARY KEY NOT NULL, + batch BIGINT NOT NULL, + applied_at TIMESTAMPTZ NOT NULL, + duration_ns BIGINT NOT NULL + ); + SQL + + db.exec(sql) end def prepared?(db : Queriable) : Bool - raise NotImplementedError.new("PostgreSQL#prepared?") + sql = <<-SQL + SELECT + EXISTS ( + SELECT + 1 + FROM + information_schema.tables + WHERE + table_schema = current_schema() + AND table_name = 'drift_migrations' + ); + SQL + + db.query_one(sql, as: Bool) + end + + def applied?(db : Queriable, id : Int64) : Bool + sql = <<-SQL + SELECT + id + FROM + drift_migrations + WHERE + id = $1 + LIMIT + 1; + SQL + + db.query_one?(sql, id, as: Int64) == id + end + + def batch_ids(db : Queriable, batch : Int64) : Array(Int64) + sql = <<-SQL + SELECT + id + FROM + drift_migrations + WHERE + batch = $1 + ORDER BY + id DESC; + SQL + + db.query_all(sql, batch, as: Int64) + end + + def insert_migration(db : Queriable, id : Int64, batch : Int64, applied_at : Time, duration_ns : Int64) : Nil + sql = <<-SQL + INSERT INTO drift_migrations + (id, batch, applied_at, duration_ns) + VALUES + ($1, $2, $3, $4); + SQL + + db.exec(sql, id, batch, applied_at, duration_ns) + end + + def delete_migration(db : Queriable, id : Int64) : Nil + sql = <<-SQL + DELETE FROM + drift_migrations + WHERE + id = $1; + SQL + + db.exec(sql, id) end end end From e5d014cada56f11bb8b17929f9a3c881f478fbe4 Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Wed, 10 Jun 2026 10:57:37 +0200 Subject: [PATCH 08/13] Add shared migrator lifecycle spec Writing the Migrator contract once and registering it per engine holds every dialect to identical expectations. SQLite runs it first, MySQL and PostgreSQL hook in next. --- spec/drift/migrator_lifecycle_sqlite3_spec.cr | 21 +++ spec/support/migrator_lifecycle.cr | 139 ++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 spec/drift/migrator_lifecycle_sqlite3_spec.cr create mode 100644 spec/support/migrator_lifecycle.cr diff --git a/spec/drift/migrator_lifecycle_sqlite3_spec.cr b/spec/drift/migrator_lifecycle_sqlite3_spec.cr new file mode 100644 index 0000000..90adadf --- /dev/null +++ b/spec/drift/migrator_lifecycle_sqlite3_spec.cr @@ -0,0 +1,21 @@ +# Copyright 2026 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 "../support/migrator_lifecycle" + +require "sqlite3" + +it_behaves_like_a_migrator("SQLite3", Proc(LifecycleDB).new { + DB.connect "sqlite3:%3Amemory%3A" +}) diff --git a/spec/support/migrator_lifecycle.cr b/spec/support/migrator_lifecycle.cr new file mode 100644 index 0000000..7e5511b --- /dev/null +++ b/spec/support/migrator_lifecycle.cr @@ -0,0 +1,139 @@ +# Copyright 2026 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" + +alias LifecycleDB = DB::Database | DB::Connection + +# Migrations used by the cross-engine lifecycle specs. The SQL here must be +# valid on SQLite, MySQL and PostgreSQL alike. +def lifecycle_context + ctx = Drift::Context.new + + m1 = Drift::Migration.new(1) + m1.add(:up, "CREATE TABLE drift_test_people (id BIGINT NOT NULL, name VARCHAR(50) NOT NULL);") + m1.add(:down, "DROP TABLE drift_test_people;") + ctx.add m1 + + m2 = Drift::Migration.new(2) + m2.add(:up, "INSERT INTO drift_test_people (id, name) VALUES (1, 'first');") + m2.add(:down, "DELETE FROM drift_test_people;") + ctx.add m2 + + ctx +end + +# Yields a database handle with no leftover Drift state. Tables are dropped +# before (not after) each example, so an interrupted run never poisons the +# next one. In-memory SQLite starts empty, but dropping is harmless there. +def with_clean_db(factory : -> LifecycleDB, &) + db = factory.call + begin + db.exec "DROP TABLE IF EXISTS drift_migrations;" + db.exec "DROP TABLE IF EXISTS drift_test_people;" + yield db + ensure + db.close + end +end + +# Registers the Migrator behavioral contract against one engine. Every +# engine must pass exactly the same examples; only the factory differs. +def it_behaves_like_a_migrator(engine : String, factory : -> LifecycleDB) + describe "Drift::Migrator (#{engine})" do + it "prepares the migrations table" do + with_clean_db(factory) do |db| + migrator = Drift::Migrator.new(db, lifecycle_context) + + migrator.prepared?.should be_false + migrator.prepare! + migrator.prepared?.should be_true + + # prepare! is idempotent + migrator.prepare! + migrator.prepared?.should be_true + end + end + + it "applies pending migrations and records them" do + with_clean_db(factory) do |db| + migrator = Drift::Migrator.new(db, lifecycle_context) + + migrator.apply! + + migrator.pending?.should be_false + migrator.applied_ids.should eq([1, 2]) + migrator.applied?(1).should be_true + + entries = migrator.applied + entries.size.should eq(2) + + entry = entries.first + entry.id.should eq(1) + entry.batch.should eq(1) + entry.applied_at.should be_close(Time.utc, 5.seconds) + entry.duration_ns.should be >= 0 + + db.scalar("SELECT COUNT(*) FROM drift_test_people;").should eq(1) + end + end + + it "does nothing when migrations are applied again" do + with_clean_db(factory) do |db| + migrator = Drift::Migrator.new(db, lifecycle_context) + + migrator.apply! + migrator.apply! + + migrator.applied_ids.should eq([1, 2]) + db.scalar("SELECT COUNT(*) FROM drift_test_people;").should eq(1) + end + end + + it "rolls back only the last batch" do + with_clean_db(factory) do |db| + migrator = Drift::Migrator.new(db, lifecycle_context) + + migrator.prepare! + migrator.apply(1) + migrator.apply(2) # second batch + + migrator.rollback_plan.should eq([2]) + migrator.rollback(migrator.rollback_plan) + + migrator.applied_ids.should eq([1]) + db.scalar("SELECT COUNT(*) FROM drift_test_people;").should eq(0) + end + end + + it "resets all applied migrations in reverse order" do + with_clean_db(factory) do |db| + migrator = Drift::Migrator.new(db, lifecycle_context) + + migrator.apply! + + rolled_back = Array(Int64).new + migrator.before_rollback do |id| + rolled_back.push id + end + + migrator.reset! + + rolled_back.should eq([2, 1]) + migrator.applied_ids.should be_empty + migrator.pending?.should be_true + end + end + end +end From 27fe070080e4bdb62edfc1c1d2512a960fcfea79 Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Wed, 10 Jun 2026 11:09:14 +0200 Subject: [PATCH 09/13] Run lifecycle specs on MySQL and PostgreSQL A fresh clone still runs the SQLite suite with zero setup, while the development container wires the database URLs automatically. Skipped engines show as pending instead of silently shrinking the suite. MySQL 8.4 defaults to caching_sha2_password, which the crystal-mysql driver does not implement. Startup flags enable the native password plugin and make the entrypoint create the root user with it. --- compose.yaml | 38 +++++++++++++++++++ spec/drift/migrator_lifecycle_mysql_spec.cr | 27 +++++++++++++ .../migrator_lifecycle_postgresql_spec.cr | 27 +++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 spec/drift/migrator_lifecycle_mysql_spec.cr create mode 100644 spec/drift/migrator_lifecycle_postgresql_spec.cr diff --git a/compose.yaml b/compose.yaml index d366f14..6df6206 100644 --- a/compose.yaml +++ b/compose.yaml @@ -13,9 +13,47 @@ services: - OVERMIND_SOCKET=/tmp/overmind.sock # Disable Shards' postinstall - SHARDS_OPTS=--skip-postinstall + # Cross-engine specs connect to the sibling database services + - MYSQL_DATABASE_URL=mysql://root:drift@mysql:3306/drift_test + - POSTGRES_DATABASE_URL=postgres://postgres:drift@postgres:5432/drift_test + + depends_on: + mysql: + condition: service_healthy + postgres: + condition: service_healthy # Set these env variables using `export FIXUID=$(id -u) FIXGID=$(id -g)` user: ${FIXUID:-1000}:${FIXGID:-1000} volumes: - .:/workspace/${COMPOSE_PROJECT_NAME}:cached + + mysql: + image: mysql:8.4 + # crystal-mysql only speaks mysql_native_password, which MySQL 8.4 + # disables by default (and 9.x removes entirely). + command: --mysql-native-password=ON --authentication-policy=mysql_native_password,, + environment: + MYSQL_ROOT_PASSWORD: drift + MYSQL_DATABASE: drift_test + ports: + - "3306:3306" + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-pdrift"] + interval: 5s + timeout: 5s + retries: 20 + + postgres: + image: postgres:17 + environment: + POSTGRES_PASSWORD: drift + POSTGRES_DB: drift_test + ports: + - "5432:5432" + healthcheck: + test: ["CMD", "pg_isready", "-h", "127.0.0.1", "-U", "postgres"] + interval: 5s + timeout: 5s + retries: 20 diff --git a/spec/drift/migrator_lifecycle_mysql_spec.cr b/spec/drift/migrator_lifecycle_mysql_spec.cr new file mode 100644 index 0000000..fcf6609 --- /dev/null +++ b/spec/drift/migrator_lifecycle_mysql_spec.cr @@ -0,0 +1,27 @@ +# Copyright 2026 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 "../support/migrator_lifecycle" + +require "mysql" + +if mysql_url = ENV["MYSQL_DATABASE_URL"]? + it_behaves_like_a_migrator("MySQL", Proc(LifecycleDB).new { + DB.open(mysql_url) + }) +else + describe "Drift::Migrator (MySQL)" do + pending "set MYSQL_DATABASE_URL to run against a live server" + end +end diff --git a/spec/drift/migrator_lifecycle_postgresql_spec.cr b/spec/drift/migrator_lifecycle_postgresql_spec.cr new file mode 100644 index 0000000..47f6872 --- /dev/null +++ b/spec/drift/migrator_lifecycle_postgresql_spec.cr @@ -0,0 +1,27 @@ +# Copyright 2026 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 "../support/migrator_lifecycle" + +require "pg" + +if pg_url = ENV["POSTGRES_DATABASE_URL"]? + it_behaves_like_a_migrator("PostgreSQL", Proc(LifecycleDB).new { + DB.open(pg_url) + }) +else + describe "Drift::Migrator (PostgreSQL)" do + pending "set POSTGRES_DATABASE_URL to run against a live server" + end +end From e170e80270944750e125183d287e43cadad181db Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Wed, 10 Jun 2026 11:19:32 +0200 Subject: [PATCH 10/13] Support MySQL and PostgreSQL in the CLI The binary can now migrate any of the three engines by connection URI. Both drivers are pure Crystal, so static builds stay self-contained. --- src/cli.cr | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/cli.cr b/src/cli.cr index 31e4281..f097219 100644 --- a/src/cli.cr +++ b/src/cli.cr @@ -18,6 +18,8 @@ require "./drift" require "./drift/commands/*" require "sqlite3" +require "mysql" +require "pg" module Drift class CLI From 61515fee18b18ccf690014e59304c875eaf3721b Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Wed, 10 Jun 2026 11:41:08 +0200 Subject: [PATCH 11/13] Run cross-engine specs in CI Service containers keep the gated groups always running on pull requests, so a dialect regression fails the build even when it was never exercised locally. MySQL needs server flags that service containers cannot pass, so it starts via docker run instead. --- .github/workflows/test.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f42e1c7..ca60265 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,8 +38,35 @@ jobs: test: runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_PASSWORD: drift + POSTGRES_DB: drift_test + ports: + - "5432:5432" + options: >- + --health-cmd "pg_isready -h 127.0.0.1 -U postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 20 + env: + MYSQL_DATABASE_URL: mysql://root:drift@127.0.0.1:3306/drift_test + POSTGRES_DATABASE_URL: postgres://postgres:drift@127.0.0.1:5432/drift_test steps: - uses: actions/checkout@v4 - uses: crystal-lang/install-crystal@v1 + # crystal-mysql only speaks mysql_native_password, which MySQL 8.4 + # disables by default; service containers cannot pass server flags, + # so MySQL starts via docker run instead. + - name: Start MySQL + run: | + docker run --detach --name mysql --publish 3306:3306 \ + --env MYSQL_ROOT_PASSWORD=drift --env MYSQL_DATABASE=drift_test \ + mysql:8.4 --mysql-native-password=ON --authentication-policy=mysql_native_password,, - run: shards install + - name: Wait for MySQL + run: | + timeout 90 sh -c 'until docker exec mysql mysqladmin ping -h 127.0.0.1 --silent; do sleep 2; done' || { docker logs mysql; exit 1; } - run: crystal spec From 1a8d6897cae33189f7f476840e59e6dfde0a8da1 Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Wed, 10 Jun 2026 11:58:35 +0200 Subject: [PATCH 12/13] Document multi-database support Readers need to know which driver to require and what to expect from each engine, including MySQL's implicit commits during failed batches. --- .../unreleased/added-20260610-095806.yaml | 5 ++ .../unreleased/changed-20260610-095807.yaml | 5 ++ .../unreleased/changed-20260610-095808.yaml | 5 ++ AGENTS.md | 9 ++++ README.md | 46 ++++++++++++++++++- 5 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 .changes/unreleased/added-20260610-095806.yaml create mode 100644 .changes/unreleased/changed-20260610-095807.yaml create mode 100644 .changes/unreleased/changed-20260610-095808.yaml diff --git a/.changes/unreleased/added-20260610-095806.yaml b/.changes/unreleased/added-20260610-095806.yaml new file mode 100644 index 0000000..1c59a50 --- /dev/null +++ b/.changes/unreleased/added-20260610-095806.yaml @@ -0,0 +1,5 @@ +kind: added +body: Support MySQL and PostgreSQL databases in the Migrator and the CLI +time: 2026-06-10T09:58:06.000000+00:00 +custom: + Issue: "" diff --git a/.changes/unreleased/changed-20260610-095807.yaml b/.changes/unreleased/changed-20260610-095807.yaml new file mode 100644 index 0000000..879caab --- /dev/null +++ b/.changes/unreleased/changed-20260610-095807.yaml @@ -0,0 +1,5 @@ +kind: changed +body: Move database drivers (sqlite3, mysql, pg) to development dependencies; applications require the driver they use +time: 2026-06-10T09:58:07.000000+00:00 +custom: + Issue: "" diff --git a/.changes/unreleased/changed-20260610-095808.yaml b/.changes/unreleased/changed-20260610-095808.yaml new file mode 100644 index 0000000..321ffc3 --- /dev/null +++ b/.changes/unreleased/changed-20260610-095808.yaml @@ -0,0 +1,5 @@ +kind: changed +body: Rename Drift::Migrator::MigrationEntry to Drift::MigrationEntry; update type annotations that reference the nested name +time: 2026-06-10T09:58:08.000000+00:00 +custom: + Issue: "" diff --git a/AGENTS.md b/AGENTS.md index 2a22afe..d7bb17a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,9 @@ src/ ├── migration.cr # SQL parser ├── migrator.cr # Migration orchestrator ├── context.cr # Migration collection manager + ├── dialect.cr # Dialect base class + selection (from_db) + ├── dialects/ # Per-engine SQL (sqlite3, mysql, postgresql) + ├── migration_entry.cr # Applied migration record ├── embed.cr # Macro for embedding migrations ├── commands/ # CLI commands (migrate, rollback, reset, status, new, version) └── support/migrations_loader.cr @@ -64,6 +67,12 @@ crystal spec # All tests crystal spec spec/drift/migration_spec.cr # Specific file crystal spec --verbose # Verbose +# Cross-engine specs (MySQL/PostgreSQL) skip unless these are set. The app +# container in compose.yaml sets them already; from the host: +docker compose up -d mysql postgres +export MYSQL_DATABASE_URL="mysql://root:drift@127.0.0.1:3306/drift_test" +export POSTGRES_DATABASE_URL="postgres://postgres:drift@127.0.0.1:5432/drift_test" + # Format (enforced by CI) crystal tool format src/ spec/ # Apply crystal tool format --check src/ spec/ # Check only diff --git a/README.md b/README.md index d0c340d..6de5ac8 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ 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. +SQLite, MySQL and PostgreSQL databases. Drift (as library) only depends on Crystal's [`db`](https://github.com/crystal-lang/crystal-db) common API. To use it with @@ -201,6 +201,28 @@ 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. +#### Supported databases + +Drift works with SQLite, MySQL and PostgreSQL. It detects the engine from +the connection and uses the matching SQL to track applied migrations, so +they work the same way independently of the database you choose. + +Add the driver for your database to `shard.yml` and require it before Drift: + +* SQLite: [crystal-sqlite3](https://github.com/crystal-lang/crystal-sqlite3) +* MySQL: [crystal-mysql](https://github.com/crystal-lang/crystal-mysql) +* PostgreSQL: [crystal-pg](https://github.com/will/crystal-pg) + +```crystal +require "sqlite3" # or "mysql" or "pg" +require "drift" +``` + +Note: MySQL cannot roll back a failed migration batch. Every DDL statement +(Eg. `CREATE TABLE` or `ALTER TABLE`) triggers an implicit commit, so the +migrations applied earlier in that batch stay applied. SQLite and PostgreSQL +support transactional DDL, so a failed batch rolls back as a whole. + Internally, the library interconnects the following elements: A **migration** (`Drift::Migration`): represents each individual SQL file. It @@ -313,6 +335,28 @@ In this pattern, `embed_as` generates an instance method named `context` that satisfies the abstract method requirement from `Migratable`. Each database class can embed its own set of migrations while sharing the common migration logic. +## Development + +Specs against MySQL and PostgreSQL need a running server, so they are +skipped (shown as pending) unless `MYSQL_DATABASE_URL` and +`POSTGRES_DATABASE_URL` are set. SQLite specs always run, no setup needed. + +The development container starts both databases and sets both variables +for you: + +```console +$ docker compose run --rm app -- crystal spec +``` + +To run the specs from the host instead: + +```console +$ docker compose up -d mysql postgres +$ export MYSQL_DATABASE_URL="mysql://root:drift@127.0.0.1:3306/drift_test" +$ export POSTGRES_DATABASE_URL="postgres://postgres:drift@127.0.0.1:5432/drift_test" +$ crystal spec +``` + ## Contribution policy Inspired by [Litestream](https://github.com/benbjohnson/litestream) and From cf0a53145a03fba00869b8557cf61a210662160f Mon Sep 17 00:00:00 2001 From: Luis Lavena Date: Sun, 25 Oct 2026 03:33:41 +0100 Subject: [PATCH 13/13] Select dialect by driver name crystal-db 0.15 adds `Connection#driver_name`, so Drift no longer needs to guess the engine from the connection class name. It also makes the spec stub connection fail to compile until it defines the method. The lifecycle specs now take a connection URL instead of a factory proc. This is shorter and passes the Ameba check added on main. --- spec/drift/dialect_spec.cr | 10 +++++++--- spec/drift/migrator_lifecycle_mysql_spec.cr | 4 +--- .../migrator_lifecycle_postgresql_spec.cr | 4 +--- spec/drift/migrator_lifecycle_sqlite3_spec.cr | 4 +--- spec/support/migrator_lifecycle.cr | 20 +++++++++---------- src/drift/dialect.cr | 12 +++++------ 6 files changed, 24 insertions(+), 30 deletions(-) diff --git a/spec/drift/dialect_spec.cr b/spec/drift/dialect_spec.cr index de54e4e..8a87776 100644 --- a/spec/drift/dialect_spec.cr +++ b/spec/drift/dialect_spec.cr @@ -18,13 +18,17 @@ require "sqlite3" require "mysql" require "pg" -# A connection class crystal-db accepts but no Drift dialect knows about, +# A connection crystal-db accepts but no Drift dialect knows about, # used to exercise the UnsupportedDialectError path without a server. private class BogusConnection < DB::Connection def initialize super(DB::Connection::Options.new) end + def driver_name : String + "bogus" + end + def build_prepared_statement(query) : DB::Statement raise NotImplementedError.new("build_prepared_statement") end @@ -54,8 +58,8 @@ describe Drift::Dialect do end end - it "raises UnsupportedDialectError for unknown connection classes" do - expect_raises(Drift::UnsupportedDialectError, /BogusConnection/) do + it "raises UnsupportedDialectError for unknown drivers" do + expect_raises(Drift::UnsupportedDialectError, /bogus/) do Drift::Dialect.from_db(BogusConnection.new) end end diff --git a/spec/drift/migrator_lifecycle_mysql_spec.cr b/spec/drift/migrator_lifecycle_mysql_spec.cr index fcf6609..0c13268 100644 --- a/spec/drift/migrator_lifecycle_mysql_spec.cr +++ b/spec/drift/migrator_lifecycle_mysql_spec.cr @@ -17,9 +17,7 @@ require "../support/migrator_lifecycle" require "mysql" if mysql_url = ENV["MYSQL_DATABASE_URL"]? - it_behaves_like_a_migrator("MySQL", Proc(LifecycleDB).new { - DB.open(mysql_url) - }) + it_behaves_like_a_migrator("MySQL", mysql_url) else describe "Drift::Migrator (MySQL)" do pending "set MYSQL_DATABASE_URL to run against a live server" diff --git a/spec/drift/migrator_lifecycle_postgresql_spec.cr b/spec/drift/migrator_lifecycle_postgresql_spec.cr index 47f6872..7b6f8b9 100644 --- a/spec/drift/migrator_lifecycle_postgresql_spec.cr +++ b/spec/drift/migrator_lifecycle_postgresql_spec.cr @@ -17,9 +17,7 @@ require "../support/migrator_lifecycle" require "pg" if pg_url = ENV["POSTGRES_DATABASE_URL"]? - it_behaves_like_a_migrator("PostgreSQL", Proc(LifecycleDB).new { - DB.open(pg_url) - }) + it_behaves_like_a_migrator("PostgreSQL", pg_url) else describe "Drift::Migrator (PostgreSQL)" do pending "set POSTGRES_DATABASE_URL to run against a live server" diff --git a/spec/drift/migrator_lifecycle_sqlite3_spec.cr b/spec/drift/migrator_lifecycle_sqlite3_spec.cr index 90adadf..a4c2867 100644 --- a/spec/drift/migrator_lifecycle_sqlite3_spec.cr +++ b/spec/drift/migrator_lifecycle_sqlite3_spec.cr @@ -16,6 +16,4 @@ require "../support/migrator_lifecycle" require "sqlite3" -it_behaves_like_a_migrator("SQLite3", Proc(LifecycleDB).new { - DB.connect "sqlite3:%3Amemory%3A" -}) +it_behaves_like_a_migrator("SQLite3", "sqlite3:%3Amemory%3A") diff --git a/spec/support/migrator_lifecycle.cr b/spec/support/migrator_lifecycle.cr index 7e5511b..b3d7eec 100644 --- a/spec/support/migrator_lifecycle.cr +++ b/spec/support/migrator_lifecycle.cr @@ -14,8 +14,6 @@ require "../spec_helper" -alias LifecycleDB = DB::Database | DB::Connection - # Migrations used by the cross-engine lifecycle specs. The SQL here must be # valid on SQLite, MySQL and PostgreSQL alike. def lifecycle_context @@ -37,8 +35,8 @@ end # Yields a database handle with no leftover Drift state. Tables are dropped # before (not after) each example, so an interrupted run never poisons the # next one. In-memory SQLite starts empty, but dropping is harmless there. -def with_clean_db(factory : -> LifecycleDB, &) - db = factory.call +def with_clean_db(url : String, &) + db = DB.connect(url) begin db.exec "DROP TABLE IF EXISTS drift_migrations;" db.exec "DROP TABLE IF EXISTS drift_test_people;" @@ -49,11 +47,11 @@ def with_clean_db(factory : -> LifecycleDB, &) end # Registers the Migrator behavioral contract against one engine. Every -# engine must pass exactly the same examples; only the factory differs. -def it_behaves_like_a_migrator(engine : String, factory : -> LifecycleDB) +# engine must pass exactly the same examples; only the URL differs. +def it_behaves_like_a_migrator(engine : String, url : String) describe "Drift::Migrator (#{engine})" do it "prepares the migrations table" do - with_clean_db(factory) do |db| + with_clean_db(url) do |db| migrator = Drift::Migrator.new(db, lifecycle_context) migrator.prepared?.should be_false @@ -67,7 +65,7 @@ def it_behaves_like_a_migrator(engine : String, factory : -> LifecycleDB) end it "applies pending migrations and records them" do - with_clean_db(factory) do |db| + with_clean_db(url) do |db| migrator = Drift::Migrator.new(db, lifecycle_context) migrator.apply! @@ -90,7 +88,7 @@ def it_behaves_like_a_migrator(engine : String, factory : -> LifecycleDB) end it "does nothing when migrations are applied again" do - with_clean_db(factory) do |db| + with_clean_db(url) do |db| migrator = Drift::Migrator.new(db, lifecycle_context) migrator.apply! @@ -102,7 +100,7 @@ def it_behaves_like_a_migrator(engine : String, factory : -> LifecycleDB) end it "rolls back only the last batch" do - with_clean_db(factory) do |db| + with_clean_db(url) do |db| migrator = Drift::Migrator.new(db, lifecycle_context) migrator.prepare! @@ -118,7 +116,7 @@ def it_behaves_like_a_migrator(engine : String, factory : -> LifecycleDB) end it "resets all applied migrations in reverse order" do - with_clean_db(factory) do |db| + with_clean_db(url) do |db| migrator = Drift::Migrator.new(db, lifecycle_context) migrator.apply! diff --git a/src/drift/dialect.cr b/src/drift/dialect.cr index cf51e1a..514837b 100644 --- a/src/drift/dialect.cr +++ b/src/drift/dialect.cr @@ -27,18 +27,16 @@ module Drift abstract class Dialect alias Queriable = DB::Database | DB::Connection - # Crystal's DB API cannot expose URI or scheme information from a live - # connection, so the dialect is selected by connection class name. def self.from_db(conn : DB::Connection) : Dialect - case conn.class.name - when .starts_with?("MySql::") + case conn.driver_name + when "mysql" Dialects::MySQL.new - when .starts_with?("PG::") + when "postgres" Dialects::PostgreSQL.new - when .starts_with?("SQLite3") + when "sqlite3" Dialects::SQLite3.new else - raise UnsupportedDialectError.new("Unsupported database: #{conn.class.name}") + raise UnsupportedDialectError.new("Unsupported database: #{conn.driver_name}") end end