Add (now passing) test with file from RT#70473
[dbsrgits/SQL-Translator.git] / lib / SQL / Translator / Diff.pm
CommitLineData
51ffe5ee 1package SQL::Translator::Diff;
4d438549 2
11ad2df9 3
51ffe5ee 4## SQLT schema diffing code
5use strict;
6use warnings;
11ad2df9 7
da5a1bae 8use Data::Dumper;
1f5b2625 9use Carp::Clan qw/^SQL::Translator/;
51ffe5ee 10use SQL::Translator::Schema::Constants;
11
4d438549 12use base 'Class::Accessor::Fast';
13
14# Input/option accessors
15__PACKAGE__->mk_accessors(qw/
16 ignore_index_names ignore_constraint_names ignore_view_sql
ea93df61 17 ignore_proc_sql output_db source_schema target_schema
1f5b2625 18 case_insensitive no_batch_alters ignore_missing_methods producer_args
4d438549 19/);
20
21my @diff_arrays = qw/
22 tables_to_drop
23 tables_to_create
24/;
25
26my @diff_hash_keys = qw/
27 constraints_to_create
28 constraints_to_drop
29 indexes_to_create
30 indexes_to_drop
31 fields_to_create
32 fields_to_alter
33 fields_to_rename
34 fields_to_drop
35 table_options
46bf5655 36 table_renamed_from
4d438549 37/;
38
39__PACKAGE__->mk_accessors(@diff_arrays, 'table_diff_hash');
40
41sub schema_diff {
da5a1bae 42 # use Data::Dumper;
43 ## we are getting instructions on how to turn the source into the target
44 ## source == original, target == new (hmm, if I need to comment this, should I rename the vars again ??)
45 ## _schema isa SQL::Translator::Schema
46 ## _db is the name of the producer/db it came out of/into
47 ## results are formatted to the source preferences
48
e30b71b8 49 my ($source_schema, $source_db, $target_schema, $output_db, $options) = @_;
4d438549 50 $options ||= {};
da5a1bae 51
4d438549 52 my $obj = SQL::Translator::Diff->new( {
53 %$options,
54 source_schema => $source_schema,
4d438549 55 target_schema => $target_schema,
e30b71b8 56 output_db => $output_db
4d438549 57 } );
da5a1bae 58
4d438549 59 $obj->compute_differences->produce_diff_sql;
60}
51ffe5ee 61
4d438549 62sub new {
63 my ($class, $values) = @_;
64 $values->{$_} ||= [] foreach @diff_arrays;
65 $values->{table_diff_hash} = {};
66
1f5b2625 67 $values->{producer_args} ||= {};
68 if ($values->{producer_options}) {
69 carp 'producer_options is deprecated. Please use producer_args';
70 $values->{producer_args} = { %{$values->{producer_options}}, %{$values->{producer_args}} };
71 }
4d438549 72 $values->{output_db} ||= $values->{source_db};
73 return $class->SUPER::new($values);
74}
75
76sub compute_differences {
77 my ($self) = @_;
51ffe5ee 78
4d438549 79 my $target_schema = $self->target_schema;
80 my $source_schema = $self->source_schema;
81
9ab59f87 82 my $producer_class = "SQL::Translator::Producer::@{[$self->output_db]}";
83 eval "require $producer_class";
84 die $@ if $@;
85
86 if (my $preprocess = $producer_class->can('preprocess_schema')) {
934e1b39 87 $preprocess->($source_schema);
88 $preprocess->($target_schema);
9ab59f87 89 }
90
46bf5655 91 my %src_tables_checked = ();
4d438549 92 my @tar_tables = sort { $a->name cmp $b->name } $target_schema->get_tables;
da5a1bae 93 ## do original/source tables exist in target?
4d438549 94 for my $tar_table ( @tar_tables ) {
da5a1bae 95 my $tar_table_name = $tar_table->name;
46bf5655 96
97 my $src_table;
98
99 $self->table_diff_hash->{$tar_table_name} = {
100 map {$_ => [] } @diff_hash_keys
101 };
102
103 if (my $old_name = $tar_table->extra('renamed_from')) {
104 $src_table = $source_schema->get_table( $old_name, $self->case_insensitive );
105 if ($src_table) {
106 $self->table_diff_hash->{$tar_table_name}{table_renamed_from} = [ [$src_table, $tar_table] ];
107 } else {
108 delete $tar_table->extra->{renamed_from};
1f5b2625 109 carp qq#Renamed table can't find old table "$old_name" for renamed table\n#;
46bf5655 110 }
111 } else {
112 $src_table = $source_schema->get_table( $tar_table_name, $self->case_insensitive );
113 }
da5a1bae 114
da5a1bae 115 unless ( $src_table ) {
da5a1bae 116 ## table is new
ea93df61 117 ## add table(s) later.
4d438549 118 push @{$self->tables_to_create}, $tar_table;
da5a1bae 119 next;
120 }
51ffe5ee 121
46bf5655 122 my $src_table_name = $src_table->name;
123 $src_table_name = lc $src_table_name if $self->case_insensitive;
124 $src_tables_checked{$src_table_name} = 1;
125
4d438549 126
127 $self->diff_table_options($src_table, $tar_table);
51ffe5ee 128
da5a1bae 129 ## Compare fields, their types, defaults, sizes etc etc
4d438549 130 $self->diff_table_fields($src_table, $tar_table);
51ffe5ee 131
4d438549 132 $self->diff_table_indexes($src_table, $tar_table);
133 $self->diff_table_constraints($src_table, $tar_table);
51ffe5ee 134
4d438549 135 } # end of target_schema->get_tables loop
51ffe5ee 136
4d438549 137 for my $src_table ( $source_schema->get_tables ) {
138 my $src_table_name = $src_table->name;
51ffe5ee 139
46bf5655 140 $src_table_name = lc $src_table_name if $self->case_insensitive;
51ffe5ee 141
46bf5655 142 push @{ $self->tables_to_drop}, $src_table
143 unless $src_tables_checked{$src_table_name};
4d438549 144 }
145
146 return $self;
147}
148
149sub produce_diff_sql {
150 my ($self) = @_;
151
152 my $target_schema = $self->target_schema;
153 my $source_schema = $self->source_schema;
154 my $tar_name = $target_schema->name;
155 my $src_name = $source_schema->name;
156
157 my $producer_class = "SQL::Translator::Producer::@{[$self->output_db]}";
158 eval "require $producer_class";
159 die $@ if $@;
160
161 # Map of name we store under => producer method name
162 my %func_map = (
163 constraints_to_create => 'alter_create_constraint',
164 constraints_to_drop => 'alter_drop_constraint',
165 indexes_to_create => 'alter_create_index',
166 indexes_to_drop => 'alter_drop_index',
167 fields_to_create => 'add_field',
168 fields_to_alter => 'alter_field',
169 fields_to_rename => 'rename_field',
170 fields_to_drop => 'drop_field',
46bf5655 171 table_options => 'alter_table',
172 table_renamed_from => 'rename_table',
4d438549 173 );
174 my @diffs;
ea93df61 175
176 if (!$self->no_batch_alters &&
177 (my $batch_alter = $producer_class->can('batch_alter_table')) )
4d438549 178 {
179 # Good - Producer supports batch altering of tables.
180 foreach my $table ( sort keys %{$self->table_diff_hash} ) {
181 my $tar_table = $target_schema->get_table($table)
182 || $source_schema->get_table($table);
da5a1bae 183
4d438549 184 push @diffs, $batch_alter->($tar_table,
185 { map {
186 $func_map{$_} => $self->table_diff_hash->{$table}{$_}
ea93df61 187 } keys %func_map
188 },
1f5b2625 189 $self->producer_args
4d438549 190 );
da5a1bae 191 }
4d438549 192 } else {
51ffe5ee 193
46bf5655 194 # If we have any table renames we need to do those first;
4d438549 195 my %flattened_diffs;
196 foreach my $table ( sort keys %{$self->table_diff_hash} ) {
197 my $table_diff = $self->table_diff_hash->{$table};
198 for (@diff_hash_keys) {
199 push( @{ $flattened_diffs{ $func_map{$_} } ||= [] }, @{ $table_diff->{$_} } );
51ffe5ee 200 }
4d438549 201 }
da5a1bae 202
4d438549 203 push @diffs, map( {
46bf5655 204 if (@{ $flattened_diffs{$_} || [] }) {
4d438549 205 my $meth = $producer_class->can($_);
ea93df61 206
207 $meth ? map {
1f5b2625 208 my $sql = $meth->( (ref $_ eq 'ARRAY' ? @$_ : $_), $self->producer_args );
ea93df61 209 $sql ? ("$sql") : ();
7467c458 210 } @{ $flattened_diffs{$_} }
4d438549 211 : $self->ignore_missing_methods
212 ? "-- $producer_class cant $_"
213 : die "$producer_class cant $_";
214 } else { () }
51ffe5ee 215
46bf5655 216 } qw/rename_table
217 alter_drop_constraint
4d438549 218 alter_drop_index
219 drop_field
220 add_field
221 alter_field
222 rename_field
223 alter_create_index
224 alter_create_constraint
225 alter_table/),
da5a1bae 226 }
51ffe5ee 227
4d438549 228 if (my @tables = @{ $self->tables_to_create } ) {
229 my $translator = new SQL::Translator(
230 producer_type => $self->output_db,
231 add_drop_table => 0,
232 no_comments => 1,
233 # TODO: sort out options
1f5b2625 234 %{ $self->producer_args }
4d438549 235 );
f9c96971 236 $translator->producer_args->{no_transaction} = 1;
4d438549 237 my $schema = $translator->schema;
51ffe5ee 238
4d438549 239 $schema->add_table($_) for @tables;
51ffe5ee 240
ea93df61 241 unshift @diffs,
4d438549 242 # Remove begin/commit here, since we wrap everything in one.
24d9fe69 243 grep { $_ !~ /^(?:COMMIT|START(?: TRANSACTION)?|BEGIN(?: TRANSACTION)?)/ } $producer_class->can('produce')->($translator);
7ac784ff 244 }
245
4d438549 246 if (my @tables_to_drop = @{ $self->{tables_to_drop} || []} ) {
247 my $meth = $producer_class->can('drop_table');
ea93df61 248
1f5b2625 249 push @diffs, $meth ? ( map { $meth->($_, $self->producer_args) } @tables_to_drop)
4d438549 250 : $self->ignore_missing_methods
251 ? "-- $producer_class cant drop_table"
252 : die "$producer_class cant drop_table";
253 }
7ac784ff 254
4d438549 255 if (@diffs) {
24d9fe69 256 unshift @diffs, "BEGIN";
257 push @diffs, "\nCOMMIT";
4d438549 258 } else {
24d9fe69 259 @diffs = ("-- No differences found");
51ffe5ee 260 }
51ffe5ee 261
da5a1bae 262 if ( @diffs ) {
3406fd5b 263 if ( $self->output_db !~ /^(?:MySQL|SQLite|PostgreSQL)$/ ) {
e30b71b8 264 unshift(@diffs, "-- Output database @{[$self->output_db]} is untested/unsupported!!!");
51ffe5ee 265 }
c12a81eb 266
ea93df61 267 my @return =
c12a81eb 268 map { $_ ? ( $_ =~ /;$/xms ? $_ : "$_;\n\n" ) : "\n" }
269 ("-- Convert schema '$src_name' to '$tar_name':", @diffs);
270
271 return wantarray ? @return : join('', @return);
51ffe5ee 272 }
da5a1bae 273 return undef;
4d438549 274
275}
276
277sub diff_table_indexes {
278 my ($self, $src_table, $tar_table) = @_;
279
280 my (%checked_indices);
281 INDEX_CREATE:
282 for my $i_tar ( $tar_table->get_indices ) {
283 for my $i_src ( $src_table->get_indices ) {
284 if ( $i_tar->equals($i_src, $self->case_insensitive, $self->ignore_index_names) ) {
285 $checked_indices{$i_src} = 1;
286 next INDEX_CREATE;
287 }
288 }
289 push @{$self->table_diff_hash->{$tar_table}{indexes_to_create}}, $i_tar;
290 }
291
292 INDEX_DROP:
293 for my $i_src ( $src_table->get_indices ) {
294 next if !$self->ignore_index_names && $checked_indices{$i_src};
295 for my $i_tar ( $tar_table->get_indices ) {
296 next INDEX_DROP if $i_src->equals($i_tar, $self->case_insensitive, $self->ignore_index_names);
297 }
298 push @{$self->table_diff_hash->{$tar_table}{indexes_to_drop}}, $i_src;
299 }
300}
301
302
303sub diff_table_constraints {
304 my ($self, $src_table, $tar_table) = @_;
305
306 my(%checked_constraints);
307 CONSTRAINT_CREATE:
308 for my $c_tar ( $tar_table->get_constraints ) {
309 for my $c_src ( $src_table->get_constraints ) {
46bf5655 310
311 # This is a bit of a hack - needed for renaming tables to work
312 local $c_src->{table} = $tar_table;
313
4d438549 314 if ( $c_tar->equals($c_src, $self->case_insensitive, $self->ignore_constraint_names) ) {
315 $checked_constraints{$c_src} = 1;
316 next CONSTRAINT_CREATE;
317 }
318 }
319 push @{ $self->table_diff_hash->{$tar_table}{constraints_to_create} }, $c_tar;
320 }
321
322
323 CONSTRAINT_DROP:
324 for my $c_src ( $src_table->get_constraints ) {
46bf5655 325
326 # This is a bit of a hack - needed for renaming tables to work
327 local $c_src->{table} = $tar_table;
328
4d438549 329 next if !$self->ignore_constraint_names && $checked_constraints{$c_src};
330 for my $c_tar ( $tar_table->get_constraints ) {
331 next CONSTRAINT_DROP if $c_src->equals($c_tar, $self->case_insensitive, $self->ignore_constraint_names);
332 }
333
334 push @{ $self->table_diff_hash->{$tar_table}{constraints_to_drop} }, $c_src;
51ffe5ee 335 }
51ffe5ee 336
4d438549 337}
338
339sub diff_table_fields {
340 my ($self, $src_table, $tar_table) = @_;
341
342 # List of ones ew've renamed from so we dont drop them
343 my %renamed_source_fields;
344
345 for my $tar_table_field ( $tar_table->get_fields ) {
346 my $f_tar_name = $tar_table_field->name;
347
348 if (my $old_name = $tar_table_field->extra->{renamed_from}) {
349 my $src_table_field = $src_table->get_field( $old_name, $self->case_insensitive );
46bf5655 350 unless ($src_table_field) {
1f5b2625 351 carp qq#Renamed column can't find old column "@{[$src_table->name]}.$old_name" for renamed column\n#;
46bf5655 352 delete $tar_table_field->extra->{renamed_from};
353 } else {
354 push @{$self->table_diff_hash->{$tar_table}{fields_to_rename} }, [ $src_table_field, $tar_table_field ];
355 $renamed_source_fields{$old_name} = 1;
356 next;
357 }
4d438549 358 }
359
360 my $src_table_field = $src_table->get_field( $f_tar_name, $self->case_insensitive );
361
362 unless ( $src_table_field ) {
363 push @{$self->table_diff_hash->{$tar_table}{fields_to_create}}, $tar_table_field;
364 next;
365 }
366
ea93df61 367 # field exists, something changed. This is a bit complex. Parsers can
07d6e5f7 368 # normalize types, but only some of them do, so compare the normalized and
369 # parsed types for each field to each other
370 if ( !$tar_table_field->equals($src_table_field, $self->case_insensitive) &&
ea93df61 371 !$tar_table_field->equals($src_table_field->parsed_field, $self->case_insensitive) &&
372 !$tar_table_field->parsed_field->equals($src_table_field, $self->case_insensitive) &&
07d6e5f7 373 !$tar_table_field->parsed_field->equals($src_table_field->parsed_field, $self->case_insensitive) ) {
4d438549 374
375 # Some producers might need src field to diff against
376 push @{$self->table_diff_hash->{$tar_table}{fields_to_alter}}, [ $src_table_field, $tar_table_field ];
377 next;
378 }
379 }
380
381
382 # Now check to see if any fields from src_table need to be dropped
383 for my $src_table_field ( $src_table->get_fields ) {
384 my $f_src_name = $src_table_field->name;
385 next if $renamed_source_fields{$f_src_name};
386
387 my $tar_table_field = $tar_table->get_field( $f_src_name, $self->case_insensitive );
388
389 unless ( $tar_table_field ) {
390 push @{$self->table_diff_hash->{$tar_table}{fields_to_drop}}, $src_table_field;
391 next;
392 }
393 }
394}
395
396sub diff_table_options {
397 my ($self, $src_table, $tar_table) = @_;
398
7725e1e6 399 my $cmp = sub {
400 my ($a_name, undef, $b_name, undef) = ( %$a, %$b );
401 $a_name cmp $b_name;
402 };
403 # Need to sort the options so we dont get supruious diffs.
404 my (@src_opts, @tar_opts);
405 @src_opts = sort $cmp $src_table->options;
406 @tar_opts = sort $cmp $tar_table->options;
407
4d438549 408
4d438549 409 # If there's a difference, just re-set all the options
410 push @{ $self->table_diff_hash->{$tar_table}{table_options} }, $tar_table
7725e1e6 411 unless $src_table->_compare_objects( \@src_opts, \@tar_opts );
4d438549 412}
413
1f5b2625 414# support producer_options as an alias for producer_args for legacy code.
415sub producer_options {
416 my $self = shift;
417
418 return $self->producer_args( @_ );
419}
420
51ffe5ee 4211;
4d438549 422
423__END__
424
425=head1 NAME
426
10f70490 427SQL::Translator::Diff - determine differences between two schemas
4d438549 428
429=head1 DESCRIPTION
430
ea93df61 431Takes two input SQL::Translator::Schemas (or SQL files) and produces ALTER
4d438549 432statments to make them the same
433
434=head1 SNYOPSIS
435
436Simplest usage:
437
438 use SQL::Translator::Diff;
439 my $sql = SQL::Translator::Diff::schema_diff($source_schema, 'MySQL', $target_schema, 'MySQL', $options_hash)
440
441OO usage:
442
443 use SQL::Translator::Diff;
444 my $diff = SQL::Translator::Diff->new({
445 output_db => 'MySQL',
446 source_schema => $source_schema,
447 target_schema => $target_schema,
448 %$options_hash,
449 })->compute_differences->produce_diff_sql;
450
451=head1 OPTIONS
452
453=over
454
455=item B<ignore_index_names>
456
457Match indexes based on types and fields, ignoring name.
458
459=item B<ignore_constraint_names>
460
461Match constrains based on types, fields and tables, ignoring name.
462
463=item B<output_db>
464
465Which producer to use to produce the output.
466
467=item B<case_insensitive>
468
469Ignore case of table, field, index and constraint names when comparing
470
471=item B<no_batch_alters>
472
473Produce each alter as a distinct C<ALTER TABLE> statement even if the producer
474supports the ability to do all alters for a table as one statement.
475
476=item B<ignore_missing_methods>
477
478If the diff would need a method that is missing from the producer, just emit a
479comment showing the method is missing, rather than dieing with an error
480
481=back
482
483=head1 PRODUCER FUNCTIONS
484
485The following producer functions should be implemented for completeness. If
ea93df61 486any of them are needed for a given diff, but not found, an error will be
4d438549 487thrown.
488
489=over
490
491=item * C<alter_create_constraint($con)>
492
493=item * C<alter_drop_constraint($con)>
494
495=item * C<alter_create_index($idx)>
496
497=item * C<alter_drop_index($idx)>
498
499=item * C<add_field($fld)>
500
501=item * C<alter_field($old_fld, $new_fld)>
502
503=item * C<rename_field($old_fld, $new_fld)>
504
505=item * C<drop_field($fld)>
506
507=item * C<alter_table($table)>
508
509=item * C<drop_table($table)>
510
46bf5655 511=item * C<rename_table($old_table, $new_table)> (optional)
512
4d438549 513=item * C<batch_alter_table($table, $hash)> (optional)
514
ea93df61 515If the producer supports C<batch_alter_table>, it will be called with the
4d438549 516table to alter and a hash, the keys of which will be the method names listed
ea93df61 517above; values will be arrays of fields or constraints to operate on. In the
4d438549 518case of the field functions that take two arguments this will appear as a hash.
519
520I.e. the hash might look something like the following:
521
522 {
523 alter_create_constraint => [ $constraint1, $constraint2 ],
524 add_field => [ $field ],
525 alter_field => [ [$old_field, $new_field] ]
526 }
527
934e1b39 528
529=item * C<preprocess_schema($class, $schema)> (optional)
530
531C<preprocess_schema> is called by the Diff code to allow the producer to
532normalize any data it needs to first. For example, the MySQL producer uses
533this method to ensure that FK contraint names are unique.
534
535Basicaly any changes that need to be made to produce the SQL file for the
536schema should be done here, so that a diff between a parsed SQL file and (say)
537a parsed DBIx::Class::Schema object will be sane.
538
ea93df61 539(As an aside, DBIx::Class, for instance, uses the presence of a
934e1b39 540C<preprocess_schema> function on the producer to know that it can diff between
541the previous SQL file and its own internal representation. Without this method
ea93df61 542on th producer it will diff the two SQL files which is slower, but known to
934e1b39 543work better on old-style producers.)
544
545=back
546
547
4d438549 548=head1 AUTHOR
549
550Original Author(s) unknown.
551
934e1b39 552Refactor/re-write and more comprehensive tests by Ash Berlin C<< ash@cpan.org >>.
4d438549 553
554Redevelopment sponsored by Takkle Inc.
555
556=cut