File Coverage

blib/lib/App/Sqitch/Engine/exasol.pm
Criterion Covered Total %
statement 134 181 74.0
branch 22 38 57.8
condition 10 29 34.4
subroutine 48 61 78.6
pod 18 18 100.0
total 232 327 70.9


line stmt bran cond sub pod time code
1              
2             use 5.010;
3 2     2   19208 use Moo;
  2         7  
4 2     2   11 use utf8;
  2         4  
  2         9  
5 2     2   621 use Path::Class;
  2         5  
  2         11  
6 2     2   50 use DBI;
  2         5  
  2         104  
7 2     2   1304 use Try::Tiny;
  2         15448  
  2         85  
8 2     2   12 use App::Sqitch::X qw(hurl);
  2         4  
  2         117  
9 2     2   12 use Locale::TextDomain qw(App-Sqitch);
  2         10  
  2         18  
10 2     2   562 use App::Sqitch::Types qw(DBH Dir ArrayRef);
  2         5  
  2         10  
11 2     2   288 use App::Sqitch::Plan::Change;
  2         4  
  2         14  
12 2     2   1710 use List::Util qw(first);
  2         3  
  2         67  
13 2     2   13 use namespace::autoclean;
  2         4  
  2         116  
14 2     2   10  
  2         12  
  2         20  
15             extends 'App::Sqitch::Engine';
16              
17             our $VERSION = 'v1.3.1'; # VERSION
18              
19             require App::Sqitch::DateTime;
20             return App::Sqitch::DateTime->new(split /:/ => shift);
21 1     1   716 }
22 1         20  
23              
24             BEGIN {
25 4     4 1 5812 # Disable SQLPATH so that we don't read scripts from unexpected places.
26 3     3 1 38 $ENV{SQLPATH} = '';
27 1     1 1 3 }
28 8     8 1 1183  
29             my $self = shift;
30              
31             # Just use the target name if it doesn't look like a URI or if the URI
32 2     2   6699 # includes the database name.
33             return $self->target->name if $self->target->name !~ /:/
34             || $self->target->uri->dbname;
35              
36 5     5 1 1312 # Use the URI sans password.
37             my $uri = $self->target->uri->clone;
38             $uri->password(undef) if $uri->password;
39             return $uri->as_string;
40 5 50 33     50 }
41              
42             # No username or password defaults.
43              
44 5         252 has _exaplus => (
45 5 50       61 is => 'ro',
46 5         126 isa => ArrayRef,
47             lazy => 1,
48             default => sub {
49             my $self = shift;
50       8     my $uri = $self->uri;
51       8     my @ret = ( $self->client );
52              
53             # Collect the cquery params and convert keys to uppercase.
54             require URI::QueryParam;
55             my $qry = $uri->query_form_hash;
56             for my $key (keys %{ $qry }) {
57             my $ukey = uc $key;
58             next if $key eq $ukey;
59              
60             # Move value to uppercase key.
61             my $val = delete $qry->{$key};
62             if (!exists $qry->{$ukey}) {
63             # Store under uppercase key.
64             $qry->{$ukey} = $val;
65             } else {
66             # Push the value(s) onto upercase key array value.
67             $qry->{$ukey} = [$qry->{$ukey}] if ref $qry->{$ukey} ne 'ARRAY';
68             push @{ $qry->{$ukey} } => ref $val eq 'ARRAY' ? @{ $val } : $val;
69             }
70             }
71              
72             # Use _port instead of port so it's empty if no port is in the URI.
73             # https://github.com/sqitchers/sqitch/issues/675
74             for my $spec (
75             [ u => $self->username ],
76             [ p => $self->password ],
77             [ c => $uri->host && $uri->_port ? $uri->host . ':' . $uri->_port : undef ],
78             [ profile => $uri->host ? undef : $uri->dbname ],
79             [ jdbcparam => ($qry->{SSLCERTIFICATE} || '') eq 'SSL_VERIFY_NONE' ? 'validateservercertificate=0' : undef ],
80             [ jdbcparam => $qry->{AUTHMETHOD} ? "authmethod=$qry->{AUTHMETHOD}" : undef ],
81             ) {
82             push @ret, "-$spec->[0]" => $spec->[1] if $spec->[1];
83             }
84              
85             push @ret => (
86             '-q', # Quiet mode
87             '-L', # Don't prompt if login fails, just exit
88             '-pipe', # Enable piping of scripts to 'exaplus'
89             '-x', # Stop in case of errors
90             '-autoCompletion' => 'OFF',
91             '-encoding' => 'UTF8',
92             '-autocommit' => 'OFF',
93             );
94             return \@ret;
95             },
96             );
97              
98              
99             has tmpdir => (
100             is => 'ro',
101             isa => Dir,
102             lazy => 1,
103             default => sub {
104             require File::Temp;
105             dir File::Temp::tempdir( CLEANUP => 1 );
106             },
107 14     14   2361 );
  14         244  
