File Coverage

blib/lib/SQL/Translator/Producer/Diagram.pm
Criterion Covered Total %
statement 7 9 77.7
branch n/a
condition n/a
subroutine 3 3 100.0
pod n/a
total 10 12 83.3


line stmt bran cond sub pod time code
1             package SQL::Translator::Producer::Diagram;
2              
3             =head1 NAME
4              
5             SQL::Translator::Producer::Diagram - ER diagram producer for SQL::Translator
6              
7             =head1 SYNOPSIS
8              
9             Use via SQL::Translator:
10              
11             use SQL::Translator;
12              
13             my $t = SQL::Translator->new(
14             from => 'MySQL',
15             to => 'Diagram',
16             producer_args => {
17             # All args are optional
18             out_file => 'schema.png',# if not provided will return from translate()
19             output_type => 'png', # is default or 'jpeg'
20             title => 'My Schema', # default is filename
21             font_size => 'medium', # is default or 'small,' 'large'
22             imap_file => '', # filename to write image map coords
23             imap_url => '', # base URL for image map
24             gutter => 30 # is default, px distance b/w cols
25             num_columns => 5, # the number of columns
26             no_lines => 1, # do not draw lines to show FKs
27             add_color => 1, # give it some color
28             show_fk_only => 1, # show only fields used in FKs
29             join_pk_only => 1, # use only primary keys to figure PKs
30             natural_join => 1, # intuit FKs if not defined
31             skip_fields => [...], # list* of field names to exclude
32             skip_tables => [...], # list* of table names to exclude
33             skip_tables_like => [...], # list* of regexen to exclude tables
34             }
35             ) or die SQL::Translator->error;
36             $t->translate;
37              
38             * "list" can be either an array-ref or a comma-separated string
39              
40             =cut
41              
42 1     1   7 use strict;
  1         3  
  1         45  
43 1     1   6 use warnings;
  1         1  
  1         50  
44 1     1   159 use GD;
  0            
  0            
