File Coverage

blib/lib/App/Sqitch/Engine/mysql.pm
Criterion Covered Total %
statement 123 151 81.4
branch 24 34 70.5
condition 7 17 41.1
subroutine 43 51 84.3
pod 15 15 100.0
total 212 268 79.1


line stmt bran cond sub pod time code
1              
2             use 5.010;
3 4     4   105056 use strict;
  4         19  
4 4     4   26 use warnings;
  4         8  
  4         83  
5 4     4   16 use utf8;
  4         8  
  4         99  
6 4     4   17 use Try::Tiny;
  4         8  
  4         19  
7 4     4   72 use App::Sqitch::X qw(hurl);
  4         9  
  4         198  
8 4     4   24 use Locale::TextDomain qw(App-Sqitch);
  4         5  
  4         34  
9 4     4   1205 use App::Sqitch::Plan::Change;
  4         5  
  4         25  
10 4     4   633 use Path::Class;
  4         8  
  4         126  
11 4     4   19 use Moo;
  4         10  
  4         160  
12 4     4   21 use App::Sqitch::Types qw(DBH URIDB ArrayRef Bool Str HashRef);
  4         11  
  4         21  
13 4     4   1408 use namespace::autoclean;
  4         12  
  4         49  
14 4     4   4700 use List::MoreUtils qw(firstidx);
  4         11  
  4         30  
15 4     4   292  
  4         8  
  4         25  