108              
109             has dbh => (
110             is => 'rw',
111             isa => DBH,
112             lazy => 1,
113             default => sub {
114             my $self = shift;
115             $self->use_driver;
116              
117             my $uri = $self->uri;
118             DBI->connect($uri->dbi_dsn, $self->username, $self->password, {
119             PrintError => 0,
120             RaiseError => 0,
121             AutoCommit => 1,
122             odbc_utf8_on => 1,
123             FetchHashKeyName => 'NAME_lc',
124             HandleError => sub {
125             my ($err, $dbh) = @_;
126             $@ = $err;
127             @_ = ($dbh->state || 'DEV' => $dbh->errstr);
128             goto &hurl;
129             },
130             Callbacks => {
131             connected => sub {
132             my $dbh = shift;
133             $dbh->do("ALTER SESSION SET $_='YYYY-MM-DD HH24:MI:SS'") for qw(
134             nls_date_format
135             nls_timestamp_format
136             );
137             $dbh->do("ALTER SESSION SET TIME_ZONE='UTC'");
138             if (my $schema = $self->registry) {
139             try {
140             $dbh->do("OPEN SCHEMA $schema");
141             # https://www.nntp.perl.org/group/perl.dbi.dev/2013/11/msg7622.html
142             $dbh->set_err(undef, undef) if $dbh->err;
143             };
144             }
145             return;
146             },
147             },
148             });
149             }
150             );
151              
152             # Need to wait until dbh is defined.
153             with 'App::Sqitch::Role::DBIEngine';
154              
155             # Timestamp formats
156              
157             my $dt = $_[1];
158             $dt->set_time_zone('UTC');
159             $dt->ymd('-') . ' ' . $dt->hms(':');
160             }
161              
162             return qq{'year:' || CAST(EXTRACT(YEAR FROM %s) AS SMALLINT)
163             || ':month:' || CAST(EXTRACT(MONTH FROM %1\$s) AS SMALLINT)
164             || ':day:' || CAST(EXTRACT(DAY FROM %1\$s) AS SMALLINT)
165             || ':hour:' || CAST(EXTRACT(HOUR FROM %1\$s) AS SMALLINT)
166             || ':minute:' || CAST(EXTRACT(MINUTE FROM %1\$s) AS SMALLINT)
167             || ':second:' || FLOOR(CAST(EXTRACT(SECOND FROM %1\$s) AS NUMERIC(9,4)))
168 1     1   18031 || ':time_zone:UTC'};
169 1         7 }
170 1         212  
171              
172             return q{GROUP_CONCAT(%1$s ORDER BY %1$s SEPARATOR ' ')};
173             }
174 1     1   1237  
175              
176             # LIMIT in Exasol doesn't behave properly with values > 18446744073709551611
177              
178              
179             my ($self, $count, $expr) = @_;
180             return join "\nUNION ALL ", ("SELECT $expr FROM dual") x $count;
181             }
182              
183 1     1   7 my $self = shift;
184             return $self->dbh->selectcol_arrayref(q{
185             SELECT EXISTS(
186 1     1   645 SELECT TRUE FROM exa_all_tables
187             WHERE table_schema = ? AND table_name = ?
188             )
189 5     5   16 }, undef, uc $self->registry, 'CHANGES')->[0];
190             }
191              
192 2     2   17 # LIMIT / OFFSET in Exasol doesn't seem to play nice in the original query with
193             # JOIN and GROUP BY; wrap it in a subquery instead..
194 1     1   5 my ( $self, $change_id, $offset ) = @_;
195              
196             # Just return the object if there is no offset.
197 0     0   0 return $self->load_change($change_id) unless $offset;
198 0         0  
199             # Are we offset forwards or backwards?
200             my ($dir, $op, $offset_expr) = $self->_offset_op($offset);
201             my $tscol = sprintf $self->_ts2char_format, 'c.planned_at';
202 1     1 1 1660 my $tagcol = sprintf $self->_listagg_format, 't.tag';
203 1         14  
204             my $change = $self->dbh->selectrow_hashref(qq{
205             SELECT id, name, project, note, "timestamp", planner_name, planner_email,
206             tags, script_hash
207             FROM (
208             SELECT c.change_id AS id, c.change AS name, c.project, c.note,
209             $tscol AS "timestamp", c.planner_name, c.planner_email,
210             $tagcol AS tags, c.committed_at, c.script_hash
211             FROM changes c
212             LEFT JOIN tags t ON c.change_id = t.change_id
213             WHERE c.project = ?
214 0     0 1 0 AND c.committed_at $op (
215             SELECT committed_at FROM changes WHERE change_id = ?
216             )
217 0 0       0 GROUP BY c.change_id, c.change, c.project, c.note, c.planned_at,
218             c.planner_name, c.planner_email, c.committed_at, c.script_hash
219             ) changes
220 0         0 ORDER BY changes.committed_at $dir
221 0         0 LIMIT 1 $offset_expr
222 0         0 }, undef, $self->plan->project, $change_id) || return undef;
223             $change->{timestamp} = _dt $change->{timestamp};
224 0   0     0 unless (ref $change->{tags}) {
225             $change->{tags} = $change->{tags} ? [ split / / => $change->{tags} ] : [];
226             }
227             return $change;
228             }
229              
230             my ( $self, $change ) = @_;
231             # Why CTE: https://forums.oracle.com/forums/thread.jspa?threadID=1005221
232             # NOTE: Query from DBIEngine doesn't work in Exasol:
233             # Error: [00444] more than one column in select list of correlated subselect
234             # The CTE-based query below seems to be fine, however.
235             return @{ $self->dbh->selectall_arrayref(q{
236             WITH tag AS (
237             SELECT tag, committed_at, project,
238             ROW_NUMBER() OVER (partition by project ORDER BY committed_at) AS rnk
239             FROM tags
240             )
241             SELECT c.change_id, c.project, c.change, t.tag AS asof_tag
242             FROM dependencies d
243 0         0 JOIN changes c ON c.change_id = d.change_id
244 0 0       0 LEFT JOIN tag t ON t.project = c.project AND t.committed_at >= c.committed_at
245 0 0       0 WHERE d.dependency_id = ?
246             AND (t.rnk IS NULL OR t.rnk = 1)
247 0         0 }, { Slice => {} }, $change->id) };
248             }
249              
250             my ( $self, $change_id ) = @_;
251 0     0 1 0 # Why CTE: https://forums.oracle.com/forums/thread.jspa?threadID=1005221
252             # NOTE: Query from DBIEngine doesn't work in Exasol:
253             # Error: [0A000] Feature not supported: non-equality correlations in correlated subselect
254             # The CTE-based query below seems to be fine, however.
255             return $self->dbh->selectcol_arrayref(q{
256 0         0 WITH tag AS (
  0         0  
257             SELECT tag, committed_at, project,
258             ROW_NUMBER() OVER (partition by project ORDER BY committed_at) AS rnk
259             FROM tags
260             )
261             SELECT change || COALESCE(t.tag, '@HEAD')
262             FROM changes c
263             LEFT JOIN tag t ON c.project = t.project AND t.committed_at >= c.committed_at
264             WHERE change_id = ?
265             AND (t.rnk IS NULL OR t.rnk = 1)
266             }, undef, $change_id)->[0];
267             }
268              
269             my ( $self, $ord, $offset, $project ) = @_;
270              
271             my $offset_expr = $offset ? " OFFSET $offset" : '';
272 0     0 1 0 return try {
273             $self->dbh->selectcol_arrayref(qq{
274             SELECT change_id
275             FROM changes
276             WHERE project = ?
277 0         0 ORDER BY committed_at $ord
278             LIMIT 1$offset_expr
279             }, undef, $project || $self->plan->project)->[0];
280             } catch {
281             return if $self->_no_table_error && !$self->initialized;
282             die $_;
283             };
284             }
285              
286             my ( $self, $tag ) = @_;
287             return $self->dbh->selectcol_arrayref(
288             'SELECT 1 FROM tags WHERE tag_id = ?',
289             undef, $tag->id
290             )->[0];
291             }
292 1     1   354  
293             my $self = shift;
294 1 50       9 my $schema = $self->registry;
295             return "DEFINE registry=$schema;";
296 1   0 1   177 }
297              
298             my $self = shift;
299             my $schema = $self->registry;
300             hurl engine => __ 'Sqitch already initialized' if $self->initialized;
301              
302             # Load up our database.
303             (my $file = file(__FILE__)->dir->file('exasol.sql')) =~ s/"/""/g;
304 1 50 33 1   37 $self->_run_with_verbosity($file);
305 1         9 $self->dbh->do("OPEN SCHEMA $schema") if $schema;
306 1         38 $self->_register_release;
307             }
308              
309             # LIMIT/OFFSET don't support parameters, alas. So just put them in the query.
310 0     0 1 0 my ($self, $lim, $off) = @_;
311 0         0 # OFFSET cannot be used without LIMIT, sadly.
312             return ['LIMIT ' . ($lim || $self->_limit_default), "OFFSET $off"], [] if $off;
313             return ["LIMIT $lim"], [] if $lim;
314             return [], [];
315             }
316              
317             my ( $self, $col, $regex ) = @_;
318 13     13   22 $regex = '.*' . $regex if $regex !~ m{^\^};
319 13         189 $regex .= '.*' if $regex !~ m{\$$};
320 13         592 my $op = $self->_regex_op;
321             return "$col $op ?", $regex;
322             }
323              
324 0     0 1 0 # Override to lock the changes table. This ensures that only one instance of
325 0         0 # Sqitch runs at one time.
326 0 0       0 my $self = shift;
327             my $dbh = $self->dbh;
328              
329 0         0 # Start transaction and lock changes to allow only one change at a time.
330 0         0 # https://www.exasol.com/portal/pages/viewpage.action?pageId=22518143
331 0 0       0 $dbh->begin_work;
332 0         0 $dbh->do('DELETE FROM changes WHERE FALSE');
333             return $self;
334             }
335              
336             # Release lock by comitting or rolling back.
337 6     6   290 my $self = shift;
338             $self->dbh->commit;
339 6 100 66     28 return $self;
340 4 100       23 }
341 2         78  
342             my $self = shift;
343             $self->dbh->rollback;
344             return $self;
345 4     4   10 }
346 4 100       26  
347 4 100       19 my ($self, $file) = @_;
348 4         7  
349 4         35 # Just use the file if no special character.
350             if ($file !~ /[@?%\$]/) {
351             $file =~ s/"/""/g;
352             return $file;
353             }
354              
355 0     0 1 0 # Alias or copy the file to a temporary directory that's removed on exit.
356 0         0 (my $alias = $file->basename) =~ s/[@?%\$]/_/g;
357             $alias = $self->tmpdir->file($alias);
358              
359             # Remove existing file.
360 0         0 if (-e $alias) {
361 0         0 $alias->remove or hurl exasol => __x(
362 0         0 'Cannot remove {file}: {error}',
363             file => $alias,
364             error => $!
365             );
366             }
367 0     0 1 0  
368 0         0 if (App::Sqitch::ISWIN) {
369 0         0 # Copy it.
370             $file->copy_to($alias) or hurl exasol => __x(
371             'Cannot copy {file} to {alias}: {error}',
372             file => $file,
373 0     0 1 0 alias => $alias,
374 0         0 error => $!
375 0         0 );
376             } else {
377             # Symlink it.
378             $alias->remove;
379 9     9   6504 symlink $file->absolute, $alias or hurl exasol => __x(
380             'Cannot symlink {file} to {alias}: {error}',
381             file => $file,
382 9 100       57 alias => $alias,
383 6         39 error => $!
384 6         53 );
385             }
386              
387             # Return the alias.
388 3         94 $alias =~ s/"/""/g;
389 3         76 return $alias;
390             }
391              
392 3 100       216 my $self = shift;
393 1 50       45 my $file = $self->_file_for_script(shift);
394             $self->_capture(qq{\@"$file"});
395             }
396              
397             my $self = shift;
398             my $file = $self->_file_for_script(shift);
399             # Suppress STDOUT unless we want extra verbosity.
400 2         87 #my $meth = $self->can($self->sqitch->verbosity > 1 ? '_run' : '_capture');
401             my $meth = '_capture';
402             $self->$meth(qq{\@"$file"});
403             }
404              
405              
406             my ($self, $fh) = @_;
407             my $conn = $self->_script;
408             open my $tfh, '<:utf8_strict', \$conn;
409             $self->sqitch->spool( [$tfh, $fh], $self->exaplus );
410 2         15 }
411 2 50       128  
412             # Exasol treats empty string as NULL; adjust accordingly..
413              
414             my $res = join ' ' => map { $_->format_name } $_[1]->tags;
415             return $res || ' ';
416             }
417              
418             my $res = join ',' => map { $_->as_string } $_[1]->requires;
419             return $res || ' ';
420 2         235 }
421 2         92  
422             my $res = join ',' => map { $_->as_string } $_[1]->conflicts;
423             return $res || ' ';
424             }
425 2     2 1 1649  
426 2         12 return $DBI::errstr && $DBI::errstr =~ /object \w+ not found/m;
427 2         14 }
428              
429             return $DBI::errstr && $DBI::errstr =~ /object \w+ not found/m;
430             }
431 2     2   4  
432 2         271 my $self = shift;
433             my $uri = $self->uri;
434             my %vars = $self->variables;
435 2         3  
436 2         11 return join "\n" => (
437             'SET FEEDBACK OFF;',
438             'SET HEADING OFF;',
439 0     0 1 0 'WHENEVER OSERROR EXIT 9;',
440 2     2 1 876 'WHENEVER SQLERROR EXIT 4;',
441             (map {; (my $v = $vars{$_}) =~ s/'/''/g; qq{DEFINE $_='$v';} } sort keys %vars),
442             $self->_registry_variable,
443 1     1 1 733 # Just 'map { s/;?$/;/r } ...' doesn't work in earlier Perl versions;
444 1         3 # see: https://www.perlmonks.org/index.pl?node_id=1048579
445 1         18 map { (my $foo=$_) =~ s/;?$/;/; $foo } @_
446 1         13 );
447             }
448              
449             my $self = shift;
450             my $script = $self->_script(@_);
451             open my $fh, '<:utf8_strict', \$script;
452 0     0   0 return $self->sqitch->spool( $fh, $self->exaplus );
  0         0  
453 0   0     0 }
454              
455             my $self = shift;
456             my $conn = $self->_script(@_);
457 0     0   0 my @out;
  0         0  
