File Coverage

lib/Mojolicious/Command/migration.pm
Criterion Covered Total %
statement 36 312 11.5
branch 3 122 2.4
condition 2 27 7.4
subroutine 12 29 41.3
pod 7 14 50.0
total 60 504 11.9


line stmt bran cond sub pod time code
1             package Mojolicious::Command::migration;
2              
3             BEGIN {
4 1   50 1   751 $ENV{MOJO_MIGRATION_TMP } ||= 'tmp';
5 1   50     30 $ENV{MOJO_MIGRATION_SHARE} ||= 'share';
6             };
7              
8 1     1   482 use common::sense;
  1         13  
  1         6  
9 1     1   59 use Mojo::Base 'Mojolicious::Command';
  1         2  
  1         8  
10 1     1   48356 use Getopt::Long qw(GetOptionsFromArray :config no_auto_abbrev no_ignore_case);
  1         3  
  1         10  
11 1     1   233 use File::Basename;
  1         2  
  1         69  
12 1     1   6 use File::Path qw(make_path remove_tree);
  1         3  
  1         50  
13 1     1   707 use Storable qw/nstore retrieve/;
  1         3178  
  1         74  
14 1     1   612 use SQL::Translator;
  1         289606  
  1         43  
15 1     1   492 use SQL::Translator::Diff;
  1         10763  
  1         37  
16 1     1   8 no warnings;
  1         2  
  1         33  
17 1     1   6 use Data::Dumper;
  1         2  
  1         4911  
