Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
192 / 192
100.00% covered (success)
100.00%
16 / 16
CRAP
100.00% covered (success)
100.00%
1 / 1
Condition
100.00% covered (success)
100.00%
192 / 192
100.00% covered (success)
100.00%
16 / 16
94
100.00% covered (success)
100.00%
1 / 1
 parse
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 parseConditions
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
16
 parseGroup
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
6
 parseLegacy
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 isNewSyntax
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
4
 isPlainEquality
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
7
 parseTuple
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
1 / 1
12
 parseJsonPathTuple
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 parseMultiTuple
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
7
 callMulti
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 callArity0
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 callArity1
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
10
 callArity1Mixed
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
8
 callArity2
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 addParameter
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 createParameterKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
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\Parser;
16
17use Pop\Db\Sql\AbstractSql;
18use Pop\Db\Sql\PredicateSet;
19
20/**
21 * Structured shorthand condition parser class
22 *
23 * @category   Pop
24 * @package    Pop\Db
25 * @author     Nick Sagona, III <nick@popphp.org>
26 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
27 * @license    https://www.popphp.org/license     New BSD License
28 * @version    7.0.0
29 */
30class Condition
31{
32
33    /**
34     * Supported operators, mapped to their PredicateSet method and value arity
35     * @var array
36     */
37    protected const OPERATORS = [
38        '='           => ['method' => 'equalTo',              'arity' => 1],
39        '!='          => ['method' => 'notEqualTo',           'arity' => 1],
40        '>'           => ['method' => 'greaterThan',          'arity' => 1],
41        '>='          => ['method' => 'greaterThanOrEqualTo', 'arity' => 1],
42        '<'           => ['method' => 'lessThan',             'arity' => 1],
43        '<='          => ['method' => 'lessThanOrEqualTo',    'arity' => 1],
44        'LIKE'        => ['method' => 'like',                 'arity' => 1],
45        'NOT LIKE'    => ['method' => 'notLike',              'arity' => 1],
46        'IN'          => ['method' => 'in',                   'multi' => true],
47        'NOT IN'      => ['method' => 'notIn',                'multi' => true],
48        'BETWEEN'     => ['method' => 'between',              'arity' => 2],
49        'NOT BETWEEN' => ['method' => 'notBetween',           'arity' => 2],
50        'IS NULL'     => ['method' => 'isNull',               'arity' => 0],
51        'IS NOT NULL' => ['method' => 'isNotNull',            'arity' => 0],
52        'CONTAINS'    => ['method' => 'jsonContains',         'arity' => 1],
53    ];
54
55    /**
56     * Parse a shorthand columns array into a PredicateSet
57     *
58     * @param  array       $columns
59     * @param  AbstractSql $sql
60     * @param  bool        $allowLegacy
61     * @throws Exception
62     * @return PredicateSet
63     */
64    public static function parse(array $columns, AbstractSql $sql, bool $allowLegacy = true): PredicateSet
65    {
66        // Bound-parameter keys must be unique across the WHOLE parse tree (every nested OR/AND
67        // group included), because PredicateSet::getParameters() merges nested sets with
68        // array_merge(), which silently drops duplicate string keys. This counter is threaded
69        // by reference through every recursive call so that a column repeated across branches
70        // (e.g. 'logins' in two OR branches) still gets two distinct parameter keys.
71        //
72        // It is deliberately NOT AbstractSql::$parameterCount - that counter is owned by
73        // AbstractSql and reserved for the PostgreSQL "$N" token-numbering rewrite in
74        // AbstractSql::getParameter(). The two concerns both need "a counter", but not the
75        // same one.
76        $parameterIndex = 0;
77
78        return self::parseConditions($columns, $sql, $allowLegacy, $parameterIndex);
79    }
80
81    /**
82     * Parse a shorthand columns array into a PredicateSet, threading the tree-wide
83     * parameter-key counter through each level of recursion
84     *
85     * @param  array       $columns
86     * @param  AbstractSql $sql
87     * @param  bool        $allowLegacy
88     * @param  int         $parameterIndex
89     * @throws Exception
90     * @return PredicateSet
91     */
92    protected static function parseConditions(
93        array $columns, AbstractSql $sql, bool $allowLegacy, int &$parameterIndex
94    ): PredicateSet
95    {
96        $predicateSet  = new PredicateSet($sql);
97        $legacyColumns = [];
98        $newColumns    = [];
99        $groups        = [];
100        $existsKeys    = [];
101
102        foreach ($columns as $key => $value) {
103            if (($key === 'OR') || ($key === 'AND')) {
104                $groups[$key] = $value;
105            } else if (($key === 'EXISTS') || ($key === 'NOT EXISTS')) {
106                $existsKeys[$key] = $value;
107            } else if (self::isNewSyntax($value)) {
108                $newColumns[$key] = $value;
109            } else if (self::isPlainEquality((string)$key, $value)) {
110                // A bare column key (no operator suffix) carrying a plain scalar value is
111                // first-class new syntax - 'age' => 18 means age = 18. It is classified here,
112                // BEFORE the $allowLegacy check, so it never counts as "legacy": it fires no
113                // deprecation notice and is permitted inside OR/AND groups. A bare key with a
114                // null value keeps its documented IS NULL semantics (never 'column = NULL').
115                $newColumns[$key] = ($value === null) ? ['IS NULL'] : ['=', $value];
116            } else if ($allowLegacy) {
117                $legacyColumns[$key] = $value;
118            } else {
119                throw new Exception(
120                    "Error: Legacy shorthand format is not supported inside 'OR'/'AND' groups. Column '" . $key .
121                    "' must use the structured ['column' => [OPERATOR, ...values]] format here."
122                );
123            }
124        }
125
126        // The three buckets are always processed in this order - legacy entries, then
127        // new-syntax tuples, then OR/AND groups - regardless of the order the caller wrote
128        // the keys in. This is deliberate: parameters are registered (and, for PostgreSQL,
129        // "$N" placeholder tokens are numbered) in the order the predicates are appended to
130        // the set, and PredicateSet::render() emits them in that same order. Processing the
131        // buckets in a fixed order keeps the rendered "$N" sequence and the bound-parameter
132        // order in lockstep even when legacy and new-syntax entries are mixed in one call.
133        if (!empty($legacyColumns)) {
134            self::parseLegacy($predicateSet, $legacyColumns, $sql);
135        }
136
137        foreach ($newColumns as $column => $tuple) {
138            self::parseTuple($predicateSet, (string)$column, $tuple, $sql, $parameterIndex);
139        }
140
141        foreach ($existsKeys as $key => $select) {
142            if (!($select instanceof AbstractSql)) {
143                throw new Exception("Error: The '" . $key . "' key must contain a Sql\Select instance.");
144            }
145            if ($key === 'EXISTS') {
146                $predicateSet->exists($select);
147            } else {
148                $predicateSet->notExists($select);
149            }
150        }
151
152        foreach ($groups as $conjunction => $groupList) {
153            self::parseGroup($predicateSet, $conjunction, $groupList, $sql, $parameterIndex);
154        }
155
156        return $predicateSet;
157    }
158
159    /**
160     * Parse a reserved 'OR'/'AND' group key into a single combined nested PredicateSet
161     *
162     * @param  PredicateSet $predicateSet
163     * @param  string       $conjunction
164     * @param  mixed        $groups
165     * @param  AbstractSql  $sql
166     * @param  int          $parameterIndex
167     * @throws Exception
168     * @return void
169     */
170    protected static function parseGroup(
171        PredicateSet $predicateSet, string $conjunction, mixed $groups, AbstractSql $sql, int &$parameterIndex
172    ): void
173    {
174        if (!is_array($groups)) {
175            throw new Exception("Error: The '" . $conjunction . "' key must contain an array of condition groups.");
176        }
177
178        $combined = new PredicateSet($sql);
179
180        foreach ($groups as $group) {
181            if (!is_array($group)) {
182                throw new Exception("Error: Each entry under '" . $conjunction . "' must be an array of conditions.");
183            }
184            if (empty($group)) {
185                continue;
186            }
187
188            $child = self::parseConditions($group, $sql, false, $parameterIndex);
189            $child->setConjunction($conjunction);
190            $combined->addPredicateSet($child);
191        }
192
193        if ($combined->hasPredicateSets()) {
194            $combined->setConjunction('AND');
195            $predicateSet->addPredicateSet($combined);
196        }
197    }
198
199    /**
200     * Parse legacy-shaped shorthand entries via the existing Expression parser,
201     * firing a deprecation notice and folding the result into the given PredicateSet
202     *
203     * @param  PredicateSet $predicateSet
204     * @param  array        $legacyColumns
205     * @param  AbstractSql  $sql
206     * @return void
207     */
208    protected static function parseLegacy(PredicateSet $predicateSet, array $legacyColumns, AbstractSql $sql): void
209    {
210        $columnList = implode(', ', array_map('strval', array_keys($legacyColumns)));
211
212        trigger_error(
213            "Deprecated: The shorthand column format used for [" . $columnList . "] is deprecated and will be " .
214            "removed in pop-db v8. Use the structured format instead, e.g. ['column' => ['>=', value]]. " .
215            "See README.md for the new syntax.",
216            E_USER_DEPRECATED
217        );
218
219        $result = Expression::parseShorthand($legacyColumns, $sql->getPlaceholder());
220
221        $predicateSet->addExpressions($result['expressions']);
222        $predicateSet->addParameters($result['params']);
223
224        if ($sql->getPlaceholder() === '$') {
225            foreach ($result['params'] as $ignored) {
226                $sql->incrementParameterCount();
227            }
228        }
229    }
230
231    /**
232     * Determine if a shorthand column value uses the new structured (operator-tuple) syntax
233     *
234     * @param  mixed $value
235     * @return bool
236     */
237    public static function isNewSyntax(mixed $value): bool
238    {
239        return (is_array($value) && isset($value[0]) && is_string($value[0]) &&
240            array_key_exists(strtoupper($value[0]), self::OPERATORS));
241    }
242
243    /**
244     * Determine if a shorthand entry is plain equality, i.e. a bare column key carrying no
245     * operator suffix paired with a scalar (or null) value
246     *
247     * A '(value1, value2)'-shaped string value is excluded: that is the legacy packed
248     * BETWEEN shape, which must keep routing through Expression::parseShorthand(). Its
249     * documented replacement is the unambiguous ['column' => ['BETWEEN', v1, v2]] tuple.
250     *
251     * @param  string $key
252     * @param  mixed  $value
253     * @return bool
254     */
255    public static function isPlainEquality(string $key, mixed $value): bool
256    {
257        if (($value !== null) && !is_scalar($value)) {
258            return false;
259        }
260        if (is_string($value) && str_starts_with($value, '(') && str_ends_with($value, ')')) {
261            return false;
262        }
263
264        ['column' => $column, 'operator' => $operator] = Operator::parse($key);
265
266        return (($column === $key) && ($operator === '='));
267    }
268
269    /**
270     * Parse a single new-syntax operator tuple onto the given PredicateSet
271     *
272     * @param  PredicateSet $predicateSet
273     * @param  string       $column
274     * @param  array        $tuple
275     * @param  AbstractSql  $sql
276     * @param  int          $parameterIndex
277     * @throws Exception
278     * @return void
279     */
280    protected static function parseTuple(
281        PredicateSet $predicateSet, string $column, array $tuple, AbstractSql $sql, int &$parameterIndex
282    ): void
283    {
284        $operator = strtoupper(array_shift($tuple));
285        $spec     = self::OPERATORS[$operator];
286        $method   = $spec['method'];
287
288        $jsonPath = null;
289        if (str_contains($column, '->')) {
290            [$column, $jsonPath] = explode('->', $column, 2);
291        }
292
293        if ($jsonPath !== null) {
294            self::parseJsonPathTuple($predicateSet, $column, $jsonPath, $operator, $tuple, $sql, $parameterIndex);
295            return;
296        }
297
298        if ($operator === 'CONTAINS') {
299            throw new Exception(
300                "Error: The 'CONTAINS' operator requires a JSON path in the column key, e.g. 'column->\$.path'."
301            );
302        }
303
304        if (!empty($spec['multi'])) {
305            self::parseMultiTuple($predicateSet, $method, $column, $operator, $tuple, $sql, $parameterIndex);
306        } else if ($spec['arity'] === 0) {
307            if (count($tuple) !== 0) {
308                throw new Exception(
309                    "Error: The '" . $operator . "' operator for column '" . $column . "' does not accept any values."
310                );
311            }
312            self::callArity0($predicateSet, $method, $column);
313        } else if ($spec['arity'] === 1) {
314            if (count($tuple) !== 1) {
315                throw new Exception(
316                    "Error: The '" . $operator . "' operator for column '" . $column . "' requires exactly 1 value, " .
317                    count($tuple) . ' given.'
318                );
319            }
320            if ($tuple[0] instanceof AbstractSql) {
321                if (!in_array($operator, ['=', '!=', '>', '>=', '<', '<='], true)) {
322                    throw new Exception(
323                        "Error: A Sql\Select instance is not a supported value for the '" . $operator . "' operator."
324                    );
325                }
326                self::callArity1Mixed($predicateSet, $method, $column, $tuple[0]);
327            } else {
328                $placeholder = self::addParameter($predicateSet, $sql, $column, $tuple[0], $parameterIndex);
329                self::callArity1($predicateSet, $method, $column, $placeholder);
330            }
331        } else {
332            if (count($tuple) !== 2) {
333                throw new Exception(
334                    "Error: The '" . $operator . "' operator for column '" . $column . "' requires exactly 2 values, " .
335                    count($tuple) . ' given.'
336                );
337            }
338            $placeholder1 = self::addParameter($predicateSet, $sql, $column, $tuple[0], $parameterIndex);
339            $placeholder2 = self::addParameter($predicateSet, $sql, $column, $tuple[1], $parameterIndex);
340            self::callArity2($predicateSet, $method, $column, $placeholder1, $placeholder2);
341        }
342    }
343
344    /**
345     * Parse a single new-syntax operator tuple whose column key used JSON path access ('column->path')
346     *
347     * @param  PredicateSet $predicateSet
348     * @param  string       $column
349     * @param  string       $jsonPath
350     * @param  string       $operator
351     * @param  array        $tuple
352     * @param  AbstractSql  $sql
353     * @param  int          $parameterIndex
354     * @throws Exception
355     * @return void
356     */
357    protected static function parseJsonPathTuple(
358        PredicateSet $predicateSet, string $column, string $jsonPath, string $operator, array $tuple,
359        AbstractSql $sql, int &$parameterIndex
360    ): void
361    {
362        $jsonMethods = ['=' => 'jsonEqualTo', '!=' => 'jsonNotEqualTo', 'CONTAINS' => 'jsonContains'];
363        if (!isset($jsonMethods[$operator])) {
364            throw new Exception(
365                "Error: The '" . $operator . "' operator is not supported for JSON path access (column '" .
366                $column . "->" . $jsonPath . "')."
367            );
368        }
369        if (count($tuple) !== 1) {
370            throw new Exception(
371                "Error: The '" . $operator . "' operator for column '" . $column . "->" . $jsonPath .
372                "' requires exactly 1 value, " . count($tuple) . ' given.'
373            );
374        }
375
376        $jsonMethod = $jsonMethods[$operator];
377        if ($operator === 'CONTAINS') {
378            $predicateSet->{$jsonMethod}($column, $jsonPath, $tuple[0]);
379        } else {
380            $placeholder = self::addParameter($predicateSet, $sql, $column, $tuple[0], $parameterIndex);
381            $predicateSet->{$jsonMethod}($column, $jsonPath, $placeholder);
382        }
383    }
384
385    /**
386     * Parse a single new-syntax operator tuple for a multi-value ('in'/'notIn') operator
387     *
388     * @param  PredicateSet $predicateSet
389     * @param  string       $method
390     * @param  string       $column
391     * @param  string       $operator
392     * @param  array        $tuple
393     * @param  AbstractSql  $sql
394     * @param  int          $parameterIndex
395     * @throws Exception
396     * @return void
397     */
398    protected static function parseMultiTuple(
399        PredicateSet $predicateSet, string $method, string $column, string $operator, array $tuple,
400        AbstractSql $sql, int &$parameterIndex
401    ): void
402    {
403        if (isset($tuple[0]) && ($tuple[0] instanceof AbstractSql)) {
404            self::callMulti($predicateSet, $method, $column, $tuple[0]);
405            return;
406        }
407
408        if (!isset($tuple[0]) || !is_array($tuple[0])) {
409            throw new Exception(
410                "Error: The '" . $operator . "' operator for column '" . $column .
411                "' requires an array of values or a Sql\Select instance."
412            );
413        }
414        if (empty($tuple[0])) {
415            throw new Exception(
416                "Error: The '" . $operator . "' operator for column '" . $column .
417                "' requires at least 1 value, 0 given."
418            );
419        }
420
421        $placeholders = [];
422        foreach ($tuple[0] as $val) {
423            $placeholders[] = self::addParameter($predicateSet, $sql, $column, $val, $parameterIndex);
424        }
425        self::callMulti($predicateSet, $method, $column, $placeholders);
426    }
427
428    /**
429     * Dispatch a 2-value ('in'/'notIn') PredicateSet call
430     *
431     * @param  PredicateSet $predicateSet
432     * @param  string       $method
433     * @param  string       $column
434     * @param  mixed        $values
435     * @return void
436     */
437    protected static function callMulti(PredicateSet $predicateSet, string $method, string $column, mixed $values): void
438    {
439        match ($method) {
440            'in'    => $predicateSet->in($column, $values),
441            'notIn' => $predicateSet->notIn($column, $values),
442            default => throw new Exception("Error: Unsupported multi-value method '" . $method . "'."),
443        };
444    }
445
446    /**
447     * Dispatch a no-value ('isNull'/'isNotNull') PredicateSet call
448     *
449     * @param  PredicateSet $predicateSet
450     * @param  string       $method
451     * @param  string       $column
452     * @return void
453     */
454    protected static function callArity0(PredicateSet $predicateSet, string $method, string $column): void
455    {
456        match ($method) {
457            'isNull'    => $predicateSet->isNull($column),
458            'isNotNull' => $predicateSet->isNotNull($column),
459            default     => throw new Exception("Error: Unsupported no-value method '" . $method . "'."),
460        };
461    }
462
463    /**
464     * Dispatch a single-value PredicateSet call whose value is a bound placeholder string
465     *
466     * @param  PredicateSet $predicateSet
467     * @param  string       $method
468     * @param  string       $column
469     * @param  string       $value
470     * @return void
471     */
472    protected static function callArity1(PredicateSet $predicateSet, string $method, string $column, string $value): void
473    {
474        match ($method) {
475            'equalTo'              => $predicateSet->equalTo($column, $value),
476            'notEqualTo'           => $predicateSet->notEqualTo($column, $value),
477            'greaterThan'          => $predicateSet->greaterThan($column, $value),
478            'greaterThanOrEqualTo' => $predicateSet->greaterThanOrEqualTo($column, $value),
479            'lessThan'             => $predicateSet->lessThan($column, $value),
480            'lessThanOrEqualTo'    => $predicateSet->lessThanOrEqualTo($column, $value),
481            'like'                 => $predicateSet->like($column, $value),
482            'notLike'              => $predicateSet->notLike($column, $value),
483            default                => throw new Exception("Error: Unsupported single-value method '" . $method . "'."),
484        };
485    }
486
487    /**
488     * Dispatch a single-value PredicateSet call whose value is a nested Sql\Select instance
489     *
490     * @param  PredicateSet $predicateSet
491     * @param  string       $method
492     * @param  string       $column
493     * @param  AbstractSql  $value
494     * @return void
495     */
496    protected static function callArity1Mixed(PredicateSet $predicateSet, string $method, string $column, AbstractSql $value): void
497    {
498        match ($method) {
499            'equalTo'              => $predicateSet->equalTo($column, $value),
500            'notEqualTo'           => $predicateSet->notEqualTo($column, $value),
501            'greaterThan'          => $predicateSet->greaterThan($column, $value),
502            'greaterThanOrEqualTo' => $predicateSet->greaterThanOrEqualTo($column, $value),
503            'lessThan'             => $predicateSet->lessThan($column, $value),
504            'lessThanOrEqualTo'    => $predicateSet->lessThanOrEqualTo($column, $value),
505            default                => throw new Exception("Error: Unsupported single-value method '" . $method . "'."),
506        };
507    }
508
509    /**
510     * Dispatch a two-value ('between'/'notBetween') PredicateSet call
511     *
512     * @param  PredicateSet $predicateSet
513     * @param  string       $method
514     * @param  string       $column
515     * @param  string       $value1
516     * @param  string       $value2
517     * @return void
518     */
519    protected static function callArity2(
520        PredicateSet $predicateSet, string $method, string $column, string $value1, string $value2
521    ): void
522    {
523        match ($method) {
524            'between'    => $predicateSet->between($column, $value1, $value2),
525            'notBetween' => $predicateSet->notBetween($column, $value1, $value2),
526            default      => throw new Exception("Error: Unsupported two-value method '" . $method . "'."),
527        };
528    }
529
530    /**
531     * Register a bound parameter under a tree-unique key and return the dialect-correct
532     * placeholder token that refers to it
533     *
534     * For ':'-style dialects (SQLite/PDO) the returned token is ':' . the parameter key, so
535     * the token in the rendered SQL and the key in the parameter array always match exactly.
536     * For '?'-style (MySQL/SQL Server) and '$'-style (PostgreSQL) dialects only the parameter
537     * ORDER matters at bind time, but the key must still be unique so that the array_merge()
538     * in PredicateSet::getParameters() cannot drop it.
539     *
540     * @param  PredicateSet $predicateSet
541     * @param  AbstractSql  $sql
542     * @param  string       $column
543     * @param  mixed        $value
544     * @param  int          $parameterIndex
545     * @return string
546     */
547    protected static function addParameter(
548        PredicateSet $predicateSet, AbstractSql $sql, string $column, mixed $value, int &$parameterIndex
549    ): string
550    {
551        $parameterIndex++;
552
553        $key = self::createParameterKey($column, $parameterIndex);
554        $predicateSet->addParameter($key, $value);
555
556        $placeholder = $sql->getPlaceholder();
557
558        if ($placeholder === ':') {
559            return ':' . $key;
560        } else if ($placeholder === '$') {
561            $sql->incrementParameterCount();
562            return '$' . $sql->getParameterCount();
563        } else {
564            return '?';
565        }
566    }
567
568    /**
569     * Create a tree-unique, bind-safe parameter key for a column
570     *
571     * The counter comes FIRST, so every generated key starts with a digit. That is what keeps
572     * these keys from ever colliding with the keys the legacy path emits: the (unmodified)
573     * Expression::parseShorthand() derives its keys straight from the column name, as either
574     * '<column>' or '<column><digit>', and a column name never starts with a digit. Putting
575     * the counter last would allow a column literally named 'line_1' to collide with the
576     * first generated key for a column named 'line'.
577     *
578     * Any character that is not valid in a named placeholder (e.g. the '.' of a
579     * table-qualified column) is normalized to an underscore.
580     *
581     * @param  string $column
582     * @param  int    $parameterIndex
583     * @return string
584     */
585    protected static function createParameterKey(string $column, int $parameterIndex): string
586    {
587        return $parameterIndex . '_' . preg_replace('/[^a-zA-Z0-9_]/', '_', $column);
588    }
589
590}