File Coverage

blib/lib/SQL/Wizard/Renderer.pm
Criterion Covered Total %
statement 576 623 92.4
branch 222 270 82.2
condition 51 82 62.2
subroutine 35 35 100.0
pod 0 2 0.0
total 884 1012 87.3


line stmt bran cond sub pod time code
1             package SQL::Wizard::Renderer;
2              
3 14     14   82 use strict;
  14         27  
  14         420  
4 14     14   55 use warnings;
  14         22  
  14         539  
5 14     14   79 use Carp;
  14         36  
  14         860  
6 14     14   82 use Scalar::Util qw(blessed);
  14         25  
  14         112575  
7              
8             my $INJECTION_GUARD = qr/
9             \;
10             |
11             ^ \s* go \s
12             /xmi;
13              
14             my %VALID_OPS = map { $_ => 1 }
15             '=', '!=', '<>', '<', '>', '<=', '>=',
16             'LIKE', 'NOT LIKE', 'ILIKE', 'NOT ILIKE',
17             '-IN', '-NOT_IN';
18              
19             sub new {
20 14     14 0 56 my ($class, %args) = @_;
21 14         65 bless \%args, $class;
22             }
23              
24             # Combined reserved words (PostgreSQL + MySQL + ANSI SQL)
25             my %RESERVED = map { $_ => 1 } qw(
26             ACCESSIBLE ADD ALL ALTER ANALYZE AND ANY ARRAY AS ASC ASENSITIVE ASYMMETRIC
27             BEFORE BETWEEN BIGINT BINARY BLOB BOTH BY
28             CALL CASCADE CASE CAST CHANGE CHAR CHARACTER CHECK COLLATE COLUMN CONCURRENTLY
29             CONDITION CONSTRAINT CONTINUE CONVERT CREATE CROSS CUBE CUME_DIST CURRENT_DATE
30             CURRENT_TIME CURRENT_TIMESTAMP CURRENT_USER CURSOR
31             DATABASE DATABASES DAY_HOUR DAY_MICROSECOND DAY_MINUTE DAY_SECOND DEC DECIMAL
32             DECLARE DEFAULT DEFERRABLE DELAYED DELETE DENSE_RANK DESC DESCRIBE DETERMINISTIC
33             DISTINCT DISTINCTROW DIV DO DOUBLE DROP DUAL
34             EACH ELSE ELSEIF EMPTY ENCLOSED END ESCAPED EXCEPT EXISTS EXIT EXPLAIN
35             FALSE FETCH FIRST_VALUE FLOAT FLOAT4 FLOAT8 FOR FORCE FOREIGN FREEZE FROM
36             FULL FULLTEXT FUNCTION
37             GENERATED GET GRANT GROUP GROUPING GROUPS
38             HAVING HIGH_PRIORITY HOUR_MICROSECOND HOUR_MINUTE HOUR_SECOND
39             IF IGNORE IN INDEX INFILE INITIALLY INNER INOUT INSENSITIVE INSERT INT INT1
40             INT2 INT3 INT4 INT8 INTEGER INTERSECT INTERVAL INTO IO_AFTER_GTIDS
41             IO_BEFORE_GTIDS IS ISNULL ITERATE
42             JOIN JSON_TABLE
43             KEY KEYS KILL
44             LAG LAST_VALUE LATERAL LEAD LEADING LEAVE LEFT LIKE LIMIT LINEAR LINES LOAD
45             LOCALTIME LOCALTIMESTAMP LOCK LONG LONGBLOB LONGTEXT LOOP LOW_PRIORITY
46             MASTER_BIND MASTER_SSL_VERIFY_SERVER_CERT MATCH MAXVALUE MEDIUMBLOB MEDIUMINT
47             MEDIUMTEXT MIDDLEINT MOD MODIFIES
48             NATURAL NOT NOTNULL NO_WRITE_TO_BINLOG NTH_VALUE NTILE NULL NUMERIC
49             OF ON ONLY OPTIMIZE OPTIMIZER_COSTS OPTION OPTIONALLY OR ORDER OUT OUTER
50             OUTFILE OVER OVERLAPS
51             PARTITION PRECISION PRIMARY PROCEDURE PURGE
52             RANGE READ READS READ_WRITE REAL RECURSIVE REFERENCES REGEXP RELEASE RENAME
53             REPEAT REPLACE REQUIRE RESIGNAL RESTRICT RETURN REVOKE RIGHT RLIKE ROW ROWS
54             ROW_NUMBER
55             SCHEMA SCHEMAS SELECT SENSITIVE SEPARATOR SET SHOW SIGNAL SIMILAR SOME SPATIAL
56             SPECIFIC SQL SQLEXCEPTION SQLSTATE SQLWARNING SQL_BIG_RESULT SQL_CALC_FOUND_ROWS
57             SQL_SMALL_RESULT SSL STARTING STORED STRAIGHT_JOIN SYMMETRIC SYSTEM
58             TABLE TABLESAMPLE TERMINATED THEN TINYBLOB TINYINT TINYTEXT TO TRAILING
59             TRIGGER TRUE
60             UNDO UNION UNIQUE UNLOCK UNSIGNED UPDATE USAGE USE USING UTC_DATE UTC_TIME
61             UTC_TIMESTAMP
62             VALUES VARBINARY VARCHAR VARCHARACTER VARIADIC VARYING VERBOSE VIRTUAL
63             WHEN WHERE WHILE WINDOW WITH WRITE
64             XOR YEAR_MONTH ZEROFILL
65             );
66              
67             sub _needs_quoting {
68 680     680   862 my ($self, $part) = @_;
69 680 100       1507 return 0 if $part eq '*';
70 579 100       1141 return 1 if $RESERVED{uc $part};
71 576 50       1080 return 1 if $part =~ /[A-Z]/;
72 576 50       962 return 1 if $part =~ /[^a-z0-9_]/;
73 576         1945 return 0;
74             }
75              
76             sub _quote_ident {
77 3     3   4 my ($self, $name) = @_;
78 3 50 50     10 my $q = ($self->{dialect} || 'ansi') eq 'mysql' ? '`' : '"';
79             return join('.', map {
80 3 50       5 $_ eq '*' ? $_ : $q . (s/\Q$q\E/$q$q/gr) . $q
  3         31  
81             } split /\./, $name, -1);
82             }
83              
84             sub _quote_ident_if_needed {
85 640     640   914 my ($self, $name) = @_;
86 640         1152 my @parts = split /\./, $name, -1;
87 640 100       907 return $name unless grep { $self->_needs_quoting($_) } @parts;
  680         1138  
88 3         7 return $self->_quote_ident($name);
89             }
90              
91             sub _injection_guard {
92 325     325   420 my ($self, $string) = @_;
93 325 50       2493 if ($string =~ $INJECTION_GUARD) {
94 0         0 confess "Possible SQL injection attempt '$string'. "
95             . "If this is indeed a part of the desired SQL, use raw()";
96             }
97             }
98              
99             sub _assert_column {
100 28     28   37 my ($self, $col) = @_;
101 28 50       126 confess "Invalid column name '$col'"
102             unless $col =~ /^(\w+\.)*(\w+|\*)$/;
103             }
104              
105             sub _assert_order_column {
106 14     14   22 my ($self, $col) = @_;
107 14 50       58 confess "Invalid order_by column '$col'"
108             unless $col =~ /^(\w+\.)*\w+$/;
109             }
110              
111             sub _assert_integer {
112 9     9   18 my ($self, $name, $value) = @_;
113 9 50       34 confess "$name must be an integer, got '$value'"
114             unless $value =~ /^\d+$/;
115             }
116              
117             # Main dispatch
118             sub render {
119 431     431 0 643 my ($self, $node) = @_;
120 431         684 my $type = ref $node;
121              
122 431         2833 my %dispatch = (
123             'SQL::Wizard::Expr::Column' => \&_render_column,
124             'SQL::Wizard::Expr::Value' => \&_render_value,
125             'SQL::Wizard::Expr::Raw' => \&_render_raw,
126             'SQL::Wizard::Expr::Alias' => \&_render_alias,
127             'SQL::Wizard::Expr::Order' => \&_render_order,
128             'SQL::Wizard::Expr::Func' => \&_render_func,
129             'SQL::Wizard::Expr::BinaryOp' => \&_render_binop,
130             'SQL::Wizard::Expr::Select' => \&_render_select,
131             'SQL::Wizard::Expr::Join' => \&_render_join,
132             'SQL::Wizard::Expr::Case' => \&_render_case,
133             'SQL::Wizard::Expr::Window' => \&_render_window,
134             'SQL::Wizard::Expr::Compound' => \&_render_compound,
135             'SQL::Wizard::Expr::CTE' => \&_render_cte,
136             'SQL::Wizard::Expr::Insert' => \&_render_insert,
137             'SQL::Wizard::Expr::Update' => \&_render_update,
138             'SQL::Wizard::Expr::Delete' => \&_render_delete,
139             );
140              
141 431 50       854 my $handler = $dispatch{$type}
142             or croak "No renderer for node type: $type";
143 431         977 $handler->($self, $node);
144             }
145              
146             # Render any expression or plain string (column name)
147             sub _render_expr {
148 286     286   481 my ($self, $thing) = @_;
149 286 50       454 return ('', ()) unless defined $thing;
150 286 100 66     829 if (blessed($thing) && $thing->isa('SQL::Wizard::Expr')) {
151 99         228 return $self->render($thing);
152             }
153             # Plain string = column name
154 187         417 $self->_injection_guard($thing);
155 187         349 return ($self->_quote_ident_if_needed($thing), ());
156             }
157              
158             # table|alias => table alias
159             sub _expand_table {
160 168     168   249 my ($self, $thing) = @_;
161 168 100 66     390 if (blessed($thing) && $thing->isa('SQL::Wizard::Expr')) {
162 3         15 return $self->render($thing);
163             }
164 165 50       806 confess "Invalid table name '$thing'"
165             unless $thing =~ /^(\w+\.)*\w+(\|\w+)?$/;
166 165         443 my ($table, $alias) = split /\|/, $thing, 2;
167 165         266 my $qt = $self->_quote_ident_if_needed($table);
168 165 100       399 return $alias ? ("$qt " . $self->_quote_ident_if_needed($alias), ()) : ($qt, ());
169             }
170              
171             ## Leaf renderers
172              
173             sub _render_column {
174 71     71   104 my ($self, $node) = @_;
175 71         362 return ($self->_quote_ident_if_needed($node->{name}), ());
176             }
177              
178             sub _render_value {
179 48     48   66 my ($self, $node) = @_;
180 48         337 return ('?', $node->{value});
181             }
182              
183             sub _render_raw {
184 22     22   50 my ($self, $node) = @_;
185              
186             # COMPARE
187 22 100       96 if ($node->{_compare}) {
188 4         6 my $c = $node->{_compare};
189 4         6 my $sql_op = uc($c->{op});
190             confess "Unknown operator '$c->{op}' in compare()"
191 4 50       8 unless $VALID_OPS{$sql_op};
192 4         6 my ($ls, @lb) = $self->render($c->{left});
193 4         7 my ($rs, @rb) = $self->render($c->{right});
194 4 100       17 $ls = "($ls)" if $c->{left}->isa('SQL::Wizard::Expr::Select');
195 4 50       12 $rs = "($rs)" if $c->{right}->isa('SQL::Wizard::Expr::Select');
196 4         14 return ("$ls $sql_op $rs", @lb, @rb);
197             }
198              
199             # TRUNCATE
200 18 50       39 if ($node->{_truncate}) {
201 0         0 my $table = $node->{_truncate};
202 0 0       0 confess "Invalid table name '$table'"
203             unless $table =~ /^(\w+\.)*\w+$/;
204 0         0 return ("TRUNCATE TABLE " . $self->_quote_ident_if_needed($table), ());
205             }
206              
207             # EXISTS / NOT EXISTS
208 18 100       99 if ($node->{_subquery}) {
209 7         18 my ($s, @b) = $self->render($node->{_subquery});
210 7         32 return ("$node->{sql}($s)", @b);
211             }
212              
213             # BETWEEN / NOT BETWEEN
214 11 100 100     51 if ($node->{_between} || $node->{_not_between}) {
215 2   66     7 my $spec = $node->{_between} || $node->{_not_between};
216 2 100       4 my $op = $node->{_between} ? 'BETWEEN' : 'NOT BETWEEN';
217 2         4 my ($cs, @cb) = $self->render($spec->{col});
218 2         4 my ($ls, @lb) = $self->render($spec->{lo});
219 2         4 my ($hs, @hb) = $self->render($spec->{hi});
220 2         11 return ("$cs $op $ls AND $hs", @cb, @lb, @hb);
221             }
222              
223             # CAST
224 9 100       20 if ($node->{_cast}) {
225 1         2 my $type = $node->{_cast}{type};
226 1 50       6 confess "Invalid CAST type '$type'"
227             unless $type =~ /^\w[\w\s(),]*$/;
228 1         3 my ($es, @eb) = $self->render($node->{_cast}{expr});
229 1         5 return ("CAST($es AS $type)", @eb);
230             }
231              
232             # AND / OR
233 8 100       16 if ($node->{_logic}) {
234 2         3 my $op = $node->{_logic}{op};
235 2         3 my @conds = @{$node->{_logic}{conds}};
  2         4  
236 2         3 my @parts;
237             my @bind;
238 2         3 for my $c (@conds) {
239 4         6 my ($s, @b) = $self->_render_where($c);
240 4         5 push @parts, $s;
241 4         8 push @bind, @b;
242             }
243 2         5 my $joined = join(" $op ", @parts);
244 2 50       7 $joined = "($joined)" if @parts > 1;
245 2         11 return ($joined, @bind);
246             }
247              
248             # NOT
249 6 100       14 if ($node->{_not}) {
250 1         4 my ($s, @b) = $self->_render_where($node->{_not});
251 1         5 return ("NOT ($s)", @b);
252             }
253              
254 5         14 return ($node->{sql}, @{$node->{bind}});
  5         30  
255             }
256              
257             sub _render_alias {
258 28     28   80 my ($self, $node) = @_;
259 28         222 my ($sql, @bind) = $self->render($node->{expr});
260             # Wrap subselects in parens
261 28 100       165 if ($node->{expr}->isa('SQL::Wizard::Expr::Select')) {
262 4         15 $sql = "($sql)";
263             }
264 28         77 return ("$sql AS " . $self->_quote_ident_if_needed($node->{alias}), @bind);
265             }
266              
267             sub _render_order {
268 6     6   14 my ($self, $node) = @_;
269 6         39 my ($sql, @bind) = $self->_render_expr($node->{expr});
270 6         15 $sql .= " $node->{direction}";
271 6 100       20 $sql .= " NULLS $node->{nulls}" if $node->{nulls};
272 6         28 return ($sql, @bind);
273             }
274              
275             sub _render_func {
276 34     34   56 my ($self, $node) = @_;
277 34         54 my @arg_sqls;
278             my @bind;
279 34         51 for my $arg (@{$node->{args}}) {
  34         137  
280 31         74 my ($s, @b) = $self->_render_expr($arg);
281 31         112 push @arg_sqls, $s;
282 31         69 push @bind, @b;
283             }
284 34         88 my $args_str = join(', ', @arg_sqls);
285 34         196 return ("$node->{name}($args_str)", @bind);
286             }
287              
288             sub _render_binop {
289 14     14   19 my ($self, $node) = @_;
290 14         59 my ($lsql, @lbind) = $self->render($node->{left});
291 14         27 my ($rsql, @rbind) = $self->render($node->{right});
292 14         84 return ("$lsql $node->{op} $rsql", @lbind, @rbind);
293             }
294              
295             ## SELECT
296              
297             sub _render_select {
298 148     148   235 my ($self, $node) = @_;
299 148         222 my @parts;
300             my @bind;
301              
302             # CTE
303 148 100       339 if ($node->{_cte}) {
304 4         15 my ($cte_sql, @cte_bind) = $self->render($node->{_cte});
305 4         5 push @parts, $cte_sql;
306 4         4 push @bind, @cte_bind;
307             }
308              
309             # SELECT columns
310 148         172 my @col_sqls;
311 148 100       237 for my $col (@{$node->{columns} || ['*']}) {
  148         495  
312 180         330 my ($s, @b) = $self->_render_expr($col);
313 180         331 push @col_sqls, $s;
314 180         321 push @bind, @b;
315             }
316 148 100       341 my $select_keyword = $node->{distinct} ? "SELECT DISTINCT" : "SELECT";
317 148         447 push @parts, "$select_keyword " . join(', ', @col_sqls);
318              
319             # FROM
320 148 50       330 if ($node->{from}) {
321 148         190 my @from_sqls;
322 148 100       350 my @from_items = ref $node->{from} eq 'ARRAY' ? @{$node->{from}} : ($node->{from});
  12         26  
323 148         351 for my $i (0 .. $#from_items) {
324 160         217 my $item = $from_items[$i];
325 160 100 100     388 if (blessed($item) && $item->isa('SQL::Wizard::Expr::Join')) {
326 12         29 my ($s, @b) = $self->render($item);
327 12         16 push @from_sqls, $s;
328 12         22 push @bind, @b;
329             } else {
330 148         264 my ($s, @b) = $self->_expand_table($item);
331             # First item or non-join items
332 148 50       249 if ($i == 0) {
333 148         213 push @from_sqls, $s;
334             } else {
335 0         0 push @from_sqls, $s;
336             }
337 148         254 push @bind, @b;
338             }
339             }
340 148         398 push @parts, "FROM " . join(' ', @from_sqls);
341             }
342              
343             # WHERE
344 148 100       309 if ($node->{where}) {
345 80         218 my ($wsql, @wbind) = $self->_render_where($node->{where});
346 79 100 66     267 if (defined $wsql && $wsql ne '') {
347 77         123 push @parts, "WHERE $wsql";
348 77         110 push @bind, @wbind;
349             }
350             }
351              
352             # GROUP BY
353 147 100       269 if ($node->{group_by}) {
354 7 50       23 my @items = ref $node->{group_by} eq 'ARRAY' ? @{$node->{group_by}} : ($node->{group_by});
  0         0  
355 7         9 my @gsqls;
356 7         12 for my $g (@items) {
357 7         13 my ($s, @b) = $self->_render_expr($g);
358 7         15 push @gsqls, $s;
359 7         14 push @bind, @b;
360             }
361 7         18 push @parts, "GROUP BY " . join(', ', @gsqls);
362             }
363              
364             # HAVING
365 147 100       223 if ($node->{having}) {
366 3         9 my ($hsql, @hbind) = $self->_render_where($node->{having});
367 3 100 66     11 if (defined $hsql && $hsql ne '') {
368 1         2 push @parts, "HAVING $hsql";
369 1         3 push @bind, @hbind;
370             }
371             }
372              
373             # WINDOW
374 147 100       215 if ($node->{window}) {
375 2         4 my @wdefs;
376 2         2 for my $name (sort keys %{$node->{window}}) {
  2         8  
377 2 50       15 confess "Invalid window name '$name'" unless $name =~ /^\w+$/;
378 2         4 my $spec = $node->{window}{$name};
379 2         6 my ($s, @b) = $self->_render_window_spec($spec);
380 2         5 push @wdefs, $self->_quote_ident_if_needed($name) . " AS ($s)";
381 2         6 push @bind, @b;
382             }
383 2         7 push @parts, "WINDOW " . join(', ', @wdefs);
384             }
385              
386             # ORDER BY
387 147 100       254 if ($node->{order_by}) {
388 7 100       19 my @items = ref $node->{order_by} eq 'ARRAY' ? @{$node->{order_by}} : ($node->{order_by});
  3         7  
389 7         10 my @osqls;
390 7         10 for my $o (@items) {
391 8 100 66     37 if (ref $o eq 'HASH') {
    50          
    100          
392             # { -desc => 'col' } or { -asc => 'col' }
393 2         24 my ($dir, $col) = each %$o;
394 2         5 $dir = uc($dir);
395 2         7 $dir =~ s/^-//;
396 2 50       9 $self->_assert_order_column($col) unless ref $col;
397 2         4 my ($s, @b) = $self->_render_expr($col);
398 2         4 push @osqls, "$s $dir";
399 2         4 push @bind, @b;
400             } elsif (!ref $o && $o =~ /^-(.+)/) {
401             # '-col' shorthand for col DESC
402 0         0 $self->_assert_order_column($1);
403 0         0 my ($s, @b) = $self->_render_expr($1);
404 0         0 push @osqls, "$s DESC";
405 0         0 push @bind, @b;
406             } elsif (!ref $o) {
407 4         9 $self->_assert_order_column($o);
408 4         8 my ($s, @b) = $self->_render_expr($o);
409 4         7 push @osqls, $s;
410 4         6 push @bind, @b;
411             } else {
412 2         5 my ($s, @b) = $self->_render_expr($o);
413 2         3 push @osqls, $s;
414 2         9 push @bind, @b;
415             }
416             }
417 7         18 push @parts, "ORDER BY " . join(', ', @osqls);
418             }
419              
420             # LIMIT / OFFSET
421 147 100       257 if (defined $node->{limit}) {
422 4         26 $self->_assert_integer('-limit', $node->{limit});
423 4         5 push @parts, "LIMIT ?";
424 4         7 push @bind, $node->{limit};
425             }
426 147 100       222 if (defined $node->{offset}) {
427 3         7 $self->_assert_integer('-offset', $node->{offset});
428 3         5 push @parts, "OFFSET ?";
429 3         4 push @bind, $node->{offset};
430             }
431              
432 147         877 return (join(' ', @parts), @bind);
433             }
434              
435             ## JOIN
436              
437             sub _render_join {
438 13     13   18 my ($self, $node) = @_;
439 13         14 my @bind;
440              
441 13         126 my ($table_sql, @tb) = $self->_expand_table($node->{table});
442 13         16 push @bind, @tb;
443              
444 13         24 my $sql = "$node->{type} $table_sql";
445              
446 13 100       26 if (defined $node->{on}) {
447 12 100       25 if (ref $node->{on} eq 'HASH') {
448 1         6 my ($on_sql, @ob) = $self->_render_where($node->{on});
449 1         2 $sql .= " ON $on_sql";
450 1         2 push @bind, @ob;
451             } else {
452             # String ON condition
453 11         24 $self->_injection_guard($node->{on});
454 11         24 $sql .= " ON $node->{on}";
455             }
456             }
457              
458 13         42 return ($sql, @bind);
459             }
460              
461             ## CASE
462              
463             sub _render_case {
464 6     6   10 my ($self, $node) = @_;
465 6         10 my @parts;
466             my @bind;
467              
468 6         12 push @parts, 'CASE';
469              
470             # CASE ON (simple case with operand)
471 6 100       63 if ($node->{operand}) {
472 1         4 my ($os, @ob) = $self->_render_expr($node->{operand});
473 1         3 $parts[0] .= " $os";
474 1         2 push @bind, @ob;
475             }
476              
477 6         8 for my $when (@{$node->{whens}}) {
  6         14  
478 9 100       19 if ($node->{operand}) {
479             # Simple CASE: WHEN value THEN result
480 2         4 my ($ws, @wb) = $self->_render_expr($when->{condition});
481 2         3 my ($ts, @tb) = $self->_render_expr($when->{then});
482 2         5 push @parts, "WHEN $ws THEN $ts";
483 2         3 push @bind, @wb, @tb;
484             } else {
485             # Searched CASE: WHEN condition THEN result
486 7         24 my ($ws, @wb) = $self->_render_where($when->{condition});
487 7         15 my ($ts, @tb) = $self->_render_expr($when->{then});
488 7         15 push @parts, "WHEN $ws THEN $ts";
489 7         16 push @bind, @wb, @tb;
490             }
491             }
492              
493 6 100       11 if (defined $node->{else}) {
494 5         7 my ($es, @eb) = $self->_render_expr($node->{else});
495 5         8 push @parts, "ELSE $es";
496 5         7 push @bind, @eb;
497             }
498              
499 6         8 push @parts, 'END';
500 6         36 return (join(' ', @parts), @bind);
501             }
502              
503             ## Window
504              
505             sub _render_window {
506 9     9   12 my ($self, $node) = @_;
507 9         21 my ($expr_sql, @bind) = $self->render($node->{expr});
508              
509 9         16 my $spec = $node->{spec};
510 9 100       16 if ($spec->{name}) {
511 4 50       17 confess "Invalid window name '$spec->{name}'" unless $spec->{name} =~ /^\w+$/;
512 4         11 return ("$expr_sql OVER " . $self->_quote_ident_if_needed($spec->{name}), @bind);
513             }
514              
515 5         12 my ($spec_sql, @sb) = $self->_render_window_spec($spec);
516 5         6 push @bind, @sb;
517 5         43 return ("$expr_sql OVER ($spec_sql)", @bind);
518             }
519              
520             sub _render_window_spec {
521 7     7   10 my ($self, $spec) = @_;
522 7         9 my @parts;
523             my @bind;
524              
525 7 100       50 if ($spec->{'-partition_by'}) {
526             my @items = ref $spec->{'-partition_by'} eq 'ARRAY'
527 6 100       20 ? @{$spec->{'-partition_by'}} : ($spec->{'-partition_by'});
  1         2  
528 6         7 my @sqls;
529 6         10 for my $p (@items) {
530 7         26 my ($s, @b) = $self->_render_expr($p);
531 7         47 push @sqls, $s;
532 7         13 push @bind, @b;
533             }
534 6         17 push @parts, "PARTITION BY " . join(', ', @sqls);
535             }
536              
537 7 50       13 if ($spec->{'-order_by'}) {
538             my @items = ref $spec->{'-order_by'} eq 'ARRAY'
539 7 100       19 ? @{$spec->{'-order_by'}} : ($spec->{'-order_by'});
  3         5  
540 7         27 my @sqls;
541 7         10 for my $o (@items) {
542 7 100 33     26 if (ref $o eq 'HASH') {
    50          
    50          
543 3         9 my ($dir, $col) = each %$o;
544 3         5 $dir = uc($dir);
545 3         10 $dir =~ s/^-//;
546 3 50       23 $self->_assert_order_column($col) unless ref $col;
547 3         6 my ($s, @b) = $self->_render_expr($col);
548 3         7 push @sqls, "$s $dir";
549 3         6 push @bind, @b;
550             } elsif (!ref $o && $o =~ /^-(.+)/) {
551 0         0 $self->_assert_order_column($1);
552 0         0 my ($s, @b) = $self->_render_expr($1);
553 0         0 push @sqls, "$s DESC";
554 0         0 push @bind, @b;
555             } elsif (!ref $o) {
556 4         8 $self->_assert_order_column($o);
557 4         6 my ($s, @b) = $self->_render_expr($o);
558 4         6 push @sqls, $s;
559 4         4 push @bind, @b;
560             } else {
561 0         0 my ($s, @b) = $self->_render_expr($o);
562 0         0 push @sqls, $s;
563 0         0 push @bind, @b;
564             }
565             }
566 7         35 push @parts, "ORDER BY " . join(', ', @sqls);
567             }
568              
569 7 100       22 if ($spec->{'-frame'}) {
570 1         3 $self->_injection_guard($spec->{'-frame'});
571 1         2 push @parts, $spec->{'-frame'};
572             }
573              
574 7         19 return (join(' ', @parts), @bind);
575             }
576              
577             ## Compound (UNION/INTERSECT/EXCEPT)
578              
579             sub _render_compound {
580 9     9   12 my ($self, $node) = @_;
581 9         9 my @parts;
582             my @bind;
583              
584 9         11 for my $entry (@{$node->{queries}}) {
  9         18  
585 20         33 my ($s, @b) = $self->render($entry->{query});
586 20 100       33 if ($entry->{type}) {
587 11         14 push @parts, $entry->{type};
588             }
589 20         26 push @parts, "($s)";
590 20         22 push @bind, @b;
591             }
592              
593             # ORDER BY / LIMIT / OFFSET on the compound
594 9 100       18 if ($node->{order_by}) {
595 1 50       4 my @items = ref $node->{order_by} eq 'ARRAY' ? @{$node->{order_by}} : ($node->{order_by});
  0         0  
596 1         2 my @osqls;
597 1         2 for my $o (@items) {
598 1 50 33     8 if (ref $o eq 'HASH') {
    50          
    50          
599 0         0 my ($dir, $col) = each %$o;
600 0         0 $dir = uc($dir);
601 0         0 $dir =~ s/^-//;
602 0 0       0 $self->_assert_order_column($col) unless ref $col;
603 0         0 my ($s, @b) = $self->_render_expr($col);
604 0         0 push @osqls, "$s $dir";
605 0         0 push @bind, @b;
606             } elsif (!ref $o && $o =~ /^-(.+)/) {
607 0         0 $self->_assert_order_column($1);
608 0         0 my ($s, @b) = $self->_render_expr($1);
609 0         0 push @osqls, "$s DESC";
610 0         0 push @bind, @b;
611             } elsif (!ref $o) {
612 1         4 $self->_assert_order_column($o);
613 1         2 my ($s, @b) = $self->_render_expr($o);
614 1         2 push @osqls, $s;
615 1         3 push @bind, @b;
616             } else {
617 0         0 my ($s, @b) = $self->_render_expr($o);
618 0         0 push @osqls, $s;
619 0         0 push @bind, @b;
620             }
621             }
622 1         3 push @parts, "ORDER BY " . join(', ', @osqls);
623             }
624 9 100       11 if (defined $node->{limit}) {
625 1         3 $self->_assert_integer('-limit', $node->{limit});
626 1         2 push @parts, "LIMIT ?";
627 1         3 push @bind, $node->{limit};
628             }
629 9 50       18 if (defined $node->{offset}) {
630 0         0 $self->_assert_integer('-offset', $node->{offset});
631 0         0 push @parts, "OFFSET ?";
632 0         0 push @bind, $node->{offset};
633             }
634              
635 9         39 return (join(' ', @parts), @bind);
636             }
637              
638             ## CTE
639              
640             sub _render_cte {
641 4     4   5 my ($self, $node) = @_;
642 4         5 my @parts;
643             my @bind;
644              
645 4 100       21 my $keyword = $node->{recursive} ? 'WITH RECURSIVE' : 'WITH';
646              
647 4         5 my @cte_sqls;
648 4         5 for my $cte (@{$node->{ctes}}) {
  4         8  
649 5         6 my $name = $cte->{name};
650 5         16 $self->_injection_guard($name);
651 5         7 my $query = $cte->{query};
652              
653             # Recursive CTE with -initial and -recurse
654 5 100 66     17 if (ref $query eq 'HASH' && $query->{'-initial'}) {
655 1         4 my ($is, @ib) = $self->render($query->{'-initial'});
656 1         3 my ($rs, @rb) = $self->render($query->{'-recurse'});
657 1         2 push @cte_sqls, $self->_quote_ident_if_needed($name) . " AS ($is UNION ALL $rs)";
658 1         2 push @bind, @ib, @rb;
659             } else {
660 4         12 my ($s, @b) = $self->render($query);
661 4         7 push @cte_sqls, $self->_quote_ident_if_needed($name) . " AS ($s)";
662 4         8 push @bind, @b;
663             }
664             }
665              
666 4         16 return ("$keyword " . join(', ', @cte_sqls), @bind);
667             }
668              
669             ## INSERT
670              
671             sub _render_insert {
672 7     7   9 my ($self, $node) = @_;
673 7         9 my @parts;
674             my @bind;
675              
676 7         18 $self->_injection_guard($node->{into});
677 7         15 push @parts, "INSERT INTO " . $self->_quote_ident_if_needed($node->{into});
678              
679 7 100       25 if ($node->{select}) {
    100          
    50          
680             # INSERT ... SELECT
681 1 50       3 if ($node->{columns}) {
682 1         2 $self->_assert_column($_) for @{$node->{columns}};
  1         3  
683 1         2 push @parts, "(" . join(', ', map { $self->_quote_ident_if_needed($_) } @{$node->{columns}}) . ")";
  2         3  
  1         2  
684             }
685 1         2 my ($s, @b) = $self->render($node->{select});
686 1         2 push @parts, $s;
687 1         2 push @bind, @b;
688             } elsif (ref $node->{values} eq 'HASH') {
689             # Single row insert from hash
690 5         5 my @cols = sort keys %{$node->{values}};
  5         19  
691 5         13 $self->_assert_column($_) for @cols;
692 5         5 my @vals;
693 5         9 for my $col (@cols) {
694 9         13 my $v = $node->{values}{$col};
695 9         12 my ($s, @b) = $self->_render_expr($v);
696 9         14 push @vals, $s;
697 9         15 push @bind, @b;
698             }
699 5         9 push @parts, "(" . join(', ', map { $self->_quote_ident_if_needed($_) } @cols) . ")";
  9         11  
700 5         13 push @parts, "VALUES (" . join(', ', @vals) . ")";
701             } elsif (ref $node->{values} eq 'ARRAY') {
702             # Multi-row insert
703 1 50       3 if ($node->{columns}) {
704 1         2 $self->_assert_column($_) for @{$node->{columns}};
  1         3  
705 1         2 push @parts, "(" . join(', ', map { $self->_quote_ident_if_needed($_) } @{$node->{columns}}) . ")";
  2         4  
  1         2  
706             }
707 1         2 my @row_sqls;
708 1         1 for my $row (@{$node->{values}}) {
  1         2  
709 2         3 my @vals;
710 2         17 for my $v (@$row) {
711 4         5 my ($s, @b) = $self->_render_expr($v);
712 4         6 push @vals, $s;
713 4         6 push @bind, @b;
714             }
715 2         5 push @row_sqls, "(" . join(', ', @vals) . ")";
716             }
717 1         4 push @parts, "VALUES " . join(', ', @row_sqls);
718             }
719              
720             # ON CONFLICT (PostgreSQL)
721 7 100       21 if ($node->{on_conflict}) {
722 1         2 my $oc = $node->{on_conflict};
723 1         2 my $target = $oc->{'-target'};
724 1         2 $self->_injection_guard($target);
725 1         1 my $update = $oc->{'-update'};
726 1         3 my @set_parts;
727 1         2 for my $col (sort keys %$update) {
728 1         2 $self->_assert_column($col);
729 1         3 my ($s, @b) = $self->_render_expr($update->{$col});
730 1         3 push @set_parts, $self->_quote_ident_if_needed($col) . " = $s";
731 1         2 push @bind, @b;
732             }
733 1         3 my $quoted_target = join(', ', map { $self->_quote_ident_if_needed(s/^\s+|\s+$//gr) } split /,/, $target);
  1         4  
734 1         4 push @parts, "ON CONFLICT ($quoted_target) DO UPDATE SET " . join(', ', @set_parts);
735             }
736              
737             # ON DUPLICATE KEY (MySQL)
738 7 100       12 if ($node->{on_duplicate}) {
739 1         1 my @set_parts;
740 1         2 for my $col (sort keys %{$node->{on_duplicate}}) {
  1         3  
741 1         3 $self->_assert_column($col);
742 1         2 my ($s, @b) = $self->_render_expr($node->{on_duplicate}{$col});
743 1         3 push @set_parts, $self->_quote_ident_if_needed($col) . " = $s";
744 1         3 push @bind, @b;
745             }
746 1         3 push @parts, "ON DUPLICATE KEY UPDATE " . join(', ', @set_parts);
747             }
748              
749             # RETURNING
750 7 100       33 if ($node->{returning}) {
751 1         2 $self->_assert_column($_) for @{$node->{returning}};
  1         4  
752 1         2 push @parts, "RETURNING " . join(', ', map { $self->_quote_ident_if_needed($_) } @{$node->{returning}});
  2         3  
  1         2  
753             }
754              
755 7         38 return (join(' ', @parts), @bind);
756             }
757              
758             ## UPDATE
759              
760             sub _render_update {
761 6     6   8 my ($self, $node) = @_;
762 6         8 my @parts;
763             my @bind;
764              
765             # Table (possibly with joins)
766 6 100       13 if (ref $node->{table} eq 'ARRAY') {
767 1         1 my @table_parts;
768 1         2 for my $item (@{$node->{table}}) {
  1         2  
769 2 100 66     12 if (blessed($item) && $item->isa('SQL::Wizard::Expr::Join')) {
770 1         3 my ($s, @b) = $self->render($item);
771 1         2 push @table_parts, $s;
772 1         2 push @bind, @b;
773             } else {
774 1         2 my ($s, @b) = $self->_expand_table($item);
775 1         2 push @table_parts, $s;
776 1         3 push @bind, @b;
777             }
778             }
779 1         3 push @parts, "UPDATE " . join(' ', @table_parts);
780             } else {
781 5         12 my ($ts, @tb) = $self->_expand_table($node->{table});
782 5         11 push @parts, "UPDATE $ts";
783 5         8 push @bind, @tb;
784             }
785              
786             # SET
787 6         7 my @set_parts;
788 6         7 for my $col (sort keys %{$node->{set}}) {
  6         23  
789 7         33 $self->_assert_column($col);
790 7         40 my ($s, @b) = $self->_render_expr($node->{set}{$col});
791 7         12 push @set_parts, $self->_quote_ident_if_needed($col) . " = $s";
792 7         15 push @bind, @b;
793             }
794 6         13 push @parts, "SET " . join(', ', @set_parts);
795              
796             # FROM (PostgreSQL)
797 6 100       11 if ($node->{from}) {
798 1 50       4 my @from_items = ref $node->{from} eq 'ARRAY' ? @{$node->{from}} : ($node->{from});
  1         2  
799 1         2 my @from_sqls;
800 1         2 for my $item (@from_items) {
801 1         2 my ($s, @b) = $self->_expand_table($item);
802 1         2 push @from_sqls, $s;
803 1         1 push @bind, @b;
804             }
805 1         3 push @parts, "FROM " . join(', ', @from_sqls);
806             }
807              
808             # WHERE
809 6 100       10 if ($node->{where}) {
810 5         13 my ($ws, @wb) = $self->_render_where($node->{where});
811 5 50 33     22 if (defined $ws && $ws ne '') {
812 5         9 push @parts, "WHERE $ws";
813 5         5 push @bind, @wb;
814             }
815             }
816              
817             # LIMIT (MySQL UPDATE ... LIMIT n)
818 6 100       11 if (defined $node->{limit}) {
819 1         3 $self->_assert_integer('-limit', $node->{limit});
820 1         1 push @parts, "LIMIT ?";
821 1         3 push @bind, $node->{limit};
822             }
823              
824             # RETURNING
825 6 100       12 if ($node->{returning}) {
826 1         1 $self->_assert_column($_) for @{$node->{returning}};
  1         3  
827 1         3 push @parts, "RETURNING " . join(', ', map { $self->_quote_ident_if_needed($_) } @{$node->{returning}});
  2         4  
  1         2  
828             }
829              
830 6         36 return (join(' ', @parts), @bind);
831             }
832              
833             ## DELETE
834              
835             sub _render_delete {
836 6     6   8 my ($self, $node) = @_;
837 6         6 my @parts;
838             my @bind;
839              
840 6         15 $self->_injection_guard($node->{from});
841 6         12 push @parts, "DELETE FROM " . $self->_quote_ident_if_needed($node->{from});
842              
843             # USING (PostgreSQL)
844 6 100       14 if ($node->{using}) {
845 1         3 $self->_injection_guard($node->{using});
846 1         3 push @parts, "USING " . $self->_quote_ident_if_needed($node->{using});
847             }
848              
849             # WHERE
850 6 100       12 if ($node->{where}) {
851 5         12 my ($ws, @wb) = $self->_render_where($node->{where});
852 5 50 33     20 if (defined $ws && $ws ne '') {
853 5         7 push @parts, "WHERE $ws";
854 5         8 push @bind, @wb;
855             }
856             }
857              
858             # RETURNING
859 6 100       16 if ($node->{returning}) {
860 1         2 $self->_assert_column($_) for @{$node->{returning}};
  1         4  
861 1         2 push @parts, "RETURNING " . join(', ', map { $self->_quote_ident_if_needed($_) } @{$node->{returning}});
  2         4  
  1         1  
862             }
863              
864 6         39 return (join(' ', @parts), @bind);
865             }
866              
867             ## WHERE clause rendering (self-contained, SQL::Abstract-compatible syntax)
868              
869             sub _render_where {
870 117     117   165 my ($self, $where) = @_;
871              
872             # Expression object
873 117 100 66     237 if (blessed($where) && $where->isa('SQL::Wizard::Expr')) {
874 1         4 return $self->render($where);
875             }
876              
877             # Hashref: { col => val, col2 => { '>' => 3 } }
878 116 100       232 if (ref $where eq 'HASH') {
879 103         138 my @parts;
880             my @bind;
881 103         308 for my $key (sort keys %$where) {
882 105         161 my $val = $where->{$key};
883              
884             # Expression object as key (e.g. $q->func(...) => { '>' => 5 })
885 105 50 33     198 if (blessed($key) && $key->isa('SQL::Wizard::Expr')) {
886 0         0 my ($ks, @kb) = $self->render($key);
887 0         0 my ($vs, @vb) = $self->_render_where_value($ks, $val);
888 0         0 push @parts, $vs;
889 0         0 push @bind, @kb, @vb;
890 0         0 next;
891             }
892              
893 105         198 $self->_injection_guard($key);
894 105         177 my $qkey = $self->_quote_ident_if_needed($key);
895              
896 105 100 66     447 if (!defined $val) {
    100          
    100          
    100          
897 2         8 push @parts, "$qkey IS NULL";
898             } elsif (blessed($val) && $val->isa('SQL::Wizard::Expr')) {
899 8         22 my ($vs, @vb) = $self->render($val);
900 8         22 push @parts, "$qkey = $vs";
901 8         32 push @bind, @vb;
902             } elsif (ref $val eq 'HASH') {
903 42         123 my ($s, @b) = $self->_render_where_value($qkey, $val);
904 41         62 push @parts, $s;
905 41         91 push @bind, @b;
906             } elsif (ref $val eq 'ARRAY') {
907             # { col => [v1, v2, ...] } => col = ? OR col = ? OR ...
908             # Each element is processed individually:
909             # undef => col IS NULL
910             # hashref => operator form (e.g. { '>' => 3 } => col > 3)
911             # expr obj => col =
912             # scalar => col = ?
913 4 100       8 if (!@$val) {
914 1         2 push @parts, '1 = 0';
915             } else {
916 3         4 my @or_parts;
917             my @or_bind;
918 3         5 for my $v (@$val) {
919 6 100 33     17 if (!defined $v) {
    50          
    100          
920 1         3 push @or_parts, "$qkey IS NULL";
921             } elsif (blessed($v) && $v->isa('SQL::Wizard::Expr')) {
922 0         0 my ($s, @b) = $self->render($v);
923 0         0 push @or_parts, "$qkey = $s";
924 0         0 push @or_bind, @b;
925             } elsif (ref $v eq 'HASH') {
926 1         4 my ($s, @b) = $self->_render_where_value($qkey, $v);
927 1         2 push @or_parts, $s;
928 1         3 push @or_bind, @b;
929             } else {
930 4         6 push @or_parts, "$qkey = ?";
931 4         6 push @or_bind, $v;
932             }
933             }
934 3 100       5 if (@or_parts == 1) {
935 1         2 push @parts, $or_parts[0];
936             } else {
937 2         5 push @parts, '(' . join(' OR ', @or_parts) . ')';
938             }
939 3         6 push @bind, @or_bind;
940             }
941             } else {
942 49         83 push @parts, "$qkey = ?";
943 49         81 push @bind, $val;
944             }
945             }
946 102         295 return (join(' AND ', @parts), @bind);
947             }
948              
949             # Arrayref: [-and => ..., -or => ...]
950 13 100       32 if (ref $where eq 'ARRAY') {
951 12         35 return $self->_render_where_array($where);
952             }
953              
954             # Plain string
955 1         2 $self->_injection_guard($where);
956 1         2 return ($where, ());
957             }
958              
959             sub _render_where_value {
960 43     43   82 my ($self, $col, $val) = @_;
961              
962 43 50       93 if (ref $val eq 'HASH') {
963 43         54 my @parts;
964             my @bind;
965 43         96 for my $op (sort keys %$val) {
966 43         67 my $rhs = $val->{$op};
967 43         60 my $sql_op = uc($op);
968              
969             confess "Unknown operator '$op' in WHERE clause"
970 43 100       436 unless $VALID_OPS{$sql_op};
971              
972             # -in / -not_in
973 42 100 100     205 if ($sql_op eq '-IN' || $sql_op eq '-NOT_IN') {
    100 66        
    100          
974 10 100       22 my $neg = $sql_op eq '-NOT_IN' ? 'NOT ' : '';
975 10 100 66     47 if (blessed($rhs) && $rhs->isa('SQL::Wizard::Expr')) {
    50          
976 4         14 my ($s, @b) = $self->render($rhs);
977 4         17 push @parts, "$col ${neg}IN ($s)";
978 4         8 push @bind, @b;
979             } elsif (ref $rhs eq 'ARRAY') {
980 6 100       10 if (!@$rhs) {
981             # Empty list: -in => always false, -not_in => always true
982 2 100       6 push @parts, $neg ? '1 = 1' : '1 = 0';
983             } else {
984 4         5 my @ph;
985 4         8 for my $v (@$rhs) {
986 9 50 33     13 if (blessed($v) && $v->isa('SQL::Wizard::Expr')) {
987 0         0 my ($s, @b) = $self->render($v);
988 0         0 push @ph, $s;
989 0         0 push @bind, @b;
990             } else {
991 9         11 push @ph, '?';
992 9         12 push @bind, $v;
993             }
994             }
995 4         16 push @parts, "$col ${neg}IN (" . join(', ', @ph) . ")";
996             }
997             }
998             } elsif (!defined $rhs) {
999 3 100 100     13 if ($sql_op eq '!=' || $sql_op eq '<>') {
1000 2         8 push @parts, "$col IS NOT NULL";
1001             } else {
1002 1         4 push @parts, "$col IS NULL";
1003             }
1004             } elsif (blessed($rhs) && $rhs->isa('SQL::Wizard::Expr')) {
1005 3         7 my ($s, @b) = $self->render($rhs);
1006 3 100       14 $s = "($s)" if $rhs->isa('SQL::Wizard::Expr::Select');
1007 3         8 push @parts, "$col $sql_op $s";
1008 3         5 push @bind, @b;
1009             } else {
1010 26         93 push @parts, "$col $sql_op ?";
1011 26         56 push @bind, $rhs;
1012             }
1013             }
1014 42         171 return (join(' AND ', @parts), @bind);
1015             }
1016              
1017 0         0 return ("$col = ?", $val);
1018             }
1019              
1020             sub _render_where_array {
1021 17     17   29 my ($self, $arr, $default_logic) = @_;
1022 17         31 my @items = @$arr;
1023 17         20 my @parts;
1024             my @bind;
1025              
1026 17   100     50 my $logic = $default_logic || 'AND';
1027              
1028 17         23 my $i = 0;
1029 17         33 while ($i <= $#items) {
1030 23         28 my $item = $items[$i];
1031              
1032 23 100 66     71 if (!ref $item && $item =~ /^-(and|or)$/i) {
1033 6         18 $logic = uc($1);
1034 6         10 $i++;
1035             # Next item could be arrayref of conditions
1036 6 100 66     22 if ($i <= $#items && ref $items[$i] eq 'ARRAY') {
1037 4         10 my ($s, @b) = $self->_render_where_array($items[$i], $logic);
1038 4         5 push @parts, $s;
1039 4         8 push @bind, @b;
1040 4         4 $i++;
1041             }
1042 6         12 next;
1043             }
1044              
1045 17 100 33     62 if (ref $item eq 'HASH') {
    100          
    50          
1046 11         33 my ($s, @b) = $self->_render_where($item);
1047 11         15 push @parts, $s;
1048 11         17 push @bind, @b;
1049             } elsif (ref $item eq 'ARRAY') {
1050 1         8 my ($s, @b) = $self->_render_where_array($item);
1051 1         2 push @parts, "($s)";
1052 1         2 push @bind, @b;
1053             } elsif (blessed($item) && $item->isa('SQL::Wizard::Expr')) {
1054 5         8 my ($s, @b) = $self->render($item);
1055 5         10 push @parts, $s;
1056 5         6 push @bind, @b;
1057             }
1058              
1059 17         29 $i++;
1060             }
1061              
1062 17         37 my $joined = join(" $logic ", @parts);
1063 17 100       39 $joined = "($joined)" if @parts > 1;
1064 17         45 return ($joined, @bind);
1065             }
1066              
1067             1;