18              
19             our $VERSION = 0.16;
20              
21             has description => 'MySQL migration tool';
22             has usage => sub { shift->extract_usage };
23             has config => sub { shift->app->config->{db}->{mysql} };
24             has paths => sub {+{
25             deploy_status => "$ENV{MOJO_MIGRATION_TMP}/.deploy_status",
26             source_deploy => "$ENV{MOJO_MIGRATION_SHARE}/migrations/_source/deploy",
27             db_deploy => "$ENV{MOJO_MIGRATION_SHARE}/migrations/MySQL/deploy",
28             db_upgrade => "$ENV{MOJO_MIGRATION_SHARE}/migrations/MySQL/upgrade",
29             db_downgrade => "$ENV{MOJO_MIGRATION_SHARE}/migrations/MySQL/downgrade",
30             }};
31             has deployed => sub {
32             my $self = shift;
33             return {} unless -e $self->paths->{deploy_status};
34             return retrieve $self->paths->{deploy_status};
35             };
36             has db => sub {
37             my $self = shift;
38             return $self->app->db if $self->app->can('db');
39             DBI->connect('dbi:mysql:'.$self->config->{datasource}->{database},
40             $self->config->{user },
41             $self->config->{password},
42             );
43             };
44             has params => sub {{}};
45              
46             sub run {
47 2     2 1 43090 my $self = shift;
48 2         6 my @args = @_;
49              
50 2 100       10 die $self->usage unless my $action = shift @args;
51 1 50       33 die $self->usage unless $action ~~ [qw/status prepare install upgrade downgrade rm diff/];
52              
53             GetOptionsFromArray \@args,
54 0     0     'to-version=s' => sub { $self->params->{'to-version'} = $_[1] },
55 0     0     'version=s' => sub { $self->params->{'version' } = $_[1] },
56 0     0     'force' => sub { $self->params->{force } = 1 },
57 0           ;
58              
59 0           $self->$action;
60              
61 0           $self->params({});
62             }
63              
64             sub install {
65 0     0 1   my $self = shift;
66 0           my $paths = $self->paths;
67              
68 0           my $last_version = $self->get_last_version;
69              
70 0 0         unless ($last_version) {
71 0           say "Migration dont initialized. Please run ";
72              
73 0           return;
74             }
75              
76 0           say "Schema version: $last_version";
77              
78 0 0         if (my $version = $self->deployed->{version}) {
79 0           say "Deployed database is $version";
80 0           say "A versioned schema has already been deployed, try upgrade instead.";
81              
82 0           return;
83             }
84              
85 0 0 0       if (!$self->params->{force} && !$self->db_is_empty) {
86 0           say "Database is not empty. Installing is dangerous. Try --force to skip installation";
87              
88 0           return;
89             }
90              
91 0 0         $last_version = $self->params->{'to-version'} if $self->params->{'to-version'};
92              
93 0 0         unless (-s "$paths->{source_deploy}/$last_version/001_auto.yml") {
94 0           say "Schema $last_version not exists";
95              
96 0           return;
97             }
98              
99 0 0         if ($self->db_is_empty) {
100 0           say "Deploy database to $last_version";
101              
102 0           my $source = $self->deployment_statements(
103             type => 'install',
104             version => $last_version,
105             );
106              
107 0           for my $line(@$source) {
108 0           eval { $self->db->do($line) };
  0            
109              
110 0 0         if ($@) {
111 0           die "Deploy failed: $@";
112             }
113             }
114              
115 0           $self->deployed->{version} = $last_version;
116 0           $self->save_deployed;
117              
118 0           return;
119             } else {
120 0           say "Force deploy to $last_version";
121 0           $self->deployed->{version} = $last_version;
122 0           $self->save_deployed;
123              
124 0           return;
125             }
126             }
127              
128             sub upgrade {
129 0     0 1   my $self = shift;
130 0           my $paths = $self->paths;
131              
132 0           my $to_version = $self->get_last_version;
133              
134 0 0         unless ($to_version) {
135 0           say "Migration dont initialized. Please run ";
136              
137 0           return;
138             }
139              
140 0           say "Schema version: $to_version";
141              
142 0 0         unless ($self->deployed->{version}) {
143 0           say "Database is not installed. Please run ";
144              
145 0           return;
146             }
147              
148 0 0         if ($self->deployed->{version} == $to_version) {
149 0           say "Database is already up-to-date.";
150              
151 0           return;
152             }
153              
154 0 0 0       if ($self->params->{'to-version'} && $self->params->{'to-version'} > $to_version) {
155 0           say "Schema not exists.";
156              
157 0           return;
158             }
159              
160 0 0         $to_version = $self->params->{'to-version'} if $self->params->{'to-version'};
161              
162 0 0         if ($self->deployed->{version} == $to_version) {
163 0           say "Database is already deployed to $to_version";
164              
165 0           return;
166             }
167              
168 0           say "Database version: ".$self->deployed->{version};
169              
170 0 0         if ($self->params->{force}) {
171 0           say "Force upgrade to $to_version";
172 0           $self->deployed->{version} = $to_version;
173 0           $self->save_deployed;
174 0           return;
175             }
176              
177 0           my $current = $self->deployed->{version};
178 0           for my $upgrade ($self->deployed->{version} + 1 .. $to_version) {
179 0           say "Upgrade to $upgrade";
180 0           say "+++++++++ "."$paths->{db_upgrade}/$current-$upgrade/*";
181 0           my @files = sort {$a cmp $b} glob("$paths->{db_upgrade}/$current-$upgrade/*");
  0            
182 0 0         say "Upgrade is empty" unless @files;
183              
184 0           for my $file (@files) {
185 0 0         next unless -s $file;
186 0           say "Exec file: $file";
187              
188 0           my $source = $self->deployment_statements(
189             filename => $file,
190             );
191              
192 0           for my $line(@$source) {
193 0 0         next unless $line;
194              
195 0           say "Exec SQL: $line";
196              
197 0           eval { $self->db->do($line) };
  0            
198              
199 0 0         if ($@) {
200 0           die "SQL failed: $@";
201             }
202             }
203             }
204              
205 0           $self->deployed->{version} = $upgrade;
206 0           $self->save_deployed;
207 0           ++$current;
208             }
209             }
210              
211             sub downgrade {
212 0     0 1   my $self = shift;
213 0           my $paths = $self->paths;
214              
215 0           my $last_version = $self->get_last_version;
216              
217 0 0         unless ($last_version) {
218 0           say "Migration dont initialized. Please run ";
219              
220 0           return;
221             }
222              
223 0           say "Schema version: $last_version";
224              
225 0 0         unless ($self->deployed->{version}) {
226 0           say "Database is not installed. Please run ";
227              
228 0           return;
229             }
230              
231 0 0 0       if ($self->params->{'to-version'} && $self->params->{'to-version'} > $last_version) {
232 0           say "Schema not exists.";
233              
234 0           return;
235             }
236              
237 0   0       my $to_version = $self->params->{'to-version'} || $self->deployed->{version} - 1;
238              
239 0 0         unless ($to_version > 0) {
240 0           say "Nothing to downgrade.";
241              
242 0           return;
243             }
244              
245 0 0         if ($self->deployed->{version} == $to_version) {
246 0           say "Database is already deployed to $to_version";
247              
248 0           return;
249             }
250              
251 0           say "Database version: ".$self->deployed->{version};
252              
253 0 0         if ($self->params->{force}) {
254 0           say "Force downgrade to $to_version";
255 0           $self->deployed->{version} = $to_version;
256 0           $self->save_deployed;
257 0           return;
258             }
259              
260 0           my $current = $self->deployed->{version};
261 0           for my $downgrade ($self->deployed->{version} - 1 .. $to_version) {
262 0           say "Downgrade to $downgrade";
263 0           my @files = sort {$a cmp $b} glob("$paths->{db_downgrade}/$current-$downgrade/*");
  0            
264 0 0         say "Downgrade is empty" unless @files;
265              
266 0           for my $file (@files) {
267 0 0         next unless -s $file;
268 0           say "Exec file: $file";
269              
270 0           my $source = $self->deployment_statements(
271             filename => $file,
272             );
273              
274 0           for my $line(@$source) {
275 0 0         next unless $line;
276              
277 0           say "Exec SQL: $line";
278              
279 0           eval { $self->db->do($line) };
  0            
280              
281 0 0         if ($@) {
282 0           die "SQL failed: $@";
283             }
284             }
285             }
286              
287 0           $self->deployed->{version} = $downgrade;
288 0           $self->save_deployed;
289             }
290             }
291              
292             sub status {
293 0     0 1   my $self = shift;
294              
295 0           my $last_version = $self->get_last_version;
296              
297 0 0         unless ($last_version) {
298 0           say "Migration dont initialized. Please run ";
299              
300 0           return;
301             }
302 0           say "Schema version: $last_version";
303              
304 0 0         if (my $version = $self->deployed->{version}) {
305 0           say "Deployed database is $version";
306             } else {
307 0           say "Database is not deployed";
308             }
309              
310             }
311              
312             sub save_deployed {
313 0     0 0   my $self = shift;
314 0           nstore $self->deployed, $self->paths->{deploy_status};
315             }
316              
317             sub prepare {
318 0     0 1   my $self = shift;
319 0           my $paths = $self->paths;
320              
321 0           my $last_version = $self->get_last_version;
322 0 0         my $new_version = $last_version ? $last_version + 1 : 1;
323              
324 0 0         if ($new_version == 1) {
325 0           say "Initialization";
326             } else {
327 0           say "Schema version: $last_version";
328             }
329              
330 0 0         if (my $version = $self->deployed->{version}) {
331 0           say "Deployed database is $version";
332             }
333              
334 0 0         if ($self->db_is_empty) {
335 0           say "Nothing to prepare. Database is empty.";
336              
337 0           return;
338             }
339              
340 0           my $deploy = $self->get_schema(to => 'MySQL');
341             my $error = $self->save_migration(
342             path => "$paths->{db_deploy}/$new_version/001_auto.sql",
343 0           data => join '', @{ $deploy->{data} },
  0            
344             );
345 0 0         die "Cant create MySQL deploy: $error" if $error;
346              
347 0           my $deploy = $self->get_schema(to => 'YAML');
348             my $error = $self->save_migration(
349             path => "$paths->{source_deploy}/$new_version/001_auto.yml",
350 0           data => join '', @{ $deploy->{data} },
  0            
351             );
352 0 0         die "Cant create YML deploy: $error" if $error;
353              
354 0           $deploy->{schema}->name("$paths->{source_deploy}/$new_version/001_auto.yml");
355              
356 0 0         if ($new_version > 1) {
357 0           my $target_schema = $deploy->{schema};
358             my $source_schema = $self->get_schema(
359             from => 'YAML',
360             filename => "$paths->{source_deploy}/$last_version/001_auto.yml",
361 0           )->{schema};
362              
363 0           my $diff = $self->_diff($target_schema, $source_schema);
364            
365 0 0         if ($diff =~ /No differences/) {
366 0           say "Nothing to upgrade. Exit";
367              
368 0           remove_tree "$paths->{source_deploy}/$new_version";
369 0           remove_tree "$paths->{db_deploy}/$new_version";
370              
371 0           return;
372             } else {
373 0           my $error = $self->save_migration(
374             path => "$paths->{db_upgrade}/$last_version-$new_version/001_auto.sql",
375             data => $diff,
376             );
377 0 0         die "Cant create MySQL upgrade: $error" if $error;
378              
379 0           my $diff = SQL::Translator::Diff->new({
380             output_db => 'MySQL',
381             source_schema => $target_schema,
382             target_schema => $source_schema,
383             ignore_index_names => 1,
384             ignore_constraint_names => 1,
385             caseopt => 1,
386             })->compute_differences->produce_diff_sql;
387              
388 0           my $error = $self->save_migration(
389             path => "$paths->{db_downgrade}/$new_version-$last_version/001_auto.sql",
390             data => $diff,
391             );
392 0 0         die "Cant create MySQL downgrade: $error" if $error;
393             }
394             }
395              
396 0           say "New schema version: $new_version";
397 0           say "Deploy to $new_version";
398 0           $self->deployed->{version} = $new_version;
399 0           $self->save_deployed;
400              
401 0           say "Done";
402             }
403              
404             sub diff {
405 0     0 0   my $self = shift;
406 0           my $paths = $self->paths;
407              
408 0           my $last_version = $self->get_last_version;
409              
410 0           say "Schema version: $last_version";
411              
412 0 0         if (my $version = $self->deployed->{version}) {
413 0           say "Deployed database is $version";
414             }
415              
416 0 0         if ($self->db_is_empty) {
417 0           say "Nothing to diff. Database is empty.";
418              
419 0           return;
420             }
421              
422 0           my $schema1 = $self->get_schema(to => 'YAML')->{schema};
423             my $schema2 = $self->get_schema(
424             from => 'YAML',
425             filename => "$paths->{source_deploy}/$last_version/001_auto.yml",
426 0           )->{schema};
427              
428 0           my $diff = $self->_diff($schema1, $schema2);
429 0           say "==== BEGIN SQL ====";
430 0           say $diff;
431 0           say "==== END SQL ====";
432             }
433              
434             sub _diff {
435 0     0     my $self = shift;
436 0   0       my $schema1 = shift || return;
437 0   0       my $schema2 = shift || return;
438              
439 0           for ($schema1->get_tables) {
440 0           $_->{options} = [grep {!$_->{'AUTO_INCREMENT'}} @{ $_->{options} }];
  0            
  0            
441             }
442 0           for ($schema2->get_tables) {
443 0           $_->{options} = [grep {!$_->{'AUTO_INCREMENT'}} @{ $_->{options} }];
  0            
  0            
444             }
445 0           my $diff = SQL::Translator::Diff->new({
446             output_db => 'MySQL',
447             source_schema => $schema2,
448             target_schema => $schema1,
449             ignore_index_names => 1,
450             ignore_constraint_names => 1,
451             caseopt => 1
452             })->compute_differences;
453              
454 0           my $h = {};
455 0 0         for my $table(keys %{ $diff->{table_diff_hash} || {} }) {
  0            
456 0           for my $field (@{$diff->{table_diff_hash}->{$table}->{fields_to_create}}) {
  0            
457 0           $h->{$table}->{$field->name} = [grep {$_->order == $field->{order} - 1} $field->table->get_fields]->[0]->{name};
  0            
458             }
459             }
460 0           $diff = $diff->produce_diff_sql;
461              
462 0 0         if (%$h) {
463 0           my @res = split "\n\n", $diff;
464 0           for my $s (@res) {
465 0           my ($t, $a) = $s =~ /ALTER TABLE ([^\s]+) ([^;]+)/;
466              
467 0           for ($a =~ /ADD COLUMN ([^\s]+) /g) {
468 0           $s =~ s/ADD COLUMN $_ (.*)([\,\;])/ADD COLUMN $_ $1 AFTER $h->{$t}->{$_}$2/g;
469             }
470             }
471              
472 0           $diff = join "\n\n", @res;
473             }
474              
475 0           return $diff;
476             }
477              
478             sub get_last_version {
479 0     0 0   my $self = shift;
480              
481 0           my $path = $self->paths->{source_deploy};
482              
483 0           my $last_version;
484 0 0         if (-e $path) {
485 0 0         opendir my $dh, $path or die "can't opendir $path: $!";
486 0           ($last_version) = sort {$b <=> $a} readdir $dh;
  0            
487 0           closedir $dh;
488             }
489              
490 0           return $last_version;
491             }
492              
493 0 0   0 0   sub db_is_empty { @{ shift->db->selectall_arrayref('show tables', { Slice => {} }) } ? 0 : 1 }
  0            
494              
495             sub save_migration {
496 0     0 0   my $self = shift;
497 0           my $p = {@_};
498              
499 0           my $dir = dirname $p->{path};
500 0 0         make_path $dir unless -d $dir;
501              
502 0 0         return 'No input data to save!' unless $p->{data};
503              
504 0 0         open my $fh, '>', $p->{path} or return $!;
505 0           print $fh $p->{data};
506 0           close $fh;
507              
508 0           return;
509             }
510              
511             sub get_schema {
512 0     0 0   my $self = shift;
513 0           my $p = {@_};
514              
515             my $translator = SQL::Translator->new(
516             debug => 1,
517             no_comments => $p->{no_comments} || 0,
518             $p->{filename}
519             ?
520             ()
521             :
522             (
523             parser_args => {
524             dsn => 'dbi:mysql:'.$self->config->{datasource}->{database},
525             db_user => $self->config->{user },
526             db_password => $self->config->{password},
527             },
528             )
529 0 0 0       );
530 0   0       $translator->parser($p->{from} || 'DBI');
531              
532             my @output = $translator->translate(
533             producer => $p->{to},
534             $p->{filename}
535             ?
536             (filename => $p->{filename})
537 0 0         :
    0          
538             ()
539             ) or die "Error: " . $translator->error;
540              
541 0           my $schema = $translator->schema;
542 0 0         if ($p->{filename}) {
543 0           $schema->name($p->{filename});
544             }
545              
546             return {
547 0           schema => $schema,
548             data => \@output,
549             };
550             }
551              
552             sub deployment_statements {
553 0     0 0   my $self = shift;
554 0           my $p = {@_};
555 0           my $paths = $self->paths;
556              
557 0 0         if ($p->{type} eq 'install') {
558             return $self->get_schema(
559             from => 'YAML',
560             to => 'MySQL',
561             filename => "$paths->{source_deploy}/$p->{version}/001_auto.yml",
562             no_comments => 1,
563 0           )->{data};
564             } else {
565 0   0       my $filename = $p->{filename} || "$paths->{db_$p->{type}}/$p->{from}-$p->{to}/001_auto.sql";
566 0 0         if(-f $filename) {
567 0           my $file;
568 0 0         open $file, "<$filename" or die "Can't open $filename ($!)";
569 0           my @rows = <$file>;
570 0           close $file;
571              
572             return [
573             grep {
574 0           s/\n//g;
  0            
575 0 0         /(^--|^BEGIN|^COMMIT|^\s*$)/ ? 0 : 1
576             }
577             split
578             /\s*--.*\n|;\n/,
579             join '', @rows
580             ];
581             }
582             }
583              
584 0           return [];
585             }
586              
587             sub rm {
588 0     0 1   my $self = shift;
589 0           my $paths = $self->paths;
590 0 0         say 'Params --version in required' unless my $version = $self->params->{version};
591              
592 0           remove_tree "$paths->{source_deploy}/$version";
593 0           remove_tree "$paths->{db_deploy}/$version";
594 0           remove_tree "$paths->{db_upgrade}/".($version-1)."-$version";
595 0           remove_tree "$paths->{db_downgrade}/$version-".($version-1);
596             }
597              
598             1;
599              
600              
601             =pod
602              
603             =encoding utf8
604            
605             =head1 NAME
606            
607             Mojolicious::Command::migration — MySQL migration tool for Mojolicious
608              
609             =head1 VERSION
610              
611             version 0.16
612              
613             =head1 SYNOPSIS
614            
615             Usage: APPLICATION migration [COMMAND] [OPTIONS]
616            
617             mojo migration prepare
618            
619             Commands:
620             status : Current database and schema version
621             diff : SQL diff with last version.
622             install : Install a version to the database.
623             prepare : Makes deployment files for your database
624             upgrade : Upgrade the database.
625             downgrade : Downgrade the database.
626             rm : Remove files of migration by version number.
627            
628             =head1 DESCRIPTION
629            
630             L MySQL migration tool.
631            
632              
633             =head1 USAGE
634              
635             L uses app->db for mysql connection and following configuration:
636              
637             {
638             'user' => 'USER',
639             'password' => 'PASSWORD',
640             'datasource' => { 'database' => 'DB_NAME'},
641             }
642              
643             from
644              
645             $ app->config->{db}->{mysql}
646              
647             Use can force command without saving state with param --force. Example:
648             $ app migration downgrade --force
649              
650             All deploy files saves to relative directory 'share/'. You can change it with 'MOJO_MIGRATION_SHARE' environment.
651             Current project state saves to 'tmp/.deploy_status' file. You can change directory with 'MOJO_MIGRATION_TMP' environment.
652              
653             Note: we create directories automatically
654              
655             =head1 COMMANDS
656            
657             =head2 status
658            
659             $ app migration status
660             Schema version: 21
661             Deployed database is 20
662              
663             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
664              
665             =head2 rm
666            
667             $ app migration rm --version 123
668              
669             =head2 prepare
670              
671             Makes deployment files for the current schema. If deployment files exist, will fail unless you "overwrite_migrations".
672              
673             # have changes
674             $ app migration prepare
675             Schema version: 21
676             New version is 22
677             Deploy to 22
678            
679             # no changes
680             $ app migration prepare
681             Schema version: 21
682             Nothing to upgrade. Exit
683              
684             =head2 install
685              
686             Installs either the current schema version (if already prepared) or the target version specified via any to_version flags.
687              
688             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.
689              
690             # last
691             $ app migration install
692             Schema version: 21
693             Deploy database to 21
694            
695             # target version
696             $ app migration install --to-version 10
697             Schema version: 21
698             Deploy database to 10
699              
700             # force install
701             $ app migration install --force
702             Schema version: 21
703             Force deploy to 21
704              
705             =head2 upgrade
706              
707              
708             Use flag --force to set current database to schema version without changes database.
709              
710             # last
711             $ app migration upgrade
712             Schema version: 21
713             Database version: 20
714             Upgrade to 21
715            
716             # target version
717             $ app migration upgrade --to-version 10
718             Schema version: 21
719             Database version: 8
720             Upgrade to 10
721              
722             # force upgrade
723             $ app migration upgrade --force
724             Schema version: 21
725             Database version: 8
726             Force upgrade to 21
727              
728             =head2 downgrade
729              
730              
731             Use flag --force to set current database to schema version without changes database.
732              
733             # last
734             $ app migration downgrade
735             Schema version: 21
736             Database version: 20
737             Downgrade to 21
738            
739             # target version
740             $ app migration downgrade --to-version 10
741             Schema version: 21
742             Database version: 8
743             Downgrade to 10
744              
745             # force downgrade
746             $ app migration downgrade --force
747             Schema version: 21
748             Database version: 8
749             Force downgrade to 21
750              
751             =head1 Custom upgrade and downgrade
752              
753             You can customize upgrade and downgrade by adding additional SQL scripts to path of action. All scripts will be executed in alphabetical order.
754              
755             # share/migration/MySQL/upgrade/10-11/001_auto.sql is automatic
756             # share/migration/MySQL/upgrade/10-11/002_some_script.sql is additional sctipt
757             $ app migration upgrade
758             Schema version: 11
759             Database version: 10
760             Upgrade to 11
761             Exec file: share/migrations/MySQL/upgrade/10-11/001_auto.sql
762             Exec file: share/migrations/MySQL/upgrade/10-11/002_some_script.sql
763              
764             =head1 SOURCE REPOSITORY
765              
766             L
767              
768             =head1 AUTHOR
769              
770             Alexey Likhatskiy,
771              
772             =head1 LICENSE AND COPYRIGHT
773              
774             Copyright (C) 2015 "Alexey Likhatskiy"
775              
776             This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.