File Coverage

blib/lib/App/Sqitch/Engine/firebird.pm
Criterion Covered Total %
statement 153 315 48.5
branch 33 114 28.9
condition 11 37 29.7
subroutine 48 73 65.7
pod 24 24 100.0
total 269 563 47.7


line stmt bran cond sub pod time code
1             package App::Sqitch::Engine::firebird;
2              
3 2     2   78653 use 5.010;
  2         8  
4 2     2   16 use strict;
  2         19  
  2         47  
5 2     2   12 use warnings;
  2         9  
  2         77  
6 2     2   14 use utf8;
  2         7  
  2         16  
7 2     2   48 use Try::Tiny;
  2         4  
  2         154  
8 2     2   15 use App::Sqitch::X qw(hurl);
  2         16  
  2         20  
9 2     2   731 use Locale::TextDomain qw(App-Sqitch);
  2         5  
  2         17  
10 2     2   435 use App::Sqitch::Plan::Change;
  2         4  
  2         51  
11 2     2   11 use Path::Class;
  2         5  
  2         115  
12 2     2   15 use File::Basename;
  2         4  
  2         163  
13 2     2   1408 use Time::Local;
  2         3579  
  2         133  
14 2     2   19 use Time::HiRes qw(sleep);
  2         7  
  2         30  
15 2     2   217 use Moo;
  2         15  
  2         16  
16 2     2   857 use App::Sqitch::Types qw(DBH URIDB ArrayRef Maybe Int);
  2         9  
  2         31  
17 2     2   2632 use namespace::autoclean;
  2         5  
  2         19  
