File Coverage

blib/lib/DBIx/Migration.pm
Criterion Covered Total %
statement 170 170 100.0
branch 46 52 88.4
condition 5 6 83.3
subroutine 34 34 100.0
pod 7 7 100.0
total 262 269 97.4


line stmt bran cond sub pod time code
1             package DBIx::Migration;
2              
3             our $VERSION = '0.32';
4              
5 5     5   1425294 use feature qw( state );
  5         13  
  5         776  
6              
7 5     5   3382 use Moo;
  5         41032  
  5         30  
8 5     5   11418 use MooX::SetOnce;
  5         31722  
  5         29  
9 5     5   5868 use MooX::StrictConstructor;
  5         60582  
  5         52  
10              
11 5     5   187062 use DBI ();
  5         89236  
  5         235  
12 5     5   2150 use DBI::Const::GetInfoType qw( %GetInfoType );
  5         29055  
  5         875  
13 5     5   3108 use Log::Any qw( $Logger );
  5         53175  
  5         30  
14 5     5   17424 use String::Expand qw( expand_string );
  5         5545  
  5         426  
15 5     5   690 use Try::Tiny qw( catch try );
  5         2183  
  5         412  
16 5     5   4160 use Type::Params qw( signature );
  5         670545  
  5         60  
17 5     5   5076 use Types::Common::Numeric qw( PositiveInt PositiveOrZeroInt );
  5         133844  
  5         84  
18 5     5   9898 use Types::Path::Tiny qw( Dir );
  5         228647  
  5         89  
19 5     5   9219 use Types::Self qw( Self );
  5         51586  
  5         59  
20 5     5   1847 use Types::Standard qw( ArrayRef Defined HashRef Str );
  5         11  
  5         48  
21              
22 5     5   21501 use namespace::clean -except => [ qw( before new ) ];
  5         92105  
  5         74  
