File Coverage

blib/lib/App/migrate.pm
Criterion Covered Total %
statement 265 281 94.3
branch 129 150 86.0
condition 28 37 75.6
subroutine 32 35 91.4
pod 6 6 100.0
total 460 509 90.3


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