18              
19             extends 'App::Sqitch::Engine';
20              
21             our $VERSION = 'v1.4.0'; # VERSION
22              
23             has registry_uri => (
24             is => 'ro',
25             isa => URIDB,
26             lazy => 1,
27             default => sub {
28             my $self = shift;
29             my $uri = $self->uri->clone;
30             my $reg = $self->registry;
31              
32             if ( file($reg)->is_absolute ) {
33             # Just use an absolute path.
34             $uri->dbname($reg);
35             } elsif (my @segs = $uri->path_segments) {
36             # Use the same name, but replace $name.$ext with $reg.$ext.
37             my $reg = $self->registry;
38             if ($reg =~ /[.]/) {
39             $segs[-1] =~ s/^[^.]+(?:[.].+)?$/$reg/;
40             } else {
41             $segs[-1] =~ s{^[^.]+([.].+)?$}{$reg . ($1 // '')}e;
42             }
43             $uri->path_segments(@segs);
44             } else {
45             # No known path, so no name.
46             $uri->dbname(undef);
47             }
48              
49             return $uri;
50             },
51             );
52              
53             sub registry_destination {
54 2     2 1 4148 my $uri = shift->registry_uri;
55 2 100       45 if ($uri->password) {
56 1         49 $uri = $uri->clone;
57 1         18 $uri->password(undef);
58             }
59 2         146 return $uri->as_string;
60             }
61              
62 2     2   173 sub _def_user { $ENV{ISC_USER} }
63 3     3   248 sub _def_pass { $ENV{ISC_PASSWORD} }
64              
65             has dbh => (
66             is => 'rw',
67             isa => DBH,
68             lazy => 1,
69             clearer => '_clear_dbh',
70             default => sub {
71             my $self = shift;
72             my $uri = $self->registry_uri;
73             $self->use_driver;
74              
75             my $dsn = $uri->dbi_dsn . ';ib_dialect=3;ib_charset=UTF8';
76             return DBI->connect($dsn, scalar $self->username, scalar $self->password, {
77             PrintError => 0,
78             RaiseError => 0,
79             AutoCommit => 1,
80             ib_enable_utf8 => 1,
81             FetchHashKeyName => 'NAME_lc',
82             HandleError => sub {
83             my ($err, $dbh) = @_;
84             $@ = $err;
85             @_ = ($dbh->state || 'DEV' => $dbh->errstr);
86             goto &hurl;
87             },
88             });
89             }
90             );
91              
92             # Need to wait until dbh is defined.
93             with 'App::Sqitch::Role::DBIEngine';
94              
95             has _isql => (
96             is => 'ro',
97             isa => ArrayRef,
98             lazy => 1,
99             default => sub {
100             my $self = shift;
101             my $uri = $self->uri;
102             my @ret = ( $self->client );
103             for my $spec (
104             [ user => $self->username ],
105             [ password => $self->password ],
106             ) {
107             push @ret, "-$spec->[0]" => $spec->[1] if $spec->[1];
108             }
109              
110             push @ret => (
111             '-quiet',
112             '-bail',
113             '-sqldialect' => '3',
114             '-pagelength' => '16384',
115             '-charset' => 'UTF8',
116             $self->connection_string($uri),
117             );
118              
119             return \@ret;
120             },
121             );
122              
123 22     22 1 11104 sub isql { @{ shift->_isql } }
  22         548  
124              
125             has tz_offset => (
126             is => 'ro',
127             isa => Maybe[Int],
128             lazy => 1,
129             default => sub {
130             # From: https://stackoverflow.com/questions/2143528/whats-the-best-way-to-get-the-utc-offset-in-perl
131             my @t = localtime(time);
132             my $gmt_offset_in_seconds = timegm(@t) - timelocal(@t);
133             my $offset = -($gmt_offset_in_seconds / 3600);
134             return $offset;
135             },
136             );
137              
138 3     3 1 18827 sub key { 'firebird' }
139 2     2 1 37 sub name { 'Firebird' }
140 0     0 1 0 sub driver { 'DBD::Firebird 1.11' }
141              
142             sub _char2ts {
143 0     0   0 my $dt = $_[1];
144 0         0 $dt->set_time_zone('UTC');
145 0         0 return join ' ', $dt->ymd('-'), $dt->hms(':');
146             }
147              
148             sub _ts2char_format {
149 5     5   1464 return qq{'year:' || CAST(EXTRACT(YEAR FROM %s) AS SMALLINT)
150             || ':month:' || CAST(EXTRACT(MONTH FROM %1\$s) AS SMALLINT)
151             || ':day:' || CAST(EXTRACT(DAY FROM %1\$s) AS SMALLINT)
152             || ':hour:' || CAST(EXTRACT(HOUR FROM %1\$s) AS SMALLINT)
153             || ':minute:' || CAST(EXTRACT(MINUTE FROM %1\$s) AS SMALLINT)
154             || ':second:' || FLOOR(CAST(EXTRACT(SECOND FROM %1\$s) AS NUMERIC(9,4)))
155             || ':time_zone:UTC'};
156             }
157              
158             sub _ts_default {
159 0     0   0 my $offset = shift->tz_offset;
160 0         0 sleep 0.01; # give Firebird a little time to tick microseconds.
161 0         0 return qq(DATEADD($offset HOUR TO CURRENT_TIMESTAMP(3)));
162             }
163              
164             sub _version_query {
165             # Turns out, if you cast to varchar, the trailing 0s get removed. So value
166             # 1.1, represented as 1.10000002384186, returns as preferred value 1.1.
167 0     0   0 'SELECT CAST(ROUND(MAX(version), 1) AS VARCHAR(24)) AS v FROM releases',
168             }
169              
170             sub is_deployed_change {
171 0     0 1 0 my ( $self, $change ) = @_;
172 0         0 return $self->dbh->selectcol_arrayref(
173             'SELECT 1 FROM changes WHERE change_id = ?',
174             undef, $change->id
175             )->[0];
176             }
177              
178             sub is_deployed_tag {
179 0     0 1 0 my ( $self, $tag ) = @_;
180 0         0 return $self->dbh->selectcol_arrayref(q{
181             SELECT 1
182             FROM tags
183             WHERE tag_id = ?
184             }, undef, $tag->id)->[0];
185             }
186              
187             sub _initialized {
188 0     0   0 my $self = shift;
189              
190             # Try to connect.
191 0         0 my $err = 0;
192 0     0   0 my $dbh = try { $self->dbh } catch { $err = $DBI::err; $self->sqitch->debug($_); };
  0         0  
  0         0  
  0         0  
193 0 0       0 return 0 if $err;
194              
195 0         0 return $self->dbh->selectcol_arrayref(qq{
196             SELECT COUNT(RDB\$RELATION_NAME)
197             FROM RDB\$RELATIONS
198             WHERE RDB\$SYSTEM_FLAG=0
199             AND RDB\$VIEW_BLR IS NULL
200             AND RDB\$RELATION_NAME = ?
201             }, undef, 'CHANGES')->[0];
202             }
203              
204             sub _initialize {
205 1     1   3 my $self = shift;
206 1         54 my $uri = $self->registry_uri;
207 1 50       39 hurl engine => __x(
208             'Sqitch database {database} already initialized',
209             database => $uri->dbname,
210             ) if $self->initialized;
211              
212 1         10 my $sqitch_db = $self->connection_string($uri);
213              
214             # Create the registry database if it does not exist.
215 1         6 $self->use_driver;
216             try {
217 1     1   94 DBD::Firebird->create_database({
218             db_path => $sqitch_db,
219             user => scalar $self->username,
220             password => scalar $self->password,
221             character_set => 'UTF8',
222             page_size => 16384,
223             });
224             }
225             catch {
226 1     1   77 hurl firebird => __x(
227             'Cannot create database {database}: {error}',
228             database => $sqitch_db,
229             error => $_,
230             );
231 1         153 };
232              
233             # Load up our database. The database must exist!
234 0         0 $self->run_upgrade( file(__FILE__)->dir->file('firebird.sql') );
235 0         0 $self->_register_release;
236             }
237              
238             sub connection_string {
239 16     16 1 9653 my ($self, $uri) = @_;
240 16 100       89 my $file = $uri->dbname or hurl firebird => __x(
241             'Database name missing in URI {uri}',
242             uri => $uri,
243             );
244             # Use _port instead of port so it's empty if no port is in the URI.
245             # https://github.com/sqitchers/sqitch/issues/675
246 15 100       1100 my $host = $uri->host or return $file;
247 4 50       189 my $port = $uri->_port or return "$host:$file";
248 4         139 return "$host/$port:$file";
249             }
250              
251             # Override to lock the Sqitch tables. This ensures that only one instance of
252             # Sqitch runs at one time.
253             sub begin_work {
254 0     0 1 0 my $self = shift;
255 0         0 my $dbh = $self->dbh;
256              
257             # Start transaction and lock all tables to disallow concurrent changes.
258             # This should be equivalent to 'LOCK TABLE changes' ???
259             # http://conferences.embarcadero.com/article/32280#TableReservation
260 0         0 $dbh->func(
261             -lock_resolution => 'no_wait',
262             -reserving => {
263             changes => {
264             lock => 'read',
265             access => 'protected',
266             },
267             },
268             'ib_set_tx_param'
269             );
270 0         0 $dbh->begin_work;
271 0         0 return $self;
272             }
273              
274             # Override to unlock the tables, otherwise future transactions on this
275             # connection can fail.
276             sub finish_work {
277 0     0 1 0 my $self = shift;
278 0         0 my $dbh = $self->dbh;
279 0         0 $dbh->commit;
280 0         0 $dbh->func( 'ib_set_tx_param' ); # reset parameters
281 0         0 return $self;
282             }
283              
284             sub _dt($) {
285 1     1   927 require App::Sqitch::DateTime;
286 1         32 return App::Sqitch::DateTime->new(split /:/ => shift);
287             }
288              
289             sub _no_table_error {
290 6   100 6   7657 return $DBI::errstr && $DBI::errstr =~ /^-Table unknown|No such file or directory/m;
291             }
292              
293             sub _no_column_error {
294 4   100 4   61 return $DBI::errstr && $DBI::errstr =~ /^-Column unknown/m;
295             }
296              
297             sub _unique_error {
298 0   0 0   0 return $DBI::errstr && $DBI::errstr =~ /no 2 table rows can have duplicate column values$/m;
299             }
300              
301 0     0   0 sub _regex_op { 'SIMILAR TO' } # NOT good match for
302             # REGEXP :(
303              
304 1     1   670 sub _limit_default { '18446744073709551615' }
305              
306             sub _listagg_format {
307 2     2   7 return q{LIST(ALL %s, ' ')}; # Firebird v2.1.4 minimum
308             }
309              
310             sub _run {
311 4     4   16601 my $self = shift;
312 4         18 my $sqitch = $self->sqitch;
313 4 100       97 my $pass = $self->password or return $sqitch->run( $self->isql, @_ );
314 1         20 local $ENV{ISC_PASSWORD} = $pass;
315 1         6 return $sqitch->run( $self->isql, @_ );
316             }
317              
318             sub _capture {
319 3     3   1649 my $self = shift;
320 3         11 my $sqitch = $self->sqitch;
321 3 100       70 my $pass = $self->password or return $sqitch->capture( $self->isql, @_ );
322 1         18 local $ENV{ISC_PASSWORD} = $pass;
323 1         4 return $sqitch->capture( $self->isql, @_ );
324             }
325              
326             sub _spool {
327 3     3   1754 my $self = shift;
328 3         6 my $fh = shift;
329 3         11 my $sqitch = $self->sqitch;
330 3 100       73 my $pass = $self->password or return $sqitch->spool( $fh, $self->isql, @_ );
331 1         17 local $ENV{ISC_PASSWORD} = $pass;
332 1         6 return $sqitch->spool( $fh, $self->isql, @_ );
333             }
334              
335             sub run_file {
336 1     1 1 821 my ($self, $file) = @_;
337 1         5 $self->_run( '-input' => $file );
338             }
339              
340             sub run_verify {
341 2     2 1 1756 my ($self, $file) = @_;
342             # Suppress STDOUT unless we want extra verbosity.
343 2 100       37 my $meth = $self->can($self->sqitch->verbosity > 1 ? '_run' : '_capture');
344 2         180 $self->$meth( '-input' => $file );
345             }
346              
347             sub run_upgrade {
348 1     1 1 4413 my ($self, $file) = @_;
349 1         4 my $uri = $self->registry_uri;
350 1         6 my @cmd = $self->isql;
351 1         24 $cmd[-1] = $self->connection_string($uri);
352 1         26 my $sqitch = $self->sqitch;
353 1 50       3 unless ($uri->host) {
354             # Only one connection allowed when using an embedded database (Engine 12
355             # provider). So disconnect so that the upgrade can connect and succeed,
356             # and clear the disconnected handle so that the next call to ->dbh will
357             # reconnect.
358 1         19 $self->dbh->disconnect; $self->_clear_dbh;
  1         8  
359             }
360 1         8 $sqitch->run( @cmd, '-input' => $sqitch->quote_shell($file) );
361             }
362              
363             sub run_handle {
364 1     1 1 807 my ($self, $fh) = @_;
365 1         3 $self->_spool($fh);
366             }
367              
368             sub _cid {
369 2     2   2957 my ( $self, $ord, $offset, $project ) = @_;
370              
371 2 50       8 my $offexpr = $offset ? " SKIP $offset" : '';
372             return try {
373 2   0 2   92 return $self->dbh->selectcol_arrayref(qq{
374             SELECT FIRST 1$offexpr change_id
375             FROM changes
376             WHERE project = ?
377             ORDER BY committed_at $ord;
378             }, undef, $project || $self->plan->project)->[0];
379             } catch {
380             # Firebird generic error code -902, one possible message:
381             # -I/O error during "open" operation for file...
382             # -Error while trying to open file
383             # -No such file or directory
384             # print "===DBI ERROR: $DBI::err\n";
385 2 100   2   47 return if $DBI::err == -902; # can't connect to database
386 1         8 die $_;
387 2         16 };
388             }
389              
390             sub current_state {
391 2     2 1 1309 my ( $self, $project ) = @_;
392 2         8 my $cdtcol = sprintf $self->_ts2char_format, 'c.committed_at';
393 2         7 my $pdtcol = sprintf $self->_ts2char_format, 'c.planned_at';
394 2         6 my $tagcol = sprintf $self->_listagg_format, 't.tag';
395             my $state = try {
396 2   0 2   91 $self->dbh->selectrow_hashref(qq{
397             SELECT FIRST 1 c.change_id
398             , c.script_hash
399             , c.change
400             , c.project
401             , c.note
402             , c.committer_name
403             , c.committer_email
404             , $cdtcol AS committed_at
405             , c.planner_name
406             , c.planner_email
407             , $pdtcol AS planned_at
408             , $tagcol AS tags
409             FROM changes c
410             LEFT JOIN tags t ON c.change_id = t.change_id
411             WHERE c.project = ?
412             GROUP BY c.change_id
413             , c.script_hash
414             , c.change
415             , c.project
416             , c.note
417             , c.committer_name
418             , c.committer_email
419             , c.committed_at
420             , c.planner_name
421             , c.planner_email
422             , c.planned_at
423             ORDER BY c.committed_at DESC
424             }, undef, $project // $self->plan->project );
425             } catch {
426 2 100 66 2   71 return if $self->_no_table_error && !$self->initialized;
427 1         10 die $_;
428 2 50       16 } or return undef;
429              
430 0 0       0 unless (ref $state->{tags}) {
431 0 0       0 $state->{tags} = $state->{tags} ? [ split / / => $state->{tags} ] : [];
432             }
433 0         0 $state->{committed_at} = _dt $state->{committed_at};
434 0         0 $state->{planned_at} = _dt $state->{planned_at};
435 0         0 return $state;
436             }
437              
438             sub search_events {
439 0     0 1 0 my ( $self, %p ) = @_;
440              
441             # Determine order direction.
442 0         0 my $dir = 'DESC';
443 0 0       0 if (my $d = delete $p{direction}) {
444 0 0       0 $dir = $d =~ /^ASC/i ? 'ASC'
    0          
445             : $d =~ /^DESC/i ? 'DESC'
446             : hurl 'Search direction must be either "ASC" or "DESC"';
447             }
448              
449             # Limit with regular expressions?
450 0         0 my (@wheres, @params);
451 0         0 my $op = $self->_regex_op;
452 0         0 for my $spec (
453             [ committer => 'e.committer_name' ],
454             [ planner => 'e.planner_name' ],
455             [ change => 'e.change' ],
456             [ project => 'e.project' ],
457             ) {
458 0   0     0 my $regex = delete $p{ $spec->[0] } // next;
459             # Trying to adapt REGEXP for SIMILAR TO from Firebird 2.5 :)
460             # Yes, I know is ugly...
461             # There is no support for ^ and $ as in normal REGEXP.
462             #
463             # From the docs:
464             # Description: SIMILAR TO matches a string against an SQL
465             # regular expression pattern. UNLIKE in some other languages,
466             # the pattern MUST MATCH THE ENTIRE STRING in order to succeed
467             # – matching a substring is not enough. If any operand is
468             # NULL, the result is NULL. Otherwise, the result is TRUE or
469             # FALSE.
470             #
471             # Maybe use the CONTAINING operator instead?
472             # print "===REGEX: $regex\n";
473 0 0 0     0 if ( $regex =~ m{^\^} and $regex =~ m{\$$} ) {
474 0         0 $regex =~ s{\^}{};
475 0         0 $regex =~ s{\$}{};
476 0         0 $regex = "%$regex%";
477             }
478             else {
479 0 0 0     0 if ( $regex !~ m{^\^} and $regex !~ m{\$$} ) {
480 0         0 $regex = "%$regex%";
481             }
482             }
483 0 0       0 if ( $regex =~ m{\$$} ) {
484 0         0 $regex =~ s{\$}{};
485 0         0 $regex = "%$regex";
486             }
487 0 0       0 if ( $regex =~ m{^\^} ) {
488 0         0 $regex =~ s{\^}{};
489 0         0 $regex = "$regex%";
490             }
491             # print "== SIMILAR TO: $regex\n";
492 0         0 push @wheres => "$spec->[1] $op ?";
493 0         0 push @params => "$regex";
494             }
495              
496             # Match events?
497 0 0       0 if (my $e = delete $p{event} ) {
498 0         0 my ($in, @vals) = $self->_in_expr( $e );
499 0         0 push @wheres => "e.event $in";
500 0         0 push @params => @vals;
501             }
502              
503             # Assemble the where clause.
504 0 0       0 my $where = @wheres
505             ? "\n WHERE " . join( "\n ", @wheres )
506             : '';
507              
508             # Handle remaining parameters.
509 0         0 my $limits = '';
510 0 0 0     0 if (exists $p{limit} || exists $p{offset}) {
511 0         0 my $lim = delete $p{limit};
512 0 0       0 if ($lim) {
513 0         0 $limits = " FIRST ? ";
514 0         0 push @params => $lim;
515             }
516 0 0       0 if (my $off = delete $p{offset}) {
517 0         0 $limits .= " SKIP ? ";
518 0         0 push @params => $off;
519             }
520             }
521              
522 0 0       0 hurl 'Invalid parameters passed to search_events(): '
523             . join ', ', sort keys %p if %p;
524              
525 0         0 $self->dbh->{ib_softcommit} = 1;
526              
527             # Prepare, execute, and return.
528 0         0 my $cdtcol = sprintf $self->_ts2char_format, 'e.committed_at';
529 0         0 my $pdtcol = sprintf $self->_ts2char_format, 'e.planned_at';
530 0         0 my $sth = $self->dbh->prepare(qq{
531             SELECT $limits e.event
532             , e.project
533             , e.change_id
534             , e.change
535             , e.note
536             , e.requires
537             , e.conflicts
538             , e.tags
539             , e.committer_name
540             , e.committer_email
541             , $cdtcol AS committed_at
542             , e.planner_name
543             , e.planner_email
544             , $pdtcol AS planned_at
545             FROM events e$where
546             ORDER BY e.committed_at $dir
547             });
548 0         0 $sth->execute(@params);
549             return sub {
550 0 0   0   0 my $row = $sth->fetchrow_hashref or return;
551 0         0 $row->{committed_at} = _dt $row->{committed_at};
552 0         0 $row->{planned_at} = _dt $row->{planned_at};
553 0         0 return $row;
554 0         0 };
555             }
556              
557             sub changes_requiring_change {
558 0     0 1 0 my ( $self, $change ) = @_;
559 0         0 return @{ $self->dbh->selectall_arrayref(q{
  0         0  
560             SELECT c.change_id, c.project, c.change, (
561             SELECT FIRST 1 tag
562             FROM changes c2
563             JOIN tags ON c2.change_id = tags.change_id
564             WHERE c2.project = c.project
565             AND c2.committed_at >= c.committed_at
566             ORDER BY c2.committed_at
567             ) AS asof_tag
568             FROM dependencies d
569             JOIN changes c ON c.change_id = d.change_id
570             WHERE d.dependency_id = ?
571             }, { Slice => {} }, $change->id) };
572             }
573              
574             sub name_for_change_id {
575 0     0 1 0 my ( $self, $change_id ) = @_;
576 0         0 return $self->dbh->selectcol_arrayref(q{
577             SELECT c.change || COALESCE((
578             SELECT FIRST 1 tag
579             FROM changes c2
580             JOIN tags ON c2.change_id = tags.change_id
581             WHERE c2.committed_at >= c.committed_at
582             AND c2.project = c.project
583             ), '@HEAD')
584             FROM changes c
585             WHERE change_id = ?
586             }, undef, $change_id)->[0];
587             }
588              
589             sub _offset_op {
590 0     0   0 my ( $self, $offset ) = @_;
591 0 0       0 my ( $dir, $op ) = $offset > 0 ? ( 'ASC', '>' ) : ( 'DESC' , '<' );
592 0         0 return $dir, $op, 'SKIP ' . (abs($offset) - 1);
593             }
594              
595             sub change_id_offset_from_id {
596 0     0 1 0 my ( $self, $change_id, $offset ) = @_;
597              
598             # Just return the ID if there is no offset.
599 0 0       0 return $change_id unless $offset;
600              
601 0         0 my ($dir, $op, $offset_expr) = $self->_offset_op($offset);
602 0         0 return $self->dbh->selectcol_arrayref(qq{
603             SELECT FIRST 1 $offset_expr change_id AS "id"
604             FROM changes
605             WHERE project = ?
606             AND committed_at $op (
607             SELECT committed_at FROM changes WHERE change_id = ?
608             )
609             ORDER BY committed_at $dir
610             }, undef, $self->plan->project, $change_id )->[0];
611             }
612              
613             sub change_offset_from_id {
614 0     0 1 0 my ( $self, $change_id, $offset ) = @_;
615              
616             # Just return the object if there is no offset.
617 0 0       0 return $self->load_change($change_id) unless $offset;
618              
619             # Are we offset forwards or backwards?
620 0         0 my ($dir, $op, $offset_expr) = $self->_offset_op($offset);
621 0         0 my $tscol = sprintf $self->_ts2char_format, 'c.planned_at';
622 0         0 my $tagcol = sprintf $self->_listagg_format, 't.tag';
623              
624 0   0     0 my $change = $self->dbh->selectrow_hashref(qq{
625             SELECT FIRST 1 $offset_expr
626             c.change_id AS "id", c.change AS name, c.project, c.note,
627             $tscol AS "timestamp", c.planner_name, c.planner_email,
628             $tagcol AS tags, c.script_hash
629             FROM changes c
630             LEFT JOIN tags t ON c.change_id = t.change_id
631             WHERE c.project = ?
632             AND c.committed_at $op (
633             SELECT committed_at FROM changes WHERE change_id = ?
634             )
635             GROUP BY c.change_id, c.change, c.project, c.note, c.planned_at,
636             c.planner_name, c.planner_email, c.committed_at, c.script_hash
637             ORDER BY c.committed_at $dir
638             }, undef, $self->plan->project, $change_id ) || return undef;
639 0         0 $change->{timestamp} = _dt $change->{timestamp};
640 0 0       0 unless ( ref $change->{tags} ) {
641 0 0       0 $change->{tags} = $change->{tags} ? [ split / / => $change->{tags} ] : [];
642             }
643 0         0 return $change;
644             }
645              
646             sub _cid_head {
647 0     0   0 my ($self, $project, $change) = @_;
648 0         0 return $self->dbh->selectcol_arrayref(q{
649             SELECT FIRST 1 change_id
650             FROM changes
651             WHERE project = ?
652             AND changes.change = ?
653             ORDER BY committed_at DESC
654             }, undef, $project, $change)->[0];
655             }
656              
657             sub change_id_for {
658 1     1 1 649 my ( $self, %p) = @_;
659 1         4 my $dbh = $self->dbh;
660              
661 1 50       38 if ( my $cid = $p{change_id} ) {
662             # Find by ID.
663 0         0 return $dbh->selectcol_arrayref(q{
664             SELECT change_id
665             FROM changes
666             WHERE change_id = ?
667             }, undef, $cid)->[0];
668             }
669              
670 1   33     7 my $project = $p{project} || $self->plan->project;
671 1 50       4 if ( my $change = $p{change} ) {
672 0 0       0 if ( my $tag = $p{tag} ) {
673             # There is nothing before the first tag.
674 0 0       0 return undef if $tag eq 'ROOT';
675              
676             # Find closest to the end for @HEAD.
677 0 0       0 return $self->_cid_head($project, $change) if $tag eq 'HEAD';
678              
679             # Find by change name and following tag.
680 0         0 return $dbh->selectcol_arrayref(q{
681             SELECT FIRST 1 changes.change_id
682             FROM changes
683             JOIN tags
684             ON changes.committed_at <= tags.committed_at
685             AND changes.project = tags.project
686             WHERE changes.project = ?
687             AND changes.change = ?
688             AND tags.tag = ?
689             ORDER BY changes.committed_at DESC
690             }, undef, $project, $change, '@' . $tag)->[0];
691             }
692              
693             # Find earliest by change name.
694 0         0 my $ids = $dbh->selectcol_arrayref(qq{
695             SELECT change_id
696             FROM changes
697             WHERE project = ?
698             AND changes.change = ?
699             ORDER BY changes.committed_at ASC
700             }, undef, $project, $change);
701              
702             # Return the ID.
703 0 0       0 return $ids->[0] if $p{first};
704 0         0 return $self->_handle_lookup_index($change, $ids);
705             }
706              
707 1 50       4 if ( my $tag = $p{tag} ) {
708             # Just return the latest for @HEAD.
709 0 0       0 return $self->_cid('DESC', 0, $project) if $tag eq 'HEAD';
710              
711             # Just return the earliest for @ROOT.
712 0 0       0 return $self->_cid('ASC', 0, $project) if $tag eq 'ROOT';
713              
714             # Find by tag name.
715 0         0 return $dbh->selectcol_arrayref(q{
716             SELECT change_id
717             FROM tags
718             WHERE project = ?
719             AND tag = ?
720             }, undef, $project, '@' . $tag)->[0];
721             }
722              
723             # We got nothin.
724 1         6 return undef;
725             }
726              
727             sub log_new_tags {
728 0     0 1 0 my ( $self, $change ) = @_;
729 0 0       0 my @tags = $change->tags or return $self;
730 0         0 my $sqitch = $self->sqitch;
731              
732 0         0 my ($id, $name, $proj, $user, $email) = (
733             $change->id,
734             $change->format_name,
735             $change->project,
736             $sqitch->user_name,
737             $sqitch->user_email
738             );
739              
740 0         0 my $ts = $self->_ts_default;
741 0         0 my $sf = $self->_simple_from;
742              
743 0         0 my $sql = q{
744             INSERT INTO tags (
745             tag_id
746             , tag
747             , project
748             , change_id
749             , note
750             , committer_name
751             , committer_email
752             , planned_at
753             , planner_name
754             , planner_email
755             , committed_at
756             )
757             SELECT i.* FROM (
758             } . join(
759             "\n UNION ALL ",
760             ("SELECT CAST(? AS CHAR(40)) AS tid
761             , CAST(? AS VARCHAR(250)) AS tname
762             , CAST(? AS VARCHAR(255)) AS proj
763             , CAST(? AS CHAR(40)) AS cid
764             , CAST(? AS VARCHAR(4000)) AS note
765             , CAST(? AS VARCHAR(512)) AS cuser
766             , CAST(? AS VARCHAR(512)) AS cemail
767             , CAST(? AS TIMESTAMP) AS tts
768             , CAST(? AS VARCHAR(512)) AS puser
769             , CAST(? AS VARCHAR(512)) AS pemail
770             , CAST($ts$sf AS TIMESTAMP) AS cts"
771             ) x @tags ) . q{
772             FROM RDB$DATABASE ) i
773             LEFT JOIN tags ON i.tid = tags.tag_id
774             WHERE tags.tag_id IS NULL
775             };
776 0         0 my @params = map { (
777 0         0 $_->id,
778             $_->format_name,
779             $proj,
780             $id,
781             $_->note,
782             $user,
783             $email,
784             $self->_char2ts( $_->timestamp ),
785             $_->planner_name,
786             $_->planner_email,
787             ) } @tags;
788 0         0 $self->dbh->do($sql, undef, @params );
789 0         0 return $self;
790             }
791              
792             sub log_deploy_change {
793 0     0 1 0 my ($self, $change) = @_;
794 0         0 my $dbh = $self->dbh;
795 0         0 my $sqitch = $self->sqitch;
796              
797 0         0 my ($id, $name, $proj, $user, $email) = (
798             $change->id,
799             $change->format_name,
800             $change->project,
801             $sqitch->user_name,
802             $sqitch->user_email
803             );
804              
805 0         0 my $ts = $self->_ts_default;
806 0         0 my $cols = join "\n , ", $self->_quote_idents(qw(
807             change_id
808             script_hash
809             change
810             project
811             note
812             committer_name
813             committer_email
814             planned_at
815             planner_name
816             planner_email
817             committed_at
818             ));
819             try {
820 0     0   0 $dbh->do(qq{
821             INSERT INTO changes (
822             $cols
823             )
824             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, $ts)
825             }, undef,
826             $id,
827             $change->script_hash,
828             $name,
829             $proj,
830             $change->note,
831             $user,
832             $email,
833             $self->_char2ts( $change->timestamp ),
834             $change->planner_name,
835             $change->planner_email,
836             );
837             } catch {
838 0 0   0   0 hurl engine => __x(
839             'Cannot log change "{change}": The deploy script is not unique',
840             change => $name,
841             ) if $self->_unique_error;
842 0         0 die $_;
843 0         0 };
844              
845 0 0       0 if ( my @deps = $change->dependencies ) {
846 0         0 foreach my $dep (@deps) {
847 0         0 my $sql = q{
848             INSERT INTO dependencies (
849             change_id
850             , type
851             , dependency
852             , dependency_id
853             ) VALUES ( ?, ?, ?, ? ) };
854 0         0 $dbh->do( $sql, undef,
855             ( $id, $dep->type, $dep->as_string, $dep->resolved_id ) );
856             }
857             }
858              
859 0 0       0 if ( my @tags = $change->tags ) {
860 0         0 foreach my $tag (@tags) {
861 0         0 my $sql = qq{
862             INSERT INTO tags (
863             tag_id
864             , tag
865             , project
866             , change_id
867             , note
868             , committer_name
869             , committer_email
870             , planned_at
871             , planner_name
872             , planner_email
873             , committed_at
874             ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, $ts) };
875 0         0 $dbh->do(
876             $sql, undef,
877             ( $tag->id, $tag->format_name,
878             $proj, $id,
879             $tag->note, $user,
880             $email, $self->_char2ts( $tag->timestamp ),
881             $tag->planner_name, $tag->planner_email,
882             )
883             );
884             }
885             }
886              
887 0         0 return $self->_log_event( deploy => $change );
888             }
889              
890             sub default_client {
891 2     2 1 2731 my $self = shift;
892 2 50       18 my $ext = App::Sqitch::ISWIN || $^O eq 'cygwin' ? '.exe' : '';
893              
894             # Create a script to run.
895 2         12 require File::Temp;
896 2         22 my $fh = File::Temp->new( CLEANUP => 1 );
897 2         995 my @opts = (qw(-z -q -i), $fh->filename);
898 2         31 $fh->print("quit;\n");
899 2         64 $fh->close;
900              
901             # Suppress STDERR, including in subprocess.
902 2 50       148 open my $olderr, '>&', \*STDERR or hurl firebird => __x(
903             'Cannot dup STDERR: {error}', $!
904             );
905 2         17 close STDERR;
906 1 50   1   7 open STDERR, '>', \my $stderr or hurl firebird => __x(
  1         2  
  1         8  
  2         38  
907             'Cannot reirect STDERR: {error}', $!
908             );
909              
910             # Try to find a client in the path.
911 2         890 for my $try ( map { $_ . $ext } qw(fbsql isql-fb isql) ) {
  6         19  
912 6         245 my $loops = 0;
913 6         80 for my $dir (File::Spec->path) {
914 30         1394 my $path = file $dir, $try;
915             # GetShortPathName returns undef for nonexistent files.
916 30         2636 $path = Win32::GetShortPathName($path) // next if App::Sqitch::ISWIN;
917 30 100 66     113 if (-f $path && -x $path) {
918 1 50   1   115 if (try { App::Sqitch->probe($path, @opts) =~ /Firebird/ } ) {
  1         36  
919             # Restore STDERR and return.
920 0 0       0 open STDERR, '>&', $olderr or hurl firebird => __x(
921             'Cannot dup STDERR: {error}', $!
922             );
923 0 0       0 return $loops ? $path->stringify : $try;
924             }
925 1         2147 $loops++;
926             }
927             }
928             }
929              
930             # Restore STDERR and die.
931 2 50       232 open STDERR, '>&', $olderr or hurl firebird => __x(
932             'Cannot dup STDERR: {error}', $!
933             );
934 2         56 hurl firebird => __(
935             'Unable to locate Firebird ISQL; set "engine.firebird.client" via sqitch config'
936             );
937             }
938              
939             sub _update_script_hashes {
940 0     0     my $self = shift;
941 0           my $plan = $self->plan;
942 0           my $proj = $plan->project;
943 0           my $dbh = $self->dbh;
944              
945 0           $self->begin_work;
946             # Firebird refuses to update via a prepared statement, so use do(). :-(
947             $dbh->do(
948             'UPDATE changes SET script_hash = ? WHERE change_id = ?',
949             undef, $_->script_hash, $_->id
950 0           ) for $plan->changes;
951 0           $dbh->do(q{
952             UPDATE changes SET script_hash = NULL
953             WHERE project = ? AND script_hash = change_id
954             }, undef, $proj);
955              
956 0           $self->finish_work;
957 0           return $self;
958             }
959              
960             1;
961              
962             __END__
963              
964             =encoding utf8
965              
966             =head1 Name
967              
968             App::Sqitch::Engine::firebird - Sqitch Firebird Engine
969              
970             =head1 Synopsis
971              
972             my $firebird = App::Sqitch::Engine->load( engine => 'firebird' );
973              
974             =head1 Description
975              
976             App::Sqitch::Engine::firebird provides the Firebird storage engine for Sqitch.
977              
978             =head1 Interface
979              
980             =head2 Instance Methods
981              
982             =head3 C<connection_string>
983              
984             Constructs a connection string from a database URI for passing to C<isql>.
985              
986             =head3 C<isql>
987              
988             Returns a list containing the C<isql> client and options to be passed to it.
989             Used internally when executing scripts.
990              
991             =head1 Author
992              
993             David E. Wheeler <david@justatheory.com>
994              
995             Ștefan Suciu <stefan@s2i2.ro>
996              
997             =head1 License
998              
999             Copyright (c) 2012-2023 iovation Inc., David E. Wheeler
1000              
1001             Copyright (c) 2013 Ștefan Suciu
1002              
1003             Permission is hereby granted, free of charge, to any person obtaining a copy
1004             of this software and associated documentation files (the "Software"), to deal
1005             in the Software without restriction, including without limitation the rights
1006             to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
1007             copies of the Software, and to permit persons to whom the Software is
1008             furnished to do so, subject to the following conditions:
1009              
1010             The above copyright notice and this permission notice shall be included in all
1011             copies or substantial portions of the Software.
1012              
1013             THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1014             IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1015             FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1016             AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1017             LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
1018             OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
1019             SOFTWARE.
1020              
1021             =cut