File Coverage

blib/lib/App/Sqitch/Engine/snowflake.pm
Criterion Covered Total %
statement 104 115 90.4
branch 31 36 86.1
condition 15 24 62.5
subroutine 45 48 93.7
pod 13 13 100.0
total 208 236 88.1


line stmt bran cond sub pod time code
1              
2             use 5.010;
3 2     2   18843 use Moo;
  2         9  
4 2     2   13 use utf8;
  2         4  
  2         12  
5 2     2   616 use Path::Class;
  2         4  
  2         9  
6 2     2   71 use DBI;
  2         4  
  2         95  
7 2     2   1311 use Try::Tiny;
  2         15457  
  2         89  
8 2     2   23 use App::Sqitch::X qw(hurl);
  2         6  
  2         99  
9 2     2   12 use Locale::TextDomain qw(App-Sqitch);
  2         3  
  2         14  
10 2     2   488 use App::Sqitch::Types qw(DBH ArrayRef HashRef URIDB Str);
  2         5  
  2         19  
11 2     2   297  
  2         16  
  2         24  
12             extends 'App::Sqitch::Engine';
13              
14             our $VERSION = 'v1.3.1'; # VERSION
15              
16              
17 4     4 1 12289 my $self = shift;
18 3     3 1 37 # Just use the target name if it doesn't look like a URI.
19 2     2 1 12 return $self->target->name if $self->target->name !~ /:/;
20 5     5 1 651  
21             # Use the URI sans password.
22             my $uri = $self->target->uri->clone;
23 15     15 1 2454 $uri->password(undef) if $uri->password;
24             return $uri->as_string;
25 15 100       85 }
26              
27             has _snowsql => (
28 12         44 is => 'ro',
29 12 100       156 isa => ArrayRef,
30 12         469 lazy => 1,
31             default => sub {
32             my $self = shift;
33             my $uri = $self->uri;
34             my @ret = ( $self->client );
35             for my $spec (
36             [ accountname => $self->account ],
37             [ username => $self->username ],
38             [ dbname => $uri->dbname ],
39             [ rolename => $self->role ],
40             ) {
41             push @ret, "--$spec->[0]" => $spec->[1] if $spec->[1];
42             }
43              
44             if (my %vars = $self->variables) {
45             push @ret => map {; '--variable', "$_=$vars{$_}" } sort keys %vars;
46             }
47              
48             push @ret => $self->_client_opts;
49             return \@ret;
50             },
51             );
52              
53              
54             has _snowcfg => (
55             is => 'rw',
56             isa => HashRef,
57             lazy => 1,
58             default => sub {
59 25     25 1 3170 my $hd = $^O eq 'MSWin32' && "$]" < '5.016' ? $ENV{HOME} || $ENV{USERPROFILE} : (glob('~'))[0];
  25         463  
60             return {} if not $hd;
61             my $fn = dir $hd, '.snowsql', 'config';
62             return {} unless -e $fn;
63             my $data = App::Sqitch::Config->new->load_file($fn);
64             my $cfg = {};
65             for my $k (keys %{ $data }) {
66             # We only want the default connections config. No named config.
67             # (For now, anyway; maybe use database as config name laster?)
68             next unless $k =~ /\Aconnections[.]([^.]+)\z/;
69             my $key = $1;
70             my $val = $data->{$k};
71             # Apparently snowsql config supports single quotes, while
72             # Config::GitLike does not.
73             # https://support.snowflake.net/s/case/5000Z000010xUYJQA2
74             # https://docs.snowflake.com/en/user-guide/snowsql-config.html#snowsql-config-file
75             if ($val =~ s/\A'//) {
76             $val = $data->{$k} unless $val =~ s/'\z//;
77             }
78             $cfg->{$key} = $val;
79             }
80             return $cfg;
81             },
82             );
83              
84             has uri => (
85             is => 'ro',
86             isa => URIDB,
87             lazy => 1,
88             default => sub {
89             my $self = shift;
90             my $uri = $self->SUPER::uri;
91              
92             # Set defaults in the URI.
93             $uri->host($self->_host($uri));
94             # Use _port instead of port so it's empty if no port is in the URI.
95             # https://github.com/sqitchers/sqitch/issues/675
96             # XXX SNOWSQL_PORT deprecated; remove once Snowflake removes it.
97             $uri->port($ENV{SNOWSQL_PORT}) if !$uri->_port && $ENV{SNOWSQL_PORT};
98             $uri->dbname(
99             $ENV{SNOWSQL_DATABASE}
100             || $self->_snowcfg->{dbname}
101             || $self->username
102             ) if !$uri->dbname;
103             return $uri;
104             },
105             );
106              
107             $ENV{SNOWSQL_USER} || $_[0]->_snowcfg->{username} || $_[0]->sqitch->sysuser
108             }
109              
110             my $acct = $ENV{SNOWSQL_ACCOUNT} || $_[0]->_snowcfg->{accountname}
111             || hurl engine => __('Cannot determine Snowflake account name');
112              
113             # XXX Region is deprecated as a separate value, because the acount name may now be
114             # <account_name>.<region_id>.<cloud_platform_or_private_link>
115 7 100 100 7   499 # https://docs.snowflake.com/en/user-guide/snowsql-start.html#a-accountname
116             # Remove from here down and just return on the line above once Snowflake removes it.
117             my $region = $ENV{SNOWSQL_REGION} || $_[0]->_snowcfg->{region} or return $acct;
118 4 100   4   303 return "$acct.$region";
119             }
120              
121 6   33 6   28 has account => (
122             is => 'ro',
123             isa => Str,
124             lazy => 1,
125             default => sub {
126             my $self = shift;
127 6 100 66     36 if (my $host = $self->uri->host) {
128 1         6 # <account_name>.<region_id>.<cloud_platform_or_privatelink>.snowflakecomputing.com
129             $host =~ s/[.]snowflakecomputing[.]com$//;
130             return $host;
131             }
132             return $self->_def_acct;
133             },
134             );
135              
136             my ($self, $uri) = @_;
137             if (my $host = $uri->host) {
138             return $host if $host =~ /\.snowflakecomputing\.com$/;
139             return $host . ".snowflakecomputing.com";
140             }
141             # XXX SNOWSQL_HOST is deprecated; remove it once Snowflake removes it.
142             return $ENV{SNOWSQL_HOST} if $ENV{SNOWSQL_HOST};
143             return $self->_def_acct . '.snowflakecomputing.com';
144             }
145              
146             has warehouse => (
147 9     9   15 is => 'ro',
148 9 100       27 isa => Str,
149 2 100       99 lazy => 1,
150 1         10 default => sub {
151             my $self = shift;
152             my $uri = $self->uri;
153 7 100       160 require URI::QueryParam;
154 6         19 $uri->query_param('warehouse')
155             || $ENV{SNOWSQL_WAREHOUSE}
156             || $self->_snowcfg->{warehousename}
157             || 'sqitch';
158             },
159             );
160              
161             has role => (
162             is => 'ro',
163             isa => Str,
164             lazy => 1,
165             default => sub {
166             my $self = shift;
167             my $uri = $self->uri;
168             require URI::QueryParam;
169             $uri->query_param('role')
170             || $ENV{SNOWSQL_ROLE}
171             || $self->_snowcfg->{rolename}
172             || '';
173             },
174             );
175              
176             has dbh => (
177             is => 'rw',
178             isa => DBH,
179             lazy => 1,
180             default => sub {
181             my $self = shift;
182             $self->use_driver;
183             my $uri = $self->uri;
184             my $wh = $self->warehouse;
185             my $role = $self->role;
186             DBI->connect($uri->dbi_dsn, $self->username, $self->password, {
187             PrintError => 0,
188             RaiseError => 0,
189             AutoCommit => 1,
190             odbc_utf8_on => 1,
191             FetchHashKeyName => 'NAME_lc',
192             HandleError => sub {
193             my ($err, $dbh) = @_;
194             $@ = $err;
195             @_ = ($dbh->state || 'DEV' => $dbh->errstr);
196             goto &hurl;
197             },
198             Callbacks => {
199             connected => sub {
200             my $dbh = shift;
201             try {
202             $dbh->do($_) for (
203             ($role ? ("USE ROLE $role") : ()),
204             "ALTER WAREHOUSE $wh RESUME IF SUSPENDED",
205             "USE WAREHOUSE $wh",
206             'USE SCHEMA ' . $self->registry,
207             'ALTER SESSION SET TIMESTAMP_TYPE_MAPPING=TIMESTAMP_LTZ',
208             "ALTER SESSION SET TIMESTAMP_OUTPUT_FORMAT='YYYY-MM-DD HH24:MI:SS'",
209             "ALTER SESSION SET TIMEZONE='UTC'",
210             );
211             $dbh->set_err(undef, undef) if $dbh->err;
212             };
213             return;
214             },
215             disconnect => sub {
216             shift->do("ALTER WAREHOUSE $wh SUSPEND");
217             return;
218             },
219             },
220             });
221             }
222             );
223              
224             # Need to wait until dbh is defined.
225             with 'App::Sqitch::Role::DBIEngine';
226              
227             return (
228             '--noup',
229             '--option' => 'auto_completion=false',
230             '--option' => 'echo=false',
231             '--option' => 'execution_only=false',
232             '--option' => 'friendly=false',
233             '--option' => 'header=false',
234             '--option' => 'exit_on_error=true',
235             '--option' => 'stop_on_error=true',
236             '--option' => 'output_format=csv',
237             '--option' => 'paging=false',
238             '--option' => 'timing=false',
239             # results=false suppresses errors! Bug report:
240 5     5   80 # https://support.snowflake.net/s/case/5000Z000010wm6BQAQ/
241             '--option' => 'results=true',
242             '--option' => 'wrap=false',
243             '--option' => 'rowset_size=1000',
244             '--option' => 'syntax_style=default',
245             '--option' => 'variable_substitution=true',
246             '--variable' => 'registry=' . $_[0]->registry,
247             '--variable' => 'warehouse=' . $_[0]->warehouse,
248             );
249             }
250              
251             return (
252             '--option' => 'quiet=true',
253             );
254             }
255              
256             return (
257             '--option' => 'quiet=false',
258             );
259             }
260              
261             # Not using arrays, but delimited strings that are the default in
262             # App::Sqitch::Role::DBIEngine, because:
263             # * There is currently no literal syntax for arrays
264             # https://support.snowflake.net/s/case/5000Z000010wXBRQA2/
265 2     2   33 # * Scalar variables like the array constructor can't be used in WHERE clauses
266             # https://support.snowflake.net/s/case/5000Z000010wX7yQAE/
267             return q{listagg(%1$s, ' ') WITHIN GROUP (ORDER BY %1$s)};
268             }
269              
270              
271 16     16   329 my $self = shift;
272             return $self->dbh->selectcol_arrayref(q{
273             SELECT true
274             FROM information_schema.tables
275             WHERE TABLE_CATALOG = current_database()
276             AND TABLE_SCHEMA = UPPER(?)
277             AND TABLE_NAME = UPPER(?)
278             }, undef, $self->registry, 'changes')->[0];
279             }
280              
281             my $self = shift;
282 1     1   883 my $schema = $self->registry;
283             hurl engine => __x(
284             'Sqitch schema "{schema}" already exists',
285 1     1   13 schema => $schema
286             ) if $self->initialized;
287              
288 1     1 1 100 $self->run_file( file(__FILE__)->dir->file('snowflake.sql') );
289 1         4 $self->dbh->do("USE SCHEMA $schema");
290             $self->_register_release;
291             }
292              
293             return $DBI::state && $DBI::state eq '42S02'; # ERRCODE_UNDEFINED_TABLE
294             }
295              
296             return $DBI::state && $DBI::state eq '42703'; # ERRCODE_UNDEFINED_COLUMN
297             }
298              
299 0     0 1 0 # The colon has to be inside the quotation marks, because otherwise it
300 0         0 # generates wayward single quotation marks. Bug report:
301 0 0       0 # https://support.snowflake.net/s/case/5000Z000010wTkKQAU/
302             qq{to_varchar(CONVERT_TIMEZONE('UTC', %s), '"year:"YYYY":month:"MM":day:"DD":hour:"HH24":minute:"MI":second:"SS":time_zone:UTC"')};
303             }
304              
305              
306 0         0 require App::Sqitch::DateTime;
307 0         0 return App::Sqitch::DateTime->new(split /:/ => shift);
308 0         0 }
309              
310              
311              
312 4   100 4   55 my ( $self, $ord, $offset, $project ) = @_;
313              
314             my $offset_expr = $offset ? " OFFSET $offset" : '';
315             return try {
316 3   100 3   28 $self->dbh->selectcol_arrayref(qq{
317             SELECT change_id
318             FROM changes
319             WHERE project = ?
320             ORDER BY committed_at $ord
321             LIMIT 1$offset_expr
322             }, undef, $project || $self->plan->project)->[0];
323 1     1   25 } catch {
324             return if $self->_no_table_error && !$self->initialized;
325             die $_;
326 1     1   6373 };
327             }
328              
329 1     1   8 my ( $self, $change ) = @_;
330 1         53 # NOTE: Query from DBIEngine doesn't work in Snowflake:
331             # SQL compilation error: Unsupported subquery type cannot be evaluated (SQL-42601)
332             # Looks like it doesn't yet support correlated subqueries.
333 1     1   13 # https://docs.snowflake.com/en/sql-reference/operators-subquery.html
334             # The CTE-based query borrowed from Exasol seems to be fine, however.
335 1     1   6 return @{ $self->dbh->selectall_arrayref(q{
336             WITH tag AS (
337             SELECT tag, committed_at, project,
338 1     1   55 ROW_NUMBER() OVER (partition by project ORDER BY committed_at) AS rnk
339             FROM tags
340 1 50       9 )
341             SELECT c.change_id, c.project, c.change, t.tag AS asof_tag
342 1   0 1   231 FROM dependencies d
343             JOIN changes c ON c.change_id = d.change_id
344             LEFT JOIN tag t ON t.project = c.project AND t.committed_at >= c.committed_at
345             WHERE d.dependency_id = ?
346             AND (t.rnk IS NULL OR t.rnk = 1)
347             }, { Slice => {} }, $change->id) };
348             }
349              
350 1 50 33 1   31 my ( $self, $change_id ) = @_;
351 1         8 # NOTE: Query from DBIEngine doesn't work in Snowflake:
352 1         44 # SQL compilation error: Unsupported subquery type cannot be evaluated (SQL-42601)
353             # Looks like it doesn't yet support correlated subqueries.
354             # https://docs.snowflake.com/en/sql-reference/operators-subquery.html
355             # The CTE-based query borrowed from Exasol seems to be fine, however.
356 0     0 1 0 return $self->dbh->selectcol_arrayref(q{
357             WITH tag AS (
358             SELECT tag, committed_at, project,
359             ROW_NUMBER() OVER (partition by project ORDER BY committed_at) AS rnk
360             FROM tags
361             )
362 0         0 SELECT change || COALESCE(t.tag, '@HEAD')
  0         0  
363             FROM changes c
364             LEFT JOIN tag t ON c.project = t.project AND t.committed_at >= c.committed_at
365             WHERE change_id = ?
366             AND (t.rnk IS NULL OR t.rnk = 1)
367             }, undef, $change_id)->[0];
368             }
369              
370             # https://support.snowflake.net/s/question/0D50Z00008BENO5SAP
371              
372             # LIMIT/OFFSET don't support parameters, alas. So just put them in the query.
373             my ($self, $lim, $off) = @_;
374             # OFFSET cannot be used without LIMIT, sadly.
375             # https://support.snowflake.net/s/case/5000Z000010wfnWQAQ
376             return ['LIMIT ' . ($lim || $self->_limit_default), "OFFSET $off"], [] if $off;
377             return ["LIMIT $lim"], [] if $lim;
378 0     0 1 0 return [], [];
379             }
380              
381             my ( $self, $col, $regex ) = @_;
382             # Snowflake regular expressions are implicitly anchored to match the
383             # entire string. To work around this, issue, we use regexp_substr(), which
384 0         0 # is not so anchored, and just check to see that if it returns a string.
385             # https://support.snowflake.net/s/case/5000Z000010wbUSQAY
386             # https://support.snowflake.net/s/question/0D50Z00008C90beSAB/
387             return "regexp_substr($col, ?) IS NOT NULL", $regex;
388             }
389              
390             my ($self, $file) = @_;
391             $self->_run(_quiet_opts, '--filename' => $file);
392             }
393              
394             my ($self, $file) = @_;
395             # Suppress STDOUT unless we want extra verbosity.
396             return $self->run_file($file) unless $self->sqitch->verbosity > 1;
397             $self->_run(_verbose_opts, '--filename' => $file);
398             }
399 2     2   25  
400             my ($self, $fh) = @_;
401             $self->_spool($fh);
402             }
403 6     6   290  
404             my $self = shift;
405             my $sqitch = $self->sqitch;
406 6 100 66     49 my $pass = $self->password or
407 4 100       385 # Use capture and emit instead of _run to avoid a wayward newline in
408 2         10 # the output.
409             return $sqitch->emit_literal( $sqitch->capture( $self->snowsql, @_ ) );
410             # Does not override connection config, alas.
411             local $ENV{SNOWSQL_PWD} = $pass;
412 1     1   9 return $sqitch->emit_literal( $sqitch->capture( $self->snowsql, @_ ) );
413             }
414              
415             my $self = shift;
416             my $sqitch = $self->sqitch;
417             my $pass = $self->password or
418 1         12 return $sqitch->capture( $self->snowsql, _verbose_opts, @_ );
419             local $ENV{SNOWSQL_PWD} = $pass;
420             return $sqitch->capture( $self->snowsql, _verbose_opts, @_ );
421             }
422 1     1 1 9  
423 1         10 my $self = shift;
424             my $sqitch = $self->sqitch;
425             my $pass = $self->password or
426             return $sqitch->probe( $self->snowsql, _verbose_opts, @_ );
427 1     1 1 3 local $ENV{SNOWSQL_PWD} = $pass;
428             return $sqitch->probe( $self->snowsql, _verbose_opts, @_ );
429 1 50       5 }
430 1         14  
431             my $self = shift;
432             my $fh = shift;
433             my $sqitch = $self->sqitch;
434 1     1 1 4 my $pass = $self->password or
435 1         3 return $sqitch->spool( $fh, $self->snowsql, _verbose_opts, @_ );
436             local $ENV{SNOWSQL_PWD} = $pass;
437             return $sqitch->spool( $fh, $self->snowsql, _verbose_opts, @_ );
438             }
439 4     4   866  
440 4         16 1;
441 4 100       87  
442              
443             =head1 Name
444              
445             App::Sqitch::Engine::snowflake - Sqitch Snowflake Engine
446 1         113  
447 1         4 =head1 Synopsis
448              
449             my $snowflake = App::Sqitch::Engine->load( engine => 'snowflake' );
450              
451 2     2   85 =head1 Description
452 2         8  
453 2 100       43 App::Sqitch::Engine::snowflake provides the Snowflake storage engine for Sqitch.
454              
455 1         19 =head1 Interface
456 1         3  
457             =head2 Attributes
458              
459             =head3 C<uri>
460 2     2   57  
461 2         12 Returns the Snowflake database URI name. It starts with the URI for the target
462 2 100       44 and builds out missing parts. Sqitch looks for the host name in this order:
463              
464 1         13 =over
465 1         11  
466             =item 1
467              
468             In the host name of the target URI. If that host name does not end in
469 3     3   1618 C<snowflakecomputing.com>, Sqitch appends it. This lets Snowflake URLs just
470 3         4 reference the Snowflake account name or the account name and region in URLs.
471 3         8  
472 3 100       61 =item 2
473              
474 1         13 In the C<$SNOWSQL_HOST> environment variable (Deprecated by Snowflake).
475 1         11  
476             =item 3
477              
478             By concatenating the account name and region, if available, from the
479             C<$SNOWSQL_ACCOUNT> environment variable or C<connections.accountname> setting
480             in the
481             L<SnowSQL configuration file|https://docs.snowflake.com/en/user-guide/snowsql-start.html#configuring-default-connection-settings>,
482             the C<$SNOWSQL_REGION> or C<connections.region> setting in the
483             L<SnowSQL configuration file|https://docs.snowflake.com/en/user-guide/snowsql-start.html#configuring-default-connection-settings>,
484             and C<snowflakecomputing.com>. Note that Snowflake has deprecated
485             C<$SNOWSQL_REGION> and C<connections.region>, and will be removed in a future
486             version. Append the region name and cloud platform name to the account name,
487             instead.
488              
489             =back
490              
491             The database name is determined by the following methods:
492              
493             =over
494              
495             =item 1.
496              
497             The path par t of the database URI.
498              
499             =item 2.
500              
501             The C<$SNOWSQL_DATABASE> environment variable.
502              
503             =item 3.
504              
505             In the C<connections.dbname> setting in the
506             L<SnowSQL configuration file|https://docs.snowflake.com/en/user-guide/snowsql-start.html#configuring-default-connection-settings>.
507              
508             =item 4.
509              
510             If sqitch finds no value in the above places, it falls back on the system
511             username.
512              
513             =back
514              
515             Other attributes of the URI are set from the C<account>, C<username> and
516             C<password> attributes documented below.
517              
518             =head3 C<account>
519              
520             Returns the Snowflake account name, or an exception if none can be determined.
521             Sqitch looks for the account code in this order:
522              
523             =over
524              
525             =item 1
526              
527             In the host name of the target URI.
528              
529             =item 2
530              
531             In the C<$SNOWSQL_ACCOUNT> environment variable.
532              
533             =item 3
534              
535             In the C<connections.accountname> setting in the
536             L<SnowSQL configuration file|https://docs.snowflake.com/en/user-guide/snowsql-start.html#configuring-default-connection-settings>.
537              
538             =back
539              
540             =head3 username
541              
542             Returns the snowflake user name. Sqitch looks for the user name in this order:
543              
544             =over
545              
546             =item 1
547              
548             In the C<$SQITCH_USERNAME> environment variable.
549              
550             =item 2
551              
552             In the target URI.
553              
554             =item 3
555              
556             In the C<$SNOWSQL_USER> environment variable.
557              
558             =item 4
559              
560             In the C<connections.username> variable from the
561             L<SnowSQL config file|https://docs.snowflake.com/en/user-guide/snowsql-config.html#snowsql-config-file>.
562              
563             =item 5
564              
565             The system username.
566              
567             =back
568              
569             =head3 password
570              
571             Returns the snowflake password. Sqitch looks for the password in this order:
572              
573             =over
574              
575             =item 1
576              
577             In the C<$SQITCH_PASSWORD> environment variable.
578              
579             =item 2
580              
581             In the target URI.
582              
583             =item 3
584              
585             In the C<$SNOWSQL_PWD> environment variable.
586              
587             =item 4
588              
589             In the C<connections.password> variable from the
590             L<SnowSQL config file|https://docs.snowflake.com/en/user-guide/snowsql-config.html#snowsql-config-file>.
591              
592             =back
593              
594             =head3 C<warehouse>
595              
596             Returns the warehouse to use for all connections. This value will be available
597             to all Snowflake change scripts as the C<&warehouse> variable. Sqitch looks
598             for the warehouse in this order:
599              
600             =over
601              
602             =item 1
603              
604             In the C<warehouse> query parameter of the target URI
605              
606             =item 2
607              
608             In the C<$SNOWSQL_WAREHOUSE> environment variable.
609              
610             =item 3
611              
612             In the C<connections.warehousename> variable from the
613             L<SnowSQL config file|https://docs.snowflake.com/en/user-guide/snowsql-config.html#snowsql-config-file>.
614              
615             =item 4
616              
617             If none of the above are found, it falls back on the hard-coded value
618             "sqitch".
619              
620             =back
621              
622             =head3 C<role>
623              
624             Returns the role to use for all connections. Sqitch looks for the role in this
625             order:
626              
627             =over
628              
629             =item 1
630              
631             In the C<role> query parameter of the target URI
632              
633             =item 2
634              
635             In the C<$SNOWSQL_ROLE> environment variable.
636              
637             =item 3
638              
639             In the C<connections.rolename> variable from the
640             L<SnowSQL config file|https://docs.snowflake.com/en/user-guide/snowsql-config.html#snowsql-config-file>.
641              
642             =item 4
643              
644             If none of the above are found, no role will be set.
645              
646             =back
647              
648             =head2 Instance Methods
649              
650             =head3 C<initialized>
651              
652             $snowflake->initialize unless $snowflake->initialized;
653              
654             Returns true if the database has been initialized for Sqitch, and false if it
655             has not.
656              
657             =head3 C<initialize>
658              
659             $snowflake->initialize;
660              
661             Initializes a database for Sqitch by installing the Sqitch registry schema.
662              
663             =head3 C<snowsql>
664              
665             Returns a list containing the C<snowsql> client and options to be passed to
666             it. Used internally when executing scripts.
667              
668             =head1 Author
669              
670             David E. Wheeler <david@justatheory.com>
671              
672             =head1 License
673              
674             Copyright (c) 2012-2022 iovation Inc., David E. Wheeler
675              
676             Permission is hereby granted, free of charge, to any person obtaining a copy
677             of this software and associated documentation files (the "Software"), to deal
678             in the Software without restriction, including without limitation the rights
679             to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
680             copies of the Software, and to permit persons to whom the Software is
681             furnished to do so, subject to the following conditions:
682              
683             The above copyright notice and this permission notice shall be included in all
684             copies or substantial portions of the Software.
685              
686             THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
687             IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
688             FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
689             AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
690             LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
691             OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
692             SOFTWARE.
693              
694             =cut