Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
191 / 191
100.00% covered (success)
100.00%
9 / 9
CRAP
100.00% covered (success)
100.00%
1 / 1
Expression
100.00% covered (success)
100.00%
191 / 191
100.00% covered (success)
100.00%
9 / 9
98
100.00% covered (success)
100.00%
1 / 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
 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-2025 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
16/**
17 * Predicate expression parser 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 Expression
27{
28
29    /**
30     * Allowed operators
31     * @var array
32     */
33    protected static array $operators = [
34        '>=', '<=', '!=', '=', '>', '<',
35        'NOT LIKE', 'LIKE', 'NOT BETWEEN', 'BETWEEN',
36        'NOT IN', 'IN', 'IS NOT NULL', 'IS NULL'
37    ];
38
39    /**
40     * Method to parse a predicate string expression into its components
41     *
42     * @param  string $expression
43     * @throws Exception
44     * @return array
45     */
46    public static function parse(string $expression): array
47    {
48        $column   = null;
49        $operator = null;
50        $value    = null;
51
52        if (stripos($expression, ' NULL') !== false) {
53            $column   = self::stripIdQuotes(trim(substr($expression, 0, strpos($expression, ' '))));
54            $operator = (stripos($expression, ' IS NOT NULL') !== false) ? 'IS NOT NULL' : 'IS NULL';
55        } else if (stripos($expression, ' IN ') !== false) {
56            $column   = self::stripIdQuotes(trim(substr($expression, 0, strpos($expression, ' '))));
57            $operator = (stripos($expression, ' NOT IN ') !== false) ? 'NOT IN' : 'IN';
58            $values   = substr($expression, (strpos($expression, '(') + 1));
59            $values   = substr($values, 0, strpos($values, ')'));
60            $values   = array_map(function($value) {
61                return \Pop\Db\Sql\Parser\Expression::stripQuotes(trim($value));
62            }, explode(',', $values));
63            $value    = $values;
64        } else if (stripos($expression, ' BETWEEN ') !== false) {
65            $column   = self::stripIdQuotes(trim(substr($expression, 0, strpos($expression, ' '))));
66            $operator = (stripos($expression, ' NOT BETWEEN ') !== false) ? 'NOT BETWEEN' : 'BETWEEN';
67            $value1   = substr($expression, (strpos($expression, 'BETWEEN ') + 8));
68            $value1   = trim(substr($value1, 0, strpos($value1, ' ')));
69            $value2   = trim(substr($expression, (stripos($expression, ' AND ') + 5)));
70            $value    = [self::stripQuotes($value1), self::stripQuotes($value2)];
71        } else if (stripos($expression, ' LIKE ') !== false) {
72            $column   = self::stripIdQuotes(trim(substr($expression, 0, strpos($expression, ' '))));
73            $operator = (stripos($expression, ' NOT LIKE ') !== false) ? 'NOT LIKE' : 'LIKE';
74            $value    = self::stripQuotes(trim(substr($expression, (stripos($expression, ' LIKE ') + 6))));
75        } else {
76            $column   = substr($expression, 0, strpos($expression, ' '));
77            $operator = substr($expression, (strlen($column) + 1));
78            $operator = substr($operator, 0, strpos($operator, ' '));
79            $value    = self::stripQuotes(trim(substr($expression, (strpos($expression, $operator) + strlen($operator)))));
80        }
81
82        if (!in_array($operator, self::$operators)) {
83            throw new Exception("Error: The operator '" . $operator . "' is not allowed.");
84        }
85
86        return [
87            'column'   => $column,
88            'operator' => $operator,
89            'value'    => $value
90        ];
91    }
92
93    /**
94     * Method to parse predicate string expressions into its components
95     *
96     * @param  array $expressions
97     * @return array
98     */
99    public static function parseExpressions(array $expressions): array
100    {
101        $components = [];
102
103        foreach ($expressions as $expression) {
104            $components[] = self::parse($expression);
105        }
106
107        return $components;
108    }
109
110    /**
111     * Convert to expression to shorthand value
112     *
113     * @param  string $expression
114     * @return array
115     */
116    public static function convertExpressionToShorthand(string $expression): array
117    {
118        ['column' => $column, 'operator' => $operator, 'value' => $value] = self::parse($expression);
119
120        switch ($operator) {
121            case '>=':
122            case '<=':
123            case '!=':
124            case '>':
125            case '<':
126                $column .= $operator;
127                break;
128            case 'LIKE':
129                if (str_starts_with($value, '%')) {
130                    $column = '%' . $column;
131                    $value  = substr($value, 1);
132                }
133                if (str_ends_with($value, '%')) {
134                    $column .= '%';
135                    $value   = substr($value, 0, -1);
136                }
137                break;
138            case 'NOT LIKE':
139                if (str_starts_with($value, '%')) {
140                    $column = '-%' . $column;
141                    $value  = substr($value, 1);
142                }
143                if (str_ends_with($value, '%')) {
144                    $column .= '%-';
145                    $value   = substr($value, 0, -1);
146                }
147                break;
148            case 'NOT IN':
149            case 'NOT BETWEEN':
150            case 'IS NOT NULL':
151                $column .= '-';
152                break;
153        }
154
155        if (str_contains($expression, ' BETWEEN ')) {
156            $value = '(' . implode(', ', $value) . ')';
157        }
158
159        return [$column => $value];
160    }
161
162    /**
163     * Convert to expression to shorthand value
164     *
165     * @param  array $expressions
166     * @return array
167     */
168    public static function convertExpressionsToShorthand(array $expressions): array
169    {
170        $conditions = [];
171
172        foreach ($expressions as $expression) {
173            $conditions = array_merge($conditions, self::convertExpressionToShorthand($expression));
174        }
175
176        return $conditions;
177    }
178
179    /**
180     * Method to check if the column is shorthand
181     *
182     * @param  string $column
183     * @return bool
184     */
185    public static function isShorthand(string $column): bool
186    {
187        return str_contains($column, '%') || str_ends_with($column, '-') || str_ends_with($column, '>=') ||
188            str_ends_with($column, '<=') || str_ends_with($column, '!=') || str_ends_with($column, '>') ||
189            str_ends_with($column, '<');
190    }
191
192    /**
193     * Method to parse the shorthand columns to create expressions and their parameters
194     *
195     * @param  array   $columns
196     * @param  ?string $placeholder
197     * @param  bool    $flatten
198     * @return array
199     */
200    public static function parseShorthand(array $columns, ?string $placeholder = null, bool $flatten = true): array
201    {
202        $expressions = [];
203        $params      = [];
204        $i           = 1;
205        $j           = 0;
206
207        foreach ($columns as $column => $value) {
208            ['column' => $parsedColumn, 'operator' => $operator] = Operator::parse($column);
209
210            $pHolder = $placeholder;
211            if ($placeholder == ':') {
212                $pHolder .= $parsedColumn;
213            } else if ($placeholder == '$') {
214                $pHolder .= $i;
215            }
216
217            // IS NULL/IS NOT NULL
218            if ($value === null) {
219                $newExpression = $parsedColumn . ' IS ' . (($operator == 'NOT') ? 'NOT ' : '') . 'NULL';
220                if ($placeholder == ':') {
221                    $expressions[$parsedColumn] = $newExpression;
222                } else {
223                    $expressions[] = $newExpression;
224                }
225            // IN/NOT IN
226            } else if (is_array($value)) {
227                $p = [];
228                if ($placeholder == ':') {
229                    $pHolders = [];
230                    foreach ($value as $j => $val) {
231                        $ph         = $pHolder . ($j + 1);
232                        $pHolders[] = $ph;
233                        $p[]        = $val;
234                    }
235                } else if ($placeholder == '$') {
236                    $pHolders = [];
237                    foreach ($value as $val) {
238                        $pHolders[] = $placeholder . $i++;
239                        $p[]        = $val;
240                    }
241                } else {
242                    $pHolders = array_fill(0, count($value), $pHolder);
243                    $p        = $value;
244                    $i++;
245                }
246                if ($placeholder !== null) {
247                    $newExpression = $parsedColumn . (($operator == 'NOT') ? ' NOT ' : ' ') . 'IN (' .
248                        implode(', ', $pHolders) . ')';
249                    if ($placeholder == ':') {
250                        $expressions[$parsedColumn] = $newExpression;
251                    } else {
252                        $expressions[] = $newExpression;
253                    }
254                } else {
255                    $expressions[] = $parsedColumn . (($operator == 'NOT') ? ' NOT ' : ' ') . 'IN (' .
256                        implode(', ', array_map('Pop\Db\Sql\Parser\Expression::quote', $value)) . ')';
257                }
258                if ($placeholder == ':') {
259                    $params[$parsedColumn] = $p;
260                } else {
261                    $params[$j] = $p;
262                }
263            // BETWEEN/NOT BETWEEN
264            } else if (is_string($value) && (str_starts_with($value, '(')) && (str_ends_with($value, ')')) &&
265                (str_contains($value, ','))) {
266                $values            = substr($value, (strpos($value, '(') + 1));
267                $values            = substr($values, 0, strpos($values, ')'));
268                [$value1, $value2] = array_map('trim', explode(',', $values));
269                $p                 = [$value1, $value2];
270
271                if ($placeholder == ':') {
272                    $pHolder2 = $pHolder . 2;
273                    $pHolder .= 1;
274                } else if ($placeholder == '$') {
275                    $pHolder2 = $placeholder . ++$i;
276                } else {
277                    $pHolder2 = $pHolder;
278                }
279
280                if ($placeholder !== null) {
281                    $newExpression = $parsedColumn . (($operator == 'NOT') ? ' NOT ' : ' ') .
282                        'BETWEEN ' . $pHolder . ' AND ' . $pHolder2;
283                    if ($placeholder == ':') {
284                        $expressions[$parsedColumn] = $newExpression;
285                    } else {
286                        $expressions[] = $newExpression;
287                    }
288                } else {
289                    $expressions[] = $parsedColumn . (($operator == 'NOT') ? ' NOT ' : ' ') .
290                        'BETWEEN ' . self::quote($value1) . ' AND ' . self::quote($value2);
291                }
292                if ($placeholder == ':') {
293                    $params[$parsedColumn] = $p;
294                } else {
295                    $params[$j] = $p;
296                }
297                $i++;
298            // LIKE/NOT LIKE or Standard Operators
299            } else  {
300                if ((str_starts_with($column, '%')) || (str_starts_with($column, '-%'))) {
301                    $value  = '%' . $value;
302                }
303                if ((str_ends_with($column, '%')) || (str_ends_with($column, '%-'))) {
304                    $value .= '%';
305                }
306                if ($placeholder !== null) {
307                    $newExpression = $parsedColumn . ' ' . $operator . ' ' . $pHolder;
308                    if ($placeholder == ':') {
309                        $expressions[$parsedColumn] = $newExpression;
310                    } else {
311                        $expressions[] = $newExpression;
312                    }
313                } else {
314                    $expressions[] = $parsedColumn . ' ' . $operator . ' ' . self::quote($value);
315                }
316                if ($placeholder == ':') {
317                    $params[$parsedColumn] = $value;
318                } else {
319                    $params[$j] = $value;
320                }
321                $i++;
322            }
323            $j++;
324        }
325
326        if ($flatten) {
327            $flattenParams = [];
328
329            foreach ($params as $key => $value) {
330                if (is_array($value)) {
331                    foreach ($value as $k => $v) {
332                        if ($placeholder == ':') {
333                            $flattenParams[$key . ($k + 1)] = $v;
334                        } else {
335                            $flattenParams[] = $v;
336                        }
337                    }
338                } else {
339                    if ($placeholder == ':') {
340                        $flattenParams[$key] = $value;
341                    } else {
342                        $flattenParams[] = $value;
343                    }
344                }
345            }
346
347            return ['expressions' => $expressions, 'params' => $flattenParams];
348        } else {
349            return ['expressions' => $expressions, 'params' => $params];
350        }
351
352    }
353
354    /**
355     * Strip ID quotes
356     *
357     * @param  string $identifier
358     * @return string
359     */
360    public static function stripIdQuotes(string $identifier): string
361    {
362        if (((str_starts_with($identifier, '"')) && (str_ends_with($identifier, '"'))) ||
363            ((str_starts_with($identifier, '`')) && (str_ends_with($identifier, '`'))) ||
364            ((str_starts_with($identifier, '[')) && (str_ends_with($identifier, ']')))) {
365            $identifier = substr($identifier, 1);
366            $identifier = substr($identifier, 0, -1);
367        }
368
369        return $identifier;
370    }
371
372    /**
373     * Strip quotes
374     *
375     * @param  string $value
376     * @return string
377     */
378    public static function stripQuotes(string $value): string
379    {
380        if (((str_starts_with($value, '"')) && (str_ends_with($value, '"'))) ||
381            ((str_starts_with($value, "'")) && (str_ends_with($value, "'")))) {
382            $value = substr($value, 1);
383            $value = substr($value, 0, -1);
384        }
385
386        return $value;
387    }
388
389    /**
390     * Quote the value (if it is not a numeric value)
391     *
392     * @param  string $value
393     * @return string
394     */
395    public static function quote(string $value): string
396    {
397        if (($value == '') ||
398            (($value != '?') && (!str_starts_with($value, ':')) && (preg_match('/^\$\d*\d$/', $value) == 0) &&
399                !is_int($value) && !is_float($value) && (preg_match('/^\d*$/', $value) == 0))) {
400            $value = "'" . $value . "'";
401        }
402        return $value;
403    }
404
405}