16             extends 'App::Sqitch::Engine';
17              
18             our $VERSION = 'v1.3.0'; # VERSION
19              
20             has uri => (
21             is => 'ro',
22             isa => URIDB,
23             lazy => 1,
24             default => sub {
25             my $self = shift;
26             my $uri = $self->SUPER::uri;
27             $uri->host($ENV{MYSQL_HOST}) if !$uri->host && $ENV{MYSQL_HOST};
28             $uri->port($ENV{MYSQL_TCP_PORT}) if !$uri->_port && $ENV{MYSQL_TCP_PORT};
29             return $uri;
30             },
31             );
32              
33             has registry_uri => (
34             is => 'ro',
35             isa => URIDB,
36             lazy => 1,
37             default => sub {
38             my $self = shift;
39             my $uri = $self->uri->clone;
40             $uri->dbname($self->registry);
41             return $uri;
42             },
43             );
44              
45             my $uri = shift->registry_uri;
46             if ($uri->password) {
47 4     4 1 4519 $uri = $uri->clone;
48 4 100       45 $uri->password(undef);
49 1         47 }
50 1         18 return $uri->as_string;
51             }
52 4         202  
53             has _mycnf => (
54             is => 'rw',
55             isa => HashRef,
56             default => sub {
57             eval 'require MySQL::Config; 1' or return {};
58             return scalar MySQL::Config::parse_defaults('my', [qw(client mysql)]);
59             },
60             );
61              
62              
63             has dbh => (
64 4 50   4   598 is => 'rw',
65 6 100   6   597 isa => DBH,
66             lazy => 1,
67             default => sub {
68             my $self = shift;
69             $self->use_driver;
70             my $uri = $self->registry_uri;
71             my $dbh = DBI->connect($uri->dbi_dsn, $self->username, $self->password, {
72             PrintError => 0,
73             RaiseError => 0,
74             AutoCommit => 1,
75             mysql_enable_utf8 => 1,
76             mysql_auto_reconnect => 0,
77             mysql_use_result => 0, # Prevent "Commands out of sync" error.
78             HandleError => sub {
79             my ($err, $dbh) = @_;
80             $@ = $err;
81             @_ = ($dbh->state || 'DEV' => $dbh->errstr);
82             goto &hurl;
83             },
84             Callbacks => {
85             connected => sub {
86             my $dbh = shift;
87             $dbh->do("SET SESSION $_") for (
88             q{character_set_client = 'utf8'},
89             q{character_set_server = 'utf8'},
90             ($dbh->{mysql_serverversion} || 0 < 50500 ? () : (q{default_storage_engine = 'InnoDB'})),
91             q{time_zone = '+00:00'},
92             q{group_concat_max_len = 32768},
93             q{sql_mode = '} . join(',', qw(
94             ansi
95             strict_trans_tables
96             no_auto_value_on_zero
97             no_zero_date
98             no_zero_in_date
99             only_full_group_by
100             error_for_division_by_zero
101             )) . q{'},
102             );
103             if (!$dbh->{mysql_serverversion} && DBI->VERSION < 1.631) {
104             # Prior to 1.631, callbacks were inner handles and
105             # mysql_* aren't set yet. So set InnoDB in a try block.
106             try {
107             $dbh->do(q{SET SESSION default_storage_engine = 'InnoDB'});
108             };
109             # https://www.nntp.perl.org/group/perl.dbi.dev/2013/11/msg7622.html
110             $dbh->set_err(undef, undef) if $dbh->err;
111             }
112             return;
113             },
114             },
115             });
116              
117             # Make sure we support this version.
118             my ($dbms, $vnum, $vstr) = $dbh->{mysql_serverinfo} =~ /mariadb/i
119             ? ('MariaDB', 50300, '5.3')
120             : ('MySQL', 50100, '5.1.0');
121             hurl mysql => __x(
122             'Sqitch requires {rdbms} {want_version} or higher; this is {have_version}',
123             rdbms => $dbms,
124             want_version => $vstr,
125             have_version => $dbh->selectcol_arrayref('SELECT version()')->[0],
126             ) unless $dbh->{mysql_serverversion} >= $vnum;
127              
128             return $dbh;
129             }
130             );
131              
132             has _ts_default => (
133             is => 'ro',
134             isa => Str,
135             lazy => 1,
136             default => sub {
137             return 'utc_timestamp(6)' if shift->_fractional_seconds;
138             return 'utc_timestamp';
139             },
140             );
141              
142             # Need to wait until dbh and _ts_default are defined.
143             with 'App::Sqitch::Role::DBIEngine';
144              
145             has _mysql => (
146             is => 'ro',
147             isa => ArrayRef,
148             lazy => 1,
149             default => sub {
150             my $self = shift;
151             my $uri = $self->uri;
152              
153             $self->sqitch->warn(__x
154             'Database name missing in URI "{uri}"',
155             uri => $uri
156             ) unless $uri->dbname;
157              
158             my @ret = ( $self->client );
159             for my $spec (
160             [ user => $self->username ],
161             [ database => $uri->dbname ],
162             [ host => $uri->host ],
163             [ port => $uri->_port ],
164             ) {
165             push @ret, "--$spec->[0]" => $spec->[1] if $spec->[1];
166             }
167              
168             # Special-case --password, which requires = before the value. O_o
169             if (my $pw = $self->password) {
170             my $cfgpwd = $self->_mycnf->{password} || '';
171             push @ret, "--password=$pw" if $pw ne $cfgpwd;
172             }
173              
174             # Options to keep things quiet.
175             push @ret => (
176             (App::Sqitch::ISWIN ? () : '--skip-pager' ),
177             '--silent',
178             '--skip-column-names',
179             '--skip-line-numbers',
180             );
181              
182             # Get Maria to abort properly on error.
183             my $vinfo = try { $self->sqitch->probe($self->client, '--version') } || '';
184             if ($vinfo =~ /mariadb/i) {
185             my ($version) = $vinfo =~ /Ver\s(\S+)/;
186             my ($maj, undef, $pat) = split /[.]/ => $version;
187             push @ret => '--abort-source-on-error'
188             if $maj > 5 || ($maj == 5 && $pat >= 66);
189             }
190              
191             # Add relevant query args.
192             if (my @p = $uri->query_params) {
193             my %option_for = (
194             mysql_compression => sub { $_[0] ? '--compress' : () },
195             mysql_ssl => sub { $_[0] ? '--ssl' : () },
196             mysql_connect_timeout => sub { '--connect_timeout', $_[0] },
197             mysql_init_command => sub { '--init-command', $_[0] },
198             mysql_socket => sub { '--socket', $_[0] },
199             mysql_ssl_client_key => sub { '--ssl-key', $_[0] },
200             mysql_ssl_client_cert => sub { '--ssl-cert', $_[0] },
201             mysql_ssl_ca_file => sub { '--ssl-ca', $_[0] },
202             mysql_ssl_ca_path => sub { '--ssl-capath', $_[0] },
203             mysql_ssl_cipher => sub { '--ssl-cipher', $_[0] },
204             );
205             while (@p) {
206             my ($k, $v) = (shift @p, shift @p);
207             my $code = $option_for{$k} or next;
208             push @ret => $code->($v);
209             }
210             }
211              
212             return \@ret;
213             },
214             );
215              
216             has _fractional_seconds => (
217             is => 'ro',
218             isa => Bool,
219             lazy => 1,
220             default => sub {
221             my $dbh = shift->dbh;
222             return $dbh->{mysql_serverinfo} =~ /mariadb/i
223             ? $dbh->{mysql_serverversion} >= 50305
224             : $dbh->{mysql_serverversion} >= 50604;
225             },
226             );
227              
228              
229              
230             $_[1]->set_time_zone('UTC')->iso8601;
231             }
232 39     39 1 15430  
  39         969  
