Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
188 / 188
100.00% covered (success)
100.00%
8 / 8
CRAP
100.00% covered (success)
100.00%
1 / 1
Expression
100.00% covered (success)
100.00%
188 / 188
100.00% covered (success)
100.00%
8 / 8
91
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
 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 (http://www.popphp.org/)
4 *
5 * @link       https://github.com/popphp/popphp-framework
6 * @author     Nick Sagona, III <dev@nolainteractive.com>
7 * @copyright  Copyright (c) 2009-2024 NOLA Interactive, LLC. (http://www.nolainteractive.com)
8 * @license    http://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@nolainteractive.com>
22 * @copyright  Copyright (c) 2009-2024 NOLA Interactive, LLC. (http://www.nolainteractive.com)
23 * @license    http://www.popphp.org/license     New BSD License
24 * @version    6.5.0
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 parse the shorthand columns to create expressions and their parameters
181     *
182     * @param  array   $columns
183     * @param  ?string $placeholder
184     * @param  bool    $flatten
185     * @return array
186     */
187    public static function parseShorthand(array $columns, ?string $placeholder = null, bool $flatten = true): array
188    {
189        $expressions = [];
190        $params      = [];
191        $i           = 1;
192        $j           = 0;
193
194        foreach ($columns as $column => $value) {
195            ['column' => $parsedColumn, 'operator' => $operator] = Operator::parse($column);
196
197            $pHolder = $placeholder;
198            if ($placeholder == ':') {
199                $pHolder .= $parsedColumn;
200            } else if ($placeholder == '$') {
201                $pHolder .= $i;
202            }
203
204            // IS NULL/IS NOT NULL
205            if ($value === null) {
206                $newExpression = $parsedColumn . ' IS ' . (($operator == 'NOT') ? 'NOT ' : '') . 'NULL';
207                if ($placeholder == ':') {
208                    $expressions[$parsedColumn] = $newExpression;
209                } else {
210                    $expressions[] = $newExpression;
211                }
212            // IN/NOT IN
213            } else if (is_array($value)) {
214                $p = [];
215                if ($placeholder == ':') {
216                    $pHolders = [];
217                    foreach ($value as $j => $val) {
218                        $ph         = $pHolder . ($j + 1);
219                        $pHolders[] = $ph;
220                        $p[]        = $val;
221                    }
222                } else if ($placeholder == '$') {
223                    $pHolders = [];
224                    foreach ($value as $val) {
225                        $pHolders[] = $placeholder . $i++;
226                        $p[]        = $val;
227                    }
228                } else {
229                    $pHolders = array_fill(0, count($value), $pHolder);
230                    $p        = $value;
231                    $i++;
232                }
233                if ($placeholder !== null) {
234                    $newExpression = $parsedColumn . (($operator == 'NOT') ? ' NOT ' : ' ') . 'IN (' .
235                        implode(', ', $pHolders) . ')';
236                    if ($placeholder == ':') {
237                        $expressions[$parsedColumn] = $newExpression;
238                    } else {
239                        $expressions[] = $newExpression;
240                    }
241                } else {
242                    $expressions[] = $parsedColumn . (($operator == 'NOT') ? ' NOT ' : ' ') . 'IN (' .
243                        implode(', ', array_map('Pop\Db\Sql\Parser\Expression::quote', $value)) . ')';
244                }
245                if ($placeholder == ':') {
246                    $params[$parsedColumn] = $p;
247                } else {
248                    $params[$j] = $p;
249                }
250            // BETWEEN/NOT BETWEEN
251            } else if (is_string($value) && (str_starts_with($value, '(')) && (str_ends_with($value, ')')) &&
252                (str_contains($value, ','))) {
253                $values            = substr($value, (strpos($value, '(') + 1));
254                $values            = substr($values, 0, strpos($values, ')'));
255                [$value1, $value2] = array_map('trim', explode(',', $values));
256                $p                 = [$value1, $value2];
257
258                if ($placeholder == ':') {
259                    $pHolder2 = $pHolder . 2;
260                    $pHolder .= 1;
261                } else if ($placeholder == '$') {
262                    $pHolder2 = $placeholder . ++$i;
263                } else {
264                    $pHolder2 = $pHolder;
265                }
266
267                if ($placeholder !== null) {
268                    $newExpression = $parsedColumn . (($operator == 'NOT') ? ' NOT ' : ' ') .
269                        'BETWEEN ' . $pHolder . ' AND ' . $pHolder2;
270                    if ($placeholder == ':') {
271                        $expressions[$parsedColumn] = $newExpression;
272                    } else {
273                        $expressions[] = $newExpression;
274                    }
275                } else {
276                    $expressions[] = $parsedColumn . (($operator == 'NOT') ? ' NOT ' : ' ') .
277                        'BETWEEN ' . self::quote($value1) . ' AND ' . self::quote($value2);
278                }
279                if ($placeholder == ':') {
280                    $params[$parsedColumn] = $p;
281                } else {
282                    $params[$j] = $p;
283                }
284                $i++;
285            // LIKE/NOT LIKE or Standard Operators
286            } else  {
287                if ((str_starts_with($column, '%')) || (str_starts_with($column, '-%'))) {
288                    $value  = '%' . $value;
289                }
290                if ((str_ends_with($column, '%')) || (str_ends_with($column, '%-'))) {
291                    $value .= '%';
292                }
293                if ($placeholder !== null) {
294                    $newExpression = $parsedColumn . ' ' . $operator . ' ' . $pHolder;
295                    if ($placeholder == ':') {
296                        $expressions[$parsedColumn] = $newExpression;
297                    } else {
298                        $expressions[] = $newExpression;
299                    }
300                } else {
301                    $expressions[] = $parsedColumn . ' ' . $operator . ' ' . self::quote($value);
302                }
303                if ($placeholder == ':') {
304                    $params[$parsedColumn] = $value;
305                } else {
306                    $params[$j] = $value;
307                }
308                $i++;
309            }
310            $j++;
311        }
312
313        if ($flatten) {
314            $flattenParams = [];
315
316            foreach ($params as $key => $value) {
317                if (is_array($value)) {
318                    foreach ($value as $k => $v) {
319                        if ($placeholder == ':') {
320                            $flattenParams[$key . ($k + 1)] = $v;
321                        } else {
322                            $flattenParams[] = $v;
323                        }
324                    }
325                } else {
326                    if ($placeholder == ':') {
327                        $flattenParams[$key] = $value;
328                    } else {
329                        $flattenParams[] = $value;
330                    }
331                }
332            }
333
334            return ['expressions' => $expressions, 'params' => $flattenParams];
335        } else {
336            return ['expressions' => $expressions, 'params' => $params];
337        }
338
339    }
340
341    /**
342     * Strip ID quotes
343     *
344     * @param  string $identifier
345     * @return string
346     */
347    public static function stripIdQuotes(string $identifier): string
348    {
349        if (((str_starts_with($identifier, '"')) && (str_ends_with($identifier, '"'))) ||
350            ((str_starts_with($identifier, '`')) && (str_ends_with($identifier, '`'))) ||
351            ((str_starts_with($identifier, '[')) && (str_ends_with($identifier, ']')))) {
352            $identifier = substr($identifier, 1);
353            $identifier = substr($identifier, 0, -1);
354        }
355
356        return $identifier;
357    }
358
359    /**
360     * Strip quotes
361     *
362     * @param  string $value
363     * @return string
364     */
365    public static function stripQuotes(string $value): string
366    {
367        if (((str_starts_with($value, '"')) && (str_ends_with($value, '"'))) ||
368            ((str_starts_with($value, "'")) && (str_ends_with($value, "'")))) {
369            $value = substr($value, 1);
370            $value = substr($value, 0, -1);
371        }
372
373        return $value;
374    }
375
376    /**
377     * Quote the value (if it is not a numeric value)
378     *
379     * @param  string $value
380     * @return string
381     */
382    public static function quote(string $value): string
383    {
384        if (($value == '') ||
385            (($value != '?') && (!str_starts_with($value, ':')) && (preg_match('/^\$\d*\d$/', $value) == 0) &&
386                !is_int($value) && !is_float($value) && (preg_match('/^\d*$/', $value) == 0))) {
387            $value = "'" . $value . "'";
388        }
389        return $value;
390    }
391
392}