File Coverage

blib/lib/DBIx/Util/Schema.pm
Criterion Covered Total %
statement 121 164 73.7
branch 51 102 50.0
condition 1 9 11.1
subroutine 15 15 100.0
pod 9 9 100.0
total 197 299 65.8


line stmt bran cond sub pod time code
1             package DBIx::Util::Schema;
2              
3             our $AUTHORITY = 'cpan:PERLANCAR'; # AUTHORITY
4             our $DATE = '2020-05-06'; # DATE
5             our $DIST = 'DBIx-Util-Schema'; # DIST
6             our $VERSION = '0.002'; # VERSION
7              
8 1     1   91097 use 5.010001;
  1         13  
9 1     1   5 use strict 'subs', 'vars';
  1         2  
  1         28  
10 1     1   5 use warnings;
  1         2  
  1         30  
11 1     1   1829 use Log::ger;
  1         53  
  1         5  
12              
13 1     1   267 use List::Util qw(first);
  1         2  
  1         111  
14              
15 1     1   7 use Exporter 'import';
  1         1  
  1         2563  
16             our @EXPORT_OK = qw(
17             table_exists
18             has_table
19             has_all_tables
20             has_any_table
21              
22             column_exists
23             has_column
24             has_all_columns
25             has_any_column
26              
27             list_tables
28             list_columns
29             list_indexes
30             );
31              
32             # TODO:
33             # primary_key_columns
34             # has_primary_key
35             # has_index_on (has_index?)
36             # has_unique_index_on
37             # has_a_unique_index
38             #
39             # has_foreign_key
40             #
41             # is_null_column
42             # is_not_null_column
43             # is_unique_column
44              
45             our %SPEC;
46              
47             $SPEC{':package'} = {
48             v => 1.1,
49             summary => 'Utility routines related to database schema',
50             };
51              
52             our %arg0_dbh = (
53             dbh => {
54             schema => ['obj*'],
55             summary => 'DBI database handle',
56             req => 1,
57             pos => 0,
58             },
59             );
60              
61             our %arg1_table = (
62             table => {
63             schema => ['str*'],
64             summary => 'Table name',
65             req => 1,
66             pos => 1,
67             },
68             );
69              
70             our %arg1rest_tables = (
71             tables => {
72             schema => ['array*', of=>'str*'],
73             summary => 'Table names',
74             req => 1,
75             pos => 1,
76             slurpy => 1,
77             },
78             );
79              
80             our %arg2_column = (
81             column => {
82             schema => ['str*'],
83             summary => 'Table column name',
84             req => 1,
85             pos => 2,
86             },
87             );
88              
89             our %arg2rest_columns = (
90             columns => {
91             schema => ['array*', of=>'str*'],
92             summary => 'Table column names',
93             req => 1,
94             pos => 2,
95             slurpy => 1,
96             },
97             );
98              
99             $SPEC{has_table} = {
100             v => 1.1,
101             summary => 'Check whether database has a certain table',
102             args => {
103             %arg0_dbh,
104             %arg1_table,
105             },
106             args_as => "array",
107             result_naked => 1,
108             };
109             sub has_table {
110 15     15 1 53888 my ($dbh, $table) = @_;
111 15         25 my $sth;
112 15 50       36 if ($table =~ /(.+)\.(.+)/) {
113 0         0 $sth = $dbh->table_info(undef, $1, $2, undef);
114             } else {
115 15         82 $sth = $dbh->table_info(undef, undef, $table, undef);
116             }
117              
118 15 100       5365 $sth->fetchrow_hashref ? 1:0;
119             }
120              
121             # alias for has_table
122             $SPEC{table_exists} = { %{$SPEC{has_table}}, summary=>'Alias for has_table()' };
123             *table_exists = \&has_table;
124              
125             $SPEC{has_all_tables} = {
126             v => 1.1,
127             summary => 'Check whether database has all specified tables',
128             args => {
129             %arg0_dbh,
130             %arg1rest_tables,
131             },
132             args_as => "array",
133             result_naked => 1,
134             };
135             sub has_all_tables {
136 3     3 1 2333 my ($dbh, @tables) = @_;
137 3         5 my $sth;
138 3         6 for my $table (@tables) {
139 6 50       16 if ($table =~ /(.+)\.(.+)/) {
140 0         0 $sth = $dbh->table_info(undef, $1, $2, undef);
141             } else {
142 6         26 $sth = $dbh->table_info(undef, undef, $table, undef);
143             }
144 6 100       1921 return 0 unless $sth->fetchrow_hashref;
145             }
146 2         53 1;
147             }
148              
149             $SPEC{has_any_table} = {
150             v => 1.1,
151             summary => 'Check whether database has at least one of specified tables',
152             args => {
153             %arg0_dbh,
154             %arg1rest_tables,
155             },
156             args_as => "array",
157             result_naked => 1,
158             };
159             sub has_any_table {
160 4     4 1 2086 my ($dbh, @tables) = @_;
161 4         6 my $sth;
162 4         8 for my $table (@tables) {
163 5 50       12 if ($table =~ /(.+)\.(.+)/) {
164 0         0 $sth = $dbh->table_info(undef, $1, $2, undef);
165             } else {
166 5         23 $sth = $dbh->table_info(undef, undef, $table, undef);
167             }
168 5 100       1600 return 1 if $sth->fetchrow_hashref;
169             }
170 1 50       15 @tables ? 0 : 1;
171             }
172              
173             $SPEC{has_column} = {
174             v => 1.1,
175             summary => 'Check whether a table has a specified column',
176             args => {
177             %arg0_dbh,
178             %arg1_table,
179             %arg2_column,
180             },
181             args_as => "array",
182             result_naked => 1,
183             };
184             sub has_column {
185 5     5 1 2265 my ($dbh, $table, $column) = @_;
186 5 50       14 return 0 unless has_table($dbh, $table);
187 5         117 my @columns = list_columns($dbh, $table);
188 5 100       13 (grep {$_->{COLUMN_NAME} eq $column} @columns) ? 1:0;
  17         72  
189             }
190              
191             # alias for has_column
192             $SPEC{column_exists} = { %{$SPEC{has_column}}, summary=>'Alias for has_column()' };
193             *column_exists = \&has_column;
194              
195             $SPEC{has_all_columns} = {
196             v => 1.1,
197             summary => 'Check whether a table has all specified columns',
198             args => {
199             %arg0_dbh,
200             %arg1_table,
201             %arg2rest_columns,
202             },
203             args_as => "array",
204             result_naked => 1,
205             };
206             sub has_all_columns {
207 3     3 1 2285 my ($dbh, $table, @columns) = @_;
208 3 50       8 return 0 unless has_table($dbh, $table);
209 3         66 my @all_columns = list_columns($dbh, $table);
210 3         10 for my $column (@columns) {
211 8 100       11 unless (grep {$_->{COLUMN_NAME} eq $column} @all_columns) { return 0 }
  40         64  
  1         10  
212             }
213 2         21 1;
214             }
215              
216             $SPEC{has_any_column} = {
217             v => 1.1,
218             summary => 'Check whether a table has at least one of specified columns',
219             args => {
220             %arg0_dbh,
221             %arg1_table,
222             %arg2rest_columns,
223             },
224             args_as => "array",
225             result_naked => 1,
226             };
227             sub has_any_column {
228 4     4 1 2253 my ($dbh, $table, @columns) = @_;
229 4 50       10 return 0 unless has_table($dbh, $table);
230 4         91 my @all_columns = list_columns($dbh, $table);
231 4         11 for my $column (@columns) {
232 4 100       7 if (grep {$_->{COLUMN_NAME} eq $column} @all_columns) { return 1 }
  20         40  
  3         32  
233             }
234 1 50       13 @columns ? 0:1;
235             }
236              
237             $SPEC{list_tables} = {
238             v => 1.1,
239             summary => 'List table names in a database',
240             args => {
241             %arg0_dbh,
242             },
243             args_as => "array",
244             result_naked => 1,
245             };
246             sub list_tables {
247 2     2 1 2279 my ($dbh) = @_;
248              
249 2         25 my $driver = $dbh->{Driver}{Name};
250              
251 2         6 my @res;
252 2         12 my $sth = $dbh->table_info(undef, undef, undef, undef);
253 2         732 while (my $row = $sth->fetchrow_hashref) {
254 12         27 my $name = $row->{TABLE_NAME};
255 12         24 my $schem = $row->{TABLE_SCHEM};
256 12         17 my $type = $row->{TABLE_TYPE};
257              
258 12 50       24 if ($driver eq 'mysql') {
259             # mysql driver returns database name as schema, so that's useless
260 0         0 $schem = '';
261             }
262              
263 12 50       18 next if $type eq 'VIEW';
264 12 100       65 next if $type eq 'INDEX';
265 8 50       13 next if $schem =~ /^(information_schema)$/;
266              
267 8 50       21 if ($driver eq 'Pg') {
    50          
268 0 0       0 next if $schem =~ /^(pg_catalog)$/;
269             } elsif ($driver eq 'SQLite') {
270 8 100       41 next if $schem =~ /^(temp)$/;
271 6 100       38 next if $name =~ /^(sqlite_master|sqlite_temp_master)$/;
272             }
273              
274 4 50       132 push @res, join(
275             "",
276             $schem,
277             length($schem) ? "." : "",
278             $name,
279             );
280             }
281 2         30 sort @res;
282             }
283              
284             $SPEC{list_indexes} = {
285             v => 1.1,
286             summary => 'List indexes for a table in a database',
287             description => <<'_',
288              
289             SQLite notes: information is retrieved from DBI's table_info(). Autoindex for
290             primary key is not listed using table_info(), but this function adds it by
291             looking at `sqlite_master` table.
292              
293             MySQL notes: information is retrieved from statistics_info(). Note that a
294             multi-column index is reported as separate rows by statistics_info(), one for
295             each indexed column. But this function merges them and returns the list of
296             columns in `columns`.
297              
298             _
299             args => {
300             %arg0_dbh,
301             %arg1_table,
302             },
303             args_as => "array",
304             result_naked => 1,
305             };
306             sub list_indexes {
307 1     1 1 2649 my ($dbh, $wanted_table) = @_;
308              
309 1         25 my $driver = $dbh->{Driver}{Name};
310              
311 1         4 my @res;
312              
313 1 50       4 if ($driver eq 'SQLite') {
    0          
314              
315 1         2 my @wanted_tables;
316 1 50       4 if (defined $wanted_table) {
317 0         0 @wanted_tables = ($wanted_table);
318             } else {
319 1         3 @wanted_tables = list_tables($dbh);
320             }
321 1 50       4 for (@wanted_tables) { $_ = $1 if /.+\.(.+)/ }
  2         13  
322              
323 1         6 my $sth = $dbh->prepare("SELECT * FROM sqlite_master");
324 1         93 $sth->execute;
325 1         20 while (my $row = $sth->fetchrow_hashref) {
326 4 100       43 next unless $row->{type} eq 'index';
327 2 50       5 next unless grep { $_ eq $row->{tbl_name} } @wanted_tables;
  4         13  
328 2 100       18 next unless $row->{name} =~ /\Asqlite_autoindex_.+_(\d+)\z/;
329 1         3 my $col_num = $1;
330 1         4 my @cols = list_columns($dbh, $row->{tbl_name});
331             push @res, {
332             name => "PRIMARY",
333             table => $row->{tbl_name},
334 1         26 columns => [$cols[$col_num-1]{COLUMN_NAME}],
335             is_unique => 1,
336             is_pk => 1,
337             };
338             }
339              
340 1         8 $sth = $dbh->table_info(undef, undef, undef, undef);
341             ROW:
342 1         266 while (my $row = $sth->fetchrow_hashref) {
343 6 100       67 next unless $row->{TABLE_TYPE} eq 'INDEX';
344              
345 2         5 my $table = $row->{TABLE_NAME};
346 2         3 my $schem = $row->{TABLE_SCHEM};
347              
348             # match table name
349 2 50       4 if (defined $wanted_table) {
350 0 0       0 if ($wanted_table =~ /(.+)\.(.+)/) {
351 0 0 0     0 next unless $schem eq $1 && $table eq $2;
352             } else {
353 0 0       0 next unless $table eq $wanted_table;
354             }
355             }
356              
357 2 100       16 next unless my $sql = $row->{sqlite_sql};
358             #use DD; dd $row;
359 1 50       9 $sql =~ s/\A\s*CREATE\s+(UNIQUE\s+)?INDEX\s+//is or do {
360 0         0 log_trace "Not a CREATE INDEX statement, skipped: $row->{sqlite_sql}";
361 0         0 next ROW;
362             };
363              
364 1 50       5 $row->{is_unique} = $1 ? 1:0; # not-standard, backward compat
365 1 50       4 $row->{NON_UNIQUE} = $1 ? 0:1;
366              
367 1 50       5 $sql =~ s/\A(\S+)\s+//s
368             or die "Can't extract index name from sqlite_sql: $sql";
369 1         4 $row->{name} = $1; # non-standard, backward compat
370 1         3 $row->{INDEX_NAME} = $1;
371              
372 1 50       7 $sql =~ s/\AON\s*(\S+)\s*\(\s*(.+)\s*\)//s
373             or die "Can't extract indexed table+columns from sqlite_sql: $sql";
374 1   33     4 $row->{table} = $table // $1; # non-standard, backward-compat
375 1         20 $row->{columns} = [split /\s*,\s*/, $2]; # non-standard
376              
377 1         15 push @res, $row;
378             } # while row
379              
380             } elsif ($driver eq 'mysql') {
381              
382 0         0 my $sth = $dbh->statistics_info(undef, undef, undef, undef, undef);
383 0         0 $sth->execute;
384 0         0 my @res0;
385 0         0 while (my $row = $sth->fetchrow_hashref) {
386 0 0       0 if (defined $wanted_table) {
387 0 0       0 if ($wanted_table =~ /(.+)\.(.+)/) {
388 0 0 0     0 next unless $row->{TABLE_SCHEM} eq $1 && $row->{TABLE_NAME} eq $2;
389             } else {
390 0 0       0 next unless $row->{TABLE_NAME} eq $wanted_table;
391             }
392             }
393 0         0 $row->{table} = $row->{TABLE_NAME}; # non-standard, backward-compat
394 0         0 $row->{name} = $row->{INDEX_NAME}; # non-standard, backward-compat
395 0 0       0 $row->{is_unique} = $row->{NON_UNIQUE} ? 0:1; # non-standard, backward-compat
396 0 0       0 $row->{is_pk} = $row->{INDEX_NAME} eq 'PRIMARY' ? 1:0; # non-standard, backward-compat
397              
398 0         0 push @res0, $row;
399             }
400              
401             # merge separated per-indexed-column result into a single all-columns
402             # result
403 0         0 my @index_names;
404 0 0       0 for my $row (@res0) { push @index_names, $row->{INDEX_NAME} unless grep { $row->{INDEX_NAME} eq $_ } @index_names }
  0         0  
  0         0  
405 0         0 for my $index_name (@index_names) {
406 0         0 my @hashes = grep { $_->{INDEX_NAME} eq $index_name } @res0;
  0         0  
407 0 0       0 if (@hashes == 1) {
408 0         0 push @res, $hashes[0];
409             } else {
410 0         0 my %merged_hash;
411 0         0 $merged_hash{columns} = [];
412 0         0 for my $hash (@hashes) {
413 0         0 $merged_hash{columns}[ $hash->{ORDINAL_POSITION}-1 ] = $hash->{COLUMN_NAME};
414 0         0 for (keys %$hash) { $merged_hash{$_} = $hash->{$_} }
  0         0  
415             }
416 0         0 delete $merged_hash{ORDINAL_POSITION};
417 0         0 push @res, \%merged_hash;
418             }
419             }
420              
421             } else {
422              
423 0         0 die "Driver $driver is not yet supported for list_indexes";
424             }
425              
426 1         23 @res;
427             }
428              
429             $SPEC{list_columns} = {
430             v => 1.1,
431             summary => 'List columns of a table',
432             args => {
433             %arg0_dbh,
434             %arg1_table,
435             },
436             args_as => "array",
437             result_naked => 1,
438             };
439             sub list_columns {
440 14     14 1 2316 my ($dbh, $table) = @_;
441              
442 14         18 my @res;
443 14         21 my ($schema, $utable);
444 14 50       43 if ($table =~ /\./) {
445 0         0 ($schema, $utable) = split /\./, $table;
446             } else {
447 14         45 $schema = undef;
448 14         23 $utable = $table;
449             }
450 14         72 my $sth = $dbh->column_info(undef, $schema, $utable, undef);
451 14         17013 while (my $row = $sth->fetchrow_hashref) {
452 58         1140 push @res, $row;
453             }
454 14         617 sort @res;
455             }
456              
457             1;
458             # ABSTRACT: Utility routines related to database schema
459              
460             __END__