File Coverage

blib/lib/DBIx/Diff/Schema.pm
Criterion Covered Total %
statement 162 253 64.0
branch 77 168 45.8
condition 8 48 16.6
subroutine 23 24 95.8
pod 8 8 100.0
total 278 501 55.4


line stmt bran cond sub pod time code
1             package DBIx::Diff::Schema;
2              
3             our $AUTHORITY = 'cpan:PERLANCAR'; # AUTHORITY
4             our $DATE = '2020-04-23'; # DATE
5             our $DIST = 'DBIx-Diff-Schema'; # DIST
6             our $VERSION = '0.095'; # VERSION
7              
8 1     1   22842 use 5.010001;
  1         12  
9 1     1   6 use strict 'subs', 'vars';
  1         2  
  1         28  
10 1     1   5 use warnings;
  1         1  
  1         31  
11 1     1   1773 use Log::ger;
  1         78  
  1         5  
12              
13 1     1   242 use List::Util qw(first);
  1         2  
  1         101  
14              
15 1     1   7 use Exporter;
  1         1  
  1         2432  
16             our @ISA = qw(Exporter);
17             our @EXPORT_OK = qw(
18             list_columns
19             list_tables
20             list_table_indexes
21             list_indexes
22             check_table_exists
23             diff_db_schema
24             diff_table_schema
25             db_schema_eq
26             table_schema_eq
27             );
28              
29             our %SPEC;
30              
31             $SPEC{':package'} = {
32             v => 1.1,
33             summary => 'Compare schema of two DBI databases',
34             };
35              
36             my %arg0_dbh = (
37             dbh => {
38             schema => ['obj*'],
39             summary => 'DBI database handle',
40             req => 1,
41             pos => 0,
42             },
43             );
44              
45             my %arg1_table = (
46             table => {
47             schema => ['str*'],
48             summary => 'Table name',
49             req => 1,
50             pos => 1,
51             },
52             );
53              
54             my %diff_db_args = (
55             dbh1 => {
56             schema => ['obj*'],
57             summary => 'DBI database handle for the first database',
58             req => 1,
59             pos => 0,
60             },
61             dbh2 => {
62             schema => ['obj*'],
63             summary => 'DBI database handle for the second database',
64             req => 1,
65             pos => 1,
66             },
67             );
68              
69             my %diff_table_args = (
70             %diff_db_args,
71             table1 => {
72             schema => 'str*',
73             summary => 'Table name',
74             req => 1,
75             pos => 2,
76             },
77             table2 => {
78             schema => 'str*',
79             summary => 'Second table name (assumed to be the same as first table name if unspecified)',
80             pos => 3,
81             },
82             );
83              
84             $SPEC{check_table_exists} = {
85             v => 1.1,
86             summary => 'Check whether a table exists',
87             args => {
88             %arg0_dbh,
89             %arg1_table,
90             },
91             args_as => "array",
92             result_naked => 1,
93             };
94             sub check_table_exists {
95 11     11 1 26 my ($dbh, $name) = @_;
96 11         15 my $sth;
97 11 100       63 if ($name =~ /(.+)\.(.+)/) {
98 9         56 $sth = $dbh->table_info(undef, $1, $2, undef);
99             } else {
100 2         15 $sth = $dbh->table_info(undef, undef, $name, undef);
101             }
102              
103 11 100       4528 $sth->fetchrow_hashref ? 1:0;
104             }
105              
106             $SPEC{list_tables} = {
107             v => 1.1,
108             summary => 'List table names in a database',
109             args => {
110             %arg0_dbh,
111             },
112             args_as => "array",
113             result_naked => 1,
114             };
115             sub list_tables {
116 10     10 1 25 my ($dbh) = @_;
117              
118 10         138 my $driver = $dbh->{Driver}{Name};
119              
120 10         30 my @res;
121 10         63 my $sth = $dbh->table_info(undef, undef, undef, undef);
122 10         4013 while (my $row = $sth->fetchrow_hashref) {
123 40         105 my $name = $row->{TABLE_NAME};
124 40         56 my $schem = $row->{TABLE_SCHEM};
125 40         56 my $type = $row->{TABLE_TYPE};
126              
127 40 50       77 if ($driver eq 'mysql') {
128             # mysql driver returns database name as schema, so that's useless
129 0         0 $schem = '';
130             }
131              
132 40 50       96 next if $type eq 'VIEW';
133 40 50       71 next if $type eq 'INDEX';
134 40 50       94 next if $schem =~ /^(information_schema)$/;
135              
136 40 50       92 if ($driver eq 'Pg') {
    50          
137 0 0       0 next if $schem =~ /^(pg_catalog)$/;
138             } elsif ($driver eq 'SQLite') {
139 40 100       219 next if $schem =~ /^(temp)$/;
140 30 100       257 next if $name =~ /^(sqlite_master|sqlite_temp_master)$/;
141             }
142              
143 20 50       497 push @res, join(
144             "",
145             $schem,
146             length($schem) ? "." : "",
147             $name,
148             );
149             }
150 10         164 sort @res;
151             }
152              
153             $SPEC{list_indexes} = {
154             v => 1.1,
155             summary => 'List indexes for a table in a database',
156             description => <<'_',
157              
158             General notes: information is retrieved from DBI's table_info().
159              
160             SQLite notes: autoindex for primary key is also listed as the first index, if it
161             exists. This information is retrieved using "SELECT * FROM sqlite_master".
162             Autoindex is not listed using table_info().
163              
164             _
165             args => {
166             %arg0_dbh,
167             %arg1_table,
168             },
169             args_as => "array",
170             result_naked => 1,
171             };
172             sub list_indexes {
173 0     0 1 0 my ($dbh, $wanted_table) = @_;
174              
175 0         0 my $driver = $dbh->{Driver}{Name};
176              
177 0         0 my @res;
178              
179 0 0       0 if ($driver eq 'SQLite') {
    0          
180              
181 0         0 my @wanted_tables;
182 0 0       0 if (defined $wanted_table) {
183 0         0 @wanted_tables = ($wanted_table);
184             } else {
185 0         0 @wanted_tables = list_tables($dbh);
186             }
187 0 0       0 for (@wanted_tables) { $_ = $1 if /.+\.(.+)/ }
  0         0  
188              
189 0         0 my $sth = $dbh->prepare("SELECT * FROM sqlite_master");
190 0         0 $sth->execute;
191 0         0 while (my $row = $sth->fetchrow_hashref) {
192 0 0       0 next unless $row->{type} eq 'index';
193 0 0       0 next unless grep { $_ eq $row->{tbl_name} } @wanted_tables;
  0         0  
194 0 0       0 next unless $row->{name} =~ /\Asqlite_autoindex_.+_(\d+)\z/;
195 0         0 my $col_num = $1;
196 0         0 my @cols = list_columns($dbh, $row->{tbl_name});
197             push @res, {
198             name => "PRIMARY",
199             table => $row->{tbl_name},
200 0         0 columns => [$cols[$col_num-1]{COLUMN_NAME}],
201             is_unique => 1,
202             is_pk => 1,
203             };
204             }
205              
206 0         0 $sth = $dbh->table_info(undef, undef, undef, undef);
207             ROW:
208 0         0 while (my $row = $sth->fetchrow_hashref) {
209 0 0       0 next unless $row->{TABLE_TYPE} eq 'INDEX';
210              
211 0         0 my $table = $row->{TABLE_NAME};
212 0         0 my $schem = $row->{TABLE_SCHEM};
213              
214             # match table name
215 0 0       0 if (defined $wanted_table) {
216 0 0       0 if ($wanted_table =~ /(.+)\.(.+)/) {
217 0 0 0     0 next unless $schem eq $1 && $table eq $2;
218             } else {
219 0 0       0 next unless $table eq $wanted_table;
220             }
221             }
222              
223 0 0       0 next unless my $sql = $row->{sqlite_sql};
224             #use DD; dd $row;
225 0 0       0 $sql =~ s/\A\s*CREATE\s+(UNIQUE\s+)?INDEX\s+//is or do {
226 0         0 log_trace "Not a CREATE INDEX statement, skipped: $row->{sqlite_sql}";
227 0         0 next ROW;
228             };
229              
230 0 0       0 $row->{is_unique} = $1 ? 1:0; # not-standard, backward compat
231 0 0       0 $row->{NON_UNIQUE} = $1 ? 0:1;
232              
233 0 0       0 $sql =~ s/\A(\S+)\s+//s
234             or die "Can't extract index name from sqlite_sql: $sql";
235 0         0 $row->{name} = $1; # non-standard, backward compat
236 0         0 $row->{INDEX_NAME} = $1;
237              
238 0 0       0 $sql =~ s/\AON\s*(\S+)\s*\(\s*(.+)\s*\)//s
239             or die "Can't extract indexed table+columns from sqlite_sql: $sql";
240 0   0     0 $row->{table} = $table // $1; # non-standard, backward-compat
241 0         0 $row->{columns} = [split /\s*,\s*/, $2]; # non-standard
242              
243 0         0 push @res, $row;
244             } # while row
245              
246             } elsif ($driver eq 'mysql') {
247              
248 0         0 my $sth = $dbh->statistics_info(undef, undef, undef, undef, undef);
249 0         0 $sth->execute;
250 0         0 my @res0;
251 0         0 while (my $row = $sth->fetchrow_hashref) {
252 0 0       0 if (defined $wanted_table) {
253 0 0       0 if ($wanted_table =~ /(.+)\.(.+)/) {
254 0 0 0     0 next unless $row->{TABLE_SCHEM} eq $1 && $row->{TABLE_NAME} eq $2;
255             } else {
256 0 0       0 next unless $row->{TABLE_NAME} eq $wanted_table;
257             }
258             }
259 0         0 $row->{table} = $row->{TABLE_NAME}; # non-standard, backward-compat
260 0         0 $row->{name} = $row->{INDEX_NAME}; # non-standard, backward-compat
261 0 0       0 $row->{is_unique} = $row->{NON_UNIQUE} ? 0:1; # non-standard, backward-compat
262 0 0       0 $row->{is_pk} = $row->{INDEX_NAME} eq 'PRIMARY' ? 1:0; # non-standard, backward-compat
263              
264 0         0 push @res0, $row;
265             }
266              
267             # merge separated per-indexed-column result into a single all-columns
268             # result
269 0         0 my @index_names;
270 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  
271 0         0 for my $index_name (@index_names) {
272 0         0 my @hashes = grep { $_->{INDEX_NAME} eq $index_name } @res0;
  0         0  
273 0 0       0 if (@hashes == 1) {
274 0         0 push @res, $hashes[0];
275             } else {
276 0         0 my %merged_hash;
277 0         0 $merged_hash{columns} = [map { $_->{COLUMN_NAME} } @hashes];
  0         0  
278 0         0 for my $hash (@hashes) { for (keys %$hash) { $merged_hash{$_} = $hash->{$_} } }
  0         0  
  0         0  
279 0         0 delete $merged_hash{ORDINAL_POSITION};
280 0         0 push @res, \%merged_hash;
281             }
282             }
283              
284             } else {
285              
286 0         0 die "Driver $driver is not yet supported for list_indexes";
287             }
288              
289 0         0 @res;
290             }
291              
292             # old name, deprecated
293             $SPEC{list_table_indexes} = $SPEC{list_indexes};
294             *list_table_indexes = \&list_indexes;
295              
296             $SPEC{list_columns} = {
297             v => 1.1,
298             summary => 'List columns of a table',
299             args => {
300             %arg0_dbh,
301             %arg1_table,
302             },
303             args_as => "array",
304             result_naked => 1,
305             };
306             sub list_columns {
307 22     22 1 44 my ($dbh, $table) = @_;
308              
309 22         34 my @res;
310 22         33 my ($schema, $utable);
311 22 50       77 if ($table =~ /\./) {
312 22         86 ($schema, $utable) = split /\./, $table;
313             } else {
314 0         0 $schema = undef;
315 0         0 $utable = $table;
316             }
317 22         113 my $sth = $dbh->column_info(undef, $schema, $utable, undef);
318 22         24028 while (my $row = $sth->fetchrow_hashref) {
319 74         1723 push @res, $row;
320             }
321 22         1062 sort @res;
322             }
323              
324             sub _diff_column_schema {
325 30     30   56 my ($c1, $c2) = @_;
326              
327 30         43 my $res = {};
328             {
329 30 100       41 if ($c1->{TYPE_NAME} ne $c2->{TYPE_NAME}) {
  30         67  
330 4         10 $res->{old_type} = $c1->{TYPE_NAME};
331 4         10 $res->{new_type} = $c2->{TYPE_NAME};
332 4         7 last;
333             }
334 26 100 100     103 if ($c1->{NULLABLE} xor $c2->{NULLABLE}) {
335 4         10 $res->{old_nullable} = $c1->{NULLABLE};
336 4         8 $res->{new_nullable} = $c2->{NULLABLE};
337             }
338 26 50       53 if (defined $c1->{CHAR_OCTET_LENGTH}) {
339 0 0       0 if ($c1->{CHAR_OCTET_LENGTH} != $c2->{CHAR_OCTET_LENGTH}) {
340 0         0 $res->{old_length} = $c1->{CHAR_OCTET_LENGTH};
341 0         0 $res->{new_length} = $c2->{CHAR_OCTET_LENGTH};
342             }
343             }
344 26 100       44 if (defined $c1->{DECIMAL_DIGITS}) {
345 6 50       22 if ($c1->{DECIMAL_DIGITS} != $c2->{DECIMAL_DIGITS}) {
346 0         0 $res->{old_digits} = $c1->{DECIMAL_DIGITS};
347 0         0 $res->{new_digits} = $c2->{DECIMAL_DIGITS};
348             }
349             }
350 26 50 50     116 if (($c1->{mysql_is_auto_increment} // 0) != ($c2->{mysql_is_auto_increment} // 0)) {
      50        
351 0   0     0 $res->{old_auto_increment} = $c1->{mysql_is_auto_increment} // 0;
352 0   0     0 $res->{new_auto_increment} = $c2->{mysql_is_auto_increment} // 0;
353             }
354             }
355 30         50 $res;
356             }
357              
358             sub _diff_table_schema {
359 11     11   33 my ($dbh1, $dbh2, $table1, $table2) = @_;
360              
361 11         27 my @columns1 = list_columns($dbh1, $table1);
362 11         35 my @columns2 = list_columns($dbh2, $table2);
363              
364 11         55 log_trace("columns1: %s ...", \@columns1);
365 11         47 log_trace("columns2: %s ...", \@columns2);
366              
367 11         32 my (@added, @deleted, %modified);
368 11         22 for my $c1 (@columns1) {
369 35         66 my $c1n = $c1->{COLUMN_NAME};
370 35     107   139 my $c2 = first {$c1n eq $_->{COLUMN_NAME}} @columns2;
  107         162  
371 35 100       100 if (defined $c2) {
372 30         53 my $tres = _diff_column_schema($c1, $c2);
373 30 100       84 $modified{$c1n} = $tres if %$tres;
374             } else {
375 5         14 push @deleted, $c1n;
376             }
377             }
378 11         23 for my $c2 (@columns2) {
379 39         62 my $c2n = $c2->{COLUMN_NAME};
380 39     112   106 my $c1 = first {$c2n eq $_->{COLUMN_NAME}} @columns1;
  112         161  
381 39 100       100 if (defined $c1) {
382             } else {
383 9         18 push @added, $c2n;
384             }
385             }
386              
387 11         22 my $res = {};
388 11 100       30 $res->{added_columns} = \@added if @added;
389 11 100       25 $res->{deleted_columns} = \@deleted if @deleted;
390 11 100       25 $res->{modified_columns} = \%modified if %modified;
391 11         122 $res;
392             }
393              
394             $SPEC{diff_table_schema} = {
395             v => 1.1,
396             summary => 'Compare schema of two DBI tables',
397             description => <<'_',
398              
399             This function compares schemas of two DBI tables. You supply two `DBI` database
400             handles along with table name and this function will return a hash:
401              
402             {
403             deleted_columns => [...],
404             added_columns => [...],
405             modified_columns => {
406             column1 => {
407             old_type => '...',
408             new_type => '...',
409             ...
410             },
411             },
412             }
413              
414             _
415             args => {
416             %diff_table_args,
417             },
418             args_as => "array",
419             result_naked => 1,
420             "x.perinci.sub.wrapper.disable_validate_args" => 1,
421             };
422             sub diff_table_schema {
423 1 50 0 1 1 9 my $dbh1 = shift; my $arg_err; { no warnings ('void');require Scalar::Util;((defined($dbh1)) ? 1 : (($arg_err //= "Required but not specified"),0)) && ((Scalar::Util::blessed($dbh1)) ? 1 : (($arg_err //= "Not of type object"),0)); if ($arg_err) { die "diff_table_schema(): " . "Invalid argument value for dbh1: $arg_err" } } # VALIDATE_ARG
  1 50 0 6   2  
  1 50       134  
  6 50       94  
  6         10  
  6         10  
  6         56  
  6         45  
  6         18  
  0         0  
424 1 50 0 1   8 my $dbh2 = shift; { no warnings ('void');((defined($dbh2)) ? 1 : (($arg_err //= "Required but not specified"),0)) && ((Scalar::Util::blessed($dbh2)) ? 1 : (($arg_err //= "Not of type object"),0)); if ($arg_err) { die "diff_table_schema(): " . "Invalid argument value for dbh2: $arg_err" } } # VALIDATE_ARG
  1 50 0     2  
  1 50       106  
  6 50       9  
  6         7  
  6         22  
  6         15  
  0         0  
425 1 50 0 1   8 my $table1 = shift; { no warnings ('void');((defined($table1)) ? 1 : (($arg_err //= "Required but not specified"),0)) && ((!ref($table1)) ? 1 : (($arg_err //= "Not of type text"),0)); if ($arg_err) { die "diff_table_schema(): " . "Invalid argument value for table1: $arg_err" } } # VALIDATE_ARG
  1 50 0     1  
  1 50       119  
  6 50       11  
  6         10  
  6         21  
  6         12  
  0         0  
426 1 50 66 1   7 my $table2 = shift // $table1; { no warnings ('void');((defined($table2)) ? 1 : (($arg_err //= "Required but not specified"),0)) && ((!ref($table2)) ? 1 : (($arg_err //= "Not of type text"),0)); if ($arg_err) { die "diff_table_schema(): " . "Invalid argument value for table2: $arg_err" } } # VALIDATE_ARG
  1 50 0     2  
  1 50 0     279  
  6 50       17  
  6         8  
  6         17  
  6         15  
  0         0  
427              
428             #$log->tracef("Comparing table %s vs %s ...", $table1, $table2);
429              
430 6 100       12 die "Table $table1 in first database does not exist"
431             unless check_table_exists($dbh1, $table1);
432 5 100       73 die "Table $table2 in second database does not exist"
433             unless check_table_exists($dbh2, $table2);
434 4         57 _diff_table_schema($dbh1, $dbh2, $table1, $table2);
435             }
436              
437             $SPEC{table_schema_eq} = {
438             v => 1.1,
439             summary => 'Return true if two DBI tables have the same schema',
440             description => <<'_',
441              
442             This is basically just a shortcut for:
443              
444             my $res = diff_table_schema(...);
445             !%res;
446              
447             _
448             args => {
449             %diff_table_args,
450             },
451             args_as => "array",
452             result_naked => 1,
453             "x.perinci.sub.wrapper.disable_validate_args" => 1,
454             };
455             sub table_schema_eq {
456 2     2 1 1426 my $res = diff_table_schema(@_);
457 2         13 !%$res;
458             }
459              
460             $SPEC{diff_db_schema} = {
461             v => 1.1,
462             summary => 'Compare schemas of two DBI databases',
463             description => <<'_',
464              
465             This function compares schemas of two DBI databases. You supply two `DBI`
466             database handles and this function will return a hash:
467              
468             {
469             # list of tables found in first db but missing in second
470             deleted_tables => ['table1', ...],
471              
472             # list of tables found only in the second db
473             added_tables => ['table2', ...],
474              
475             # list of modified tables, with details for each
476             modified_tables => {
477             table3 => {
478             deleted_columns => [...],
479             added_columns => [...],
480             modified_columns => {
481             column1 => {
482             old_type => '...',
483             new_type => '...',
484             ...
485             },
486             },
487             },
488             },
489             }
490              
491             _
492             args => {
493             %diff_db_args,
494             },
495             args_as => "array",
496             result_naked => 1,
497             "x.perinci.sub.wrapper.disable_validate_args" => 1,
498             };
499             sub diff_db_schema {
500 1 50 0 1 1 8 my $dbh1 = shift; my $arg_err; { no warnings ('void');require Scalar::Util;((defined($dbh1)) ? 1 : (($arg_err //= "Required but not specified"),0)) && ((Scalar::Util::blessed($dbh1)) ? 1 : (($arg_err //= "Not of type object"),0)); if ($arg_err) { die "diff_db_schema(): " . "Invalid argument value for dbh1: $arg_err" } } # VALIDATE_ARG
  1 50 0 5   1  
  1 50       137  
  5 50       68200  
  5         11  
  5         9  
  5         56  
  5         47  
  5         15  
  0         0  
501 1 50 0 1   8 my $dbh2 = shift; { no warnings ('void');((defined($dbh2)) ? 1 : (($arg_err //= "Required but not specified"),0)) && ((Scalar::Util::blessed($dbh2)) ? 1 : (($arg_err //= "Not of type object"),0)); if ($arg_err) { die "diff_db_schema(): " . "Invalid argument value for dbh2: $arg_err" } } # VALIDATE_ARG
  1 50 0     2  
  1 50       451  
  5 50       11  
  5         8  
  5         41  
  5         15  
  0         0  
502              
503 5         18 my @tables1 = list_tables($dbh1);
504 5         16 my @tables2 = list_tables($dbh2);
505              
506 5         32 log_trace("tables1: %s ...", \@tables1);
507 5         23 log_trace("tables2: %s ...", \@tables2);
508              
509 5         17 my (@added, @deleted, %modified);
510 5         14 for my $t (@tables1) {
511 10 100       23 if (grep {$_ eq $t} @tables2) {
  20         57  
512             #$log->tracef("Comparing table %s ...", $_);
513 7         20 my $tres = _diff_table_schema($dbh1, $dbh2, $t, $t);
514 7 100       24 $modified{$t} = $tres if %$tres;
515             } else {
516 3         9 push @deleted, $t;
517             }
518             }
519 5         10 for my $t (@tables2) {
520 10 100       17 if (grep {$_ eq $t} @tables1) {
  20         48  
521             } else {
522 3         8 push @added, $t;
523             }
524             }
525              
526 5         9 my $res = {};
527 5 100       17 $res->{added_tables} = \@added if @added;
528 5 100       14 $res->{deleted_tables} = \@deleted if @deleted;
529 5 100       12 $res->{modified_tables} = \%modified if %modified;
530 5         27 $res;
531             }
532              
533             $SPEC{db_schema_eq} = {
534             v => 1.1,
535             summary => 'Return true if two DBI databases have the same schema',
536             description => <<'_',
537              
538             This is basically just a shortcut for:
539              
540             my $res = diff_db_schema(...);
541             !%$res;
542              
543             _
544             args => {
545             %diff_db_args,
546             },
547             args_as => "array",
548             result_naked => 1,
549             "x.perinci.sub.wrapper.disable_validate_args" => 1,
550             };
551             sub db_schema_eq {
552 2     2 1 2289 my $res = diff_db_schema(@_);
553 2         23 !%$res;
554             }
555              
556             1;
557             # ABSTRACT: Compare schema of two DBI databases
558              
559             __END__