package SQL::Translator::Producer::GraphViz; =pod =head1 NAME SQL::Translator::Producer::GraphViz - GraphViz producer for SQL::Translator =head1 SYNOPSIS use SQL::Translator; my $trans = SQL::Translator->new( from => 'MySQL', # or your db of choice to => 'GraphViz', producer_args => { out_file => 'schema.png', bgcolor => 'lightgoldenrodyellow', show_constraints => 1, show_datatypes => 1, show_sizes => 1 } ) or die SQL::Translator->error; $trans->translate or die $trans->error; =head1 DESCRIPTION Creates a graph of a schema using the amazing graphviz (see http://www.graphviz.org/) application (via the L module). It's nifty--you should try it! =head1 PRODUCER ARGS All L constructor attributes are accepted and passed through to L. The following defaults are assumed for some attributes: layout => 'dot', overlap => 'false', node => { shape => 'record', style => 'filled', fillcolor => 'white', }, # in inches width => 8.5, height => 11, See the documentation of L for more info on these and other attributes. In addition this producer accepts the following arguments: =over 4 =item * skip_tables An arrayref or a comma-separated list of table names that should be skipped. Note that a skipped table node may still appear if another table has foreign key constraints pointing to the skipped table. If this happens no table field/index information will be included. =item * skip_tables_like An arrayref or a comma-separated list of regular expressions matching table names that should be skipped. =item * cluster Clustering of tables allows you to group and box tables according to function or domain or whatever criteria you choose. The syntax for clustering tables is: cluster => 'cluster1=table1,table2;cluster2=table3,table4' Or pass it as an arrayref like so: cluster => [ 'cluster1=table1,table2', 'cluster2=table3,table4' ] Or like so: cluster => [ { name => 'cluster1', tables => [ 'table1', 'table2' ] }, { name => 'cluster2', tables => [ 'table3', 'table4' ] }, ] =item * out_file The name of the file where the resulting GraphViz output will be written. Alternatively an open filehandle can be supplied. If undefined (the default) - the result is returned as a string. =item * output_type (DEFAULT: 'png') This determines which L will be invoked to generate the graph: C translates to C, C to C and so on. =item * fontname This sets the global font name (or full path to font file) for node, edge, and graph labels =item * fontsize This sets the global font size for node and edge labels (note that arbitrarily large sizes may be ignored due to page size or graph size constraints) =item * show_fields (DEFAULT: true) If set to a true value, the names of the columns in a table will be displayed in each table's node =item * show_fk_only If set to a true value, only columns which are foreign keys will be displayed in each table's node =item * show_datatypes If set to a true value, the datatype of each column will be displayed next to each column's name; this option will have no effect if the value of C is set to false =item * friendly_ints If set to a true value, each integer type field will be displayed as a tinyint, smallint, integer or bigint depending on the field's associated size parameter. This only applies for the C type (and not the C type, which is always assumed to be a 32-bit integer); this option will have no effect if the value of C is set to false =item * friendly_ints_extended If set to a true value, the friendly ints displayed will take into account the non-standard types, 'tinyint' and 'mediumint' (which, as far as I am aware, is only implemented in MySQL) =item * show_sizes If set to a true value, the size (in bytes) of each CHAR and VARCHAR column will be displayed in parentheses next to the column's name; this option will have no effect if the value of C is set to false =item * show_constraints If set to a true value, a field's constraints (i.e., its primary-key-ness, its foreign-key-ness and/or its uniqueness) will appear as a comma-separated list in brackets next to the field's name; this option will have no effect if the value of C is set to false =item * show_indexes If set to a true value, each record will also show the indexes set on each table. It describes the index types along with which columns are included in the index. =item * show_index_names (DEFAULT: true) If C is set to a true value, then the value of this parameter determines whether or not to print names of indexes. if C is false, then a list of indexed columns will appear below the field list. Otherwise, it will be a list prefixed with the name of each index. =item * natural_join If set to a true value, L will be called before generating the graph. =item * join_pk_only The value of this option will be passed as the value of the like-named argument to L; implies C<< natural_join => 1 >> =item * skip_fields The value of this option will be passed as the value of the like-named argument to L; implies C<< natural_join => 1 >> =back =head2 DEPRECATED ARGS =over 4 =item * node_shape Deprecated, use node => { shape => ... } instead =item * add_color Deprecated, use bgcolor => 'lightgoldenrodyellow' instead If set to a true value, the graphic will have a background color of 'lightgoldenrodyellow'; otherwise the default white background will be used =item * nodeattrs Deprecated, use node => { ... } instead =item * edgeattrs Deprecated, use edge => { ... } instead =item * graphattrs Deprecated, use graph => { ... } instead =back =cut use warnings; use strict; use GraphViz; use SQL::Translator::Schema::Constants; use SQL::Translator::Utils qw(debug); use Scalar::Util qw/openhandle/; our $DEBUG; our $VERSION = '1.66'; $DEBUG = 0 unless defined $DEBUG; sub produce { my $t = shift; my $schema = $t->schema; my $args = $t->producer_args; local $DEBUG = $t->debug; # translate legacy {node|edge|graph}attrs to just {node|edge|graph} for my $argtype (qw/node edge graph/) { my $old_arg = $argtype . 'attrs'; my %arglist = (map { %{ $_ || {} } } (delete $args->{$old_arg}, delete $args->{$argtype})); $args->{$argtype} = \%arglist if keys %arglist; } # explode font settings for (qw/fontsize fontname/) { if (defined $args->{$_}) { $args->{node}{$_} ||= $args->{$_}; $args->{edge}{$_} ||= $args->{$_}; $args->{graph}{$_} ||= $args->{$_}; } } # legacy add_color setting, trumped by bgcolor if set $args->{bgcolor} ||= 'lightgoldenrodyellow' if $args->{add_color}; # legacy node_shape setting, defaults to 'record', trumped by {node}{shape} $args->{node}{shape} ||= ($args->{node_shape} || 'record'); # maintain defaults $args->{layout} ||= 'dot'; $args->{output_type} ||= 'png'; $args->{overlap} ||= 'false'; $args->{node}{style} ||= 'filled'; $args->{node}{fillcolor} ||= 'white'; $args->{show_fields} = 1 if not exists $args->{show_fields}; $args->{show_index_names} = 1 if not exists $args->{show_index_names}; $args->{width} = 8.5 if not defined $args->{width}; $args->{height} = 11 if not defined $args->{height}; for ($args->{height}, $args->{width}) { $_ = 0 unless $_ =~ /^\d+(?:.\d+)?$/; $_ = 0 if $_ < 0; } # so split won't warn $args->{$_} ||= '' for qw/skip_fields skip_tables skip_tables_like cluster/; my %skip_fields = map { s/^\s+|\s+$//g; length $_ ? ($_, 1) : () } split(/,/, $args->{skip_fields}); my %skip_tables = map { $_, 1 } ( ref $args->{skip_tables} eq 'ARRAY' ? @{ $args->{skip_tables} } : split(/\s*,\s*/, $args->{skip_tables}) ); my @skip_tables_like = map {qr/$_/} ( ref $args->{skip_tables_like} eq 'ARRAY' ? @{ $args->{skip_tables_like} } : split(/\s*,\s*/, $args->{skip_tables_like}) ); # join_pk_only/skip_fields implies natural_join $args->{natural_join} = 1 if ($args->{join_pk_only} or scalar keys %skip_fields); # usually we do not want direction when using natural join $args->{directed} = ($args->{natural_join} ? 0 : 1) if not exists $args->{directed}; $schema->make_natural_joins( join_pk_only => $args->{join_pk_only}, skip_fields => $args->{skip_fields}, ) if $args->{natural_join}; my %cluster; if (defined $args->{'cluster'}) { my @clusters; if (ref $args->{'cluster'} eq 'ARRAY') { @clusters = @{ $args->{'cluster'} }; } else { @clusters = split /\s*;\s*/, $args->{'cluster'}; } for my $c (@clusters) { my ($cluster_name, @cluster_tables); if (ref $c eq 'HASH') { $cluster_name = $c->{'name'} || $c->{'cluster_name'}; @cluster_tables = @{ $c->{'tables'} || [] }; } else { my ($name, $tables) = split /\s*=\s*/, $c; $cluster_name = $name; @cluster_tables = split /\s*,\s*/, $tables; } for my $table (@cluster_tables) { $cluster{$table} = $cluster_name; } } } # # Create a blank GraphViz object and see if we can produce the output type. # my $gv = GraphViz->new(%$args) or die sprintf("Can't create GraphViz object: %s\n", $@ || 'reason unknown'); my $output_method = "as_$args->{output_type}"; # the generators are AUTOLOADed so can't use ->can ($output_method) eval { $gv->$output_method }; die "Invalid output type: '$args->{output_type}'" if $@; # # Process tables definitions, create nodes # my %nj_registry; # for locations of fields for natural joins my @fk_registry; # for locations of fields for foreign keys TABLE: for my $table ($schema->get_tables) { my $table_name = $table->name; if (@skip_tables_like or keys %skip_tables) { next TABLE if $skip_tables{$table_name}; for my $regex (@skip_tables_like) { next TABLE if $table_name =~ $regex; } } my @fields = $table->get_fields; if ($args->{show_fk_only}) { @fields = grep { $_->is_foreign_key } @fields; } my $field_str = ''; if ($args->{show_fields}) { my @fmt_fields; for my $field (@fields) { my $field_info; if ($args->{show_datatypes}) { my $field_type = $field->data_type; my $size = $field->size; if ( $args->{friendly_ints} && $size && (lc($field_type) eq 'integer')) { # Automatically translate to int2, int4, int8 # Type (Bits) Max. Signed/Unsigned Length # tinyint* (8) 128 3 # 255 3 # smallint (16) 32767 5 # 65535 5 # mediumint* (24) 8388607 7 # 16777215 8 # int (32) 2147483647 10 # 4294967295 11 # bigint (64) 9223372036854775807 19 # 18446744073709551615 20 # # * tinyint and mediumint are nonstandard extensions which are # only available under MySQL (to my knowledge) if ($size <= 3 and $args->{friendly_ints_extended}) { $field_type = 'tinyint'; } elsif ($size <= 5) { $field_type = 'smallint'; } elsif ($size <= 8 and $args->{friendly_ints_extended}) { $field_type = 'mediumint'; } elsif ($size <= 11) { $field_type = 'integer'; } else { $field_type = 'bigint'; } } $field_info = $field_type; if ( $args->{show_sizes} && $size && ($field_type =~ /^ (?: NUMERIC | DECIMAL | (VAR)?CHAR2? ) $/ix)) { $field_info .= '(' . $size . ')'; } } my $constraints; if ($args->{show_constraints}) { my @constraints; push(@constraints, $field->is_auto_increment ? 'PA' : 'PK') if $field->is_primary_key; push(@constraints, 'FK') if $field->is_foreign_key; push(@constraints, 'U') if $field->is_unique; push(@constraints, 'N') if $field->is_nullable; $constraints = join(',', @constraints); } # construct the field line from all info gathered so far push @fmt_fields, join(' ', '-', $field->name, $field_info || (), $constraints ? "[$constraints]" : (),); } # join field lines with graphviz formatting $field_str = join('\l', @fmt_fields) . '\l'; } my $index_str = ''; if ($args->{show_indexes}) { my @fmt_indexes; for my $index ($table->get_indices) { next unless $index->is_valid; push @fmt_indexes, join(' ', '*', $args->{show_index_names} ? $index->name . ':' : (), join(', ', $index->fields), ($index->type eq 'UNIQUE') ? '[U]' : (), ); } # join index lines with graphviz formatting (if any indexes at all) $index_str = join('\l', @fmt_indexes) . '\l' if @fmt_indexes; } my $name_str = $table_name . '\n'; # escape spaces for ($name_str, $field_str, $index_str) { $_ =~ s/ /\\ /g; } my $node_args; # only the 'record' type supports nice formatting if ($args->{node}{shape} eq 'record') { # the necessity to supply shape => 'record' is a graphviz bug $node_args = { shape => 'record', label => sprintf('{%s}', join('|', $name_str, $field_str || (), $index_str || (),),), }; } else { my $sep = sprintf('%s\n', '-' x ((length $table_name) + 2)); $node_args = { label => join($sep, $name_str, $field_str || (), $index_str || (),), }; } if (my $cluster_name = $cluster{$table_name}) { $node_args->{cluster} = $cluster_name; } $gv->add_node(qq["$table_name"], %$node_args); debug("Processing table '$table_name'"); debug("Fields = ", join(', ', map { $_->name } @fields)) if $DEBUG; for my $f (@fields) { my $name = $f->name or next; my $is_pk = $f->is_primary_key; my $is_unique = $f->is_unique; # # Decide if we should skip this field. # if ($args->{natural_join}) { next unless $is_pk || $f->is_foreign_key; } my $constraints = $f->{'constraints'}; if ($args->{natural_join} && !$skip_fields{$name}) { push @{ $nj_registry{$name} }, $table_name; } } unless ($args->{natural_join}) { for my $c ($table->get_constraints) { next unless $c->type eq FOREIGN_KEY; my $fk_table = $c->reference_table or next; for my $field_name ($c->fields) { for my $fk_field ($c->reference_fields) { next unless defined $schema->get_table($fk_table); # a condition is optional if at least one fk is nullable push @fk_registry, [ $table_name, $fk_table, scalar(grep { $_->is_nullable } ($c->fields)) ]; } } } } } # # Process relationships, create edges # my (@table_bunches, %optional_constraints); if ($args->{natural_join}) { for my $field_name (keys %nj_registry) { my @table_names = @{ $nj_registry{$field_name} || [] } or next; next if scalar @table_names == 1; push @table_bunches, [@table_names]; } } else { for my $i (0 .. $#fk_registry) { my $fk = $fk_registry[$i]; push @table_bunches, [ $fk->[0], $fk->[1] ]; $optional_constraints{$i} = $fk->[2]; } } my %done; for my $bi (0 .. $#table_bunches) { my @tables = @{ $table_bunches[$bi] }; for my $i (0 .. $#tables) { my $table1 = $tables[$i]; for my $j (1 .. $#tables) { next if $i == $j; my $table2 = $tables[$j]; next if $done{$table1}{$table2}; debug("Adding edge '$table2' -> '$table1'"); $gv->add_edge( qq["$table2"], qq["$table1"], arrowhead => $optional_constraints{$bi} ? 'empty' : 'normal', ); $done{$table1}{$table2} = 1; } } } # # Print the image # if (my $out = $args->{out_file}) { if (openhandle($out)) { print $out $gv->$output_method; } else { open my $fh, '>', $out or die "Can't write '$out': $!\n"; binmode $fh; print $fh $gv->$output_method; close $fh; } } else { return $gv->$output_method; } } 1; =pod =head1 AUTHOR Ken Youens-Clark Ekclark@cpan.orgE Jonathan Yu Efrequency@cpan.orgE =head1 SEE ALSO SQL::Translator, GraphViz =cut