233             return q{date_format(%s, 'year:%%Y:month:%%m:day:%%d:hour:%%H:minute:%%i:second:%%S:time_zone:UTC')};
234 6     6 1 11510 }
235 5     5 1 40  
236 1     1 1 4 shift;
237 3     3 1 492 map { $_ eq 'change' ? '"change"' : $_ } @_;
238             }
239              
240 0     0   0  
241             has initialized => (
242             is => 'ro',
243             isa => Bool,
244 1     1   454 lazy => 1,
245             writer => '_set_initialized',
246             default => sub {
247             my $self = shift;
248 0     0   0  
249 0 0       0 # Try to connect.
  0         0  
250             my $dbh = try { $self->dbh } catch {
251             # MySQL error code 1049 (ER_BAD_DB_ERROR): Unknown database '%-.192s'
252 0     0   0 return if $DBI::err && $DBI::err == 1049;
253             die $_;
254             } or return 0;
255              
256             return $dbh->selectcol_arrayref(q{
257             SELECT COUNT(*)
258             FROM information_schema.tables
259             WHERE table_schema = ?
260             AND table_name = ?
261             }, undef, $self->registry, 'changes')->[0];
262             }
263             );
264              
265             my $self = shift;
266             hurl engine => __x(
267             'Sqitch database {database} already initialized',
268             database => $self->registry,
269             ) if $self->initialized;
270              
271             # Create the Sqitch database if it does not exist.
272             (my $db = $self->registry) =~ s/"/""/g;
273             $self->_run(
274             '--execute' => sprintf(
275             'SET sql_mode = ansi; CREATE DATABASE IF NOT EXISTS "%s"',
276             $self->registry
277             ),
278             );
279 0     0 1 0  
280 0 0       0 # Deploy the registry to the Sqitch database.
281             $self->run_upgrade( file(__FILE__)->dir->file('mysql.sql') );
282             $self->_set_initialized(1);
283             $self->_register_release;
284             }
285              
286 0         0 # Override to lock the Sqitch tables. This ensures that only one instance of
287 0         0 # Sqitch runs at one time.
288             my $self = shift;
289             my $dbh = $self->dbh;
290              
291             # Start transaction and lock all tables to disallow concurrent changes.
292             $dbh->do('LOCK TABLES ' . join ', ', map {
293             "$_ WRITE"
294             } qw(releases changes dependencies events projects tags));
295 0         0 $dbh->begin_work;
296 0         0 return $self;
297 0         0 }
298              
299             # Override to try to acquire a lock on the string "sqitch working" without
300             # waiting.
301             my $self = shift;
302             # Can't create a lock in the registry if it doesn't exist.
303 0     0 1 0 $self->initialize unless $self->initialized;
304 0         0 $self->dbh->selectcol_arrayref(
305             q{SELECT get_lock('sqitch working', 0)}
306             )->[0]
307             }
308 0         0  
  0         0  
