File Coverage

blib/lib/App/migrate.pm
Criterion Covered Total %
statement 267 283 94.3
branch 129 150 86.0
condition 26 37 70.2
subroutine 32 35 91.4
pod 6 6 100.0
total 460 511 90.0


line stmt bran cond sub pod time code
1             package App::migrate;
2 3     3   135734 use 5.010001;
  3         6  
  3         80  
3 3     3   9 use warnings;
  3         4  
  3         58  
4 3     3   10 use strict;
  3         8  
  3         55  
5 3     3   1259 use utf8;
  3         21  
  3         12  
6 3     3   91 use Carp;
  3         3  
  3         219  
7             ## no critic (RequireCarping)
8              
9             our $VERSION = 'v0.2.1';
10              
11 3     3   14 use List::Util qw( first );
  3         3  
  3         214  
12 3     3   1217 use File::Temp qw( tempfile ); # don't use Path::Tiny to have temp files in error $SHELL
  3         27596  
  3         223  
13              
14 3     3   20 use constant KW_DEFINE => { map {$_=>1} qw( DEFINE DEFINE2 DEFINE4 ) };
  3         6  
  3         6  
  9         198  
15 3     3   10 use constant KW_VERSION => { map {$_=>1} qw( VERSION ) };
  3         5  
  3         23  
  3         205  
16 3     3   10 use constant KW_UP => { map {$_=>1} qw( before_upgrade upgrade ) };
  3         3  
  3         3  
  6         128  
17 3     3   9 use constant KW_DOWN => { map {$_=>1} qw( downgrade after_downgrade ) };
  3         3  
  3         4  
  6         132  
18 3     3   10 use constant KW_RESTORE => { map {$_=>1} qw( RESTORE ) };
  3         3  
  3         3  
  3         212  
19 3     3   13 use constant KW => { %{&KW_UP}, %{&KW_DOWN}, %{&KW_DEFINE}, %{&KW_RESTORE}, %{&KW_VERSION} };
  3         4  
  3         3  
  3         11  
  3         5  
  3         8  
  3         5  
  3         104  
20 3     3   9 use constant DEFINE_TOKENS => 1;
  3         3  
  3         99  
21 3     3   8 use constant DEFINE2_TOKENS => 2;
  3         7  
  3         94  
22 3     3   9 use constant DEFINE4_TOKENS => 4;
  3         3  
  3         7965  
