File Coverage

blib/lib/App/Sqitch/Engine/exasol.pm
Criterion Covered Total %
statement 132 179 73.7
branch 22 38 57.8
condition 10 29 34.4
subroutine 48 61 78.6
pod 16 16 100.0
total 228 323 70.5


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