docs for ::Tree
[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 use List::Util;
8 use Hash::Merge 'merge';
9
10 Hash::Merge::specify_behavior({
11    SCALAR => {
12       SCALAR => sub { $_[1] },
13       ARRAY  => sub { [ $_[0], @{$_[1]} ] },
14       HASH   => sub { $_[1] },
15    },
16    ARRAY => {
17       SCALAR => sub { $_[1] },
18       ARRAY  => sub { $_[1] },
19       HASH   => sub { $_[1] },
20    },
21    HASH => {
22       SCALAR => sub { $_[1] },
23       ARRAY  => sub { [ values %{$_[0]}, @{$_[1]} ] },
24       HASH   => sub { Hash::Merge::_merge_hashes( $_[0], $_[1] ) },
25    },
26 }, 'My Behavior' );
27
28 use base 'Class::Accessor::Grouped';
29
30 __PACKAGE__->mk_group_accessors( simple => $_ ) for qw(
31    newline indent_string indent_amount colormap indentmap fill_in_placeholders
32 );
33
34 # Parser states for _recurse_parse()
35 use constant PARSE_TOP_LEVEL => 0;
36 use constant PARSE_IN_EXPR => 1;
37 use constant PARSE_IN_PARENS => 2;
38 use constant PARSE_RHS => 3;
39
40 # These SQL keywords always signal end of the current expression (except inside
41 # of a parenthesized subexpression).
42 # Format: A list of strings that will be compiled to extended syntax (ie.
43 # /.../x) regexes, without capturing parentheses. They will be automatically
44 # anchored to word boundaries to match the whole token).
45 my @expression_terminator_sql_keywords = (
46   'SELECT',
47   'UPDATE',
48   'INSERT \s+ INTO',
49   'DELETE \s+ FROM',
50   'FROM',
51   'SET',
52   '(?:
53     (?:
54         (?: \b (?: LEFT | RIGHT | FULL ) \s+ )?
55         (?: \b (?: CROSS | INNER | OUTER ) \s+ )?
56     )?
57     JOIN
58   )',
59   'ON',
60   'WHERE',
61   'VALUES',
62   'EXISTS',
63   'GROUP \s+ BY',
64   'HAVING',
65   'ORDER \s+ BY',
66   'LIMIT',
67   'OFFSET',
68   'FOR',
69   'UNION',
70   'INTERSECT',
71   'EXCEPT',
72   'RETURNING',
73   'ROW_NUMBER \s* \( \s* \) \s+ OVER',
74 );
75
76 # These are binary operator keywords always a single LHS and RHS
77 # * AND/OR are handled separately as they are N-ary
78 # * so is NOT as being unary
79 # * BETWEEN without paranthesis around the ANDed arguments (which
80 #   makes it a non-binary op) is detected and accomodated in
81 #   _recurse_parse()
82 my $stuff_around_mathops = qr/[\w\s\`\'\"\)]/;
83 my @binary_op_keywords = (
84   ( map
85     {
86       ' ^ '  . quotemeta ($_) . "(?= \$ | $stuff_around_mathops ) ",
87       " (?<= $stuff_around_mathops)" . quotemeta ($_) . "(?= \$ | $stuff_around_mathops ) ",
88     }
89     (qw/< > != <> = <= >=/)
90   ),
91   ( map
92     { '\b (?: NOT \s+)?' . $_ . '\b' }
93     (qw/IN BETWEEN LIKE/)
94   ),
95 );
96
97 my $tokenizer_re_str = join("\n\t|\n",
98   ( map { '\b' . $_ . '\b' } @expression_terminator_sql_keywords, 'AND', 'OR', 'NOT'),
99   @binary_op_keywords,
100 );
101
102 my $tokenizer_re = qr/ \s* ( $tokenizer_re_str | \( | \) | \? ) \s* /xi;
103
104 sub _binary_op_keywords { @binary_op_keywords }
105
106 my %indents = (
107    select        => 0,
108    update        => 0,
109    'insert into' => 0,
110    'delete from' => 0,
111    from          => 1,
112    where         => 0,
113    join          => 1,
114    'left join'   => 1,
115    on            => 2,
116    'group by'    => 0,
117    'order by'    => 0,
118    set           => 1,
119    into          => 1,
120    values        => 1,
121 );
122
123 my %profiles = (
124    console => {
125       fill_in_placeholders => 1,
126       indent_string => ' ',
127       indent_amount => 2,
128       newline       => "\n",
129       colormap      => {},
130       indentmap     => { %indents },
131    },
132    console_monochrome => {
133       fill_in_placeholders => 1,
134       indent_string => ' ',
135       indent_amount => 2,
136       newline       => "\n",
137       colormap      => {},
138       indentmap     => { %indents },
139    },
140    html => {
141       fill_in_placeholders => 1,
142       indent_string => '&nbsp;',
143       indent_amount => 2,
144       newline       => "<br />\n",
145       colormap      => {
146          select        => ['<span class="select">'  , '</span>'],
147          'insert into' => ['<span class="insert-into">'  , '</span>'],
148          update        => ['<span class="select">'  , '</span>'],
149          'delete from' => ['<span class="delete-from">'  , '</span>'],
150          where         => ['<span class="where">'   , '</span>'],
151          from          => ['<span class="from">'    , '</span>'],
152          join          => ['<span class="join">'    , '</span>'],
153          on            => ['<span class="on">'      , '</span>'],
154          'group by'    => ['<span class="group-by">', '</span>'],
155          'order by'    => ['<span class="order-by">', '</span>'],
156          set           => ['<span class="set">', '</span>'],
157          into          => ['<span class="into">', '</span>'],
158          values        => ['<span class="values">', '</span>'],
159       },
160       indentmap     => { %indents },
161    },
162    none => {
163       colormap      => {},
164       indentmap     => {},
165    },
166 );
167
168 eval {
169    require Term::ANSIColor;
170    $profiles{console}->{colormap} = {
171       select        => [Term::ANSIColor::color('red'), Term::ANSIColor::color('reset')],
172       'insert into' => [Term::ANSIColor::color('red'), Term::ANSIColor::color('reset')],
173       update        => [Term::ANSIColor::color('red'), Term::ANSIColor::color('reset')],
174       'delete from' => [Term::ANSIColor::color('red'), Term::ANSIColor::color('reset')],
175
176       set           => [Term::ANSIColor::color('cyan'), Term::ANSIColor::color('reset')],
177       from          => [Term::ANSIColor::color('cyan'), Term::ANSIColor::color('reset')],
178
179       where         => [Term::ANSIColor::color('green'), Term::ANSIColor::color('reset')],
180       values        => [Term::ANSIColor::color('yellow'), Term::ANSIColor::color('reset')],
181
182       join          => [Term::ANSIColor::color('magenta'), Term::ANSIColor::color('reset')],
183       'left join'   => [Term::ANSIColor::color('magenta'), Term::ANSIColor::color('reset')],
184       on            => [Term::ANSIColor::color('blue'), Term::ANSIColor::color('reset')],
185
186       'group by'    => [Term::ANSIColor::color('yellow'), Term::ANSIColor::color('reset')],
187       'order by'    => [Term::ANSIColor::color('yellow'), Term::ANSIColor::color('reset')],
188    };
189 };
190
191 sub new {
192    my $class = shift;
193    my $args  = shift || {};
194
195    my $profile = delete $args->{profile} || 'none';
196    my $data = merge( $profiles{$profile}, $args );
197
198    bless $data, $class
199 }
200
201 sub parse {
202   my ($self, $s) = @_;
203
204   # tokenize string, and remove all optional whitespace
205   my $tokens = [];
206   foreach my $token (split $tokenizer_re, $s) {
207     push @$tokens, $token if (length $token) && ($token =~ /\S/);
208   }
209
210   my $tree = $self->_recurse_parse($tokens, PARSE_TOP_LEVEL);
211   return $tree;
212 }
213
214 sub _recurse_parse {
215   my ($self, $tokens, $state) = @_;
216
217   my $left;
218   while (1) { # left-associative parsing
219
220     my $lookahead = $tokens->[0];
221     if ( not defined($lookahead)
222           or
223         ($state == PARSE_IN_PARENS && $lookahead eq ')')
224           or
225         ($state == PARSE_IN_EXPR && grep { $lookahead =~ /^ $_ $/xi } ('\)', @expression_terminator_sql_keywords ) )
226           or
227         ($state == PARSE_RHS && grep { $lookahead =~ /^ $_ $/xi } ('\)', @expression_terminator_sql_keywords, @binary_op_keywords, 'AND', 'OR', 'NOT' ) )
228     ) {
229       return $left;
230     }
231
232     my $token = shift @$tokens;
233
234     # nested expression in ()
235     if ($token eq '(' ) {
236       my $right = $self->_recurse_parse($tokens, PARSE_IN_PARENS);
237       $token = shift @$tokens   or croak "missing closing ')' around block " . $self->unparse($right);
238       $token eq ')'             or croak "unexpected token '$token' terminating block " . $self->unparse($right);
239
240       $left = $left ? [@$left, [PAREN => [$right] ]]
241                     : [PAREN  => [$right] ];
242     }
243     # AND/OR
244     elsif ($token =~ /^ (?: OR | AND ) $/xi )  {
245       my $op = uc $token;
246       my $right = $self->_recurse_parse($tokens, PARSE_IN_EXPR);
247
248       # Merge chunks if logic matches
249       if (ref $right and $op eq $right->[0]) {
250         $left = [ (shift @$right ), [$left, map { @$_ } @$right] ];
251       }
252       else {
253        $left = [$op => [$left, $right]];
254       }
255     }
256     # binary operator keywords
257     elsif (grep { $token =~ /^ $_ $/xi } @binary_op_keywords ) {
258       my $op = uc $token;
259       my $right = $self->_recurse_parse($tokens, PARSE_RHS);
260
261       # A between with a simple LITERAL for a 1st RHS argument needs a
262       # rerun of the search to (hopefully) find the proper AND construct
263       if ($op eq 'BETWEEN' and $right->[0] eq 'LITERAL') {
264         unshift @$tokens, $right->[1][0];
265         $right = $self->_recurse_parse($tokens, PARSE_IN_EXPR);
266       }
267
268       $left = [$op => [$left, $right] ];
269     }
270     # expression terminator keywords (as they start a new expression)
271     elsif (grep { $token =~ /^ $_ $/xi } @expression_terminator_sql_keywords ) {
272       my $op = uc $token;
273       my $right = $self->_recurse_parse($tokens, PARSE_IN_EXPR);
274       $left = $left ? [ $left,  [$op => [$right] ]]
275                     : [ $op => [$right] ];
276     }
277     # NOT (last as to allow all other NOT X pieces first)
278     elsif ( $token =~ /^ not $/ix ) {
279       my $op = uc $token;
280       my $right = $self->_recurse_parse ($tokens, PARSE_RHS);
281       $left = $left ? [ @$left, [$op => [$right] ]]
282                     : [ $op => [$right] ];
283
284     }
285     # literal (eat everything on the right until RHS termination)
286     else {
287       my $right = $self->_recurse_parse ($tokens, PARSE_RHS);
288       $left = $left ? [ $left, [LITERAL => [join ' ', $token, $self->unparse($right)||()] ] ]
289                     : [ LITERAL => [join ' ', $token, $self->unparse($right)||()] ];
290     }
291   }
292 }
293
294 sub format_keyword {
295   my ($self, $keyword) = @_;
296
297   if (my $around = $self->colormap->{lc $keyword}) {
298      $keyword = "$around->[0]$keyword$around->[1]";
299   }
300
301   return $keyword
302 }
303
304 my %starters = (
305    select        => 1,
306    update        => 1,
307    'insert into' => 1,
308    'delete from' => 1,
309 );
310
311 sub whitespace {
312    my ($self, $keyword, $depth) = @_;
313
314    my $before = '';
315    if (defined $self->indentmap->{lc $keyword}) {
316       $before = $self->newline . $self->indent($depth + $self->indentmap->{lc $keyword});
317    }
318    $before = '' if $depth == 0 and defined $starters{lc $keyword};
319    return [$before, ' '];
320 }
321
322 sub indent { ($_[0]->indent_string||'') x ( ( $_[0]->indent_amount || 0 ) * $_[1] ) }
323
324 sub _is_key {
325    my ($self, $tree) = @_;
326    $tree = $tree->[0] while ref $tree;
327
328    defined $tree && defined $self->indentmap->{lc $tree};
329 }
330
331 sub _fill_in_placeholder {
332    my ($self, $bindargs) = @_;
333
334    if ($self->fill_in_placeholders) {
335       my $val = pop @{$bindargs} || '';
336       $val =~ s/\\/\\\\/g;
337       $val =~ s/'/\\'/g;
338       return qq('$val')
339    }
340    return '?'
341 }
342
343 sub unparse {
344   my ($self, $tree, $bindargs, $depth) = @_;
345
346   $depth ||= 0;
347
348   if (not $tree ) {
349     return '';
350   }
351
352   my $car = $tree->[0];
353   my $cdr = $tree->[1];
354
355   if (ref $car) {
356     return join ('', map $self->unparse($_, $bindargs, $depth), @$tree);
357   }
358   elsif ($car eq 'LITERAL') {
359     if ($cdr->[0] eq '?') {
360       return $self->_fill_in_placeholder($bindargs)
361     }
362     return $cdr->[0];
363   }
364   elsif ($car eq 'PAREN') {
365     return '(' .
366       join(' ',
367         map $self->unparse($_, $bindargs, $depth + 2), @{$cdr}) .
368     ($self->_is_key($cdr)?( $self->newline||'' ).$self->indent($depth + 1):'') . ') ';
369   }
370   elsif ($car eq 'OR' or $car eq 'AND' or (grep { $car =~ /^ $_ $/xi } @binary_op_keywords ) ) {
371     return join (" $car ", map $self->unparse($_, $bindargs, $depth), @{$cdr});
372   }
373   else {
374     my ($l, $r) = @{$self->whitespace($car, $depth)};
375     return sprintf "$l%s %s$r", $self->format_keyword($car), $self->unparse($cdr, $bindargs, $depth);
376   }
377 }
378
379 sub format { my $self = shift; $self->unparse($self->parse($_[0]), $_[1]) }
380
381 1;
382
383 =pod
384
385 =head1 SYNOPSIS
386
387  my $sqla_tree = SQL::Abstract::Tree->new({ profile => 'console' });
388
389  print $sqla_tree->format('SELECT * FROM foo WHERE foo.a > 2');
390
391  # SELECT *
392  #   FROM foo
393  #   WHERE foo.a > 2
394
395 =head1 METHODS
396
397 =head2 new
398
399  my $sqla_tree = SQL::Abstract::Tree->new({ profile => 'console' });
400
401  $args = {
402    profile => 'console',      # predefined profile to use (default: 'none')
403    fill_in_placeholders => 1, # true for placeholder population
404    indent_string => ' ',      # the string used when indenting
405    indent_amount => 2,        # how many of above string to use for a single
406                               # indent level
407    newline       => "\n",     # string for newline
408    colormap      => {
409      select => [RED, RESET], # a pair of strings defining what to surround
410                              # the keyword with for colorization
411      # ...
412    },
413    indentmap     => {
414      select        => 0,     # A zero means that the keyword will start on
415                              # a new line
416      from          => 1,     # Any other positive integer means that after
417      on            => 2,     # said newline it will get that many indents
418      # ...
419    },
420  }
421
422 Returns a new SQL::Abstract::Tree object.  All arguments are optional.
423
424 =head3 profiles
425
426 There are four predefined profiles, C<none>, C<console>, C<console_monochrome>,
427 and C<html>.  Typically a user will probably just use C<console> or
428 C<console_monochrome>, but if something about a profile bothers you, merely
429 use the profile and override the parts that you don't like.
430
431 =head2 format
432
433  $sqlat->format('SELECT * FROM bar WHERE x = ?', [1])
434
435 Takes C<$sql> and C<\@bindargs>.
436
437 Returns a formatting string based on the string passed in