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