Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
79.17% |
133 / 168 |
|
88.89% |
24 / 27 |
CRAP | |
0.00% |
0 / 1 |
Select | |
79.17% |
133 / 168 |
|
88.89% |
24 / 27 |
182.08 | |
0.00% |
0 / 1 |
distinct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
from | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
asAlias | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
join | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
leftJoin | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
rightJoin | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
fullJoin | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
outerJoin | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
leftOuterJoin | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
rightOuterJoin | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
fullOuterJoin | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
innerJoin | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
leftInnerJoin | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
rightInnerJoin | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
fullInnerJoin | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
having | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
14 | |||
andHaving | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
6 | |||
orHaving | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
6 | |||
groupBy | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
orderBy | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
8 | |||
limit | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
offset | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
render | |
82.69% |
43 / 52 |
|
0.00% |
0 / 1 |
32.06 | |||
__toString | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
__get | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
6 | |||
getLimitAndOffset | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
20 | |||
buildSqlSrvLimitAndOffset | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
20 |
1 | <?php |
2 | /** |
3 | * Pop PHP Framework (http://www.popphp.org/) |
4 | * |
5 | * @link https://github.com/popphp/popphp-framework |
6 | * @author Nick Sagona, III <dev@nolainteractive.com> |
7 | * @copyright Copyright (c) 2009-2024 NOLA Interactive, LLC. (http://www.nolainteractive.com) |
8 | * @license http://www.popphp.org/license New BSD License |
9 | */ |
10 | |
11 | /** |
12 | * @namespace |
13 | */ |
14 | namespace Pop\Db\Sql; |
15 | |
16 | /** |
17 | * Select class |
18 | * |
19 | * @category Pop |
20 | * @package Pop\Db |
21 | * @author Nick Sagona, III <dev@nolainteractive.com> |
22 | * @copyright Copyright (c) 2009-2024 NOLA Interactive, LLC. (http://www.nolainteractive.com) |
23 | * @license http://www.popphp.org/license New BSD License |
24 | * @version 6.5.0 |
25 | */ |
26 | class Select extends AbstractPredicateClause |
27 | { |
28 | |
29 | /** |
30 | * Distinct keyword |
31 | * @var bool |
32 | */ |
33 | protected bool $distinct = false; |
34 | |
35 | /** |
36 | * Joins |
37 | * @var array |
38 | */ |
39 | protected array $joins = []; |
40 | |
41 | /** |
42 | * HAVING predicate object |
43 | * @var ?Having |
44 | */ |
45 | protected ?Having $having = null; |
46 | |
47 | /** |
48 | * GROUP BY value |
49 | * @var ?string |
50 | */ |
51 | protected ?string $groupBy = null; |
52 | |
53 | /** |
54 | * ORDER BY value |
55 | * @var ?string |
56 | */ |
57 | protected ?string $orderBy = null; |
58 | |
59 | /** |
60 | * LIMIT value |
61 | * @var mixed |
62 | */ |
63 | protected mixed $limit = null; |
64 | |
65 | /** |
66 | * OFFSET value |
67 | * @var ?int |
68 | */ |
69 | protected ?int $offset = null; |
70 | |
71 | /** |
72 | * Select distinct |
73 | * |
74 | * @param bool $distinct |
75 | * @return Select |
76 | */ |
77 | public function distinct(bool $distinct = true): Select |
78 | { |
79 | $this->distinct = (bool)$distinct; |
80 | return $this; |
81 | } |
82 | |
83 | /** |
84 | * Set from table |
85 | * |
86 | * @param mixed $table |
87 | * @return Select |
88 | */ |
89 | public function from(mixed $table): Select |
90 | { |
91 | $this->setTable($table); |
92 | return $this; |
93 | } |
94 | |
95 | /** |
96 | * Set table AS alias name |
97 | * |
98 | * @param mixed $table |
99 | * @return Select |
100 | */ |
101 | public function asAlias(mixed $table): Select |
102 | { |
103 | $this->setAlias($table); |
104 | return $this; |
105 | } |
106 | |
107 | /** |
108 | * Add a JOIN clause |
109 | * |
110 | * @param mixed $foreignTable |
111 | * @param array $columns |
112 | * @param string $join |
113 | * @return Select |
114 | */ |
115 | public function join(mixed $foreignTable, array $columns, string $join = 'JOIN'): Select |
116 | { |
117 | $this->joins[] = new Join($this, $foreignTable, $columns, $join); |
118 | return $this; |
119 | } |
120 | |
121 | /** |
122 | * Add a LEFT JOIN clause |
123 | * |
124 | * @param mixed $foreignTable |
125 | * @param array $columns |
126 | * @return Select |
127 | */ |
128 | public function leftJoin(mixed $foreignTable, array $columns): Select |
129 | { |
130 | return $this->join($foreignTable, $columns, 'LEFT JOIN'); |
131 | } |
132 | |
133 | /** |
134 | * Add a RIGHT JOIN clause |
135 | * |
136 | * @param mixed $foreignTable |
137 | * @param array $columns |
138 | * @return Select |
139 | */ |
140 | public function rightJoin(mixed $foreignTable, array $columns): Select |
141 | { |
142 | return $this->join($foreignTable, $columns, 'RIGHT JOIN'); |
143 | } |
144 | |
145 | /** |
146 | * Add a FULL JOIN clause |
147 | * |
148 | * @param mixed $foreignTable |
149 | * @param array $columns |
150 | * @return Select |
151 | */ |
152 | public function fullJoin(mixed $foreignTable, array $columns): Select |
153 | { |
154 | return $this->join($foreignTable, $columns, 'FULL JOIN'); |
155 | } |
156 | |
157 | /** |
158 | * Add a OUTER JOIN clause |
159 | * |
160 | * @param mixed $foreignTable |
161 | * @param array $columns |
162 | * @return Select |
163 | */ |
164 | public function outerJoin(mixed $foreignTable, array $columns): Select |
165 | { |
166 | return $this->join($foreignTable, $columns, 'OUTER JOIN'); |
167 | } |
168 | |
169 | /** |
170 | * Add a LEFT OUTER JOIN clause |
171 | * |
172 | * @param mixed $foreignTable |
173 | * @param array $columns |
174 | * @return Select |
175 | */ |
176 | public function leftOuterJoin(mixed $foreignTable, array $columns): Select |
177 | { |
178 | return $this->join($foreignTable, $columns, 'LEFT OUTER JOIN'); |
179 | } |
180 | |
181 | /** |
182 | * Add a RIGHT OUTER JOIN clause |
183 | * |
184 | * @param mixed $foreignTable |
185 | * @param array $columns |
186 | * @return Select |
187 | */ |
188 | public function rightOuterJoin(mixed $foreignTable, array $columns): Select |
189 | { |
190 | return $this->join($foreignTable, $columns, 'RIGHT OUTER JOIN'); |
191 | } |
192 | |
193 | /** |
194 | * Add a FULL OUTER JOIN clause |
195 | * |
196 | * @param mixed $foreignTable |
197 | * @param array $columns |
198 | * @return Select |
199 | */ |
200 | public function fullOuterJoin(mixed $foreignTable, array $columns): Select |
201 | { |
202 | return $this->join($foreignTable, $columns, 'FULL OUTER JOIN'); |
203 | } |
204 | |
205 | /** |
206 | * Add a INNER JOIN clause |
207 | * |
208 | * @param mixed $foreignTable |
209 | * @param array $columns |
210 | * @return Select |
211 | */ |
212 | public function innerJoin(mixed $foreignTable, array $columns): Select |
213 | { |
214 | return $this->join($foreignTable, $columns, 'INNER JOIN'); |
215 | } |
216 | |
217 | /** |
218 | * Add a LEFT INNER JOIN clause |
219 | * |
220 | * @param mixed $foreignTable |
221 | * @param array $columns |
222 | * @return Select |
223 | */ |
224 | public function leftInnerJoin(mixed $foreignTable, array $columns): Select |
225 | { |
226 | return $this->join($foreignTable, $columns, 'LEFT INNER JOIN'); |
227 | } |
228 | |
229 | /** |
230 | * Add a RIGHT INNER JOIN clause |
231 | * |
232 | * @param mixed $foreignTable |
233 | * @param array $columns |
234 | * @return Select |
235 | */ |
236 | public function rightInnerJoin(mixed $foreignTable, array $columns): Select |
237 | { |
238 | return $this->join($foreignTable, $columns, 'RIGHT INNER JOIN'); |
239 | } |
240 | |
241 | /** |
242 | * Add a FULL INNER JOIN clause |
243 | * |
244 | * @param mixed $foreignTable |
245 | * @param array $columns |
246 | * @return Select |
247 | */ |
248 | public function fullInnerJoin(mixed $foreignTable, array $columns): Select |
249 | { |
250 | return $this->join($foreignTable, $columns, 'FULL INNER JOIN'); |
251 | } |
252 | |
253 | /** |
254 | * Access the HAVING clause |
255 | * |
256 | * @param mixed $having |
257 | * @return Select |
258 | */ |
259 | public function having(mixed $having = null): Select |
260 | { |
261 | if ($this->having === null) { |
262 | $this->having = new Having($this); |
263 | } |
264 | |
265 | if ($having !== null) { |
266 | if (is_string($having)) { |
267 | if ((stripos($having, ' AND ') !== false) || (stripos($having, ' OR ') !== false)) { |
268 | $expressions = array_map('trim', preg_split( |
269 | '/(AND|OR)/', $having, -1, PREG_SPLIT_DELIM_CAPTURE|PREG_SPLIT_NO_EMPTY |
270 | )); |
271 | foreach ($expressions as $i => $expression) { |
272 | if (isset($expressions[$i - 1]) && (strtoupper($expressions[$i - 1]) == 'AND')) { |
273 | $this->having->and($expression); |
274 | } else if (isset($expressions[$i - 1]) && (strtoupper($expressions[$i - 1]) == 'OR')) { |
275 | $this->having->or($expression); |
276 | } else if (($expression != 'AND') && ($expression != 'OR')) { |
277 | $this->having->add($expression); |
278 | } |
279 | } |
280 | } else { |
281 | $this->having->add($having); |
282 | } |
283 | } else if (is_array($having)) { |
284 | $this->having->addExpressions($having); |
285 | } |
286 | } |
287 | |
288 | return $this; |
289 | } |
290 | |
291 | /** |
292 | * Access the HAVING clause with AND |
293 | * |
294 | * @param mixed $having |
295 | * @return Select |
296 | */ |
297 | public function andHaving(mixed $having = null): Select |
298 | { |
299 | if ($this->having === null) { |
300 | $this->having = new Having($this); |
301 | } |
302 | |
303 | if ($having !== null) { |
304 | if (is_string($having)) { |
305 | $this->having->and($having); |
306 | } else if (is_array($having)) { |
307 | foreach ($having as $h) { |
308 | $this->having->and($h); |
309 | } |
310 | } |
311 | } |
312 | |
313 | return $this; |
314 | } |
315 | |
316 | /** |
317 | * Access the HAVING clause with OR |
318 | * |
319 | * @param mixed $having |
320 | * @return Select |
321 | */ |
322 | public function orHaving(mixed $having = null): Select |
323 | { |
324 | if ($this->having === null) { |
325 | $this->having = new Having($this); |
326 | } |
327 | |
328 | if ($having !== null) { |
329 | if (is_string($having)) { |
330 | $this->having->or($having); |
331 | } else if (is_array($having)) { |
332 | foreach ($having as $h) { |
333 | $this->having->or($h); |
334 | } |
335 | } |
336 | } |
337 | |
338 | return $this; |
339 | } |
340 | |
341 | /** |
342 | * Set the GROUP BY value |
343 | * |
344 | * @param mixed $by |
345 | * @return Select |
346 | */ |
347 | public function groupBy(mixed $by): Select |
348 | { |
349 | if (is_array($by)) { |
350 | $this->groupBy = implode(', ', array_map([$this, 'quoteId'], array_map('trim', $by))); |
351 | } else if (str_contains($by, ',')) { |
352 | $this->groupBy = implode(', ', array_map([$this, 'quoteId'], array_map('trim', explode(',' , $by)))); |
353 | } else { |
354 | $this->groupBy = $this->quoteId(trim($by)); |
355 | } |
356 | |
357 | return $this; |
358 | } |
359 | |
360 | /** |
361 | * Set the ORDER BY value |
362 | * |
363 | * @param mixed $by |
364 | * @param string $order |
365 | * @return Select |
366 | */ |
367 | public function orderBy(mixed $by, string $order = 'ASC'): Select |
368 | { |
369 | $byColumns = null; |
370 | $order = strtoupper($order); |
371 | |
372 | if (is_array($by)) { |
373 | $byColumns = implode(', ', array_map([$this, 'quoteId'], array_map('trim', $by))); |
374 | } else if (str_contains($by, ',')) { |
375 | $byColumns = implode(', ', array_map([$this, 'quoteId'], array_map('trim', explode(',' , $by)))); |
376 | } else { |
377 | $byColumns = $this->quoteId(trim($by)); |
378 | } |
379 | |
380 | $this->orderBy .= (($this->orderBy !== null) ? ', ' : '') . $byColumns; |
381 | |
382 | if (str_contains($order, 'RAND')) { |
383 | $this->orderBy .= ($this->isSqlite()) ? ' RANDOM()' : ' RAND()'; |
384 | } else if (($order == 'ASC') || ($order == 'DESC')) { |
385 | $this->orderBy .= ' ' . $order; |
386 | } |
387 | |
388 | return $this; |
389 | } |
390 | |
391 | /** |
392 | * Set the LIMIT value |
393 | * |
394 | * @param int $limit |
395 | * @return Select |
396 | */ |
397 | public function limit(int $limit): Select |
398 | { |
399 | $this->limit = $limit; |
400 | return $this; |
401 | } |
402 | |
403 | /** |
404 | * Set the OFFSET value |
405 | * |
406 | * @param int $offset |
407 | * @return Select |
408 | */ |
409 | public function offset(int $offset): Select |
410 | { |
411 | $this->offset = $offset; |
412 | return $this; |
413 | } |
414 | |
415 | /** |
416 | * Render the SELECT statement |
417 | * |
418 | * @throws Exception |
419 | * @return string |
420 | */ |
421 | public function render(): string |
422 | { |
423 | // Start building the SELECT statement |
424 | $sql = 'SELECT ' . (($this->distinct) ? 'DISTINCT ' : null); |
425 | |
426 | if (count($this->values) > 0) { |
427 | $cols = []; |
428 | foreach ($this->values as $as => $col) { |
429 | // If column is a SQL function, don't quote it |
430 | $c = self::isSupportedFunction($col) ? $col : $this->quoteId($col); |
431 | if (!is_numeric($as)) { |
432 | $cols[] = $c . ' AS ' . $this->quoteId($as); |
433 | } else { |
434 | $cols[] = $c; |
435 | } |
436 | } |
437 | $sql .= implode(', ', $cols) . ' '; |
438 | } else { |
439 | $sql .= '* '; |
440 | } |
441 | |
442 | $sql .= 'FROM '; |
443 | |
444 | // Account for LIMIT and OFFSET clauses if the database is SQLSRV |
445 | if (($this->isSqlsrv()) && (($this->limit !== null) || ($this->offset !== null))) { |
446 | if ($this->orderBy === null) { |
447 | throw new Exception( |
448 | 'Error: You must set an order by clause to execute a limit clause on the MS SQL Server database.' |
449 | ); |
450 | } |
451 | $sql .= $this->buildSqlSrvLimitAndOffset(); |
452 | // Else, if there is a nested SELECT statement. |
453 | } else if (($this->table instanceof \Pop\Db\Sql) && ($this->table->hasSelect())) { |
454 | $sql .= (string)$this->table->select(); |
455 | // Else, if there is a nested SELECT statement. |
456 | } else if ($this->table instanceof \Pop\Db\Sql\Select) { |
457 | $sql .= (string)$this->table; |
458 | // Else, if there is an aliased table |
459 | } else if (is_array($this->table)) { |
460 | if (count($this->table) !== 1) { |
461 | throw new Exception('Error: Only one table can be used in FROM clause.'); |
462 | } |
463 | $alias = array_key_first($this->table); |
464 | $table = $this->table[$alias]; |
465 | $sql .= $this->quoteId($table) . ' AS ' . $this->quoteId($alias); |
466 | // Else, just select from the table |
467 | } else { |
468 | $sql .= $this->quoteId($this->table); |
469 | } |
470 | |
471 | // Build any JOIN clauses |
472 | if (count($this->joins) > 0) { |
473 | foreach ($this->joins as $join) { |
474 | $sql .= ' ' . $join; |
475 | } |
476 | } |
477 | |
478 | // Build WHERE clause |
479 | if ($this->where !== null) { |
480 | $sql .= ' WHERE ' . $this->where; |
481 | } |
482 | |
483 | // Build HAVING clause |
484 | if ($this->having !== null) { |
485 | $sql .= ' HAVING ' . $this->having; |
486 | } |
487 | |
488 | // Build GROUP BY clause |
489 | if ($this->groupBy !== null) { |
490 | $sql .= ' GROUP BY ' . $this->groupBy; |
491 | } |
492 | |
493 | // Build ORDER BY clause |
494 | if ($this->orderBy !== null) { |
495 | $sql .= ' ORDER BY ' . $this->orderBy; |
496 | } |
497 | |
498 | // Build LIMIT clause for all other database types. |
499 | if (!$this->isSqlsrv()) { |
500 | if ($this->limit !== null) { |
501 | if ((str_contains($this->limit, ',')) && ($this->isPgsql())) { |
502 | [$offset, $limit] = explode(',', $this->limit); |
503 | $this->offset = (int)trim($offset); |
504 | $this->limit = (int)trim($limit); |
505 | } |
506 | $sql .= ' LIMIT ' . $this->limit; |
507 | } |
508 | } |
509 | |
510 | // Build OFFSET clause for all other database types. |
511 | if (!$this->isSqlsrv()) { |
512 | if ($this->offset !== null) { |
513 | $sql .= ' OFFSET ' . $this->offset; |
514 | } |
515 | } |
516 | |
517 | if ($this->alias !== null) { |
518 | $sql = '(' . $sql . ') AS ' . $this->quoteId($this->alias); |
519 | } |
520 | |
521 | return $sql; |
522 | } |
523 | |
524 | /** |
525 | * Render the SELECT statement |
526 | * |
527 | * @throws Exception |
528 | * @return string |
529 | */ |
530 | public function __toString(): string |
531 | { |
532 | return $this->render(); |
533 | } |
534 | |
535 | /** |
536 | * Magic method to access $where and $having properties |
537 | * |
538 | * @param string $name |
539 | * @throws Exception |
540 | * @return mixed |
541 | */ |
542 | public function __get(string $name): mixed |
543 | { |
544 | switch (strtolower($name)) { |
545 | case 'where': |
546 | if ($this->where === null) { |
547 | $this->where = new Where($this); |
548 | } |
549 | return $this->where; |
550 | break; |
551 | case 'having': |
552 | if ($this->having === null) { |
553 | $this->having = new Having($this); |
554 | } |
555 | return $this->having; |
556 | break; |
557 | default: |
558 | throw new Exception("The property '" . $name ."' is not a valid property for this select object."); |
559 | } |
560 | } |
561 | |
562 | /** |
563 | * Method to get the limit and offset |
564 | * |
565 | * @return array |
566 | */ |
567 | protected function getLimitAndOffset(): array |
568 | { |
569 | $result = [ |
570 | 'limit' => null, |
571 | 'offset' => null |
572 | ]; |
573 | |
574 | // Calculate the limit and/or offset |
575 | if ($this->offset !== null) { |
576 | $result['offset'] = (int)$this->offset + 1; |
577 | $result['limit'] = ($this->limit !== null) ? (int)$this->limit + (int)$this->offset : 0; |
578 | } else if (str_contains($this->limit, ',')) { |
579 | $ary = explode(',', $this->limit); |
580 | $result['offset'] = (int)trim($ary[0]) + 1; |
581 | $result['limit'] = (int)trim($ary[1]) + (int)trim($ary[0]); |
582 | } else { |
583 | $result['limit'] = (int)$this->limit; |
584 | } |
585 | |
586 | return $result; |
587 | } |
588 | |
589 | /** |
590 | * Method to build SQL Server limit and offset sub-clause |
591 | * |
592 | * @return string |
593 | */ |
594 | protected function buildSqlSrvLimitAndOffset(): string |
595 | { |
596 | $sql = null; |
597 | $result = $this->getLimitAndOffset(); |
598 | if ($result['offset'] !== null) { |
599 | if ($this->where === null) { |
600 | $this->where = new Where($this); |
601 | } |
602 | |
603 | $sql .= '(SELECT *, ROW_NUMBER() OVER (ORDER BY ' . $this->orderBy . ') AS RowNumber FROM ' . |
604 | $this->quoteId($this->table) . ') AS OrderedTable'; |
605 | if ($result['limit'] > 0) { |
606 | $this->where->between('OrderedTable.RowNumber', $result['offset'], $result['limit']); |
607 | } else { |
608 | $this->where->greaterThanOrEqualTo('OrderedTable.RowNumber', $result['offset']); |
609 | } |
610 | } else { |
611 | $sql = str_replace('SELECT', 'SELECT TOP ' . $result['limit'], $sql); |
612 | $sql .= $this->quoteId($this->table); |
613 | } |
614 | |
615 | return $sql; |
616 | } |
617 | |
618 | } |