File Coverage

blib/lib/App/Sqitch/Engine/mysql.pm
Criterion Covered Total %
statement 124 153 81.0
branch 24 34 70.5
condition 7 20 35.0
subroutine 44 53 83.0
pod 14 14 100.0
total 213 274 77.7


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