45             use Data::Dumper;
46             use SQL::Translator::Schema::Constants;
47             use SQL::Translator::Utils qw(debug);
48              
49             our $DEBUG;
50             our $VERSION = '1.66';
51             $DEBUG = 0 unless defined $DEBUG;
52              
53             use constant VALID_FONT_SIZE => {
54             small => 1,
55             medium => 1,
56             large => 1,
57             huge => 1,
58             };
59              
60             use constant VALID_IMAGE_TYPE => {
61             png => 1,
62             jpeg => 1,
63             };
64              
65             sub produce {
66             my $t = shift;
67             my $schema = $t->schema;
68             my $args = $t->producer_args;
69             local $DEBUG = $t->debug;
70             debug("Schema =\n", Dumper($schema)) if $DEBUG;
71             debug("Producer args =\n", Dumper($args)) if $DEBUG;
72              
73             my $out_file = $args->{'out_file'} || '';
74             my $output_type = $args->{'output_type'} || 'png';
75             my $title = $args->{'title'} || $t->filename;
76             my $font_size = $args->{'font_size'} || 'medium';
77             my $imap_file = $args->{'imap_file'} || '';
78             my $imap_url = $args->{'imap_url'} || '';
79             my $gutter = $args->{'gutter'} || 30; # distance b/w columns
80             my $num_columns = $args->{'num_columns'} || $args->{'no_columns'} || '';
81             my $no_lines = $args->{'no_lines'};
82             my $add_color = $args->{'add_color'};
83             my $show_fk_only = $args->{'show_fk_only'};
84             my $join_pk_only = $args->{'join_pk_only'};
85             my $natural_join = $args->{'natural_join'} || $join_pk_only;
86             my %skip_field = map { $_, 1 } (
87             ref $args->{'skip_fields'} eq 'ARRAY'
88             ? @{ $args->{'skip_fields'} }
89             : split(/\s*,\s*/, $args->{'skip_fields'} || '')
90             );
91              
92             my %skip_table = map { $_, 1 } (
93             ref $args->{'skip_tables'} eq 'ARRAY'
94             ? @{ $args->{'skip_tables'} }
95             : split(/\s*,\s*/, $args->{'skip_tables'} || '')
96             );
97              
98             my @skip_tables_like = map {qr/$_/} (
99             ref $args->{'skip_tables_like'} eq 'ARRAY'
100             ? @{ $args->{'skip_tables_like'} }
101             : split(/\s*,\s*/, $args->{'skip_tables_like'} || '')
102             );
103              
104             my @table_names;
105             if ($natural_join) {
106             $schema->make_natural_joins(
107             join_pk_only => $join_pk_only,
108             skip_fields => $args->{'skip_fields'},
109             );
110              
111             my $g = $schema->as_graph_pm;
112             my $d = Graph::Traversal::DFS->new($g, next_alphabetic => 1);
113             $d->preorder;
114              
115             @table_names = $d->dfs;
116             } else {
117             @table_names = map { $_->name } $schema->get_tables;
118             }
119              
120             die "Invalid image type '$output_type'"
121             unless VALID_IMAGE_TYPE->{$output_type};
122             die "Invalid font size '$font_size'"
123             unless VALID_FONT_SIZE->{$font_size};
124              
125             #
126             # Layout the image.
127             #
128             my $font
129             = $font_size eq 'small' ? gdTinyFont
130             : $font_size eq 'medium' ? gdSmallFont
131             : $font_size eq 'large' ? gdLargeFont
132             : gdGiantFont;
133              
134             my $num_tables = scalar @table_names;
135             $num_columns = 0 unless $num_columns =~ /^\d+$/;
136             $num_columns ||= sprintf("%.0f", sqrt($num_tables) + .5);
137             $num_columns ||= .5;
138             my $no_per_col = sprintf("%.0f", $num_tables / $num_columns + .5);
139              
140             my @shapes;
141             my ($max_x, $max_y); # the furthest x and y used
142             my $orig_y = 40; # used to reset y for each column
143             my ($x, $y) = (30, $orig_y); # where to start
144             my $cur_col = 1; # the current column
145             my $no_this_col = 0; # number of tables in current column
146             my $this_col_x = $x; # current column's x
147             my %nj_registry; # for locations of fields for natural joins
148             my @fk_registry; # for locations of fields for foreign keys
149             my %table_x; # for max x of each table
150             my $field_no; # counter to give distinct no. to each field
151             my %coords; # holds fields coordinates
152             my @imap_coords; # for making clickable image map
153             my %legend;
154              
155             TABLE:
156             for my $table_name (@table_names) {
157             my $table = $schema->get_table($table_name);
158              
159             if (@skip_tables_like or keys %skip_table) {
160             next TABLE if $skip_table{$table_name};
161             for my $regex (@skip_tables_like) {
162             next TABLE if $table_name =~ $regex;
163             }
164             }
165              
166             my $top = $y;
167             push @shapes, [ 'string', $font, $this_col_x, $y, $table_name, 'black' ];
168             $y += $font->height + 2;
169             my $below_table_name = $y;
170             $y += 2;
171             my $this_max_x = $this_col_x + ($font->width * length($table_name));
172              
173             debug("Processing table '$table_name'");
174              
175             my @fields = $table->get_fields;
176             debug("Fields = ", join(', ', map { $_->name } @fields)) if $DEBUG;
177              
178             my (@fld_desc, $max_name, $max_desc);
179             for my $f (@fields) {
180             my $name = $f->name or next;
181             my $is_pk = $f->is_primary_key;
182              
183             my @attr;
184              
185             #
186             # Decide if we should skip this field.
187             #
188             if ($show_fk_only) {
189             next unless $is_pk || $f->is_foreign_key;
190             }
191              
192             if ($is_pk) {
193             push @attr, 'PK';
194             $legend{'Primary key'} = '[PK]';
195             }
196              
197             if ($f->is_unique) {
198             push @attr, 'U';
199             $legend{'Unique constraint'} = '[U]';
200             }
201              
202             if ($f->is_foreign_key) {
203             push @attr, 'FK';
204             $legend{'Foreign Key'} = '[FK]';
205             }
206              
207             my $attr = '';
208             if (@attr) {
209             $attr .= '[' . join(', ', @attr) . ']';
210             }
211              
212             my $desc = $f->data_type;
213             $desc .= '(' . $f->size . ')'
214             if $f->size
215             && $f->data_type =~ /^(VAR)?CHAR2?$/i;
216              
217             my $nlen = length $name;
218             my $dlen = length $desc;
219             $max_name = $nlen if $nlen > ($max_name || 0);
220             $max_desc = $dlen if $dlen > ($max_desc || 0);
221             push @fld_desc, [ $name, $desc, $f->{'name'}, $is_pk, $attr ];
222             }
223              
224             $max_name += 2;
225             $max_desc += 2;
226             for my $fld_desc (@fld_desc) {
227             my ($name, $desc, $orig_name, $is_pk, $attr) = @$fld_desc;
228             my $diff1 = $max_name - length $name;
229             my $diff2 = $max_desc - length $desc;
230             $name .= ' ' x $diff1;
231             $desc .= ' ' x $diff2;
232             $desc = $name . $desc . $attr;
233              
234             push @shapes, [ 'string', $font, $this_col_x, $y, $desc, 'black' ];
235             $y += $font->height + 2;
236             my $length = $this_col_x + ($font->width * length($desc));
237             $this_max_x = $length if $length > $this_max_x;
238              
239             my $constraints = $table->{'fields'}{$orig_name}{'constraints'};
240              
241             if ($natural_join && !$skip_field{$orig_name}) {
242             push @{ $nj_registry{$orig_name} }, $table_name;
243             }
244              
245             my $y_link = $y - $font->height / 2;
246             $coords{$table_name}{$orig_name}{'coords'} = {
247             left => [ $this_col_x - 6, $y_link ],
248             right => [ $length + 2, $y_link ],
249             table => $table_name,
250             field_no => ++$field_no,
251             is_pk => $is_pk,
252             fld_name => $orig_name,
253             };
254              
255             push @imap_coords, [ $imap_url . "#$table_name-$orig_name", $this_col_x, $y - $font->height, $length, $y_link, ];
256             }
257              
258             unless ($natural_join) {
259             for my $c ($table->get_constraints) {
260             next unless $c->type eq FOREIGN_KEY;
261             my $fk_table = $c->reference_table or next;
262              
263             for my $field_name ($c->fields) {
264             for my $fk_field ($c->reference_fields) {
265             next unless defined $schema->get_table($fk_table);
266             push @fk_registry, [ [ $fk_table, $fk_field ], [ $table_name, $field_name ], ];
267             }
268             }
269             }
270             }
271              
272             $this_max_x += 5;
273             $table_x{$table_name} = $this_max_x + 5;
274             push @shapes, [ 'line', $this_col_x - 5, $below_table_name, $this_max_x, $below_table_name, 'black' ];
275             my @bounds = ($this_col_x - 5, $top - 5, $this_max_x, $y + 5);
276             if ($add_color) {
277             unshift @shapes, [ 'filledRectangle', $bounds[0], $bounds[1], $this_max_x, $below_table_name, 'khaki' ];
278             unshift @shapes, [ 'filledRectangle', @bounds, 'white' ];
279             }
280              
281             push @imap_coords, [ $imap_url . "#$table_name", $bounds[0], $bounds[1], $this_max_x, $below_table_name, ];
282              
283             push @shapes, [ 'rectangle', @bounds, 'black' ];
284             $max_x = $this_max_x if $this_max_x > ($max_x || 0);
285             $y += 25;
286              
287             if (++$no_this_col == $no_per_col) { # if we've filled up this column
288             $cur_col++; # up the column number
289             $no_this_col = 0; # reset the number of tables
290             $max_x += $gutter; # push the x over for next column
291             $this_col_x = $max_x; # remember the max x for this col
292             $max_y = $y if $y > ($max_y || 0); # note the max y
293             $y = $orig_y; # reset the y for next column
294             }
295             }
296              
297             #
298             # Connect the lines.
299             #
300             my %horz_taken;
301             my %done;
302             unless ($no_lines) {
303             my @position_bunches;
304              
305             if ($natural_join) {
306             for my $field_name (keys %nj_registry) {
307             my @positions;
308             my @table_names = @{ $nj_registry{$field_name} || [] }
309             or next;
310             next if scalar @table_names == 1;
311              
312             for my $table_name (@table_names) {
313             push @positions, $coords{$table_name}{$field_name}{'coords'};
314             }
315              
316             push @position_bunches, [@positions];
317             }
318             } else {
319             for my $pair (@fk_registry) {
320             push @position_bunches,
321             [
322             $coords{ $pair->[0][0] }{ $pair->[0][1] }{'coords'},
323             $coords{ $pair->[1][0] }{ $pair->[1][1] }{'coords'},
324             ];
325             }
326             }
327              
328             my $is_directed = $natural_join ? 0 : 1;
329              
330             for my $bunch (@position_bunches) {
331             my @positions = @$bunch;
332              
333             for my $i (0 .. $#positions) {
334             my $pos1 = $positions[$i];
335             my ($ax, $ay) = @{ $pos1->{'left'} || [] } or next;
336             my ($bx, $by) = @{ $pos1->{'right'} || [] } or next;
337             my $table1 = $pos1->{'table'};
338             my $fno1 = $pos1->{'field_no'};
339             my $is_pk = $pos1->{'is_pk'};
340             next if $join_pk_only and !$is_pk;
341              
342             for my $j (0 .. $#positions) {
343             my $pos2 = $positions[$j];
344             my ($cx, $cy) = @{ $pos2->{'left'} || [] } or next;
345             my ($dx, $dy) = @{ $pos2->{'right'} || [] } or next;
346             my $table2 = $pos2->{'table'};
347             my $fno2 = $pos2->{'field_no'};
348             next if $table1 eq $table2;
349             next if $done{$fno1}{$fno2};
350             next if $fno1 == $fno2;
351              
352             my @distances = ();
353             push @distances, [ abs($ax - $cx) + abs($ay - $cy), [ $ax, $ay, $cx, $cy ], [ 'left', 'left' ] ];
354             push @distances, [ abs($ax - $dx) + abs($ay - $dy), [ $ax, $ay, $dx, $dy ], [ 'left', 'right' ], ];
355             push @distances, [ abs($bx - $cx) + abs($by - $cy), [ $bx, $by, $cx, $cy ], [ 'right', 'left' ], ];
356             push @distances, [ abs($bx - $dx) + abs($by - $dy), [ $bx, $by, $dx, $dy ], [ 'right', 'right' ], ];
357             @distances = sort { $a->[0] <=> $b->[0] } @distances;
358             my $shortest = $distances[0];
359             my ($x1, $y1, $x2, $y2) = @{ $shortest->[1] };
360             my ($side1, $side2) = @{ $shortest->[2] };
361             my ($start, $end);
362             my $offset = 9;
363             my $col1_right = $table_x{$table1};
364             my $col2_right = $table_x{$table2};
365              
366             my $diff = 0;
367             if ($x1 == $x2) {
368             while ($horz_taken{ $x1 + $diff }) {
369             $diff = $side1 eq 'left' ? $diff - 2 : $diff + 2;
370             }
371             $horz_taken{ $x1 + $diff } = 1;
372             }
373              
374             if ($side1 eq 'left') {
375             $start = $x1 - $offset + $diff;
376             } else {
377             $start = $col1_right + $diff;
378             }
379              
380             if ($side2 eq 'left') {
381             $end = $x2 - $offset + $diff;
382             } else {
383             $end = $col2_right + $diff;
384             }
385              
386             push @shapes, [ 'line', $x1, $y1, $start, $y1, 'cadetblue' ];
387             push @shapes, [ 'line', $start, $y1, $end, $y2, 'cadetblue' ];
388             push @shapes, [ 'line', $end, $y2, $x2, $y2, 'cadetblue' ];
389              
390             if ($is_directed) {
391             if ( $side1 eq 'right' && $side2 eq 'left'
392             || $side1 eq 'left' && $side2 eq 'left') {
393             push @shapes, [ 'line', $x2 - 3, $y2 - 3, $x2, $y2, 'cadetblue' ];
394             push @shapes, [ 'line', $x2 - 3, $y2 + 3, $x2, $y2, 'cadetblue' ];
395             push @shapes, [ 'line', $x2 - 3, $y2 - 3, $x2 - 3, $y2 + 3, 'cadetblue' ];
396             } else {
397             push @shapes, [ 'line', $x2 + 3, $y2 - 3, $x2, $y2, 'cadetblue' ];
398             push @shapes, [ 'line', $x2 + 3, $y2 + 3, $x2, $y2, 'cadetblue' ];
399             push @shapes, [ 'line', $x2 + 3, $y2 - 3, $x2 + 3, $y2 + 3, 'cadetblue' ];
400             }
401             }
402              
403             $done{$fno1}{$fno2} = 1;
404             $done{$fno2}{$fno1} = 1;
405             }
406             }
407             }
408             }
409              
410             #
411             # Add the title, legend and signature.
412             #
413             my $large_font = gdLargeFont;
414             my $title_len = $large_font->width * length $title;
415             push @shapes, [ 'string', $large_font, $max_x / 2 - $title_len / 2, 10, $title, 'black' ];
416              
417             if (%legend) {
418             $max_y += 5;
419             push @shapes, [ 'string', $font, $x, $max_y - $font->height - 4, 'Legend', 'black' ];
420             $max_y += $font->height + 4;
421              
422             my $longest;
423             for my $len (map { length $_ } values %legend) {
424             $longest = $len if $len > ($longest || 0);
425             }
426             $longest += 2;
427              
428             while (my ($key, $shape) = each %legend) {
429             my $space = $longest - length $shape;
430             push @shapes, [ 'string', $font, $x, $max_y - $font->height - 4, join('', $shape, ' ' x $space, $key), 'black' ];
431              
432             $max_y += $font->height + 4;
433             }
434             }
435              
436             my $sig = 'Created by SQL::Translator ' . $t->version;
437             my $sig_len = $font->width * length $sig;
438             push @shapes, [ 'string', $font, $max_x - $sig_len, $max_y - $font->height - 4, $sig, 'black' ];
439              
440             #
441             # Render the image.
442             #
443             my $gd = GD::Image->new($max_x + 30, $max_y);
444             unless ($gd->can($output_type)) {
445             die "GD can't create images of type '$output_type'\n";
446             }
447             my %colors = map { $_->[0], $gd->colorAllocate(@{ $_->[1] }) } (
448             [ white => [ 255, 255, 255 ] ],
449             [ beige => [ 245, 245, 220 ] ],
450             [ black => [ 0, 0, 0 ] ],
451             [ lightblue => [ 173, 216, 230 ] ],
452             [ cadetblue => [ 95, 158, 160 ] ],
453             [ lightgoldenrodyellow => [ 250, 250, 210 ] ],
454             [ khaki => [ 240, 230, 140 ] ],
455             [ red => [ 255, 0, 0 ] ],
456             );
457             $gd->interlaced('true');
458             my $background_color = $add_color ? 'lightgoldenrodyellow' : 'white';
459             $gd->fill(0, 0, $colors{$background_color});
460             for my $shape (@shapes) {
461             my $method = shift @$shape;
462             my $color = pop @$shape;
463             $gd->$method(@$shape, $colors{$color});
464             }
465              
466             #
467             # Make image map.
468             #
469             debug("imap file = '$imap_file'");
470             if ($imap_file && @imap_coords) {
471             open my $fh, '>', $imap_file or die "Can't write '$imap_file': $!\n";
472             print $fh qq[\n] . qq[\n];
473             for my $rec (@imap_coords) {
474             my $href = shift @$rec;
475             print $fh q[\n];
476             }
477             print $fh qq[];
478             close $fh;
479             }
480              
481             #
482             # Print the image.
483             #
484             if ($out_file) {
485             open my $fh, '>', $out_file or die "Can't write '$out_file': $!\n";
486             binmode $fh;
487             print $fh $gd->$output_type;
488             close $fh;
489             } else {
490             return $gd->$output_type;
491             }
492             }
493              
494             1;
495              
496             =pod
497              
498             =head1 AUTHOR
499              
500             Ken Youens-Clark Ekclark@cpan.orgE.
501              
502             =cut