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