Mojolicious-Command-migration/lib/Mojolicious/Command/migration.pm
package Mojolicious::Command::migration;
BEGIN {
$ENV{MOJO_MIGRATION_TMP } ||= 'tmp';
$ENV{MOJO_MIGRATION_SHARE} ||= 'share';
};
use common::sense;
use Mojo::Base 'Mojolicious::Command';
use Getopt::Long qw(GetOptionsFromArray :config no_auto_abbrev no_ignore_case);
use File::Basename;
use File::Path qw(make_path remove_tree);
use Storable qw/nstore retrieve/;
use SQL::Translator;
use SQL::Translator::Diff;
no warnings;
use Data::Dumper;
our $VERSION = 0.18;
has description => 'MySQL migration tool';
has usage => sub { shift->extract_usage };
has config => sub { shift->app->config->{db}->{mysql} };
has paths => sub {+{
deploy_status => "$ENV{MOJO_MIGRATION_TMP}/.deploy_status",
source_deploy => "$ENV{MOJO_MIGRATION_SHARE}/migrations/_source/deploy",
db_deploy => "$ENV{MOJO_MIGRATION_SHARE}/migrations/MySQL/deploy",
db_upgrade => "$ENV{MOJO_MIGRATION_SHARE}/migrations/MySQL/upgrade",
db_downgrade => "$ENV{MOJO_MIGRATION_SHARE}/migrations/MySQL/downgrade",
}};
has deployed => sub {
my $self = shift;
return {} unless -e $self->paths->{deploy_status};
return retrieve $self->paths->{deploy_status};
};
has db => sub {
my $self = shift;
return $self->app->db if $self->app->can('db');
DBI->connect('dbi:mysql:'.$self->config->{datasource}->{database},
$self->config->{user },
$self->config->{password},
{
host => $self->config->{datasource}->{host} || '', port => $self->config->{datasource}->{port},
}
);
};
has params => sub {{}};
sub run {
my $self = shift;
my @args = @_;
die $self->usage unless my $action = shift @args;
die $self->usage unless $action ~~ [qw/status prepare install upgrade downgrade rm diff/];
GetOptionsFromArray \@args,
'to-version=s' => sub { $self->params->{'to-version'} = $_[1] },
'version=s' => sub { $self->params->{'version' } = $_[1] },
'force' => sub { $self->params->{force } = 1 },
;
$self->$action;
$self->params({});
}
sub install {
my $self = shift;
my $paths = $self->paths;
my $last_version = $self->get_last_version;
unless ($last_version) {
say "Migration dont initialized. Please run <migration prepare>";
return;
}
say "Schema version: $last_version";
if (my $version = $self->deployed->{version}) {
say "Deployed database is $version";
say "A versioned schema has already been deployed, try upgrade instead.";
return;
}
if (!$self->params->{force} && !$self->db_is_empty) {
say "Database is not empty. Installing is dangerous. Try --force to skip installation";
return;
}
$last_version = $self->params->{'to-version'} if $self->params->{'to-version'};
unless (-s "$paths->{source_deploy}/$last_version/001_auto.yml") {
say "Schema $last_version not exists";
return;
}
if ($self->db_is_empty) {
say "Deploy database to $last_version";
my $source = $self->deployment_statements(
type => 'install',
version => $last_version,
);
for my $line(@$source) {
eval { $self->db->do($line) };
if ($@) {
die "Deploy failed: $@";
}
}
$self->deployed->{version} = $last_version;
$self->save_deployed;
return;
} else {
say "Force deploy to $last_version";
$self->deployed->{version} = $last_version;
$self->save_deployed;
return;
}
}
sub upgrade {
my $self = shift;
my $paths = $self->paths;
my $to_version = $self->get_last_version;
unless ($to_version) {
say "Migration dont initialized. Please run <migration prepare>";
return;
}
say "Schema version: $to_version";
unless ($self->deployed->{version}) {
say "Database is not installed. Please run <migration install>";
return;
}
if ($self->deployed->{version} == $to_version) {
say "Database is already up-to-date.";
return;
}
if ($self->params->{'to-version'} && $self->params->{'to-version'} > $to_version) {
say "Schema not exists.";
return;
}
$to_version = $self->params->{'to-version'} if $self->params->{'to-version'};
if ($self->deployed->{version} == $to_version) {
say "Database is already deployed to $to_version";
return;
}
say "Database version: ".$self->deployed->{version};
if ($self->params->{force}) {
say "Force upgrade to $to_version";
$self->deployed->{version} = $to_version;
$self->save_deployed;
return;
}
my $current = $self->deployed->{version};
for my $upgrade ($self->deployed->{version} + 1 .. $to_version) {
say "Upgrade to $upgrade";
say "+++++++++ "."$paths->{db_upgrade}/$current-$upgrade/*";
my @files = sort {$a cmp $b} glob("$paths->{db_upgrade}/$current-$upgrade/*");
say "Upgrade is empty" unless @files;
for my $file (@files) {
next unless -s $file;
say "Exec file: $file";
my $source = $self->deployment_statements(
filename => $file,
);
for my $line(@$source) {
next unless $line;
say "Exec SQL: $line";
eval { $self->db->do($line) };
if ($@) {
die "SQL failed: $@";
}
}
}
$self->deployed->{version} = $upgrade;
$self->save_deployed;
++$current;
}
}
sub downgrade {
my $self = shift;
my $paths = $self->paths;
my $last_version = $self->get_last_version;
unless ($last_version) {
say "Migration dont initialized. Please run <migration prepare>";
return;
}
say "Schema version: $last_version";
unless ($self->deployed->{version}) {
say "Database is not installed. Please run <migration install>";
return;
}
if ($self->params->{'to-version'} && $self->params->{'to-version'} > $last_version) {
say "Schema not exists.";
return;
}
my $to_version = $self->params->{'to-version'} || $self->deployed->{version} - 1;
unless ($to_version > 0) {
say "Nothing to downgrade.";
return;
}
if ($self->deployed->{version} == $to_version) {
say "Database is already deployed to $to_version";
return;
}
say "Database version: ".$self->deployed->{version};
if ($self->params->{force}) {
say "Force downgrade to $to_version";
$self->deployed->{version} = $to_version;
$self->save_deployed;
return;
}
my $current = $self->deployed->{version};
for my $downgrade ($self->deployed->{version} - 1 .. $to_version) {
say "Downgrade to $downgrade";
my @files = sort {$a cmp $b} glob("$paths->{db_downgrade}/$current-$downgrade/*");
say "Downgrade is empty" unless @files;
for my $file (@files) {
next unless -s $file;
say "Exec file: $file";
my $source = $self->deployment_statements(
filename => $file,
);
for my $line(@$source) {
next unless $line;
say "Exec SQL: $line";
eval { $self->db->do($line) };
if ($@) {
die "SQL failed: $@";
}
}
}
$self->deployed->{version} = $downgrade;
$self->save_deployed;
}
}
sub status {
my $self = shift;
my $last_version = $self->get_last_version;
unless ($last_version) {
say "Migration dont initialized. Please run <migration prepare>";
return;
}
say "Schema version: $last_version";
if (my $version = $self->deployed->{version}) {
say "Deployed database is $version";
} else {
say "Database is not deployed";
}
}
sub save_deployed {
my $self = shift;
nstore $self->deployed, $self->paths->{deploy_status};
}
sub prepare {
my $self = shift;
my $paths = $self->paths;
my $last_version = $self->get_last_version;
my $new_version = $last_version ? $last_version + 1 : 1;
if ($new_version == 1) {
say "Initialization";
} else {
say "Schema version: $last_version";
}
if (my $version = $self->deployed->{version}) {
say "Deployed database is $version";
}
if ($self->db_is_empty) {
say "Nothing to prepare. Database is empty.";
return;
}
my $deploy = $self->get_schema(to => 'MySQL');
my $error = $self->save_migration(
path => "$paths->{db_deploy}/$new_version/001_auto.sql",
data => join '', @{ $deploy->{data} },
);
die "Cant create MySQL deploy: $error" if $error;
my $deploy = $self->get_schema(to => 'YAML');
my $error = $self->save_migration(
path => "$paths->{source_deploy}/$new_version/001_auto.yml",
data => join '', @{ $deploy->{data} },
);
die "Cant create YML deploy: $error" if $error;
$deploy->{schema}->name("$paths->{source_deploy}/$new_version/001_auto.yml");
if ($new_version > 1) {
my $target_schema = $deploy->{schema};
my $source_schema = $self->get_schema(
from => 'YAML',
filename => "$paths->{source_deploy}/$last_version/001_auto.yml",
)->{schema};
my $diff = $self->_diff($target_schema, $source_schema);
if ($diff =~ /No differences/) {
say "Nothing to upgrade. Exit";
remove_tree "$paths->{source_deploy}/$new_version";
remove_tree "$paths->{db_deploy}/$new_version";
return;
} else {
my $error = $self->save_migration(
path => "$paths->{db_upgrade}/$last_version-$new_version/001_auto.sql",
data => $diff,
);
die "Cant create MySQL upgrade: $error" if $error;
my $diff = $self->_diff($source_schema, $target_schema);
my $error = $self->save_migration(
path => "$paths->{db_downgrade}/$new_version-$last_version/001_auto.sql",
data => $diff,
);
die "Cant create MySQL downgrade: $error" if $error;
}
}
say "New schema version: $new_version";
say "Deploy to $new_version";
$self->deployed->{version} = $new_version;
$self->save_deployed;
say "Done";
}
sub diff {
my $self = shift;
my $paths = $self->paths;
my $last_version = $self->get_last_version;
say "Schema version: $last_version";
if (my $version = $self->deployed->{version}) {
say "Deployed database is $version";
}
if ($self->db_is_empty) {
say "Nothing to diff. Database is empty.";
return;
}
my $schema1 = $self->get_schema(to => 'YAML')->{schema};
my $schema2 = $self->get_schema(
from => 'YAML',
filename => "$paths->{source_deploy}/$last_version/001_auto.yml",
)->{schema};
my $diff = $self->_diff($schema1, $schema2);
say "==== BEGIN SQL ====";
say $diff;
say "==== END SQL ====";
}
sub _diff {
my $self = shift;
my $schema1 = shift || return;
my $schema2 = shift || return;
for ($schema1->get_tables) {
$_->{options} = [grep {!$_->{'AUTO_INCREMENT'}} @{ $_->{options} }];
}
for ($schema2->get_tables) {
$_->{options} = [grep {!$_->{'AUTO_INCREMENT'}} @{ $_->{options} }];
}
my $diff = SQL::Translator::Diff->new({
output_db => 'MySQL',
source_schema => $schema2,
target_schema => $schema1,
ignore_index_names => 1,
ignore_constraint_names => 1,
caseopt => 1
})->compute_differences;
my $h = {};
for my $table(keys %{ $diff->{table_diff_hash} || {} }) {
for my $field (@{$diff->{table_diff_hash}->{$table}->{fields_to_create}}) {
$h->{$table}->{$field->name} = [grep {$_->order == $field->{order} - 1} $field->table->get_fields]->[0]->{name};
}
for my $field (@{$diff->{table_diff_hash}->{$table}->{fields_to_drop}}) {
$h->{$table}->{$field->name} = [grep {$_->order == $field->{order} - 1} $field->table->get_fields]->[0]->{name};
}
}
$diff = $diff->produce_diff_sql;
if (%$h) {
my @res = split "\n\n", $diff;
for my $s (@res) {
my ($t, $a) = $s =~ /ALTER TABLE ([^\s]+) ([^;]+)/;
for ($a =~ /ADD COLUMN ([^\s]+) /g) {
$s =~ s/ADD COLUMN $_ (.*)([\,\;])/ADD COLUMN `$_` $1 AFTER `$h->{$t}->{$_}`$2/g;
}
for ($a =~ /DROP COLUMN ([^\s,;]+)/g) {
$s =~ s/DROP COLUMN $_/DROP COLUMN `$_`/g;
}
}
$diff = join "\n\n", @res;
}
return $diff;
}
sub get_last_version {
my $self = shift;
my $path = $self->paths->{source_deploy};
my $last_version;
if (-e $path) {
opendir my $dh, $path or die "can't opendir $path: $!";
($last_version) = sort {$b <=> $a} readdir $dh;
closedir $dh;
}
return $last_version;
}
sub db_is_empty { @{ shift->db->selectall_arrayref('show tables', { Slice => {} }) } ? 0 : 1 }
sub save_migration {
my $self = shift;
my $p = {@_};
my $dir = dirname $p->{path};
make_path $dir unless -d $dir;
return 'No input data to save!' unless $p->{data};
open my $fh, '>', $p->{path} or return $!;
print $fh $p->{data};
close $fh;
return;
}
sub get_schema {
my $self = shift;
my $p = {@_};
my $args = join ';',
$self->config->{datasource}->{host} ? 'host='.$self->config->{datasource}->{host} : (),
$self->config->{datasource}->{port} ? 'port='.$self->config->{datasource}->{port} : (),
;
$args = ":$args" if $args;
my $translator = SQL::Translator->new(
debug => 1,
no_comments => $p->{no_comments} || 0,
$p->{filename}
?
()
:
(
parser_args => {
dsn => 'dbi:mysql:'.$self->config->{datasource}->{database}.$args,
db_user => $self->config->{user },
db_password => $self->config->{password},
},
)
);
$translator->parser($p->{from} || 'DBI');
my @output = $translator->translate(
producer => $p->{to},
$p->{filename}
?
(filename => $p->{filename})
:
()
) or die "Error: " . $translator->error;
my $schema = $translator->schema;
if ($p->{filename}) {
$schema->name($p->{filename});
}
return {
schema => $schema,
data => \@output,
};
}
sub deployment_statements {
my $self = shift;
my $p = {@_};
my $paths = $self->paths;
if ($p->{type} eq 'install') {
return $self->get_schema(
from => 'YAML',
to => 'MySQL',
filename => "$paths->{source_deploy}/$p->{version}/001_auto.yml",
no_comments => 1,
)->{data};
} else {
my $filename = $p->{filename} || "$paths->{db_$p->{type}}/$p->{from}-$p->{to}/001_auto.sql";
if(-f $filename) {
my $file;
open $file, "<$filename" or die "Can't open $filename ($!)";
my @rows = <$file>;
close $file;
return [
grep {
s/\n//g;
/(^--|^BEGIN|^COMMIT|^\s*$)/ ? 0 : 1
}
split
/\s*--.*\n|;\n/,
join '', @rows
];
}
}
return [];
}
sub rm {
my $self = shift;
my $paths = $self->paths;
say 'Params --version in required' unless my $version = $self->params->{version};
remove_tree "$paths->{source_deploy}/$version";
remove_tree "$paths->{db_deploy}/$version";
remove_tree "$paths->{db_upgrade}/".($version-1)."-$version";
remove_tree "$paths->{db_downgrade}/$version-".($version-1);
}
1;
=pod
=encoding utf8
=head1 NAME
Mojolicious::Command::migration — MySQL migration tool for Mojolicious
=head1 VERSION
version 0.18
=head1 SYNOPSIS
Usage: APPLICATION migration [COMMAND] [OPTIONS]
mojo migration prepare
Commands:
status : Current database and schema version
diff : SQL diff with last version.
install : Install a version to the database.
prepare : Makes deployment files for your database
upgrade : Upgrade the database.
downgrade : Downgrade the database.
rm : Remove files of migration by version number.
=head1 DESCRIPTION
L<Mojolicious::Command::migration> MySQL migration tool.
=head1 USAGE
L<Mojolicious::Command::migration> uses app->db for mysql connection and following configuration:
{
'user' => 'USER',
'password' => 'PASSWORD',
'datasource' => { 'database' => 'DB_NAME'},
}
from
$ app->config->{db}->{mysql}
Use can force command without saving state with param --force. Example:
$ app migration downgrade --force
All deploy files saves to relative directory 'share/'. You can change it with 'MOJO_MIGRATION_SHARE' environment.
Current project state saves to 'tmp/.deploy_status' file. You can change directory with 'MOJO_MIGRATION_TMP' environment.
Note: we create directories automatically
=head1 COMMANDS
=head2 status
$ app migration status
Schema version: 21
Deployed database is 20
Returns the state of the deployed database (if it is deployed) and the state of the current schema version. Sends this as a string to STDOUT
=head2 rm
$ app migration rm --version 123
=head2 prepare
Makes deployment files for the current schema. If deployment files exist, will fail unless you "overwrite_migrations".
# have changes
$ app migration prepare
Schema version: 21
New version is 22
Deploy to 22
# no changes
$ app migration prepare
Schema version: 21
Nothing to upgrade. Exit
=head2 install
Installs either the current schema version (if already prepared) or the target version specified via any to_version flags.
If you try to install to a database that has already been installed (not empty), you'll get an error. Use flag force to set current database to schema version without changes database.
# last
$ app migration install
Schema version: 21
Deploy database to 21
# target version
$ app migration install --to-version 10
Schema version: 21
Deploy database to 10
# force install
$ app migration install --force
Schema version: 21
Force deploy to 21
=head2 upgrade
Use flag --force to set current database to schema version without changes database.
# last
$ app migration upgrade
Schema version: 21
Database version: 20
Upgrade to 21
# target version
$ app migration upgrade --to-version 10
Schema version: 21
Database version: 8
Upgrade to 10
# force upgrade
$ app migration upgrade --force
Schema version: 21
Database version: 8
Force upgrade to 21
=head2 downgrade
Use flag --force to set current database to schema version without changes database.
# last
$ app migration downgrade
Schema version: 21
Database version: 20
Downgrade to 21
# target version
$ app migration downgrade --to-version 10
Schema version: 21
Database version: 8
Downgrade to 10
# force downgrade
$ app migration downgrade --force
Schema version: 21
Database version: 8
Force downgrade to 21
=head1 Custom upgrade and downgrade
You can customize upgrade and downgrade by adding additional SQL scripts to path of action. All scripts will be executed in alphabetical order.
# share/migration/MySQL/upgrade/10-11/001_auto.sql is automatic
# share/migration/MySQL/upgrade/10-11/002_some_script.sql is additional sctipt
$ app migration upgrade
Schema version: 11
Database version: 10
Upgrade to 11
Exec file: share/migrations/MySQL/upgrade/10-11/001_auto.sql
Exec file: share/migrations/MySQL/upgrade/10-11/002_some_script.sql
=head1 SOURCE REPOSITORY
L<https://github.com/likhatskiy/Mojolicious-Command-migration>
=head1 AUTHOR
Alexey Likhatskiy, <likhatskiy@gmail.com>
=head1 LICENSE AND COPYRIGHT
Copyright (C) 2015 "Alexey Likhatskiy"
This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.