add html profile
[scpubgit/Q-Branch.git] / lib / SQL / Abstract / Tree.pm
1 package SQL::Abstract::Tree;
2
3 use strict;
4 use warnings;
5 use Carp;
6
7
8 use base 'Class::Accessor::Grouped';
9
10 __PACKAGE__->mk_group_accessors( simple => $_ ) for qw(
11    newline indent_string indent_amount colormap indentmap
12 );
13
14 # Parser states for _recurse_parse()
15 use constant PARSE_TOP_LEVEL => 0;
16 use constant PARSE_IN_EXPR => 1;
17 use constant PARSE_IN_PARENS => 2;
18 use constant PARSE_RHS => 3;
19
20 # These SQL keywords always signal end of the current expression (except inside
21 # of a parenthesized subexpression).
22 # Format: A list of strings that will be compiled to extended syntax (ie.
23 # /.../x) regexes, without capturing parentheses. They will be automatically
24 # anchored to word boundaries to match the whole token).
25 my @expression_terminator_sql_keywords = (
26   'SELECT',
27   'FROM',
28   '(?:
29     (?:
30         (?: \b (?: LEFT | RIGHT | FULL ) \s+ )?
31         (?: \b (?: CROSS | INNER | OUTER ) \s+ )?
32     )?
33     JOIN
34   )',
35   'ON',
36   'WHERE',
37   'EXISTS',
38   'GROUP \s+ BY',
39   'HAVING',
40   'ORDER \s+ BY',
41   'LIMIT',
42   'OFFSET',
43   'FOR',
44   'UNION',
45   'INTERSECT',
46   'EXCEPT',
47   'RETURNING',
48 );
49
50 # These are binary operator keywords always a single LHS and RHS
51 # * AND/OR are handled separately as they are N-ary
52 # * so is NOT as being unary
53 # * BETWEEN without paranthesis around the ANDed arguments (which
54 #   makes it a non-binary op) is detected and accomodated in
55 #   _recurse_parse()
56 my $stuff_around_mathops = qr/[\w\s\`\'\"\)]/;
57 my @binary_op_keywords = (
58   ( map
59     {
60       ' ^ '  . quotemeta ($_) . "(?= \$ | $stuff_around_mathops ) ",
61       " (?<= $stuff_around_mathops)" . quotemeta ($_) . "(?= \$ | $stuff_around_mathops ) ",
62     }
63     (qw/< > != <> = <= >=/)
64   ),
65   ( map
66     { '\b (?: NOT \s+)?' . $_ . '\b' }
67     (qw/IN BETWEEN LIKE/)
68   ),
69 );
70
71 my $tokenizer_re_str = join("\n\t|\n",
72   ( map { '\b' . $_ . '\b' } @expression_terminator_sql_keywords, 'AND', 'OR', 'NOT'),
73   @binary_op_keywords,
74 );
75
76 my $tokenizer_re = qr/ \s* ( $tokenizer_re_str | \( | \) | \? ) \s* /xi;
77
78 sub _binary_op_keywords { @binary_op_keywords }
79
80 my %indents = (
81    select => 0,
82    where  => 1,
83    from   => 1,
84 );
85
86 my %profiles = (
87    console => {
88       indent_string => ' ',
89       indent_amount => 2,
90       newline       => "\n",
91       colormap      => {},
92       indentmap     => { %indents },
93    },
94    console_monochrome => {
95       indent_string => ' ',
96       indent_amount => 2,
97       newline       => "\n",
98       colormap      => {},
99       indentmap     => { %indents },
100    },
101    html => {
102       indent_string => '&nbsp;',
103       indent_amount => 2,
104       newline       => "<br />\n",
105       colormap      => {
106          select => ['<span class="select">', '</span>'],
107          where  => ['<span class="where">', '</span>'],
108          from   => ['<span class="from">', '</span>'],
109       },
110       indentmap     => { %indents },
111    },
112    none => {
113       colormap      => {},
114       indentmap     => {},
115    },
116 );
117
118 eval {
119    require Term::ANSIColor;
120    $profiles{console}->{colormap} = {
121       select => [Term::ANSIColor::color('red'), Term::ANSIColor::color('reset')],
122       where  => [Term::ANSIColor::color('green'), Term::ANSIColor::color('reset')],
123       from   => [Term::ANSIColor::color('cyan'), Term::ANSIColor::color('reset')],
124    };
125 };
126
127 sub new {
128    my ($class, $args) = @_;
129
130    my $profile = delete $args->{profile} || 'none';
131    my $data = {%{$profiles{$profile}}, %{$args||{}}};
132
133    bless $data, $class
134 }
135
136 sub parse {
137   my ($self, $s) = @_;
138
139   # tokenize string, and remove all optional whitespace
140   my $tokens = [];
141   foreach my $token (split $tokenizer_re, $s) {
142     push @$tokens, $token if (length $token) && ($token =~ /\S/);
143   }
144
145   my $tree = $self->_recurse_parse($tokens, PARSE_TOP_LEVEL);
146   return $tree;
147 }
148
149 sub _recurse_parse {
150   my ($self, $tokens, $state) = @_;
151
152   my $left;
153   while (1) { # left-associative parsing
154
155     my $lookahead = $tokens->[0];
156     if ( not defined($lookahead)
157           or
158         ($state == PARSE_IN_PARENS && $lookahead eq ')')
159           or
160         ($state == PARSE_IN_EXPR && grep { $lookahead =~ /^ $_ $/xi } ('\)', @expression_terminator_sql_keywords ) )
161           or
162         ($state == PARSE_RHS && grep { $lookahead =~ /^ $_ $/xi } ('\)', @expression_terminator_sql_keywords, @binary_op_keywords, 'AND', 'OR', 'NOT' ) )
163     ) {
164       return $left;
165     }
166
167     my $token = shift @$tokens;
168
169     # nested expression in ()
170     if ($token eq '(' ) {
171       my $right = $self->_recurse_parse($tokens, PARSE_IN_PARENS);
172       $token = shift @$tokens   or croak "missing closing ')' around block " . $self->unparse($right);
173       $token eq ')'             or croak "unexpected token '$token' terminating block " . $self->unparse($right);
174
175       $left = $left ? [@$left, [PAREN => [$right] ]]
176                     : [PAREN  => [$right] ];
177     }
178     # AND/OR
179     elsif ($token =~ /^ (?: OR | AND ) $/xi )  {
180       my $op = uc $token;
181       my $right = $self->_recurse_parse($tokens, PARSE_IN_EXPR);
182
183       # Merge chunks if logic matches
184       if (ref $right and $op eq $right->[0]) {
185         $left = [ (shift @$right ), [$left, map { @$_ } @$right] ];
186       }
187       else {
188        $left = [$op => [$left, $right]];
189       }
190     }
191     # binary operator keywords
192     elsif (grep { $token =~ /^ $_ $/xi } @binary_op_keywords ) {
193       my $op = uc $token;
194       my $right = $self->_recurse_parse($tokens, PARSE_RHS);
195
196       # A between with a simple LITERAL for a 1st RHS argument needs a
197       # rerun of the search to (hopefully) find the proper AND construct
198       if ($op eq 'BETWEEN' and $right->[0] eq 'LITERAL') {
199         unshift @$tokens, $right->[1][0];
200         $right = $self->_recurse_parse($tokens, PARSE_IN_EXPR);
201       }
202
203       $left = [$op => [$left, $right] ];
204     }
205     # expression terminator keywords (as they start a new expression)
206     elsif (grep { $token =~ /^ $_ $/xi } @expression_terminator_sql_keywords ) {
207       my $op = uc $token;
208       my $right = $self->_recurse_parse($tokens, PARSE_IN_EXPR);
209       $left = $left ? [ $left,  [$op => [$right] ]]
210                     : [ $op => [$right] ];
211     }
212     # NOT (last as to allow all other NOT X pieces first)
213     elsif ( $token =~ /^ not $/ix ) {
214       my $op = uc $token;
215       my $right = $self->_recurse_parse ($tokens, PARSE_RHS);
216       $left = $left ? [ @$left, [$op => [$right] ]]
217                     : [ $op => [$right] ];
218
219     }
220     # literal (eat everything on the right until RHS termination)
221     else {
222       my $right = $self->_recurse_parse ($tokens, PARSE_RHS);
223       $left = $left ? [ $left, [LITERAL => [join ' ', $token, $self->unparse($right)||()] ] ]
224                     : [ LITERAL => [join ' ', $token, $self->unparse($right)||()] ];
225     }
226   }
227 }
228
229 sub format_keyword {
230   my ($self, $keyword) = @_;
231
232   if (my $around = $self->colormap->{lc $keyword}) {
233      $keyword = "$around->[0]$keyword$around->[1]";
234   }
235
236   return $keyword
237 }
238
239 sub whitespace {
240    my ($self, $keyword, $depth) = @_;
241
242    my $before = '';
243    my $after  = ' ';
244    if (defined $self->indentmap->{lc $keyword}) {
245       $before = $self->newline . $self->indent($depth + $self->indentmap->{lc $keyword});
246    }
247    $before = '' if $depth == 0 and lc $keyword eq 'select';
248    return [$before, $after];
249 }
250
251 sub indent { ($_[0]->indent_string||'') x ( ( $_[0]->indent_amount || 0 ) * $_[1] ) }
252
253 sub _is_select {
254    my $tree = shift;
255    $tree = $tree->[0] while ref $tree;
256
257    defined $tree && lc $tree eq 'select';
258 }
259
260 sub unparse {
261   my ($self, $tree, $depth) = @_;
262
263   $depth ||= 0;
264
265   if (not $tree ) {
266     return '';
267   }
268
269   my $car = $tree->[0];
270   my $cdr = $tree->[1];
271
272   if (ref $car) {
273     return join ('', map $self->unparse($_, $depth), @$tree);
274   }
275   elsif ($car eq 'LITERAL') {
276     return $cdr->[0];
277   }
278   elsif ($car eq 'PAREN') {
279     return '(' .
280       join(' ',
281         map $self->unparse($_, $depth + 2), @{$cdr}) .
282     (_is_select($cdr)?( $self->newline||'' ).$self->indent($depth + 1):'') . ')';
283   }
284   elsif ($car eq 'OR' or $car eq 'AND' or (grep { $car =~ /^ $_ $/xi } @binary_op_keywords ) ) {
285     return join (" $car ", map $self->unparse($_, $depth), @{$cdr});
286   }
287   else {
288     my ($l, $r) = @{$self->whitespace($car, $depth)};
289     return sprintf "$l%s %s$r", $self->format_keyword($car), $self->unparse($cdr, $depth);
290   }
291 }
292
293 sub format { my $self = shift; $self->unparse($self->parse(@_)) }
294
295 1;
296
297 =pod
298
299 =head1 SYNOPSIS
300
301  my $sqla_tree = SQL::Abstract::Tree->new({ profile => 'console' });
302
303  print $sqla_tree->format('SELECT * FROM foo WHERE foo.a > 2');
304
305  # SELECT *
306  #   FROM foo
307  #   WHERE foo.a > 2
308