Bumping version to 1.62
[dbsrgits/SQL-Translator.git] / lib / SQL / Translator / Producer / GraphViz.pm
CommitLineData
14d7eb56 1package SQL::Translator::Producer::GraphViz;
2
d491c962 3=pod
4
5=head1 NAME
6
7SQL::Translator::Producer::GraphViz - GraphViz producer for SQL::Translator
8
9=head1 SYNOPSIS
10
11 use SQL::Translator;
12
f2b20a77 13 my $trans = SQL::Translator->new(
d491c962 14 from => 'MySQL', # or your db of choice
f2b20a77 15 to => 'GraphViz',
d491c962 16 producer_args => {
f2b20a77 17 out_file => 'schema.png',
18 bgcolor => 'lightgoldenrodyellow',
d491c962 19 show_constraints => 1,
f2b20a77 20 show_datatypes => 1,
21 show_sizes => 1
d491c962 22 }
23 ) or die SQL::Translator->error;
24
25 $trans->translate or die $trans->error;
26
27=head1 DESCRIPTION
28
29Creates a graph of a schema using the amazing graphviz
30(see http://www.graphviz.org/) application (via
da810c37 31the L<GraphViz> module). It's nifty--you should try it!
d491c962 32
33=head1 PRODUCER ARGS
34
da810c37 35All L<GraphViz> constructor attributes are accepted and passed
36through to L<GraphViz/new>. The following defaults are assumed
37for some attributes:
d491c962 38
da810c37 39 layout => 'dot',
40 overlap => 'false',
d491c962 41
da810c37 42 node => {
43 shape => 'record',
44 style => 'filled',
45 fillcolor => 'white',
46 },
d491c962 47
da810c37 48 # in inches
49 width => 8.5,
50 height => 11,
d491c962 51
da810c37 52See the documentation of L<GraphViz/new> for more info on these
53and other attributes.
d491c962 54
da810c37 55In addition this producer accepts the following arguments:
d491c962 56
da810c37 57=over 4
d491c962 58
da810c37 59=item * skip_tables
d491c962 60
da810c37 61An arrayref or a comma-separated list of table names that should be
62skipped. Note that a skipped table node may still appear if another
63table has foreign key constraints pointing to the skipped table. If
64this happens no table field/index information will be included.
d491c962 65
da810c37 66=item * skip_tables_like
d491c962 67
da810c37 68An arrayref or a comma-separated list of regular expressions matching
69table names that should be skipped.
d491c962 70
da810c37 71=item * cluster
027cebc7 72
f2b20a77 73Clustering of tables allows you to group and box tables according to
74function or domain or whatever criteria you choose. The syntax for
75clustering tables is:
76
77 cluster => 'cluster1=table1,table2;cluster2=table3,table4'
78
79Or pass it as an arrayref like so:
80
81 cluster => [ 'cluster1=table1,table2', 'cluster2=table3,table4' ]
82
83Or like so:
84
ea93df61 85 cluster => [
f2b20a77 86 { name => 'cluster1', tables => [ 'table1', 'table2' ] },
87 { name => 'cluster2', tables => [ 'table3', 'table4' ] },
88 ]
027cebc7 89
da810c37 90=item * out_file
027cebc7 91
da810c37 92The name of the file where the resulting GraphViz output will be
93written. Alternatively an open filehandle can be supplied. If
94undefined (the default) - the result is returned as a string.
027cebc7 95
da810c37 96=item * output_type (DEFAULT: 'png')
027cebc7 97
ea93df61 98This determines which
ac7adbab 99L<output method|GraphViz/as_canon, as_text, as_gif etc. methods>
da810c37 100will be invoked to generate the graph: C<png> translates to
101C<as_png>, C<ps> to C<as_ps> and so on.
027cebc7 102
da810c37 103=item * fontname
027cebc7 104
ea93df61 105This sets the global font name (or full path to font file) for
da810c37 106node, edge, and graph labels
027cebc7 107
da810c37 108=item * fontsize
027cebc7 109
da810c37 110This sets the global font size for node and edge labels (note that
111arbitrarily large sizes may be ignored due to page size or graph size
112constraints)
027cebc7 113
d491c962 114=item * show_fields (DEFAULT: true)
115
b8d523a0 116If set to a true value, the names of the columns in a table will
d491c962 117be displayed in each table's node
118
119=item * show_fk_only
120
da810c37 121If set to a true value, only columns which are foreign keys
d491c962 122will be displayed in each table's node
123
124=item * show_datatypes
125
da810c37 126If set to a true value, the datatype of each column will be
d491c962 127displayed next to each column's name; this option will have no
da810c37 128effect if the value of C<show_fields> is set to false
129
130=item * friendly_ints
131
132If set to a true value, each integer type field will be displayed
133as a tinyint, smallint, integer or bigint depending on the field's
134associated size parameter. This only applies for the C<integer>
135type (and not the C<int> type, which is always assumed to be a
13632-bit integer); this option will have no effect if the value of
137C<show_fields> is set to false
138
139=item * friendly_ints_extended
140
141If set to a true value, the friendly ints displayed will take into
142account the non-standard types, 'tinyint' and 'mediumint' (which,
143as far as I am aware, is only implemented in MySQL)
d491c962 144
9d93edab 145=item * show_sizes
d491c962 146
da810c37 147If set to a true value, the size (in bytes) of each CHAR and
d491c962 148VARCHAR column will be displayed in parentheses next to the
149column's name; this option will have no effect if the value of
da810c37 150C<show_fields> is set to false
d491c962 151
152=item * show_constraints
153
da810c37 154If set to a true value, a field's constraints (i.e., its
d491c962 155primary-key-ness, its foreign-key-ness and/or its uniqueness)
156will appear as a comma-separated list in brackets next to the
157field's name; this option will have no effect if the value of
da810c37 158C<show_fields> is set to false
d491c962 159
da810c37 160=item * show_indexes
161
162If set to a true value, each record will also show the indexes
163set on each table. It describes the index types along with
164which columns are included in the index.
d491c962 165
da810c37 166=item * show_index_names (DEFAULT: true)
167
168If C<show_indexes> is set to a true value, then the value of this
169parameter determines whether or not to print names of indexes.
170if C<show_index_names> is false, then a list of indexed columns
171will appear below the field list. Otherwise, it will be a list
172prefixed with the name of each index.
d491c962 173
174=item * natural_join
175
da810c37 176If set to a true value, L<SQL::Translator::Schema/make_natural_joins>
177will be called before generating the graph.
d491c962 178
179=item * join_pk_only
180
da810c37 181The value of this option will be passed as the value of the
182like-named argument to L<SQL::Translator::Schema/make_natural_joins>;
183implies C<< natural_join => 1 >>
d491c962 184
185=item * skip_fields
186
da810c37 187The value of this option will be passed as the value of the
188like-named argument to L<SQL::Translator::Schema/make_natural_joins>;
189implies C<< natural_join => 1 >>
d491c962 190
da810c37 191=back
44435b87 192
da810c37 193=head2 DEPRECATED ARGS
818e0d98 194
da810c37 195=over 4
818e0d98 196
da810c37 197=item * node_shape
44435b87 198
da810c37 199Deprecated, use node => { shape => ... } instead
b7478526 200
da810c37 201=item * add_color
b7478526 202
da810c37 203Deprecated, use bgcolor => 'lightgoldenrodyellow' instead
d6d6b205 204
da810c37 205If set to a true value, the graphic will have a background
206color of 'lightgoldenrodyellow'; otherwise the default
207white background will be used
d6d6b205 208
da810c37 209=item * nodeattrs
b7478526 210
da810c37 211Deprecated, use node => { ... } instead
b7478526 212
da810c37 213=item * edgeattrs
d44b5f54 214
da810c37 215Deprecated, use edge => { ... } instead
216
217=item * graphattrs
218
219Deprecated, use graph => { ... } instead
d44b5f54 220
d491c962 221=back
222
223=cut
224
da810c37 225use warnings;
14d7eb56 226use strict;
227use GraphViz;
997f14b2 228use SQL::Translator::Schema::Constants;
14d7eb56 229use SQL::Translator::Utils qw(debug);
d8324f08 230use Scalar::Util qw/openhandle/;
14d7eb56 231
0c04c5a2 232our $DEBUG;
f769b7e8 233our $VERSION = '1.62';
14d7eb56 234$DEBUG = 0 unless defined $DEBUG;
235
14d7eb56 236sub produce {
997f14b2 237 my $t = shift;
238 my $schema = $t->schema;
14d7eb56 239 my $args = $t->producer_args;
240 local $DEBUG = $t->debug;
14d7eb56 241
da810c37 242 # translate legacy {node|edge|graph}attrs to just {node|edge|graph}
243 for my $argtype (qw/node edge graph/) {
244 my $old_arg = $argtype . 'attrs';
349ec4b8 245
246 my %arglist = (map
247 { %{ $_ || {} } }
248 ( delete $args->{$old_arg}, delete $args->{$argtype} )
249 );
250
251 $args->{$argtype} = \%arglist if keys %arglist;
da810c37 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
ea93df61 304 $args->{natural_join} = 1
da810c37 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
d8324f08 344 #
345 # Create a blank GraphViz object and see if we can produce the output type.
346 #
da810c37 347 my $gv = GraphViz->new( %$args )
348 or die sprintf ("Can't create GraphViz object: %s\n",
349 $@ || 'reason unknown'
350 );
d8324f08 351
da810c37 352 my $output_method = "as_$args->{output_type}";
353
354 # the generators are AUTOLOADed so can't use ->can ($output_method)
d8324f08 355 eval { $gv->$output_method };
da810c37 356 die "Invalid output type: '$args->{output_type}'" if $@;
d8324f08 357
da810c37 358 #
359 # Process tables definitions, create nodes
360 #
14d7eb56 361 my %nj_registry; # for locations of fields for natural joins
362 my @fk_registry; # for locations of fields for foreign keys
363
44435b87 364 TABLE:
997f14b2 365 for my $table ( $schema->get_tables ) {
44435b87 366
da810c37 367 my $table_name = $table->name;
368 if ( @skip_tables_like or keys %skip_tables ) {
369 next TABLE if $skip_tables{ $table_name };
370 for my $regex ( @skip_tables_like ) {
371 next TABLE if $table_name =~ $regex;
372 }
44435b87 373 }
374
da810c37 375 my @fields = $table->get_fields;
376 if ( $args->{show_fk_only} ) {
997f14b2 377 @fields = grep { $_->is_foreign_key } @fields;
14d7eb56 378 }
14d7eb56 379
6264cdc8 380 my $field_str = '';
da810c37 381 if ($args->{show_fields}) {
818e0d98 382 my @fmt_fields;
da810c37 383 for my $field (@fields) {
384
385 my $field_info;
386 if ($args->{show_datatypes}) {
387
388 my $field_type = $field->data_type;
389 my $size = $field->size;
390
391 if ( $args->{friendly_ints} && $size && (lc ($field_type) eq 'integer') ) {
392 # Automatically translate to int2, int4, int8
393 # Type (Bits) Max. Signed/Unsigned Length
394 # tinyint* (8) 128 3
395 # 255 3
396 # smallint (16) 32767 5
397 # 65535 5
398 # mediumint* (24) 8388607 7
399 # 16777215 8
400 # int (32) 2147483647 10
401 # 4294967295 11
402 # bigint (64) 9223372036854775807 19
403 # 18446744073709551615 20
404 #
405 # * tinyint and mediumint are nonstandard extensions which are
406 # only available under MySQL (to my knowledge)
407 if ($size <= 3 and $args->{friendly_ints_extended}) {
408 $field_type = 'tinyint';
409 }
410 elsif ($size <= 5) {
411 $field_type = 'smallint';
412 }
413 elsif ($size <= 8 and $args->{friendly_ints_extended}) {
414 $field_type = 'mediumint';
415 }
416 elsif ($size <= 11) {
417 $field_type = 'integer';
418 }
419 else {
420 $field_type = 'bigint';
421 }
b7478526 422 }
b7478526 423
da810c37 424 $field_info = $field_type;
425 if ($args->{show_sizes} && $size && ($field_type =~ /^ (?: NUMERIC | DECIMAL | (VAR)?CHAR2? ) $/ix ) ) {
426 $field_info .= '(' . $size . ')';
818e0d98 427 }
da810c37 428 }
429
430 my $constraints;
431 if ($args->{show_constraints}) {
432 my @constraints;
4426fb84 433 push(@constraints, $field->is_auto_increment ? 'PA' : 'PK') if $field->is_primary_key;
da810c37 434 push(@constraints, 'FK') if $field->is_foreign_key;
435 push(@constraints, 'U') if $field->is_unique;
436 push(@constraints, 'N') if $field->is_nullable;
437
438 $constraints = join (',', @constraints);
439 }
440
441 # construct the field line from all info gathered so far
442 push @fmt_fields, join (' ',
443 '-',
444 $field->name,
445 $field_info || (),
446 $constraints ? "[$constraints]" : (),
447 );
818e0d98 448 }
6264cdc8 449
818e0d98 450 # join field lines with graphviz formatting
da810c37 451 $field_str = join ('\l', @fmt_fields) . '\l';
452
b7478526 453 }
454
6264cdc8 455 my $index_str = '';
da810c37 456 if ($args->{show_indexes}) {
457
458 my @fmt_indexes;
459 for my $index ($table->get_indices) {
460 next unless $index->is_valid;
461
462 push @fmt_indexes, join (' ',
463 '*',
464 $args->{show_index_names}
ea93df61 465 ? $index->name . ':'
da810c37 466 : ()
467 ,
468 join (', ', $index->fields),
469 ($index->type eq 'UNIQUE') ? '[U]' : (),
470 );
471 }
6264cdc8 472
da810c37 473 # join index lines with graphviz formatting (if any indexes at all)
474 $index_str = join ('\l', @fmt_indexes) . '\l' if @fmt_indexes;
6264cdc8 475 }
476
6264cdc8 477 my $name_str = $table_name . '\n';
478
479 # escape spaces
480 for ($name_str, $field_str, $index_str) {
da810c37 481 $_ =~ s/ /\\ /g;
b7478526 482 }
6264cdc8 483
da810c37 484 my $node_args;
485
6264cdc8 486 # only the 'record' type supports nice formatting
da810c37 487 if ($args->{node}{shape} eq 'record') {
488
818e0d98 489 # the necessity to supply shape => 'record' is a graphviz bug
da810c37 490 $node_args = {
491 shape => 'record',
492 label => sprintf ('{%s}',
493 join ('|',
494 $name_str,
495 $field_str || (),
496 $index_str || (),
6264cdc8 497 ),
da810c37 498 ),
499 };
6264cdc8 500 }
501 else {
502 my $sep = sprintf ('%s\n',
da810c37 503 '-' x ( (length $table_name) + 2)
6264cdc8 504 );
505
da810c37 506 $node_args = {
507 label => join ($sep,
508 $name_str,
509 $field_str || (),
510 $index_str || (),
511 ),
512 };
513 }
514
515 if (my $cluster_name = $cluster{$table_name} ) {
516 $node_args->{cluster} = $cluster_name;
6264cdc8 517 }
518
30e85fe9 519 $gv->add_node(qq["$table_name"], %$node_args);
da810c37 520
e36752ea 521 debug("Processing table '$table_name'");
522
fce0f0b5 523 debug("Fields = ", join(', ', map { $_->name } @fields)) if $DEBUG;
14d7eb56 524
525 for my $f ( @fields ) {
997f14b2 526 my $name = $f->name or next;
527 my $is_pk = $f->is_primary_key;
528 my $is_unique = $f->is_unique;
14d7eb56 529
530 #
531 # Decide if we should skip this field.
532 #
da810c37 533 if ( $args->{natural_join} ) {
997f14b2 534 next unless $is_pk || $f->is_foreign_key;
14d7eb56 535 }
536
537 my $constraints = $f->{'constraints'};
538
da810c37 539 if ( $args->{natural_join} && !$skip_fields{ $name } ) {
14d7eb56 540 push @{ $nj_registry{ $name } }, $table_name;
541 }
997f14b2 542 }
543
da810c37 544 unless ( $args->{natural_join} ) {
997f14b2 545 for my $c ( $table->get_constraints ) {
546 next unless $c->type eq FOREIGN_KEY;
547 my $fk_table = $c->reference_table or next;
548
549 for my $field_name ( $c->fields ) {
550 for my $fk_field ( $c->reference_fields ) {
551 next unless defined $schema->get_table( $fk_table );
da810c37 552
553 # a condition is optional if at least one fk is nullable
554 push @fk_registry, [
555 $table_name,
556 $fk_table,
557 scalar (grep { $_->is_nullable } ($c->fields))
558 ];
14d7eb56 559 }
560 }
561 }
562 }
563 }
564
565 #
da810c37 566 # Process relationships, create edges
14d7eb56 567 #
da810c37 568 my (@table_bunches, %optional_constraints);
569 if ( $args->{natural_join} ) {
14d7eb56 570 for my $field_name ( keys %nj_registry ) {
571 my @table_names = @{ $nj_registry{ $field_name } || [] } or next;
572 next if scalar @table_names == 1;
573 push @table_bunches, [ @table_names ];
574 }
575 }
576 else {
da810c37 577 for my $i (0 .. $#fk_registry) {
578 my $fk = $fk_registry[$i];
579 push @table_bunches, [$fk->[0], $fk->[1]];
580 $optional_constraints{$i} = $fk->[2];
581 }
14d7eb56 582 }
583
584 my %done;
da810c37 585 for my $bi (0 .. $#table_bunches) {
586 my @tables = @{$table_bunches[$bi]};
14d7eb56 587
588 for my $i ( 0 .. $#tables ) {
589 my $table1 = $tables[ $i ];
771479d7 590 for my $j ( 1 .. $#tables ) {
d51a158e 591 next if $i == $j;
14d7eb56 592 my $table2 = $tables[ $j ];
14d7eb56 593 next if $done{ $table1 }{ $table2 };
f2b20a77 594 debug("Adding edge '$table2' -> '$table1'");
da810c37 595 $gv->add_edge(
f2b20a77 596 qq["$table2"],
597 qq["$table1"],
da810c37 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
626=pod
627
14d7eb56 628=head1 AUTHOR
629
11ad2df9 630Ken Youens-Clark E<lt>kclark@cpan.orgE<gt>
631
632Jonathan Yu E<lt>frequency@cpan.orgE<gt>
d44b5f54 633
d491c962 634=head1 SEE ALSO
635
11ad2df9 636SQL::Translator, GraphViz
d491c962 637
14d7eb56 638=cut