23              
24              
25             # cleanup temp files
26             $SIG{HUP} = $SIG{HUP} // sub { exit 129 }; ## no critic (RequireLocalizedPunctuationVars ProhibitMagicNumbers)
27             $SIG{INT} = $SIG{INT} // sub { exit 130 }; ## no critic (RequireLocalizedPunctuationVars ProhibitMagicNumbers)
28             $SIG{QUIT}= $SIG{QUIT} // sub { exit 131 }; ## no critic (RequireLocalizedPunctuationVars ProhibitMagicNumbers)
29             $SIG{TERM}= $SIG{TERM} // sub { exit 143 }; ## no critic (RequireLocalizedPunctuationVars ProhibitMagicNumbers)
30              
31              
32             sub new {
33 3     3 1 24 my ($class) = @_;
34 3   33     33 my $self = bless {
35             paths => {}, # {prev_version}{next_version} = \@steps
36             on => {
37             BACKUP => \&_on_backup,
38             RESTORE => \&_on_restore,
39             VERSION => \&_on_version,
40             error => \&_on_error,
41             },
42             }, ref $class || $class;
43 3         8 return $self;
44             }
45              
46             sub find_paths {
47 13     13 1 48558 my ($self, $from, $to) = @_;
48 13         56 return $self->_find_paths($to, $from);
49             }
50              
51             sub get_steps {
52 32     32 1 39 my ($self, $path) = @_;
53 32   50     37 my @path = @{ $path // [] };
  32         87  
54 32 50       69 croak 'Path must contain at least 2 versions' if 2 > @path;
55 32         34 my @all_steps;
56 32         62 for (; 2 <= @path; shift @path) {
57 46         56 my ($prev, $next) = @path;
58 46 50       86 croak "Unknown version '$prev'" if !$self->{paths}{$prev};
59 46 50       106 croak "No one-step migration from '$prev' to '$next'" if !$self->{paths}{$prev}{$next};
60 46         37 push @all_steps, @{ $self->{paths}{$prev}{$next} };
  46         141  
61             }
62 32         69 return @all_steps;
63             }
64              
65             sub load {
66 69     69 1 43793 my ($self, $file) = @_;
67              
68 3 100   3   54 open my $fh, '<:encoding(UTF-8)', $file or croak "open($file): $!";
  3         4  
  3         14  
  69         1607  
69 68 100       27962 croak "'$file' is not a plain file" if !-f $file;
70 66         755 my @op = _preprocess(_tokenize($fh, { file => $file, line => 0 }));
71 10 50       130 close $fh or croak "close($file): $!";
72              
73 10         17 my ($prev_version, $next_version, @steps) = (q{}, q{});
74 10         16 while (@op) {
75 33         24 my $op = shift @op;
76 33 100       69 if (KW_VERSION->{$op->{op}}) {
    100          
77 18         19 $next_version = $op->{args}[0];
78 18 100       29 if ($prev_version ne q{}) {
79 20         42 $self->{paths}{ $prev_version }{ $next_version } ||= [
80 20         34 (grep { $_->{type} eq 'before_upgrade' } @steps),
81 11   50     75 (grep { $_->{type} eq 'upgrade' } @steps),
82             {
83             type => 'VERSION',
84             version => $next_version,
85             },
86             ];
87 11     20   60 my $restore = first { KW_RESTORE->{$_->{type}} } @steps;
  20         23  
88 16         19 $self->{paths}{ $next_version }{ $prev_version } ||= [
89             $restore ? (
90             $restore,
91             ) : (
92 16         25 (grep { $_->{type} eq 'downgrade' } reverse @steps),
93 11 100 50     57 (grep { $_->{type} eq 'after_downgrade' } reverse @steps),
94             ),
95             {
96             type => 'VERSION',
97             version => $prev_version,
98             },
99             ];
100 11         6 for (@{ $self->{paths}{ $prev_version }{ $next_version } }) {
  11         21  
101 21         17 $_->{prev_version} = $prev_version;
102 21         24 $_->{next_version} = $next_version;
103             }
104 11         9 for (@{ $self->{paths}{ $next_version }{ $prev_version } }) {
  11         19  
105 21         65 $_->{prev_version} = $next_version;
106 21         51 $_->{next_version} = $prev_version;
107             }
108             }
109 18         55 ($prev_version, $next_version, @steps) = ($next_version, q{});
110             }
111             elsif (KW_UP->{$op->{op}}) {
112 14 100       25 die _e($op, "Need 'VERSION' before '$op->{op}'") if $prev_version eq q{};
113 13         10 my ($cmd1, @args1) = @{ $op->{args} };
  13         15  
114 13         29 push @steps, {
115             type => $op->{op},
116             cmd => $cmd1,
117             args => \@args1,
118             };
119 13 100 100     56 die _e($op, "Need 'RESTORE' or 'downgrade' or 'after_downgrade' after '$op->{op}'")
      66        
120             if !( @op && (KW_DOWN->{$op[0]{op}} || KW_RESTORE->{$op[0]{op}}) );
121 11         12 my $op2 = shift @op;
122 11 100       16 if (KW_RESTORE->{$op2->{op}}) {
123 2         9 push @steps, {
124             type => 'RESTORE',
125             version => $prev_version,
126             };
127             }
128             else {
129 9         6 my ($cmd2, @args2) = @{ $op2->{args} };
  9         13  
130 9         33 push @steps, {
131             type => $op2->{op},
132             cmd => $cmd2,
133             args => \@args2,
134             };
135             }
136             }
137             else {
138 1         5 die _e($op, "Need 'before_upgrade' or 'upgrade' before '$op->{op}'");
139             }
140             }
141              
142 6         42 return $self;
143             }
144              
145             sub on {
146 6     6 1 1284 my ($self, $e, $code) = @_;
147 6 50       19 croak "Unknown event $e" if !$self->{on}{$e};
148 6         6 $self->{on}{$e} = $code;
149 6         8 return $self;
150             }
151              
152             sub run {
153 12     12 1 1355 my ($self, $path) = @_;
154 12         42 $self->get_steps($path); # validate full @path before starting
155 12         10 my @path = @{ $path };
  12         21  
156 12         13 my $from;
157             eval {
158 12         15 my $just_restored = 0;
159 12         32 for (; 2 <= @path; shift @path) {
160 17         30 my ($prev, $next) = @path;
161 17 100       34 if (!$just_restored) {
162 16         150 $self->_do({
163             type => 'BACKUP', # internal step type
164             version => $prev,
165             prev_version => $prev,
166             next_version => $next,
167             });
168             }
169 17         34 $just_restored = 0;
170 17         19 $from = $prev;
171 17         48 for my $step ($self->get_steps([$prev, $next])) {
172 32         220 $self->_do($step);
173 32 100       272 if ($step->{type} eq 'RESTORE') {
174 2         9 $just_restored = 1;
175             }
176             }
177             }
178 12         35 1;
179             }
180 12 50       20 or do {
181 0         0 my $err = $@;
182 0 0       0 if ($from) {
183 0 0       0 eval {
184 0         0 $self->_do({
185             type => 'RESTORE', # internal step type
186             version => $from,
187             prev_version => $from,
188             next_version => $path[-1],
189             }, 1);
190 0         0 warn "Successfully undone interrupted migration by RESTORE $from\n";
191 0         0 1;
192             } or warn "Failed to RESTORE $from: $@";
193             }
194 0         0 die $err;
195             };
196 12         125 return;
197             }
198              
199             sub _data2arg {
200 29     29   38 my ($data) = @_;
201              
202 29 100       102 return if $data eq q{};
203              
204 8         53 my ($fh, $file) = tempfile('migrate.XXXXXX', TMPDIR=>1, UNLINK=>1);
205 8         3730 print {$fh} $data;
  8         70  
206 8 50       329 close $fh or croak "close($file): $!";
207              
208 8         25 return $file;
209             }
210              
211             sub _do {
212 48     48   73 my ($self, $step, $is_fatal) = @_;
213 48         312 local $ENV{MIGRATE_PREV_VERSION} = $step->{prev_version};
214 48         206 local $ENV{MIGRATE_NEXT_VERSION} = $step->{next_version};
215             eval {
216 48 100 100     421 if ($step->{type} eq 'BACKUP' or $step->{type} eq 'RESTORE' or $step->{type} eq 'VERSION') {
      100        
217 35         195 $self->{on}{ $step->{type} }->($step);
218             }
219             else {
220 13         31 my $cmd = $step->{cmd};
221 13 100       85 if ($cmd =~ /\A#!/ms) {
222 7         33 $cmd = _data2arg($cmd);
223 7 50       136 chmod 0700, $cmd or croak "chmod($cmd): $!"; ## no critic (ProhibitMagicNumbers)
224             }
225 13 50       23 system($cmd, @{ $step->{args} }) == 0 or die "'$step->{type}' failed: $cmd @{ $step->{args} }\n";
  0         0  
  13         64855  
226 13         566 print "\n";
227             }
228 48         227 1;
229             }
230 48 50       61 or do {
231 0 0       0 die $@ if $is_fatal;
232 0         0 warn $@;
233 0         0 $self->{on}{error}->($step);
234             };
235 48         464 return;
236             }
237              
238             sub _e {
239 60     60   70 my ($t, $msg, $near) = @_;
240 60 100       180 return "parse error: $msg at $t->{loc}{file}:$t->{loc}{line}"
241             . (length $near ? " near '$near'\n" : "\n");
242             }
243              
244             sub _find_paths {
245 25     25   43 my ($self, $to, @from) = @_;
246 25   50     81 my $p = $self->{paths}{ $from[-1] } || {};
247 25 100       120 return [@from, $to] if $p->{$to};
248 12         8 my %seen = map {$_=>1} @from;
  26         39  
249 12         10 return map {$self->_find_paths($to,@from,$_)} grep {!$seen{$_}} keys %{$p};
  12         28  
  21         32  
  12         21  
250             }
251              
252             sub _on_backup {
253 0     0   0 croak 'You need to define how to make BACKUP';
254             }
255              
256             sub _on_restore {
257 0     0   0 croak 'You need to define how to RESTORE from backup';
258             }
259              
260 10     10   28 sub _on_version {
261             # do nothing
262             }
263              
264             sub _on_error {
265 0     0   0 warn <<'ERROR';
266              
267             YOU NEED TO MANUALLY FIX THIS ISSUE RIGHT NOW
268             When done, use:
269             exit to continue migration
270             exit 1 to interrupt migration and RESTORE from backup
271              
272             ERROR
273 0 0 0     0 system($ENV{SHELL} // '/bin/sh') == 0 or die "Migration interrupted\n";
274 0         0 return;
275             }
276              
277             sub _preprocess { ## no critic (ProhibitExcessComplexity)
278 58     58   77 my @tokens = @_;
279 58         36 my @op;
280             my %macro;
281 58         91 while (@tokens) {
282 99         75 my $t = shift @tokens;
283 99 100 100     365 if ($t->{op} =~ /\ADEFINE[24]?\z/ms) {
    100          
    100          
    100          
    100          
284 34 100       26 die _e($t, "'$t->{op}' must have one param", "@{$t->{args}}") if 1 != @{$t->{args}};
  3         11  
  34         64  
285 31 100       103 die _e($t, "Bad name for '$t->{op}'", $t->{args}[0]) if $t->{args}[0] !~ /\A(?!#)\S+\z/ms;
286 26 100       50 die _e($t, "No data allowed for '$t->{op}'", $t->{data}) if $t->{data} ne q{};
287 23         25 my $name = $t->{args}[0];
288 23 100       44 die _e($t, "Can't redefine keyword '$name'") if KW->{$name};
289 20 100       42 die _e($t, "'$name' is already defined") if $macro{$name};
290 17 100       47 if ($t->{op} eq 'DEFINE') {
    100          
    50          
291 3 100       7 die _e($t, q{Need operation after 'DEFINE'}) if @tokens < DEFINE_TOKENS;
292 2         2 my $t1 = shift @tokens;
293 2 100 66     13 die _e($t1, q{First operation after 'DEFINE' must be 'before_upgrade' or 'upgrade' or 'downgrade' or 'after_downgrade'}, $t1->{op}) if !( KW_UP->{$t1->{op}} || KW_DOWN->{$t1->{op}} );
294 1         3 $macro{$name} = [ $t1 ];
295             }
296             elsif ($t->{op} eq 'DEFINE2') {
297 8 100       17 die _e($t, q{Need two operations after 'DEFINE2'}) if @tokens < DEFINE2_TOKENS;
298 7         6 my $t1 = shift @tokens;
299 7         8 my $t2 = shift @tokens;
300 7 100       26 die _e($t1, q{First operation after 'DEFINE2' must be 'before_upgrade' or 'upgrade'}, $t1->{op}) if !KW_UP->{$t1->{op}};
301 6 100       13 die _e($t2, q{Second operation after 'DEFINE2' must be 'downgrade' or 'after_downgrade'}, $t2->{op}) if !KW_DOWN->{$t2->{op}};
302 5         17 $macro{$name} = [ $t1, $t2 ];
303             }
304             elsif ($t->{op} eq 'DEFINE4') {
305 6 100       12 die _e($t, q{Need four operations after 'DEFINE4'}) if @tokens < DEFINE4_TOKENS;
306 5         4 my $t1 = shift @tokens;
307 5         4 my $t2 = shift @tokens;
308 5         4 my $t3 = shift @tokens;
309 5         4 my $t4 = shift @tokens;
310 5 100       9 die _e($t1, q{First operation after 'DEFINE4' must be 'before_upgrade'}, $t1->{op}) if $t1->{op} ne 'before_upgrade';
311 4 100       8 die _e($t2, q{Second operation after 'DEFINE4' must be 'upgrade'}, $t2->{op}) if $t2->{op} ne 'upgrade';
312 3 100       10 die _e($t3, q{Third operation after 'DEFINE4' must be 'downgrade'}, $t3->{op}) if $t3->{op} ne 'downgrade';
313 2 100       7 die _e($t4, q{Fourth operation after 'DEFINE4' must be 'after_downgrade'}, $t4->{op}) if $t4->{op} ne 'after_downgrade';
314 1         10 $macro{$name} = [ $t1, $t4, $t2, $t3 ];
315             }
316             }
317             elsif (KW_VERSION->{$t->{op}}) {
318 33 100       44 die _e($t, q{'VERSION' must have one param}, "@{$t->{args}}") if 1 != @{$t->{args}};
  2         7  
  33         64  
319 31 100 100     194 die _e($t, q{Bad value for 'VERSION'}, $t->{args}[0])
320             if $t->{args}[0] !~ /\A\S+\z/ms || $t->{args}[0] =~ /[\x00-\x1F\x7F \/?*`"'\\]/ms;
321 21 100       33 die _e($t, q{No data allowed for 'VERSION'}, $t->{data}) if $t->{data} ne q{};
322 20         101 push @op, {
323             loc => $t->{loc},
324             op => $t->{op},
325             args => [ $t->{args}[0] ],
326             };
327             }
328             elsif (KW_RESTORE->{$t->{op}}) {
329 4 100       7 die _e($t, q{'RESTORE' must have no params}, "@{$t->{args}}") if 0 != @{$t->{args}};
  1         4  
  4         13  
330 3 100       17 die _e($t, q{No data allowed for 'RESTORE'}, $t->{data}) if $t->{data} ne q{};
331 2         10 push @op, {
332             loc => $t->{loc},
333             op => $t->{op},
334             args => [],
335             };
336             }
337             elsif (KW_UP->{$t->{op}} || KW_DOWN->{$t->{op}}) {
338 23 100 66     19 die _e($t, "'$t->{op}' require command or data") if !@{$t->{args}} && $t->{data} !~ /\S/ms;
  23         68  
339 19         33 push @op, {
340             loc => $t->{loc},
341             op => $t->{op},
342             args => [
343 19 100       23 @{$t->{args}} ? (@{$t->{args}}, _data2arg($t->{data}))
  14         26  
344             : _shebang($t->{data})
345             ],
346             };
347             }
348             elsif ($macro{ $t->{op} }) {
349 4         4 for (@{ $macro{ $t->{op} } }) {
  4         9  
350             my @args
351 9 100       10 = @{$_->{args}} ? (@{$_->{args}}, _data2arg($_->{data}))
  9 100       30  
  1         2  
352             : $_->{data} =~ /\S/ms ? _shebang($_->{data})
353             : ()
354             ;
355             @args
356 3         4 = @args ? (@args, @{$t->{args}}, _data2arg($t->{data}))
  6         14  
357 9 100       12 : @{$t->{args}} ? (@{$t->{args}}, _data2arg($t->{data}))
  4 100       6  
    100          
358             : $t->{data} =~ /\S/ms ? _shebang($t->{data})
359             : ()
360             ;
361 9 100       16 die _e($t, "'$t->{op}' require command or data") if !@args;
362 8         25 push @op, {
363             loc => $t->{loc},
364             op => $_->{op},
365             args => \@args,
366             };
367             }
368             }
369             else {
370 1         4 die _e($t, "Unknown operation '$t->{op}'");
371             }
372             }
373 10         25 return @op;
374             }
375              
376             sub _shebang {
377 8     8   6 my ($script) = @_;
378 8 50       20 state $bin = (grep { -x "$_/bash" } split /:/ms, $ENV{PATH})[0] or die 'bash not found';
  14         128  
379 8 50       42 return $script =~ /\A#!/ms ? $script : "#!$bin/bash -ex\n$script";
380             }
381              
382             sub _tokenize {
383 66     66   69 my ($fh, $loc) = @_;
384 66         66 state $QUOTED = {
385             q{\\} => q{\\},
386             q{"} => q{\"},
387             'n' => "\n",
388             'r' => "\r",
389             't' => "\t",
390             };
391 66         51 my @tokens;
392 66         721 while (<$fh>) {
393 186         552 $loc->{line}++;
394 186 100       711 if (/\A#/ms) {
    100          
    100          
395             # skip comments
396             }
397             elsif (/\A(\S+)\s*(.*)\z/ms) {
398             # parse token's op and args
399 145         231 my ($op, $args) = ($1, $2);
400 145         87 my @args;
401 145         345 while ($args =~ /\G([^\s"\\]+|"[^"\\]*(?:\\[\\"nrt][^"\\]*)*")(?:\s+|\z)/msgc) {
402 103         104 my $param = $1;
403 103 100       205 if ($param =~ s/\A"(.*)"\z/$1/ms) {
404 17         33 $param =~ s/\\([\\"nrt])/$QUOTED->{$1}/msg;
405             }
406 103         225 push @args, $param;
407             }
408 145 100       185 die _e({loc=>$loc}, 'Bad operation param', $1) if $args =~ /\G(.+)\z/msgc; ## no critic (ProhibitCaptureWithoutTest)
409 141         839 push @tokens, {
410 141         102 loc => {%{ $loc }},
411             op => $op,
412             args=> \@args,
413             data=> q{},
414             };
415             }
416             elsif (/\A(?:\r?\n|[ ][ ].*)\z/ms) {
417 25 100       38 if (@tokens) {
    100          
418 22         63 $tokens[-1]{data} .= $_;
419             }
420             elsif (/\S/ms) {
421 1         3 die _e({loc=>$loc}, 'Data before operation', $_);
422             }
423             else {
424             # skip /^\s*$/ before first token
425             }
426             }
427             else {
428 3         10 die _e({loc=>$loc}, 'Bad token', $_);
429             }
430             }
431             # post-process data
432 58         73 for (@tokens) {
433 141         426 $_->{data} =~ s/(\A(?:.*?\n)??)(?:\r?\n)*\z/$1/ms;
434 141         235 $_->{data} =~ s/^[ ][ ]//msg;
435             }
436 58         132 return @tokens;
437             }
438              
439              
440             1; # Magic true value required at end of module
441             __END__