• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

nextras / dbal / 19193970809

08 Nov 2025 01:56PM UTC coverage: 86.873% (+0.3%) from 86.622%
19193970809

push

github

web-flow
Merge pull request #313 from nextras/php8.5

build for PHP 8.5

6 of 7 new or added lines in 1 file covered. (85.71%)

1992 of 2293 relevant lines covered (86.87%)

5.09 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.44
/src/SqlProcessor.php
1
<?php declare(strict_types = 1);
2

3
namespace Nextras\Dbal;
4

5

6
use DateInterval;
7
use DateTime;
8
use DateTimeImmutable;
9
use Nextras\Dbal\Exception\InvalidArgumentException;
10
use Nextras\Dbal\Platforms\Data\Fqn;
11
use Nextras\Dbal\Platforms\IPlatform;
12
use Nextras\Dbal\Utils\StrictObjectTrait;
13
use SplObjectStorage;
14

15

16
class SqlProcessor
17
{
18
        use StrictObjectTrait;
19

20

21
        /**
22
         * Modifiers definition in form of array (name => [supports ?, supports [], expected type description]).
23
         * @var array<string, array{bool, bool, string}>
24
         */
25
        protected $modifiers = [
26
                // expressions
27
                's' => [true, true, 'string'],
28
                'json' => [true, true, 'pretty much anything'],
29
                'i' => [true, true, 'int'],
30
                'f' => [true, true, '(finite) float'],
31
                'b' => [true, true, 'bool'],
32
                'dt' => [true, true, 'DateTimeInterface'],
33
                'dts' => [true, true, 'DateTimeInterface'], // @deprecated use ldt
34
                'ldt' => [true, true, 'DateTimeInterface'],
35
                'ld' => [true, true, 'DateTimeInterface|string(YYYY-MM-DD)'],
36
                'di' => [true, true, 'DateInterval'],
37
                'blob' => [true, true, 'blob string'],
38
                '_like' => [true, false, 'string'],
39
                'like_' => [true, false, 'string'],
40
                '_like_' => [true, false, 'string'],
41
                'any' => [false, false, 'pretty much anything'],
42
                'and' => [false, false, 'array'],
43
                'or' => [false, false, 'array'],
44
                'multiOr' => [false, false, 'array'],
45

46
                // SQL constructs
47
                'table' => [false, true, 'string|array'],
48
                'column' => [false, true, 'string'],
49
                'values' => [false, true, 'array'],
50
                'set' => [false, false, 'array'],
51
                'raw' => [false, false, 'string'],
52
                'ex' => [false, false, 'array'],
53
        ];
54

55
        /**
56
         * Modifiers storage as array(modifier name => callable)
57
         * @var array<string, callable(SqlProcessor, mixed, string): mixed>
58
         */
59
        protected $customModifiers = [];
60

61
        /** @var SplObjectStorage<ISqlProcessorModifierResolver, never> */
62
        protected SplObjectStorage $modifierResolvers;
63

64
        /** @var array<string, string> */
65
        private ?array $identifiers = null;
66

67

68
        public function __construct(private readonly IPlatform $platform)
6 ✔
69
        {
70
                $this->modifierResolvers = new SplObjectStorage();
6 ✔
71
        }
6 ✔
72

73

74
        /**
75
         * @param callable(SqlProcessor, mixed $value, string $modifier): mixed $callback
76
         */
77
        public function setCustomModifier(string $modifier, callable $callback): void
78
        {
79
                $baseModifier = trim($modifier, '[]?');
6 ✔
80
                if (isset($this->modifiers[$baseModifier])) {
6 ✔
81
                        throw new InvalidArgumentException("Cannot override core modifier '$baseModifier'.");
6 ✔
82
                }
83

84
                $this->customModifiers[$modifier] = $callback;
6 ✔
85
        }
6 ✔
86

87

88
        /**
89
         * Adds a modifier resolver for any unspecified type (either implicit or explicit `%any` modifier).
90
         */
91
        public function addModifierResolver(ISqlProcessorModifierResolver $resolver): void
92
        {
93
                $this->modifierResolvers->offsetSet(object: $resolver);
6 ✔
94
        }
6 ✔
95

96

97
        /**
98
         * Removes modifier resolver.
99
         */
100
        public function removeModifierResolver(ISqlProcessorModifierResolver $resolver): void
101
        {
NEW
102
                $this->modifierResolvers->offsetUnset(object: $resolver);
×
103
        }
×
104

105

106
        /**
107
         * @param mixed[] $args
108
         */
109
        public function process(array $args): string
110
        {
111
                $last = count($args) - 1;
6 ✔
112
                $fragments = [];
6 ✔
113

114
                for ($i = 0, $j = 0; $j <= $last; $j++) {
6 ✔
115
                        if (!is_string($args[$j])) {
6 ✔
116
                                throw new InvalidArgumentException($j === 0
6 ✔
117
                                        ? 'Query fragment must be string.'
6 ✔
118
                                        : "Redundant query parameter or missing modifier in query fragment '$args[$i]'.",
6 ✔
119
                                );
120
                        }
121

122
                        $i = $j;
6 ✔
123
                        $fragments[] = preg_replace_callback(
6 ✔
124
                                '#%((?:\.\.\.)?+\??+\w++(?:\[]){0,2}+)|(%%)|(\[\[)|(]])|\[(.+?)]#S', // %modifier | %% | [[ | ]] | [identifier]
6 ✔
125
                                function($matches) use ($args, &$j, $last): string {
6 ✔
126
                                        if ($matches[1] !== '') {
6 ✔
127
                                                if ($j === $last) {
6 ✔
128
                                                        throw new InvalidArgumentException("Missing query parameter for modifier $matches[0].");
6 ✔
129
                                                }
130
                                                return $this->processModifier($matches[1], $args[++$j]);
6 ✔
131

132
                                        } elseif ($matches[2] !== '') {
6 ✔
133
                                                return '%';
6 ✔
134

135
                                        } elseif ($matches[3] !== '') {
6 ✔
136
                                                return '[';
6 ✔
137

138
                                        } elseif ($matches[4] !== '') {
6 ✔
139
                                                return ']';
6 ✔
140

141
                                        } elseif (!ctype_digit($matches[5])) {
6 ✔
142
                                                return $this->identifierToSql($matches[5]);
6 ✔
143

144
                                        } else {
145
                                                return "[$matches[5]]";
6 ✔
146
                                        }
147
                                },
6 ✔
148
                                (string) $args[$i],
6 ✔
149
                        );
150

151
                        if ($i === $j && $j !== $last) {
6 ✔
152
                                throw new InvalidArgumentException("Redundant query parameter or missing modifier in query fragment '$args[$i]'.");
6 ✔
153
                        }
154
                }
155

156
                return implode(' ', $fragments);
6 ✔
157
        }
158

159

160
        public function processModifier(string $type, mixed $value): string
161
        {
162
                if ($value instanceof \BackedEnum) {
6 ✔
163
                        $value = $value->value;
6 ✔
164
                }
165

166
                if ($type === 'any') {
6 ✔
167
                        $type = $this->detectType($value) ?? 'any';
6 ✔
168
                }
169

170
                switch (gettype($value)) {
6 ✔
171
                        case 'string':
6 ✔
172
                                switch ($type) {
173
                                        case 'any':
6 ✔
174
                                        case 's':
6 ✔
175
                                        case '?s':
6 ✔
176
                                                return $this->platform->formatString($value);
6 ✔
177

178
                                        case 'json':
6 ✔
179
                                        case '?json':
6 ✔
180
                                                return $this->platform->formatJson($value);
6 ✔
181

182
                                        case 'i':
6 ✔
183
                                        case '?i':
6 ✔
184
                                                if (preg_match('#^-?[1-9][0-9]*+\z#', $value) !== 1) {
6 ✔
185
                                                        break;
6 ✔
186
                                                }
187
                                                return $value;
6 ✔
188

189
                                        case 'ld':
6 ✔
190
                                        case '?ld':
6 ✔
191
                                                if (preg_match('#^\d{4}-\d{2}-\d{2}$#', $value) !== 1) {
6 ✔
192
                                                        break;
6 ✔
193
                                                }
194
                                                return $this->platform->formatString($value);
6 ✔
195

196
                                        case '_like':
6 ✔
197
                                                return $this->platform->formatStringLike($value, -1);
6 ✔
198
                                        case 'like_':
6 ✔
199
                                                return $this->platform->formatStringLike($value, 1);
6 ✔
200
                                        case '_like_':
6 ✔
201
                                                return $this->platform->formatStringLike($value, 0);
6 ✔
202

203
                                        /** @noinspection PhpMissingBreakStatementInspection */
204
                                        case 'column':
6 ✔
205
                                                if ($value === '*') {
6 ✔
206
                                                        return '*';
×
207
                                                }
208
                                        // intentional pass-through
209
                                        case 'table':
6 ✔
210
                                                return $this->identifierToSql($value);
6 ✔
211

212
                                        case 'blob':
6 ✔
213
                                                return $this->platform->formatBlob($value);
6 ✔
214

215
                                        case 'raw':
6 ✔
216
                                                return $value;
6 ✔
217
                                }
218

219
                                break;
6 ✔
220
                        case 'integer':
6 ✔
221
                                switch ($type) {
222
                                        case 'any':
6 ✔
223
                                        case 'i':
6 ✔
224
                                        case '?i':
6 ✔
225
                                                return (string) $value;
6 ✔
226

227
                                        case 'json':
6 ✔
228
                                        case '?json':
6 ✔
229
                                                return $this->platform->formatJson($value);
6 ✔
230
                                }
231

232
                                break;
6 ✔
233
                        case 'double':
6 ✔
234
                                if (is_finite($value)) { // database can not handle INF and NAN
6 ✔
235
                                        switch ($type) {
236
                                                case 'any':
6 ✔
237
                                                case 'f':
6 ✔
238
                                                case '?f':
6 ✔
239
                                                        $tmp = json_encode($value, JSON_THROW_ON_ERROR);
6 ✔
240
                                                        return $tmp . (!str_contains($tmp, '.') ? '.0' : '');
6 ✔
241

242
                                                case 'json':
6 ✔
243
                                                case '?json':
6 ✔
244
                                                        return $this->platform->formatJson($value);
6 ✔
245
                                        }
246
                                }
247

248
                                break;
6 ✔
249
                        case 'boolean':
6 ✔
250
                                switch ($type) {
251
                                        case 'any':
6 ✔
252
                                        case 'b':
6 ✔
253
                                        case '?b':
6 ✔
254
                                                return $this->platform->formatBool($value);
6 ✔
255

256
                                        case 'json':
6 ✔
257
                                        case '?json':
6 ✔
258
                                                return $this->platform->formatJson($value);
6 ✔
259
                                }
260

261
                                break;
6 ✔
262
                        case 'NULL':
6 ✔
263
                                switch ($type) {
264
                                        case 'any':
6 ✔
265
                                        case '?s':
6 ✔
266
                                        case '?i':
6 ✔
267
                                        case '?f':
6 ✔
268
                                        case '?b':
6 ✔
269
                                        case '?dt':
6 ✔
270
                                        case '?dts':
6 ✔
271
                                        case '?ld':
6 ✔
272
                                        case '?ldt':
6 ✔
273
                                        case '?di':
6 ✔
274
                                        case '?blob':
6 ✔
275
                                        case '?json':
6 ✔
276
                                                return 'NULL';
6 ✔
277
                                }
278

279
                                break;
6 ✔
280
                        case 'object':
6 ✔
281
                                if ($type === 'json' || $type === '?json') {
6 ✔
282
                                        return $this->platform->formatJson($value);
6 ✔
283
                                }
284

285
                                if ($value instanceof DateTimeImmutable || $value instanceof DateTime) {
6 ✔
286
                                        switch ($type) {
287
                                                case 'any':
6 ✔
288
                                                case 'dt':
6 ✔
289
                                                case '?dt':
6 ✔
290
                                                        return $this->platform->formatDateTime($value);
6 ✔
291

292
                                                case 'dts':
6 ✔
293
                                                case '?dts':
6 ✔
294
                                                case 'ldt':
6 ✔
295
                                                case '?ldt':
6 ✔
296
                                                        return $this->platform->formatLocalDateTime($value);
6 ✔
297

298
                                                case 'ld':
6 ✔
299
                                                case '?ld':
×
300
                                                        return $this->platform->formatLocalDate($value);
6 ✔
301
                                        }
302

303
                                } elseif ($value instanceof DateInterval) {
6 ✔
304
                                        switch ($type) {
305
                                                case 'any':
6 ✔
306
                                                case 'di':
×
307
                                                case '?di':
×
308
                                                        return $this->platform->formatDateInterval($value);
6 ✔
309
                                        }
310

311
                                } elseif ($value instanceof Fqn) {
6 ✔
312
                                        switch ($type) {
313
                                                case 'column':
6 ✔
314
                                                case 'table':
6 ✔
315
                                                        $schema = $this->identifierToSql($value->schema);
6 ✔
316
                                                        $table = $this->identifierToSql($value->name);
6 ✔
317
                                                        return "$schema.$table";
6 ✔
318
                                        }
319

320
                                } elseif (method_exists($value, '__toString')) {
6 ✔
321
                                        switch ($type) {
322
                                                case 'any':
6 ✔
323
                                                case 's':
6 ✔
324
                                                case '?s':
6 ✔
325
                                                        return $this->platform->formatString((string) $value);
6 ✔
326

327
                                                case '_like':
6 ✔
328
                                                        return $this->platform->formatStringLike((string) $value, -1);
×
329
                                                case 'like_':
6 ✔
330
                                                        return $this->platform->formatStringLike((string) $value, 1);
×
331
                                                case '_like_':
6 ✔
332
                                                        return $this->platform->formatStringLike((string) $value, 0);
×
333
                                        }
334
                                }
335

336
                                break;
6 ✔
337
                        case 'array':
6 ✔
338
                                switch ($type) {
339
                                        // micro-optimizations
340
                                        case 'any':
6 ✔
341
                                                return $this->processArray("any[]", $value);
6 ✔
342

343
                                        case 'i[]':
6 ✔
344
                                                foreach ($value as $v) {
6 ✔
345
                                                        if (!is_int($v)) break 2; // fallback to processArray
6 ✔
346
                                                }
347
                                                return '(' . implode(', ', $value) . ')';
6 ✔
348

349
                                        case 's[]':
6 ✔
350
                                                foreach ($value as &$subValue) {
6 ✔
351
                                                        if (!is_string($subValue)) break 2; // fallback to processArray
6 ✔
352
                                                        $subValue = $this->platform->formatString($subValue);
6 ✔
353
                                                }
354
                                                return '(' . implode(', ', $value) . ')';
6 ✔
355

356
                                        case 'json':
6 ✔
357
                                        case '?json':
6 ✔
358
                                                return $this->platform->formatJson($value);
6 ✔
359

360
                                        // normal
361
                                        case 'column[]':
6 ✔
362
                                        case '...column[]':
6 ✔
363
                                        case 'table[]':
6 ✔
364
                                        case '...table[]':
6 ✔
365
                                                $subType = substr($type, 0, -2);
6 ✔
366
                                                foreach ($value as &$subValue) {
6 ✔
367
                                                        $subValue = $this->processModifier($subType, $subValue);
6 ✔
368
                                                }
369
                                                return implode(', ', $value);
6 ✔
370

371
                                        case 'and':
6 ✔
372
                                        case 'or':
6 ✔
373
                                                return $this->processWhere($type, $value);
6 ✔
374

375
                                        case 'multiOr':
6 ✔
376
                                                return $this->processMultiColumnOr($value);
6 ✔
377

378
                                        case 'values':
6 ✔
379
                                                return $this->processValues($value);
6 ✔
380

381
                                        case 'values[]':
6 ✔
382
                                                return $this->processMultiValues($value);
6 ✔
383

384
                                        case 'set':
6 ✔
385
                                                return $this->processSet($value);
6 ✔
386

387
                                        case 'ex':
6 ✔
388
                                                return $this->process($value);
6 ✔
389
                                }
390

391
                                if (str_ends_with($type, ']')) {
6 ✔
392
                                        $baseType = trim(trim($type, '.'), '[]?');
6 ✔
393
                                        if (isset($this->modifiers[$baseType]) && $this->modifiers[$baseType][1]) {
6 ✔
394
                                                return $this->processArray($type, $value);
6 ✔
395
                                        }
396
                                }
397
                }
398

399
                $baseType = trim(trim($type, '.'), '[]?');
6 ✔
400

401
                if (isset($this->customModifiers[$baseType])) {
6 ✔
402
                        return $this->customModifiers[$baseType]($this, $value, $type);
6 ✔
403
                }
404

405
                $typeNullable = $type[0] === '?';
6 ✔
406
                $typeArray = str_ends_with($type, '[]');
6 ✔
407

408
                if (!isset($this->modifiers[$baseType])) {
6 ✔
409
                        throw new InvalidArgumentException("Unknown modifier %$type.");
6 ✔
410

411
                } elseif (($typeNullable && !$this->modifiers[$baseType][0]) || ($typeArray && !$this->modifiers[$baseType][1])) {
6 ✔
412
                        throw new InvalidArgumentException("Modifier %$baseType does not have %$type variant.");
6 ✔
413

414
                } elseif ($typeArray) {
6 ✔
415
                        $this->throwInvalidValueTypeException($type, $value, 'array');
6 ✔
416

417
                } elseif ($value === null && !$typeNullable && $this->modifiers[$baseType][0]) {
6 ✔
418
                        $this->throwWrongModifierException($type, $value, "?$type");
6 ✔
419

420
                } elseif (is_array($value) && $this->modifiers[$baseType][1]) {
6 ✔
421
                        $this->throwWrongModifierException($type, $value, "{$type}[]");
6 ✔
422

423
                } else {
424
                        $this->throwInvalidValueTypeException($type, $value, $this->modifiers[$baseType][2]);
6 ✔
425
                }
426
        }
×
427

428

429
        protected function detectType(mixed $value): ?string
430
        {
431
                foreach ($this->modifierResolvers as $modifierResolver) {
6 ✔
432
                        $resolved = $modifierResolver->resolve($value);
6 ✔
433
                        if ($resolved !== null) return $resolved;
6 ✔
434
                }
435
                return null;
6 ✔
436
        }
437

438

439
        protected function throwInvalidValueTypeException(string $type, mixed $value, string $expectedType): never
440
        {
441
                $actualType = $this->getVariableTypeName($value);
6 ✔
442
                throw new InvalidArgumentException("Modifier %$type expects value to be $expectedType, $actualType given.");
6 ✔
443
        }
444

445

446
        protected function throwWrongModifierException(string $type, mixed $value, string $hint): never
447
        {
448
                $valueLabel = is_scalar($value) ? var_export($value, true) : gettype($value);
6 ✔
449
                throw new InvalidArgumentException("Modifier %$type does not allow $valueLabel value, use modifier %$hint instead.");
6 ✔
450
        }
451

452

453
        /**
454
         * @param array<mixed> $value
455
         */
456
        protected function processArray(string $type, array $value): string
457
        {
458
                $subType = substr($type, 0, -2);
6 ✔
459
                $wrapped = true;
6 ✔
460

461
                if (str_starts_with($subType, '...')) {
6 ✔
462
                        $subType = substr($subType, 3);
6 ✔
463
                        $wrapped = false;
6 ✔
464
                }
465

466
                foreach ($value as &$subValue) {
6 ✔
467
                        $subValue = $this->processModifier($subType, $subValue);
6 ✔
468
                }
469

470
                if ($wrapped) {
6 ✔
471
                        return '(' . implode(', ', $value) . ')';
6 ✔
472
                } else {
473
                        return implode(', ', $value);
6 ✔
474
                }
475
        }
476

477

478
        /**
479
         * @param array<string, mixed> $value
480
         */
481
        protected function processSet(array $value): string
482
        {
483
                $values = [];
6 ✔
484
                foreach ($value as $_key => $val) {
6 ✔
485
                        $key = explode('%', $_key, 2);
6 ✔
486
                        $column = $this->identifierToSql($key[0]);
6 ✔
487
                        $expr = $this->processModifier($key[1] ?? 'any', $val);
6 ✔
488
                        $values[] = "$column = $expr";
6 ✔
489
                }
490

491
                return implode(', ', $values);
6 ✔
492
        }
493

494

495
        /**
496
         * @param array<string, mixed> $value
497
         */
498
        protected function processMultiValues(array $value): string
499
        {
500
                if (count($value) === 0) {
6 ✔
501
                        throw new InvalidArgumentException('Modifier %values[] must contain at least one array element.');
6 ✔
502
                }
503

504
                $keys = $values = [];
6 ✔
505
                foreach (array_keys(reset($value)) as $key) {
6 ✔
506
                        $keys[] = $this->identifierToSql(explode('%', (string) $key, 2)[0]);
6 ✔
507
                }
508
                foreach ($value as $subValue) {
6 ✔
509
                        if (!is_array($subValue) || count($subValue) === 0) {
6 ✔
510
                                $values[] = '(' . str_repeat('DEFAULT, ', max(count($keys) - 1, 0)) . 'DEFAULT)';
6 ✔
511
                        } else {
512
                                $subValues = [];
6 ✔
513
                                foreach ($subValue as $_key => $val) {
6 ✔
514
                                        $key = explode('%', (string) $_key, 2);
6 ✔
515
                                        $subValues[] = $this->processModifier($key[1] ?? 'any', $val);
6 ✔
516
                                }
517
                                $values[] = '(' . implode(', ', $subValues) . ')';
6 ✔
518
                        }
519
                }
520

521
                return (count($keys) > 0 ? '(' . implode(', ', $keys) . ') ' : '') . 'VALUES ' . implode(', ', $values);
6 ✔
522
        }
523

524

525
        /**
526
         * @param array<string, mixed> $value
527
         */
528
        private function processValues(array $value): string
529
        {
530
                if (count($value) === 0) {
6 ✔
531
                        return 'VALUES (DEFAULT)';
6 ✔
532
                }
533

534
                $keys = $values = [];
6 ✔
535
                foreach ($value as $_key => $val) {
6 ✔
536
                        $key = explode('%', $_key, 2);
6 ✔
537
                        $keys[] = $this->identifierToSql($key[0]);
6 ✔
538
                        $values[] = $this->processModifier($key[1] ?? 'any', $val);
6 ✔
539
                }
540

541
                return '(' . implode(', ', $keys) . ') VALUES (' . implode(', ', $values) . ')';
6 ✔
542
        }
543

544

545
        /**
546
         * Handles multiple condition formats for AND and OR operators.
547
         *
548
         * Key-based:
549
         * ```
550
         * $connection->query('%or', [
551
         *     'city' => 'Winterfell',
552
         *     'age%i[]' => [23, 25],
553
         * ]);
554
         * ```
555
         *
556
         * Auto-expanding:
557
         * ```
558
         * $connection->query('%or', [
559
         *     'city' => 'Winterfell',
560
         *     ['[age] IN %i[]', [23, 25]],
561
         * ]);
562
         * ```
563
         *
564
         * Fqn instsance-based:
565
         * ```
566
         * $connection->query('%or', [
567
         *     [new Fqn(schema: '', name: 'city'), 'Winterfell'],
568
         *     [new Fqn(schema: '', name: 'age'), [23, 25], '%i[]'],
569
         * ]);
570
         * ```
571
         *
572
         * @param array<int|string, mixed> $value
573
         */
574
        private function processWhere(string $type, array $value): string
575
        {
576
                $totalCount = \count($value);
6 ✔
577
                if ($totalCount === 0) {
6 ✔
578
                        return '1=1';
6 ✔
579
                }
580

581
                $operands = [];
6 ✔
582
                foreach ($value as $_key => $subValue) {
6 ✔
583
                        if (is_int($_key)) {
6 ✔
584
                                if (!is_array($subValue)) {
6 ✔
585
                                        $subValueType = $this->getVariableTypeName($subValue);
6 ✔
586
                                        throw new InvalidArgumentException("Modifier %$type requires items with numeric index to be array, $subValueType given.");
6 ✔
587
                                }
588

589
                                if (count($subValue) > 0 && ($subValue[0] ?? null) instanceof Fqn) {
6 ✔
590
                                        $column = $this->processModifier('column', $subValue[0]);
6 ✔
591
                                        $subType = substr($subValue[2] ?? '%any', 1);
6 ✔
592
                                        if ($subValue[1] === null) {
6 ✔
593
                                                $op = ' IS ';
×
594
                                        } elseif (is_array($subValue[1])) {
6 ✔
595
                                                $op = ' IN ';
×
596
                                        } else {
597
                                                $op = ' = ';
6 ✔
598
                                        }
599
                                        $operand = $column . $op . $this->processModifier($subType, $subValue[1]);
6 ✔
600
                                } else {
601
                                        if ($totalCount === 1) {
6 ✔
602
                                                $operand = $this->process($subValue);
6 ✔
603
                                        } else {
604
                                                $operand = '(' . $this->process($subValue) . ')';
6 ✔
605
                                        }
606
                                }
607

608
                        } else {
609
                                $key = explode('%', $_key, 2);
6 ✔
610
                                $column = $this->identifierToSql($key[0]);
6 ✔
611
                                $subType = $key[1] ?? 'any';
6 ✔
612
                                if ($subValue === null) {
6 ✔
613
                                        $op = ' IS ';
6 ✔
614
                                } elseif (is_array($subValue) && $subType !== 'ex') {
6 ✔
615
                                        $op = ' IN ';
6 ✔
616
                                } else {
617
                                        $op = ' = ';
6 ✔
618
                                }
619
                                $operand = $column . $op . $this->processModifier($subType, $subValue);
6 ✔
620
                        }
621

622
                        $operands[] = $operand;
6 ✔
623
                }
624

625
                return implode($type === 'and' ? ' AND ' : ' OR ', $operands);
6 ✔
626
        }
627

628

629
        /**
630
         * Handles multi-column conditions with multiple paired values.
631
         *
632
         * The implementation considers database support and if not available, delegates to {@see processWhere} and joins
633
         * the resulting SQLs with OR operator.
634
         *
635
         * Key-based:
636
         * ```
637
         * $connection->query('%multiOr', [
638
         *     ['tag_id%i' => 1, 'book_id' => 23],
639
         *     ['tag_id%i' => 4, 'book_id' => 12],
640
         *     ['tag_id%i' => 9, 'book_id' => 83],
641
         * ]);
642
         * ```
643
         *
644
         * Fqn instance-based:
645
         * ```
646
         * $connection->query('%multiOr', [
647
         *     [[new Fqn('tbl', 'tag_id'), 1, '%i'], [new Fqn('tbl', 'book_id'), 23]],
648
         *     [[new Fqn('tbl', 'tag_id'), 4, '%i'], [new Fqn('tbl', 'book_id'), 12]],
649
         *     [[new Fqn('tbl', 'tag_id'), 9, '%i'], [new Fqn('tbl', 'book_id'), 83]],
650
         * ]);
651
         * ```
652
         *
653
         * @param array<string, mixed>|list<list<array{Fqn, mixed, 2?: string}>> $values
654
         */
655
        private function processMultiColumnOr(array $values): string
656
        {
657
                if (!$this->platform->isSupported(IPlatform::SUPPORT_MULTI_COLUMN_IN)) {
6 ✔
658
                        $sqls = [];
6 ✔
659
                        foreach ($values as $value) {
6 ✔
660
                                $sqls[] = $this->processWhere('and', $value);
6 ✔
661
                        }
662
                        return '(' . implode(') OR (', $sqls) . ')';
6 ✔
663
                }
664

665
                // Detect Fqn instance-based variant
666
                $isFqnBased = ($values[0][0][0] ?? null) instanceof Fqn;
6 ✔
667
                if ($isFqnBased) {
6 ✔
668
                        $keys = [];
6 ✔
669
                        foreach ($values[0] as $triple) {
6 ✔
670
                                $keys[] = $this->processModifier('column', $triple[0]);
6 ✔
671
                        }
672
                        foreach ($values as &$subValue) {
6 ✔
673
                                foreach ($subValue as &$subSubValue) {
6 ✔
674
                                        $type = substr($subSubValue[2] ?? '%any', 1);
6 ✔
675
                                        $subSubValue = $this->processModifier($type, $subSubValue[1]);
6 ✔
676
                                }
677
                                $subValue = '(' . implode(', ', $subValue) . ')';
6 ✔
678
                        }
679
                        return '(' . implode(', ', $keys) . ') IN (' . implode(', ', $values) . ')';
6 ✔
680
                }
681

682
                $keys = [];
6 ✔
683
                $modifiers = [];
6 ✔
684
                foreach (array_keys(reset($values)) as $key) {
6 ✔
685
                        $exploded = explode('%', (string) $key, 2);
6 ✔
686
                        $keys[] = $this->identifierToSql($exploded[0]);
6 ✔
687
                        $modifiers[] = $exploded[1] ?? 'any';
6 ✔
688
                }
689
                foreach ($values as &$subValue) {
6 ✔
690
                        $i = 0;
6 ✔
691
                        foreach ($subValue as &$subSubValue) {
6 ✔
692
                                $subSubValue = $this->processModifier($modifiers[$i++], $subSubValue);
6 ✔
693
                        }
694
                        $subValue = '(' . implode(', ', $subValue) . ')';
6 ✔
695
                }
696
                return '(' . implode(', ', $keys) . ') IN (' . implode(', ', $values) . ')';
6 ✔
697
        }
698

699

700
        protected function getVariableTypeName(mixed $value): float|string
701
        {
702
                return is_object($value)
6 ✔
703
                        ? $value::class
6 ✔
704
                        : (is_float($value) && !is_finite($value)
6 ✔
705
                                ? (is_nan($value) ? "NAN" : $value)
6 ✔
706
                                : gettype($value));
6 ✔
707
        }
708

709

710
        protected function identifierToSql(string $key): string
711
        {
712
                return $this->identifiers[$key] ??
6 ✔
713
                        ($this->identifiers[$key] = // = intentionally
6 ✔
714
                                str_ends_with($key, '.*')
6 ✔
715
                                        ? $this->platform->formatIdentifier(substr($key, 0, -2)) . '.*'
6 ✔
716
                                        : $this->platform->formatIdentifier($key)
6 ✔
717
                        );
718
        }
719
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc