File Coverage

blib/lib/App/Sqitch/Engine/clickhouse.pm
Criterion Covered Total %
statement 230 294 78.2
branch 111 122 90.9
condition 67 87 77.0
subroutine 55 66 83.3
pod 15 15 100.0
total 478 584 81.8


line stmt bran cond sub pod time code
1             package App::Sqitch::Engine::clickhouse;
2              
3 2     2   108079 use 5.010;
  2         9  
4 2     2   11 use strict;
  2         6  
  2         77  
5 2     2   11 use warnings;
  2         9  
  2         110  
6 2     2   11 use utf8;
  2         4  
  2         15  
7 2     2   77 use Try::Tiny;
  2         4  
  2         226  
8 2     2   15 use App::Sqitch::X qw(hurl);
  2         4  
  2         24  
9 2     2   673 use Locale::TextDomain qw(App-Sqitch);
  2         5  
  2         65  
10 2     2   444 use App::Sqitch::Plan::Change;
  2         4  
  2         97  
11 2     2   12 use Path::Class;
  2         3  
  2         175  
12 2     2   20 use Scalar::Util qw(looks_like_number);
  2         3  
  2         97  
13 2     2   10 use Moo;
  2         3  
  2         13  
14 2     2   1058 use App::Sqitch::Types qw(DBH URIDB ArrayRef Str HashRef);
  2         5  
  2         31  
15 2     2   7052 use namespace::autoclean;
  2         3  
  2         23  
16 2     2   164 use List::MoreUtils qw(firstidx);
  2         5  
  2         16  
17              
18             extends 'App::Sqitch::Engine';
19              
20             our $VERSION = 'v1.6.1'; # VERSION
21              
22             has uri => (
23             is => 'ro',
24             isa => URIDB,
25             lazy => 1,
26             default => \&_setup_uri,
27             );
28              
29             sub _setup_uri {
30 27     27   11626 my $self = shift;
31 27         722 my $uri = $self->SUPER::uri;
32 27         1253 my $cfg = $self->_clickcnf;
33 27 100 100     442 if (!$uri->host && (my $host = $ENV{CLICKHOUSE_HOST} || $cfg->{host})) {
      100        
34 8         319 $uri->host($host);
35             }
36 27 100 100     1932 if (!$uri->dbname && (my $db = $cfg->{database})) {
37 9         595 $uri->dbname($db);
38             }
39              
40             # Use HTTPS port if CLI using native TLS port.
41             # https://clickhouse.com/docs/guides/sre/network-ports
42 27 100 100     1981 $uri->port(8443) if !$uri->_port && ($cfg->{port} || 0) == 9440;
      100        
43              
44             # Always require secure connections when required.
45             # https://github.com/ClickHouse/ClickHouse/blob/faf6d05/src/Client/ConnectionParameters.cpp#L27-L43
46 27 100 100     1111 if (
      100        
      100        
      100        
47             $cfg->{secure}
48             || ($cfg->{port} || 0) == 9440 # assume both native and http should be secure or not.
49             || ($uri->host || '') =~ /\.clickhouse(?:-staging)?\.cloud\z/
50             ) {
51 4 100       69 $uri->query_param( SSLMode => 'require' )
52             unless $uri->query_param( 'SSLMode' );
53             }
54              
55             # Add ODBC params for TLS configs.
56             # https://clickhouse.com/docs/operations/server-configuration-parameters/settings
57             # https://github.com/clickHouse/clickhouse-odbc?tab=readme-ov-file#configuration
58 27 100       1527 if ( my $tls = $cfg->{tls} ) {
59 8         46 for my $map (
60             [ privateKeyFile => 'PrivateKeyFile' ],
61             [ certificateFile => 'CertificateFile' ],
62             [ caConfig => 'CALocation' ],
63             ) {
64 22 100       812 if ( my $val = $tls->{ $map->[0] } ) {
65 5 100       33 if ( my $p = $uri->query_param( $map->[1] ) ) {
66             # Ideally the ODBC param would override the config,
67             # bug there is currently no way to pass TLS options to
68             # the CLI.
69 2 100       197 hurl engine => __x(
70             'Client config {cfg_key} value "{cfg_val}" conflicts with ODBC param {odb_param} value "{odbc_val}"',
71             cfg_key => "openSSL.client.$map->[0]",
72             cfg_val => $val,
73             odbc_param => $map->[1],
74             odbc_val => $p,
75             ) if $p ne $val;
76             }
77 4         284 $uri->query_param( $map->[1] => $val );
78             }
79             }
80              
81             # verificationMode | SSLMode
82             # -----------------|---------------
83             # none | [nonexistent]
84             # relaxed | allow
85             # strict | require
86             # once | require
87 7 100 100     379 if (
88             (my $mode = $tls->{verificationMode})
89             && !$uri->query_param( 'SSLMode' )
90             ) {
91 5 100 100     379 if ($mode eq 'strict' || $mode eq 'once') {
    100          
92 2         14 $uri->query_param( SSLMode => 'require' );
93             } elsif ($mode eq 'relaxed') {
94 2         11 $uri->query_param( SSLMode => 'allow' );
95             }
96             }
97             }
98              
99 26         1752 return $uri;
100             }
101              
102             has registry_uri => (
103             is => 'ro',
104             isa => URIDB,
105             lazy => 1,
106             default => sub {
107             my $self = shift;
108             my $uri = $self->uri->clone;
109             $uri->dbname($self->registry);
110             return $uri;
111             },
112             );
113              
114             sub registry_destination {
115 2     2 1 3486 my $uri = shift->registry_uri;
116 2 100       35 if ($uri->password) {
117 1         72 $uri = $uri->clone;
118 1         21 $uri->password(undef);
119             }
120 2         277 return $uri->as_string;
121             }
122              
123             sub _load_xml {
124 6     6   6446 my $path = shift;
125 6         585 require XML::Tiny;
126 6         1580 my $doc = XML::Tiny::parsefile($path->stringify);
127 6 50       6429 return {} unless @{ $doc } > 0;
  6         22  
128 6         16 return _xml2hash($doc->[0]);
129             }
130              
131             sub _xml2hash {
132 58     58   56 my $e = shift;
133 58         62 my $n = $e->{content};
134             # Return text if it's a text node.
135 58 100 100     54 return $n->[0]{content} if @{ $n } == 1 && $n->[0]{type} eq 't';
  58         166  
136 22         25 my $hash = {};
137 22         24 for my $c (@{ $n }) {
  22         28  
138             # We only care about element nodes.
139 53 100       70 next if $c->{type} ne 'e';
140 52 100       71 if (my $prev = $hash->{ $c->{name} }) {
141             # Convert to an array.
142 2 100       8 $hash->{ $c->{name} } = $prev = [$prev] unless ref $prev eq 'ARRAY';
143 2         3 push @{ $prev } => _xml2hash($c)
  2         4  
144             } else {
145 50         59 $hash->{ $c->{name} } = _xml2hash($c);
146             }
147             }
148 22         77 return $hash;
149             }
150              
151             sub _is_true($) {
152 27   100 27   15604 my $val = shift || return 0;
153             # https://github.com/ClickHouse/ClickHouse/blob/ce5a43c/base/poco/Util/src/AbstractConfiguration.cpp#L528C29-L547
154 25 100 100     173 return $val != 0 || 0 if looks_like_number $val;
155 20         47 $val = lc $val;
156 20   100     168 return $val eq 'true' || $val eq 'yes' || $val eq 'on' || 0;
157             }
158              
159             # Connection name defaults to host name from url, or else hostname from config
160             # or else localhost. Then look for that name in a connection under
161             # `connections_credentials`. If it exists, copy/overwrite `hostname`, `port`,
162             # `secure`, `user`, `password`, and `database`. Fall back on root object
163             # values `host` (not `hostname`) `port`, `secure`, `user`, `password`, and
164             # `database`.
165             #
166             # https://github.com/ClickHouse/ClickHouse/blob/d0facf0/programs/client/Client.cpp#L139-L212
167             sub _conn_cfg {
168 15     15   7617 my ($cfg, $host) = @_;
169              
170             # Copy root-level configs.
171             my $conn = {
172             (exists $cfg->{secure} ? (secure => _is_true $cfg->{secure}) : ()),
173 15 100       65 map { ( $_ => $cfg->{$_}) } grep { $cfg->{$_} } qw(host port user password database),
  27         76  
  75         152  
174             };
175              
176             # Copy client TLS config if exists.
177 15 100       69 if (my $tls = $cfg->{openSSL}) {
178 2 100       11 $conn->{tls} = $tls->{client} if $tls->{client};
179             }
180              
181             # Copy connection credentials for this host if they exists.
182 15   100     84 $host ||= $cfg->{host} || 'localhost';
      66        
183 15 100       79 my $creds = $cfg->{connections_credentials} or return $conn;
184 11 100       30 my $conns = $creds->{connection} or return $conn;
185 10 100       17 for my $c (@{ ref $conns eq 'ARRAY' ? $conns : [$conns] }) {
  10         41  
186 14 100 100     44 next unless ($c->{name} || '') eq $host;
187 10 100       23 if (exists $c->{secure}) {
188             $conn->{secure} = _is_true $c->{secure}
189 1         6 }
190 10 100       27 $conn->{host} = $c->{hostname} if $c->{hostname};
191 10         18 $conn->{$_} = $c->{$_} for grep { $c->{$_} } qw(port user password database);
  40         74  
192             }
193 10         130 return $conn;
194             }
195              
196             has _clickcnf => (
197             is => 'rw',
198             isa => HashRef,
199             lazy => 1,
200             default => \&_load_cfg,
201             );
202              
203             sub _load_cfg {
204 15     15   694 my $self = shift;
205             # https://clickhouse.com/docs/interfaces/cli#configuration_files
206             # https://github.com/ClickHouse/ClickHouse/blob/master/src/Common/Config/getClientConfigPath.cpp
207 15         138 for my $spec (
208             ['.', 'clickhouse-client'],
209             [App::Sqitch::Config->home_dir, '.clickhouse-client'],
210             ['etc', 'clickhouse-client'],
211             ) {
212 36         1109 for my $ext (qw(xml yaml yml)) {
213 102         5095 my $path = file $spec->[0], "$spec->[1].$ext";
214 102 100       10590 next unless -f $path;
215 6 100       228 my $config = $ext eq 'xml' ? _load_xml $path : do {
216 4         558 require YAML::Tiny;
217 4         5405 YAML::Tiny->read($path)->[0];
218             };
219             # We want the hostname specified by the user, if present.
220 6   33     5459 my $host = $ENV{CLICKHOUSE_HOST} || $self->SUPER::uri->host;
221 6         331 return _conn_cfg $config, $host;
222             }
223             }
224 9         797 return {};
225             }
226              
227 9 100   9   879 sub _def_user { $ENV{CLICKHOUSE_USER} || $_[0]->_clickcnf->{user} }
228 10 100   10   1047 sub _def_pass { $ENV{CLICKHOUSE_PASSWORD} || shift->_clickcnf->{password} }
229              
230             sub _dsn {
231             # Always set the host name to the default if it's not set. Otherwise
232             # URI::db::_odbc returns the DSN `dbi:ODBC:DSN=sqitch;Driver=ClickHouse`.
233             # We don't want that, because no such DSN exists. By setting the host
234             # name, it instead returns
235             # `dbi:ODBC:Server=localhost;Database=sqitch;Driver=ClickHouse`, almost
236             # certainly more correct.
237 1     1   2365 my $uri = shift->registry_uri;
238 1 50       14 unless ($uri->host) {
239 1         51 $uri = $uri->clone;
240 1         18 $uri->host('localhost');
241             }
242 1         813 return $uri->dbi_dsn
243             }
244              
245             has dbh => (
246             is => 'rw',
247             isa => DBH,
248             lazy => 1,
249             default => sub {
250             my $self = shift;
251             $self->use_driver;
252             return DBI->connect($self->_dsn, $self->username, $self->password, {
253             PrintError => 0,
254             RaiseError => 0,
255             AutoCommit => 1,
256             HandleError => $self->error_handler,
257             odbc_utf8_on => 1,
258             });
259             }
260             );
261              
262             has _ts_default => (
263             is => 'ro',
264             isa => Str,
265             lazy => 1,
266             default => sub { q{now64(6, 'UTC')} },
267             );
268              
269             # Need to wait until dbh and _ts_default are defined.
270             with 'App::Sqitch::Role::DBIEngine';
271              
272             has _cli => (
273             is => 'ro',
274             isa => ArrayRef,
275             lazy => 1,
276             default => \&_load_cli,
277             );
278              
279             sub _load_cli {
280 10     10   133 my $self = shift;
281 10         243 my $uri = $self->uri;
282              
283 10 100       347 $self->sqitch->warn(__x
284             'Database name missing in URI "{uri}"',
285             uri => $uri
286             ) unless $uri->dbname;
287              
288 10         1308 my @ret = ($self->client);
289 10 100       2111 push @ret => 'client' if $ret[0] !~ /-client(?:[.]exe)?$/;
290             # Omit port because the CLI needs the native port and the URL
291             # specifies the HTTP port.
292 10         290 for my $spec (
293             [ user => $self->username ],
294             [ password => $self->password ],
295             [ database => $uri->dbname ],
296             [ host => $uri->host ],
297             ) {
298 40 100       2141 push @ret, "--$spec->[0]" => $spec->[1] if $spec->[1];
299             }
300              
301             # Add variables, if any.
302 10 100       78 if (my %vars = $self->variables) {
303 1         39 push @ret => map {; "--param_$_" => $vars{$_} } sort keys %vars;
  3         14  
304             }
305              
306             # Options to keep things quiet.
307 10         145 push @ret => (
308             '--progress' => 'off',
309             '--progress-table' => 'off',
310             '--disable_suggestion',
311             );
312              
313             # Add relevant query args.
314 10   100     221 my $have_port = $self->_clickcnf->{port} || 0;
315 10 100       160 if (my @p = $uri->query_params) {
316 1         344 while (@p) {
317 2         10 my ($k, $v) = (lc shift @p, shift @p);
318 2 100       11 if ($k eq 'sslmode') {
    50          
319             # Prefer secure connectivity if SSL mode specified.
320 1         4 push @ret => '--secure';
321             } elsif ($k eq 'nativeport') {
322             # Custom config to set the CLI port, which is different
323             # from the HTTP port used by the ODBC driver.
324 1         4 push @ret => '--port', $v;
325 1         4 $have_port = 1;
326             }
327             }
328             }
329              
330             # If no port from config or query params, set it to encrypted port
331             # 9440 if the URL port is an HTTPS port.
332 10 100       469 if (!$have_port) {
333 8         53 my $http_port = $uri->port;
334 8 100 100     343 push @ret => '--port', 9440 if $http_port == 8443 || $http_port == 443;
335             }
336              
337 10         298 return \@ret;
338             }
339              
340 30     30   18578 sub cli { @{ shift->_cli } }
  30         1025  
