Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.17% covered (success)
79.17%
133 / 168
88.89% covered (success)
88.89%
24 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
Select
79.17% covered (success)
79.17%
133 / 168
88.89% covered (success)
88.89%
24 / 27
182.08
0.00% covered (danger)
0.00%
0 / 1
 distinct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 from
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 asAlias
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 join
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 leftJoin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 rightJoin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fullJoin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 outerJoin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 leftOuterJoin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 rightOuterJoin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fullOuterJoin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 innerJoin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 leftInnerJoin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 rightInnerJoin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fullInnerJoin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 having
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
14
 andHaving
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 orHaving
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 groupBy
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 orderBy
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
8
 limit
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 offset
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 render
82.69% covered (success)
82.69%
43 / 52
0.00% covered (danger)
0.00%
0 / 1
32.06
 __toString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __get
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 getLimitAndOffset
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 buildSqlSrvLimitAndOffset
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
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 */
14namespace 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 */
26class 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}