Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
194 / 194
100.00% covered (success)
100.00%
34 / 34
CRAP
100.00% covered (success)
100.00%
1 / 1
Select
100.00% covered (success)
100.00%
194 / 194
100.00% covered (success)
100.00%
34 / 34
118
100.00% covered (success)
100.00%
1 / 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%
17 / 17
100.00% covered (success)
100.00%
1 / 1
13
 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
 quoteByColumn
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 groupBy
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 orderBy
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
9
 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
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
8
 buildColumnsClause
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
9
 buildFromClause
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
10
 buildJoinsClause
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 buildLimitOffsetClause
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
7
 __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
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 buildSqlSrvLimitAndOffset
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 buildSqlSrvTopClause
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
5
 buildSqlSrvRowNumberPredicate
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Db\Sql;
16
17/**
18 * Select class
19 *
20 * @category   Pop
21 * @package    Pop\Db
22 * @author     Nick Sagona, III <nick@popphp.org>
23 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
24 * @license    https://www.popphp.org/license     New BSD License
25 * @version    7.0.0
26 *
27 * @property-read ?PredicateSet $having HAVING predicate object (lazily created)
28 */
29class Select extends AbstractPredicateClause
30{
31
32    /**
33     * Distinct keyword
34     * @var bool
35     */
36    protected bool $distinct = false;
37
38    /**
39     * Joins
40     * @var array
41     */
42    protected array $joins = [];
43
44    /**
45     * HAVING predicate object
46     * @var ?Having
47     */
48    protected ?Having $having = null;
49
50    /**
51     * GROUP BY value
52     * @var ?string
53     */
54    protected ?string $groupBy = null;
55
56    /**
57     * ORDER BY value
58     * @var ?string
59     */
60    protected ?string $orderBy = null;
61
62    /**
63     * LIMIT value
64     * @var mixed
65     */
66    protected mixed $limit = null;
67
68    /**
69     * OFFSET value
70     * @var ?int
71     */
72    protected ?int $offset = null;
73
74    /**
75     * Select distinct
76     *
77     * @param  bool $distinct
78     * @return Select
79     */
80    public function distinct(bool $distinct = true): Select
81    {
82        $this->distinct = (bool)$distinct;
83        return $this;
84    }
85
86    /**
87     * Set from table
88     *
89     * @param  mixed  $table
90     * @return Select
91     */
92    public function from(mixed $table): Select
93    {
94        $this->setTable($table);
95        return $this;
96    }
97
98    /**
99     * Set table AS alias name
100     *
101     * @param  mixed  $table
102     * @return Select
103     */
104    public function asAlias(mixed $table): Select
105    {
106        $this->setAlias($table);
107        return $this;
108    }
109
110    /**
111     * Add a JOIN clause
112     *
113     * @param  mixed $foreignTable
114     * @param  array  $columns
115     * @param  string $join
116     * @return Select
117     */
118    public function join(mixed $foreignTable, array $columns, string $join = 'JOIN'): Select
119    {
120        $this->joins[] = new Join($this, $foreignTable, $columns, $join);
121        return $this;
122    }
123
124    /**
125     * Add a LEFT JOIN clause
126     *
127     * @param  mixed $foreignTable
128     * @param  array $columns
129     * @return Select
130     */
131    public function leftJoin(mixed $foreignTable, array $columns): Select
132    {
133        return $this->join($foreignTable, $columns, 'LEFT JOIN');
134    }
135
136    /**
137     * Add a RIGHT JOIN clause
138     *
139     * @param  mixed $foreignTable
140     * @param  array $columns
141     * @return Select
142     */
143    public function rightJoin(mixed $foreignTable, array $columns): Select
144    {
145        return $this->join($foreignTable, $columns, 'RIGHT JOIN');
146    }
147
148    /**
149     * Add a FULL JOIN clause
150     *
151     * @param  mixed $foreignTable
152     * @param  array $columns
153     * @return Select
154     */
155    public function fullJoin(mixed $foreignTable, array $columns): Select
156    {
157        return $this->join($foreignTable, $columns, 'FULL JOIN');
158    }
159
160    /**
161     * Add a OUTER JOIN clause
162     *
163     * @param  mixed $foreignTable
164     * @param  array $columns
165     * @return Select
166     */
167    public function outerJoin(mixed $foreignTable, array $columns): Select
168    {
169        return $this->join($foreignTable, $columns, 'OUTER JOIN');
170    }
171
172    /**
173     * Add a LEFT OUTER JOIN clause
174     *
175     * @param  mixed $foreignTable
176     * @param  array $columns
177     * @return Select
178     */
179    public function leftOuterJoin(mixed $foreignTable, array $columns): Select
180    {
181        return $this->join($foreignTable, $columns, 'LEFT OUTER JOIN');
182    }
183
184    /**
185     * Add a RIGHT OUTER JOIN clause
186     *
187     * @param  mixed $foreignTable
188     * @param  array $columns
189     * @return Select
190     */
191    public function rightOuterJoin(mixed $foreignTable, array $columns): Select
192    {
193        return $this->join($foreignTable, $columns, 'RIGHT OUTER JOIN');
194    }
195
196    /**
197     * Add a FULL OUTER JOIN clause
198     *
199     * @param  mixed $foreignTable
200     * @param  array $columns
201     * @return Select
202     */
203    public function fullOuterJoin(mixed $foreignTable, array $columns): Select
204    {
205        return $this->join($foreignTable, $columns, 'FULL OUTER JOIN');
206    }
207
208    /**
209     * Add a INNER JOIN clause
210     *
211     * @param  mixed $foreignTable
212     * @param  array $columns
213     * @return Select
214     */
215    public function innerJoin(mixed $foreignTable, array $columns): Select
216    {
217        return $this->join($foreignTable, $columns, 'INNER JOIN');
218    }
219
220    /**
221     * Add a LEFT INNER JOIN clause
222     *
223     * @param  mixed $foreignTable
224     * @param  array $columns
225     * @return Select
226     */
227    public function leftInnerJoin(mixed $foreignTable, array $columns): Select
228    {
229        return $this->join($foreignTable, $columns, 'LEFT INNER JOIN');
230    }
231
232    /**
233     * Add a RIGHT INNER JOIN clause
234     *
235     * @param  mixed $foreignTable
236     * @param  array $columns
237     * @return Select
238     */
239    public function rightInnerJoin(mixed $foreignTable, array $columns): Select
240    {
241        return $this->join($foreignTable, $columns, 'RIGHT INNER JOIN');
242    }
243
244    /**
245     * Add a FULL INNER JOIN clause
246     *
247     * @param  mixed $foreignTable
248     * @param  array $columns
249     * @return Select
250     */
251    public function fullInnerJoin(mixed $foreignTable, array $columns): Select
252    {
253        return $this->join($foreignTable, $columns, 'FULL INNER JOIN');
254    }
255
256    /**
257     * Access the HAVING clause
258     *
259     * @param  mixed $having
260     * @return Select
261     */
262    public function having(mixed $having = null): Select
263    {
264        if ($this->having === null) {
265            $this->having = new Having($this);
266        }
267
268        if ($having !== null) {
269            if (is_string($having)) {
270                $tokens = Parser\Keyword::split($having);
271                if (count($tokens) > 1) {
272                    foreach ($tokens as $i => $token) {
273                        if (($i > 0) && (($tokens[$i - 1] === 'AND') || ($tokens[$i - 1] === 'OR'))) {
274                            if ($tokens[$i - 1] === 'AND') {
275                                $this->having->and($token);
276                            } else {
277                                $this->having->or($token);
278                            }
279                        } else if (($token !== 'AND') && ($token !== 'OR')) {
280                            $this->having->add($token);
281                        }
282                    }
283                } else {
284                    $this->having->add($having);
285                }
286            } else if (is_array($having)) {
287                $this->having->addExpressions($having);
288            }
289        }
290
291        return $this;
292    }
293
294    /**
295     * Access the HAVING clause with AND
296     *
297     * @param  mixed $having
298     * @return Select
299     */
300    public function andHaving(mixed $having = null): Select
301    {
302        if ($this->having === null) {
303            $this->having = new Having($this);
304        }
305
306        if ($having !== null) {
307            if (is_string($having)) {
308                $this->having->and($having);
309            } else if (is_array($having)) {
310                foreach ($having as $h) {
311                    $this->having->and($h);
312                }
313            }
314        }
315
316        return $this;
317    }
318
319    /**
320     * Access the HAVING clause with OR
321     *
322     * @param  mixed $having
323     * @return Select
324     */
325    public function orHaving(mixed $having = null): Select
326    {
327        if ($this->having === null) {
328            $this->having = new Having($this);
329        }
330
331        if ($having !== null) {
332            if (is_string($having)) {
333                $this->having->or($having);
334            } else if (is_array($having)) {
335                foreach ($having as $h) {
336                    $this->having->or($h);
337                }
338            }
339        }
340
341        return $this;
342    }
343
344    /**
345     * Quote a single GROUP BY / ORDER BY column
346     *
347     * A JsonExtract value object already carries its own fully-rendered, dialect-specific
348     * extraction SQL, so it is embedded verbatim - passing it through trim()/quoteId() would
349     * either error or wrap the whole expression in identifier quotes, producing invalid SQL.
350     *
351     * @param  mixed $column
352     * @return string
353     */
354    protected function quoteByColumn(mixed $column): string
355    {
356        return ($column instanceof JsonExtract) ? (string)$column : $this->quoteId(trim($column));
357    }
358
359    /**
360     * Set the GROUP BY value
361     *
362     * @param mixed $by
363     * @return Select
364     */
365    public function groupBy(mixed $by): Select
366    {
367        if ($by instanceof JsonExtract) {
368            $this->groupBy = (string)$by;
369        } else if (is_array($by)) {
370            $this->groupBy = implode(', ', array_map([$this, 'quoteByColumn'], $by));
371        } else if (str_contains($by, ',')) {
372            $this->groupBy = implode(', ', array_map([$this, 'quoteId'], array_map('trim', explode(',' , $by))));
373        } else {
374            $this->groupBy = $this->quoteId(trim($by));
375        }
376
377        return $this;
378    }
379
380    /**
381     * Set the ORDER BY value
382     *
383     * @param  mixed  $by
384     * @param  string $order
385     * @return Select
386     */
387    public function orderBy(mixed $by, string $order = 'ASC'): Select
388    {
389        $byColumns = null;
390        $order     = strtoupper($order);
391
392        if ($by instanceof JsonExtract) {
393            $byColumns = (string)$by;
394        } else if (is_array($by)) {
395            $byColumns = implode(', ', array_map([$this, 'quoteByColumn'], $by));
396        } else if (str_contains($by, ',')) {
397            $byColumns = implode(', ', array_map([$this, 'quoteId'], array_map('trim', explode(',' , $by))));
398        } else {
399            $byColumns = $this->quoteId(trim($by));
400        }
401
402        $this->orderBy .= (($this->orderBy !== null) ? ', ' : '') . $byColumns;
403
404        if (str_contains($order, 'RAND')) {
405            $this->orderBy .= ($this->isSqlite()) ? ' RANDOM()' : ' RAND()';
406        } else if (($order == 'ASC') || ($order == 'DESC')) {
407            $this->orderBy .= ' ' . $order;
408        }
409
410        return $this;
411    }
412
413    /**
414     * Set the LIMIT value
415     *
416     * @param  int $limit
417     * @return Select
418     */
419    public function limit(int $limit): Select
420    {
421        $this->limit = $limit;
422        return $this;
423    }
424
425    /**
426     * Set the OFFSET value
427     *
428     * @param  int $offset
429     * @return Select
430     */
431    public function offset(int $offset): Select
432    {
433        $this->offset = $offset;
434        return $this;
435    }
436
437    /**
438     * Render the SELECT statement
439     *
440     * @throws Exception
441     * @return string
442     */
443    public function render(): string
444    {
445        $sql  = 'SELECT ' . (($this->distinct) ? 'DISTINCT ' : null) . $this->buildSqlSrvTopClause();
446        $sql .= $this->buildColumnsClause();
447        $sql .= 'FROM ' . $this->buildFromClause();
448        $sql .= $this->buildJoinsClause();
449
450        // Build WHERE clause. The SQLSRV row number predicate is derived fresh on each render
451        // and combined here rather than pushed into $wherePredicate, so that rendering twice
452        // does not accumulate it and so that it always ANDs with the whole user predicate.
453        $wherePredicates = array_values(array_filter([
454            ($this->wherePredicate !== null) ? (string)$this->wherePredicate : '',
455            (string)$this->buildSqlSrvRowNumberPredicate()
456        ], function($predicate) {
457            return ($predicate !== '');
458        }));
459
460        if (!empty($wherePredicates)) {
461            $sql .= ' WHERE ' . implode(' AND ', $wherePredicates);
462        }
463
464        // Build GROUP BY clause (must precede HAVING)
465        if ($this->groupBy !== null) {
466            $sql .= ' GROUP BY ' . $this->groupBy;
467        }
468
469        // Build HAVING clause
470        if ($this->having !== null) {
471            $sql .= ' HAVING ' . $this->having;
472        }
473
474        // Build ORDER BY clause
475        if ($this->orderBy !== null) {
476            $sql .= ' ORDER BY ' . $this->orderBy;
477        }
478
479        $sql .= $this->buildLimitOffsetClause();
480
481        if ($this->alias !== null) {
482            $sql = '(' . $sql . ') AS ' . $this->quoteId($this->alias);
483        }
484
485        return $sql;
486    }
487
488    /**
489     * Build the column list portion of the SELECT statement
490     *
491     * @return string
492     */
493    protected function buildColumnsClause(): string
494    {
495        if (count($this->values) === 0) {
496            return '* ';
497        }
498
499        $cols = [];
500        foreach ($this->values as $as => $col) {
501            // If column is a nested SQL query
502            if ($col instanceof AbstractSql) {
503                $cols[] = (!is_numeric($as)) ?
504                    '(' . $col . ') AS ' . $this->quoteId($as) : '(' .  $col . ')';
505            } else if ($col instanceof JsonExtract) {
506                $cols[] = (!is_numeric($as)) ?
507                    (string)$col . ' AS ' . $this->quoteId($as) : (string)$col;
508            } else {
509                // If column is a SQL function, don't quote it
510                $c = self::isSupportedFunction($col) ? $col :  $this->quoteId($col);
511                $cols[] = (!is_numeric($as)) ?
512                    $c . ' AS ' . $this->quoteId($as) : $c;
513            }
514        }
515
516        return implode(', ', $cols) . ' ';
517    }
518
519    /**
520     * Build the FROM clause target (table, aliased table, or nested SELECT)
521     *
522     * @throws Exception
523     * @return string
524     */
525    protected function buildFromClause(): string
526    {
527        // Account for LIMIT and OFFSET clauses if the database is SQLSRV
528        if (($this->isSqlsrv()) && (($this->limit !== null) || ($this->offset !== null))) {
529            if ($this->orderBy === null) {
530                throw new Exception(
531                    'Error: You must set an order by clause to execute a limit clause on the MS SQL Server database.'
532                );
533            }
534            return $this->buildSqlSrvLimitAndOffset();
535        }
536
537        // Nested SELECT statement
538        if (($this->table instanceof \Pop\Db\Sql) && ($this->table->hasSelect())) {
539            return (string)$this->table->select();
540        }
541
542        // Nested SELECT statement
543        if ($this->table instanceof \Pop\Db\Sql\Select) {
544            return (string)$this->table;
545        }
546
547        // Aliased table
548        if (is_array($this->table)) {
549            if (count($this->table) !== 1) {
550                throw new Exception('Error: Only one table can be used in FROM clause.');
551            }
552            $alias = array_key_first($this->table);
553            $table = $this->table[$alias];
554            return $this->quoteId($table) . ' AS ' . $this->quoteId($alias);
555        }
556
557        return $this->quoteId($this->table);
558    }
559
560    /**
561     * Build any JOIN clauses
562     *
563     * @return string
564     */
565    protected function buildJoinsClause(): string
566    {
567        $sql = '';
568
569        foreach ($this->joins as $join) {
570            $sql .= ' ' . $join;
571        }
572
573        return $sql;
574    }
575
576    /**
577     * Build the LIMIT/OFFSET clause for non-SQLSRV databases
578     *
579     * @return string
580     */
581    protected function buildLimitOffsetClause(): string
582    {
583        if ($this->isSqlsrv()) {
584            return '';
585        }
586
587        $sql = '';
588
589        if ($this->limit !== null) {
590            if ((is_string($this->limit)) && (str_contains($this->limit, ',')) && ($this->isPgsql())) {
591                [$offset, $limit] = explode(',', $this->limit);
592                $this->offset     = (int)trim($offset);
593                $this->limit      = (int)trim($limit);
594            }
595            $sql .= ' LIMIT ' . $this->limit;
596        }
597
598        if ($this->offset !== null) {
599            $sql .= ' OFFSET ' . $this->offset;
600        }
601
602        return $sql;
603    }
604
605    /**
606     * Render the SELECT statement
607     *
608     * @throws Exception
609     * @return string
610     */
611    public function __toString(): string
612    {
613        return $this->render();
614    }
615
616    /**
617     * Magic method to access $where and $having properties
618     *
619     * @param  string $name
620     * @throws Exception
621     * @return mixed
622     */
623    public function __get(string $name): mixed
624    {
625        switch (strtolower($name)) {
626            case 'where':
627                if ($this->wherePredicate === null) {
628                    $this->wherePredicate = new Where($this);
629                }
630                return $this->wherePredicate;
631            case 'having':
632                if ($this->having === null) {
633                    $this->having = new Having($this);
634                }
635                return $this->having;
636            default:
637                throw new Exception("The property '" . $name ."' is not a valid property for this select object.");
638        }
639    }
640
641    /**
642     * Method to get the limit and offset
643     *
644     * @return array
645     */
646    protected function getLimitAndOffset(): array
647    {
648        $result = [
649            'limit'  => null,
650            'offset' => null
651        ];
652
653        // Calculate the limit and/or offset
654        if ($this->offset !== null) {
655            $result['offset'] = (int)$this->offset + 1;
656            $result['limit']  = ($this->limit !== null) ? (int)$this->limit + (int)$this->offset : 0;
657        } else if (is_string($this->limit) && str_contains($this->limit, ',')) {
658            $ary  = explode(',', $this->limit);
659            $result['offset'] = (int)trim($ary[0]) + 1;
660            $result['limit']  = (int)trim($ary[1]) + (int)trim($ary[0]);
661        } else {
662            $result['limit']  = (int)$this->limit;
663        }
664
665        return $result;
666    }
667
668    /**
669     * Method to build the SQL Server limit and offset FROM clause target
670     *
671     * With an offset, the table is wrapped in a derived table carrying a ROW_NUMBER() column
672     * that buildSqlSrvRowNumberPredicate() then filters on. Without one, the row cap is a
673     * TOP clause on the SELECT itself (see buildSqlSrvTopClause()), so the table is used
674     * as is. This method is side effect free.
675     *
676     * @return string
677     */
678    protected function buildSqlSrvLimitAndOffset(): string
679    {
680        $result = $this->getLimitAndOffset();
681
682        if ($result['offset'] !== null) {
683            return '(SELECT *, ROW_NUMBER() OVER (ORDER BY ' . $this->orderBy . ') AS RowNumber FROM ' .
684                $this->quoteId($this->table) . ') AS OrderedTable';
685        }
686
687        return $this->quoteId($this->table);
688    }
689
690    /**
691     * Method to build the SQL Server TOP clause, for a limit that carries no offset
692     *
693     * @return string
694     */
695    protected function buildSqlSrvTopClause(): string
696    {
697        if ((!$this->isSqlsrv()) || (($this->limit === null) && ($this->offset === null))) {
698            return '';
699        }
700
701        $result = $this->getLimitAndOffset();
702
703        // An offset is handled by the ROW_NUMBER() derived table instead
704        return ($result['offset'] === null) ? 'TOP ' . $result['limit'] . ' ' : '';
705    }
706
707    /**
708     * Method to build the SQL Server predicate that filters the ROW_NUMBER() derived table
709     *
710     * Returned as its own predicate set so that it is combined with the user's WHERE clause
711     * by a top level AND. Adding it to the user's predicate set would inherit the conjunction
712     * of the predicate before it, turning a WHERE of 'a OR b' into 'a OR b OR rownumber',
713     * which drops the limit entirely.
714     *
715     * @return ?string
716     */
717    protected function buildSqlSrvRowNumberPredicate(): ?string
718    {
719        if ((!$this->isSqlsrv()) || (($this->limit === null) && ($this->offset === null))) {
720            return null;
721        }
722
723        $result = $this->getLimitAndOffset();
724
725        if ($result['offset'] === null) {
726            return null;
727        }
728
729        $rowNumber = new Where($this);
730
731        if ($result['limit'] > 0) {
732            $rowNumber->between('OrderedTable.RowNumber', $result['offset'], $result['limit']);
733        } else {
734            $rowNumber->greaterThanOrEqualTo('OrderedTable.RowNumber', $result['offset']);
735        }
736
737        return (string)$rowNumber;
738    }
739
740}