Upped version numbers, cleaned up code, fixed my name.
[dbsrgits/SQL-Translator.git] / lib / SQL / Translator / Producer / GraphViz.pm
CommitLineData
14d7eb56 1package SQL::Translator::Producer::GraphViz;
2
3# -------------------------------------------------------------------
6264cdc8 4# Copyright (C) 2002-2009 SQLFairy Authors
14d7eb56 5#
6# This program is free software; you can redistribute it and/or
7# modify it under the terms of the GNU General Public License as
8# published by the Free Software Foundation; version 2.
9#
10# This program is distributed in the hope that it will be useful, but
11# WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
13# General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License
16# along with this program; if not, write to the Free Software
17# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA
18# 02111-1307 USA
19# -------------------------------------------------------------------
20
d491c962 21=pod
22
23=head1 NAME
24
25SQL::Translator::Producer::GraphViz - GraphViz producer for SQL::Translator
26
27=head1 SYNOPSIS
28
29 use SQL::Translator;
30
31 my $trans = new SQL::Translator(
32 from => 'MySQL', # or your db of choice
33 to => 'GraphViz',
34 producer_args => {
35 out_file => 'schema.png',
da810c37 36 bgcolor => 'lightgoldenrodyellow',
d491c962 37 show_constraints => 1,
38 show_datatypes => 1,
9d93edab 39 show_sizes => 1
d491c962 40 }
41 ) or die SQL::Translator->error;
42
43 $trans->translate or die $trans->error;
44
45=head1 DESCRIPTION
46
47Creates a graph of a schema using the amazing graphviz
48(see http://www.graphviz.org/) application (via
da810c37 49the L<GraphViz> module). It's nifty--you should try it!
d491c962 50
51=head1 PRODUCER ARGS
52
da810c37 53All L<GraphViz> constructor attributes are accepted and passed
54through to L<GraphViz/new>. The following defaults are assumed
55for some attributes:
d491c962 56
da810c37 57 layout => 'dot',
58 overlap => 'false',
d491c962 59
da810c37 60 node => {
61 shape => 'record',
62 style => 'filled',
63 fillcolor => 'white',
64 },
d491c962 65
da810c37 66 # in inches
67 width => 8.5,
68 height => 11,
d491c962 69
da810c37 70See the documentation of L<GraphViz/new> for more info on these
71and other attributes.
d491c962 72
da810c37 73In addition this producer accepts the following arguments:
d491c962 74
da810c37 75=over 4
d491c962 76
da810c37 77=item * skip_tables
d491c962 78
da810c37 79An arrayref or a comma-separated list of table names that should be
80skipped. Note that a skipped table node may still appear if another
81table has foreign key constraints pointing to the skipped table. If
82this happens no table field/index information will be included.
d491c962 83
da810c37 84=item * skip_tables_like
d491c962 85
da810c37 86An arrayref or a comma-separated list of regular expressions matching
87table names that should be skipped.
d491c962 88
da810c37 89=item * cluster
027cebc7 90
da810c37 91POD PENDING
027cebc7 92
da810c37 93=item * out_file
027cebc7 94
da810c37 95The name of the file where the resulting GraphViz output will be
96written. Alternatively an open filehandle can be supplied. If
97undefined (the default) - the result is returned as a string.
027cebc7 98
da810c37 99=item * output_type (DEFAULT: 'png')
027cebc7 100
da810c37 101This determines which
102L<output method|GraphViz/as_canon,_as_text,_as_gif_etc._methods>
103will be invoked to generate the graph: C<png> translates to
104C<as_png>, C<ps> to C<as_ps> and so on.
027cebc7 105
da810c37 106=item * fontname
027cebc7 107
da810c37 108This sets the global font name (or full path to font file) for
109node, edge, and graph labels
027cebc7 110
da810c37 111=item * fontsize
027cebc7 112
da810c37 113This sets the global font size for node and edge labels (note that
114arbitrarily large sizes may be ignored due to page size or graph size
115constraints)
027cebc7 116
d491c962 117=item * show_fields (DEFAULT: true)
118
da810c37 119If set to a true value, the names of the colums in a table will
d491c962 120be displayed in each table's node
121
122=item * show_fk_only
123
da810c37 124If set to a true value, only columns which are foreign keys
d491c962 125will be displayed in each table's node
126
127=item * show_datatypes
128
da810c37 129If set to a true value, the datatype of each column will be
d491c962 130displayed next to each column's name; this option will have no
da810c37 131effect if the value of C<show_fields> is set to false
132
133=item * friendly_ints
134
135If set to a true value, each integer type field will be displayed
136as a tinyint, smallint, integer or bigint depending on the field's
137associated size parameter. This only applies for the C<integer>
138type (and not the C<int> type, which is always assumed to be a
13932-bit integer); this option will have no effect if the value of
140C<show_fields> is set to false
141
142=item * friendly_ints_extended
143
144If set to a true value, the friendly ints displayed will take into
145account the non-standard types, 'tinyint' and 'mediumint' (which,
146as far as I am aware, is only implemented in MySQL)
d491c962 147
9d93edab 148=item * show_sizes
d491c962 149
da810c37 150If set to a true value, the size (in bytes) of each CHAR and
d491c962 151VARCHAR column will be displayed in parentheses next to the
152column's name; this option will have no effect if the value of
da810c37 153C<show_fields> is set to false
d491c962 154
155=item * show_constraints
156
da810c37 157If set to a true value, a field's constraints (i.e., its
d491c962 158primary-key-ness, its foreign-key-ness and/or its uniqueness)
159will appear as a comma-separated list in brackets next to the
160field's name; this option will have no effect if the value of
da810c37 161C<show_fields> is set to false
d491c962 162
da810c37 163=item * show_indexes
164
165If set to a true value, each record will also show the indexes
166set on each table. It describes the index types along with
167which columns are included in the index.
d491c962 168
da810c37 169=item * show_index_names (DEFAULT: true)
170
171If C<show_indexes> is set to a true value, then the value of this
172parameter determines whether or not to print names of indexes.
173if C<show_index_names> is false, then a list of indexed columns
174will appear below the field list. Otherwise, it will be a list
175prefixed with the name of each index.
d491c962 176
177=item * natural_join
178
da810c37 179If set to a true value, L<SQL::Translator::Schema/make_natural_joins>
180will be called before generating the graph.
d491c962 181
182=item * join_pk_only
183
da810c37 184The value of this option will be passed as the value of the
185like-named argument to L<SQL::Translator::Schema/make_natural_joins>;
186implies C<< natural_join => 1 >>
d491c962 187
188=item * skip_fields
189
da810c37 190The value of this option will be passed as the value of the
191like-named argument to L<SQL::Translator::Schema/make_natural_joins>;
192implies C<< natural_join => 1 >>
d491c962 193
da810c37 194=back
44435b87 195
da810c37 196=head2 DEPRECATED ARGS
818e0d98 197
da810c37 198=over 4
818e0d98 199
da810c37 200=item * node_shape
44435b87 201
da810c37 202Deprecated, use node => { shape => ... } instead
b7478526 203
da810c37 204=item * add_color
b7478526 205
da810c37 206Deprecated, use bgcolor => 'lightgoldenrodyellow' instead
d6d6b205 207
da810c37 208If set to a true value, the graphic will have a background
209color of 'lightgoldenrodyellow'; otherwise the default
210white background will be used
d6d6b205 211
da810c37 212=item * nodeattrs
b7478526 213
da810c37 214Deprecated, use node => { ... } instead
b7478526 215
da810c37 216=item * edgeattrs
d44b5f54 217
da810c37 218Deprecated, use edge => { ... } instead
219
220=item * graphattrs
221
222Deprecated, use graph => { ... } instead
d44b5f54 223
d491c962 224=back
225
226=cut
227
da810c37 228use warnings;
14d7eb56 229use strict;
230use GraphViz;
997f14b2 231use SQL::Translator::Schema::Constants;
14d7eb56 232use SQL::Translator::Utils qw(debug);
d8324f08 233use Scalar::Util qw/openhandle/;
14d7eb56 234
235use vars qw[ $VERSION $DEBUG ];
ba506e52 236$VERSION = '1.60';
14d7eb56 237$DEBUG = 0 unless defined $DEBUG;
238
14d7eb56 239sub produce {
997f14b2 240 my $t = shift;
241 my $schema = $t->schema;
14d7eb56 242 my $args = $t->producer_args;
243 local $DEBUG = $t->debug;
14d7eb56 244
da810c37 245 # translate legacy {node|edge|graph}attrs to just {node|edge|graph}
246 for my $argtype (qw/node edge graph/) {
247 my $old_arg = $argtype . 'attrs';
349ec4b8 248
249 my %arglist = (map
250 { %{ $_ || {} } }
251 ( delete $args->{$old_arg}, delete $args->{$argtype} )
252 );
253
254 $args->{$argtype} = \%arglist if keys %arglist;
da810c37 255 }
997f14b2 256
da810c37 257 # explode font settings
258 for (qw/fontsize fontname/) {
259 if (defined $args->{$_}) {
260 $args->{node}{$_} ||= $args->{$_};
261 $args->{edge}{$_} ||= $args->{$_};
262 $args->{graph}{$_} ||= $args->{$_};
263 }
264 }
14d7eb56 265
da810c37 266 # legacy add_color setting, trumped by bgcolor if set
267 $args->{bgcolor} ||= 'lightgoldenrodyellow' if $args->{add_color};
268
269 # legacy node_shape setting, defaults to 'record', trumped by {node}{shape}
270 $args->{node}{shape} ||= ( $args->{node_shape} || 'record' );
271
272 # maintain defaults
273 $args->{layout} ||= 'dot';
274 $args->{output_type} ||= 'png';
275 $args->{overlap} ||= 'false';
276 $args->{node}{style} ||= 'filled';
277 $args->{node}{fillcolor} ||= 'white';
278
279 $args->{show_fields} = 1 if not exists $args->{show_fields};
280 $args->{show_index_names} = 1 if not exists $args->{show_index_names};
281 $args->{width} = 8.5 if not defined $args->{width};
282 $args->{height} = 11 if not defined $args->{height};
283 for ( $args->{height}, $args->{width} ) {
284 $_ = 0 unless $_ =~ /^\d+(?:.\d+)?$/;
e36752ea 285 $_ = 0 if $_ < 0;
286 }
287
da810c37 288 # so split won't warn
289 $args->{$_} ||= '' for qw/skip_fields skip_tables skip_tables_like cluster/;
771479d7 290
da810c37 291 my %skip_fields = map { s/^\s+|\s+$//g; length $_ ? ($_, 1) : () }
292 split ( /,/, $args->{skip_fields} );
771479d7 293
da810c37 294 my %skip_tables = map { $_, 1 } (
295 ref $args->{skip_tables} eq 'ARRAY'
296 ? @{$args->{skip_tables}}
297 : split (/\s*,\s*/, $args->{skip_tables})
298 );
771479d7 299
da810c37 300 my @skip_tables_like = map { qr/$_/ } (
301 ref $args->{skip_tables_like} eq 'ARRAY'
302 ? @{$args->{skip_tables_like}}
303 : split (/\s*,\s*/, $args->{skip_tables_like})
304 );
771479d7 305
da810c37 306 # join_pk_only/skip_fields implies natural_join
307 $args->{natural_join} = 1
308 if ($args->{join_pk_only} or scalar keys %skip_fields);
771479d7 309
da810c37 310 # usually we do not want direction when using natural join
311 $args->{directed} = ($args->{natural_join} ? 0 : 1)
312 if not exists $args->{directed};
771479d7 313
da810c37 314 $schema->make_natural_joins(
315 join_pk_only => $args->{join_pk_only},
316 skip_fields => $args->{skip_fields},
317 ) if $args->{natural_join};
e36752ea 318
771479d7 319 my %cluster;
320 if ( defined $args->{'cluster'} ) {
321 my @clusters;
322 if ( ref $args->{'cluster'} eq 'ARRAY' ) {
323 @clusters = @{ $args->{'cluster'} };
324 }
325 else {
326 @clusters = split /\s*;\s*/, $args->{'cluster'};
327 }
328
329 for my $c ( @clusters ) {
330 my ( $cluster_name, @cluster_tables );
331 if ( ref $c eq 'HASH' ) {
332 $cluster_name = $c->{'name'} || $c->{'cluster_name'};
333 @cluster_tables = @{ $c->{'tables'} || [] };
334 }
335 else {
336 my ( $name, $tables ) = split /\s*=\s*/, $c;
337 $cluster_name = $name;
338 @cluster_tables = split /\s*,\s*/, $tables;
339 }
340
341 for my $table ( @cluster_tables ) {
342 $cluster{ $table } = $cluster_name;
343 }
344 }
345 }
346
d8324f08 347 #
348 # Create a blank GraphViz object and see if we can produce the output type.
349 #
da810c37 350 my $gv = GraphViz->new( %$args )
351 or die sprintf ("Can't create GraphViz object: %s\n",
352 $@ || 'reason unknown'
353 );
d8324f08 354
da810c37 355 my $output_method = "as_$args->{output_type}";
356
357 # the generators are AUTOLOADed so can't use ->can ($output_method)
d8324f08 358 eval { $gv->$output_method };
da810c37 359 die "Invalid output type: '$args->{output_type}'" if $@;
d8324f08 360
da810c37 361 #
362 # Process tables definitions, create nodes
363 #
14d7eb56 364 my %nj_registry; # for locations of fields for natural joins
365 my @fk_registry; # for locations of fields for foreign keys
366
44435b87 367 TABLE:
997f14b2 368 for my $table ( $schema->get_tables ) {
44435b87 369
da810c37 370 my $table_name = $table->name;
371 if ( @skip_tables_like or keys %skip_tables ) {
372 next TABLE if $skip_tables{ $table_name };
373 for my $regex ( @skip_tables_like ) {
374 next TABLE if $table_name =~ $regex;
375 }
44435b87 376 }
377
da810c37 378 my @fields = $table->get_fields;
379 if ( $args->{show_fk_only} ) {
997f14b2 380 @fields = grep { $_->is_foreign_key } @fields;
14d7eb56 381 }
14d7eb56 382
6264cdc8 383 my $field_str = '';
da810c37 384 if ($args->{show_fields}) {
818e0d98 385 my @fmt_fields;
da810c37 386 for my $field (@fields) {
387
388 my $field_info;
389 if ($args->{show_datatypes}) {
390
391 my $field_type = $field->data_type;
392 my $size = $field->size;
393
394 if ( $args->{friendly_ints} && $size && (lc ($field_type) eq 'integer') ) {
395 # Automatically translate to int2, int4, int8
396 # Type (Bits) Max. Signed/Unsigned Length
397 # tinyint* (8) 128 3
398 # 255 3
399 # smallint (16) 32767 5
400 # 65535 5
401 # mediumint* (24) 8388607 7
402 # 16777215 8
403 # int (32) 2147483647 10
404 # 4294967295 11
405 # bigint (64) 9223372036854775807 19
406 # 18446744073709551615 20
407 #
408 # * tinyint and mediumint are nonstandard extensions which are
409 # only available under MySQL (to my knowledge)
410 if ($size <= 3 and $args->{friendly_ints_extended}) {
411 $field_type = 'tinyint';
412 }
413 elsif ($size <= 5) {
414 $field_type = 'smallint';
415 }
416 elsif ($size <= 8 and $args->{friendly_ints_extended}) {
417 $field_type = 'mediumint';
418 }
419 elsif ($size <= 11) {
420 $field_type = 'integer';
421 }
422 else {
423 $field_type = 'bigint';
424 }
b7478526 425 }
b7478526 426
da810c37 427 $field_info = $field_type;
428 if ($args->{show_sizes} && $size && ($field_type =~ /^ (?: NUMERIC | DECIMAL | (VAR)?CHAR2? ) $/ix ) ) {
429 $field_info .= '(' . $size . ')';
818e0d98 430 }
da810c37 431 }
432
433 my $constraints;
434 if ($args->{show_constraints}) {
435 my @constraints;
436 push(@constraints, 'PK') if $field->is_primary_key;
437 push(@constraints, 'FK') if $field->is_foreign_key;
438 push(@constraints, 'U') if $field->is_unique;
439 push(@constraints, 'N') if $field->is_nullable;
440
441 $constraints = join (',', @constraints);
442 }
443
444 # construct the field line from all info gathered so far
445 push @fmt_fields, join (' ',
446 '-',
447 $field->name,
448 $field_info || (),
449 $constraints ? "[$constraints]" : (),
450 );
818e0d98 451 }
6264cdc8 452
818e0d98 453 # join field lines with graphviz formatting
da810c37 454 $field_str = join ('\l', @fmt_fields) . '\l';
455
b7478526 456 }
457
6264cdc8 458 my $index_str = '';
da810c37 459 if ($args->{show_indexes}) {
460
461 my @fmt_indexes;
462 for my $index ($table->get_indices) {
463 next unless $index->is_valid;
464
465 push @fmt_indexes, join (' ',
466 '*',
467 $args->{show_index_names}
468 ? $index->name . ':'
469 : ()
470 ,
471 join (', ', $index->fields),
472 ($index->type eq 'UNIQUE') ? '[U]' : (),
473 );
474 }
6264cdc8 475
da810c37 476 # join index lines with graphviz formatting (if any indexes at all)
477 $index_str = join ('\l', @fmt_indexes) . '\l' if @fmt_indexes;
6264cdc8 478 }
479
6264cdc8 480 my $name_str = $table_name . '\n';
481
482 # escape spaces
483 for ($name_str, $field_str, $index_str) {
da810c37 484 $_ =~ s/ /\\ /g;
b7478526 485 }
6264cdc8 486
da810c37 487 my $node_args;
488
6264cdc8 489 # only the 'record' type supports nice formatting
da810c37 490 if ($args->{node}{shape} eq 'record') {
491
818e0d98 492 # the necessity to supply shape => 'record' is a graphviz bug
da810c37 493 $node_args = {
494 shape => 'record',
495 label => sprintf ('{%s}',
496 join ('|',
497 $name_str,
498 $field_str || (),
499 $index_str || (),
6264cdc8 500 ),
da810c37 501 ),
502 };
6264cdc8 503 }
504 else {
505 my $sep = sprintf ('%s\n',
da810c37 506 '-' x ( (length $table_name) + 2)
6264cdc8 507 );
508
da810c37 509 $node_args = {
510 label => join ($sep,
511 $name_str,
512 $field_str || (),
513 $index_str || (),
514 ),
515 };
516 }
517
518 if (my $cluster_name = $cluster{$table_name} ) {
519 $node_args->{cluster} = $cluster_name;
6264cdc8 520 }
521
30e85fe9 522 $gv->add_node(qq["$table_name"], %$node_args);
da810c37 523
e36752ea 524 debug("Processing table '$table_name'");
525
997f14b2 526 debug("Fields = ", join(', ', map { $_->name } @fields));
14d7eb56 527
528 for my $f ( @fields ) {
997f14b2 529 my $name = $f->name or next;
530 my $is_pk = $f->is_primary_key;
531 my $is_unique = $f->is_unique;
14d7eb56 532
533 #
534 # Decide if we should skip this field.
535 #
da810c37 536 if ( $args->{natural_join} ) {
997f14b2 537 next unless $is_pk || $f->is_foreign_key;
14d7eb56 538 }
539
540 my $constraints = $f->{'constraints'};
541
da810c37 542 if ( $args->{natural_join} && !$skip_fields{ $name } ) {
14d7eb56 543 push @{ $nj_registry{ $name } }, $table_name;
544 }
997f14b2 545 }
546
da810c37 547 unless ( $args->{natural_join} ) {
997f14b2 548 for my $c ( $table->get_constraints ) {
549 next unless $c->type eq FOREIGN_KEY;
550 my $fk_table = $c->reference_table or next;
551
552 for my $field_name ( $c->fields ) {
553 for my $fk_field ( $c->reference_fields ) {
554 next unless defined $schema->get_table( $fk_table );
da810c37 555
556 # a condition is optional if at least one fk is nullable
557 push @fk_registry, [
558 $table_name,
559 $fk_table,
560 scalar (grep { $_->is_nullable } ($c->fields))
561 ];
14d7eb56 562 }
563 }
564 }
565 }
566 }
567
568 #
da810c37 569 # Process relationships, create edges
14d7eb56 570 #
da810c37 571 my (@table_bunches, %optional_constraints);
572 if ( $args->{natural_join} ) {
14d7eb56 573 for my $field_name ( keys %nj_registry ) {
574 my @table_names = @{ $nj_registry{ $field_name } || [] } or next;
575 next if scalar @table_names == 1;
576 push @table_bunches, [ @table_names ];
577 }
578 }
579 else {
da810c37 580 for my $i (0 .. $#fk_registry) {
581 my $fk = $fk_registry[$i];
582 push @table_bunches, [$fk->[0], $fk->[1]];
583 $optional_constraints{$i} = $fk->[2];
584 }
14d7eb56 585 }
586
587 my %done;
da810c37 588 for my $bi (0 .. $#table_bunches) {
589 my @tables = @{$table_bunches[$bi]};
14d7eb56 590
591 for my $i ( 0 .. $#tables ) {
592 my $table1 = $tables[ $i ];
771479d7 593 for my $j ( 1 .. $#tables ) {
d51a158e 594 next if $i == $j;
14d7eb56 595 my $table2 = $tables[ $j ];
14d7eb56 596 next if $done{ $table1 }{ $table2 };
da810c37 597 $gv->add_edge(
598 $table2,
599 $table1,
600 arrowhead => $optional_constraints{$bi} ? 'empty' : 'normal',
601 );
14d7eb56 602 $done{ $table1 }{ $table2 } = 1;
14d7eb56 603 }
604 }
605 }
606
607 #
da810c37 608 # Print the image
14d7eb56 609 #
da810c37 610 if ( my $out = $args->{out_file} ) {
611 if (openhandle ($out)) {
612 print $out $gv->$output_method;
818e0d98 613 }
614 else {
da810c37 615 open my $fh, '>', $out or die "Can't write '$out': $!\n";
818e0d98 616 binmode $fh;
617 print $fh $gv->$output_method;
618 close $fh;
619 }
14d7eb56 620 }
621 else {
818e0d98 622 return $gv->$output_method;
14d7eb56 623 }
624}
625
6261;
627
977651a5 628# -------------------------------------------------------------------
629
14d7eb56 630=pod
631
14d7eb56 632=head1 AUTHOR
633
ba506e52 634Ken Youens-Clark E<lt>kclark@cpan.orgE<gt>,
635Jonathan Yu E<lt>frequency@cpan.orgE<gt>.
d44b5f54 636
d491c962 637=head1 SEE ALSO
638
ba506e52 639SQL::Translator, GraphViz.
d491c962 640
14d7eb56 641=cut