341              
342 10     10 1 6078 sub key { 'clickhouse' }
343 8     8 1 43 sub name { 'ClickHouse' }
344 6     6 1 12 sub driver { 'DBD::ODBC 1.59' }
345              
346             sub default_client {
347 2     2 1 60 my $self = shift;
348 2 50       12 my $ext = App::Sqitch::ISWIN || $^O eq 'cygwin' ? '.exe' : '';
349              
350             # Try to find the client in the path.
351 2         6 my @names = map { $_ . $ext } 'clickhouse', 'clickhouse-client';
  4         17  
352 2         30 for my $dir (File::Spec->path) {
353 11         733 for my $try ( @names ) {
354 22         1138 my $path = file $dir, $try;
355             # GetShortPathName returns undef for nonexistent files.
356 22         2039 $path = Win32::GetShortPathName($path) // next if App::Sqitch::ISWIN;
357 22 50 33     103 return $try if -f $path && -x $path;
358             }
359             }
360              
361 2         130 hurl clickhouse => __x(
362             'Unable to locate {cli} client; set "engine.{eng}.client" via sqitch config',
363             cli => 'clickhouse',
364             eng => 'clickhouse',
365             );
366             }
367              
368 0     0   0 sub _char2ts { $_[1]->set_time_zone('UTC')->iso8601 }
369              
370             sub _ts2char_format {
371 1     1   763 q{formatDateTime(%s, 'year:%%Y:month:%%m:day:%%d:hour:%%H:minute:%%i:second:%%S:time_zone:UTC')};
372             }
373              
374             sub _log_tags_param {
375 0     0   0 [ map { $_->format_name } $_[1]->tags ];
  0         0  
376             }
377              
378             sub _log_requires_param {
379 0     0   0 [ map { $_->as_string } $_[1]->requires ];
  0         0  
380             }
381              
382             sub _log_conflicts_param {
383 0     0   0 [ map { $_->as_string } $_[1]->conflicts ];
  0         0  
384             }
385              
386             sub _limit_offset {
387             # LIMIT/OFFSET don't support parameters, alas. So just put them in the query.
388 6     6   209 my ($self, $lim, $off) = @_;
389 6 100 100     28 return ["LIMIT $lim", "OFFSET $off"], [] if $lim && $off;
390 5 100       20 return ["LIMIT $lim"], [] if $lim;
391 3 100       10 return ["OFFSET $off"], [] if $off;
392 2         9 return [], [];
393             }
394              
395             # ClickHouse ODBC does not support arrays. So we must parse them manually.
396             # I'd rather not do an eval, so rip this out once the issue is fixed.
397             # https://github.com/clickHouse/clickhouse-odbc/issues/525
398             sub _parse_array {
399 2     2   8219 no utf8;
  2         3  
  2         14  
400 11 100   11   34 return [] unless $_[1];
401 9         499 my $list = eval $_[1];
402 9 100       38 return [] unless $list;
403 8 100 66     9 shift @{ $list } if @{ $list } && $list->[0] eq '';
  2         5  
  8         34  
404 8         35 return $list;
405             }
406              
407 1     1   4 sub _version_query { 'SELECT CAST(ROUND(MAX(version), 1) AS CHAR) FROM releases' }
408              
409             sub _initialized {
410 4     4   41 my $self = shift;
411             return try {
412 4     4   203 $self->dbh->selectcol_arrayref(q{
413             SELECT true
414             FROM information_schema.tables
415             WHERE TABLE_CATALOG = current_database()
416             AND TABLE_SCHEMA = ?
417             AND TABLE_NAME = ?
418             }, undef, $self->registry, 'changes')->[0]
419             } catch {
420 4 100 66 4   4202 return 0 if $DBI::state && $DBI::state eq 'HY000';
421 2         23 die $_;
422             }
423 4         27 }
424              
425             sub _initialize {
426 0     0   0 my $self = shift;
427 0 0       0 hurl engine => __x(
428             'Sqitch database {database} already initialized',
429             database => $self->registry,
430             ) if $self->initialized;
431              
432             # Create the Sqitch database if it does not exist.
433 0         0 (my $db = $self->registry) =~ s/"/""/g;
434 0         0 $self->_run(
435             '--query' => sprintf(
436             q{CREATE DATABASE IF NOT EXISTS "%s" COMMENT 'Sqitch database deployment metadata v%s'},
437             $self->registry, $self->registry_release,
438             ),
439             );
440              
441             # Deploy the registry to the Sqitch database.
442 0         0 $self->run_upgrade( file(__FILE__)->dir->file('clickhouse.sql') );
443 0         0 $self->_register_release;
444             }
445              
446             sub _no_table_error {
447             # /HTTP status code: 404$/
448 6   100 6   602 return $DBI::state && $DBI::state eq 'HY000'; # General Error
449             }
450              
451             sub _no_column_error {
452 3   100 3   18 return $DBI::state && $DBI::state eq '42703'; # ERRCODE_UNDEFINED_COLUMN
453             }
454              
455             sub _unique_error {
456             # ClickHouse doe not support unique constraints.
457 1     1   3 return 0;
458             }
459              
460 2     2   6 sub _regex_op { 'REGEXP' }
461              
462 1     1   4909 sub _listagg_format { q{groupArraySorted(10000)(%1$s)} }
463              
464             sub _cid {
465 3     3   371 my ( $self, $ord, $offset, $project ) = @_;
466              
467 3 50       8 my $off = $offset ? " OFFSET $offset" : '';
468             return try {
469 3   0 3   134 return $self->dbh->selectcol_arrayref(qq{
470             SELECT change_id
471             FROM changes
472             WHERE project = ?
473             ORDER BY committed_at $ord
474             LIMIT 1$off
475             }, undef, $project || $self->plan->project)->[0];
476             } catch {
477 3 100 66 3   2585 return if $self->_no_table_error && !$self->initialized;
478 2         22 die $_;
479 3         17 };
480             }
481              
482             # Override to query for existing tags separately.
483             sub _log_event {
484 0     0   0 my ( $self, $event, $change, $tags, $requires, $conflicts) = @_;
485 0         0 my $dbh = $self->dbh;
486 0         0 my $sqitch = $self->sqitch;
487              
488 0   0     0 $tags ||= $self->_log_tags_param($change);
489 0   0     0 $requires ||= $self->_log_requires_param($change);
490 0   0     0 $conflicts ||= $self->_log_conflicts_param($change);
491              
492             # Use the array() constructor to insert arrays of values. Remove if
493             # https://github.com/clickHouse/clickhouse-odbc/issues/525 fixed.
494 0         0 my $tag_ph = 'array('. join(', ', ('?') x @{ $tags }) . ')';
  0         0  
495 0         0 my $req_ph = 'array('. join(', ', ('?') x @{ $requires }) . ')';
  0         0  
496 0         0 my $con_ph = 'array('. join(', ', ('?') x @{ $conflicts }) . ')';
  0         0  
497 0         0 my $ts = $self->_ts_default;
498              
499             $dbh->do(qq{
500             INSERT INTO events (
501             event
502             , change_id
503             , change
504             , project
505             , note
506             , tags
507             , requires
508             , conflicts
509             , committer_name
510             , committer_email
511             , planned_at
512             , planner_name
513             , planner_email
514             , committed_at
515             )
516             VALUES (?, ?, ?, ?, ?, $tag_ph, $req_ph, $con_ph, ?, ?, ?, ?, ?, $ts)
517             }, undef,
518             $event,
519             $change->id,
520             $change->name,
521             $change->project,
522             $change->note,
523 0         0 @{ $tags },
524 0         0 @{ $requires },
525 0         0 @{ $conflicts },
  0         0  
526             $sqitch->user_name,
527             $sqitch->user_email,
528             $self->_char2ts( $change->timestamp ),
529             $change->planner_name,
530             $change->planner_email,
531             );
532              
533 0         0 return $self;
534             }
535              
536             # Override to save tags as an array rather than a space-delimited string.
537             sub log_revert_change {
538 0     0 1 0 my ($self, $change) = @_;
539 0         0 my $dbh = $self->dbh;
540 0         0 my $cid = $change->id;
541              
542             # Retrieve and delete tags.
543 0         0 my $del_tags = $dbh->selectcol_arrayref(
544             'SELECT tag FROM tags WHERE change_id = ?',
545             undef, $cid
546             );
547              
548 0         0 $dbh->do(
549             'DELETE FROM tags WHERE change_id = ?',
550             undef, $cid
551             );
552              
553             # Retrieve dependencies and delete.
554 0         0 my $sth = $dbh->prepare(q{
555             SELECT dependency
556             FROM dependencies
557             WHERE change_id = ?
558             AND type = ?
559             });
560              
561 0         0 my $req = $dbh->selectcol_arrayref( $sth, undef, $cid, 'require' );
562 0         0 my $conf = $dbh->selectcol_arrayref( $sth, undef, $cid, 'conflict' );
563              
564 0         0 $dbh->do('DELETE FROM dependencies WHERE change_id = ?', undef, $cid);
565              
566             # Delete the change record.
567 0         0 $dbh->do(
568             'DELETE FROM changes where change_id = ?',
569             undef, $cid,
570             );
571              
572             # Log it.
573 0         0 return $self->_log_event( revert => $change, $del_tags, $req, $conf );
574             }
575              
576             # NOTE: Query from DBIEngine doesn't work in ClickHouse:
577             # DB::Exception: Current query is not supported yet, because can't find
578             # correlated column [...] (NOT_IMPLEMENTED)
579             # Looks like it doesn't yet support correlated subqueries. The CTE-based query
580             # adapted from Exasol seems to be fine, however.
581             sub changes_requiring_change {
582 0     0 1 0 my ( $self, $change ) = @_;
583             # Weirdly, ClickHouse doesn't inject NULLs when the tag window query
584             # returns no rows, but empty values: "" for tag name and 0 for rank. Use
585             # multiIf() to change an empty string to a NULL, and compare rank to <= 1
586             # instead of bothering with NULLs.
587 0         0 return @{ $self->dbh->selectall_arrayref(q{
  0         0  
588             WITH tag AS (
589             SELECT tag, committed_at, project,
590             ROW_NUMBER() OVER (partition by project ORDER BY committed_at) AS rnk
591             FROM tags
592             )
593             SELECT c.change_id AS change_id,
594             c.project AS project,
595             c.change AS change,
596             multiIf(t.tag == '', NULL, t.tag) AS asof_tag
597             FROM dependencies d
598             JOIN changes c ON c.change_id = d.change_id
599             LEFT JOIN tag t ON t.project = c.project AND t.committed_at >= c.committed_at
600             WHERE d.dependency_id = ?
601             AND t.rnk <= 1
602             }, { Slice => {} }, $change->id) };
603             }
604              
605             # NOTE: Query from DBIEngine doesn't work in ClickHouse:
606             # DB::Exception: Current query is not supported yet, because can't find \
607             # correlated column '__table4.committed_at' in current header: [...] (NOT_IMPLEMENTED)
608             # Looks like it doesn't yet support correlated subqueries. The CTE-based query
609             # adapted from Exasol seems to be fine, however.
610             sub name_for_change_id {
611 0     0 1 0 my ( $self, $change_id ) = @_;
612             # Weirdly, ClickHouse doesn't inject NULLs when the tag window query
613             # returns no rows, but empty values: "" for tag name and 0 for rank. Use
614             # multiIf() to change an empty string to a NULL, and compare rank to <= 1
615             # instead of bothering with NULLs.
616 0         0 return $self->dbh->selectcol_arrayref(q{
617             WITH tag AS (
618             SELECT multiIf(tag == '', NULL, tag) AS tag,
619             committed_at,
620             project,
621             ROW_NUMBER() OVER (partition by project ORDER BY committed_at) AS rnk
622             FROM tags
623             )
624             SELECT change || COALESCE(t.tag, '@HEAD')
625             FROM changes c
626             LEFT JOIN tag t ON c.project = t.project AND t.committed_at >= c.committed_at
627             WHERE change_id = ?
628             AND t.rnk <= 1
629             }, undef, $change_id)->[0];
630             }
631              
632             # There is a bug in ClickHouse EXISTS(), so do without it.
633             # https://github.com/ClickHouse/ClickHouse/issues/86415
634             sub is_deployed_change {
635 0     0 1 0 my ( $self, $change ) = @_;
636 0         0 $self->dbh->selectcol_arrayref(
637             'SELECT 1 FROM changes WHERE change_id = ?',
638             undef, $change->id
639             )->[0];
640             }
641              
642             # There is a bug in ClickHouse EXISTS(), so do without it.
643             # https://github.com/ClickHouse/ClickHouse/issues/86415
644             sub is_deployed_tag {
645 0     0 1 0 my ( $self, $tag ) = @_;
646 0         0 return $self->dbh->selectcol_arrayref(
647             'SELECT 1 FROM tags WHERE tag_id = ?',
648             undef, $tag->id,
649             )->[0];
650             }
651              
652             # Override to query for existing tags in a separate query. The LEFT JOIN/UNION
653             # dance simply didn't work in ClickHouse.
654             sub log_new_tags {
655 1     1 1 396 my ( $self, $change ) = @_;
656 1 50       6 my @tags = $change->tags or return $self;
657 0         0 my $sqitch = $self->sqitch;
658              
659 0         0 my ($id, $name, $proj, $user, $email) = (
660             $change->id,
661             $change->format_name,
662             $change->project,
663             $sqitch->user_name,
664             $sqitch->user_email
665             );
666              
667             # Get a list of existing tags.
668 0         0 my $in = join ', ', ('?') x @tags;
669 0         0 my %exists = map { $_ => undef } $self->dbh->selectrow_array(
670             "SELECT tag_id FROM tags WHERE tag_id IN($in)",
671 0         0 undef, map { $_->id } @tags,
  0         0  
672             );
673              
674             # Filter out the existing tags.
675 0         0 @tags = grep { ! exists $exists{$_->id} } @tags;
  0         0  
676 0 0       0 return $self unless @tags;
677              
678             # Insert the new tags.
679 0         0 my $row = q{(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)};
680             $self->dbh->do(
681             q{
682             INSERT INTO tags (
683             tag_id
684             , tag
685             , project
686             , change_id
687             , note
688             , committer_name
689             , committer_email
690             , planned_at
691             , planner_name
692             , planner_email
693             ) VALUES
694             } . join( ",\n ", ($row) x @tags ),
695             undef,
696 0         0 map { (
697 0         0 $_->id,
698             $_->format_name,
699             $proj,
700             $id,
701             $_->note,
702             $user,
703             $email,
704             $self->_char2ts($_->timestamp),
705             $_->planner_name,
706             $_->planner_email,
707             ) } @tags
708             );
709              
710 0         0 return $self;
711             }
712              
713             # Wrap _select_state to parse the tags into an array. Remove if and when
714             # clickhouse-odbc properly supports arrays. Remove if
715             # https://github.com/clickHouse/clickhouse-odbc/issues/525 fixed.
716             around _select_state => sub {
717             my ($orig, $self) = (shift, shift);
718             my $state = $self->$orig(@_);
719             $state->{tags} = $self->_parse_array($state->{tags})
720             if $state && $state->{tags};
721             return $state;
722             };
723              
724             sub _run {
725 4     4   1817 my $self = shift;
726 4         23 my $sqitch = $self->sqitch;
727 4 100       188 my $pass = $self->password or return $sqitch->run( $self->cli, @_ );
728 1         189 local $ENV{CLICKHOUSE_PASSWORD} = $pass;
729 1         7 return $sqitch->run( $self->cli, @_ );
730             }
731              
732             sub _capture {
733 3     3   2475 my $self = shift;
734 3         15 my $sqitch = $self->sqitch;
735 3 100       108 my $pass = $self->password or return $sqitch->capture( $self->cli, @_ );
736 1         23 local $ENV{CLICKHOUSE_PASSWORD} = $pass;
737 1         5 return $sqitch->capture( $self->cli, @_ );
738             }
739              
740             sub _spool {
741 3     3   2065 my $self = shift;
742 3         10 my @fh = (shift);
743 3         15 my $sqitch = $self->sqitch;
744 3 100       114 my $pass = $self->password or return $sqitch->spool( \@fh, $self->cli, @_ );
745 1         22 local $ENV{CLICKHOUSE_PASSWORD} = $pass;
746 1         5 return $sqitch->spool( \@fh, $self->cli, @_ );
747             }
748              
749             sub run_file {
750 1     1 1 978 my ($self, $file) = @_;
751 1         5 $self->_run('--queries-file' => $file);
752             }
753              
754             sub run_verify {
755 2     2 1 2438 my ($self, $file) = @_;
756             # Suppress STDOUT unless we want extra verbosity.
757 2 100       53 my $meth = $self->can($self->sqitch->verbosity > 1 ? '_run' : '_capture');
758 2         219 $self->$meth('--queries-file' => $file);
759             }
760              
761             sub run_upgrade {
762 2     2 1 1761 my ($self, $file) = @_;
763 2         5 my @cmd = $self->cli;
764              
765 2 100   16   22 if ((my $idx = firstidx { $_ eq '--database' } @cmd) > 0) {
  16         19  
766             # Replace the database name with the registry database.
767 1         36 $cmd[$idx + 1] = $self->registry;
768             } else {
769             # Append the registry database name.
770 1         26 push @cmd => '--database', $self->registry;
771             }
772              
773 2         101 return $self->sqitch->run(@cmd, '--queries-file' => $file);
774             }
775              
776             sub run_handle {
777 1     1 1 987 my ($self, $fh) = @_;
778 1         5 $self->_spool($fh);
779             }
780              
781             1;
782              
783             __END__
784              
785             =head1 Name
786              
787             App::Sqitch::Engine::clickhouse - Sqitch ClickHouse Engine
788              
789             =head1 Synopsis
790              
791             my $clickhouse = App::Sqitch::Engine->load( engine => 'clickhouse' );
792              
793             =head1 Description
794              
795             App::Sqitch::Engine::clickhouse provides the ClickHouse storage engine for Sqitch. It
796             supports ClickHouse v25.8 and higher.
797              
798             =head1 Interface
799              
800             =head2 Instance Methods
801              
802             =head3 C<cli>
803              
804             Returns a list containing the C<clickhouse> client and options to be passed to it.
805             Used internally when executing scripts.
806             L<Query parameters|https://github.com/clickHouse/clickhouse-odbc> in the URI
807             that map to C<clickhouse> client options will be passed to the client, as
808             follows:
809              
810             =over
811              
812             =item * C<SSLMode>: C<--secure>
813              
814             Assume that TLS is required in the client if SSLMode is set.
815              
816             =item * C<NativePort>: C<--port>
817              
818             Sqitch-specific parameter for the client port. Required because the
819             ODBC driver uses the HTTP ports (8123 or 8443 with C<SSLMode>) while the
820             ClickHouse CLI uses the Native Protocol port (9000 or 9440 with C<SSLMode>).
821             Use this option to specify an alternative port for the CLI. See
822             L<Network Ports|https://clickhouse.com/docs/guides/sre/network-ports> for
823             additional information.
824              
825             =back
826              
827             =head3 C<username>
828              
829             =head3 C<password>
830              
831             Overrides the methods provided by the target so that, if the target has
832             no username or password, Sqitch can look them up in a configuration file
833             (although it does not yet do so).
834              
835             =head3 C<uri>
836              
837             Returns the L<URI> used to connect to the database. It modifies the URI as
838             follows:
839              
840             =over
841              
842             =item hostname
843              
844             If the host name is not set, sets it from the C<$CLICKHOUSE_HOSTNAME>
845             environment variable or the hostname read from the ClickHouse configuration
846             file.
847              
848             =item port
849              
850             If the port is not set but the configuration file specifies port C<9440>, assume
851             the HTTP port should also be secure and set it to C<8443>.
852              
853             =item database
854              
855             If the database name is not set, sets it from the C<database> parameter read
856             from the ClickHouse configuration file.
857              
858             =item query
859              
860             Sets ODBC L<query parameters|https://github.com/clickHouse/clickhouse-odbc>
861             based on the C<$.openSSL.client> parameters from the ClickHouse configuration
862             file as follows:
863              
864             =over
865              
866             =item C<privateKeyFile>: C<PrivateKeyFile>
867              
868             Path to private key file. Raises an error if both are set and not the same
869             value.
870              
871             =item C<certificateFile>: C<CertificateFile>
872              
873             Path to certificate file. Raises an error if both are set and not the same
874             value.
875              
876             =item C<caConfig>: C<CALocation>
877              
878             Path to the file or directory containing the CA/root certificates. Raises an
879             error if both are set and not the same value.
880              
881             =item C<secure>, C<port>, C<host>, C<verificationMode>: C<SSLMode>
882              
883             Sets the ODBC C<SSLMode> parameter to C<require> when the C<secure> parameter
884             from the configuration file is true or the port is C<9440>, or the host name
885             from the configuration file or the target ends in C<.clickhouse.cloud>. If
886             none of those are true but C<verificationMode> is set, set the C<SSLMode>
887             query parameters as follows:
888              
889             verificationMode | SSLMode
890             ------------------|-----------
891             none | [not set]
892             relaxed | allow
893             strict | require
894             once | require
895              
896             =back
897              
898             =back
899              
900             =head1 Author
901              
902             David E. Wheeler <david@justatheory.com>
903              
904             =head1 License
905              
906             Copyright (c) 2012-2026 David E. Wheeler, 2012-2021 iovation Inc.
907              
908             Permission is hereby granted, free of charge, to any person obtaining a copy
909             of this software and associated documentation files (the "Software"), to deal
910             in the Software without restriction, including without limitation the rights
911             to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
912             copies of the Software, and to permit persons to whom the Software is
913             furnished to do so, subject to the following conditions:
914              
915             The above copyright notice and this permission notice shall be included in all
916             copies or substantial portions of the Software.
917              
918             THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
919             IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
920             FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
921             AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
922             LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
923             OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
924             SOFTWARE.
925              
926             =cut