Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
124 / 124
100.00% covered (success)
100.00%
26 / 26
CRAP
100.00% covered (success)
100.00%
1 / 1
AbstractSql
100.00% covered (success)
100.00%
124 / 124
100.00% covered (success)
100.00%
26 / 26
85
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isMysql
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isPgsql
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isSqlsrv
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isSqlite
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 jsonExtract
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 db
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDb
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setIdQuoteType
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setPlaceholder
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getDbType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPlaceholder
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getIdQuoteType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getOpenQuote
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCloseQuote
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getParameterCount
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 incrementParameterCount
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 decrementParameterCount
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isParameter
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
5
 getParameter
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
13
 isSupportedFunctionCall
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 quoteId
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
8
 quote
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
15
 init
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
11
 initQuoteType
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 isSupportedFunction
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 __toString
n/a
0 / 0
n/a
0 / 0
0
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
17use Pop\Db\Adapter;
18
19/**
20 * Abstract SQL class
21 *
22 * @category   Pop
23 * @package    Pop\Db
24 * @author     Nick Sagona, III <nick@popphp.org>
25 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
26 * @license    https://www.popphp.org/license     New BSD License
27 * @version    7.0.0
28 */
29abstract class AbstractSql
30{
31
32    /**
33     * Constants for database types
34     */
35    const MYSQL  = 'MYSQL';
36    const PGSQL  = 'PGSQL';
37    const SQLITE = 'SQLITE';
38    const SQLSRV = 'SQLSRV';
39
40    /**
41     * Constants for id quote types
42     */
43    const BACKTICK     = 'BACKTICK';
44    const BRACKET      = 'BRACKET';
45    const DOUBLE_QUOTE = 'DOUBLE_QUOTE';
46    const NO_QUOTE     = 'NO_QUOTE';
47
48    /**
49     * Database object
50     * @var ?Adapter\AbstractAdapter
51     */
52    protected ?Adapter\AbstractAdapter $db = null;
53
54    /**
55     * Database type
56     * @var ?string
57     */
58    protected ?string $dbType = null;
59
60    /**
61     * SQL placeholder
62     * @var ?string
63     */
64    protected ?string $placeholder = null;
65
66    /**
67     * ID quote type
68     * @var string
69     */
70    protected string $idQuoteType = 'NO_QUOTE';
71
72    /**
73     * ID open quote
74     * @var ?string
75     */
76    protected ?string $openQuote = null;
77
78    /**
79     * ID close quote
80     * @var ?string
81     */
82    protected ?string $closeQuote = null;
83
84    /**
85     * Parameter count
86     * @var int
87     */
88    protected int $parameterCount = 0;
89
90    /**
91     * Supported standard SQL aggregate functions
92     * @var array
93     */
94    protected static array $aggregateFunctions = [
95        'AVG', 'COUNT', 'MAX', 'MIN', 'SUM'
96    ];
97
98    /**
99     * Supported standard SQL math functions
100     * @var array
101     */
102    protected static array $mathFunctions = [
103        'ABS', 'RAND', 'SQRT', 'POW', 'POWER', 'EXP', 'LN', 'LOG', 'LOG10', 'GREATEST', 'LEAST',
104        'DIV', 'MOD', 'ROUND', 'TRUNC', 'CEIL', 'CEILING', 'FLOOR', 'COS', 'ACOS', 'ACOSH', 'SIN',
105        'SINH', 'ASIN', 'ASINH', 'TAN', 'TANH', 'ATANH', 'ATAN2',
106    ];
107
108    /**
109     * Supported standard SQL string functions
110     * @var array
111     */
112    protected static array $stringFunctions = [
113        'CONCAT', 'FORMAT', 'INSTR', 'LCASE', 'LEFT', 'LENGTH', 'LOCATE', 'LOWER', 'LPAD',
114        'LTRIM', 'POSITION', 'QUOTE', 'REGEXP', 'REPEAT', 'REPLACE', 'REVERSE', 'RIGHT', 'RPAD',
115        'RTRIM', 'SPACE', 'STRCMP', 'SUBSTRING', 'SUBSTR', 'TRIM', 'UCASE', 'UPPER'
116    ];
117
118    /**
119     * Supported standard SQL date-time functions
120     * @var array
121     */
122    protected static array $dateTimeFunctions = [
123        'CURRENT_DATE', 'CURRENT_TIMESTAMP', 'CURRENT_TIME', 'CURDATE', 'CURTIME', 'DATE', 'DATETIME',
124        'DAY', 'EXTRACT', 'GETDATE', 'HOUR', 'LOCALTIME', 'LOCALTIMESTAMP', 'MINUTE', 'MONTH',
125        'NOW', 'SECOND', 'TIME', 'TIMEDIFF', 'TIMESTAMP', 'UNIX_TIMESTAMP', 'YEAR',
126    ];
127
128    /**
129     * Constructor
130     *
131     * Instantiate the SQL object
132     *
133     * @param  Adapter\AbstractAdapter $db
134     */
135    public function __construct(Adapter\AbstractAdapter $db)
136    {
137        $this->db = $db;
138        $this->init(strtolower(get_class($db)));
139    }
140
141    /**
142     * Determine if the DB type is MySQL
143     *
144     * @return bool
145     */
146    public function isMysql(): bool
147    {
148        return ($this->dbType == self::MYSQL);
149    }
150
151    /**
152     * Determine if the DB type is PostgreSQL
153     *
154     * @return bool
155     */
156    public function isPgsql(): bool
157    {
158        return ($this->dbType == self::PGSQL);
159    }
160
161    /**
162     * Determine if the DB type is SQL Server
163     *
164     * @return bool
165     */
166    public function isSqlsrv(): bool
167    {
168        return ($this->dbType == self::SQLSRV);
169    }
170
171    /**
172     * Determine if the DB type is SQLite
173     *
174     * @return bool
175     */
176    public function isSqlite(): bool
177    {
178        return ($this->dbType == self::SQLITE);
179    }
180
181    /**
182     * Create a JSON path extraction expression, usable as a SELECT column value or an
183     * orderBy() argument
184     *
185     * @param  string $column
186     * @param  string $path
187     * @return JsonExtract
188     */
189    public function jsonExtract(string $column, string $path): JsonExtract
190    {
191        return new JsonExtract($this, $column, $path);
192    }
193
194    /**
195     * Get the current database adapter object (alias method)
196     *
197     * @return ?Adapter\AbstractAdapter
198     */
199    public function db(): ?Adapter\AbstractAdapter
200    {
201        return $this->db;
202    }
203
204    /**
205     * Get the current database adapter object
206     *
207     * @return ?Adapter\AbstractAdapter
208     */
209    public function getDb(): ?Adapter\AbstractAdapter
210    {
211        return $this->db;
212    }
213
214    /**
215     * Set the quote ID type
216     *
217     * @param  string $type
218     * @return AbstractSql
219     */
220    public function setIdQuoteType(string $type = self::NO_QUOTE): AbstractSql
221    {
222        if (defined('Pop\Db\Sql::' . $type)) {
223            $this->idQuoteType = $type;
224            $this->initQuoteType();
225        }
226        return $this;
227    }
228
229    /**
230     * Set the placeholder
231     *
232     * @param  string $placeholder
233     * @return AbstractSql
234     */
235    public function setPlaceholder(string $placeholder): AbstractSql
236    {
237        $this->placeholder = $placeholder;
238        return $this;
239    }
240
241    /**
242     * Get the current database type
243     *
244     * @return ?string
245     */
246    public function getDbType(): ?string
247    {
248        return $this->dbType;
249    }
250
251    /**
252     * Get the SQL placeholder
253     *
254     * @return ?string
255     */
256    public function getPlaceholder(): ?string
257    {
258        return $this->placeholder;
259    }
260
261    /**
262     * Get the quote ID type
263     *
264     * @return string
265     */
266    public function getIdQuoteType(): string
267    {
268        return $this->idQuoteType;
269    }
270
271    /**
272     * Get open quote
273     *
274     * @return ?string
275     */
276    public function getOpenQuote(): ?string
277    {
278        return $this->openQuote;
279    }
280
281    /**
282     * Get close quote
283     *
284     * @return ?string
285     */
286    public function getCloseQuote(): ?string
287    {
288        return $this->closeQuote;
289    }
290
291    /**
292     * Get parameter count
293     *
294     * @return int
295     */
296    public function getParameterCount(): int
297    {
298        return $this->parameterCount;
299    }
300
301    /**
302     * Increment parameter count
303     *
304     * @return AbstractSql
305     */
306    public function incrementParameterCount(): AbstractSql
307    {
308        $this->parameterCount++;
309        return $this;
310    }
311
312    /**
313     * Decrement parameter count
314     *
315     * @return AbstractSql
316     */
317    public function decrementParameterCount(): AbstractSql
318    {
319        $this->parameterCount--;
320        return $this;
321    }
322
323    /**
324     * Check if value is parameter placeholder
325     *
326     * @param  mixed   $value
327     * @param  ?string $column
328     * @return bool
329     */
330    public function isParameter(mixed $value, ?string $column = null): bool
331    {
332        return ((!empty($value) && ($column !== null) && ((':' . $column) == $value)) ||
333                ((preg_match('/^\$\d*\d$/', (string)$value) == 1)) ||
334                (($value == '?')));
335    }
336
337    /**
338     * Get parameter placeholder value
339     *
340     * @param  mixed   $value
341     * @param  ?string $column
342     * @return string
343     */
344    public function getParameter(mixed $value, ?string $column = null): string
345    {
346        $detectedDbType = null;
347        $parameter      = $value;
348
349        // SQLITE
350        if (($column !== null) && ((':' . $column) == $value)) {
351            $detectedDbType = self::SQLITE;
352        // PGSQL
353        } else if (preg_match('/^\$\d*\d$/', $value) == 1) {
354            $detectedDbType = self::PGSQL;
355        // MYSQL/SQLSRV
356        } else if ($value == '?') {
357            $detectedDbType = self::MYSQL;
358        }
359
360        // If the parameter is given in a different format than what the db expects, translate it
361        $realDbType = $this->dbType;
362        if ($this->placeholder == ':') {
363            // Either native SQLITE or PDO, in which case also use :param syntax
364            $realDbType = self::SQLITE;
365        }
366
367        if (($detectedDbType !== null) && ($realDbType != $detectedDbType)) {
368            switch ($realDbType) {
369                case self::MYSQL:
370                case self::SQLSRV:
371                    $parameter = '?';
372                    break;
373                case self::PGSQL:
374                    $this->incrementParameterCount();
375                    $parameter = '$' . $this->parameterCount;
376                    break;
377                case self::SQLITE:
378                    if ($column !== null) {
379                        $parameter = ':' . $column;
380                    }
381                    break;
382            }
383        }
384
385        return $parameter;
386    }
387
388    /**
389     * Check if the value is a single, simple call to a standard SQL supported function,
390     * e.g. 'COUNT(*)', 'SUM(total)' or 'MAX(users.id)'
391     *
392     * The argument list is deliberately restricted to identifier characters, digits, '*',
393     * '.', ',' and whitespace, and the whole value must be nothing but that one call. That
394     * keeps anything that could carry additional SQL (quotes, operators, comment markers,
395     * a nested statement) out of the "render me verbatim" path.
396     *
397     * @param  mixed $value
398     * @return bool
399     */
400    public static function isSupportedFunctionCall(mixed $value): bool
401    {
402        return (is_string($value) && (preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*\s*\([a-zA-Z0-9_.,*\s]*\)$/', $value) == 1)
403            && self::isSupportedFunction($value));
404    }
405
406    /**
407     * Quote the identifier
408     *
409     * A supported SQL function call is an expression rather than an identifier, so it is
410     * passed through untouched - quoting it would produce a bogus identifier such as
411     * `COUNT(*)`, which errors on MySQL/PostgreSQL and silently matches nothing on SQLite.
412     *
413     * @param  string $identifier
414     * @return string
415     */
416    public function quoteId(string $identifier): string
417    {
418        $quotedId = null;
419
420        if (self::isSupportedFunctionCall($identifier)) {
421            return $identifier;
422        }
423
424        if (str_contains($identifier, '.')) {
425            $identifierAry = explode('.', $identifier);
426            foreach ($identifierAry as $key => $val) {
427                $identifierAry[$key] = ($val != '*') ? $this->openQuote . $val . $this->closeQuote : $val;
428            }
429            $quotedId = implode('.', $identifierAry);
430        } else if (($identifier != '*') &&
431            ((preg_match('/^\$\d*\d$/', $identifier) == 0) && (preg_match('/^\d*$/', $identifier) == 0))) {
432            $quotedId = $this->openQuote . $identifier . $this->closeQuote;
433        } else {
434            $quotedId = $identifier;
435        }
436
437        return $quotedId;
438    }
439
440    /**
441     * Quote the value (if it is not a numeric value)
442     *
443     * @param  mixed $value
444     * @param  bool  $force
445     * @return float|int|string
446     */
447    public function quote(mixed $value = null, bool $force = false): float|int|string
448    {
449        $value = ($value !== null) ? (string)$value : null;
450
451        if ($force) {
452            if (($value == '') ||
453                ((preg_match('/^\$\d*\d$/', $value) == 0) && (preg_match('/^\d*$/', $value) == 0))) {
454                $value = "'" . $this->db->escape($value) . "'";
455            }
456        } else {
457            if (($value == '') ||
458                (($value != '?') &&
459                    (!empty($this->openQuote) && !empty($this->closeQuote) &&
460                        !(str_starts_with($value, $this->openQuote) && str_ends_with($value, $this->closeQuote))) &&
461                    (!str_starts_with($value, ':')) && (preg_match('/^\$\d*\d$/', $value) == 0) &&
462                    (preg_match('/^\d*$/', $value) == 0))) {
463                $value = "'" . $this->db->escape($value) . "'";
464            }
465        }
466
467        return $value;
468    }
469
470    /**
471     * Initialize SQL object
472     *
473     * @param  string $adapter
474     * @return void
475     */
476    protected function init(string $adapter): void
477    {
478        if (stripos($adapter, 'pdo') !== false) {
479            if ($this->db instanceof Adapter\Pdo) {
480                $adapter = $this->db->getType() ?? $adapter;
481            }
482            $this->placeholder = ':';
483        }
484
485        if (stripos($adapter, 'mysql') !== false) {
486            $this->dbType      = self::MYSQL;
487            $this->idQuoteType = self::BACKTICK;
488            if ($this->placeholder === null) {
489                $this->placeholder = '?';
490            }
491        } else if (stripos($adapter, 'pgsql') !== false) {
492            $this->dbType      = self::PGSQL;
493            $this->idQuoteType = self::DOUBLE_QUOTE;
494            if ($this->placeholder === null) {
495                $this->placeholder = '$';
496            }
497        } else if (stripos($adapter, 'sqlite') !== false) {
498            $this->dbType      = self::SQLITE;
499            $this->idQuoteType = self::DOUBLE_QUOTE;
500            if ($this->placeholder === null) {
501                $this->placeholder = ':';
502            }
503        } else if (stripos($adapter, 'sqlsrv') !== false) {
504            $this->dbType      = self::SQLSRV;
505            $this->idQuoteType = self::BRACKET;
506            if ($this->placeholder === null) {
507                $this->placeholder = '?';
508            }
509        }
510
511        $this->initQuoteType();
512    }
513
514    /**
515     * Initialize quite type
516     *
517     * @return void
518     */
519    protected function initQuoteType(): void
520    {
521        switch ($this->idQuoteType) {
522            case (self::BACKTICK):
523                $this->openQuote   = '`';
524                $this->closeQuote  = '`';
525                break;
526            case (self::DOUBLE_QUOTE):
527                $this->openQuote   = '"';
528                $this->closeQuote  = '"';
529                break;
530            case (self::BRACKET):
531                $this->openQuote   = '[';
532                $this->closeQuote  = ']';
533                break;
534            case (self::NO_QUOTE):
535                $this->openQuote   = null;
536                $this->closeQuote  = null;
537                break;
538        }
539    }
540
541    /**
542     * Check if value contains a standard SQL supported function
543     *
544     * @param  mixed $value
545     * @return bool
546     */
547    public static function isSupportedFunction(mixed $value): bool
548    {
549        if (!is_string($value)) {
550            return false;
551        }
552
553        if (str_contains($value, '(')) {
554            $value = trim(substr($value, 0, strpos($value, '(')));
555        }
556        $value = strtoupper($value);
557
558        return (in_array($value, static::$aggregateFunctions) ||
559            in_array($value, static::$mathFunctions) ||
560            in_array($value, static::$stringFunctions) ||
561            in_array($value, static::$dateTimeFunctions));
562    }
563
564    /**
565     * Render the SQL statement as a string
566     *
567     * @return string
568     */
569    abstract public function __toString(): string;
570
571}