Merge forgotten rewrite of the GraphViz producer - keep all the logic intact but...
[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 ];
4ab3763d 236$VERSION = '1.59';
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';
248 $args->{$argtype} = {
249 map { %{ $_ || {} } }
250 ( delete $args->{$old_arg}, $args->{$argtype} )
251 };
252 }
997f14b2 253
da810c37 254 # explode font settings
255 for (qw/fontsize fontname/) {
256 if (defined $args->{$_}) {
257 $args->{node}{$_} ||= $args->{$_};
258 $args->{edge}{$_} ||= $args->{$_};
259 $args->{graph}{$_} ||= $args->{$_};
260 }
261 }
14d7eb56 262
da810c37 263 # legacy add_color setting, trumped by bgcolor if set
264 $args->{bgcolor} ||= 'lightgoldenrodyellow' if $args->{add_color};
265
266 # legacy node_shape setting, defaults to 'record', trumped by {node}{shape}
267 $args->{node}{shape} ||= ( $args->{node_shape} || 'record' );
268
269 # maintain defaults
270 $args->{layout} ||= 'dot';
271 $args->{output_type} ||= 'png';
272 $args->{overlap} ||= 'false';
273 $args->{node}{style} ||= 'filled';
274 $args->{node}{fillcolor} ||= 'white';
275
276 $args->{show_fields} = 1 if not exists $args->{show_fields};
277 $args->{show_index_names} = 1 if not exists $args->{show_index_names};
278 $args->{width} = 8.5 if not defined $args->{width};
279 $args->{height} = 11 if not defined $args->{height};
280 for ( $args->{height}, $args->{width} ) {
281 $_ = 0 unless $_ =~ /^\d+(?:.\d+)?$/;
e36752ea 282 $_ = 0 if $_ < 0;
283 }
284
da810c37 285 # so split won't warn
286 $args->{$_} ||= '' for qw/skip_fields skip_tables skip_tables_like cluster/;
771479d7 287
da810c37 288 my %skip_fields = map { s/^\s+|\s+$//g; length $_ ? ($_, 1) : () }
289 split ( /,/, $args->{skip_fields} );
771479d7 290
da810c37 291 my %skip_tables = map { $_, 1 } (
292 ref $args->{skip_tables} eq 'ARRAY'
293 ? @{$args->{skip_tables}}
294 : split (/\s*,\s*/, $args->{skip_tables})
295 );
771479d7 296
da810c37 297 my @skip_tables_like = map { qr/$_/ } (
298 ref $args->{skip_tables_like} eq 'ARRAY'
299 ? @{$args->{skip_tables_like}}
300 : split (/\s*,\s*/, $args->{skip_tables_like})
301 );
771479d7 302
da810c37 303 # join_pk_only/skip_fields implies natural_join
304 $args->{natural_join} = 1
305 if ($args->{join_pk_only} or scalar keys %skip_fields);
771479d7 306
da810c37 307 # usually we do not want direction when using natural join
308 $args->{directed} = ($args->{natural_join} ? 0 : 1)
309 if not exists $args->{directed};
771479d7 310
da810c37 311 $schema->make_natural_joins(
312 join_pk_only => $args->{join_pk_only},
313 skip_fields => $args->{skip_fields},
314 ) if $args->{natural_join};
e36752ea 315
771479d7 316 my %cluster;
317 if ( defined $args->{'cluster'} ) {
318 my @clusters;
319 if ( ref $args->{'cluster'} eq 'ARRAY' ) {
320 @clusters = @{ $args->{'cluster'} };
321 }
322 else {
323 @clusters = split /\s*;\s*/, $args->{'cluster'};
324 }
325
326 for my $c ( @clusters ) {
327 my ( $cluster_name, @cluster_tables );
328 if ( ref $c eq 'HASH' ) {
329 $cluster_name = $c->{'name'} || $c->{'cluster_name'};
330 @cluster_tables = @{ $c->{'tables'} || [] };
331 }
332 else {
333 my ( $name, $tables ) = split /\s*=\s*/, $c;
334 $cluster_name = $name;
335 @cluster_tables = split /\s*,\s*/, $tables;
336 }
337
338 for my $table ( @cluster_tables ) {
339 $cluster{ $table } = $cluster_name;
340 }
341 }
342 }
343
da810c37 344
d8324f08 345 #
346 # Create a blank GraphViz object and see if we can produce the output type.
347 #
da810c37 348 my $gv = GraphViz->new( %$args )
349 or die sprintf ("Can't create GraphViz object: %s\n",
350 $@ || 'reason unknown'
351 );
d8324f08 352
da810c37 353 my $output_method = "as_$args->{output_type}";
354
355 # the generators are AUTOLOADed so can't use ->can ($output_method)
d8324f08 356 eval { $gv->$output_method };
da810c37 357 die "Invalid output type: '$args->{output_type}'" if $@;
d8324f08 358
da810c37 359 #
360 # Process tables definitions, create nodes
361 #
14d7eb56 362 my %nj_registry; # for locations of fields for natural joins
363 my @fk_registry; # for locations of fields for foreign keys
364
44435b87 365 TABLE:
997f14b2 366 for my $table ( $schema->get_tables ) {
44435b87 367
da810c37 368 my $table_name = $table->name;
369 if ( @skip_tables_like or keys %skip_tables ) {
370 next TABLE if $skip_tables{ $table_name };
371 for my $regex ( @skip_tables_like ) {
372 next TABLE if $table_name =~ $regex;
373 }
44435b87 374 }
375
da810c37 376 my @fields = $table->get_fields;
377 if ( $args->{show_fk_only} ) {
997f14b2 378 @fields = grep { $_->is_foreign_key } @fields;
14d7eb56 379 }
14d7eb56 380
6264cdc8 381 my $field_str = '';
da810c37 382 if ($args->{show_fields}) {
818e0d98 383 my @fmt_fields;
da810c37 384 for my $field (@fields) {
385
386 my $field_info;
387 if ($args->{show_datatypes}) {
388
389 my $field_type = $field->data_type;
390 my $size = $field->size;
391
392 if ( $args->{friendly_ints} && $size && (lc ($field_type) eq 'integer') ) {
393 # Automatically translate to int2, int4, int8
394 # Type (Bits) Max. Signed/Unsigned Length
395 # tinyint* (8) 128 3
396 # 255 3
397 # smallint (16) 32767 5
398 # 65535 5
399 # mediumint* (24) 8388607 7
400 # 16777215 8
401 # int (32) 2147483647 10
402 # 4294967295 11
403 # bigint (64) 9223372036854775807 19
404 # 18446744073709551615 20
405 #
406 # * tinyint and mediumint are nonstandard extensions which are
407 # only available under MySQL (to my knowledge)
408 if ($size <= 3 and $args->{friendly_ints_extended}) {
409 $field_type = 'tinyint';
410 }
411 elsif ($size <= 5) {
412 $field_type = 'smallint';
413 }
414 elsif ($size <= 8 and $args->{friendly_ints_extended}) {
415 $field_type = 'mediumint';
416 }
417 elsif ($size <= 11) {
418 $field_type = 'integer';
419 }
420 else {
421 $field_type = 'bigint';
422 }
b7478526 423 }
b7478526 424
da810c37 425 $field_info = $field_type;
426 if ($args->{show_sizes} && $size && ($field_type =~ /^ (?: NUMERIC | DECIMAL | (VAR)?CHAR2? ) $/ix ) ) {
427 $field_info .= '(' . $size . ')';
818e0d98 428 }
da810c37 429 }
430
431 my $constraints;
432 if ($args->{show_constraints}) {
433 my @constraints;
434 push(@constraints, 'PK') if $field->is_primary_key;
435 push(@constraints, 'FK') if $field->is_foreign_key;
436 push(@constraints, 'U') if $field->is_unique;
437 push(@constraints, 'N') if $field->is_nullable;
438
439 $constraints = join (',', @constraints);
440 }
441
442 # construct the field line from all info gathered so far
443 push @fmt_fields, join (' ',
444 '-',
445 $field->name,
446 $field_info || (),
447 $constraints ? "[$constraints]" : (),
448 );
818e0d98 449 }
6264cdc8 450
818e0d98 451 # join field lines with graphviz formatting
da810c37 452 $field_str = join ('\l', @fmt_fields) . '\l';
453
b7478526 454 }
455
6264cdc8 456 my $index_str = '';
da810c37 457 if ($args->{show_indexes}) {
458
459 my @fmt_indexes;
460 for my $index ($table->get_indices) {
461 next unless $index->is_valid;
462
463 push @fmt_indexes, join (' ',
464 '*',
465 $args->{show_index_names}
466 ? $index->name . ':'
467 : ()
468 ,
469 join (', ', $index->fields),
470 ($index->type eq 'UNIQUE') ? '[U]' : (),
471 );
472 }
6264cdc8 473
da810c37 474 # join index lines with graphviz formatting (if any indexes at all)
475 $index_str = join ('\l', @fmt_indexes) . '\l' if @fmt_indexes;
6264cdc8 476 }
477
6264cdc8 478 my $name_str = $table_name . '\n';
479
480 # escape spaces
481 for ($name_str, $field_str, $index_str) {
da810c37 482 $_ =~ s/ /\\ /g;
b7478526 483 }
6264cdc8 484
da810c37 485 my $node_args;
486
6264cdc8 487 # only the 'record' type supports nice formatting
da810c37 488 if ($args->{node}{shape} eq 'record') {
489
818e0d98 490 # the necessity to supply shape => 'record' is a graphviz bug
da810c37 491 $node_args = {
492 shape => 'record',
493 label => sprintf ('{%s}',
494 join ('|',
495 $name_str,
496 $field_str || (),
497 $index_str || (),
6264cdc8 498 ),
da810c37 499 ),
500 };
6264cdc8 501 }
502 else {
503 my $sep = sprintf ('%s\n',
da810c37 504 '-' x ( (length $table_name) + 2)
6264cdc8 505 );
506
da810c37 507 $node_args = {
508 label => join ($sep,
509 $name_str,
510 $field_str || (),
511 $index_str || (),
512 ),
513 };
514 }
515
516 if (my $cluster_name = $cluster{$table_name} ) {
517 $node_args->{cluster} = $cluster_name;
6264cdc8 518 }
519
da810c37 520 $gv->add_node ($table_name, %$node_args);
521
e36752ea 522 debug("Processing table '$table_name'");
523
997f14b2 524 debug("Fields = ", join(', ', map { $_->name } @fields));
14d7eb56 525
526 for my $f ( @fields ) {
997f14b2 527 my $name = $f->name or next;
528 my $is_pk = $f->is_primary_key;
529 my $is_unique = $f->is_unique;
14d7eb56 530
531 #
532 # Decide if we should skip this field.
533 #
da810c37 534 if ( $args->{natural_join} ) {
997f14b2 535 next unless $is_pk || $f->is_foreign_key;
14d7eb56 536 }
537
538 my $constraints = $f->{'constraints'};
539
da810c37 540 if ( $args->{natural_join} && !$skip_fields{ $name } ) {
14d7eb56 541 push @{ $nj_registry{ $name } }, $table_name;
542 }
997f14b2 543 }
544
da810c37 545 unless ( $args->{natural_join} ) {
997f14b2 546 for my $c ( $table->get_constraints ) {
547 next unless $c->type eq FOREIGN_KEY;
548 my $fk_table = $c->reference_table or next;
549
550 for my $field_name ( $c->fields ) {
551 for my $fk_field ( $c->reference_fields ) {
552 next unless defined $schema->get_table( $fk_table );
da810c37 553
554 # a condition is optional if at least one fk is nullable
555 push @fk_registry, [
556 $table_name,
557 $fk_table,
558 scalar (grep { $_->is_nullable } ($c->fields))
559 ];
14d7eb56 560 }
561 }
562 }
563 }
564 }
565
566 #
da810c37 567 # Process relationships, create edges
14d7eb56 568 #
da810c37 569 my (@table_bunches, %optional_constraints);
570 if ( $args->{natural_join} ) {
14d7eb56 571 for my $field_name ( keys %nj_registry ) {
572 my @table_names = @{ $nj_registry{ $field_name } || [] } or next;
573 next if scalar @table_names == 1;
574 push @table_bunches, [ @table_names ];
575 }
576 }
577 else {
da810c37 578 for my $i (0 .. $#fk_registry) {
579 my $fk = $fk_registry[$i];
580 push @table_bunches, [$fk->[0], $fk->[1]];
581 $optional_constraints{$i} = $fk->[2];
582 }
14d7eb56 583 }
584
585 my %done;
da810c37 586 for my $bi (0 .. $#table_bunches) {
587 my @tables = @{$table_bunches[$bi]};
14d7eb56 588
589 for my $i ( 0 .. $#tables ) {
590 my $table1 = $tables[ $i ];
771479d7 591 for my $j ( 1 .. $#tables ) {
d51a158e 592 next if $i == $j;
14d7eb56 593 my $table2 = $tables[ $j ];
14d7eb56 594 next if $done{ $table1 }{ $table2 };
da810c37 595 $gv->add_edge(
596 $table2,
597 $table1,
598 arrowhead => $optional_constraints{$bi} ? 'empty' : 'normal',
599 );
14d7eb56 600 $done{ $table1 }{ $table2 } = 1;
14d7eb56 601 }
602 }
603 }
604
605 #
da810c37 606 # Print the image
14d7eb56 607 #
da810c37 608 if ( my $out = $args->{out_file} ) {
609 if (openhandle ($out)) {
610 print $out $gv->$output_method;
818e0d98 611 }
612 else {
da810c37 613 open my $fh, '>', $out or die "Can't write '$out': $!\n";
818e0d98 614 binmode $fh;
615 print $fh $gv->$output_method;
616 close $fh;
617 }
14d7eb56 618 }
619 else {
818e0d98 620 return $gv->$output_method;
14d7eb56 621 }
622}
623
6241;
625
977651a5 626# -------------------------------------------------------------------
627
14d7eb56 628=pod
629
14d7eb56 630=head1 AUTHOR
631
da810c37 632Ken Youens-Clark E<lt>kclark@cpan.orgE<gt>
633
d44b5f54 634Jonathan Yu E<lt>frequency@cpan.orgE<gt>
635
d491c962 636=head1 SEE ALSO
637
da810c37 638SQL::Translator, GraphViz
d491c962 639
14d7eb56 640=cut