309             # Override to try to acquire a lock on the string "sqitch working", waiting
310 0         0 # for the lock until timeout.
311 0         0 my $self = shift;
312             $self->dbh->selectcol_arrayref(
313             q{SELECT get_lock('sqitch working', ?)},
314             undef, $self->lock_timeout
315             )->[0]
316             }
317 0     0 1 0  
318             # Override to unlock the tables, otherwise future transactions on this
319 0 0       0 # connection can fail.
320 0         0 my $self = shift;
321             my $dbh = $self->dbh;
322             $dbh->commit;
323             $dbh->do('UNLOCK TABLES');
324             return $self;
325             }
326              
327             return $DBI::state && (
328 0     0 1 0 $DBI::state eq '42S02' # ER_BAD_TABLE_ERROR
329 0         0 ||
330             ($DBI::state eq '42000' && $DBI::err == '1049') # ER_BAD_DB_ERROR
331             )
332             }
333              
334             return $DBI::state && $DBI::state eq '42S22' && $DBI::err == '1054'; # ER_BAD_FIELD_ERROR
335             }
336              
337              
338 0     0 1 0  
339 0         0 return q{GROUP_CONCAT(%1$s ORDER BY %1$s SEPARATOR ' ')};
340 0         0 }
341 0         0  
342 0         0 my ($self, $table, $change) = @_;
343             return $self if $self->_fractional_seconds;
344              
345             # No sub-second precision, so delay logging a change until a second has passed.
346 4   66 4   1289 my $dbh = $self->dbh;
347             my $sth = $dbh->prepare(qq{
348             SELECT UNIX_TIMESTAMP(committed_at) >= UNIX_TIMESTAMP()
349             FROM $table
350             WHERE project = ?
351             ORDER BY committed_at DESC
352             LIMIT 1
353             });
354 4   66 4   34 while ($dbh->selectcol_arrayref($sth, undef, $change->project)->[0]) {
355             # Sleep for 100 ms.
356             require Time::HiRes;
357 2     2   6 Time::HiRes::sleep(0.1);
358             }
359 2     2   10  
360             return $self;
361             }
362 1     1   5058  
363             my %vars = shift->variables or return;
364             return 'SET ' . join(', ', map {
365             (my $k = $_) =~ s/"/""/g;
366 2     2   867 (my $v = $vars{$_}) =~ s/'/''/g;
367 2 100       9 qq{\@"$k" = '$v'};
368             } sort keys %vars) . ";\n";
369             }
370 1         7  
371 1         7 my ($self, $file) = @_;
372             my $set = $self->_set_vars || '';
373             return ('--execute' => "${set}source $file");
374             }
375              
376             my $self = shift;
377             my $sqitch = $self->sqitch;
378 1         35 my $pass = $self->password or return $sqitch->run( $self->mysql, @_ );
379             local $ENV{MYSQL_PWD} = $pass;
380 1         73 return $sqitch->run( $self->mysql, @_ );
381 1         3 }
382              
383             my $self = shift;
384 1         68 my $sqitch = $self->sqitch;
385             my $pass = $self->password or return $sqitch->capture( $self->mysql, @_ );
386             local $ENV{MYSQL_PWD} = $pass;
387             return $sqitch->capture( $self->mysql, @_ );
388 17 100   17   63 }
389              
390 5         60 my $self = shift;
  10         36  
391 10         22 my @fh = (shift);
392 10         43 my $sqitch = $self->sqitch;
393             if (my $set = $self->_set_vars) {
394             open my $sfh, '<:utf8_strict', \$set;
395             unshift @fh, $sfh;
396             }
397 12     12   1961 my $pass = $self->password or return $sqitch->spool( \@fh, $self->mysql, @_ );
398 12   100     24 local $ENV{MYSQL_PWD} = $pass;
399 12         148 return $sqitch->spool( \@fh, $self->mysql, @_ );
400             }
401              
402             my $self = shift;
403 6     6   1102 $self->_run( $self->_source(@_) );
404 6         23 }
405 6 100       107  
406 1         105 my $self = shift;
407 1         3 # Suppress STDOUT unless we want extra verbosity.
408             my $meth = $self->can($self->sqitch->verbosity > 1 ? '_run' : '_capture');
409             $self->$meth( $self->_source(@_) );
410             }
411 4     4   727  
412 4         17 my ($self, $file) = @_;
413 4 100       70 my @cmd = $self->mysql;
414 1         33 $cmd[1 + firstidx { $_ eq '--database' } @cmd ] = $self->registry;
415 1         4 return $self->sqitch->run( @cmd, $self->_source($file) )
416             if $self->_fractional_seconds;
417              
418             # Need to strip out datetime precision.
419 5     5   1219 (my $sql = scalar $file->slurp) =~ s{DATETIME\(\d+\)}{DATETIME}g;
420 5         15  
421 5         19 # Strip out 5.5 stuff on earlier versions.
422 5 100       16 $sql =~ s/-- ## BEGIN 5[.]5.+?-- ## END 5[.]5//ms
423 2     1   54 if $self->dbh->{mysql_serverversion} < 50500;
  1     1   6  
  1         2  
  1         14  
  1         844  
  1         8  
  1         6  
