Added grammar for "REFERENCES" (foreign keys).
[dbsrgits/SQL-Translator.git] / bin / auto-dia.pl
CommitLineData
dd9550a4 1#!/usr/bin/perl
2
14655604 3# $Id: auto-dia.pl,v 1.7 2003-04-02 01:45:45 kycl4rk Exp $
dd9550a4 4
5=head1 NAME
6
7auto-dia.pl - Automatically create a diagram from a database schema
8
9=head1 SYNOPSIS
10
11 ./auto-dia.pl -d|--db=db_parser [options] schema.sql
12
13 Options:
14
14655604 15 -o|--output Output file name (default STDOUT)
16 -i|--image Output image type (default PNG)
17 -t|--title Title to give schema
18 -c|--cols Number of columns
19 -n|--no-lines Don't draw lines
20 -f|--font-size "small," "medium," "large," or "huge"
21 (default "medium")
22 --color Add colors
23
24 --natural-join Perform natural joins
25 --natural-join-pk-only Perform natural joins from primary keys only
26 -s|--skip Fields to skip in natural joins
dd9550a4 27
28=head1 DESCRIPTION
29
30This script will create a picture of your schema. Only the database
aa0a19b9 31driver argument (for SQL::Translator) is required. If no output file
32name is given, then image will be printed to STDOUT, so you should
33redirect the output into a file.
dd9550a4 34
14655604 35The default action is to assume the presence of foreign key
36relationships defined via "REFERNCES" or "FOREIGN KEY" constraints on
37the tables. If you are parsing the schema of a file that does not
38have these, you will find the natural join options helpful. With
39natural joins, like-named fields will be considered foreign keys.
40This can prove too permissive, however, as you probably don't want a
41field called "name" to be considered a foreign key, so you could
42include it in the "skip" option, and all fields called "name" will be
43excluded from natural joins. A more efficient method, however, might
44be to simply deduce the foriegn keys from primary keys to other fields
45named the same in other tables. Use the "natural-join-pk-only" option
46to acheive this.
47
dd9550a4 48=cut
49
50use strict;
51use Getopt::Long;
52use GD;
53use Pod::Usage;
54use SQL::Translator;
55
14655604 56my $VERSION = (qw$Revision: 1.7 $)[-1];
22cb6863 57
58use constant VALID_FONT_SIZE => {
59 small => 1,
60 medium => 1,
61 large => 1,
62 huge => 1,
63};
dd9550a4 64
aa0a19b9 65#
66# Get arguments.
67#
14655604 68my (
69 $out_file, $image_type, $db_driver, $title, $no_columns,
70 $no_lines, $font_size, $add_color,
71 $natural_join, $join_pk_only, $skip_fields
72);
73
dd9550a4 74GetOptions(
14655604 75 'd|db=s' => \$db_driver,
76 'o|output:s' => \$out_file,
77 'i|image:s' => \$image_type,
78 't|title:s' => \$title,
79 'c|columns:i' => \$no_columns,
80 'n|no-lines' => \$no_lines,
81 'f|font-size:s' => \$font_size,
82 'color' => \$add_color,
83 'natural-join' => \$natural_join,
84 'natural-join-pk-only' => \$join_pk_only,
85 's|skip:s' => \$skip_fields,
dd9550a4 86) or die pod2usage;
87my $file = shift @ARGV or pod2usage( -message => 'No input file' );
88
89pod2usage( -message => "No db driver specified" ) unless $db_driver;
14655604 90$image_type = $image_type ? lc $image_type : 'png';
91$title ||= $file;
92$font_size = 'medium' unless VALID_FONT_SIZE->{ $font_size };
93my %skip = map { $_, 1 } split ( /,/, $skip_fields );
94$natural_join ||= $join_pk_only;
dd9550a4 95
aa0a19b9 96#
97# Parse file.
98#
dd9550a4 99my $t = SQL::Translator->new( parser => $db_driver, producer => 'Raw' );
100my $data = $t->translate( $file ) or die $t->error;
dd9550a4 101
aa0a19b9 102#
103# Layout the image.
104#
22cb6863 105my $font =
106 $font_size eq 'small' ? gdTinyFont :
107 $font_size eq 'medium' ? gdSmallFont :
108 $font_size eq 'large' ? gdLargeFont : gdGiantFont;
dd9550a4 109my $no_tables = scalar keys %$data;
110$no_columns ||= sprintf( "%.0f", sqrt( $no_tables ) + .5 );
111my $no_per_col = sprintf( "%.0f", $no_tables/$no_columns + .5 );
dd9550a4 112
7256996b 113my @shapes;
114my ( $max_x, $max_y ); # the furthest x and y used
115my $orig_y = 40; # used to reset y for each column
116my ( $x, $y ) = (20, $orig_y); # where to start
117my $cur_col = 1; # the current column
118my $no_this_col = 0; # number of tables in current column
119my $this_col_x = $x; # current column's x
120my $gutter = 30; # distance b/w columns
14655604 121my %nj_registry; # for locations of fields for natural joins
122my @fk_registry; # for locations of fields for foreign keys
7256996b 123my %table_x; # for max x of each table
124my $field_no; # counter to give distinct no. to each field
b18a0413 125my %legend;
dd9550a4 126
127for my $table (
128 map { $_->[1] }
129 sort { $a->[0] <=> $b->[0] }
130 map { [ $_->{'order'}, $_ ] }
131 values %$data
132) {
133 my $table_name = $table->{'table_name'};
134 my $top = $y;
aa0a19b9 135 push @shapes, [ 'string', $font, $this_col_x, $y, $table_name, 'black' ];
dd9550a4 136
137 $y += $font->height + 2;
138 my $below_table_name = $y;
139 $y += 2;
140 my $this_max_x = $this_col_x + ($font->width * length($table_name));
141
142 my @fields =
143 map { $_->[1] }
144 sort { $a->[0] <=> $b->[0] }
145 map { [ $_->{'order'}, $_ ] }
146 values %{ $table->{'fields'} };
147
b18a0413 148 my ( %pk, %unique );
dd9550a4 149 for my $index ( @{ $table->{'indices'} || [] } ) {
dd9550a4 150 my @fields = @{ $index->{'fields'} || [] } or next;
b18a0413 151 if ( $index->{'type'} eq 'primary_key' ) {
152 $pk{ $_ } = 1 for @fields;
153 }
154 elsif ( $index->{'type'} eq 'unique' ) {
155 $unique{ $_ } = 1 for @fields;
156 }
dd9550a4 157 }
158
159 my ( @fld_desc, $max_name );
160 for my $f ( @fields ) {
b18a0413 161 my $name = $f->{'name'} or next;
162 my $is_pk = $pk{ $name };
163 my $is_unique = $unique{ $name };
164 if ( $is_pk ) {
165 $name .= ' *';
166 $legend{'Primary key'} = '*';
167 }
168 elsif ( $is_unique ) {
169 $name .= ' [U]';
170 $legend{'Unique constraint'} = '[U]';
171 }
dd9550a4 172
173 my $size = @{ $f->{'size'} || [] }
174 ? '(' . join( ',', @{ $f->{'size'} } ) . ')'
175 : '';
176 my $desc = join( ' ', map { $_ || () } $f->{'data_type'}, $size );
177
178 my $nlen = length $name;
179 $max_name = $nlen if $nlen > $max_name;
7256996b 180 push @fld_desc, [ $name, $desc, $f->{'name'}, $is_pk ];
dd9550a4 181 }
182
183 $max_name += 4;
184 for my $fld_desc ( @fld_desc ) {
7256996b 185 my ( $name, $desc, $orig_name, $is_pk ) = @$fld_desc;
dd9550a4 186 my $diff = $max_name - length $name;
187 $name .= ' ' x $diff;
188 $desc = $name . $desc;
189
aa0a19b9 190 push @shapes, [ 'string', $font, $this_col_x, $y, $desc, 'black' ];
dd9550a4 191 $y += $font->height + 2;
192 my $length = $this_col_x + ( $font->width * length( $desc ) );
193 $this_max_x = $length if $length > $this_max_x;
aa0a19b9 194
14655604 195 my $constraints = $table->{'fields'}{ $orig_name }{'constraints'};
196
197 if ( $natural_join && !$skip{ $orig_name } ) {
198 push @{ $nj_registry{ $orig_name } }, $table_name;
aa0a19b9 199 }
14655604 200 elsif ( @{ $constraints || [] } ) {
201 for my $constraint ( @$constraints ) {
202 next unless $constraint->{'type'} eq 'foreign_key';
203 for my $fk_field (
204 @{ $constraint->{'reference_fields'} || [] }
205 ) {
206 push @fk_registry, [
207 [ $constraint->{'reference_table'}, $fk_field ],
208 [ $table_name, $orig_name ],
209 ];
210 }
211 }
212 }
213
214 my $y_link = $y - $font->height * .75;
215 $table->{'fields'}{ $orig_name }{'coords'} = {
216 left => [ $this_col_x - 2, $y_link ],
217 right => [ $length, $y_link ],
218 table => $table_name,
219 field_no => ++$field_no,
220 is_pk => $is_pk,
221 };
dd9550a4 222 }
223
224 $this_max_x += 5;
aa0a19b9 225 $table_x{ $table_name } = $this_max_x + 5;
dd9550a4 226 push @shapes, [ 'line', $this_col_x - 5, $below_table_name,
aa0a19b9 227 $this_max_x, $below_table_name, 'black' ];
d21faa48 228 my @bounds = ( $this_col_x - 5, $top - 5, $this_max_x, $y + 5 );
229 if ( $add_color ) {
230 unshift @shapes, [
231 'filledRectangle',
232 $bounds[0], $bounds[1],
233 $this_max_x, $below_table_name,
234 'khaki'
235 ];
236 unshift @shapes, [ 'filledRectangle', @bounds, 'white' ];
237 }
238 push @shapes, [ 'rectangle', @bounds, 'black' ];
dd9550a4 239 $max_x = $this_max_x if $this_max_x > $max_x;
240 $y += 25;
241
7256996b 242 if ( ++$no_this_col == $no_per_col ) { # if we've filled up this column
243 $cur_col++; # up the column number
244 $no_this_col = 0; # reset the number of tables
245 $max_x += $gutter; # push the x over for next column
246 $this_col_x = $max_x; # remember the max x for this column
247 $max_y = $y if $y > $max_y; # note the max y
248 $y = $orig_y; # reset the y for next column
dd9550a4 249 }
250}
251
252#
aa0a19b9 253# Connect the lines.
254#
255my %horz_taken;
256my %done;
257unless ( $no_lines ) {
14655604 258 my @position_bunches;
259
260 if ( $natural_join ) {
261 for my $field_name ( keys %nj_registry ) {
262 my @positions;
263 my @table_names = @{ $nj_registry{ $field_name } || [] } or next;
264 next if scalar @table_names == 1;
265
266 for my $table_name ( @table_names ) {
267 push @positions,
268 $data->{ $table_name }{'fields'}{ $field_name }{'coords'};
269 }
270
271 push @position_bunches, [ @positions ];
272 }
273 }
274 else {
275 for my $pair ( @fk_registry ) {
276 my $c1 =
277 $data->{ $pair->[0][0] }{'fields'}{ $pair->[0][1] }{'coords'};
278 my $c2 =
279 $data->{ $pair->[1][0] }{'fields'}{ $pair->[1][1] }{'coords'};
280 next unless %{ $c1 || {} } && %{ $c1 || {} };
281 push @position_bunches, [ $c1, $c2 ];
282 }
283 }
284
285 for my $bunch ( @position_bunches ) {
286 my @positions = @$bunch;
aa0a19b9 287
288 for my $i ( 0 .. $#positions ) {
289 my $pos1 = $positions[ $i ];
290 my ( $ax, $ay ) = @{ $pos1->{'left'} };
291 my ( $bx, $by ) = @{ $pos1->{'right'} };
292 my $table1 = $pos1->{'table'};
293 my $fno1 = $pos1->{'field_no'};
7256996b 294 my $is_pk = $pos1->{'is_pk'};
295 next if $join_pk_only and !$is_pk;
aa0a19b9 296
7256996b 297 for my $j ( 0 .. $#positions ) {
aa0a19b9 298 my $pos2 = $positions[ $j ];
299 my ( $cx, $cy ) = @{ $pos2->{'left'} };
300 my ( $dx, $dy ) = @{ $pos2->{'right'} };
301 my $table2 = $pos2->{'table'};
302 my $fno2 = $pos2->{'field_no'};
303 next if $done{ $fno1 }{ $fno2 };
7256996b 304 next if $fno1 == $fno2;
aa0a19b9 305
306 my @distances = ();
307 push @distances, [
308 abs ( $ax - $cx ) + abs ( $ay - $cy ),
309 [ $ax, $ay, $cx, $cy ],
310 [ 'left', 'left' ]
311 ];
312 push @distances, [
313 abs ( $ax - $dx ) + abs ( $ay - $dy ),
314 [ $ax, $ay, $dx, $dy ],
315 [ 'left', 'right' ],
316 ];
317 push @distances, [
318 abs ( $bx - $cx ) + abs ( $by - $cy ),
319 [ $bx, $by, $cx, $cy ],
320 [ 'right', 'left' ],
321 ];
322 push @distances, [
323 abs ( $bx - $dx ) + abs ( $by - $dy ),
324 [ $bx, $by, $dx, $dy ],
325 [ 'right', 'right' ],
326 ];
327 @distances = sort { $a->[0] <=> $b->[0] } @distances;
328 my $shortest = $distances[0];
329 my ( $x1, $y1, $x2, $y2 ) = @{ $shortest->[1] };
330 my ( $side1, $side2 ) = @{ $shortest->[2] };
331 my ( $start, $end );
7256996b 332 my $offset = 9;
aa0a19b9 333 my $col1_right = $table_x{ $table1 };
334 my $col2_right = $table_x{ $table2 };
335
336 my $diff = 0;
337 if ( $x1 == $x2 ) {
338 while ( $horz_taken{ $x1 + $diff } ) {
339 $diff = $side1 eq 'left' ? $diff - 2 : $diff + 2;
340 }
341 $horz_taken{ $x1 + $diff } = 1;
342 }
343
344 if ( $side1 eq 'left' ) {
345 $start = $x1 - $offset + $diff;
346 }
347 else {
348 $start = $col1_right + $diff;
349 }
350
351 if ( $side2 eq 'left' ) {
352 $end = $x2 - $offset + $diff;
353 }
354 else {
355 $end = $col2_right + $diff;
356 }
357
358 push @shapes, [ 'line', $x1, $y1, $start, $y1, 'lightblue' ];
359 push @shapes, [ 'line', $start, $y1, $end, $y2, 'lightblue' ];
360 push @shapes, [ 'line', $end, $y2, $x2, $y2, 'lightblue' ];
361 $done{ $fno1 }{ $fno2 } = 1;
362 $done{ $fno2 }{ $fno1 } = 1;
363 }
364 }
365 }
366}
367
368#
b18a0413 369# Add the title, legend and signature.
dd9550a4 370#
371my $large_font = gdLargeFont;
372my $title_len = $large_font->width * length $title;
aa0a19b9 373push @shapes, [
374 'string', $large_font, $max_x/2 - $title_len/2, 10, $title, 'black'
375];
dd9550a4 376
b18a0413 377if ( %legend ) {
378 $max_y += 5;
379 push @shapes, [
380 'string', $font, $x, $max_y - $font->height - 4, 'Legend', 'black'
381 ];
382 $max_y += $font->height + 4;
383
384 my $longest;
385 for my $len ( map { length $_ } values %legend ) {
386 $longest = $len if $len > $longest;
387 }
388 $longest += 2;
389
390 while ( my ( $key, $shape ) = each %legend ) {
391 my $space = $longest - length $shape;
392 push @shapes, [
393 'string', $font, $x, $max_y - $font->height - 4,
394 join( '', $shape, ' ' x $space, $key ), 'black'
395 ];
396
397 $max_y += $font->height + 4;
398 }
399}
400
aa0a19b9 401my $sig = "auto-dia.pl $VERSION";
402my $sig_len = $font->width * length $sig;
403push @shapes, [
404 'string', $font, $max_x - $sig_len, $max_y - $font->height - 4,
405 $sig, 'black'
406];
dd9550a4 407
aa0a19b9 408#
409# Render the image.
410#
dd9550a4 411my $gd = GD::Image->new( $max_x + 10, $max_y );
412unless ( $gd->can( $image_type ) ) {
413 die "GD can't create images of type '$image_type'\n";
414}
aa0a19b9 415my %colors = map { $_->[0], $gd->colorAllocate( @{$_->[1]} ) } (
d21faa48 416 [ white => [ 255, 255, 255 ] ],
417 [ beige => [ 245, 245, 220 ] ],
418 [ black => [ 0, 0, 0 ] ],
419 [ lightblue => [ 173, 216, 230 ] ],
420 [ lightgoldenrodyellow => [ 250, 250, 210 ] ],
421 [ khaki => [ 240, 230, 140 ] ],
aa0a19b9 422);
dd9550a4 423$gd->interlaced( 'true' );
d21faa48 424my $background_color = $add_color ? 'lightgoldenrodyellow' : 'white';
425$gd->fill( 0, 0, $colors{ $background_color } );
dd9550a4 426for my $shape ( @shapes ) {
427 my $method = shift @$shape;
aa0a19b9 428 my $color = pop @$shape;
429 $gd->$method( @$shape, $colors{ $color } );
dd9550a4 430}
431
aa0a19b9 432#
433# Print the image.
434#
dd9550a4 435if ( $out_file ) {
436 open my $fh, ">$out_file" or die "Can't write '$out_file': $!\n";
437 print $fh $gd->$image_type;
438 close $fh;
439 print "Image written to '$out_file'. Done.\n";
440}
441else {
442 print $gd->$image_type;
443}
444
445=pod
446
447=head1 AUTHOR
448
449Ken Y. Clark E<lt>kclark@cpan.orgE<gt>
450
451=cut