X-Git-Url: http://git.shadowcat.co.uk/gitweb/gitweb.cgi?a=blobdiff_plain;f=lib%2FSQL%2FTranslator%2FProducer%2FGraphViz.pm;h=3c026eb60c7bfe29fea2caee5ebd46748d52cfd4;hb=935800450f88b0500c4fa7c3b174cd22b5f9eb56;hp=8b8d8abae001f820f2417bae95afe3e8912cbc68;hpb=14d7eb563249fe29e71154e3ccf0e11fff75c3be;p=dbsrgits%2FSQL-Translator.git diff --git a/lib/SQL/Translator/Producer/GraphViz.pm b/lib/SQL/Translator/Producer/GraphViz.pm index 8b8d8ab..3c026eb 100644 --- a/lib/SQL/Translator/Producer/GraphViz.pm +++ b/lib/SQL/Translator/Producer/GraphViz.pm @@ -1,256 +1,561 @@ package SQL::Translator::Producer::GraphViz; -# ------------------------------------------------------------------- -# $Id: GraphViz.pm,v 1.1 2003-04-24 16:15:13 kycl4rk Exp $ -# ------------------------------------------------------------------- -# Copyright (C) 2003 Ken Y. Clark -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License as -# published by the Free Software Foundation; version 2. -# -# This program is distributed in the hope that it will be useful, but -# WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA -# 02111-1307 USA -# ------------------------------------------------------------------- +=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 colums 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 Data::Dumper; +use SQL::Translator::Schema::Constants; use SQL::Translator::Utils qw(debug); +use Scalar::Util qw/openhandle/; use vars qw[ $VERSION $DEBUG ]; -$VERSION = sprintf "%d.%02d", q$Revision: 1.1 $ =~ /(\d+)\.(\d+)/; +$VERSION = '1.59'; $DEBUG = 0 unless defined $DEBUG; -use constant VALID_LAYOUT => { - dot => 1, - neato => 1, - twopi => 1, -}; - -use constant VALID_NODE_SHAPE => { - record => 1, - plaintext => 1, - ellipse => 1, - circle => 1, - egg => 1, - triangle => 1, - box => 1, - diamond => 1, - trapezium => 1, - parallelogram => 1, - house => 1, - hexagon => 1, - octagon => 1, -}; - -use constant VALID_OUTPUT => { - canon => 1, - text => 1, - ps => 1, - hpgl => 1, - pcl => 1, - mif => 1, - pic => 1, - gd => 1, - gd2 => 1, - gif => 1, - jpeg => 1, - png => 1, - wbmp => 1, - cmap => 1, - ismap => 1, - imap => 1, - vrml => 1, - vtx => 1, - mp => 1, - fig => 1, - svg => 1, - plain => 1, -}; - sub produce { - my ($t, $data) = @_; + my $t = shift; + my $schema = $t->schema; my $args = $t->producer_args; local $DEBUG = $t->debug; - debug("Data =\n", Dumper( $data )); - debug("Producer args =\n", Dumper( $args )); - - my $out_file = $args->{'out_file'}; - my $layout = $args->{'layout'}; - my $node_shape = $args->{'node_shape'}; - my $output_type = $args->{'output_type'}; - my $add_color = $args->{'add_color'}; - my $natural_join = $args->{'natural_join'}; - my $join_pk_only = $args->{'join_pk_only'}; - my $skip_fields = $args->{'skip_fields'}; - my %skip = map { s/^\s+|\s+$//g; $_, 1 } - split ( /,/, $skip_fields ); - $natural_join ||= $join_pk_only; - - die "Invalid layout '$layout'" unless VALID_LAYOUT->{ $layout }; - die "Invalid output type: '$output_type'" - unless VALID_OUTPUT->{ $output_type }; - die "Invalid node shape'$node_shape'" - unless VALID_NODE_SHAPE->{ $node_shape }; + + # 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 GraphViz and see if we can produce the output type. + # Create a blank GraphViz object and see if we can produce the output type. # - my $gv = GraphViz->new( - directed => $natural_join ? 0 : 1, - layout => $layout, - no_overlap => 1, - bgcolor => $add_color ? 'lightgoldenrodyellow' : 'white', - node => { - shape => $node_shape, - style => 'filled', - fillcolor => 'white' - }, - ) or die "Can't create GraphViz object\n"; + my $gv = GraphViz->new( %$args ) + or die sprintf ("Can't create GraphViz object: %s\n", + $@ || 'reason unknown' + ); - my %nj_registry; # for locations of fields for natural joins - my @fk_registry; # for locations of fields for foreign keys + 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 $@; # - # If necessary, pre-process fields to find foreign keys. + # Process tables definitions, create nodes # - if ( $natural_join ) { - my ( %common_keys, %pk ); - for my $table ( values %$data ) { - for my $index ( - @{ $table->{'indices'} || [] }, - @{ $table->{'constraints'} || [] }, - ) { - my @fields = @{ $index->{'fields'} || [] } or next; - if ( $index->{'type'} eq 'primary_key' ) { - $pk{ $_ } = 1 for @fields; - } - } + my %nj_registry; # for locations of fields for natural joins + my @fk_registry; # for locations of fields for foreign keys - for my $field ( values %{ $table->{'fields'} } ) { - push @{ $common_keys{ $field->{'name'} } }, - $table->{'table_name'}; - } + 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; + } } - for my $field ( keys %common_keys ) { - my @tables = @{ $common_keys{ $field } }; - next unless scalar @tables > 1; - for my $table ( @tables ) { - next if $join_pk_only and !defined $pk{ $field }; - $data->{ $table }{'fields'}{ $field }{'is_fk'} = 1; - } + my @fields = $table->get_fields; + if ( $args->{show_fk_only} ) { + @fields = grep { $_->is_foreign_key } @fields; } - } - else { - for my $table ( values %$data ) { - for my $field ( values %{ $table->{'fields'} } ) { - for my $constraint ( - grep { $_->{'type'} eq 'foreign_key' } - @{ $field->{'constraints'} } - ) { - my $ref_table = $constraint->{'reference_table'} or next; - my @ref_fields = @{ $constraint->{'reference_fields'}||[] }; - - unless ( @ref_fields ) { - for my $field ( - values %{ $data->{ $ref_table }{'fields'} } - ) { - for my $pk ( - grep { $_->{'type'} eq 'primary_key' } - @{ $field->{'constraints'} } - ) { - push @ref_fields, @{ $pk->{'fields'} }; - } - } - - $constraint->{'reference_fields'} = [ @ref_fields ]; - } - for my $ref_field (@{$constraint->{'reference_fields'}}) { - $data->{$ref_table}{'fields'}{$ref_field}{'is_fk'} = 1; - } + 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, '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'; + } - } - for my $table ( - map { $_->[1] } - sort { $a->[0] <=> $b->[0] } - map { [ $_->{'order'}, $_ ] } - values %$data - ) { - my $table_name = $table->{'table_name'}; - $gv->add_node( $table_name ); + 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; + } - debug("Processing table '$table_name'"); + my $name_str = $table_name . '\n'; - my @fields = - map { $_->[1] } - sort { $a->[0] <=> $b->[0] } - map { [ $_->{'order'}, $_ ] } - values %{ $table->{'fields'} }; - - debug("Fields = ", join(', ', map { $_->{'name'} } @fields)); - - my ( %pk, %unique ); - for my $index ( - @{ $table->{'indices'} || [] }, - @{ $table->{'constraints'} || [] }, - ) { - my @fields = @{ $index->{'fields'} || [] } or next; - if ( $index->{'type'} eq 'primary_key' ) { - $pk{ $_ } = 1 for @fields; - } - elsif ( $index->{'type'} eq 'unique' ) { - $unique{ $_ } = 1 for @fields; - } + # escape spaces + for ($name_str, $field_str, $index_str) { + $_ =~ s/ /\\ /g; } - debug("Primary keys = ", join(', ', sort keys %pk)); - debug("Unique = ", join(', ', sort keys %unique)); + 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)); for my $f ( @fields ) { - my $name = $f->{'name'} or next; - my $is_pk = $pk{ $name }; - my $is_unique = $unique{ $name }; + 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 ( $natural_join ) { - next unless $is_pk || $f->{'is_fk'}; - } - else { - next unless $is_pk || - grep { $_->{'type'} eq 'foreign_key' } - @{ $f->{'constraints'} } - ; + if ( $args->{natural_join} ) { + next unless $is_pk || $f->is_foreign_key; } my $constraints = $f->{'constraints'}; - if ( $natural_join && !$skip{ $name } ) { + if ( $args->{natural_join} && !$skip_fields{ $name } ) { push @{ $nj_registry{ $name } }, $table_name; } - elsif ( @{ $constraints || [] } ) { - for my $constraint ( @$constraints ) { - next unless $constraint->{'type'} eq 'foreign_key'; - for my $fk_field ( - @{ $constraint->{'reference_fields'} || [] } - ) { - my $fk_table = $constraint->{'reference_table'}; - next unless defined $data->{ $fk_table }; - push @fk_registry, [ $table_name, $fk_table ]; + } + + 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)) + ]; } } } @@ -258,10 +563,10 @@ sub produce { } # - # Make the connections. + # Process relationships, create edges # - my @table_bunches; - if ( $natural_join ) { + 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; @@ -269,34 +574,47 @@ sub produce { } } else { - @table_bunches = @fk_registry; + 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 $bunch ( @table_bunches ) { - my @tables = @$bunch; + for my $bi (0 .. $#table_bunches) { + my @tables = @{$table_bunches[$bi]}; for my $i ( 0 .. $#tables ) { my $table1 = $tables[ $i ]; - for my $j ( 0 .. $#tables ) { + for my $j ( 1 .. $#tables ) { + next if $i == $j; my $table2 = $tables[ $j ]; - next if $table1 eq $table2; next if $done{ $table1 }{ $table2 }; - $gv->add_edge( $table1, $table2 ); + debug("Adding edge '$table2' -> '$table1'"); + $gv->add_edge( + qq["$table2"], + qq["$table1"], + arrowhead => $optional_constraints{$bi} ? 'empty' : 'normal', + ); $done{ $table1 }{ $table2 } = 1; - $done{ $table2 }{ $table1 } = 1; } } } # - # Print the image. + # Print the image # - my $output_method = "as_$output_type"; - if ( $out_file ) { - open my $fh, ">$out_file" or die "Can't write '$out_file': $!\n"; - print $fh $gv->$output_method; - close $fh; + 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; @@ -305,14 +623,18 @@ sub produce { 1; +# ------------------------------------------------------------------- + =pod -=head1 NAME +=head1 AUTHOR -SQL::Translator::Producer::GraphViz - GraphViz producer for SQL::Translator +Ken Youens-Clark Ekclark@cpan.orgE -=head1 AUTHOR +Jonathan Yu Efrequency@cpan.orgE + +=head1 SEE ALSO -Ken Y. Clark Ekclark@cpan.orgE +SQL::Translator, GraphViz =cut