424 2         729  
425             # Write out a temp file and execute it.
426 5 100       120 require File::Temp;
427 2         31 my $fh = File::Temp->new;
428 2         8 print $fh $sql;
429             close $fh;
430             $self->sqitch->run( @cmd, $self->_source($fh) );
431             }
432 2     2 1 621  
433 2         13 my ($self, $fh) = @_;
434             $self->_spool($fh);
435             }
436              
437 4     4 1 3306 my ( $self, $ord, $offset, $project ) = @_;
438              
439 4 100       70 my $offexpr = $offset ? " OFFSET $offset" : '';
440 4         166 return try {
441             return $self->dbh->selectcol_arrayref(qq{
442             SELECT change_id
443             FROM changes
444 3     3 1 3695 WHERE project = ?
445 3         10 ORDER BY committed_at $ord
446 3     12   74 LIMIT 1$offexpr
  12         155  
447 3 100       16 }, undef, $project || $self->plan->project)->[0];
448             } catch {
449             # MySQL error code 1049 (ER_BAD_DB_ERROR): Unknown database '%-.192s'
450             # MySQL error code 1146 (ER_NO_SUCH_TABLE): Table '%s.%s' doesn't exist
451 2         25 return if $DBI::err && ($DBI::err == 1049 || $DBI::err == 1146);
452             die $_;
453             };
454             }
455 2 100       585  
456             1;
457              
458 2         49 1;
459 2         11  
460 2         1153  
461 2         64 =head1 Name
462 2         20  
463             App::Sqitch::Engine::mysql - Sqitch MySQL Engine
464              
465             =head1 Synopsis
466 2     2 1 1198  
467 2         6 my $mysql = App::Sqitch::Engine->load( engine => 'mysql' );
468              
469             =head1 Description
470              
471 1     1   414 App::Sqitch::Engine::mysql provides the MySQL storage engine for Sqitch. It
472             supports MySQL 5.1.0 and higher (best on 5.6.4 and higher), as well as MariaDB
473 1 50       4 5.3.0 and higher.
474              
475 1   0 1   46 =head1 Interface
476              
477             =head2 Instance Methods
478              
479             =head3 C<mysql>
480              
481             Returns a list containing the C<mysql> client and options to be passed to it.
482             Used internally when executing scripts. Query parameters in the URI that map
483             to C<mysql> client options will be passed to the client, as follows:
484              
485 1 0 0 1   24 =over
      33        
486 1         6  
487 1         17 =item * C<mysql_compression=1>: C<--compress>
488              
489             =item * C<mysql_ssl=1>: C<--ssl>
490              
491             =item * C<mysql_connect_timeout>: C<--connect_timeout>
492              
493             =item * C<mysql_init_command>: C<--init-command>
494              
495             =item * C<mysql_socket>: C<--socket>
496              
497             =item * C<mysql_ssl_client_key>: C<--ssl-key>
498              
499             =item * C<mysql_ssl_client_cert>: C<--ssl-cert>
500              
501             =item * C<mysql_ssl_ca_file>: C<--ssl-ca>
502              
503             =item * C<mysql_ssl_ca_path>: C<--ssl-capath>
504              
505             =item * C<mysql_ssl_cipher>: C<--ssl-cipher>
506              
507             =back
508              
509             =head3 C<username>
510              
511             =head3 C<password>
512              
513             Overrides the methods provided by the target so that, if the target has
514             no username or password, Sqitch looks them up in the
515             L<F</etc/my.cnf> and F<~/.my.cnf> files|https://dev.mysql.com/doc/refman/5.7/en/password-security-user.html>.
516             These files must limit access only to the current user (C<0600>). Sqitch will
517             look for a username and password under the C<[client]> and C<[mysql]>
518             sections, in that order.
519              
520             =head1 Author
521              
522             David E. Wheeler <david@justatheory.com>
523              
524             =head1 License
525              
526             Copyright (c) 2012-2022 iovation Inc., David E. Wheeler
527              
528             Permission is hereby granted, free of charge, to any person obtaining a copy
529             of this software and associated documentation files (the "Software"), to deal
530             in the Software without restriction, including without limitation the rights
531             to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
532             copies of the Software, and to permit persons to whom the Software is
533             furnished to do so, subject to the following conditions:
534              
535             The above copyright notice and this permission notice shall be included in all
536             copies or substantial portions of the Software.
537              
538             THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
539             IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
540             FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
541             AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
542             LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
543             OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
544             SOFTWARE.
545              
546             =cut