Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
202 / 202
100.00% covered (success)
100.00%
19 / 19
CRAP
100.00% covered (success)
100.00%
1 / 1
Select
100.00% covered (success)
100.00%
202 / 202
100.00% covered (success)
100.00%
19 / 19
60
100.00% covered (success)
100.00%
1 / 1
 renderOptions
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
6
 expressions
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 columns
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 renderExpressions
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 limit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 procedure
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 renderProcedure
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 intoOutfile
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 renderIntoOutfile
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 partIntoOutfileFields
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 partIntoOutfileLines
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 intoDumpfile
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 renderIntoDumpfile
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 lockForUpdate
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 lockInShareMode
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 renderLock
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 sql
100.00% covered (success)
100.00%
51 / 51
100.00% covered (success)
100.00%
1 / 1
15
 run
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 runUnbuffered
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php declare(strict_types=1);
2/*
3 * This file is part of Aplus Framework Database Library.
4 *
5 * (c) Natan Felles <natanfelles@gmail.com>
6 *
7 * For the full copyright and license information, please view the LICENSE
8 * file that was distributed with this source code.
9 */
10namespace Framework\Database\Manipulation;
11
12use Closure;
13use Framework\Database\Result;
14use InvalidArgumentException;
15use LogicException;
16
17/**
18 * Class Select.
19 *
20 * @see https://mariadb.com/kb/en/select/
21 *
22 * @package database
23 */
24class Select extends Statement
25{
26    use Traits\Join;
27    use Traits\GroupBy;
28    use Traits\Having;
29    use Traits\OrderBy;
30
31    /**
32     * Option to retrieve identical rows.
33     *
34     * @see Select::renderOptions()
35     *
36     * @var string
37     */
38    public const OPT_ALL = 'ALL';
39    /**
40     * Option to not retrieve identical rows. Remove duplicates from the result set.
41     *
42     * @see Select::renderOptions()
43     * @see https://mariadb.com/kb/en/select/#distinct
44     *
45     * @var string
46     */
47    public const OPT_DISTINCT = 'DISTINCT';
48    /**
49     * Alias of OPT_DISTINCT.
50     *
51     * @see Select::renderOptions()
52     *
53     * @var string
54     */
55    public const OPT_DISTINCTROW = 'DISTINCTROW';
56    /**
57     * If the table is locked, HIGH_PRIORITY will be executed as soon as the lock is released,
58     * even if other statements are queued.
59     * Only supports table-level locking (MyISAM, MEMORY, MERGE).
60     *
61     * @see Select::renderOptions()
62     * @see https://mariadb.com/kb/en/high_priority-and-low_priority/
63     *
64     * @var string
65     */
66    public const OPT_HIGH_PRIORITY = 'HIGH_PRIORITY';
67    /**
68     * Applicable to the JOIN queries. Tells the optimizer that
69     * the tables must be read in the order they appear.
70     * For const and system table this option is sometimes ignored.
71     *
72     * @see Select::renderOptions()
73     * @see https://mariadb.com/kb/en/join-syntax/
74     * @see https://mariadb.com/kb/en/index-hints-how-to-force-query-plans/#forcing-join-order
75     *
76     * @var string
77     */
78    public const OPT_STRAIGHT_JOIN = 'STRAIGHT_JOIN';
79    /**
80     * Forces the optimizer to use a temporary table.
81     *
82     * @see https://mariadb.com/kb/en/optimizer-hints/#sql_small_result-sql_big_result
83     *
84     * @var string
85     */
86    public const OPT_SQL_SMALL_RESULT = 'SQL_SMALL_RESULT';
87    /**
88     * Forces the optimizer to avoid usage of a temporary table.
89     *
90     * @see Select::renderOptions()
91     * @see https://mariadb.com/kb/en/optimizer-hints/#sql_small_result-sql_big_result
92     *
93     * @var string
94     */
95    public const OPT_SQL_BIG_RESULT = 'SQL_BIG_RESULT';
96    /**
97     * Forces the optimizer to use a temporary table to process the result.
98     * This is useful to free locks as soon as possible.
99     *
100     * @see Select::renderOptions()
101     * @see https://mariadb.com/kb/en/optimizer-hints/#sql_buffer_result
102     *
103     * @var string
104     */
105    public const OPT_SQL_BUFFER_RESULT = 'SQL_BUFFER_RESULT';
106    /**
107     * If the query_cache_type system variable is set to 2 or DEMAND, and the current statement is
108     * cacheable, SQL_CACHE causes the query to be cached.
109     *
110     * @see Select::renderOptions()
111     * @see https://mariadb.com/kb/en/server-system-variables/#query_cache_type
112     * @see https://mariadb.com/kb/en/query-cache/
113     *
114     * @var string
115     */
116    public const OPT_SQL_CACHE = 'SQL_CACHE';
117    /**
118     * If the query_cache_type system variable is set to 2 or DEMAND, and the current statement is
119     * cacheable, SQL_NO_CACHE causes the query not to be cached.
120     *
121     * @see Select::renderOptions()
122     * @see https://mariadb.com/kb/en/server-system-variables/#query_cache_type
123     * @see https://mariadb.com/kb/en/query-cache/
124     *
125     * @var string
126     */
127    public const OPT_SQL_NO_CACHE = 'SQL_NO_CACHE';
128    /**
129     * SQL_CALC_FOUND_ROWS is only applied when using the LIMIT clause. If this option is used,
130     * MariaDB will count how many rows would match the query, without the LIMIT clause.
131     * That number can be retrieved in the next query, using FOUND_ROWS().
132     *
133     * @see Select::renderOptions()
134     * @see https://mariadb.com/kb/en/found_rows/
135     *
136     * @var string
137     */
138    public const OPT_SQL_CALC_FOUND_ROWS = 'SQL_CALC_FOUND_ROWS';
139    /**
140     * Clause to set the character of separation between fields. Default is \t.
141     *
142     * @see Select::intoOutfile()
143     *
144     * @var string
145     */
146    public const EXP_FIELDS_TERMINATED_BY = 'TERMINATED BY';
147    /**
148     * Clause to set the enclosure character of the fields. Default is ".
149     *
150     * @see Select::intoOutfile()
151     *
152     * @var string
153     */
154    public const EXP_FIELDS_ENCLOSED_BY = 'ENCLOSED BY';
155    /**
156     * @see Select::intoOutfile()
157     *
158     * @var string
159     */
160    public const EXP_FIELDS_OPTIONALLY_ENCLOSED_BY = 'OPTIONALLY ENCLOSED BY';
161    /**
162     * @see Select::intoOutfile()
163     *
164     * @var string
165     */
166    public const EXP_FIELDS_ESCAPED_BY = 'ESCAPED BY';
167    /**
168     * @see Select::intoOutfile()
169     *
170     * @var string
171     */
172    public const EXP_LINES_STARTING_BY = 'STARTING BY';
173    /**
174     * Clause to set the file End-Of-Line character. Default is \n.
175     *
176     * @see Select::intoOutfile()
177     *
178     * @var string
179     */
180    public const EXP_LINES_TERMINATED_BY = 'TERMINATED BY';
181
182    protected function renderOptions() : ?string
183    {
184        if ( ! $this->hasOptions()) {
185            return null;
186        }
187        $options = $this->sql['options'];
188        foreach ($options as &$option) {
189            $input = $option;
190            $option = \strtoupper($option);
191            if ( ! \in_array($option, [
192                static::OPT_ALL,
193                static::OPT_DISTINCT,
194                static::OPT_DISTINCTROW,
195                static::OPT_HIGH_PRIORITY,
196                static::OPT_STRAIGHT_JOIN,
197                static::OPT_SQL_SMALL_RESULT,
198                static::OPT_SQL_BIG_RESULT,
199                static::OPT_SQL_BUFFER_RESULT,
200                static::OPT_SQL_CACHE,
201                static::OPT_SQL_NO_CACHE,
202                static::OPT_SQL_CALC_FOUND_ROWS,
203            ], true)) {
204                throw new InvalidArgumentException("Invalid option: {$input}");
205            }
206        }
207        unset($option);
208        $intersection = \array_intersect(
209            $options,
210            [static::OPT_ALL, static::OPT_DISTINCT, static::OPT_DISTINCTROW]
211        );
212        if (\count($intersection) > 1) {
213            throw new LogicException('Options ALL and DISTINCT can not be used together');
214        }
215        $intersection = \array_intersect(
216            $options,
217            [static::OPT_SQL_CACHE, static::OPT_SQL_NO_CACHE]
218        );
219        if (\count($intersection) > 1) {
220            throw new LogicException('Options SQL_CACHE and SQL_NO_CACHE can not be used together');
221        }
222        return \implode(' ', $options);
223    }
224
225    /**
226     * Set expressions.
227     *
228     * Generally used with the FROM clause as column names.
229     *
230     * @param array<string,Closure|string>|Closure|string $expression
231     * @param array<string,Closure|string>|Closure|string ...$expressions
232     *
233     * @see https://mariadb.com/kb/en/select/#select-expressions
234     *
235     * @return static
236     */
237    public function expressions(
238        array | Closure | string $expression,
239        array | Closure | string ...$expressions
240    ) : static {
241        foreach ([$expression, ...$expressions] as $expression) {
242            $this->sql['expressions'][] = $expression;
243        }
244        return $this;
245    }
246
247    /**
248     * Alias of the expressions method.
249     *
250     * @param array<string,Closure|string>|Closure|string $expression
251     * @param array<string,Closure|string>|Closure|string ...$expressions
252     *
253     * @return static
254     */
255    public function columns(
256        array | Closure | string $expression,
257        array | Closure | string ...$expressions
258    ) : static {
259        return $this->expressions($expression, ...$expressions);
260    }
261
262    protected function renderExpressions() : ?string
263    {
264        if ( ! isset($this->sql['expressions'])) {
265            if ($this->hasFrom()) {
266                return ' *';
267            }
268            return null;
269        }
270        $expressions = [];
271        foreach ($this->sql['expressions'] as $expression) {
272            $expressions[] = $this->renderAliasedIdentifier($expression);
273        }
274        return ' ' . \implode(', ', $expressions);
275    }
276
277    /**
278     * Sets the LIMIT clause.
279     *
280     * @param int $limit
281     * @param int|null $offset
282     *
283     * @see https://mariadb.com/kb/en/limit/
284     *
285     * @return static
286     */
287    public function limit(int $limit, int $offset = null) : static
288    {
289        return $this->setLimit($limit, $offset);
290    }
291
292    /**
293     * @param string $name
294     * @param bool|float|int|string|null ...$arguments
295     *
296     * @see https://mariadb.com/kb/en/procedure/
297     *
298     * @return static
299     */
300    public function procedure(string $name, bool | float | int | string | null ...$arguments) : static
301    {
302        $this->sql['procedure'] = [
303            'name' => $name,
304            'arguments' => $arguments,
305        ];
306        return $this;
307    }
308
309    protected function renderProcedure() : ?string
310    {
311        if ( ! isset($this->sql['procedure'])) {
312            return null;
313        }
314        $arguments = [];
315        foreach ($this->sql['procedure']['arguments'] as $argument) {
316            $arguments[] = $this->database->quote($argument);
317        }
318        $arguments = \implode(', ', $arguments);
319        return " PROCEDURE {$this->sql['procedure']['name']}({$arguments})";
320    }
321
322    /**
323     * Exports the result to an external file.
324     *
325     * @param string $filename
326     * @param string|null $charset
327     * @param array<string,string> $fieldsOptions Each key must be one of the EXP_FIELDS_* constants
328     * @param array<string,string> $linesOptions Each key must be one of the EXP_LINES_* constants
329     *
330     * @see https://mariadb.com/kb/en/select-into-outfile/
331     *
332     * @return static
333     */
334    public function intoOutfile(
335        string $filename,
336        string $charset = null,
337        array $fieldsOptions = [],
338        array $linesOptions = []
339    ) : static {
340        $this->sql['into_outfile'] = [
341            'filename' => $filename,
342            'charset' => $charset,
343            'fields_options' => $fieldsOptions,
344            'lines_options' => $linesOptions,
345        ];
346        return $this;
347    }
348
349    protected function renderIntoOutfile() : ?string
350    {
351        if ( ! isset($this->sql['into_outfile'])) {
352            return null;
353        }
354        if (\is_file($this->sql['into_outfile']['filename'])) {
355            throw new LogicException(
356                "INTO OUTFILE filename must not exist: {$this->sql['into_outfile']['filename']}"
357            );
358        }
359        $definition = $this->database->quote($this->sql['into_outfile']['filename']);
360        if ($this->sql['into_outfile']['charset']) {
361            $definition .= ' CHARACTER SET '
362                . $this->database->quote(
363                    $this->sql['into_outfile']['charset']
364                );
365        }
366        $definition .= $this->partIntoOutfileFields();
367        $definition .= $this->partIntoOutfileLines();
368        return " INTO OUTFILE {$definition}";
369    }
370
371    private function partIntoOutfileFields() : ?string
372    {
373        $definition = null;
374        if ($this->sql['into_outfile']['fields_options']) {
375            $definition .= ' FIELDS';
376            foreach ($this->sql['into_outfile']['fields_options'] as $option => $value) {
377                $fieldsOption = \strtoupper($option);
378                if ( ! \in_array($fieldsOption, [
379                    static::EXP_FIELDS_TERMINATED_BY,
380                    static::EXP_FIELDS_ENCLOSED_BY,
381                    static::EXP_FIELDS_OPTIONALLY_ENCLOSED_BY,
382                    static::EXP_FIELDS_ESCAPED_BY,
383                ], true)) {
384                    throw new InvalidArgumentException(
385                        "Invalid INTO OUTFILE fields option: {$option}"
386                    );
387                }
388                $definition .= " {$fieldsOption} " . $this->database->quote($value);
389            }
390        }
391        return $definition;
392    }
393
394    private function partIntoOutfileLines() : ?string
395    {
396        $definition = null;
397        if ($this->sql['into_outfile']['lines_options']) {
398            $definition .= ' LINES';
399            foreach ($this->sql['into_outfile']['lines_options'] as $option => $value) {
400                $linesOption = \strtoupper($option);
401                if ( ! \in_array($linesOption, [
402                    static::EXP_LINES_STARTING_BY,
403                    static::EXP_LINES_TERMINATED_BY,
404                ], true)) {
405                    throw new InvalidArgumentException(
406                        "Invalid INTO OUTFILE lines option: {$option}"
407                    );
408                }
409                $definition .= " {$linesOption} " . $this->database->quote($value);
410            }
411        }
412        return $definition;
413    }
414
415    /**
416     * @param string $filepath
417     * @param string ...$variables
418     *
419     * @see https://mariadb.com/kb/en/select-into-dumpfile/
420     *
421     * @return static
422     */
423    public function intoDumpfile(string $filepath, string ...$variables) : static
424    {
425        $this->sql['into_dumpfile'] = [
426            'filepath' => $filepath,
427            'variables' => $variables,
428        ];
429        return $this;
430    }
431
432    protected function renderIntoDumpfile() : ?string
433    {
434        if ( ! isset($this->sql['into_dumpfile'])) {
435            return null;
436        }
437        if (\is_file($this->sql['into_dumpfile']['filepath'])) {
438            throw new LogicException(
439                "INTO DUMPFILE filepath must not exist: {$this->sql['into_dumpfile']['filepath']}"
440            );
441        }
442        $definition = $this->database->quote($this->sql['into_dumpfile']['filepath']);
443        if ($this->sql['into_dumpfile']['variables']) {
444            $variables = [];
445            foreach ($this->sql['into_dumpfile']['variables'] as $variable) {
446                $variables[] = "@{$variable}";
447            }
448            $definition .= ' INTO ' . \implode(', ', $variables);
449        }
450        return " INTO DUMPFILE {$definition}";
451    }
452
453    /**
454     * @param int|null $wait
455     *
456     * @see https://mariadb.com/kb/en/for-update/
457     *
458     * @return static
459     */
460    public function lockForUpdate(int $wait = null) : static
461    {
462        $this->sql['lock'] = [
463            'type' => 'FOR UPDATE',
464            'wait' => $wait,
465        ];
466        return $this;
467    }
468
469    /**
470     * @param int|null $wait
471     *
472     * @see https://mariadb.com/kb/en/lock-in-share-mode/
473     *
474     * @return static
475     */
476    public function lockInShareMode(int $wait = null) : static
477    {
478        $this->sql['lock'] = [
479            'type' => 'LOCK IN SHARE MODE',
480            'wait' => $wait,
481        ];
482        return $this;
483    }
484
485    protected function renderLock() : ?string
486    {
487        if ( ! isset($this->sql['lock'])) {
488            return null;
489        }
490        $wait = '';
491        if ($this->sql['lock']['wait'] !== null) {
492            if ($this->sql['lock']['wait'] < 0) {
493                throw new InvalidArgumentException(
494                    "Invalid {$this->sql['lock']['type']} WAIT value: {$this->sql['lock']['wait']}"
495                );
496            }
497            $wait .= " WAIT {$this->sql['lock']['wait']}";
498        }
499        return " {$this->sql['lock']['type']}{$wait}";
500    }
501
502    /**
503     * Renders the SELECT statement.
504     *
505     * @return string
506     */
507    public function sql() : string
508    {
509        $sql = 'SELECT' . \PHP_EOL;
510        $part = $this->renderOptions();
511        if ($part) {
512            $sql .= $part . \PHP_EOL;
513        }
514        $part = $this->renderExpressions();
515        if ($part) {
516            $sql .= $part . \PHP_EOL;
517        }
518        $part = $this->renderFrom();
519        if ($part) {
520            $sql .= $part . \PHP_EOL;
521        }
522        $part = $this->renderJoin();
523        if ($part) {
524            $sql .= $part . \PHP_EOL;
525        }
526        $part = $this->renderWhere();
527        if ($part) {
528            $this->hasFrom('WHERE');
529            $sql .= $part . \PHP_EOL;
530        }
531        $part = $this->renderGroupBy();
532        if ($part) {
533            $this->hasFrom('GROUP BY');
534            $sql .= $part . \PHP_EOL;
535        }
536        $part = $this->renderHaving();
537        if ($part) {
538            $this->hasFrom('HAVING');
539            $sql .= $part . \PHP_EOL;
540        }
541        $part = $this->renderOrderBy();
542        if ($part) {
543            $this->hasFrom('ORDER BY');
544            $sql .= $part . \PHP_EOL;
545        }
546        $part = $this->renderLimit();
547        if ($part) {
548            $this->hasFrom('LIMIT');
549            $sql .= $part . \PHP_EOL;
550        }
551        $part = $this->renderProcedure();
552        if ($part) {
553            $this->hasFrom('PROCEDURE');
554            $sql .= $part . \PHP_EOL;
555        }
556        $part = $this->renderIntoOutfile();
557        if ($part) {
558            $this->hasFrom('INTO OUTFILE');
559            $sql .= $part . \PHP_EOL;
560        }
561        $part = $this->renderIntoDumpfile();
562        if ($part) {
563            $intoDump = true;
564            $sql .= $part . \PHP_EOL;
565        }
566        $part = $this->renderLock();
567        if ($part) {
568            if (empty($intoDump)) {
569                $this->hasFrom($this->sql['lock']['type']);
570            }
571            $sql .= $part . \PHP_EOL;
572        }
573        return $sql;
574    }
575
576    /**
577     * Runs the SELECT statement.
578     *
579     * @return Result
580     */
581    public function run() : Result
582    {
583        return $this->database->query($this->sql());
584    }
585
586    /**
587     * Runs the SELECT statement unbuffered.
588     *
589     * @return Result
590     */
591    public function runUnbuffered() : Result
592    {
593        return $this->database->query($this->sql(), false);
594    }
595}