File Coverage

blib/lib/App/Sqitch/Engine/firebird.pm
Criterion Covered Total %
statement 152 316 48.1
branch 33 114 28.9
condition 11 37 29.7
subroutine 48 73 65.7
pod 24 24 100.0
total 268 564 47.5


line stmt bran cond sub pod time code
1             package App::Sqitch::Engine::firebird;
2              
3 2     2   106389 use 5.010;
  2         10  
4 2     2   13 use strict;
  2         4  
  2         70  
5 2     2   38 use warnings;
  2         5  
  2         104  
6 2     2   11 use utf8;
  2         6  
  2         18  
7 2     2   88 use Try::Tiny;
  2         5  
  2         161  
8 2     2   40 use App::Sqitch::X qw(hurl);
  2         9  
  2         25  
9 2     2   824 use Locale::TextDomain qw(App-Sqitch);
  2         6  
  2         22  
10 2     2   542 use App::Sqitch::Plan::Change;
  2         5  
  2         96  
11 2     2   13 use Path::Class;
  2         6  
  2         110  
12 2     2   12 use File::Basename;
  2         5  
  2         164  
13 2     2   1337 use Time::Local;
  2         4636  
  2         183  
14 2     2   20 use Time::HiRes qw(sleep);
  2         4  
  2         18  
15 2     2   142 use Moo;
  2         6  
  2         15  
16 2     2   1277 use App::Sqitch::Types qw(DBH URIDB ArrayRef Maybe Int);
  2         5  
  2         35  
17 2     2   7816 use namespace::autoclean;
  2         5  
  2         26  
18              
19             extends 'App::Sqitch::Engine';
20              
21             our $VERSION = 'v1.6.1'; # 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 8366 my $uri = shift->registry_uri;
55 2 100       92 if ($uri->password) {
56 1         65 $uri = $uri->clone;
57 1         20 $uri->password(undef);
58             }
59 2         232 return $uri->as_string;
60             }
61              
62 2     2   234 sub _def_user { $ENV{ISC_USER} }
63 3     3   307 sub _def_pass { $ENV{ISC_PASSWORD} }
64             sub _dsn {
65 1     1   57 my $uri = shift->registry_uri;
66 1         48 return $uri->dbi_dsn . ';ib_dialect=3;ib_charset=UTF8';
67             }
68              
69             has dbh => (
70             is => 'rw',
71             isa => DBH,
72             lazy => 1,
73             clearer => '_clear_dbh',
74             default => sub {
75             my $self = shift;
76             $self->use_driver;
77              
78             return DBI->connect($self->_dsn, scalar $self->username, scalar $self->password, {
79             PrintError => 0,
80             RaiseError => 0,
81             AutoCommit => 1,
82             ib_enable_utf8 => 1,
83             FetchHashKeyName => 'NAME_lc',
84             HandleError => $self->error_handler,
85             });
86             }
87             );
88              
89             # Need to wait until dbh is defined.
90             with 'App::Sqitch::Role::DBIEngine';
91              
92             has _isql => (
93             is => 'ro',
94             isa => ArrayRef,
95             lazy => 1,
96             default => sub {
97             my $self = shift;
98             my $uri = $self->uri;
99             my @ret = ( $self->client );
100             for my $spec (
101             [ user => $self->username ],
102             [ password => $self->password ],
103             ) {
104             push @ret, "-$spec->[0]" => $spec->[1] if $spec->[1];
105             }
106              
107             push @ret => (
108             '-quiet',
109             '-bail',
110             '-sqldialect' => '3',
111             '-pagelength' => '16384',
112             '-charset' => 'UTF8',
113             $self->connection_string($uri),
114             );
115              
116             return \@ret;
117             },
118             );
119              
120 22     22 1 10928 sub isql { @{ shift->_isql } }
  22         676  