23              
24             # 1st alternative set of constructor attributes
25             has dsn => ( is => 'lazy', isa => Str );
26              
27             sub _build_dsn {
28 5     5   93 my $self = shift;
29              
30 5         152 return $self->dbh->get_info( $GetInfoType{ SQL_DATA_SOURCE_NAME } );
31             }
32             has [ qw( password username ) ] => ( is => 'ro', isa => Str );
33              
34             # 2nd alternative set of constructor attributes
35             has dbh => ( is => 'lazy' );
36              
37             sub _build_dbh {
38 11     11   113 my $self = shift;
39              
40 11         201 return DBI->connect(
41             $self->dsn,
42             $self->username,
43             $self->password,
44             {
45             RaiseError => 1,
46             PrintError => 0,
47             AutoCommit => 1 # see below "begin_work" based transaction handling
48             }
49             );
50             }
51              
52             my $MigrationsDir = Type::Tiny->new(
53             name => 'MigrationsDir',
54             parent => Dir,
55             constraint => sub { __PACKAGE__->latest( $_ ) },
56             coercion => 1 # inherit from parent
57             );
58             has dir => ( is => 'rw', isa => $MigrationsDir, once => 1, coerce => 1 );
59             has do_before => ( is => 'lazy', isa => ArrayRef [ Str | ArrayRef ], default => sub { [] } );
60             has do_while => ( is => 'lazy', isa => ArrayRef [ Str | ArrayRef ], default => sub { [] } );
61             has tracking_table => ( is => 'ro', isa => Str, default => 'dbix_migration' );
62             has placeholders => ( is => 'lazy', isa => HashRef [ Str ], default => sub { {} }, init_arg => undef );
63              
64             sub BUILD {
65 21     21 1 464713 my ( $self, $args ) = @_;
66              
67             # new() is overloaded: check consistency of attributes
68 21 100       166 if ( exists $args->{ dsn } ) {
    100          
69             die 'dsn and dbh cannot be used at the same time'
70 12 100       74 if exists $args->{ dbh };
71             } elsif ( exists $args->{ dbh } ) {
72 8         29 foreach ( qw( dsn username password ) ) {
73             die "dbh and $_ cannot be used at the same time"
74 23 100       118 if exists $args->{ $_ };
75             }
76             } else {
77 1         21 die 'both dsn and dbh are not set';
78             }
79              
80             # driver should match subclass
81 18         49 my $class = ref $self;
82 18 100       246 if ( ( my @package = split( /::/, $class ) ) > 2 ) {
83 1         8 my $driver = $self->driver;
84 1 50       144 die "subclass $class cannot handle $driver driver"
85             unless $driver eq $package[ -1 ];
86             }
87             }
88              
89             # overrideable
90             sub create_tracking_table {
91 22     22 1 62 my $self = shift;
92              
93 22         92 my $tracking_table = $self->quoted_tracking_table;
94 21         4131 $Logger->debugf( "Create tracking table '%s'", $tracking_table );
95 21         679 $self->dbh->do( "CREATE TABLE IF NOT EXISTS $tracking_table ( name VARCHAR(64) PRIMARY KEY, value VARCHAR(64) )" );
96             }
97              
98             # can be used as an object method ($dsn not specified) and as a class method
99             # ($dsn specified)
100             sub driver {
101 9     9 1 31 my ( $self, $dsn ) = @_;
102              
103             state $Driver = Type::Tiny->new(
104             name => 'Driver',
105             parent => Defined,
106 1     1   155 message => sub { "Parsing the '$dsn' cannot extract driver name" }
107 9         28 );
108 9 100       650 return $Driver->assert_return( ( DBI->parse_dsn( defined $dsn ? $dsn : $self->dsn ) )[ 1 ] );
109             }
110              
111             sub latest {
112             # coercion is implicitly enabled because the Dir type constraint has a coercion
113 36     36 1 2355 state $signature = signature( method => 1, positional => [ Dir, { optional => 1 } ] );
114 36         451233 my ( $self, $dir ) = $signature->( @_ );
115 36 100       2603 $dir = $self->dir unless defined $dir;
116 36 100       762 Dir->assert_valid( undef ) unless defined $dir;
117              
118 35         64 my $latest = 0;
119             $dir->visit(
120             sub {
121 160 100   160   15813 return unless m/\D*([1-9][0-9]*)_up\.sql\z/;
122 87 100       3552 $latest = $1 if $1 > $latest;
123             }
124 35         276 );
125              
126 35         2205 return PositiveInt->assert_return( $latest );
127             }
128              
129             sub migrate {
130 24     24 1 49231 state $signature = signature( method => Self, positional => [ PositiveOrZeroInt, { optional => 1 } ] );
131 24         136769 my ( $self, $target ) = $signature->( @_ );
132 24         710 Dir->assert_valid( $self->dir );
133              
134 22 100       3916 $target = $self->latest unless defined $target;
135              
136 22         853 $Logger->debugf( "Will use DBI DSN '%s'", $self->dsn );
137              
138 22         816 my $fatal_error;
139             my $return_value = try {
140              
141             # on purpose outside of the transaction
142             # doesn't use _dbh (the cloned dbh)
143 22     22   1381 $self->create_tracking_table;
144              
145 21         83699 $self->{ _dbh } = $self->dbh->clone(
146             {
147             RaiseError => 1,
148             PrintError => 0,
149             AutoCommit => 1,
150             }
151             );
152              
153             $Logger->debugf( "Execute 'before' transaction todo: '%s'", $_ ), $self->{ _dbh }->do( ref eq 'ARRAY' ? @$_ : $_ )
154 21 50       15269 foreach @{ $self->do_before };
  21         826  
155              
156 21         601 $Logger->debug( 'Enable transaction turning AutoCommit off' );
157 21         232 $self->{ _dbh }->begin_work;
158              
159             $Logger->debugf( "Execute 'while' transaction todo: '%s'", $_ ), $self->{ _dbh }->do( ref eq 'ARRAY' ? @$_ : $_ )
160 21 0       370 foreach @{ $self->do_while };
  21         692  
161              
162 21         509 my $version = $self->version;
163 21 100       955 $self->_initialize_tracking_table, $version = 0 unless defined $version;
164              
165 21         3048 my @need;
166             my $type;
167 21 100       115 if ( $target == $version ) {
    100          
168 3         17 $Logger->debugf( 'Database is already at migration version %d', $target );
169 3         20 return 1;
170             } elsif ( $target > $version ) { # upgrade
171 12         28 $type = 'up';
172 12         49 $version += 1;
173 12         47 @need = $version .. $target;
174             } else { # downgrade
175 6         17 $type = 'down';
176 6         14 $target += 1;
177 6         31 @need = reverse( $target .. $version );
178             }
179 18         117 my $files = $self->_files( $type, \@need );
180 18 100       62 if ( defined $files ) {
181 16         41 for my $file ( @$files ) {
182 28         2628 my $name = $file->{ name };
183 28         75 my $ver = $file->{ version };
184 28         160 $Logger->debugf( "Process migration '%s'", $name );
185 28         238 my $content = $name->slurp_raw;
186 28 50       7283 my $delimiter = ( $content =~ m/\A-- *dbix_migration_delimiter: *([[:graph:]])/ ) ? $1 : ';';
187 28         174 $Logger->debugf( "Migration section delimiter is '%s'", $delimiter );
188 28         491 $content =~ s/\s*--.*$//mg;
189             # split content into sections ($sql)
190 28         256 for my $sql ( split /$delimiter/, $content ) {
191 61         15462 $sql =~ s/\A\s*//;
192 61 100       283 next unless $sql =~ /\w/;
193 36         1315 $sql = expand_string( $sql, $self->placeholders );
194             # prepend $sql to error message
195 36         1793 local $self->{ _dbh }->{ HandleError } = sub { $_[ 0 ] = "$sql\n$_[0]"; return 0; };
  3         210  
  3         93  
196 36         773 $self->{ _dbh }->do( $sql );
197             }
198 25 100 66     226 $ver -= 1 if ( ( $ver > 0 ) && ( $type eq 'down' ) );
199 25         115 $self->_update_tracking_table( $ver );
200             }
201 13         3448 return 1;
202             } else {
203 2         8 my $newver = $self->version;
204 2 50       68 $Logger->debugf( "Database is at version %d, couldn't migrate to version %d", $newver, $target )
205             if $target != $newver;
206 2         11 return 0;
207             }
208             } catch {
209 4     4   659 $fatal_error = $_;
210 22         335 };
211              
212 22 100       723 if ( $fatal_error ) {
213             $Logger->debug( 'Rollback transaction turning AutoCommit on again' ), $self->{ _dbh }->rollback
214 4 100       31 if exists $self->{ _dbh };
215 4         1346 delete $self->{ _dbh };
216             # rethrow exception
217 4         68 die $fatal_error;
218             }
219 18         108 $Logger->debug( 'Commit transaction turning AutoCommit on again' );
220 18         359588 $self->{ _dbh }->commit;
221 18         3677 delete $self->{ _dbh };
222              
223 18         704 return $return_value;
224             }
225              
226             # overrideable
227             sub quoted_tracking_table {
228 95     95 1 207 my $self = shift;
229              
230 95         3088 return $self->dbh->quote_identifier( $self->tracking_table );
231             }
232              
233             sub version {
234 45     45 1 13114 my $self = shift;
235              
236 45         1568 my $dbh = $self->dbh;
237 43         4235 local @{ $dbh }{ qw( RaiseError PrintError ) } = ( 1, 0 );
  43         1810  
238             try {
239 43     43   1708 my $tracking_table = $self->quoted_tracking_table;
240 43         11043 $Logger->debugf( "Read tracking table '%s'", $tracking_table );
241 43         480 my $sth = $dbh->prepare( "SELECT value FROM $tracking_table WHERE name = ?" );
242 40         11382 $sth->execute( 'version' );
243 40         167 my $version = undef;
244 40         824 for my $val ( $sth->fetchrow_arrayref ) {
245 40         293 $version = $val->[ 0 ];
246             }
247 35         736 $version;
248 43         1879 };
249             }
250              
251             sub _files {
252 20     20   203 my ( $self, $type, $need ) = @_;
253              
254 20         42 my @files;
255 20         62 for my $i ( @$need ) {
256 5     5   17773 no warnings 'uninitialized';
  5         18  
  5         1895  
257             $self->dir->visit(
258             sub {
259 197 100   197   29574 return unless $_->basename =~ m/(?:\A|\D+)${i}_$type\.sql\z/;
260 34         1871 $Logger->debugf( "Found migration '%s'", $_ );
261 34         356 push @files, { name => $_, version => $i };
262             }
263 36         2874 );
264             }
265              
266 20 100 100     2567 return ( @files and @$need == @files ) ? \@files : undef;
267             }
268              
269             sub _initialize_tracking_table {
270 5     5   14 my $self = shift;
271              
272 5         32 my $tracking_table = $self->quoted_tracking_table;
273 5         238 $Logger->debugf( "Initialize tracking table '%s'", $tracking_table );
274 5         57 $self->{ _dbh }->do( "INSERT INTO $tracking_table ( name, value ) VALUES ( ?, ? )", undef, 'version', 0 );
275             }
276              
277             sub _update_tracking_table {
278 25     25   99 my ( $self, $version ) = @_;
279              
280 25         79 my $tracking_table = $self->quoted_tracking_table;
281 25         1289 $Logger->debugf( "Update tracking table '%s'", $tracking_table );
282 25         253 $self->{ _dbh }->do( "UPDATE $tracking_table SET value = ? WHERE name = ?", undef, $version, 'version' );
283             }
284              
285             1;