458 0   0     0 my @errout;
459              
460             $self->sqitch->debug('CMD: ' . join(' ', $self->exaplus));
461             $self->sqitch->debug("SQL:\n---\n", $conn, "\n---");
462 0     0   0  
  0         0  
463 0   0     0 require IPC::Run3;
464             IPC::Run3::run3(
465             [$self->exaplus], \$conn, \@out, \@out,
466             { return_if_system_error => 1 },
467 3   66 3   46 );
468              
469             # EXAplus doesn't always seem to give a useful exit value; we need to match
470             # on output as well..
471 2   66 2   24 if (my $err = $? || grep { /^Error:/m } @out) {
472             # Ugh, send everything to STDERR.
473             $self->sqitch->vent(@out);
474             hurl io => __x(
475 11     11   4034 '{command} unexpectedly failed; exit value = {exitval}',
476 11         252 command => $self->client,
477 11         359 exitval => ($err >> 8),
478             );
479             }
480             return wantarray ? @out : \@out;
481             }
482              
483             1;
484 3         8  
  3         10  
485              
486             =head1 Name
487              
488 11         129 App::Sqitch::Engine::exasol - Sqitch Exasol Engine
  16         56  
  16         65  
489              
490             =head1 Synopsis
491              
492             my $exasol = App::Sqitch::Engine->load( engine => 'exasol' );
493 1     1   3640  
494 1         3 =head1 Description
495 1     1   7  
  1     1   2  
  1         8  
  1         775  
  1         2  
  1         5  
  1         28  