121              
122             has tz_offset => (
123             is => 'ro',
124             isa => Maybe[Int],
125             lazy => 1,
126             default => sub {
127             # From: https://stackoverflow.com/questions/2143528/whats-the-best-way-to-get-the-utc-offset-in-perl
128             my @t = localtime(time);
129             my $gmt_offset_in_seconds = timegm(@t) - timelocal(@t);
130             my $offset = -($gmt_offset_in_seconds / 3600);
131             return $offset;
132             },
133             );
134              
135 4     4 1 17572 sub key { 'firebird' }
136 2     2 1 33 sub name { 'Firebird' }
137 0     0 1 0 sub driver { 'DBD::Firebird 1.11' }
138              
139             sub _char2ts {
140 0     0   0 my $dt = $_[1];
141 0         0 $dt->set_time_zone('UTC');
142 0         0 return join ' ', $dt->ymd('-'), $dt->hms(':');
143             }
144              
145             sub _ts2char_format {
146 5     5   1169 return qq{'year:' || CAST(EXTRACT(YEAR FROM %s) AS SMALLINT)
147             || ':month:' || CAST(EXTRACT(MONTH FROM %1\$s) AS SMALLINT)
148             || ':day:' || CAST(EXTRACT(DAY FROM %1\$s) AS SMALLINT)
149             || ':hour:' || CAST(EXTRACT(HOUR FROM %1\$s) AS SMALLINT)
150             || ':minute:' || CAST(EXTRACT(MINUTE FROM %1\$s) AS SMALLINT)
151             || ':second:' || FLOOR(CAST(EXTRACT(SECOND FROM %1\$s) AS NUMERIC(9,4)))
152             || ':time_zone:UTC'};
153             }
154              
155             sub _ts_default {
156 0     0   0 my $offset = shift->tz_offset;
157 0         0 sleep 0.01; # give Firebird a little time to tick microseconds.
158 0         0 return qq(DATEADD($offset HOUR TO CURRENT_TIMESTAMP(3)));
159             }
160              
161             sub _version_query {
162             # Turns out, if you cast to varchar, the trailing 0s get removed. So value
163             # 1.1, represented as 1.10000002384186, returns as preferred value 1.1.
164 0     0   0 'SELECT CAST(ROUND(MAX(version), 1) AS VARCHAR(24)) AS v FROM releases',
165             }
166              
167             sub is_deployed_change {
168 0     0 1 0 my ( $self, $change ) = @_;
169 0         0 return $self->dbh->selectcol_arrayref(
170             'SELECT 1 FROM changes WHERE change_id = ?',
171             undef, $change->id
172             )->[0];
173             }
174              
175             sub is_deployed_tag {
176 0     0 1 0 my ( $self, $tag ) = @_;
177 0         0 return $self->dbh->selectcol_arrayref(q{
178             SELECT 1
179             FROM tags
180             WHERE tag_id = ?
181             }, undef, $tag->id)->[0];
182             }
183              
184             sub _initialized {
185 0     0   0 my $self = shift;
186              
187             # Try to connect.
188 0         0 my $err = 0;
189 0     0   0 my $dbh = try { $self->dbh } catch { $err = $DBI::err; $self->sqitch->debug($_); };
  0         0  
  0         0  
  0         0  
190 0 0       0 return 0 if $err;
191              
192 0         0 return $self->dbh->selectcol_arrayref(qq{
193             SELECT COUNT(RDB\$RELATION_NAME)
194             FROM RDB\$RELATIONS
195             WHERE RDB\$SYSTEM_FLAG=0
196             AND RDB\$VIEW_BLR IS NULL
197             AND RDB\$RELATION_NAME = ?
198             }, undef, 'CHANGES')->[0];
199             }
200              
201             sub _initialize {
202 1     1   3 my $self = shift;
203 1         42 my $uri = $self->registry_uri;
204 1 50       42 hurl engine => __x(
205             'Sqitch database {database} already initialized',
206             database => $uri->dbname,
207             ) if $self->initialized;
208              
209 1         9 my $sqitch_db = $self->connection_string($uri);
210              
211             # Create the registry database if it does not exist.
212 1         6 $self->use_driver;
213             try {
214 1     1   171 DBD::Firebird->create_database({
215             db_path => $sqitch_db,
216             user => scalar $self->username,
217             password => scalar $self->password,
218             character_set => 'UTF8',
219             page_size => 16384,
220             });
221             }
222             catch {
223 1     1   91 hurl firebird => __x(
224             'Cannot create database {database}: {error}',
225             database => $sqitch_db,
226             error => $_,
227             );
228 1         15 };
229              
230             # Load up our database. The database must exist!
231 0         0 $self->run_upgrade( file(__FILE__)->dir->file('firebird.sql') );
232 0         0 $self->_register_release;
233             }
234              
235             sub connection_string {
236 16     16 1 11641 my ($self, $uri) = @_;
237 16 100       120 my $file = $uri->dbname or hurl firebird => __x(
238             'Database name missing in URI {uri}',
239             uri => $uri,
240             );
241             # Use _port instead of port so it's empty if no port is in the URI.
242             # https://github.com/sqitchers/sqitch/issues/675
243 15 100       1480 my $host = $uri->host or return $file;
244 4 50       216 my $port = $uri->_port or return "$host:$file";
245 4         140 return "$host/$port:$file";
246             }
247              
248             # Override to lock the Sqitch tables. This ensures that only one instance of
249             # Sqitch runs at one time.
250             sub begin_work {
251 0     0 1 0 my $self = shift;
252 0         0 my $dbh = $self->dbh;
253              
254             # Start transaction and lock all tables to disallow concurrent changes.
255             # This should be equivalent to 'LOCK TABLE changes' ???
256             # http://conferences.embarcadero.com/article/32280#TableReservation
257 0         0 $dbh->func(
258             -lock_resolution => 'no_wait',
259             -reserving => {
260             changes => {
261             lock => 'read',
262             access => 'protected',
263             },
264             },
265             'ib_set_tx_param'
266             );
267 0         0 $dbh->begin_work;
268 0         0 return $self;
269             }
270              
271             # Override to unlock the tables, otherwise future transactions on this
272             # connection can fail.
273             sub finish_work {
274 0     0 1 0 my $self = shift;
275 0         0 my $dbh = $self->dbh;
276 0         0 $dbh->commit;
277 0         0 $dbh->func( 'ib_set_tx_param' ); # reset parameters
278 0         0 return $self;
279             }
280              
281             sub _dt($) {
282 1     1   811 require App::Sqitch::DateTime;
283 1         21 return App::Sqitch::DateTime->new(split /:/ => shift);
284             }
285              
286             sub _no_table_error {
287 6   100 6   8348 return $DBI::errstr && $DBI::errstr =~ /^-Table unknown|No such file or directory/m;
288             }
289              
290             sub _no_column_error {
291 4   100 4   37 return $DBI::errstr && $DBI::errstr =~ /^-Column unknown/m;
292             }
293              
294             sub _unique_error {
295 0   0 0   0 return $DBI::errstr && $DBI::errstr =~ /no 2 table rows can have duplicate column values$/m;
296             }
297              
298 0     0   0 sub _regex_op { 'SIMILAR TO' } # NOT good match for
299             # REGEXP :(
300              
301 1     1   644 sub _limit_default { '18446744073709551615' }
302              
303             sub _listagg_format {
304 2     2   36 return q{LIST(ALL %s, ' ')}; # Firebird v2.1.4 minimum
305             }
306              
307             sub _run {
308 4     4   19027 my $self = shift;
309 4         23 my $sqitch = $self->sqitch;
310 4 100       138 my $pass = $self->password or return $sqitch->run( $self->isql, @_ );
311 1         28 local $ENV{ISC_PASSWORD} = $pass;
312 1         7 return $sqitch->run( $self->isql, @_ );
313             }
314              
315             sub _capture {
316 3     3   1905 my $self = shift;
317 3         11 my $sqitch = $self->sqitch;
318 3 100       91 my $pass = $self->password or return $sqitch->capture( $self->isql, @_ );
319 1         22 local $ENV{ISC_PASSWORD} = $pass;
320 1         5 return $sqitch->capture( $self->isql, @_ );
321             }
322              
323             sub _spool {
324 3     3   2027 my $self = shift;
325 3         7 my $fh = shift;
326 3         13 my $sqitch = $self->sqitch;
327 3 100       114 my $pass = $self->password or return $sqitch->spool( $fh, $self->isql, @_ );
328 1         21 local $ENV{ISC_PASSWORD} = $pass;
329 1         5 return $sqitch->spool( $fh, $self->isql, @_ );
330             }
331              
332             sub run_file {
333 1     1 1 983 my ($self, $file) = @_;
334 1         5 $self->_run( '-input' => $file );
335             }
336              
337             sub run_verify {
338 2     2 1 2706 my ($self, $file) = @_;
339             # Suppress STDOUT unless we want extra verbosity.
340 2 100       40 my $meth = $self->can($self->sqitch->verbosity > 1 ? '_run' : '_capture');
341 2         137 $self->$meth( '-input' => $file );
342             }
343              
344             sub run_upgrade {
345 1     1 1 4865 my ($self, $file) = @_;
346 1         5 my $uri = $self->registry_uri;
347 1         8 my @cmd = $self->isql;
348 1         44 $cmd[-1] = $self->connection_string($uri);
349 1         37 my $sqitch = $self->sqitch;
350 1 50       4 unless ($uri->host) {
351             # Only one connection allowed when using an embedded database (Engine 12
352             # provider). So disconnect so that the upgrade can connect and succeed,
353             # and clear the disconnected handle so that the next call to ->dbh will
354             # reconnect.
355 1         22 $self->dbh->disconnect; $self->_clear_dbh;
  1         10  
356             }
357 1         9 $sqitch->run( @cmd, '-input' => $sqitch->quote_shell($file) );
358             }
359              
360             sub run_handle {
361 1     1 1 962 my ($self, $fh) = @_;
362 1         5 $self->_spool($fh);
363             }
364              
365             sub _cid {
366 2     2   2475 my ( $self, $ord, $offset, $project ) = @_;
367              
368 2 50       11 my $offexpr = $offset ? " SKIP $offset" : '';
369             return try {
370 2   0 2   118 return $self->dbh->selectcol_arrayref(qq{
371             SELECT FIRST 1$offexpr change_id
372             FROM changes
373             WHERE project = ?
374             ORDER BY committed_at $ord;
375             }, undef, $project || $self->plan->project)->[0];
376             } catch {
377             # Firebird generic error code -902, one possible message:
378             # -I/O error during "open" operation for file...
379             # -Error while trying to open file
380             # -No such file or directory
381             # print "===DBI ERROR: $DBI::err\n";
382 2 100   2   66 return if $DBI::err == -902; # can't connect to database
383 1         17 die $_;
384 2         18 };
385             }
386              
387             sub current_state {
388 2     2 1 1066 my ( $self, $project ) = @_;
389 2         11 my $cdtcol = sprintf $self->_ts2char_format, 'c.committed_at';
390 2         6 my $pdtcol = sprintf $self->_ts2char_format, 'c.planned_at';
391 2         8 my $tagcol = sprintf $self->_listagg_format, 't.tag';
392             my $state = try {
393 2   0 2   99 $self->dbh->selectrow_hashref(qq{
394             SELECT FIRST 1 c.change_id
395             , c.script_hash
396             , c.change
397             , c.project
398             , c.note
399             , c.committer_name
400             , c.committer_email
401             , $cdtcol AS committed_at
402             , c.planner_name
403             , c.planner_email
404             , $pdtcol AS planned_at
405             , $tagcol AS tags
406             FROM changes c
407             LEFT JOIN tags t ON c.change_id = t.change_id
408             WHERE c.project = ?
409             GROUP BY c.change_id
410             , c.script_hash
411             , c.change
412             , c.project
413             , c.note
414             , c.committer_name
415             , c.committer_email
416             , c.committed_at
417             , c.planner_name
418             , c.planner_email
419             , c.planned_at
420             ORDER BY c.committed_at DESC
421             }, undef, $project // $self->plan->project );
422             } catch {
423 2 100 66 2   49 return if $self->_no_table_error && !$self->initialized;
424 1         16 die $_;
425 2 50       21 } or return undef;
426              
427 0 0       0 unless (ref $state->{tags}) {
428 0 0       0 $state->{tags} = $state->{tags} ? [ split / / => $state->{tags} ] : [];
429             }
430 0         0 $state->{committed_at} = _dt $state->{committed_at};
431 0         0 $state->{planned_at} = _dt $state->{planned_at};
432 0         0 return $state;
433             }
434              
435             sub search_events {
436 0     0 1 0 my ( $self, %p ) = @_;
437              
438             # Determine order direction.
439 0         0 my $dir = 'DESC';
440 0 0       0 if (my $d = delete $p{direction}) {
441 0 0       0 $dir = $d =~ /^ASC/i ? 'ASC'
    0          
442             : $d =~ /^DESC/i ? 'DESC'
443             : hurl 'Search direction must be either "ASC" or "DESC"';
444             }
445              
446             # Limit with regular expressions?
447 0         0 my (@wheres, @params);
448 0         0 my $op = $self->_regex_op;
449 0         0 for my $spec (
450             [ committer => 'e.committer_name' ],
451             [ planner => 'e.planner_name' ],
452             [ change => 'e.change' ],
453             [ project => 'e.project' ],
454             ) {
455 0   0     0 my $regex = delete $p{ $spec->[0] } // next;
456             # Trying to adapt REGEXP for SIMILAR TO from Firebird 2.5 :)
457             # Yes, I know is ugly...
458             # There is no support for ^ and $ as in normal REGEXP.
459             #
460             # From the docs:
461             # Description: SIMILAR TO matches a string against an SQL
462             # regular expression pattern. UNLIKE in some other languages,
463             # the pattern MUST MATCH THE ENTIRE STRING in order to succeed
464             # – matching a substring is not enough. If any operand is
465             # NULL, the result is NULL. Otherwise, the result is TRUE or
466             # FALSE.
467             #
468             # Maybe use the CONTAINING operator instead?
469             # print "===REGEX: $regex\n";
470 0 0 0     0 if ( $regex =~ m{^\^} and $regex =~ m{\$$} ) {
471 0         0 $regex =~ s{\^}{};
472 0         0 $regex =~ s{\$}{};
473 0         0 $regex = "%$regex%";
474             }
475             else {
476 0 0 0     0 if ( $regex !~ m{^\^} and $regex !~ m{\$$} ) {
477 0         0 $regex = "%$regex%";
478             }
479             }
480 0 0       0 if ( $regex =~ m{\$$} ) {
481 0         0 $regex =~ s{\$}{};
482 0         0 $regex = "%$regex";
483             }
484 0 0       0 if ( $regex =~ m{^\^} ) {
485 0         0 $regex =~ s{\^}{};
486 0         0 $regex = "$regex%";
487             }
488             # print "== SIMILAR TO: $regex\n";
489 0         0 push @wheres => "$spec->[1] $op ?";
490 0         0 push @params => "$regex";
491             }
492              
493             # Match events?
494 0 0       0 if (my $e = delete $p{event} ) {
495 0         0 my ($in, @vals) = $self->_in_expr( $e );
496 0         0 push @wheres => "e.event $in";
497 0         0 push @params => @vals;
498             }
499              
500             # Assemble the where clause.
501 0 0       0 my $where = @wheres
502             ? "\n WHERE " . join( "\n ", @wheres )
503             : '';
504              
505             # Handle remaining parameters.
506 0         0 my $limits = '';
507 0 0 0     0 if (exists $p{limit} || exists $p{offset}) {
508 0         0 my $lim = delete $p{limit};
509 0 0       0 if ($lim) {
510 0         0 $limits = " FIRST ? ";
511 0         0 push @params => $lim;
512             }
513 0 0       0 if (my $off = delete $p{offset}) {
514 0         0 $limits .= " SKIP ? ";
515 0         0 push @params => $off;
516             }
517             }
518              
519 0 0       0 hurl 'Invalid parameters passed to search_events(): '
520             . join ', ', sort keys %p if %p;
521              
522 0         0 $self->dbh->{ib_softcommit} = 1;
523              
524             # Prepare, execute, and return.
525 0         0 my $cdtcol = sprintf $self->_ts2char_format, 'e.committed_at';
526 0         0 my $pdtcol = sprintf $self->_ts2char_format, 'e.planned_at';
527 0         0 my $sth = $self->dbh->prepare(qq{
528             SELECT $limits e.event
529             , e.project
530             , e.change_id
531             , e.change
532             , e.note
533             , e.requires
534             , e.conflicts
535             , e.tags
536             , e.committer_name
537             , e.committer_email
538             , $cdtcol AS committed_at
539             , e.planner_name
540             , e.planner_email
541             , $pdtcol AS planned_at
542             FROM events e$where
543             ORDER BY e.committed_at $dir
544             });
545 0         0 $sth->execute(@params);
546             return sub {
547 0 0   0   0 my $row = $sth->fetchrow_hashref or return;
548 0         0 $row->{committed_at} = _dt $row->{committed_at};
549 0         0 $row->{planned_at} = _dt $row->{planned_at};
550 0         0 for my $col (qw(tags requires conflicts)) {
551 0         0 $row->{$col} = $self->_parse_array($row->{$col});
552             }
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 561 my ( $self, %p) = @_;
659 1         5 my $dbh = $self->dbh;
660              
661 1 50       8 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     5 my $project = $p{project} || $self->plan->project;
671 1 50       6 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       5 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 3243 my $self = shift;
892 2 50       20 my $ext = App::Sqitch::ISWIN || $^O eq 'cygwin' ? '.exe' : '';
893              
894             # Create a script to run.
895 2         20 require File::Temp;
896 2         26 my $fh = File::Temp->new( CLEANUP => 1 );
897 2         1448 my @opts = (qw(-z -q -i), $fh->filename);
898 2         58 $fh->print("quit;\n");
899 2         56 $fh->close;
900              
901             # Suppress STDERR, including in subprocess.
902 2 50       179 open my $olderr, '>&', \*STDERR or hurl firebird => __x(
903             'Cannot dup STDERR: {error}', $!
904             );
905 2         21 close STDERR;
906 2 50       16 open STDERR, '>', \my $stderr or hurl firebird => __x(
907             'Cannot reirect STDERR: {error}', $!
908             );
909              
910             # Try to find a client in the path.
911 2         6 for my $try ( map { $_ . $ext } qw(fbsql isql-fb isql) ) {
  6         22  
912 6         267 my $loops = 0;
913 6         85 for my $dir (File::Spec->path) {
914 30         1742 my $path = file $dir, $try;
915             # GetShortPathName returns undef for nonexistent files.
916 30         2810 $path = Win32::GetShortPathName($path) // next if App::Sqitch::ISWIN;
917 30 100 66     118 if (-f $path && -x $path) {
918 1 50   1   149 if (try { App::Sqitch->probe($path, @opts) =~ /Firebird/ } ) {
  1         45  
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         1391 $loops++;
926             }
927             }
928             }
929              
930             # Restore STDERR and die.
931 2 50       214 open STDERR, '>&', $olderr or hurl firebird => __x(
932             'Cannot dup STDERR: {error}', $!
933             );
934 2         57 hurl firebird => __x(
935             'Unable to locate {cli} client; set "engine.{eng}.client" via sqitch config',
936             cli => 'Firebird ISQL',
937             eng => 'firebird',
938             );
939             }
940              
941             sub _update_script_hashes {
942 0     0     my $self = shift;
943 0           my $plan = $self->plan;
944 0           my $proj = $plan->project;
945 0           my $dbh = $self->dbh;
946              
947 0           $self->begin_work;
948             # Firebird refuses to update via a prepared statement, so use do(). :-(
949             $dbh->do(
950             'UPDATE changes SET script_hash = ? WHERE change_id = ?',
951             undef, $_->script_hash, $_->id
952 0           ) for $plan->changes;
953 0           $dbh->do(q{
954             UPDATE changes SET script_hash = NULL
955             WHERE project = ? AND script_hash = change_id
956             }, undef, $proj);
957              
958 0           $self->finish_work;
959 0           return $self;
960             }
961              
962             1;
963              
964             __END__
965              
966             =encoding utf8
967              
968             =head1 Name
969              
970             App::Sqitch::Engine::firebird - Sqitch Firebird Engine
971              
972             =head1 Synopsis
973              
974             my $firebird = App::Sqitch::Engine->load( engine => 'firebird' );
975              
976             =head1 Description
977              
978             App::Sqitch::Engine::firebird provides the Firebird storage engine for Sqitch.
979              
980             =head1 Interface
981              
982             =head2 Instance Methods
983              
984             =head3 C<connection_string>
985              
986             Constructs a connection string from a database URI for passing to C<isql>.
987              
988             =head3 C<isql>
989              
990             Returns a list containing the C<isql> client and options to be passed to it.
991             Used internally when executing scripts.
992              
993             =head1 Author
994              
995             David E. Wheeler <david@justatheory.com>
996              
997             Ștefan Suciu <stefan@s2i2.ro>
998              
999             =head1 License
1000              
1001             Copyright (c) 2012-2026 David E. Wheeler, 2012-2021 iovation Inc.
1002              
1003             Copyright (c) 2013 Ștefan Suciu
1004              
1005             Permission is hereby granted, free of charge, to any person obtaining a copy
1006             of this software and associated documentation files (the "Software"), to deal
1007             in the Software without restriction, including without limitation the rights
1008             to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
1009             copies of the Software, and to permit persons to whom the Software is
1010             furnished to do so, subject to the following conditions:
1011              
1012             The above copyright notice and this permission notice shall be included in all
1013             copies or substantial portions of the Software.
1014              
1015             THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1016             IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1017             FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1018             AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1019             LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
1020             OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
1021             SOFTWARE.
1022              
1023             =cut