Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.24% covered (success)
78.24%
133 / 170
88.89% covered (success)
88.89%
24 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
Select
78.24% covered (success)
78.24%
133 / 170
88.89% covered (success)
88.89%
24 / 27
200.05
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
79.63% covered (success)
79.63%
43 / 54
0.00% covered (danger)
0.00%
0 / 1
37.61
 __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 (https://www.popphp.org/)
4 *
5 * @link       https://github.com/popphp/popphp-framework
6 * @author     Nick Sagona, III <dev@noladev.com>
7 * @copyright  Copyright (c) 2009-2025 NOLA Interactive, LLC.
8 * @license    https://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@noladev.com>
22 * @copyright  Copyright (c) 2009-2025 NOLA Interactive, LLC.
23 * @license    https://www.popphp.org/license     New BSD License
24 * @version    6.6.5
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 nested SQL query
430                if ($col instanceof AbstractSql) {
431                    $cols[] = (!is_numeric($as)) ?
432                        '(' . $col . ') AS ' . $this->quoteId($as) : '(' .  $col . ')';
433                } else {
434                    // If column is a SQL function, don't quote it
435                    $c = self::isSupportedFunction($col) ? $col :  $this->quoteId($col);
436                    $cols[] = (!is_numeric($as)) ?
437                        $c . ' AS ' . $this->quoteId($as) : $c;
438                }
439            }
440            $sql .= implode(', ', $cols) . ' ';
441        } else {
442            $sql .= '* ';
443        }
444
445        $sql .= 'FROM ';
446
447        // Account for LIMIT and OFFSET clauses if the database is SQLSRV
448        if (($this->isSqlsrv()) && (($this->limit !== null) || ($this->offset !== null))) {
449            if ($this->orderBy === null) {
450                throw new Exception(
451                    'Error: You must set an order by clause to execute a limit clause on the MS SQL Server database.'
452                );
453            }
454            $sql .= $this->buildSqlSrvLimitAndOffset();
455        // Else, if there is a nested SELECT statement.
456        } else if (($this->table instanceof \Pop\Db\Sql) && ($this->table->hasSelect())) {
457            $sql .= (string)$this->table->select();
458        // Else, if there is a nested SELECT statement.
459        } else if ($this->table instanceof \Pop\Db\Sql\Select) {
460            $sql .= (string)$this->table;
461        // Else, if there is an aliased table
462        } else if (is_array($this->table)) {
463            if (count($this->table) !== 1) {
464                throw new Exception('Error: Only one table can be used in FROM clause.');
465            }
466            $alias = array_key_first($this->table);
467            $table = $this->table[$alias];
468            $sql  .= $this->quoteId($table) . ' AS ' . $this->quoteId($alias);
469        // Else, just select from the table
470        } else {
471            $sql .= $this->quoteId($this->table);
472        }
473
474        // Build any JOIN clauses
475        if (count($this->joins) > 0) {
476            foreach ($this->joins as $join) {
477                $sql .= ' ' . $join;
478            }
479        }
480
481        // Build WHERE clause
482        if ($this->where !== null) {
483            $sql .= ' WHERE ' . $this->where;
484        }
485
486        // Build HAVING clause
487        if ($this->having !== null) {
488            $sql .= ' HAVING ' . $this->having;
489        }
490
491        // Build GROUP BY clause
492        if ($this->groupBy !== null) {
493            $sql .= ' GROUP BY ' . $this->groupBy;
494        }
495
496        // Build ORDER BY clause
497        if ($this->orderBy !== null) {
498            $sql .= ' ORDER BY ' . $this->orderBy;
499        }
500
501        // Build LIMIT clause for all other database types.
502        if (!$this->isSqlsrv()) {
503            if ($this->limit !== null) {
504                if ((str_contains($this->limit, ',')) && ($this->isPgsql())) {
505                    [$offset, $limit] = explode(',', $this->limit);
506                    $this->offset     = (int)trim($offset);
507                    $this->limit      = (int)trim($limit);
508                }
509                $sql .= ' LIMIT ' . $this->limit;
510            }
511        }
512
513        // Build OFFSET clause for all other database types.
514        if (!$this->isSqlsrv()) {
515            if ($this->offset !== null) {
516                $sql .= ' OFFSET ' . $this->offset;
517            }
518        }
519
520        if ($this->alias !== null) {
521            $sql = '(' . $sql . ') AS ' . $this->quoteId($this->alias);
522        }
523
524        return $sql;
525    }
526
527    /**
528     * Render the SELECT statement
529     *
530     * @throws Exception
531     * @return string
532     */
533    public function __toString(): string
534    {
535        return $this->render();
536    }
537
538    /**
539     * Magic method to access $where and $having properties
540     *
541     * @param  string $name
542     * @throws Exception
543     * @return mixed
544     */
545    public function __get(string $name): mixed
546    {
547        switch (strtolower($name)) {
548            case 'where':
549                if ($this->where === null) {
550                    $this->where = new Where($this);
551                }
552                return $this->where;
553                break;
554            case 'having':
555                if ($this->having === null) {
556                    $this->having = new Having($this);
557                }
558                return $this->having;
559                break;
560            default:
561                throw new Exception("The property '" . $name ."' is not a valid property for this select object.");
562        }
563    }
564
565    /**
566     * Method to get the limit and offset
567     *
568     * @return array
569     */
570    protected function getLimitAndOffset(): array
571    {
572        $result = [
573            'limit'  => null,
574            'offset' => null
575        ];
576
577        // Calculate the limit and/or offset
578        if ($this->offset !== null) {
579            $result['offset'] = (int)$this->offset + 1;
580            $result['limit']  = ($this->limit !== null) ? (int)$this->limit + (int)$this->offset : 0;
581        } else if (str_contains($this->limit, ',')) {
582            $ary  = explode(',', $this->limit);
583            $result['offset'] = (int)trim($ary[0]) + 1;
584            $result['limit']  = (int)trim($ary[1]) + (int)trim($ary[0]);
585        } else {
586            $result['limit']  = (int)$this->limit;
587        }
588
589        return $result;
590    }
591
592    /**
593     * Method to build SQL Server limit and offset sub-clause
594     *
595     * @return string
596     */
597    protected function buildSqlSrvLimitAndOffset(): string
598    {
599        $sql    = null;
600        $result = $this->getLimitAndOffset();
601        if ($result['offset'] !== null) {
602            if ($this->where === null) {
603                $this->where = new Where($this);
604            }
605
606            $sql .= '(SELECT *, ROW_NUMBER() OVER (ORDER BY ' . $this->orderBy . ') AS RowNumber FROM ' .
607                $this->quoteId($this->table) . ') AS OrderedTable';
608            if ($result['limit'] > 0) {
609                $this->where->between('OrderedTable.RowNumber', $result['offset'], $result['limit']);
610            } else {
611                $this->where->greaterThanOrEqualTo('OrderedTable.RowNumber', $result['offset']);
612            }
613        } else {
614            $sql  = str_replace('SELECT', 'SELECT TOP ' . $result['limit'], $sql);
615            $sql .= $this->quoteId($this->table);
616        }
617
618        return $sql;
619    }
620
621}