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.096'; # VERSION
7              
8 1     1   22276 use 5.010001;
  1         10  
9 1     1   5 use strict 'subs', 'vars';
  1         2  
  1         26  
10 1     1   5 use warnings;
  1         2  
  1         367  
11 1     1   1769 use Log::ger;
  1         50  
  1         4  
12              
13 1     1   246 use List::Util qw(first);
  1         2  
  1         103  
14              
15 1     1   7 use Exporter;
  1         1  
  1         2302  
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 28 my ($dbh, $name) = @_;
96 11         15 my $sth;
97 11 100       63 if ($name =~ /(.+)\.(.+)/) {
98 9         53 $sth = $dbh->table_info(undef, $1, $2, undef);
99             } else {
100 2         17 $sth = $dbh->table_info(undef, undef, $name, undef);
101             }
102              
103 11 100       4451 $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 21 my ($dbh) = @_;
117              
118 10         124 my $driver = $dbh->{Driver}{Name};
119              
120 10         32 my @res;
121 10         50 my $sth = $dbh->table_info(undef, undef, undef, undef);
122 10         3715 while (my $row = $sth->fetchrow_hashref) {
123 40         100 my $name = $row->{TABLE_NAME};
124 40         104 my $schem = $row->{TABLE_SCHEM};
125 40         57 my $type = $row->{TABLE_TYPE};
126              
127 40 50       83 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       75 next if $type eq 'VIEW';
133 40 50       74 next if $type eq 'INDEX';
134 40 50       71 next if $schem =~ /^(information_schema)$/;
135              
136 40 50       93 if ($driver eq 'Pg') {
    50          
137 0 0       0 next if $schem =~ /^(pg_catalog)$/;
138             } elsif ($driver eq 'SQLite') {
139 40 100       218 next if $schem =~ /^(temp)$/;
140 30 100       241 next if $name =~ /^(sqlite_master|sqlite_temp_master)$/;
141             }
142              
143 20 50       452 push @res, join(
144             "",
145             $schem,
146             length($schem) ? "." : "",
147             $name,
148             );
149             }
150 10         161 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} = [];
278 0         0 for my $hash (@hashes) {
279 0         0 $merged_hash{columns}[ $hash->{ORDINAL_POSITION}-1 ] = $hash->{COLUMN_NAME};
280 0         0 for (keys %$hash) { $merged_hash{$_} = $hash->{$_} }
  0         0  
281             }
282 0         0 delete $merged_hash{ORDINAL_POSITION};
283 0         0 push @res, \%merged_hash;
284             }
285             }
286              
287             } else {
288              
289 0         0 die "Driver $driver is not yet supported for list_indexes";
290             }
291              
292 0         0 @res;
293             }
294              
295             # old name, deprecated
296             $SPEC{list_table_indexes} = $SPEC{list_indexes};
297             *list_table_indexes = \&list_indexes;
298              
299             $SPEC{list_columns} = {
300             v => 1.1,
301             summary => 'List columns of a table',
302             args => {
303             %arg0_dbh,
304             %arg1_table,
305             },
306             args_as => "array",
307             result_naked => 1,
308             };
309             sub list_columns {
310 22     22 1 42 my ($dbh, $table) = @_;
311              
312 22         32 my @res;
313 22         36 my ($schema, $utable);
314 22 50       78 if ($table =~ /\./) {
315 22         177 ($schema, $utable) = split /\./, $table;
316             } else {
317 0         0 $schema = undef;
318 0         0 $utable = $table;
319             }
320 22         119 my $sth = $dbh->column_info(undef, $schema, $utable, undef);
321 22         23323 while (my $row = $sth->fetchrow_hashref) {
322 74         1704 push @res, $row;
323             }
324 22         1071 sort @res;
325             }
326              
327             sub _diff_column_schema {
328 30     30   51 my ($c1, $c2) = @_;
329              
330 30         43 my $res = {};
331             {
332 30 100       39 if ($c1->{TYPE_NAME} ne $c2->{TYPE_NAME}) {
  30         69  
333 4         16 $res->{old_type} = $c1->{TYPE_NAME};
334 4         9 $res->{new_type} = $c2->{TYPE_NAME};
335 4         8 last;
336             }
337 26 100 100     104 if ($c1->{NULLABLE} xor $c2->{NULLABLE}) {
338 4         10 $res->{old_nullable} = $c1->{NULLABLE};
339 4         9 $res->{new_nullable} = $c2->{NULLABLE};
340             }
341 26 50       51 if (defined $c1->{CHAR_OCTET_LENGTH}) {
342 0 0       0 if ($c1->{CHAR_OCTET_LENGTH} != $c2->{CHAR_OCTET_LENGTH}) {
343 0         0 $res->{old_length} = $c1->{CHAR_OCTET_LENGTH};
344 0         0 $res->{new_length} = $c2->{CHAR_OCTET_LENGTH};
345             }
346             }
347 26 100       50 if (defined $c1->{DECIMAL_DIGITS}) {
348 6 50       20 if ($c1->{DECIMAL_DIGITS} != $c2->{DECIMAL_DIGITS}) {
349 0         0 $res->{old_digits} = $c1->{DECIMAL_DIGITS};
350 0         0 $res->{new_digits} = $c2->{DECIMAL_DIGITS};
351             }
352             }
353 26 50 50     109 if (($c1->{mysql_is_auto_increment} // 0) != ($c2->{mysql_is_auto_increment} // 0)) {
      50        
354 0   0     0 $res->{old_auto_increment} = $c1->{mysql_is_auto_increment} // 0;
355 0   0     0 $res->{new_auto_increment} = $c2->{mysql_is_auto_increment} // 0;
356             }
357             }
358 30         53 $res;
359             }
360              
361             sub _diff_table_schema {
362 11     11   33 my ($dbh1, $dbh2, $table1, $table2) = @_;
363              
364 11         24 my @columns1 = list_columns($dbh1, $table1);
365 11         34 my @columns2 = list_columns($dbh2, $table2);
366              
367 11         57 log_trace("columns1: %s ...", \@columns1);
368 11         50 log_trace("columns2: %s ...", \@columns2);
369              
370 11         32 my (@added, @deleted, %modified);
371 11         27 for my $c1 (@columns1) {
372 35         57 my $c1n = $c1->{COLUMN_NAME};
373 35     107   136 my $c2 = first {$c1n eq $_->{COLUMN_NAME}} @columns2;
  107         157  
374 35 100       101 if (defined $c2) {
375 30         57 my $tres = _diff_column_schema($c1, $c2);
376 30 100       88 $modified{$c1n} = $tres if %$tres;
377             } else {
378 5         17 push @deleted, $c1n;
379             }
380             }
381 11         22 for my $c2 (@columns2) {
382 39         67 my $c2n = $c2->{COLUMN_NAME};
383 39     108   100 my $c1 = first {$c2n eq $_->{COLUMN_NAME}} @columns1;
  108         156  
384 39 100       97 if (defined $c1) {
385             } else {
386 9         20 push @added, $c2n;
387             }
388             }
389              
390 11         21 my $res = {};
391 11 100       29 $res->{added_columns} = \@added if @added;
392 11 100       27 $res->{deleted_columns} = \@deleted if @deleted;
393 11 100       27 $res->{modified_columns} = \%modified if %modified;
394 11         124 $res;
395             }
396              
397             $SPEC{diff_table_schema} = {
398             v => 1.1,
399             summary => 'Compare schema of two DBI tables',
400             description => <<'_',
401              
402             This function compares schemas of two DBI tables. You supply two `DBI` database
403             handles along with table name and this function will return a hash:
404              
405             {
406             deleted_columns => [...],
407             added_columns => [...],
408             modified_columns => {
409             column1 => {
410             old_type => '...',
411             new_type => '...',
412             ...
413             },
414             },
415             }
416              
417             _
418             args => {
419             %diff_table_args,
420             },
421             args_as => "array",
422             result_naked => 1,
423             "x.perinci.sub.wrapper.disable_validate_args" => 1,
424             };
425             sub diff_table_schema {
426 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       129  
  6 50       92  
  6         9  
  6         13  
  6         54  
  6         45  
  6         14  
  0         0  
427 1 50 0 1   7 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       107  
  6 50       13  
  6         7  
  6         23  
  6         16  
  0         0  
428 1 50 0 1   6 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     2  
  1 50       86  
  6 50       11  
  6         11  
  6         23  
  6         9  
  0         0  
429 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     272  
  6 50       21  
  6         6  
  6         20  
  6         10  
  0         0  
430              
431             #$log->tracef("Comparing table %s vs %s ...", $table1, $table2);
432              
433 6 100       15 die "Table $table1 in first database does not exist"
434             unless check_table_exists($dbh1, $table1);
435 5 100       73 die "Table $table2 in second database does not exist"
436             unless check_table_exists($dbh2, $table2);
437 4         61 _diff_table_schema($dbh1, $dbh2, $table1, $table2);
438             }
439              
440             $SPEC{table_schema_eq} = {
441             v => 1.1,
442             summary => 'Return true if two DBI tables have the same schema',
443             description => <<'_',
444              
445             This is basically just a shortcut for:
446              
447             my $res = diff_table_schema(...);
448             !%res;
449              
450             _
451             args => {
452             %diff_table_args,
453             },
454             args_as => "array",
455             result_naked => 1,
456             "x.perinci.sub.wrapper.disable_validate_args" => 1,
457             };
458             sub table_schema_eq {
459 2     2 1 1392 my $res = diff_table_schema(@_);
460 2         15 !%$res;
461             }
462              
463             $SPEC{diff_db_schema} = {
464             v => 1.1,
465             summary => 'Compare schemas of two DBI databases',
466             description => <<'_',
467              
468             This function compares schemas of two DBI databases. You supply two `DBI`
469             database handles and this function will return a hash:
470              
471             {
472             # list of tables found in first db but missing in second
473             deleted_tables => ['table1', ...],
474              
475             # list of tables found only in the second db
476             added_tables => ['table2', ...],
477              
478             # list of modified tables, with details for each
479             modified_tables => {
480             table3 => {
481             deleted_columns => [...],
482             added_columns => [...],
483             modified_columns => {
484             column1 => {
485             old_type => '...',
486             new_type => '...',
487             ...
488             },
489             },
490             },
491             },
492             }
493              
494             _
495             args => {
496             %diff_db_args,
497             },
498             args_as => "array",
499             result_naked => 1,
500             "x.perinci.sub.wrapper.disable_validate_args" => 1,
501             };
502             sub diff_db_schema {
503 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   8  
  1 50       138  
  5 50       78889  
  5         11  
  5         8  
  5         41  
  5         54  
  5         15  
  0         0  
504 1 50 0 1   7 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       418  
  5 50       9  
  5         9  
  5         38  
  5         15  
  0         0  
505              
506 5         16 my @tables1 = list_tables($dbh1);
507 5         16 my @tables2 = list_tables($dbh2);
508              
509 5         36 log_trace("tables1: %s ...", \@tables1);
510 5         23 log_trace("tables2: %s ...", \@tables2);
511              
512 5         16 my (@added, @deleted, %modified);
513 5         10 for my $t (@tables1) {
514 10 100       19 if (grep {$_ eq $t} @tables2) {
  20         54  
515             #$log->tracef("Comparing table %s ...", $_);
516 7         20 my $tres = _diff_table_schema($dbh1, $dbh2, $t, $t);
517 7 100       25 $modified{$t} = $tres if %$tres;
518             } else {
519 3         7 push @deleted, $t;
520             }
521             }
522 5         10 for my $t (@tables2) {
523 10 100       17 if (grep {$_ eq $t} @tables1) {
  20         47  
524             } else {
525 3         8 push @added, $t;
526             }
527             }
528              
529 5         10 my $res = {};
530 5 100       15 $res->{added_tables} = \@added if @added;
531 5 100       13 $res->{deleted_tables} = \@deleted if @deleted;
532 5 100       13 $res->{modified_tables} = \%modified if %modified;
533 5         26 $res;
534             }
535              
536             $SPEC{db_schema_eq} = {
537             v => 1.1,
538             summary => 'Return true if two DBI databases have the same schema',
539             description => <<'_',
540              
541             This is basically just a shortcut for:
542              
543             my $res = diff_db_schema(...);
544             !%$res;
545              
546             _
547             args => {
548             %diff_db_args,
549             },
550             args_as => "array",
551             result_naked => 1,
552             "x.perinci.sub.wrapper.disable_validate_args" => 1,
553             };
554             sub db_schema_eq {
555 2     2 1 1988 my $res = diff_db_schema(@_);
556 2         16 !%$res;
557             }
558              
559             1;
560             # ABSTRACT: Compare schema of two DBI databases
561              
562             __END__