Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
192 / 192 |
|
100.00% |
16 / 16 |
CRAP | |
100.00% |
1 / 1 |
| Condition | |
100.00% |
192 / 192 |
|
100.00% |
16 / 16 |
94 | |
100.00% |
1 / 1 |
| parse | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| parseConditions | |
100.00% |
33 / 33 |
|
100.00% |
1 / 1 |
16 | |||
| parseGroup | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
6 | |||
| parseLegacy | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
3 | |||
| isNewSyntax | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
4 | |||
| isPlainEquality | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
7 | |||
| parseTuple | |
100.00% |
43 / 43 |
|
100.00% |
1 / 1 |
12 | |||
| parseJsonPathTuple | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
4 | |||
| parseMultiTuple | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
7 | |||
| callMulti | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
4 | |||
| callArity0 | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
4 | |||
| callArity1 | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
10 | |||
| callArity1Mixed | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
8 | |||
| callArity2 | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
4 | |||
| addParameter | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
| createParameterKey | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | declare(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 | */ |
| 15 | namespace Pop\Db\Sql\Parser; |
| 16 | |
| 17 | use Pop\Db\Sql\AbstractSql; |
| 18 | use 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 | */ |
| 30 | class 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 | } |