Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.51% covered (success)
84.51%
191 / 226
81.82% covered (success)
81.82%
9 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Expression
84.51% covered (success)
84.51%
191 / 226
81.82% covered (success)
81.82%
9 / 11
158.59
0.00% covered (danger)
0.00%
0 / 1
 parse
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
10
 parseExpressions
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 prepareExpression
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
42
 prepareExpressions
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
72
 convertExpressionToShorthand
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
16
 convertExpressionsToShorthand
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 isShorthand
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
7
 parseShorthand
100.00% covered (success)
100.00%
95 / 95
100.00% covered (success)
100.00%
1 / 1
41
 stripIdQuotes
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
7
 stripQuotes
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 quote
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
8
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-2026 NOLA Interactive, LLC.
8 * @license    https://www.popphp.org/license     New BSD License
9 */
10
11/**
12 * @namespace
13 */
14namespace Pop\Db\Sql\Parser;
15
16use Pop\Db\Sql\AbstractSql;
17
18/**
19 * Predicate expression parser class
20 *
21 * @category   Pop
22 * @package    Pop\Db
23 * @author     Nick Sagona, III <dev@noladev.com>
24 * @copyright  Copyright (c) 2009-2026 NOLA Interactive, LLC.
25 * @license    https://www.popphp.org/license     New BSD License
26 * @version    6.7.0
27 */
28class Expression
29{
30
31    /**
32     * Allowed operators
33     * @var array
34     */
35    protected static array $operators = [
36        '>=', '<=', '!=', '=', '>', '<',
37        'NOT LIKE', 'LIKE', 'NOT BETWEEN', 'BETWEEN',
38        'NOT IN', 'IN', 'IS NOT NULL', 'IS NULL'
39    ];
40
41    /**
42     * Method to parse a predicate string expression into its components
43     *
44     * @param  string $expression
45     * @throws Exception
46     * @return array
47     */
48    public static function parse(string $expression): array
49    {
50        $column   = null;
51        $operator = null;
52        $value    = null;
53
54        if (stripos($expression, ' NULL') !== false) {
55            $column   = self::stripIdQuotes(trim(substr($expression, 0, strpos($expression, ' '))));
56            $operator = (stripos($expression, ' IS NOT NULL') !== false) ? 'IS NOT NULL' : 'IS NULL';
57        } else if (stripos($expression, ' IN ') !== false) {
58            $column   = self::stripIdQuotes(trim(substr($expression, 0, strpos($expression, ' '))));
59            $operator = (stripos($expression, ' NOT IN ') !== false) ? 'NOT IN' : 'IN';
60            $values   = substr($expression, (strpos($expression, '(') + 1));
61            $values   = substr($values, 0, strpos($values, ')'));
62            $values   = array_map(function($value) {
63                return \Pop\Db\Sql\Parser\Expression::stripQuotes(trim($value));
64            }, explode(',', $values));
65            $value    = $values;
66        } else if (stripos($expression, ' BETWEEN ') !== false) {
67            $column   = self::stripIdQuotes(trim(substr($expression, 0, strpos($expression, ' '))));
68            $operator = (stripos($expression, ' NOT BETWEEN ') !== false) ? 'NOT BETWEEN' : 'BETWEEN';
69            $value1   = substr($expression, (strpos($expression, 'BETWEEN ') + 8));
70            $value1   = trim(substr($value1, 0, strpos($value1, ' ')));
71            $value2   = trim(substr($expression, (stripos($expression, ' AND ') + 5)));
72            $value    = [self::stripQuotes($value1), self::stripQuotes($value2)];
73        } else if (stripos($expression, ' LIKE ') !== false) {
74            $column   = self::stripIdQuotes(trim(substr($expression, 0, strpos($expression, ' '))));
75            $operator = (stripos($expression, ' NOT LIKE ') !== false) ? 'NOT LIKE' : 'LIKE';
76            $value    = self::stripQuotes(trim(substr($expression, (stripos($expression, ' LIKE ') + 6))));
77        } else {
78            $column   = substr($expression, 0, strpos($expression, ' '));
79            $operator = substr($expression, (strlen($column) + 1));
80            $operator = substr($operator, 0, strpos($operator, ' '));
81            $value    = self::stripQuotes(trim(substr($expression, (strpos($expression, $operator) + strlen($operator)))));
82        }
83
84        if (!in_array($operator, self::$operators)) {
85            throw new Exception("Error: The operator '" . $operator . "' is not allowed.");
86        }
87
88        return [
89            'column'   => $column,
90            'operator' => $operator,
91            'value'    => $value
92        ];
93    }
94
95    /**
96     * Method to parse predicate string expressions into its components
97     *
98     * @param  array $expressions
99     * @return array
100     */
101    public static function parseExpressions(array $expressions): array
102    {
103        $components = [];
104
105        foreach ($expressions as $expression) {
106            $components[] = self::parse($expression);
107        }
108
109        return $components;
110    }
111
112    /**
113     * Prepare a basic expression as a direct prepared predicate clause
114     *
115     * @param  string       $expression
116     * @param  ?AbstractSql $sql
117     * @param  bool         $withParams
118     * @return array
119     */
120    public static function prepareExpression(
121        string $expression, ?AbstractSql $sql = null, bool $withParams = true
122    ): array
123    {
124        ['column' => $column, 'operator' => $operator, 'value' => $value] = self::parse($expression);
125
126        $clause = $sql->quoteId($column) . ' ' . $operator;
127        $params = [];
128
129        if ($value !== null) {
130            if (is_array($value)) {
131                if ($withParams) {
132                    $clause         .= ' (' . implode(', ', array_fill(0, count($value), $sql->getPlaceholder())) . ')';
133                    $params[$column] = $value;
134                } else {
135                    $quotedValues = [];
136                    foreach ($value as $val) {
137                        $quotedValues[] = $sql->quote($val);
138                    }
139                    $clause .= ' (' . implode(', ', $quotedValues) . ')';
140                }
141            } else {
142                if ($withParams) {
143                    $clause          .= ' ' . $sql->getPlaceholder();
144                    $params[$column]  = $value;
145                } else {
146                    $clause .= ' ' . $sql->quote($value);
147                }
148            }
149        }
150
151        return ['clause' => $clause, 'params' => $params];
152    }
153
154    /**
155     * Prepare basic expressions as direct prepared predicate clauses
156     *
157     * @param  array        $expressions
158     * @param  ?AbstractSql $sql
159     * @param  bool         $withParams
160     * @param  bool         $flatten
161     * @return array
162     */
163    public static function prepareExpressions(
164        array $expressions, ?AbstractSql $sql = null, bool $withParams = true, bool $flatten = true
165    ): array
166    {
167        $clauses = [];
168
169        foreach ($expressions as $expression) {
170            $clauses[] = self::prepareExpression($expression, $sql, $withParams);
171        }
172
173        if ($flatten) {
174            $flattenClauses = [];
175            $flattenParams  = [];
176            $placeholder    = $sql->getPlaceholder();
177
178            foreach ($clauses as $clause) {
179                $flattenClauses[] = $clause['clause'];
180                if (!empty($clause['params'])) {
181                    if (is_array($clause['params'])) {
182                        $i = 1;
183                        foreach ($clause['params'] as $k => $v) {
184                            if ($placeholder == ':') {
185                                $flattenParams[$k . ($i++)] = $v;
186                            } else {
187                                $flattenParams[] = $v;
188                            }
189                        }
190                    }
191                }
192            }
193
194            return ['clauses' => $flattenClauses, 'params' => $flattenParams];
195        } else {
196            return $clauses;
197        }
198    }
199
200    /**
201     * Convert to expression to shorthand value
202     *
203     * @param  string $expression
204     * @return array
205     */
206    public static function convertExpressionToShorthand(string $expression): array
207    {
208        ['column' => $column, 'operator' => $operator, 'value' => $value] = self::parse($expression);
209
210        switch ($operator) {
211            case '>=':
212            case '<=':
213            case '!=':
214            case '>':
215            case '<':
216                $column .= $operator;
217                break;
218            case 'LIKE':
219                if (str_starts_with($value, '%')) {
220                    $column = '%' . $column;
221                    $value  = substr($value, 1);
222                }
223                if (str_ends_with($value, '%')) {
224                    $column .= '%';
225                    $value   = substr($value, 0, -1);
226                }
227                break;
228            case 'NOT LIKE':
229                if (str_starts_with($value, '%')) {
230                    $column = '-%' . $column;
231                    $value  = substr($value, 1);
232                }
233                if (str_ends_with($value, '%')) {
234                    $column .= '%-';
235                    $value   = substr($value, 0, -1);
236                }
237                break;
238            case 'NOT IN':
239            case 'NOT BETWEEN':
240            case 'IS NOT NULL':
241                $column .= '-';
242                break;
243        }
244
245        if (str_contains($expression, ' BETWEEN ')) {
246            $value = '(' . implode(', ', $value) . ')';
247        }
248
249        return [$column => $value];
250    }
251
252    /**
253     * Convert to expression to shorthand value
254     *
255     * @param  array $expressions
256     * @return array
257     */
258    public static function convertExpressionsToShorthand(array $expressions): array
259    {
260        $conditions = [];
261
262        foreach ($expressions as $expression) {
263            $conditions = array_merge($conditions, self::convertExpressionToShorthand($expression));
264        }
265
266        return $conditions;
267    }
268
269    /**
270     * Method to check if the column is shorthand
271     *
272     * @param  string $column
273     * @return bool
274     */
275    public static function isShorthand(string $column): bool
276    {
277        return str_contains($column, '%') || str_ends_with($column, '-') || str_ends_with($column, '>=') ||
278            str_ends_with($column, '<=') || str_ends_with($column, '!=') || str_ends_with($column, '>') ||
279            str_ends_with($column, '<');
280    }
281
282    /**
283     * Method to parse the shorthand columns to create expressions and their parameters
284     *
285     * @param  array   $columns
286     * @param  ?string $placeholder
287     * @param  bool    $flatten
288     * @return array
289     */
290    public static function parseShorthand(array $columns, ?string $placeholder = null, bool $flatten = true): array
291    {
292        $expressions = [];
293        $params      = [];
294        $i           = 1;
295        $j           = 0;
296
297        foreach ($columns as $column => $value) {
298            ['column' => $parsedColumn, 'operator' => $operator] = Operator::parse($column);
299
300            $pHolder = $placeholder;
301            if ($placeholder == ':') {
302                $pHolder .= $parsedColumn;
303            } else if ($placeholder == '$') {
304                $pHolder .= $i;
305            }
306
307            // IS NULL/IS NOT NULL
308            if ($value === null) {
309                $newExpression = $parsedColumn . ' IS ' . (($operator == 'NOT') ? 'NOT ' : '') . 'NULL';
310                if ($placeholder == ':') {
311                    $expressions[$parsedColumn] = $newExpression;
312                } else {
313                    $expressions[] = $newExpression;
314                }
315            // IN/NOT IN
316            } else if (is_array($value)) {
317                $p = [];
318                if ($placeholder == ':') {
319                    $pHolders = [];
320                    foreach ($value as $j => $val) {
321                        $ph         = $pHolder . ($j + 1);
322                        $pHolders[] = $ph;
323                        $p[]        = $val;
324                    }
325                } else if ($placeholder == '$') {
326                    $pHolders = [];
327                    foreach ($value as $val) {
328                        $pHolders[] = $placeholder . $i++;
329                        $p[]        = $val;
330                    }
331                } else {
332                    $pHolders = array_fill(0, count($value), $pHolder);
333                    $p        = $value;
334                    $i++;
335                }
336                if ($placeholder !== null) {
337                    $newExpression = $parsedColumn . (($operator == 'NOT') ? ' NOT ' : ' ') . 'IN (' .
338                        implode(', ', $pHolders) . ')';
339                    if ($placeholder == ':') {
340                        $expressions[$parsedColumn] = $newExpression;
341                    } else {
342                        $expressions[] = $newExpression;
343                    }
344                } else {
345                    $expressions[] = $parsedColumn . (($operator == 'NOT') ? ' NOT ' : ' ') . 'IN (' .
346                        implode(', ', array_map('Pop\Db\Sql\Parser\Expression::quote', $value)) . ')';
347                }
348                if ($placeholder == ':') {
349                    $params[$parsedColumn] = $p;
350                } else {
351                    $params[$j] = $p;
352                }
353            // BETWEEN/NOT BETWEEN
354            } else if (is_string($value) && (str_starts_with($value, '(')) && (str_ends_with($value, ')')) &&
355                (str_contains($value, ','))) {
356                $values            = substr($value, (strpos($value, '(') + 1));
357                $values            = substr($values, 0, strpos($values, ')'));
358                [$value1, $value2] = array_map('trim', explode(',', $values));
359                $p                 = [$value1, $value2];
360
361                if ($placeholder == ':') {
362                    $pHolder2 = $pHolder . 2;
363                    $pHolder .= 1;
364                } else if ($placeholder == '$') {
365                    $pHolder2 = $placeholder . ++$i;
366                } else {
367                    $pHolder2 = $pHolder;
368                }
369
370                if ($placeholder !== null) {
371                    $newExpression = $parsedColumn . (($operator == 'NOT') ? ' NOT ' : ' ') .
372                        'BETWEEN ' . $pHolder . ' AND ' . $pHolder2;
373                    if ($placeholder == ':') {
374                        $expressions[$parsedColumn] = $newExpression;
375                    } else {
376                        $expressions[] = $newExpression;
377                    }
378                } else {
379                    $expressions[] = $parsedColumn . (($operator == 'NOT') ? ' NOT ' : ' ') .
380                        'BETWEEN ' . self::quote($value1) . ' AND ' . self::quote($value2);
381                }
382                if ($placeholder == ':') {
383                    $params[$parsedColumn] = $p;
384                } else {
385                    $params[$j] = $p;
386                }
387                $i++;
388            // LIKE/NOT LIKE or Standard Operators
389            } else  {
390                if ((str_starts_with($column, '%')) || (str_starts_with($column, '-%'))) {
391                    $value  = '%' . $value;
392                }
393                if ((str_ends_with($column, '%')) || (str_ends_with($column, '%-'))) {
394                    $value .= '%';
395                }
396                if ($placeholder !== null) {
397                    $newExpression = $parsedColumn . ' ' . $operator . ' ' . $pHolder;
398                    if ($placeholder == ':') {
399                        $expressions[$parsedColumn] = $newExpression;
400                    } else {
401                        $expressions[] = $newExpression;
402                    }
403                } else {
404                    $expressions[] = $parsedColumn . ' ' . $operator . ' ' . self::quote($value);
405                }
406                if ($placeholder == ':') {
407                    $params[$parsedColumn] = $value;
408                } else {
409                    $params[$j] = $value;
410                }
411                $i++;
412            }
413            $j++;
414        }
415
416        if ($flatten) {
417            $flattenParams = [];
418
419            foreach ($params as $key => $value) {
420                if (is_array($value)) {
421                    foreach ($value as $k => $v) {
422                        if ($placeholder == ':') {
423                            $flattenParams[$key . ($k + 1)] = $v;
424                        } else {
425                            $flattenParams[] = $v;
426                        }
427                    }
428                } else {
429                    if ($placeholder == ':') {
430                        $flattenParams[$key] = $value;
431                    } else {
432                        $flattenParams[] = $value;
433                    }
434                }
435            }
436
437            return ['expressions' => $expressions, 'params' => $flattenParams];
438        } else {
439            return ['expressions' => $expressions, 'params' => $params];
440        }
441    }
442
443    /**
444     * Strip ID quotes
445     *
446     * @param  string $identifier
447     * @return string
448     */
449    public static function stripIdQuotes(string $identifier): string
450    {
451        if (((str_starts_with($identifier, '"')) && (str_ends_with($identifier, '"'))) ||
452            ((str_starts_with($identifier, '`')) && (str_ends_with($identifier, '`'))) ||
453            ((str_starts_with($identifier, '[')) && (str_ends_with($identifier, ']')))) {
454            $identifier = substr($identifier, 1);
455            $identifier = substr($identifier, 0, -1);
456        }
457
458        return $identifier;
459    }
460
461    /**
462     * Strip quotes
463     *
464     * @param  string $value
465     * @return string
466     */
467    public static function stripQuotes(string $value): string
468    {
469        if (((str_starts_with($value, '"')) && (str_ends_with($value, '"'))) ||
470            ((str_starts_with($value, "'")) && (str_ends_with($value, "'")))) {
471            $value = substr($value, 1);
472            $value = substr($value, 0, -1);
473        }
474
475        return $value;
476    }
477
478    /**
479     * Quote the value (if it is not a numeric value)
480     *
481     * @param  string $value
482     * @return string
483     */
484    public static function quote(string $value): string
485    {
486        if (($value == '') ||
487            (($value != '?') && (!str_starts_with($value, ':')) && (preg_match('/^\$\d*\d$/', $value) == 0) &&
488                !is_int($value) && !is_float($value) && (preg_match('/^\d*$/', $value) == 0))) {
489            $value = "'" . $value . "'";
490        }
491        return $value;
492    }
493
494}