496 1         699 App::Sqitch::Engine::exasol provides the Exasol storage engine for Sqitch. It
497             is tested with Exasol 6.0 and higher.
498              
499             =head1 Interface
500 3     3   1859  
501 3         13 =head2 Instance Methods
502 3         5  
503             =head3 C<initialized>
504              
505 3         13 $exasol->initialize unless $exasol->initialized;
506 3         150  
507             Returns true if the database has been initialized for Sqitch, and false if it
508 3         32 has not.
509 3         10  
510             =head3 C<initialize>
511              
512             $exasol->initialize;
513              
514             Initializes a database for Sqitch by installing the Sqitch registry schema.
515              
516 3 100 66     13765 =head3 C<exaplus>
517              
518 1         55 Returns a list containing the C<exaplus> client and options to be passed to it.
519 1         85 Used internally when executing scripts.
520              
521             =head1 Author
522              
523             David E. Wheeler <david@justatheory.com>
524              
525 2 100       46 =head1 License
526              
527             Copyright (c) 2012-2022 iovation Inc., David E. Wheeler
528              
529             Permission is hereby granted, free of charge, to any person obtaining a copy
530             of this software and associated documentation files (the "Software"), to deal
531             in the Software without restriction, including without limitation the rights
532             to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
533             copies of the Software, and to permit persons to whom the Software is
534             furnished to do so, subject to the following conditions:
535              
536             The above copyright notice and this permission notice shall be included in all
537             copies or substantial portions of the Software.
538              
539             THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
540             IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
541             FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
542             AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
543             LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
544             OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
545             SOFTWARE.
546              
547             =cut