Initial devision of sqlt-diff: Most of the functionality into a module
[dbsrgits/SQL-Translator.git] / lib / SQL / Translator / Diff.pm
1 package SQL::Translator::Diff;
2 ## SQLT schema diffing code
3 use strict;
4 use warnings;
5 use SQL::Translator::Schema::Constants;
6
7 sub schema_diff
8 {
9 #  use Data::Dumper;
10     my ($source_schema, $source_db, $target_schema, $target_db, $options) = @_;
11 #  print Data::Dumper::Dumper($target_schema);
12     my $caseopt = $options->{caseopt} || 0;
13     my $debug = $options->{debug} || 0;
14     my $trace = $options->{trace} || 0;
15
16     my $case_insensitive = $source_db =~ /SQLServer/ || $caseopt;
17
18     my $tar_name  = $target_schema->name;
19     my $src_name  = $source_schema->name;
20     my ( @new_tables, @diffs , @diffs_at_end);
21     for my $tar_table ( $target_schema->get_tables ) {
22         my $tar_table_name = $tar_table->name;
23         my $src_table      = $source_schema->get_table( $tar_table_name, $case_insensitive );
24
25         warn "TABLE '$tar_name.$tar_table_name'\n" if $debug;
26         unless ( $src_table ) {
27             warn "Couldn't find table '$tar_name.$tar_table_name' in '$src_name'\n"
28                 if $debug;
29             if ( $source_db =~ /(SQLServer|Oracle)/ ) {
30                 for my $constraint ( $tar_table->get_constraints ) {
31                     next if $constraint->type ne FOREIGN_KEY;
32                     push @diffs_at_end, "ALTER TABLE $tar_table_name ADD ".
33                         constraint_to_string($constraint, $source_db, $target_schema).";";
34                     $tar_table->drop_constraint($constraint);
35                 }
36             }
37             push @new_tables, $tar_table;
38             next;
39         }
40
41         # Go through our options
42         my $options_different = 0;
43         my %checkedOptions;
44       OPTION:
45         for my $tar_table_option_ref ( $tar_table->options ) {
46             my($key_tar, $value_tar) = %{$tar_table_option_ref};
47             for my $src_table_option_ref ( $src_table->options ) {
48                 my($key_src, $value_src) = %{$src_table_option_ref};
49                 if ( $key_tar eq $key_src ) {
50                     if ( defined $value_tar != defined $value_src ) {
51                         $options_different = 1;
52                         last OPTION;
53                     }
54                     if ( defined $value_tar && $value_tar ne $value_src ) {
55                         $options_different = 1;
56                         last OPTION;
57                     }
58                     $checkedOptions{$key_tar} = 1;
59                     next OPTION;
60                 }
61             }
62             $options_different = 1;
63             last OPTION;
64         }
65         # Go through the other table's options
66         unless ( $options_different ) {
67             for my $src_table_option_ref ( $src_table->options ) {
68                 my($key, $value) = %{$src_table_option_ref};
69                 next if $checkedOptions{$key};
70                 $options_different = 1;
71                 last;
72             }
73         }
74         # If there's a difference, just re-set all the options
75         my @diffs_table_options;
76         if ( $options_different ) {
77             my @options = ();
78             foreach my $option_ref ( $tar_table->options ) {
79                 my($key, $value) = %{$option_ref};
80                 push(@options, defined $value ? "$key=$value" : $key);
81             }
82             my $options = join(' ', @options);
83             @diffs_table_options = ("ALTER TABLE $tar_table_name $options;");
84         }
85
86         my $src_table_name = $src_table->name;
87         my(@diffs_table_adds, @diffs_table_changes);
88         for my $tar_table_field ( $tar_table->get_fields ) {
89             my $f_tar_type      = $tar_table_field->data_type;
90             my $f_tar_size      = $tar_table_field->size;
91             my $f_tar_name      = $tar_table_field->name;
92             my $f_tar_nullable  = $tar_table_field->is_nullable;
93             my $f_tar_default   = $tar_table_field->default_value;
94             my $f_tar_auto_inc  = $tar_table_field->is_auto_increment;
95             my $src_table_field     = $src_table->get_field( $f_tar_name, $case_insensitive );
96             my $f_tar_full_name = "$tar_name.$tar_table_name.$tar_table_name";
97             warn "FIELD '$f_tar_full_name'\n" if $debug;
98
99             my $f_src_full_name = "$src_name.$src_table_name.$f_tar_name";
100
101             unless ( $src_table_field ) {
102                 warn "Couldn't find field '$f_src_full_name' in '$src_table_name'\n" 
103                     if $debug;
104                 my $temp_default_value = 0;
105                 if ( $source_db =~ /SQLServer/ && 
106                      !$f_tar_nullable             && 
107                      !defined $f_tar_default ) {
108                     # SQL Server doesn't allow adding non-nullable, non-default columns
109                     # so we add it with a default value, then remove the default value
110                     $temp_default_value = 1;
111                     my(@numeric_types) = qw(decimal numeric float real int bigint smallint tinyint);
112                     $f_tar_default = grep($_ eq $f_tar_type, @numeric_types) ? 0 : '';
113                 }
114                 push @diffs_table_adds, sprintf
115                     ( "ALTER TABLE %s ADD %s%s %s%s%s%s%s%s;",
116                       $tar_table_name, $source_db =~ /Oracle/ ? '(' : '',
117                       $f_tar_name, $f_tar_type,
118                       ($f_tar_size && $f_tar_type !~ /(blob|text)$/) ? "($f_tar_size)" : '',
119                       !defined $f_tar_default ? ''
120                       : uc $f_tar_default eq 'NULL' ? ' DEFAULT NULL'
121                       : uc $f_tar_default eq 'CURRENT_TIMESTAMP' ? ' DEFAULT CURRENT_TIMESTAMP'
122                       : " DEFAULT '$f_tar_default'",
123                       $f_tar_nullable ? '' : ' NOT NULL',
124                       $f_tar_auto_inc ? ' AUTO_INCREMENT' : '',
125                       $source_db =~ /Oracle/ ? ')' : '',
126                       );
127                 if ( $temp_default_value ) {
128                     undef $f_tar_default;
129                     push @diffs_table_adds, sprintf
130                         ( <<END
131 DECLARE \@defname VARCHAR(100), \@cmd VARCHAR(1000)
132 SET \@defname = 
133 (SELECT name 
134  FROM sysobjects so JOIN sysconstraints sc
135   ON so.id = sc.constid 
136  WHERE object_name(so.parent_obj) = '%s' 
137    AND so.xtype = 'D'
138    AND sc.colid = 
139     (SELECT colid FROM syscolumns 
140      WHERE id = object_id('%s') AND 
141          name = '%s'))
142 SET \@cmd = 'ALTER TABLE %s DROP CONSTRAINT '
143 + \@defname
144 EXEC(\@cmd)
145 END
146                          , $tar_table_name, $tar_table_name, $f_tar_name, $tar_table_name,
147                         );
148                   }
149                 next;
150               }
151
152             my $f_src_type = $src_table_field->data_type;
153             my $f_src_size = $src_table_field->size || '';
154             my $f_src_nullable  = $src_table_field->is_nullable;
155             my $f_src_default   = $src_table_field->default_value;
156             my $f_src_auto_inc  = $src_table_field->is_auto_increment;
157             if ( !$tar_table_field->equals($src_table_field, $case_insensitive) ) {
158               # SQLServer timestamp fields can't be altered, so we drop and add instead
159               if ( $source_db =~ /SQLServer/ && $f_src_type eq "timestamp" ) {
160                         push @diffs_table_changes, "ALTER TABLE $tar_table_name DROP COLUMN $f_tar_name;";
161                     push @diffs_table_changes, sprintf
162                   ( "ALTER TABLE %s ADD %s%s %s%s%s%s%s%s;",
163                     $tar_table_name, $source_db =~ /Oracle/ ? '(' : '',
164                     $f_tar_name, $f_tar_type,
165                     ($f_tar_size && $f_tar_type !~ /(blob|text)$/) ? "($f_tar_size)" : '',
166                     !defined $f_tar_default ? ''
167                     : uc $f_tar_default eq 'NULL' ? ' DEFAULT NULL'
168                     : uc $f_tar_default eq 'CURRENT_TIMESTAMP' ? ' DEFAULT CURRENT_TIMESTAMP'
169                     : " DEFAULT '$f_tar_default'",
170                         $f_tar_nullable ? '' : ' NOT NULL',
171                         $f_tar_auto_inc ? ' AUTO_INCREMENT' : '',
172                         $source_db =~ /Oracle/ ? ')' : '',
173                   );
174                     next;
175               }
176
177               my $changeText = $source_db =~ /SQLServer/ ? 'ALTER COLUMN' :
178                                 $source_db =~ /Oracle/ ? 'MODIFY (' : 'CHANGE';
179               my $nullText = $f_tar_nullable ? '' : ' NOT NULL';
180               $nullText = '' if $source_db =~ /Oracle/ && $f_tar_nullable == $f_src_nullable;
181               push @diffs_table_changes, sprintf
182                 ( "ALTER TABLE %s %s %s%s %s%s%s%s%s%s;",
183                   $tar_table_name, $changeText,
184                   $f_tar_name, $source_db =~ /MySQL/ ? " $f_tar_name" : '',
185                   $f_tar_type, ($f_tar_size && $f_tar_type !~ /(blob|text)$/) ? "($f_tar_size)" : '',
186                   $nullText,
187                   !defined $f_tar_default || $source_db =~ /SQLServer/ ? ''
188                   : uc $f_tar_default eq 'NULL' ? ' DEFAULT NULL'
189                   : uc $f_tar_default eq 'CURRENT_TIMESTAMP' ? ' DEFAULT CURRENT_TIMESTAMP'
190                   : " DEFAULT '$f_tar_default'",
191                   $f_tar_auto_inc ? ' AUTO_INCREMENT' : '',
192                   $source_db =~ /Oracle/ ? ')' : '',
193                 );
194               if ( defined $f_tar_default && $source_db =~ /SQLServer/ ) {
195                 # Adding a column with a default value for SQL Server means adding a 
196                 # constraint and setting existing NULLs to the default value
197                 push @diffs_table_changes, sprintf
198                   ( "ALTER TABLE %s ADD CONSTRAINT DF_%s_%s %s FOR %s;",
199                     $tar_table_name, $tar_table_name, $f_tar_name, uc $f_tar_default eq 'NULL' ? 'DEFAULT NULL'
200                     : uc $f_tar_default eq 'CURRENT_TIMESTAMP' ? 'DEFAULT CURRENT_TIMESTAMP'
201                         : "DEFAULT '$f_tar_default'", $f_tar_name,
202                   );
203                 push @diffs_table_changes, sprintf
204                   ( "UPDATE %s SET %s = %s WHERE %s IS NULL;",
205                     $tar_table_name, $f_tar_name, uc $f_tar_default eq 'NULL' ? 'NULL'
206                         : uc $f_tar_default eq 'CURRENT_TIMESTAMP' ? 'CURRENT_TIMESTAMP'
207                         : "'$f_tar_default'", $f_tar_name,
208                   );
209               }
210             }
211           }
212
213         my(%checked_indices, @diffs_index_creates, @diffs_index_drops);
214       INDEX:
215         for my $i_tar ( $tar_table->get_indices ) {
216           for my $i_src ( $src_table->get_indices ) {
217                         if ( $i_tar->equals($i_src, $case_insensitive) ) {
218               $checked_indices{$i_src} = 1;
219               next INDEX;
220                         }
221           }
222           push @diffs_index_creates, sprintf
223             ( "CREATE %sINDEX%s ON %s (%s);",
224               $i_tar->type eq NORMAL ? '' : $i_tar->type." ",
225               $i_tar->name ? " ".$i_tar->name : '',
226               $tar_table_name,
227               join(",", $i_tar->fields),
228             );
229         }
230       INDEX2:
231         for my $i_src ( $src_table->get_indices ) {
232           next if $checked_indices{$i_src};
233           for my $i_tar ( $tar_table->get_indices ) {
234                         next INDEX2 if $i_src->equals($i_tar, $case_insensitive);
235           }
236           $source_db =~ /SQLServer/
237                         ? push @diffs_index_drops, "DROP INDEX $tar_table_name.".$i_src->name.";"
238               : push @diffs_index_drops, "DROP INDEX ".$i_src->name." on $tar_table_name;";
239         }
240
241         my(%checked_constraints, @diffs_constraint_drops);
242       CONSTRAINT:
243         for my $c_tar ( $tar_table->get_constraints ) {
244           next if $target_db =~ /Oracle/ && 
245             $c_tar->type eq UNIQUE && $c_tar->name =~ /^SYS_/i;
246           for my $c_src ( $src_table->get_constraints ) {
247                         if ( $c_tar->equals($c_src, $case_insensitive) ) {
248               $checked_constraints{$c_src} = 1;
249               next CONSTRAINT;
250                         }
251           }
252           push @diffs_at_end, "ALTER TABLE $tar_table_name ADD ".
253                         constraint_to_string($c_tar, $source_db, $target_schema).";";
254         }
255       CONSTRAINT2:
256         for my $c_src ( $src_table->get_constraints ) {
257           next if $checked_constraints{$c_src};
258           for my $c_tar ( $tar_table->get_constraints ) {
259                         next CONSTRAINT2 if $c_src->equals($c_tar, $case_insensitive);
260           }
261           if ( $c_src->type eq UNIQUE ) {
262                         push @diffs_constraint_drops, "ALTER TABLE $tar_table_name DROP INDEX ".
263               $c_src->name.";";
264           } elsif ( $source_db =~ /SQLServer/ ) {
265                         push @diffs_constraint_drops, "ALTER TABLE $tar_table_name DROP ".$c_src->name.";";
266           } else {
267                         push @diffs_constraint_drops, "ALTER TABLE $tar_table_name DROP ".$c_src->type.
268               ($c_src->type eq FOREIGN_KEY ? " ".$c_src->name : '').";";
269           }
270         }
271
272         push @diffs, @diffs_index_drops, @diffs_constraint_drops,
273           @diffs_table_options, @diffs_table_adds,
274             @diffs_table_changes, @diffs_index_creates;
275       }
276
277     for my $src_table ( $source_schema->get_tables ) {
278       my $src_table_name = $src_table->name;
279       my $tar_table      = $target_schema->get_table( $src_table_name, $source_db =~ /SQLServer/ );
280
281       unless ( $tar_table ) {
282         if ( $source_db =~ /SQLServer/ ) {
283           for my $constraint ( $src_table->get_constraints ) {
284             next if $constraint->type eq PRIMARY_KEY;
285             push @diffs, "ALTER TABLE $src_table_name DROP ".$constraint->name.";";
286           }
287         }
288         push @diffs_at_end, "DROP TABLE $src_table_name;";
289         next;
290       }
291
292       for my $src_table_field ( $src_table->get_fields ) {
293         my $f_src_name      = $src_table_field->name;
294         my $tar_table_field     = $tar_table->get_field( $f_src_name );
295         unless ( $tar_table_field ) {
296           my $modifier = $source_db =~ /SQLServer/ ? "COLUMN " : '';
297           push @diffs, "ALTER TABLE $src_table_name DROP $modifier$f_src_name;";
298         }
299       }
300     }
301
302     if ( @new_tables ) {
303       my $dummytr = SQL::Translator->new;
304       $dummytr->schema->add_table( $_ ) for @new_tables;
305       my $producer = $dummytr->producer( $source_db );
306       unshift @diffs, $producer->( $dummytr );
307     }
308     push(@diffs, @diffs_at_end);
309
310     if ( @diffs ) {
311         return join( "\n", 
312                   "-- Convert schema '$src_name' to '$tar_name':\n", @diffs, "\n"
313                 );
314     }
315     return undef;
316 }
317
318 sub constraint_to_string {
319   my $c = shift;
320   my $source_db = shift;
321   my $schema = shift or die "No schema given";
322   my @fields = $c->field_names or return '';
323
324   if ( $c->type eq PRIMARY_KEY ) {
325     if ( $source_db =~ /Oracle/ ) {
326       return (defined $c->name ? 'CONSTRAINT '.$c->name.' ' : '') .
327         'PRIMARY KEY (' . join(', ', @fields). ')';
328     } else {
329       return 'PRIMARY KEY (' . join(', ', @fields). ')';
330     }
331   }
332   elsif ( $c->type eq UNIQUE ) {
333     if ( $source_db =~ /Oracle/ ) {
334       return (defined $c->name ? 'CONSTRAINT '.$c->name.' ' : '') .
335         'UNIQUE (' . join(', ', @fields). ')';
336     } else {
337       return 'UNIQUE '.
338         (defined $c->name ? $c->name.' ' : '').
339           '(' . join(', ', @fields). ')';
340     }
341   }
342   elsif ( $c->type eq FOREIGN_KEY ) {
343     my $def = join(' ', 
344                    map { $_ || () } 'CONSTRAINT', $c->name, 'FOREIGN KEY' 
345                   );
346
347     $def .= ' (' . join( ', ', @fields ) . ')';
348
349     $def .= ' REFERENCES ' . $c->reference_table;
350
351     my @rfields = map { $_ || () } $c->reference_fields;
352     unless ( @rfields ) {
353       my $rtable_name = $c->reference_table;
354       if ( my $ref_table = $schema->get_table( $rtable_name ) ) {
355         push @rfields, $ref_table->primary_key;
356       }
357       else {
358         warn "Can't find reference table '$rtable_name' " .
359           "in schema\n";
360       }
361     }
362
363     if ( @rfields ) {
364       $def .= ' (' . join( ', ', @rfields ) . ')';
365     }
366     else {
367       warn "FK constraint on " . 'some table' . '.' .
368         join('', @fields) . " has no reference fields\n";
369     }
370
371     if ( $c->match_type ) {
372       $def .= ' MATCH ' . 
373         ( $c->match_type =~ /full/i ) ? 'FULL' : 'PARTIAL';
374     }
375
376     if ( $c->on_delete ) {
377       $def .= ' ON DELETE '.join( ' ', $c->on_delete );
378     }
379
380     if ( $c->on_update ) {
381       $def .= ' ON UPDATE '.join( ' ', $c->on_update );
382     }
383
384     return $def;
385   }
386 }
387
388 1;