diff --git a/.gitignore b/.gitignore index c8cf75a..42eb3be 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,10 @@ /doc/ /libs/ -/.crystal/ +/lib/ +/bin/ /.shards/ # Libraries don't need dependency lock # Dependencies will be locked in application that uses them /shard.lock - diff --git a/README.md b/README.md index 00a240d..f3447ea 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # micrate -Micrate is a database migration tool written in crystal. +Micrate is a database migration tool written in Crystal. It is inspired by [goose](https://bitbucket.org/liamstask/goose/). Some code was ported from there too, so check it out. -This is still a work in progress! +Micrate currently supports migrations for Postgres, Mysql and SQLite3, but it should be easy to add support for any other database engine with an existing [crystal-db API](https://github.com/crystal-lang/crystal-db) driver. -## Installation +## Command line To install the standalone binary tool check out the releases page, or use homebrew: @@ -15,32 +15,7 @@ $ brew tap juanedi/micrate $ brew install micrate ``` -To use the Crystal API, add this to your application's `shard.yml`: - -```yaml -dependencies: - micrate: - github: juanedi/micrate -``` - -This allows you to programatically use micrate's features. You'll see the `Micrate` module has an equivalent for every CLI command. If you need to use micrate's CLI without installing the tool (which could be convenient in a CI environment) you can create a simple script like the following: - -```crystal -#! /usr/bin/env crystal -require "micrate" - -Micrate::Cli.run -``` - -...and use it just as the binary program (after `chmod +x`ing it): -``` -$ bin/micrate dbversion -0 -``` - -## Usage - -Execute `micrate help` for usage instructions. Micrate will connect to the postgres database specified by the `PG_URL` environment variable. Support for other database engines and better configuration options is on the way! +Execute `micrate help` for usage instructions. Micrate will connect to the database specified by the `DB_URL` environment variable. To create a new migration use the `create` subcommand. For example, `micrate create add_users_table` will create a new SQL migration file with a name such as `db/migrations/20160524162446_add_users_table.sql` that looks like this: @@ -111,6 +86,32 @@ language plpgsql; -- +micrate StatementEnd ``` +## API + +To use the Crystal API, add this to your application's `shard.yml`: + +```yaml +dependencies: + micrate: + github: juanedi/micrate +``` + +This allows you to programatically use micrate's features. You'll see the `Micrate` module has an equivalent for every CLI command. If you need to use micrate's CLI without installing the tool (which could be convenient in a CI environment), you can write a runner script as follows: + +```crystal +#! /usr/bin/env crystal +# +# To build a standalone command line client, require the +# driver you wish to use and use `Micrate::Cli`. +# + +require "micrate" +require "pg" + +Micrate::DB.connection_url = "postgresql://..." +Micrate::Cli.run +``` + ## Contributing 1. Fork it ( https://github.com/juanedi/micrate/fork ) @@ -119,13 +120,6 @@ language plpgsql; 4. Push to the branch (git push origin my-new-feature) 5. Create a new Pull Request -## TODOs - - * Support for other database engines (currently only postgres is supported) - * Use common crystal API for DB access - * Multiple environments (development, test, production) - * Crystal DSL for database migrations - ## Contributors - [juanedi](https://github.com/juanedi) - creator, maintainer diff --git a/bin/micrate b/bin/micrate deleted file mode 100755 index 505516f..0000000 --- a/bin/micrate +++ /dev/null @@ -1,4 +0,0 @@ -#! /usr/bin/env crystal -require "../src/micrate" - -Micrate::Cli.run diff --git a/examples/micrate b/examples/micrate new file mode 100755 index 0000000..70bc5cd --- /dev/null +++ b/examples/micrate @@ -0,0 +1,12 @@ +#! /usr/bin/env crystal + +# +# To build a standalone command line client, require the +# driver you wish to use and use `Micrate::Cli`. +# + +require "../src/micrate" +require "pg" + +Micrate::DB.connection_url = "postgresql://..." +Micrate::Cli.run diff --git a/shard.yml b/shard.yml index ca57893..a45f252 100644 --- a/shard.yml +++ b/shard.yml @@ -1,9 +1,9 @@ name: micrate -version: 0.1.0 +version: 0.3.0 authors: - Juan Edi dependencies: - pg: - github: will/crystal-pg + db: + github: crystal-lang/crystal-db diff --git a/src/micrate.cr b/src/micrate.cr index 6e05281..6acca20 100644 --- a/src/micrate.cr +++ b/src/micrate.cr @@ -50,9 +50,9 @@ module Micrate def self.migration_status(db) : Hash(Migration, Time?) # ensure that migration table exists dbversion(db) - migration_status(migrations_by_version.values, db) + migration_status(migrations_by_version.values, db) end - + def self.migration_status(migrations : Array(Migration), db) : Hash(Migration, Time?) ({} of Migration => Time?).tap do |ret| migrations.each do |m| @@ -127,7 +127,6 @@ module Micrate end end - private def self.previous_version(current, all_versions) all_previous = all_versions.select { |version| version < current } if !all_previous.empty? @@ -154,8 +153,8 @@ module Micrate def self.migration_plan(status : Hash(Migration, Time?), current : Int, target : Int, direction) status = ({} of Int64 => Bool).tap do |h| - status.each { |migration, migrated_at| h[migration.version] = !migrated_at.nil? } - end + status.each { |migration, migrated_at| h[migration.version] = !migrated_at.nil? } + end migration_plan(status, current, target, direction) end @@ -206,7 +205,6 @@ module Micrate end class UnorderedMigrationsException < Exception - getter :versions def initialize(@versions : Array(Int64)) diff --git a/src/micrate/cli.cr b/src/micrate/cli.cr index 3eaec3a..6e286f3 100644 --- a/src/micrate/cli.cr +++ b/src/micrate/cli.cr @@ -100,10 +100,10 @@ Commands: else print_help end - rescue e: UnorderedMigrationsException + rescue e : UnorderedMigrationsException report_unordered_migrations(e.versions) exit 1 - rescue e: Exception + rescue e : Exception puts e.message exit 1 end @@ -119,4 +119,3 @@ Commands: end end end - diff --git a/src/micrate/db.cr b/src/micrate/db.cr index e42fc4b..c0702bb 100644 --- a/src/micrate/db.cr +++ b/src/micrate/db.cr @@ -1,47 +1,42 @@ -require "pg" +require "db" +require "./db/*" module Micrate module DB - @@connection_url = ENV["PG_URL"]? + @@connection_url = ENV["DB_URL"]? + + def self.connection_url + @@connection_url + end def self.connection_url=(connection_url) + @@dialect = nil @@connection_url = connection_url end def self.connect - if !@@connection_url - raise "No postgresql connection URL is configured. Please set the PG_URL environment variable." - end - - PG.connect(@@connection_url.not_nil!) + validate_connection_url + ::DB.connect(@@connection_url.not_nil!) end def self.connect(&block) - db = connect - begin + validate_connection_url + ::DB.open @@connection_url.not_nil! do |db| yield db - ensure - db.close end end def self.get_versions_last_first_order(db) - db.exec({Int64, Bool}, "SELECT version_id, is_applied from micrate_db_version ORDER BY id DESC").rows + db.query_all "SELECT version_id, is_applied from micrate_db_version ORDER BY id DESC", as: {Int64, Bool} end def self.create_migrations_table(db) - db.exec("CREATE TABLE micrate_db_version ( - id serial NOT NULL, - version_id bigint NOT NULL, - is_applied boolean NOT NULL, - tstamp timestamp NULL default now(), - PRIMARY KEY(id) - );") + dialect.query_create_migrations_table(db) end def self.record_migration(migration, direction, db) is_applied = direction == :forward - db.exec("INSERT INTO micrate_db_version (version_id, is_applied) VALUES ($1, $2);", [migration.version, is_applied]) + dialect.query_record_migration(migration, is_applied, db) end def self.exec(statement, db) @@ -49,7 +44,7 @@ module Micrate end def self.get_migration_status(migration, db) : Time? - rows = db.exec({Time, Bool}, "SELECT tstamp, is_applied FROM micrate_db_version WHERE version_id=$1 ORDER BY tstamp DESC LIMIT 1", [migration.version]).rows + rows = dialect.query_migration_status(migration, db) if !rows.empty? && rows[0][1] rows[0][0] @@ -57,5 +52,16 @@ module Micrate nil end end + + private def self.dialect + validate_connection_url + @@dialect ||= Dialect.from_connection_url(@@connection_url.not_nil!) + end + + private def self.validate_connection_url + if !@@connection_url + raise "No database connection URL is configured. Please set the DB_URL environment variable." + end + end end end diff --git a/src/micrate/db/dialect.cr b/src/micrate/db/dialect.cr new file mode 100644 index 0000000..aef2bca --- /dev/null +++ b/src/micrate/db/dialect.cr @@ -0,0 +1,21 @@ +module Micrate::DB + abstract class Dialect + abstract def query_create_migrations_table(db) + abstract def query_migration_status(migration, db) + abstract def query_record_migration(migration, is_applied, db) + + def self.from_connection_url(connection_url : String) + uri = URI.parse(connection_url) + case uri.scheme + when "postgresql", "postgres" + Postgres.new + when "mysql" + Mysql.new + when "sqlite3" + Sqlite3.new + else + raise "Could not infer SQL dialect from connection url #{connection_url}" + end + end + end +end diff --git a/src/micrate/db/mysql.cr b/src/micrate/db/mysql.cr new file mode 100644 index 0000000..0451f2b --- /dev/null +++ b/src/micrate/db/mysql.cr @@ -0,0 +1,21 @@ +module Micrate::DB + class Mysql < Dialect + def query_create_migrations_table(db) + db.exec("CREATE TABLE micrate_db_version ( + id serial NOT NULL, + version_id bigint NOT NULL, + is_applied boolean NOT NULL, + tstamp timestamp NULL default now(), + PRIMARY KEY(id) + );") + end + + def query_migration_status(migration, db) + db.query_all "SELECT tstamp, is_applied FROM micrate_db_version WHERE version_id=? ORDER BY tstamp DESC LIMIT 1", migration.version, as: {Time, Bool} + end + + def query_record_migration(migration, is_applied, db) + db.exec("INSERT INTO micrate_db_version (version_id, is_applied) VALUES (?, ?);", [migration.version, is_applied]) + end + end +end diff --git a/src/micrate/db/postgres.cr b/src/micrate/db/postgres.cr new file mode 100644 index 0000000..4364781 --- /dev/null +++ b/src/micrate/db/postgres.cr @@ -0,0 +1,21 @@ +module Micrate::DB + class Postgres < Dialect + def query_create_migrations_table(db) + db.exec("CREATE TABLE micrate_db_version ( + id serial NOT NULL, + version_id bigint NOT NULL, + is_applied boolean NOT NULL, + tstamp timestamp NULL default now(), + PRIMARY KEY(id) + );") + end + + def query_migration_status(migration, db) + db.query_all "SELECT tstamp, is_applied FROM micrate_db_version WHERE version_id=$1 ORDER BY tstamp DESC LIMIT 1", migration.version, as: {Time, Bool} + end + + def query_record_migration(migration, is_applied, db) + db.exec("INSERT INTO micrate_db_version (version_id, is_applied) VALUES ($1, $2);", [migration.version, is_applied]) + end + end +end diff --git a/src/micrate/db/sqlite3.cr b/src/micrate/db/sqlite3.cr new file mode 100644 index 0000000..2c8e087 --- /dev/null +++ b/src/micrate/db/sqlite3.cr @@ -0,0 +1,25 @@ +module Micrate::DB + class Sqlite3 < Dialect + def query_create_migrations_table(db) + # The current sqlite drive implementation does not store timestamps in the same + # format as the ones autogenerated by sqlite. + # + # As a workaround, we create timestamps locally so that the driver decides timestamp + # formats when writing and reading. + db.exec("CREATE TABLE micrate_db_version ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + version_id INTEGER NOT NULL, + is_applied INTEGER NOT NULL, + tstamp TIMESTAMP + );") + end + + def query_migration_status(migration, db) + db.query_all "SELECT tstamp, is_applied FROM micrate_db_version WHERE version_id=? ORDER BY tstamp DESC LIMIT 1", migration.version, as: {Time, Bool} + end + + def query_record_migration(migration, is_applied, db) + db.exec("INSERT INTO micrate_db_version (version_id, is_applied, tstamp) VALUES (?, ?, ?);", [migration.version, is_applied, Time.now]) + end + end +end diff --git a/src/micrate/version.cr b/src/micrate/version.cr index 4e48da3..df4fbaa 100644 --- a/src/micrate/version.cr +++ b/src/micrate/version.cr @@ -1,3 +1,3 @@ module Micrate - VERSION = "0.2.2" + VERSION = "0.3.0" end