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

codeigniter4 / CodeIgniter4 / 25429208079

06 May 2026 10:11AM UTC coverage: 88.321% (+0.03%) from 88.287%
25429208079

Pull #10140

github

web-flow
Merge 8bad4ea02 into 3f912495d
Pull Request #10140: feat: Add `incrementMany()` and `decrementMany()` methods to BaseBuilder and other driver specific builders

94 of 96 new or added lines in 3 files covered. (97.92%)

2 existing lines in 1 file now uncovered.

23602 of 26723 relevant lines covered (88.32%)

218.37 hits per line

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

94.24
/system/Database/BaseBuilder.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter 4 framework.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace CodeIgniter\Database;
15

16
use Closure;
17
use CodeIgniter\Database\Exceptions\DatabaseException;
18
use CodeIgniter\Database\Exceptions\DataException;
19
use CodeIgniter\Exceptions\InvalidArgumentException;
20
use CodeIgniter\Traits\ConditionalTrait;
21
use Config\Feature;
22
use TypeError;
23

24
/**
25
 * Class BaseBuilder
26
 *
27
 * Provides the core Query Builder methods.
28
 * Database-specific Builders might need to override
29
 * certain methods to make them work.
30
 */
31
class BaseBuilder
32
{
33
    use ConditionalTrait;
34

35
    /**
36
     * Reset DELETE data flag
37
     *
38
     * @var bool
39
     */
40
    protected $resetDeleteData = false;
41

42
    /**
43
     * QB SELECT data
44
     *
45
     * @var list<string>
46
     */
47
    protected $QBSelect = [];
48

49
    /**
50
     * QB DISTINCT flag
51
     *
52
     * @var bool
53
     */
54
    protected $QBDistinct = false;
55

56
    /**
57
     * QB FROM data
58
     *
59
     * @var array
60
     */
61
    protected $QBFrom = [];
62

63
    /**
64
     * QB JOIN data
65
     *
66
     * @var array
67
     */
68
    protected $QBJoin = [];
69

70
    /**
71
     * QB WHERE data
72
     *
73
     * @var array
74
     */
75
    protected $QBWhere = [];
76

77
    /**
78
     * QB GROUP BY data
79
     *
80
     * @var array
81
     */
82
    public $QBGroupBy = [];
83

84
    /**
85
     * QB HAVING data
86
     *
87
     * @var array
88
     */
89
    protected $QBHaving = [];
90

91
    /**
92
     * QB keys
93
     * list of column names.
94
     *
95
     * @var list<string>
96
     */
97
    protected $QBKeys = [];
98

99
    /**
100
     * QB LIMIT data
101
     *
102
     * @var bool|int
103
     */
104
    protected $QBLimit = false;
105

106
    /**
107
     * QB OFFSET data
108
     *
109
     * @var bool|int
110
     */
111
    protected $QBOffset = false;
112

113
    /**
114
     * QB ORDER BY data
115
     *
116
     * @var array|string|null
117
     */
118
    public $QBOrderBy = [];
119

120
    /**
121
     * QB UNION data
122
     *
123
     * @var list<string>
124
     */
125
    protected array $QBUnion = [];
126

127
    /**
128
     * Whether to protect identifiers in SELECT
129
     *
130
     * @var list<bool|null> true=protect, false=not protect
131
     */
132
    public $QBNoEscape = [];
133

134
    /**
135
     * QB data sets
136
     *
137
     * @var array<string, string>|list<list<int|string>>
138
     */
139
    protected $QBSet = [];
140

141
    /**
142
     * QB WHERE group started flag
143
     *
144
     * @var bool
145
     */
146
    protected $QBWhereGroupStarted = false;
147

148
    /**
149
     * QB WHERE group count
150
     *
151
     * @var int
152
     */
153
    protected $QBWhereGroupCount = 0;
154

155
    /**
156
     * Ignore data that cause certain
157
     * exceptions, for example in case of
158
     * duplicate keys.
159
     *
160
     * @var bool
161
     */
162
    protected $QBIgnore = false;
163

164
    /**
165
     * QB Options data
166
     * Holds additional options and data used to render SQL
167
     * and is reset by resetWrite()
168
     *
169
     * @var array{
170
     *   updateFieldsAdditional?: array,
171
     *   tableIdentity?: string,
172
     *   updateFields?: array,
173
     *   constraints?: array,
174
     *   setQueryAsData?: string,
175
     *   sql?: string,
176
     *   alias?: string,
177
     *   fieldTypes?: array<string, array<string, string>>
178
     * }
179
     *
180
     * fieldTypes: [ProtectedTableName => [FieldName => Type]]
181
     */
182
    protected $QBOptions;
183

184
    /**
185
     * A reference to the database connection.
186
     *
187
     * @var BaseConnection
188
     */
189
    protected $db;
190

191
    /**
192
     * Name of the primary table for this instance.
193
     * Tracked separately because $QBFrom gets escaped
194
     * and prefixed.
195
     *
196
     * When $tableName to the constructor has multiple tables,
197
     * the value is empty string.
198
     *
199
     * @var string
200
     */
201
    protected $tableName;
202

203
    /**
204
     * ORDER BY random keyword
205
     *
206
     * @var array
207
     */
208
    protected $randomKeyword = [
209
        'RAND()',
210
        'RAND(%d)',
211
    ];
212

213
    /**
214
     * COUNT string
215
     *
216
     * @used-by CI_DB_driver::count_all()
217
     * @used-by BaseBuilder::count_all_results()
218
     *
219
     * @var string
220
     */
221
    protected $countString = 'SELECT COUNT(*) AS ';
222

223
    /**
224
     * Collects the named parameters and
225
     * their values for later binding
226
     * in the Query object.
227
     *
228
     * @var array
229
     */
230
    protected $binds = [];
231

232
    /**
233
     * Collects the key count for named parameters
234
     * in the Query object.
235
     *
236
     * @var array
237
     */
238
    protected $bindsKeyCount = [];
239

240
    /**
241
     * Some databases, like SQLite, do not by default
242
     * allow limiting of delete clauses.
243
     *
244
     * @var bool
245
     */
246
    protected $canLimitDeletes = true;
247

248
    /**
249
     * Some databases do not by default
250
     * allow limit update queries with WHERE.
251
     *
252
     * @var bool
253
     */
254
    protected $canLimitWhereUpdates = true;
255

256
    /**
257
     * Specifies which sql statements
258
     * support the ignore option.
259
     *
260
     * @var array<string, string>
261
     */
262
    protected $supportedIgnoreStatements = [];
263

264
    /**
265
     * Builder testing mode status.
266
     *
267
     * @var bool
268
     */
269
    protected $testMode = false;
270

271
    /**
272
     * Tables relation types
273
     *
274
     * @var array
275
     */
276
    protected $joinTypes = [
277
        'LEFT',
278
        'RIGHT',
279
        'OUTER',
280
        'INNER',
281
        'LEFT OUTER',
282
        'RIGHT OUTER',
283
    ];
284

285
    /**
286
     * Strings that determine if a string represents a literal value or a field name
287
     *
288
     * @var list<string>
289
     */
290
    protected $isLiteralStr = [];
291

292
    /**
293
     * RegExp used to get operators
294
     *
295
     * @var list<string>
296
     */
297
    protected $pregOperators = [];
298

299
    /**
300
     * Constructor
301
     *
302
     * @param array|string|TableName $tableName tablename or tablenames with or without aliases
303
     *
304
     * Examples of $tableName: `mytable`, `jobs j`, `jobs j, users u`, `['jobs j','users u']`
305
     *
306
     * @throws DatabaseException
307
     */
308
    public function __construct($tableName, ConnectionInterface $db, ?array $options = null)
309
    {
310
        if (empty($tableName)) {
1,122✔
311
            throw new DatabaseException('A table must be specified when creating a new Query Builder.');
×
312
        }
313

314
        /** @var BaseConnection $db */
315
        $this->db = $db;
1,122✔
316

317
        if ($tableName instanceof TableName) {
1,122✔
318
            $this->tableName = $tableName->getTableName();
7✔
319
            $this->QBFrom[]  = $this->db->escapeIdentifier($tableName);
7✔
320
            $this->db->addTableAlias($tableName->getAlias());
7✔
321
        }
322
        // If it contains `,`, it has multiple tables
323
        elseif (is_string($tableName) && ! str_contains($tableName, ',')) {
1,118✔
324
            $this->tableName = $tableName;  // @TODO remove alias if exists
1,116✔
325
            $this->from($tableName);
1,116✔
326
        } else {
327
            $this->tableName = '';
16✔
328
            $this->from($tableName);
16✔
329
        }
330

331
        if ($options !== null && $options !== []) {
1,122✔
332
            foreach ($options as $key => $value) {
×
333
                if (property_exists($this, $key)) {
×
334
                    $this->{$key} = $value;
×
335
                }
336
            }
337
        }
338
    }
339

340
    /**
341
     * Returns the current database connection
342
     *
343
     * @return BaseConnection
344
     */
345
    public function db(): ConnectionInterface
346
    {
347
        return $this->db;
1✔
348
    }
349

350
    /**
351
     * Sets a test mode status.
352
     *
353
     * @return $this
354
     */
355
    public function testMode(bool $mode = true)
356
    {
357
        $this->testMode = $mode;
80✔
358

359
        return $this;
80✔
360
    }
361

362
    /**
363
     * Gets the name of the primary table.
364
     */
365
    public function getTable(): string
366
    {
367
        return $this->tableName;
5✔
368
    }
369

370
    /**
371
     * Returns an array of bind values and their
372
     * named parameters for binding in the Query object later.
373
     */
374
    public function getBinds(): array
375
    {
376
        return $this->binds;
44✔
377
    }
378

379
    /**
380
     * Ignore
381
     *
382
     * Set ignore Flag for next insert,
383
     * update or delete query.
384
     *
385
     * @return $this
386
     */
387
    public function ignore(bool $ignore = true)
388
    {
389
        $this->QBIgnore = $ignore;
1✔
390

391
        return $this;
1✔
392
    }
393

394
    /**
395
     * Generates the SELECT portion of the query
396
     *
397
     * @param list<RawSql|string>|RawSql|string $select
398
     * @param bool|null                         $escape Whether to protect identifiers
399
     *
400
     * @return $this
401
     */
402
    public function select($select = '*', ?bool $escape = null)
403
    {
404
        // If the escape value was not set, we will base it on the global setting
405
        if (! is_bool($escape)) {
908✔
406
            $escape = $this->db->protectIdentifiers;
905✔
407
        }
408

409
        if ($select instanceof RawSql) {
908✔
410
            $select = [$select];
1✔
411
        }
412

413
        if (is_string($select)) {
908✔
414
            $select = ($escape === false) ? [$select] : explode(',', $select);
901✔
415
        }
416

417
        foreach ($select as $val) {
908✔
418
            if ($val instanceof RawSql) {
908✔
419
                $this->QBSelect[]   = $val;
5✔
420
                $this->QBNoEscape[] = false;
5✔
421

422
                continue;
5✔
423
            }
424

425
            $val = trim($val);
906✔
426

427
            if ($val !== '') {
906✔
428
                $this->QBSelect[] = $val;
906✔
429

430
                /*
431
                 * When doing 'SELECT NULL as field_alias FROM table'
432
                 * null gets taken as a field, and therefore escaped
433
                 * with backticks.
434
                 * This prevents NULL being escaped
435
                 * @see https://github.com/codeigniter4/CodeIgniter4/issues/1169
436
                 */
437
                if (mb_stripos($val, 'NULL') === 0) {
906✔
438
                    $this->QBNoEscape[] = false;
2✔
439

440
                    continue;
2✔
441
                }
442

443
                $this->QBNoEscape[] = $escape;
906✔
444
            }
445
        }
446

447
        return $this;
908✔
448
    }
449

450
    /**
451
     * Generates a SELECT MAX(field) portion of a query
452
     *
453
     * @return $this
454
     */
455
    public function selectMax(string $select = '', string $alias = '')
456
    {
457
        return $this->maxMinAvgSum($select, $alias);
812✔
458
    }
459

460
    /**
461
     * Generates a SELECT MIN(field) portion of a query
462
     *
463
     * @return $this
464
     */
465
    public function selectMin(string $select = '', string $alias = '')
466
    {
467
        return $this->maxMinAvgSum($select, $alias, 'MIN');
4✔
468
    }
469

470
    /**
471
     * Generates a SELECT AVG(field) portion of a query
472
     *
473
     * @return $this
474
     */
475
    public function selectAvg(string $select = '', string $alias = '')
476
    {
477
        return $this->maxMinAvgSum($select, $alias, 'AVG');
4✔
478
    }
479

480
    /**
481
     * Generates a SELECT SUM(field) portion of a query
482
     *
483
     * @return $this
484
     */
485
    public function selectSum(string $select = '', string $alias = '')
486
    {
487
        return $this->maxMinAvgSum($select, $alias, 'SUM');
6✔
488
    }
489

490
    /**
491
     * Generates a SELECT COUNT(field) portion of a query
492
     *
493
     * @return $this
494
     */
495
    public function selectCount(string $select = '', string $alias = '')
496
    {
497
        return $this->maxMinAvgSum($select, $alias, 'COUNT');
5✔
498
    }
499

500
    /**
501
     * Adds a subquery to the selection
502
     */
503
    public function selectSubquery(BaseBuilder $subquery, string $as): self
504
    {
505
        $this->QBSelect[] = $this->buildSubquery($subquery, true, $as);
2✔
506

507
        return $this;
2✔
508
    }
509

510
    /**
511
     * SELECT [MAX|MIN|AVG|SUM|COUNT]()
512
     *
513
     * @used-by selectMax()
514
     * @used-by selectMin()
515
     * @used-by selectAvg()
516
     * @used-by selectSum()
517
     *
518
     * @return $this
519
     *
520
     * @throws DatabaseException
521
     * @throws DataException
522
     */
523
    protected function maxMinAvgSum(string $select = '', string $alias = '', string $type = 'MAX')
524
    {
525
        if ($select === '') {
822✔
526
            throw DataException::forEmptyInputGiven('Select');
1✔
527
        }
528

529
        if (str_contains($select, ',')) {
821✔
530
            throw DataException::forInvalidArgument('column name not separated by comma');
1✔
531
        }
532

533
        $type = strtoupper($type);
820✔
534

535
        if (! in_array($type, ['MAX', 'MIN', 'AVG', 'SUM', 'COUNT'], true)) {
820✔
536
            throw new DatabaseException('Invalid function type: ' . $type);
×
537
        }
538

539
        if ($alias === '') {
820✔
540
            $alias = $this->createAliasFromTable(trim($select));
815✔
541
        }
542

543
        $sql = $type . '(' . $this->db->protectIdentifiers(trim($select)) . ') AS ' . $this->db->escapeIdentifiers(trim($alias));
820✔
544

545
        $this->QBSelect[]   = $sql;
820✔
546
        $this->QBNoEscape[] = null;
820✔
547

548
        return $this;
820✔
549
    }
550

551
    /**
552
     * Determines the alias name based on the table
553
     */
554
    protected function createAliasFromTable(string $item): string
555
    {
556
        if (str_contains($item, '.')) {
815✔
557
            $item = explode('.', $item);
1✔
558

559
            return end($item);
1✔
560
        }
561

562
        return $item;
814✔
563
    }
564

565
    /**
566
     * Sets a flag which tells the query string compiler to add DISTINCT
567
     *
568
     * @return $this
569
     */
570
    public function distinct(bool $val = true)
571
    {
572
        $this->QBDistinct = $val;
818✔
573

574
        return $this;
818✔
575
    }
576

577
    /**
578
     * Generates the FROM portion of the query
579
     *
580
     * @param array|string $from
581
     *
582
     * @return $this
583
     */
584
    public function from($from, bool $overwrite = false): self
585
    {
586
        if ($overwrite) {
1,122✔
587
            $this->QBFrom = [];
1,058✔
588
            $this->db->setAliasedTables([]);
1,058✔
589
        }
590

591
        foreach ((array) $from as $table) {
1,122✔
592
            if (str_contains($table, ',')) {
1,122✔
593
                $this->from(explode(',', $table));
17✔
594
            } else {
595
                $table = trim($table);
1,122✔
596

597
                if ($table === '') {
1,122✔
598
                    continue;
14✔
599
                }
600

601
                $this->trackAliases($table);
1,122✔
602
                $this->QBFrom[] = $this->db->protectIdentifiers($table, true, null, false);
1,122✔
603
            }
604
        }
605

606
        return $this;
1,122✔
607
    }
608

609
    /**
610
     * @param BaseBuilder $from  Expected subquery
611
     * @param string      $alias Subquery alias
612
     *
613
     * @return $this
614
     */
615
    public function fromSubquery(BaseBuilder $from, string $alias): self
616
    {
617
        $table = $this->buildSubquery($from, true, $alias);
5✔
618

619
        $this->db->addTableAlias($alias);
4✔
620
        $this->QBFrom[] = $table;
4✔
621

622
        return $this;
4✔
623
    }
624

625
    /**
626
     * Generates the JOIN portion of the query
627
     *
628
     * @param RawSql|string $cond
629
     *
630
     * @return $this
631
     */
632
    public function join(string $table, $cond, string $type = '', ?bool $escape = null)
633
    {
634
        if ($type !== '') {
15✔
635
            $type = strtoupper(trim($type));
8✔
636

637
            if (! in_array($type, $this->joinTypes, true)) {
8✔
638
                $type = '';
×
639
            } else {
640
                $type .= ' ';
8✔
641
            }
642
        }
643

644
        // Extract any aliases that might exist. We use this information
645
        // in the protectIdentifiers to know whether to add a table prefix
646
        $this->trackAliases($table);
15✔
647

648
        if (! is_bool($escape)) {
15✔
649
            $escape = $this->db->protectIdentifiers;
15✔
650
        }
651

652
        // Do we want to escape the table name?
653
        if ($escape === true) {
15✔
654
            $table = $this->db->protectIdentifiers($table, true, null, false);
15✔
655
        }
656

657
        if ($cond instanceof RawSql) {
15✔
658
            $this->QBJoin[] = $type . 'JOIN ' . $table . ' ON ' . $cond;
1✔
659

660
            return $this;
1✔
661
        }
662

663
        if (! $this->hasOperator($cond)) {
14✔
664
            $cond = ' USING (' . ($escape ? $this->db->escapeIdentifiers($cond) : $cond) . ')';
×
665
        } elseif ($escape === false) {
14✔
666
            $cond = ' ON ' . $cond;
×
667
        } else {
668
            // Split multiple conditions
669
            // @TODO This does not parse `BETWEEN a AND b` correctly.
670
            if (preg_match_all('/\sAND\s|\sOR\s/i', $cond, $joints, PREG_OFFSET_CAPTURE) >= 1) {
14✔
671
                $conditions = [];
2✔
672
                $joints     = $joints[0];
2✔
673
                array_unshift($joints, ['', 0]);
2✔
674

675
                for ($i = count($joints) - 1, $pos = strlen($cond); $i >= 0; $i--) {
2✔
676
                    $joints[$i][1] += strlen($joints[$i][0]); // offset
2✔
677
                    $conditions[$i] = substr($cond, $joints[$i][1], $pos - $joints[$i][1]);
2✔
678
                    $pos            = $joints[$i][1] - strlen($joints[$i][0]);
2✔
679
                    $joints[$i]     = $joints[$i][0];
2✔
680
                }
681
                ksort($conditions);
2✔
682
            } else {
683
                $conditions = [$cond];
12✔
684
                $joints     = [''];
12✔
685
            }
686

687
            $cond = ' ON ';
14✔
688

689
            foreach ($conditions as $i => $condition) {
14✔
690
                $operator = $this->getOperator($condition);
14✔
691

692
                // Workaround for BETWEEN
693
                if ($operator === false) {
14✔
694
                    $cond .= $joints[$i] . $condition;
1✔
695

696
                    continue;
1✔
697
                }
698

699
                $cond .= $joints[$i];
14✔
700
                $cond .= preg_match('/(\(*)?([\[\]\w\.\'-]+)' . preg_quote($operator, '/') . '(.*)/i', $condition, $match) ? $match[1] . $this->db->protectIdentifiers($match[2]) . $operator . $this->db->protectIdentifiers($match[3]) : $condition;
14✔
701
            }
702
        }
703

704
        // Assemble the JOIN statement
705
        $this->QBJoin[] = $type . 'JOIN ' . $table . $cond;
14✔
706

707
        return $this;
14✔
708
    }
709

710
    /**
711
     * Generates the WHERE portion of the query.
712
     * Separates multiple calls with 'AND'.
713
     *
714
     * @param array|RawSql|string $key
715
     * @param mixed               $value
716
     *
717
     * @return $this
718
     */
719
    public function where($key, $value = null, ?bool $escape = null)
720
    {
721
        return $this->whereHaving('QBWhere', $key, $value, 'AND ', $escape);
924✔
722
    }
723

724
    /**
725
     * OR WHERE
726
     *
727
     * Generates the WHERE portion of the query.
728
     * Separates multiple calls with 'OR'.
729
     *
730
     * @param array|RawSql|string $key
731
     * @param mixed               $value
732
     *
733
     * @return $this
734
     */
735
    public function orWhere($key, $value = null, ?bool $escape = null)
736
    {
737
        return $this->whereHaving('QBWhere', $key, $value, 'OR ', $escape);
5✔
738
    }
739

740
    /**
741
     * @used-by where()
742
     * @used-by orWhere()
743
     * @used-by having()
744
     * @used-by orHaving()
745
     *
746
     * @param array|RawSql|string $key
747
     * @param mixed               $value
748
     *
749
     * @return $this
750
     */
751
    protected function whereHaving(string $qbKey, $key, $value = null, string $type = 'AND ', ?bool $escape = null)
752
    {
753
        $rawSqlOnly = false;
930✔
754

755
        if ($key instanceof RawSql) {
930✔
756
            if ($value === null) {
4✔
757
                $keyValue   = [(string) $key => $key];
1✔
758
                $rawSqlOnly = true;
1✔
759
            } else {
760
                $keyValue = [(string) $key => $value];
3✔
761
            }
762
        } elseif (! is_array($key)) {
926✔
763
            $keyValue = [$key => $value];
916✔
764
        } else {
765
            $keyValue = $key;
237✔
766
        }
767

768
        // If the escape value was not set will base it on the global setting
769
        if (! is_bool($escape)) {
930✔
770
            $escape = $this->db->protectIdentifiers;
926✔
771
        }
772

773
        foreach ($keyValue as $k => $v) {
930✔
774
            $prefix = empty($this->{$qbKey}) ? $this->groupGetType('') : $this->groupGetType($type);
930✔
775

776
            if ($rawSqlOnly) {
930✔
777
                $k  = '';
1✔
778
                $op = '';
1✔
779
            } elseif ($v !== null) {
929✔
780
                $op = $this->getOperatorFromWhereKey($k);
909✔
781

782
                if (! empty($op)) {
909✔
783
                    $k = trim($k);
49✔
784

785
                    end($op);
49✔
786
                    $op = trim(current($op));
49✔
787

788
                    // Does the key end with operator?
789
                    if (str_ends_with($k, $op)) {
49✔
790
                        $k  = rtrim(substr($k, 0, -strlen($op)));
49✔
791
                        $op = " {$op}";
49✔
792
                    } else {
793
                        $op = '';
×
794
                    }
795
                } else {
796
                    $op = ' =';
897✔
797
                }
798

799
                if ($this->isSubquery($v)) {
909✔
800
                    $v = $this->buildSubquery($v, true);
1✔
801
                } else {
802
                    $bind = $this->setBind($k, $v, $escape);
909✔
803
                    $v    = " :{$bind}:";
909✔
804
                }
805
            } elseif (! $this->hasOperator($k) && $qbKey !== 'QBHaving') {
147✔
806
                // value appears not to have been set, assign the test to IS NULL
807
                $op = ' IS NULL';
95✔
808
            } elseif (
809
                // The key ends with !=, =, <>, IS, IS NOT
810
                preg_match(
61✔
811
                    '/\s*(!?=|<>|IS(?:\s+NOT)?)\s*$/i',
61✔
812
                    $k,
61✔
813
                    $match,
61✔
814
                    PREG_OFFSET_CAPTURE,
61✔
815
                )
61✔
816
            ) {
817
                $k  = substr($k, 0, $match[0][1]);
1✔
818
                $op = $match[1][0] === '=' ? ' IS NULL' : ' IS NOT NULL';
1✔
819
            } else {
820
                $op = '';
60✔
821
            }
822

823
            if ($v instanceof RawSql) {
930✔
824
                $this->{$qbKey}[] = [
1✔
825
                    'condition' => $v->with($prefix . $k . $op . $v),
1✔
826
                    'escape'    => $escape,
1✔
827
                ];
1✔
828
            } else {
829
                $this->{$qbKey}[] = [
929✔
830
                    'condition' => $prefix . $k . $op . $v,
929✔
831
                    'escape'    => $escape,
929✔
832
                ];
929✔
833
            }
834
        }
835

836
        return $this;
930✔
837
    }
838

839
    /**
840
     * Generates a WHERE field IN('item', 'item') SQL query,
841
     * joined with 'AND' if appropriate.
842
     *
843
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
844
     *
845
     * @return $this
846
     */
847
    public function whereIn(?string $key = null, $values = null, ?bool $escape = null)
848
    {
849
        return $this->_whereIn($key, $values, false, 'AND ', $escape);
66✔
850
    }
851

852
    /**
853
     * Generates a WHERE field IN('item', 'item') SQL query,
854
     * joined with 'OR' if appropriate.
855
     *
856
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
857
     *
858
     * @return $this
859
     */
860
    public function orWhereIn(?string $key = null, $values = null, ?bool $escape = null)
861
    {
862
        return $this->_whereIn($key, $values, false, 'OR ', $escape);
3✔
863
    }
864

865
    /**
866
     * Generates a WHERE field NOT IN('item', 'item') SQL query,
867
     * joined with 'AND' if appropriate.
868
     *
869
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
870
     *
871
     * @return $this
872
     */
873
    public function whereNotIn(?string $key = null, $values = null, ?bool $escape = null)
874
    {
875
        return $this->_whereIn($key, $values, true, 'AND ', $escape);
3✔
876
    }
877

878
    /**
879
     * Generates a WHERE field NOT IN('item', 'item') SQL query,
880
     * joined with 'OR' if appropriate.
881
     *
882
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
883
     *
884
     * @return $this
885
     */
886
    public function orWhereNotIn(?string $key = null, $values = null, ?bool $escape = null)
887
    {
888
        return $this->_whereIn($key, $values, true, 'OR ', $escape);
2✔
889
    }
890

891
    /**
892
     * Generates a HAVING field IN('item', 'item') SQL query,
893
     * joined with 'AND' if appropriate.
894
     *
895
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
896
     *
897
     * @return $this
898
     */
899
    public function havingIn(?string $key = null, $values = null, ?bool $escape = null)
900
    {
901
        return $this->_whereIn($key, $values, false, 'AND ', $escape, 'QBHaving');
6✔
902
    }
903

904
    /**
905
     * Generates a HAVING field IN('item', 'item') SQL query,
906
     * joined with 'OR' if appropriate.
907
     *
908
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
909
     *
910
     * @return $this
911
     */
912
    public function orHavingIn(?string $key = null, $values = null, ?bool $escape = null)
913
    {
914
        return $this->_whereIn($key, $values, false, 'OR ', $escape, 'QBHaving');
3✔
915
    }
916

917
    /**
918
     * Generates a HAVING field NOT IN('item', 'item') SQL query,
919
     * joined with 'AND' if appropriate.
920
     *
921
     * @param array|BaseBuilder|(Closure(BaseBuilder):BaseBuilder)|null $values The values searched on, or anonymous function with subquery
922
     *
923
     * @return $this
924
     */
925
    public function havingNotIn(?string $key = null, $values = null, ?bool $escape = null)
926
    {
927
        return $this->_whereIn($key, $values, true, 'AND ', $escape, 'QBHaving');
5✔
928
    }
929

930
    /**
931
     * Generates a HAVING field NOT IN('item', 'item') SQL query,
932
     * joined with 'OR' if appropriate.
933
     *
934
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
935
     *
936
     * @return $this
937
     */
938
    public function orHavingNotIn(?string $key = null, $values = null, ?bool $escape = null)
939
    {
940
        return $this->_whereIn($key, $values, true, 'OR ', $escape, 'QBHaving');
3✔
941
    }
942

943
    /**
944
     * @used-by WhereIn()
945
     * @used-by orWhereIn()
946
     * @used-by whereNotIn()
947
     * @used-by orWhereNotIn()
948
     *
949
     * @param non-empty-string|null                                            $key
950
     * @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|list<mixed>|null $values The values searched on, or anonymous function with subquery
951
     *
952
     * @return $this
953
     *
954
     * @throws InvalidArgumentException
955
     */
956
    protected function _whereIn(?string $key = null, $values = null, bool $not = false, string $type = 'AND ', ?bool $escape = null, string $clause = 'QBWhere')
957
    {
958
        if ($key === null || $key === '') {
86✔
959
            throw new InvalidArgumentException(sprintf('%s() expects $key to be a non-empty string', debug_backtrace(0, 2)[1]['function']));
2✔
960
        }
961

962
        if ($values === null || (! is_array($values) && ! $this->isSubquery($values))) {
84✔
963
            throw new InvalidArgumentException(sprintf('%s() expects $values to be of type array or closure', debug_backtrace(0, 2)[1]['function']));
3✔
964
        }
965

966
        if (! is_bool($escape)) {
81✔
967
            $escape = $this->db->protectIdentifiers;
81✔
968
        }
969

970
        $ok = $key;
81✔
971

972
        if ($escape === true) {
81✔
973
            $key = $this->db->protectIdentifiers($key);
81✔
974
        }
975

976
        $not = ($not) ? ' NOT' : '';
81✔
977

978
        if ($this->isSubquery($values)) {
81✔
979
            $whereIn = $this->buildSubquery($values, true);
8✔
980
            $escape  = false;
8✔
981
        } else {
982
            $whereIn = array_values($values);
73✔
983
        }
984

985
        $ok = $this->setBind($ok, $whereIn, $escape);
81✔
986

987
        $prefix = empty($this->{$clause}) ? $this->groupGetType('') : $this->groupGetType($type);
81✔
988

989
        $whereIn = [
81✔
990
            'condition' => "{$prefix}{$key}{$not} IN :{$ok}:",
81✔
991
            'escape'    => false,
81✔
992
        ];
81✔
993

994
        $this->{$clause}[] = $whereIn;
81✔
995

996
        return $this;
81✔
997
    }
998

999
    /**
1000
     * Generates a %LIKE% portion of the query.
1001
     * Separates multiple calls with 'AND'.
1002
     *
1003
     * @param array|RawSql|string $field
1004
     *
1005
     * @return $this
1006
     */
1007
    public function like($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1008
    {
1009
        return $this->_like($field, $match, 'AND ', $side, '', $escape, $insensitiveSearch);
25✔
1010
    }
1011

1012
    /**
1013
     * Generates a NOT LIKE portion of the query.
1014
     * Separates multiple calls with 'AND'.
1015
     *
1016
     * @param array|RawSql|string $field
1017
     *
1018
     * @return $this
1019
     */
1020
    public function notLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1021
    {
1022
        return $this->_like($field, $match, 'AND ', $side, 'NOT', $escape, $insensitiveSearch);
2✔
1023
    }
1024

1025
    /**
1026
     * Generates a %LIKE% portion of the query.
1027
     * Separates multiple calls with 'OR'.
1028
     *
1029
     * @param array|RawSql|string $field
1030
     *
1031
     * @return $this
1032
     */
1033
    public function orLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1034
    {
1035
        return $this->_like($field, $match, 'OR ', $side, '', $escape, $insensitiveSearch);
2✔
1036
    }
1037

1038
    /**
1039
     * Generates a NOT LIKE portion of the query.
1040
     * Separates multiple calls with 'OR'.
1041
     *
1042
     * @param array|RawSql|string $field
1043
     *
1044
     * @return $this
1045
     */
1046
    public function orNotLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1047
    {
1048
        return $this->_like($field, $match, 'OR ', $side, 'NOT', $escape, $insensitiveSearch);
2✔
1049
    }
1050

1051
    /**
1052
     * Generates a %LIKE% portion of the query.
1053
     * Separates multiple calls with 'AND'.
1054
     *
1055
     * @param array|RawSql|string $field
1056
     *
1057
     * @return $this
1058
     */
1059
    public function havingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1060
    {
1061
        return $this->_like($field, $match, 'AND ', $side, '', $escape, $insensitiveSearch, 'QBHaving');
15✔
1062
    }
1063

1064
    /**
1065
     * Generates a NOT LIKE portion of the query.
1066
     * Separates multiple calls with 'AND'.
1067
     *
1068
     * @param array|RawSql|string $field
1069
     *
1070
     * @return $this
1071
     */
1072
    public function notHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1073
    {
1074
        return $this->_like($field, $match, 'AND ', $side, 'NOT', $escape, $insensitiveSearch, 'QBHaving');
4✔
1075
    }
1076

1077
    /**
1078
     * Generates a %LIKE% portion of the query.
1079
     * Separates multiple calls with 'OR'.
1080
     *
1081
     * @param array|RawSql|string $field
1082
     *
1083
     * @return $this
1084
     */
1085
    public function orHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1086
    {
1087
        return $this->_like($field, $match, 'OR ', $side, '', $escape, $insensitiveSearch, 'QBHaving');
4✔
1088
    }
1089

1090
    /**
1091
     * Generates a NOT LIKE portion of the query.
1092
     * Separates multiple calls with 'OR'.
1093
     *
1094
     * @param array|RawSql|string $field
1095
     *
1096
     * @return $this
1097
     */
1098
    public function orNotHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1099
    {
1100
        return $this->_like($field, $match, 'OR ', $side, 'NOT', $escape, $insensitiveSearch, 'QBHaving');
4✔
1101
    }
1102

1103
    /**
1104
     * @used-by like()
1105
     * @used-by orLike()
1106
     * @used-by notLike()
1107
     * @used-by orNotLike()
1108
     * @used-by havingLike()
1109
     * @used-by orHavingLike()
1110
     * @used-by notHavingLike()
1111
     * @used-by orNotHavingLike()
1112
     *
1113
     * @param array<string, string>|RawSql|string $field
1114
     *
1115
     * @return $this
1116
     */
1117
    protected function _like($field, string $match = '', string $type = 'AND ', string $side = 'both', string $not = '', ?bool $escape = null, bool $insensitiveSearch = false, string $clause = 'QBWhere')
1118
    {
1119
        $escape = is_bool($escape) ? $escape : $this->db->protectIdentifiers;
47✔
1120
        $side   = strtolower($side);
47✔
1121

1122
        if ($field instanceof RawSql) {
47✔
1123
            $k                 = (string) $field;
3✔
1124
            $v                 = $match;
3✔
1125
            $insensitiveSearch = false;
3✔
1126

1127
            $prefix = empty($this->{$clause}) ? $this->groupGetType('') : $this->groupGetType($type);
3✔
1128

1129
            if ($side === 'none') {
3✔
1130
                $bind = $this->setBind($field->getBindingKey(), $v, $escape);
×
1131
            } elseif ($side === 'before') {
3✔
1132
                $bind = $this->setBind($field->getBindingKey(), "%{$v}", $escape);
×
1133
            } elseif ($side === 'after') {
3✔
1134
                $bind = $this->setBind($field->getBindingKey(), "{$v}%", $escape);
×
1135
            } else {
1136
                $bind = $this->setBind($field->getBindingKey(), "%{$v}%", $escape);
3✔
1137
            }
1138

1139
            $likeStatement = $this->_like_statement($prefix, $k, $not, $bind, $insensitiveSearch);
3✔
1140

1141
            // some platforms require an escape sequence definition for LIKE wildcards
1142
            if ($escape === true && $this->db->likeEscapeStr !== '') {
3✔
1143
                $likeStatement .= sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar);
3✔
1144
            }
1145

1146
            $this->{$clause}[] = [
3✔
1147
                'condition' => $field->with($likeStatement),
3✔
1148
                'escape'    => $escape,
3✔
1149
            ];
3✔
1150

1151
            return $this;
3✔
1152
        }
1153

1154
        $keyValue = is_array($field) ? $field : [$field => $match];
44✔
1155

1156
        foreach ($keyValue as $k => $v) {
44✔
1157
            if ($insensitiveSearch) {
44✔
1158
                $v = mb_strtolower($v, 'UTF-8');
7✔
1159
            }
1160

1161
            $prefix = empty($this->{$clause}) ? $this->groupGetType('') : $this->groupGetType($type);
44✔
1162

1163
            if ($side === 'none') {
44✔
1164
                $bind = $this->setBind($k, $v, $escape);
1✔
1165
            } elseif ($side === 'before') {
43✔
1166
                $bind = $this->setBind($k, "%{$v}", $escape);
9✔
1167
            } elseif ($side === 'after') {
34✔
1168
                $bind = $this->setBind($k, "{$v}%", $escape);
5✔
1169
            } else {
1170
                $bind = $this->setBind($k, "%{$v}%", $escape);
29✔
1171
            }
1172

1173
            $likeStatement = $this->_like_statement($prefix, $k, $not, $bind, $insensitiveSearch);
44✔
1174

1175
            // some platforms require an escape sequence definition for LIKE wildcards
1176
            if ($escape === true && $this->db->likeEscapeStr !== '') {
44✔
1177
                $likeStatement .= sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar);
44✔
1178
            }
1179

1180
            $this->{$clause}[] = [
44✔
1181
                'condition' => $likeStatement,
44✔
1182
                'escape'    => $escape,
44✔
1183
            ];
44✔
1184
        }
1185

1186
        return $this;
44✔
1187
    }
1188

1189
    /**
1190
     * Platform independent LIKE statement builder.
1191
     */
1192
    protected function _like_statement(?string $prefix, string $column, ?string $not, string $bind, bool $insensitiveSearch = false): string
1193
    {
1194
        if ($insensitiveSearch) {
47✔
1195
            return "{$prefix} LOWER(" . $this->db->escapeIdentifiers($column) . ") {$not} LIKE :{$bind}:";
7✔
1196
        }
1197

1198
        return "{$prefix} {$column} {$not} LIKE :{$bind}:";
40✔
1199
    }
1200

1201
    /**
1202
     * Add UNION statement
1203
     *
1204
     * @param BaseBuilder|Closure(BaseBuilder): BaseBuilder $union
1205
     *
1206
     * @return $this
1207
     */
1208
    public function union($union)
1209
    {
1210
        return $this->addUnionStatement($union);
4✔
1211
    }
1212

1213
    /**
1214
     * Add UNION ALL statement
1215
     *
1216
     * @param BaseBuilder|Closure(BaseBuilder): BaseBuilder $union
1217
     *
1218
     * @return $this
1219
     */
1220
    public function unionAll($union)
1221
    {
1222
        return $this->addUnionStatement($union, true);
2✔
1223
    }
1224

1225
    /**
1226
     * @used-by union()
1227
     * @used-by unionAll()
1228
     *
1229
     * @param BaseBuilder|Closure(BaseBuilder): BaseBuilder $union
1230
     *
1231
     * @return $this
1232
     */
1233
    protected function addUnionStatement($union, bool $all = false)
1234
    {
1235
        $this->QBUnion[] = "\nUNION "
6✔
1236
            . ($all ? 'ALL ' : '')
6✔
1237
            . 'SELECT * FROM '
6✔
1238
            . $this->buildSubquery($union, true, 'uwrp' . (count($this->QBUnion) + 1));
6✔
1239

1240
        return $this;
6✔
1241
    }
1242

1243
    /**
1244
     * Starts a query group.
1245
     *
1246
     * @return $this
1247
     */
1248
    public function groupStart()
1249
    {
1250
        return $this->groupStartPrepare();
2✔
1251
    }
1252

1253
    /**
1254
     * Starts a query group, but ORs the group
1255
     *
1256
     * @return $this
1257
     */
1258
    public function orGroupStart()
1259
    {
1260
        return $this->groupStartPrepare('', 'OR ');
2✔
1261
    }
1262

1263
    /**
1264
     * Starts a query group, but NOTs the group
1265
     *
1266
     * @return $this
1267
     */
1268
    public function notGroupStart()
1269
    {
1270
        return $this->groupStartPrepare('NOT ');
2✔
1271
    }
1272

1273
    /**
1274
     * Starts a query group, but OR NOTs the group
1275
     *
1276
     * @return $this
1277
     */
1278
    public function orNotGroupStart()
1279
    {
1280
        return $this->groupStartPrepare('NOT ', 'OR ');
2✔
1281
    }
1282

1283
    /**
1284
     * Ends a query group
1285
     *
1286
     * @return $this
1287
     */
1288
    public function groupEnd()
1289
    {
1290
        return $this->groupEndPrepare();
8✔
1291
    }
1292

1293
    /**
1294
     * Starts a query group for HAVING clause.
1295
     *
1296
     * @return $this
1297
     */
1298
    public function havingGroupStart()
1299
    {
1300
        return $this->groupStartPrepare('', 'AND ', 'QBHaving');
2✔
1301
    }
1302

1303
    /**
1304
     * Starts a query group for HAVING clause, but ORs the group.
1305
     *
1306
     * @return $this
1307
     */
1308
    public function orHavingGroupStart()
1309
    {
1310
        return $this->groupStartPrepare('', 'OR ', 'QBHaving');
2✔
1311
    }
1312

1313
    /**
1314
     * Starts a query group for HAVING clause, but NOTs the group.
1315
     *
1316
     * @return $this
1317
     */
1318
    public function notHavingGroupStart()
1319
    {
1320
        return $this->groupStartPrepare('NOT ', 'AND ', 'QBHaving');
2✔
1321
    }
1322

1323
    /**
1324
     * Starts a query group for HAVING clause, but OR NOTs the group.
1325
     *
1326
     * @return $this
1327
     */
1328
    public function orNotHavingGroupStart()
1329
    {
1330
        return $this->groupStartPrepare('NOT ', 'OR ', 'QBHaving');
2✔
1331
    }
1332

1333
    /**
1334
     * Ends a query group for HAVING clause.
1335
     *
1336
     * @return $this
1337
     */
1338
    public function havingGroupEnd()
1339
    {
1340
        return $this->groupEndPrepare('QBHaving');
8✔
1341
    }
1342

1343
    /**
1344
     * Prepate a query group start.
1345
     *
1346
     * @return $this
1347
     */
1348
    protected function groupStartPrepare(string $not = '', string $type = 'AND ', string $clause = 'QBWhere')
1349
    {
1350
        $type = $this->groupGetType($type);
16✔
1351

1352
        $this->QBWhereGroupStarted = true;
16✔
1353
        $prefix                    = empty($this->{$clause}) ? '' : $type;
16✔
1354
        $where                     = [
16✔
1355
            'condition' => $prefix . $not . str_repeat(' ', ++$this->QBWhereGroupCount) . ' (',
16✔
1356
            'escape'    => false,
16✔
1357
        ];
16✔
1358

1359
        $this->{$clause}[] = $where;
16✔
1360

1361
        return $this;
16✔
1362
    }
1363

1364
    /**
1365
     * Prepate a query group end.
1366
     *
1367
     * @return $this
1368
     */
1369
    protected function groupEndPrepare(string $clause = 'QBWhere')
1370
    {
1371
        $this->QBWhereGroupStarted = false;
16✔
1372
        $where                     = [
16✔
1373
            'condition' => str_repeat(' ', $this->QBWhereGroupCount--) . ')',
16✔
1374
            'escape'    => false,
16✔
1375
        ];
16✔
1376

1377
        $this->{$clause}[] = $where;
16✔
1378

1379
        return $this;
16✔
1380
    }
1381

1382
    /**
1383
     * @used-by groupStart()
1384
     * @used-by _like()
1385
     * @used-by whereHaving()
1386
     * @used-by _whereIn()
1387
     * @used-by havingGroupStart()
1388
     */
1389
    protected function groupGetType(string $type): string
1390
    {
1391
        if ($this->QBWhereGroupStarted) {
959✔
1392
            $type                      = '';
16✔
1393
            $this->QBWhereGroupStarted = false;
16✔
1394
        }
1395

1396
        return $type;
959✔
1397
    }
1398

1399
    /**
1400
     * @param array|string $by
1401
     *
1402
     * @return $this
1403
     */
1404
    public function groupBy($by, ?bool $escape = null)
1405
    {
1406
        if (! is_bool($escape)) {
56✔
1407
            $escape = $this->db->protectIdentifiers;
56✔
1408
        }
1409

1410
        if (is_string($by)) {
56✔
1411
            $by = ($escape === true) ? explode(',', $by) : [$by];
56✔
1412
        }
1413

1414
        foreach ($by as $val) {
56✔
1415
            $val = trim($val);
56✔
1416

1417
            if ($val !== '') {
56✔
1418
                $val = [
56✔
1419
                    'field'  => $val,
56✔
1420
                    'escape' => $escape,
56✔
1421
                ];
56✔
1422

1423
                $this->QBGroupBy[] = $val;
56✔
1424
            }
1425
        }
1426

1427
        return $this;
56✔
1428
    }
1429

1430
    /**
1431
     * Separates multiple calls with 'AND'.
1432
     *
1433
     * @param array|RawSql|string $key
1434
     * @param mixed               $value
1435
     *
1436
     * @return $this
1437
     */
1438
    public function having($key, $value = null, ?bool $escape = null)
1439
    {
1440
        return $this->whereHaving('QBHaving', $key, $value, 'AND ', $escape);
16✔
1441
    }
1442

1443
    /**
1444
     * Separates multiple calls with 'OR'.
1445
     *
1446
     * @param array|RawSql|string $key
1447
     * @param mixed               $value
1448
     *
1449
     * @return $this
1450
     */
1451
    public function orHaving($key, $value = null, ?bool $escape = null)
1452
    {
1453
        return $this->whereHaving('QBHaving', $key, $value, 'OR ', $escape);
2✔
1454
    }
1455

1456
    /**
1457
     * @param string $direction ASC, DESC or RANDOM
1458
     *
1459
     * @return $this
1460
     */
1461
    public function orderBy(string $orderBy, string $direction = '', ?bool $escape = null)
1462
    {
1463
        if ($orderBy === '') {
838✔
1464
            return $this;
×
1465
        }
1466

1467
        $qbOrderBy = [];
838✔
1468

1469
        $direction = strtoupper(trim($direction));
838✔
1470

1471
        if ($direction === 'RANDOM') {
838✔
1472
            $direction = '';
3✔
1473
            $orderBy   = ctype_digit($orderBy) ? sprintf($this->randomKeyword[1], $orderBy) : $this->randomKeyword[0];
3✔
1474
            $escape    = false;
3✔
1475
        } elseif ($direction !== '') {
836✔
1476
            $direction = in_array($direction, ['ASC', 'DESC'], true) ? ' ' . $direction : '';
836✔
1477
        }
1478

1479
        if ($escape === null) {
838✔
1480
            $escape = $this->db->protectIdentifiers;
836✔
1481
        }
1482

1483
        if ($escape === false) {
838✔
1484
            $qbOrderBy[] = [
3✔
1485
                'field'     => $orderBy,
3✔
1486
                'direction' => $direction,
3✔
1487
                'escape'    => false,
3✔
1488
            ];
3✔
1489
        } else {
1490
            foreach (explode(',', $orderBy) as $field) {
836✔
1491
                $qbOrderBy[] = ($direction === '' && preg_match('/\s+(ASC|DESC)$/i', rtrim($field), $match, PREG_OFFSET_CAPTURE))
836✔
1492
                    ? [
×
1493
                        'field'     => ltrim(substr($field, 0, $match[0][1])),
×
1494
                        'direction' => ' ' . $match[1][0],
×
1495
                        'escape'    => true,
×
1496
                    ]
×
1497
                    : [
836✔
1498
                        'field'     => trim($field),
836✔
1499
                        'direction' => $direction,
836✔
1500
                        'escape'    => true,
836✔
1501
                    ];
836✔
1502
            }
1503
        }
1504

1505
        $this->QBOrderBy = array_merge($this->QBOrderBy, $qbOrderBy);
838✔
1506

1507
        return $this;
838✔
1508
    }
1509

1510
    /**
1511
     * @return $this
1512
     */
1513
    public function limit(?int $value = null, ?int $offset = 0)
1514
    {
1515
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
123✔
1516
        if ($limitZeroAsAll && $value === 0) {
123✔
1517
            $value = null;
13✔
1518
        }
1519

1520
        if ($value !== null) {
123✔
1521
            $this->QBLimit = $value;
111✔
1522
        }
1523

1524
        if ($offset !== null && $offset !== 0) {
123✔
1525
            $this->QBOffset = $offset;
10✔
1526
        }
1527

1528
        return $this;
123✔
1529
    }
1530

1531
    /**
1532
     * Sets the OFFSET value
1533
     *
1534
     * @return $this
1535
     */
1536
    public function offset(int $offset)
1537
    {
1538
        if ($offset !== 0) {
1✔
1539
            $this->QBOffset = $offset;
1✔
1540
        }
1541

1542
        return $this;
1✔
1543
    }
1544

1545
    /**
1546
     * Generates a platform-specific LIMIT clause.
1547
     */
1548
    protected function _limit(string $sql, bool $offsetIgnore = false): string
1549
    {
1550
        return $sql . ' LIMIT ' . ($offsetIgnore === false && $this->QBOffset ? $this->QBOffset . ', ' : '') . $this->QBLimit;
112✔
1551
    }
1552

1553
    /**
1554
     * Allows key/value pairs to be set for insert(), update() or replace().
1555
     *
1556
     * @param array|object|string $key    Field name, or an array of field/value pairs, or an object
1557
     * @param mixed               $value  Field value, if $key is a single field
1558
     * @param bool|null           $escape Whether to escape values
1559
     *
1560
     * @return $this
1561
     */
1562
    public function set($key, $value = '', ?bool $escape = null)
1563
    {
1564
        $key = $this->objectToArray($key);
854✔
1565

1566
        if (! is_array($key)) {
854✔
1567
            $key = [$key => $value];
122✔
1568
        }
1569

1570
        $escape = is_bool($escape) ? $escape : $this->db->protectIdentifiers;
854✔
1571

1572
        foreach ($key as $k => $v) {
854✔
1573
            if ($escape) {
854✔
1574
                $bind = $this->setBind($k, $v, $escape);
853✔
1575

1576
                $this->QBSet[$this->db->protectIdentifiers($k, false)] = ":{$bind}:";
853✔
1577
            } else {
1578
                $this->QBSet[$this->db->protectIdentifiers($k, false)] = $v;
10✔
1579
            }
1580
        }
1581

1582
        return $this;
854✔
1583
    }
1584

1585
    /**
1586
     * Returns the previously set() data, alternatively resetting it if needed.
1587
     */
1588
    public function getSetData(bool $clean = false): array
1589
    {
1590
        $data = $this->QBSet;
×
1591

1592
        if ($clean) {
×
1593
            $this->QBSet = [];
×
1594
        }
1595

1596
        return $data;
×
1597
    }
1598

1599
    /**
1600
     * Compiles a SELECT query string and returns the sql.
1601
     */
1602
    public function getCompiledSelect(bool $reset = true): string
1603
    {
1604
        $select = $this->compileSelect();
203✔
1605

1606
        if ($reset) {
203✔
1607
            $this->resetSelect();
201✔
1608
        }
1609

1610
        return $this->compileFinalQuery($select);
203✔
1611
    }
1612

1613
    /**
1614
     * Returns a finalized, compiled query string with the bindings
1615
     * inserted and prefixes swapped out.
1616
     */
1617
    protected function compileFinalQuery(string $sql): string
1618
    {
1619
        $query = new Query($this->db);
228✔
1620
        $query->setQuery($sql, $this->binds, false);
228✔
1621

1622
        if (! empty($this->db->swapPre) && ! empty($this->db->DBPrefix)) {
228✔
1623
            $query->swapPrefix($this->db->DBPrefix, $this->db->swapPre);
×
1624
        }
1625

1626
        return $query->getQuery();
228✔
1627
    }
1628

1629
    /**
1630
     * Compiles the select statement based on the other functions called
1631
     * and runs the query
1632
     *
1633
     * @return false|ResultInterface
1634
     */
1635
    public function get(?int $limit = null, int $offset = 0, bool $reset = true)
1636
    {
1637
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
868✔
1638
        if ($limitZeroAsAll && $limit === 0) {
868✔
1639
            $limit = null;
1✔
1640
        }
1641

1642
        if ($limit !== null) {
868✔
1643
            $this->limit($limit, $offset);
8✔
1644
        }
1645

1646
        $result = $this->testMode
868✔
1647
            ? $this->getCompiledSelect($reset)
2✔
1648
            : $this->db->query($this->compileSelect(), $this->binds, false);
866✔
1649

1650
        if ($reset) {
868✔
1651
            $this->resetSelect();
868✔
1652

1653
            // Clear our binds so we don't eat up memory
1654
            $this->binds = [];
868✔
1655
        }
1656

1657
        return $result;
868✔
1658
    }
1659

1660
    /**
1661
     * Generates a platform-specific query string that counts all records in
1662
     * the particular table
1663
     *
1664
     * @return int|string
1665
     */
1666
    public function countAll(bool $reset = true)
1667
    {
1668
        $table = $this->QBFrom[0];
6✔
1669

1670
        $sql = $this->countString . $this->db->escapeIdentifiers('numrows') . ' FROM ' .
6✔
1671
            $this->db->protectIdentifiers($table, true, null, false);
6✔
1672

1673
        if ($this->testMode) {
6✔
1674
            return $sql;
1✔
1675
        }
1676

1677
        $query = $this->db->query($sql, null, false);
5✔
1678

1679
        if (empty($query->getResult())) {
5✔
1680
            return 0;
×
1681
        }
1682

1683
        $query = $query->getRow();
5✔
1684

1685
        if ($reset) {
5✔
1686
            $this->resetSelect();
5✔
1687
        }
1688

1689
        return (int) $query->numrows;
5✔
1690
    }
1691

1692
    /**
1693
     * Generates a platform-specific query string that counts all records
1694
     * returned by an Query Builder query.
1695
     *
1696
     * @return int|string
1697
     */
1698
    public function countAllResults(bool $reset = true)
1699
    {
1700
        // ORDER BY usage is often problematic here (most notably
1701
        // on Microsoft SQL Server) and ultimately unnecessary
1702
        // for selecting COUNT(*) ...
1703
        $orderBy = [];
229✔
1704

1705
        if (! empty($this->QBOrderBy)) {
229✔
1706
            $orderBy = $this->QBOrderBy;
×
1707

1708
            $this->QBOrderBy = null;
×
1709
        }
1710

1711
        // We cannot use a LIMIT when getting the single row COUNT(*) result
1712
        $limit = $this->QBLimit;
229✔
1713

1714
        $this->QBLimit = false;
229✔
1715

1716
        if ($this->QBDistinct === true || ! empty($this->QBGroupBy)) {
229✔
1717
            // We need to backup the original SELECT in case DBPrefix is used
1718
            $select = $this->QBSelect;
4✔
1719
            $sql    = $this->countString . $this->db->protectIdentifiers('numrows') . "\nFROM (\n" . $this->compileSelect() . "\n) CI_count_all_results";
4✔
1720

1721
            // Restore SELECT part
1722
            $this->QBSelect = $select;
4✔
1723
            unset($select);
4✔
1724
        } else {
1725
            $sql = $this->compileSelect($this->countString . $this->db->protectIdentifiers('numrows'));
225✔
1726
        }
1727

1728
        if ($this->testMode) {
229✔
1729
            return $sql;
9✔
1730
        }
1731

1732
        $result = $this->db->query($sql, $this->binds, false);
224✔
1733

1734
        if ($reset) {
224✔
1735
            $this->resetSelect();
208✔
1736
        } elseif (! isset($this->QBOrderBy)) {
22✔
1737
            $this->QBOrderBy = $orderBy;
×
1738
        }
1739

1740
        // Restore the LIMIT setting
1741
        $this->QBLimit = $limit;
224✔
1742

1743
        $row = $result instanceof ResultInterface ? $result->getRow() : null;
224✔
1744

1745
        if (empty($row)) {
224✔
1746
            return 0;
×
1747
        }
1748

1749
        return (int) $row->numrows;
224✔
1750
    }
1751

1752
    /**
1753
     * Compiles the set conditions and returns the sql statement
1754
     *
1755
     * @return array
1756
     */
1757
    public function getCompiledQBWhere()
1758
    {
1759
        return $this->QBWhere;
63✔
1760
    }
1761

1762
    /**
1763
     * Allows the where clause, limit and offset to be added directly
1764
     *
1765
     * @param array|string $where
1766
     *
1767
     * @return ResultInterface
1768
     */
1769
    public function getWhere($where = null, ?int $limit = null, ?int $offset = 0, bool $reset = true)
1770
    {
1771
        if ($where !== null) {
17✔
1772
            $this->where($where);
16✔
1773
        }
1774

1775
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
17✔
1776
        if ($limitZeroAsAll && $limit === 0) {
17✔
1777
            $limit = null;
×
1778
        }
1779

1780
        if ($limit !== null) {
17✔
1781
            $this->limit($limit, $offset);
3✔
1782
        }
1783

1784
        $result = $this->testMode
17✔
1785
            ? $this->getCompiledSelect($reset)
4✔
1786
            : $this->db->query($this->compileSelect(), $this->binds, false);
13✔
1787

1788
        if ($reset) {
17✔
1789
            $this->resetSelect();
17✔
1790

1791
            // Clear our binds so we don't eat up memory
1792
            $this->binds = [];
17✔
1793
        }
1794

1795
        return $result;
17✔
1796
    }
1797

1798
    /**
1799
     * Compiles batch insert/update/upsert strings and runs the queries
1800
     *
1801
     * @param '_deleteBatch'|'_insertBatch'|'_updateBatch'|'_upsertBatch' $renderMethod
1802
     *
1803
     * @return false|int|list<string> Number of rows inserted or FALSE on failure, SQL array when testMode
1804
     *
1805
     * @throws DatabaseException
1806
     */
1807
    protected function batchExecute(string $renderMethod, int $batchSize = 100)
1808
    {
1809
        if (empty($this->QBSet)) {
73✔
1810
            if ($this->db->DBDebug) {
5✔
1811
                throw new DatabaseException(trim($renderMethod, '_') . '() has no data.');
5✔
1812
            }
1813

1814
            return false; // @codeCoverageIgnore
1815
        }
1816

1817
        $table = $this->db->protectIdentifiers($this->QBFrom[0], true, null, false);
68✔
1818

1819
        $affectedRows = 0;
68✔
1820
        $savedSQL     = [];
68✔
1821
        $cnt          = count($this->QBSet);
68✔
1822

1823
        // batch size 0 for unlimited
1824
        if ($batchSize === 0) {
68✔
1825
            $batchSize = $cnt;
×
1826
        }
1827

1828
        for ($i = 0, $total = $cnt; $i < $total; $i += $batchSize) {
68✔
1829
            $QBSet = array_slice($this->QBSet, $i, $batchSize);
68✔
1830

1831
            $sql = $this->{$renderMethod}($table, $this->QBKeys, $QBSet);
68✔
1832

1833
            if ($sql === '') {
65✔
1834
                return false; // @codeCoverageIgnore
1835
            }
1836

1837
            if ($this->testMode) {
65✔
1838
                $savedSQL[] = $sql;
3✔
1839
            } else {
1840
                $this->db->query($sql, null, false);
62✔
1841
                $affectedRows += $this->db->affectedRows();
60✔
1842
            }
1843
        }
1844

1845
        if (! $this->testMode) {
63✔
1846
            $this->resetWrite();
60✔
1847
        }
1848

1849
        return $this->testMode ? $savedSQL : $affectedRows;
63✔
1850
    }
1851

1852
    /**
1853
     * Allows a row or multiple rows to be set for batch inserts/upserts/updates
1854
     *
1855
     * @param array|object $set
1856
     * @param string       $alias alias for sql table
1857
     *
1858
     * @return $this|null
1859
     */
1860
    public function setData($set, ?bool $escape = null, string $alias = '')
1861
    {
1862
        if (empty($set)) {
68✔
1863
            if ($this->db->DBDebug) {
×
1864
                throw new DatabaseException('setData() has no data.');
×
1865
            }
1866

1867
            return null; // @codeCoverageIgnore
1868
        }
1869

1870
        $this->setAlias($alias);
68✔
1871

1872
        // this allows to set just one row at a time
1873
        if (is_object($set) || (! is_array(current($set)) && ! is_object(current($set)))) {
68✔
1874
            $set = [$set];
11✔
1875
        }
1876

1877
        $set = $this->batchObjectToArray($set);
68✔
1878

1879
        $escape = is_bool($escape) ? $escape : $this->db->protectIdentifiers;
68✔
1880

1881
        $keys = array_keys($this->objectToArray(current($set)));
68✔
1882
        sort($keys);
68✔
1883

1884
        foreach ($set as $row) {
68✔
1885
            $row = $this->objectToArray($row);
68✔
1886
            if (array_diff($keys, array_keys($row)) !== [] || array_diff(array_keys($row), $keys) !== []) {
68✔
1887
                // batchExecute() function returns an error on an empty array
1888
                $this->QBSet[] = [];
×
1889

1890
                return null;
×
1891
            }
1892

1893
            ksort($row); // puts $row in the same order as our keys
68✔
1894

1895
            $clean = [];
68✔
1896

1897
            foreach ($row as $rowValue) {
68✔
1898
                $clean[] = $escape ? $this->db->escape($rowValue) : $rowValue;
68✔
1899
            }
1900

1901
            $row = $clean;
68✔
1902

1903
            $this->QBSet[] = $row;
68✔
1904
        }
1905

1906
        foreach ($keys as $k) {
68✔
1907
            $k = $this->db->protectIdentifiers($k, false);
68✔
1908

1909
            if (! in_array($k, $this->QBKeys, true)) {
68✔
1910
                $this->QBKeys[] = $k;
68✔
1911
            }
1912
        }
1913

1914
        return $this;
68✔
1915
    }
1916

1917
    /**
1918
     * Compiles an upsert query and returns the sql
1919
     *
1920
     * @return string
1921
     *
1922
     * @throws DatabaseException
1923
     */
1924
    public function getCompiledUpsert()
1925
    {
1926
        [$currentTestMode, $this->testMode] = [$this->testMode, true];
3✔
1927

1928
        $sql = implode(";\n", $this->upsert());
3✔
1929

1930
        $this->testMode = $currentTestMode;
3✔
1931

1932
        return $this->compileFinalQuery($sql);
3✔
1933
    }
1934

1935
    /**
1936
     * Converts call to batchUpsert
1937
     *
1938
     * @param array|object|null $set
1939
     *
1940
     * @return false|int|list<string> Number of affected rows or FALSE on failure, SQL array when testMode
1941
     *
1942
     * @throws DatabaseException
1943
     */
1944
    public function upsert($set = null, ?bool $escape = null)
1945
    {
1946
        // if set() has been used merge QBSet with binds and then setData()
1947
        if ($set === null && ! is_array(current($this->QBSet))) {
10✔
1948
            $set = [];
2✔
1949

1950
            foreach ($this->QBSet as $field => $value) {
2✔
1951
                $k = trim($field, $this->db->escapeChar);
2✔
1952
                // use binds if available else use QBSet value but with RawSql to avoid escape
1953
                $set[$k] = isset($this->binds[$k]) ? $this->binds[$k][0] : new RawSql($value);
2✔
1954
            }
1955

1956
            $this->binds = [];
2✔
1957

1958
            $this->resetRun([
2✔
1959
                'QBSet'  => [],
2✔
1960
                'QBKeys' => [],
2✔
1961
            ]);
2✔
1962

1963
            $this->setData($set, true); // unescaped items are RawSql now
2✔
1964
        } elseif ($set !== null) {
8✔
1965
            $this->setData($set, $escape);
7✔
1966
        } // else setData() has already been used and we need to do nothing
1967

1968
        return $this->batchExecute('_upsertBatch');
10✔
1969
    }
1970

1971
    /**
1972
     * Compiles batch upsert strings and runs the queries
1973
     *
1974
     * @param array|object|null $set a dataset
1975
     *
1976
     * @return false|int|list<string> Number of affected rows or FALSE on failure, SQL array when testMode
1977
     *
1978
     * @throws DatabaseException
1979
     */
1980
    public function upsertBatch($set = null, ?bool $escape = null, int $batchSize = 100)
1981
    {
1982
        if (isset($this->QBOptions['setQueryAsData'])) {
12✔
1983
            $sql = $this->_upsertBatch($this->QBFrom[0], $this->QBKeys, []);
1✔
1984

1985
            if ($sql === '') {
1✔
1986
                return false; // @codeCoverageIgnore
1987
            }
1988

1989
            if ($this->testMode === false) {
1✔
1990
                $this->db->query($sql, null, false);
1✔
1991
            }
1992

1993
            $this->resetWrite();
1✔
1994

1995
            return $this->testMode ? $sql : $this->db->affectedRows();
1✔
1996
        }
1997

1998
        if ($set !== null) {
11✔
1999
            $this->setData($set, $escape);
9✔
2000
        }
2001

2002
        return $this->batchExecute('_upsertBatch', $batchSize);
11✔
2003
    }
2004

2005
    /**
2006
     * Generates a platform-specific upsertBatch string from the supplied data
2007
     *
2008
     * @used-by batchExecute()
2009
     *
2010
     * @param string                 $table  Protected table name
2011
     * @param list<string>           $keys   QBKeys
2012
     * @param list<list<int|string>> $values QBSet
2013
     */
2014
    protected function _upsertBatch(string $table, array $keys, array $values): string
2015
    {
2016
        $sql = $this->QBOptions['sql'] ?? '';
19✔
2017

2018
        // if this is the first iteration of batch then we need to build skeleton sql
2019
        if ($sql === '') {
19✔
2020
            $updateFields = $this->QBOptions['updateFields'] ?? $this->updateFields($keys)->QBOptions['updateFields'] ?? [];
19✔
2021

2022
            $sql = 'INSERT INTO ' . $table . ' (' . implode(', ', $keys) . ")\n{:_table_:}ON DUPLICATE KEY UPDATE\n" . implode(
19✔
2023
                ",\n",
19✔
2024
                array_map(
19✔
2025
                    static fn ($key, $value): string => $table . '.' . $key . ($value instanceof RawSql ?
19✔
2026
                        ' = ' . $value :
2✔
2027
                        ' = VALUES(' . $value . ')'),
19✔
2028
                    array_keys($updateFields),
19✔
2029
                    $updateFields,
19✔
2030
                ),
19✔
2031
            );
19✔
2032

2033
            $this->QBOptions['sql'] = $sql;
19✔
2034
        }
2035

2036
        if (isset($this->QBOptions['setQueryAsData'])) {
19✔
2037
            $data = $this->QBOptions['setQueryAsData'] . "\n";
1✔
2038
        } else {
2039
            $data = 'VALUES ' . implode(', ', $this->formatValues($values)) . "\n";
18✔
2040
        }
2041

2042
        return str_replace('{:_table_:}', $data, $sql);
19✔
2043
    }
2044

2045
    /**
2046
     * Set table alias for dataset pseudo table.
2047
     */
2048
    private function setAlias(string $alias): BaseBuilder
2049
    {
2050
        if ($alias !== '') {
68✔
2051
            $this->db->addTableAlias($alias);
7✔
2052
            $this->QBOptions['alias'] = $this->db->protectIdentifiers($alias);
7✔
2053
        }
2054

2055
        return $this;
68✔
2056
    }
2057

2058
    /**
2059
     * Sets update fields for upsert, update
2060
     *
2061
     * @param list<RawSql>|list<string>|string $set
2062
     * @param bool                             $addToDefault adds update fields to the default ones
2063
     * @param array|null                       $ignore       ignores items in set
2064
     *
2065
     * @return $this
2066
     */
2067
    public function updateFields($set, bool $addToDefault = false, ?array $ignore = null)
2068
    {
2069
        if (! empty($set)) {
38✔
2070
            if (! is_array($set)) {
38✔
2071
                $set = explode(',', $set);
5✔
2072
            }
2073

2074
            foreach ($set as $key => $value) {
38✔
2075
                if (! ($value instanceof RawSql)) {
38✔
2076
                    $value = $this->db->protectIdentifiers($value);
38✔
2077
                }
2078

2079
                if (is_numeric($key)) {
38✔
2080
                    $key = $value;
38✔
2081
                }
2082

2083
                if ($ignore === null || ! in_array($key, $ignore, true)) {
38✔
2084
                    if ($addToDefault) {
38✔
2085
                        $this->QBOptions['updateFieldsAdditional'][$this->db->protectIdentifiers($key)] = $value;
3✔
2086
                    } else {
2087
                        $this->QBOptions['updateFields'][$this->db->protectIdentifiers($key)] = $value;
38✔
2088
                    }
2089
                }
2090
            }
2091

2092
            if ($addToDefault === false && isset($this->QBOptions['updateFieldsAdditional'], $this->QBOptions['updateFields'])) {
38✔
2093
                $this->QBOptions['updateFields'] = array_merge($this->QBOptions['updateFields'], $this->QBOptions['updateFieldsAdditional']);
3✔
2094

2095
                unset($this->QBOptions['updateFieldsAdditional']);
3✔
2096
            }
2097
        }
2098

2099
        return $this;
38✔
2100
    }
2101

2102
    /**
2103
     * Sets constraints for batch upsert, update
2104
     *
2105
     * @param array|RawSql|string $set a string of columns, key value pairs, or RawSql
2106
     *
2107
     * @return $this
2108
     */
2109
    public function onConstraint($set)
2110
    {
2111
        if (! empty($set)) {
47✔
2112
            if (is_string($set)) {
44✔
2113
                $set = explode(',', $set);
27✔
2114

2115
                $set = array_map(trim(...), $set);
27✔
2116
            }
2117

2118
            if ($set instanceof RawSql) {
44✔
2119
                $set = [$set];
2✔
2120
            }
2121

2122
            foreach ($set as $key => $value) {
44✔
2123
                if (! ($value instanceof RawSql)) {
44✔
2124
                    $value = $this->db->protectIdentifiers($value);
41✔
2125
                }
2126

2127
                if (is_string($key)) {
44✔
2128
                    $key = $this->db->protectIdentifiers($key);
3✔
2129
                }
2130

2131
                $this->QBOptions['constraints'][$key] = $value;
44✔
2132
            }
2133
        }
2134

2135
        return $this;
47✔
2136
    }
2137

2138
    /**
2139
     * Sets data source as a query for insertBatch()/updateBatch()/upsertBatch()/deleteBatch()
2140
     *
2141
     * @param BaseBuilder|RawSql $query
2142
     * @param array|string|null  $columns an array or comma delimited string of columns
2143
     */
2144
    public function setQueryAsData($query, ?string $alias = null, $columns = null): BaseBuilder
2145
    {
2146
        if (is_string($query)) {
5✔
2147
            throw new InvalidArgumentException('$query parameter must be BaseBuilder or RawSql class.');
×
2148
        }
2149

2150
        if ($query instanceof BaseBuilder) {
5✔
2151
            $query = $query->getCompiledSelect();
4✔
2152
        } elseif ($query instanceof RawSql) {
1✔
2153
            $query = $query->__toString();
1✔
2154
        }
2155

2156
        if (is_string($query)) {
5✔
2157
            if ($columns !== null && is_string($columns)) {
5✔
2158
                $columns = explode(',', $columns);
1✔
2159
                $columns = array_map(trim(...), $columns);
1✔
2160
            }
2161

2162
            $columns = (array) $columns;
5✔
2163

2164
            if ($columns === []) {
5✔
2165
                $columns = $this->fieldsFromQuery($query);
4✔
2166
            }
2167

2168
            if ($alias !== null) {
5✔
2169
                $this->setAlias($alias);
1✔
2170
            }
2171

2172
            foreach ($columns as $key => $value) {
5✔
2173
                $columns[$key] = $this->db->escapeChar . $value . $this->db->escapeChar;
5✔
2174
            }
2175

2176
            $this->QBOptions['setQueryAsData'] = $query;
5✔
2177
            $this->QBKeys                      = $columns;
5✔
2178
            $this->QBSet                       = [];
5✔
2179
        }
2180

2181
        return $this;
5✔
2182
    }
2183

2184
    /**
2185
     * Gets column names from a select query
2186
     */
2187
    protected function fieldsFromQuery(string $sql): array
2188
    {
2189
        return $this->db->query('SELECT * FROM (' . $sql . ') _u_ LIMIT 1')->getFieldNames();
4✔
2190
    }
2191

2192
    /**
2193
     * Converts value array of array to array of strings
2194
     */
2195
    protected function formatValues(array $values): array
2196
    {
2197
        return array_map(static fn ($index): string => '(' . implode(',', $index) . ')', $values);
45✔
2198
    }
2199

2200
    /**
2201
     * Compiles batch insert strings and runs the queries
2202
     *
2203
     * @param array|object|null $set a dataset
2204
     *
2205
     * @return false|int|list<string> Number of rows inserted or FALSE on no data to perform an insert operation, SQL array when testMode
2206
     */
2207
    public function insertBatch($set = null, ?bool $escape = null, int $batchSize = 100)
2208
    {
2209
        if (isset($this->QBOptions['setQueryAsData'])) {
29✔
2210
            $sql = $this->_insertBatch($this->QBFrom[0], $this->QBKeys, []);
2✔
2211

2212
            if ($sql === '') {
2✔
2213
                return false; // @codeCoverageIgnore
2214
            }
2215

2216
            if ($this->testMode === false) {
2✔
2217
                $this->db->query($sql, null, false);
2✔
2218
            }
2219

2220
            $this->resetWrite();
2✔
2221

2222
            return $this->testMode ? $sql : $this->db->affectedRows();
2✔
2223
        }
2224

2225
        if ($set !== null && $set !== []) {
29✔
2226
            $this->setData($set, $escape);
27✔
2227
        }
2228

2229
        return $this->batchExecute('_insertBatch', $batchSize);
29✔
2230
    }
2231

2232
    /**
2233
     * Generates a platform-specific insert string from the supplied data.
2234
     *
2235
     * @used-by batchExecute()
2236
     *
2237
     * @param string                 $table  Protected table name
2238
     * @param list<string>           $keys   QBKeys
2239
     * @param list<list<int|string>> $values QBSet
2240
     */
2241
    protected function _insertBatch(string $table, array $keys, array $values): string
2242
    {
2243
        $sql = $this->QBOptions['sql'] ?? '';
27✔
2244

2245
        // if this is the first iteration of batch then we need to build skeleton sql
2246
        if ($sql === '') {
27✔
2247
            $sql = 'INSERT ' . $this->compileIgnore('insert') . 'INTO ' . $table
27✔
2248
                . ' (' . implode(', ', $keys) . ")\n{:_table_:}";
27✔
2249

2250
            $this->QBOptions['sql'] = $sql;
27✔
2251
        }
2252

2253
        if (isset($this->QBOptions['setQueryAsData'])) {
27✔
2254
            $data = $this->QBOptions['setQueryAsData'];
2✔
2255
        } else {
2256
            $data = 'VALUES ' . implode(', ', $this->formatValues($values));
27✔
2257
        }
2258

2259
        return str_replace('{:_table_:}', $data, $sql);
27✔
2260
    }
2261

2262
    /**
2263
     * Compiles an insert query and returns the sql
2264
     *
2265
     * @return bool|string
2266
     *
2267
     * @throws DatabaseException
2268
     */
2269
    public function getCompiledInsert(bool $reset = true)
2270
    {
2271
        if ($this->validateInsert() === false) {
6✔
2272
            return false;
×
2273
        }
2274

2275
        $sql = $this->_insert(
6✔
2276
            $this->db->protectIdentifiers(
6✔
2277
                $this->removeAlias($this->QBFrom[0]),
6✔
2278
                true,
6✔
2279
                null,
6✔
2280
                false,
6✔
2281
            ),
6✔
2282
            array_keys($this->QBSet),
6✔
2283
            array_values($this->QBSet),
6✔
2284
        );
6✔
2285

2286
        if ($reset) {
6✔
2287
            $this->resetWrite();
6✔
2288
        }
2289

2290
        return $this->compileFinalQuery($sql);
6✔
2291
    }
2292

2293
    /**
2294
     * Compiles an insert string and runs the query
2295
     *
2296
     * @param array|object|null $set
2297
     *
2298
     * @return BaseResult|bool|Query
2299
     *
2300
     * @throws DatabaseException
2301
     */
2302
    public function insert($set = null, ?bool $escape = null)
2303
    {
2304
        if ($set !== null) {
841✔
2305
            $this->set($set, '', $escape);
807✔
2306
        }
2307

2308
        if ($this->validateInsert() === false) {
841✔
2309
            return false;
×
2310
        }
2311

2312
        $sql = $this->_insert(
840✔
2313
            $this->db->protectIdentifiers(
840✔
2314
                $this->removeAlias($this->QBFrom[0]),
840✔
2315
                true,
840✔
2316
                $escape,
840✔
2317
                false,
840✔
2318
            ),
840✔
2319
            array_keys($this->QBSet),
840✔
2320
            array_values($this->QBSet),
840✔
2321
        );
840✔
2322

2323
        if (! $this->testMode) {
840✔
2324
            $this->resetWrite();
836✔
2325

2326
            $result = $this->db->query($sql, $this->binds, false);
836✔
2327

2328
            // Clear our binds so we don't eat up memory
2329
            $this->binds = [];
836✔
2330

2331
            return $result;
836✔
2332
        }
2333

2334
        return false;
5✔
2335
    }
2336

2337
    /**
2338
     * @internal This is a temporary solution.
2339
     *
2340
     * @see https://github.com/codeigniter4/CodeIgniter4/pull/5376
2341
     *
2342
     * @TODO Fix a root cause, and this method should be removed.
2343
     */
2344
    protected function removeAlias(string $from): string
2345
    {
2346
        if (str_contains($from, ' ')) {
845✔
2347
            // if the alias is written with the AS keyword, remove it
2348
            $from = preg_replace('/\s+AS\s+/i', ' ', $from);
2✔
2349

2350
            $parts = explode(' ', $from);
2✔
2351
            $from  = $parts[0];
2✔
2352
        }
2353

2354
        return $from;
845✔
2355
    }
2356

2357
    /**
2358
     * This method is used by both insert() and getCompiledInsert() to
2359
     * validate that the there data is actually being set and that table
2360
     * has been chosen to be inserted into.
2361
     *
2362
     * @throws DatabaseException
2363
     */
2364
    protected function validateInsert(): bool
2365
    {
2366
        if (empty($this->QBSet)) {
841✔
2367
            if ($this->db->DBDebug) {
1✔
2368
                throw new DatabaseException('You must use the "set" method to insert an entry.');
1✔
2369
            }
2370

2371
            return false; // @codeCoverageIgnore
2372
        }
2373

2374
        return true;
840✔
2375
    }
2376

2377
    /**
2378
     * Generates a platform-specific insert string from the supplied data
2379
     *
2380
     * @param string           $table         Protected table name
2381
     * @param list<string>     $keys          Keys of QBSet
2382
     * @param list<int|string> $unescapedKeys Values of QBSet
2383
     */
2384
    protected function _insert(string $table, array $keys, array $unescapedKeys): string
2385
    {
2386
        return 'INSERT ' . $this->compileIgnore('insert') . 'INTO ' . $table . ' (' . implode(', ', $keys) . ') VALUES (' . implode(', ', $unescapedKeys) . ')';
833✔
2387
    }
2388

2389
    /**
2390
     * Compiles a replace into string and runs the query
2391
     *
2392
     * @return BaseResult|false|Query|string
2393
     *
2394
     * @throws DatabaseException
2395
     */
2396
    public function replace(?array $set = null)
2397
    {
2398
        if ($set !== null) {
8✔
2399
            $this->set($set);
7✔
2400
        }
2401

2402
        if (empty($this->QBSet)) {
8✔
2403
            if ($this->db->DBDebug) {
1✔
2404
                throw new DatabaseException('You must use the "set" method to update an entry.');
1✔
2405
            }
2406

2407
            return false; // @codeCoverageIgnore
2408
        }
2409

2410
        $table = $this->QBFrom[0];
7✔
2411

2412
        $sql = $this->_replace($table, array_keys($this->QBSet), array_values($this->QBSet));
7✔
2413

2414
        $this->resetWrite();
7✔
2415

2416
        return $this->testMode ? $sql : $this->db->query($sql, $this->binds, false);
7✔
2417
    }
2418

2419
    /**
2420
     * Generates a platform-specific replace string from the supplied data
2421
     *
2422
     * @param string           $table  Protected table name
2423
     * @param list<string>     $keys   Keys of QBSet
2424
     * @param list<int|string> $values Values of QBSet
2425
     */
2426
    protected function _replace(string $table, array $keys, array $values): string
2427
    {
2428
        return 'REPLACE INTO ' . $table . ' (' . implode(', ', $keys) . ') VALUES (' . implode(', ', $values) . ')';
7✔
2429
    }
2430

2431
    /**
2432
     * Groups tables in FROM clauses if needed, so there is no confusion
2433
     * about operator precedence.
2434
     *
2435
     * Note: This is only used (and overridden) by MySQL and SQLSRV.
2436
     */
2437
    protected function _fromTables(): string
2438
    {
2439
        return implode(', ', $this->QBFrom);
1,032✔
2440
    }
2441

2442
    /**
2443
     * Compiles an update query and returns the sql
2444
     *
2445
     * @return bool|string
2446
     */
2447
    public function getCompiledUpdate(bool $reset = true)
2448
    {
2449
        if ($this->validateUpdate() === false) {
13✔
2450
            return false;
×
2451
        }
2452

2453
        $sql = $this->_update($this->QBFrom[0], $this->QBSet);
13✔
2454

2455
        if ($reset) {
13✔
2456
            $this->resetWrite();
13✔
2457
        }
2458

2459
        return $this->compileFinalQuery($sql);
13✔
2460
    }
2461

2462
    /**
2463
     * Compiles an update string and runs the query.
2464
     *
2465
     * @param array|object|null        $set
2466
     * @param array|RawSql|string|null $where
2467
     *
2468
     * @throws DatabaseException
2469
     */
2470
    public function update($set = null, $where = null, ?int $limit = null): bool
2471
    {
2472
        if ($set !== null) {
104✔
2473
            $this->set($set);
50✔
2474
        }
2475

2476
        if ($this->validateUpdate() === false) {
104✔
2477
            return false;
×
2478
        }
2479

2480
        if ($where !== null) {
103✔
2481
            $this->where($where);
7✔
2482
        }
2483

2484
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
103✔
2485
        if ($limitZeroAsAll && $limit === 0) {
103✔
2486
            $limit = null;
×
2487
        }
2488

2489
        if ($limit !== null) {
103✔
2490
            if (! $this->canLimitWhereUpdates) {
3✔
2491
                throw new DatabaseException('This driver does not allow LIMITs on UPDATE queries using WHERE.');
2✔
2492
            }
2493

2494
            $this->limit($limit);
3✔
2495
        }
2496

2497
        $sql = $this->_update($this->QBFrom[0], $this->QBSet);
103✔
2498

2499
        if (! $this->testMode) {
103✔
2500
            $this->resetWrite();
90✔
2501

2502
            $result = $this->db->query($sql, $this->binds, false);
90✔
2503

2504
            if ($result !== false) {
90✔
2505
                // Clear our binds so we don't eat up memory
2506
                $this->binds = [];
87✔
2507

2508
                return true;
87✔
2509
            }
2510

2511
            return false;
3✔
2512
        }
2513

2514
        return true;
13✔
2515
    }
2516

2517
    /**
2518
     * Generates a platform-specific update string from the supplied data
2519
     *
2520
     * @param string                $table  Protected table name
2521
     * @param array<string, string> $values QBSet
2522
     */
2523
    protected function _update(string $table, array $values): string
2524
    {
2525
        $valStr = [];
120✔
2526

2527
        foreach ($values as $key => $val) {
120✔
2528
            $valStr[] = $key . ' = ' . $val;
120✔
2529
        }
2530

2531
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
120✔
2532
        if ($limitZeroAsAll) {
120✔
2533
            return 'UPDATE ' . $this->compileIgnore('update') . $table . ' SET ' . implode(', ', $valStr)
120✔
2534
                . $this->compileWhereHaving('QBWhere')
120✔
2535
                . $this->compileOrderBy()
120✔
2536
                . ($this->QBLimit ? $this->_limit(' ', true) : '');
120✔
2537
        }
2538

2539
        return 'UPDATE ' . $this->compileIgnore('update') . $table . ' SET ' . implode(', ', $valStr)
×
2540
            . $this->compileWhereHaving('QBWhere')
×
2541
            . $this->compileOrderBy()
×
2542
            . ($this->QBLimit !== false ? $this->_limit(' ', true) : '');
×
2543
    }
2544

2545
    /**
2546
     * This method is used by both update() and getCompiledUpdate() to
2547
     * validate that data is actually being set and that a table has been
2548
     * chosen to be updated.
2549
     *
2550
     * @throws DatabaseException
2551
     */
2552
    protected function validateUpdate(): bool
2553
    {
2554
        if (empty($this->QBSet)) {
105✔
2555
            if ($this->db->DBDebug) {
1✔
2556
                throw new DatabaseException('You must use the "set" method to update an entry.');
1✔
2557
            }
2558

2559
            return false; // @codeCoverageIgnore
2560
        }
2561

2562
        return true;
104✔
2563
    }
2564

2565
    /**
2566
     * Sets data and calls batchExecute to run queries
2567
     *
2568
     * @param array|object|null        $set         a dataset
2569
     * @param array|RawSql|string|null $constraints
2570
     *
2571
     * @return false|int|list<string> Number of rows affected or FALSE on failure, SQL array when testMode
2572
     */
2573
    public function updateBatch($set = null, $constraints = null, int $batchSize = 100)
2574
    {
2575
        $this->onConstraint($constraints);
23✔
2576

2577
        if (isset($this->QBOptions['setQueryAsData'])) {
23✔
2578
            $sql = $this->_updateBatch($this->QBFrom[0], $this->QBKeys, []);
1✔
2579

2580
            if ($sql === '') {
1✔
2581
                return false; // @codeCoverageIgnore
2582
            }
2583

2584
            if ($this->testMode === false) {
1✔
2585
                $this->db->query($sql, null, false);
1✔
2586
            }
2587

2588
            $this->resetWrite();
1✔
2589

2590
            return $this->testMode ? $sql : $this->db->affectedRows();
1✔
2591
        }
2592

2593
        if ($set !== null && $set !== []) {
22✔
2594
            $this->setData($set, true);
16✔
2595
        }
2596

2597
        return $this->batchExecute('_updateBatch', $batchSize);
22✔
2598
    }
2599

2600
    /**
2601
     * Generates a platform-specific batch update string from the supplied data
2602
     *
2603
     * @used-by batchExecute()
2604
     *
2605
     * @param string                 $table  Protected table name
2606
     * @param list<string>           $keys   QBKeys
2607
     * @param list<list<int|string>> $values QBSet
2608
     */
2609
    protected function _updateBatch(string $table, array $keys, array $values): string
2610
    {
2611
        $sql = $this->QBOptions['sql'] ?? '';
19✔
2612

2613
        // if this is the first iteration of batch then we need to build skeleton sql
2614
        if ($sql === '') {
19✔
2615
            $constraints = $this->QBOptions['constraints'] ?? [];
19✔
2616

2617
            if ($constraints === []) {
19✔
2618
                if ($this->db->DBDebug) {
2✔
2619
                    throw new DatabaseException('You must specify a constraint to match on for batch updates.'); // @codeCoverageIgnore
2620
                }
2621

2622
                return ''; // @codeCoverageIgnore
2623
            }
2624

2625
            $updateFields = $this->QBOptions['updateFields'] ??
17✔
2626
                $this->updateFields($keys, false, $constraints)->QBOptions['updateFields'] ??
17✔
2627
                [];
14✔
2628

2629
            $alias = $this->QBOptions['alias'] ?? '_u';
17✔
2630

2631
            $sql = 'UPDATE ' . $this->compileIgnore('update') . $table . "\n";
17✔
2632

2633
            $sql .= "SET\n";
17✔
2634

2635
            $sql .= implode(
17✔
2636
                ",\n",
17✔
2637
                array_map(
17✔
2638
                    static fn ($key, $value): string => $key . ($value instanceof RawSql ?
17✔
2639
                        ' = ' . $value :
2✔
2640
                        ' = ' . $alias . '.' . $value),
17✔
2641
                    array_keys($updateFields),
17✔
2642
                    $updateFields,
17✔
2643
                ),
17✔
2644
            ) . "\n";
17✔
2645

2646
            $sql .= "FROM (\n{:_table_:}";
17✔
2647

2648
            $sql .= ') ' . $alias . "\n";
17✔
2649

2650
            $sql .= 'WHERE ' . implode(
17✔
2651
                ' AND ',
17✔
2652
                array_map(
17✔
2653
                    static fn ($key, $value) => (
17✔
2654
                        ($value instanceof RawSql && is_string($key))
17✔
2655
                        ?
17✔
2656
                        $table . '.' . $key . ' = ' . $value
1✔
2657
                        :
17✔
2658
                        (
17✔
2659
                            $value instanceof RawSql
16✔
2660
                            ?
16✔
2661
                            $value
3✔
2662
                            :
16✔
2663
                            $table . '.' . $value . ' = ' . $alias . '.' . $value
17✔
2664
                        )
17✔
2665
                    ),
17✔
2666
                    array_keys($constraints),
17✔
2667
                    $constraints,
17✔
2668
                ),
17✔
2669
            );
17✔
2670

2671
            $this->QBOptions['sql'] = $sql;
17✔
2672
        }
2673

2674
        if (isset($this->QBOptions['setQueryAsData'])) {
17✔
2675
            $data = $this->QBOptions['setQueryAsData'];
1✔
2676
        } else {
2677
            $data = implode(
16✔
2678
                " UNION ALL\n",
16✔
2679
                array_map(
16✔
2680
                    static fn ($value): string => 'SELECT ' . implode(', ', array_map(
16✔
2681
                        static fn ($key, $index): string => $index . ' ' . $key,
16✔
2682
                        $keys,
16✔
2683
                        $value,
16✔
2684
                    )),
16✔
2685
                    $values,
16✔
2686
                ),
16✔
2687
            ) . "\n";
16✔
2688
        }
2689

2690
        return str_replace('{:_table_:}', $data, $sql);
17✔
2691
    }
2692

2693
    /**
2694
     * Compiles a delete string and runs "DELETE FROM table"
2695
     *
2696
     * @return bool|string TRUE on success, FALSE on failure, string on testMode
2697
     */
2698
    public function emptyTable()
2699
    {
2700
        $table = $this->QBFrom[0];
4✔
2701

2702
        $sql = $this->_delete($table);
4✔
2703

2704
        if ($this->testMode) {
4✔
2705
            return $sql;
1✔
2706
        }
2707

2708
        $this->resetWrite();
3✔
2709

2710
        return $this->db->query($sql, null, false);
3✔
2711
    }
2712

2713
    /**
2714
     * Compiles a truncate string and runs the query
2715
     * If the database does not support the truncate() command
2716
     * This function maps to "DELETE FROM table"
2717
     *
2718
     * @return bool|string TRUE on success, FALSE on failure, string on testMode
2719
     */
2720
    public function truncate()
2721
    {
2722
        $table = $this->QBFrom[0];
759✔
2723

2724
        $sql = $this->_truncate($table);
759✔
2725

2726
        if ($this->testMode) {
759✔
2727
            return $sql;
2✔
2728
        }
2729

2730
        $this->resetWrite();
758✔
2731

2732
        return $this->db->query($sql, null, false);
758✔
2733
    }
2734

2735
    /**
2736
     * Generates a platform-specific truncate string from the supplied data
2737
     *
2738
     * If the database does not support the truncate() command,
2739
     * then this method maps to 'DELETE FROM table'
2740
     *
2741
     * @param string $table Protected table name
2742
     */
2743
    protected function _truncate(string $table): string
2744
    {
2745
        return 'TRUNCATE ' . $table;
746✔
2746
    }
2747

2748
    /**
2749
     * Compiles a delete query string and returns the sql
2750
     */
2751
    public function getCompiledDelete(bool $reset = true): string
2752
    {
2753
        $sql = $this->testMode()->delete('', null, $reset);
3✔
2754
        $this->testMode(false);
3✔
2755

2756
        return $this->compileFinalQuery($sql);
3✔
2757
    }
2758

2759
    /**
2760
     * Compiles a delete string and runs the query
2761
     *
2762
     * @param array|RawSql|string $where
2763
     *
2764
     * @return bool|string Returns a SQL string if in test mode.
2765
     *
2766
     * @throws DatabaseException
2767
     */
2768
    public function delete($where = '', ?int $limit = null, bool $resetData = true)
2769
    {
2770
        $table = $this->db->protectIdentifiers($this->QBFrom[0], true, null, false);
800✔
2771

2772
        if ($where !== '') {
800✔
2773
            $this->where($where);
4✔
2774
        }
2775

2776
        if (empty($this->QBWhere)) {
800✔
2777
            if ($this->db->DBDebug) {
2✔
2778
                throw new DatabaseException('Deletes are not allowed unless they contain a "where" or "like" clause.');
2✔
2779
            }
2780

2781
            return false; // @codeCoverageIgnore
2782
        }
2783

2784
        $sql = $this->_delete($this->removeAlias($table));
800✔
2785

2786
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
800✔
2787
        if ($limitZeroAsAll && $limit === 0) {
800✔
2788
            $limit = null;
×
2789
        }
2790

2791
        if ($limit !== null) {
800✔
2792
            $this->QBLimit = $limit;
1✔
2793
        }
2794

2795
        if (! empty($this->QBLimit)) {
800✔
2796
            if (! $this->canLimitDeletes) {
2✔
2797
                throw new DatabaseException('SQLite3 does not allow LIMITs on DELETE queries.');
1✔
2798
            }
2799

2800
            $sql = $this->_limit($sql, true);
2✔
2801
        }
2802

2803
        if ($resetData) {
800✔
2804
            $this->resetWrite();
800✔
2805
        }
2806

2807
        return $this->testMode ? $sql : $this->db->query($sql, $this->binds, false);
800✔
2808
    }
2809

2810
    /**
2811
     * Sets data and calls batchExecute to run queries
2812
     *
2813
     * @param array|object|null $set         a dataset
2814
     * @param array|RawSql|null $constraints
2815
     *
2816
     * @return false|int|list<string> Number of rows affected or FALSE on failure, SQL array when testMode
2817
     */
2818
    public function deleteBatch($set = null, $constraints = null, int $batchSize = 100)
2819
    {
2820
        $this->onConstraint($constraints);
3✔
2821

2822
        if (isset($this->QBOptions['setQueryAsData'])) {
3✔
2823
            $sql = $this->_deleteBatch($this->QBFrom[0], $this->QBKeys, []);
1✔
2824

2825
            if ($sql === '') {
1✔
2826
                return false; // @codeCoverageIgnore
2827
            }
2828

2829
            if ($this->testMode === false) {
1✔
2830
                $this->db->query($sql, null, false);
1✔
2831
            }
2832

2833
            $this->resetWrite();
1✔
2834

2835
            return $this->testMode ? $sql : $this->db->affectedRows();
1✔
2836
        }
2837

2838
        if ($set !== null && $set !== []) {
2✔
2839
            $this->setData($set, true);
×
2840
        }
2841

2842
        return $this->batchExecute('_deleteBatch', $batchSize);
2✔
2843
    }
2844

2845
    /**
2846
     * Generates a platform-specific batch update string from the supplied data
2847
     *
2848
     * @used-by batchExecute()
2849
     *
2850
     * @param string           $table  Protected table name
2851
     * @param list<string>     $keys   QBKeys
2852
     * @param list<int|string> $values QBSet
2853
     */
2854
    protected function _deleteBatch(string $table, array $keys, array $values): string
2855
    {
2856
        $sql = $this->QBOptions['sql'] ?? '';
3✔
2857

2858
        // if this is the first iteration of batch then we need to build skeleton sql
2859
        if ($sql === '') {
3✔
2860
            $constraints = $this->QBOptions['constraints'] ?? [];
3✔
2861

2862
            if ($constraints === []) {
3✔
2863
                if ($this->db->DBDebug) {
×
2864
                    throw new DatabaseException('You must specify a constraint to match on for batch deletes.'); // @codeCoverageIgnore
2865
                }
2866

2867
                return ''; // @codeCoverageIgnore
2868
            }
2869

2870
            $alias = $this->QBOptions['alias'] ?? '_u';
3✔
2871

2872
            $sql = 'DELETE ' . $table . ' FROM ' . $table . "\n";
3✔
2873

2874
            $sql .= "INNER JOIN (\n{:_table_:}";
3✔
2875

2876
            $sql .= ') ' . $alias . "\n";
3✔
2877

2878
            $sql .= 'ON ' . implode(
3✔
2879
                ' AND ',
3✔
2880
                array_map(
3✔
2881
                    static fn ($key, $value) => (
3✔
2882
                        $value instanceof RawSql ?
3✔
2883
                        $value :
×
2884
                        (
3✔
2885
                            is_string($key) ?
3✔
2886
                            $table . '.' . $key . ' = ' . $alias . '.' . $value :
2✔
2887
                            $table . '.' . $value . ' = ' . $alias . '.' . $value
3✔
2888
                        )
3✔
2889
                    ),
3✔
2890
                    array_keys($constraints),
3✔
2891
                    $constraints,
3✔
2892
                ),
3✔
2893
            );
3✔
2894

2895
            // convert binds in where
2896
            foreach ($this->QBWhere as $key => $where) {
3✔
2897
                foreach ($this->binds as $field => $bind) {
2✔
2898
                    $this->QBWhere[$key]['condition'] = str_replace(':' . $field . ':', $bind[0], $where['condition']);
×
2899
                }
2900
            }
2901

2902
            $sql .= ' ' . $this->compileWhereHaving('QBWhere');
3✔
2903

2904
            $this->QBOptions['sql'] = trim($sql);
3✔
2905
        }
2906

2907
        if (isset($this->QBOptions['setQueryAsData'])) {
3✔
2908
            $data = $this->QBOptions['setQueryAsData'];
1✔
2909
        } else {
2910
            $data = implode(
2✔
2911
                " UNION ALL\n",
2✔
2912
                array_map(
2✔
2913
                    static fn ($value): string => 'SELECT ' . implode(', ', array_map(
2✔
2914
                        static fn ($key, $index): string => $index . ' ' . $key,
2✔
2915
                        $keys,
2✔
2916
                        $value,
2✔
2917
                    )),
2✔
2918
                    $values,
2✔
2919
                ),
2✔
2920
            ) . "\n";
2✔
2921
        }
2922

2923
        return str_replace('{:_table_:}', $data, $sql);
3✔
2924
    }
2925

2926
    /**
2927
     * Increments a numeric column by the specified value.
2928
     *
2929
     * @return bool
2930
     */
2931
    public function increment(string $column, int $value = 1)
2932
    {
2933
        return $this->incrementMany([$column], $value);
3✔
2934
    }
2935

2936
    /**
2937
     * Increments multiple numeric columns by the specified value(s).
2938
     *
2939
     * @param array<string, int>|list<string> $columns A list of columns or array of column => value pairs to increment.
2940
     * @param int                             $value   The value to increment by if $columns is a list of column names.
2941
     */
2942
    public function incrementMany(array $columns, int $value = 1): bool
2943
    {
2944
        if ($columns === []) {
10✔
2945
            throw new InvalidArgumentException('Argument #1 ($columns) cannot be empty.');
1✔
2946
        }
2947

2948
        if (array_is_list($columns)) {
9✔
2949
            $columns = array_fill_keys($columns, $value);
5✔
2950
        }
2951

2952
        $fields = [];
9✔
2953

2954
        foreach ($columns as $col => $val) {
9✔
2955
            if (! is_int($val)) {
9✔
2956
                throw new TypeError(sprintf(
1✔
2957
                    'Argument #1 ($columns) must contain only int values, %s given for "%s".',
1✔
2958
                    get_debug_type($val),
1✔
2959
                    $col,
1✔
2960
                ));
1✔
2961
            }
2962

2963
            $col          = $this->db->protectIdentifiers($col);
9✔
2964
            $fields[$col] = "{$col} + {$val}";
9✔
2965
        }
2966

2967
        $sql = $this->_update($this->QBFrom[0], $fields);
8✔
2968

2969
        if (! $this->testMode) {
8✔
2970
            $this->resetWrite();
8✔
2971

2972
            return $this->db->query($sql, $this->binds, false);
8✔
2973
        }
2974

UNCOV
2975
        return true;
×
2976
    }
2977

2978
    /**
2979
     * Decrements a numeric column by the specified value.
2980
     *
2981
     * @return bool
2982
     */
2983
    public function decrement(string $column, int $value = 1)
2984
    {
2985
        return $this->decrementMany([$column], $value);
3✔
2986
    }
2987

2988
    /**
2989
     * Decrements multiple numeric columns by the specified value(s).
2990
     *
2991
     * @param array<string, int>|list<string> $columns A list of columns or array of column => value pairs to decrement.
2992
     * @param int                             $value   The value to decrement by if $columns is a list of column names.
2993
     */
2994
    public function decrementMany(array $columns, int $value = 1): bool
2995
    {
2996
        if ($columns === []) {
10✔
2997
            throw new InvalidArgumentException('Argument #1 ($columns) cannot be empty.');
1✔
2998
        }
2999

3000
        if (array_is_list($columns)) {
9✔
3001
            $columns = array_fill_keys($columns, $value);
5✔
3002
        }
3003

3004
        $fields = [];
9✔
3005

3006
        foreach ($columns as $col => $val) {
9✔
3007
            if (! is_int($val)) {
9✔
3008
                throw new TypeError(sprintf(
1✔
3009
                    'Argument #1 ($columns) must contain only int values, %s given for "%s".',
1✔
3010
                    get_debug_type($val),
1✔
3011
                    $col,
1✔
3012
                ));
1✔
3013
            }
3014

3015
            $col          = $this->db->protectIdentifiers($col);
9✔
3016
            $fields[$col] = "{$col} - {$val}";
9✔
3017
        }
3018

3019
        $sql = $this->_update($this->QBFrom[0], $fields);
8✔
3020

3021
        if (! $this->testMode) {
8✔
3022
            $this->resetWrite();
8✔
3023

3024
            return $this->db->query($sql, $this->binds, false);
8✔
3025
        }
3026

3027
        return true;
×
3028
    }
3029

3030
    /**
3031
     * Generates a platform-specific delete string from the supplied data
3032
     *
3033
     * @param string $table Protected table name
3034
     */
3035
    protected function _delete(string $table): string
3036
    {
3037
        return 'DELETE ' . $this->compileIgnore('delete') . 'FROM ' . $table . $this->compileWhereHaving('QBWhere');
803✔
3038
    }
3039

3040
    /**
3041
     * Used to track SQL statements written with aliased tables.
3042
     *
3043
     * @param array|string $table The table to inspect
3044
     *
3045
     * @return string|null
3046
     */
3047
    protected function trackAliases($table)
3048
    {
3049
        if (is_array($table)) {
1,122✔
3050
            foreach ($table as $t) {
×
3051
                $this->trackAliases($t);
×
3052
            }
3053

3054
            return null;
×
3055
        }
3056

3057
        // Does the string contain a comma?  If so, we need to separate
3058
        // the string into discreet statements
3059
        if (str_contains($table, ',')) {
1,122✔
UNCOV
3060
            return $this->trackAliases(explode(',', $table));
×
3061
        }
3062

3063
        // if a table alias is used we can recognize it by a space
3064
        if (str_contains($table, ' ')) {
1,122✔
3065
            // if the alias is written with the AS keyword, remove it
3066
            $table = preg_replace('/\s+AS\s+/i', ' ', $table);
16✔
3067

3068
            // Grab the alias
3069
            $alias = trim(strrchr($table, ' '));
16✔
3070

3071
            // Store the alias, if it doesn't already exist
3072
            $this->db->addTableAlias($alias);
16✔
3073
        }
3074

3075
        return null;
1,122✔
3076
    }
3077

3078
    /**
3079
     * Compile the SELECT statement
3080
     *
3081
     * Generates a query string based on which functions were used.
3082
     * Should not be called directly.
3083
     *
3084
     * @param mixed $selectOverride
3085
     */
3086
    protected function compileSelect($selectOverride = false): string
3087
    {
3088
        if ($selectOverride !== false) {
1,056✔
3089
            $sql = $selectOverride;
225✔
3090
        } else {
3091
            $sql = $this->QBDistinct ? 'SELECT DISTINCT ' : 'SELECT ';
1,053✔
3092

3093
            if (empty($this->QBSelect)) {
1,053✔
3094
                $sql .= '*';
944✔
3095
            } else {
3096
                // Cycle through the "select" portion of the query and prep each column name.
3097
                // The reason we protect identifiers here rather than in the select() function
3098
                // is because until the user calls the from() function we don't know if there are aliases
3099
                foreach ($this->QBSelect as $key => $val) {
929✔
3100
                    if ($val instanceof RawSql) {
929✔
3101
                        $this->QBSelect[$key] = (string) $val;
5✔
3102
                    } else {
3103
                        $protect              = $this->QBNoEscape[$key] ?? null;
927✔
3104
                        $this->QBSelect[$key] = $this->db->protectIdentifiers($val, false, $protect);
927✔
3105
                    }
3106
                }
3107

3108
                $sql .= implode(', ', $this->QBSelect);
929✔
3109
            }
3110
        }
3111

3112
        if (! empty($this->QBFrom)) {
1,056✔
3113
            $sql .= "\nFROM " . $this->_fromTables();
1,056✔
3114
        }
3115

3116
        if (! empty($this->QBJoin)) {
1,056✔
3117
            $sql .= "\n" . implode("\n", $this->QBJoin);
15✔
3118
        }
3119

3120
        $sql .= $this->compileWhereHaving('QBWhere')
1,056✔
3121
            . $this->compileGroupBy()
1,056✔
3122
            . $this->compileWhereHaving('QBHaving')
1,056✔
3123
            . $this->compileOrderBy();
1,056✔
3124

3125
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
1,056✔
3126
        if ($limitZeroAsAll) {
1,056✔
3127
            if ($this->QBLimit) {
1,055✔
3128
                $sql = $this->_limit($sql . "\n");
105✔
3129
            }
3130
        } elseif ($this->QBLimit !== false || $this->QBOffset) {
2✔
3131
            $sql = $this->_limit($sql . "\n");
2✔
3132
        }
3133

3134
        return $this->unionInjection($sql);
1,056✔
3135
    }
3136

3137
    /**
3138
     * Checks if the ignore option is supported by
3139
     * the Database Driver for the specific statement.
3140
     *
3141
     * @return string
3142
     */
3143
    protected function compileIgnore(string $statement)
3144
    {
3145
        if ($this->QBIgnore && isset($this->supportedIgnoreStatements[$statement])) {
864✔
3146
            return trim($this->supportedIgnoreStatements[$statement]) . ' ';
1✔
3147
        }
3148

3149
        return '';
863✔
3150
    }
3151

3152
    /**
3153
     * Escapes identifiers in WHERE and HAVING statements at execution time.
3154
     *
3155
     * Required so that aliases are tracked properly, regardless of whether
3156
     * where(), orWhere(), having(), orHaving are called prior to from(),
3157
     * join() and prefixTable is added only if needed.
3158
     *
3159
     * @param string $qbKey 'QBWhere' or 'QBHaving'
3160
     *
3161
     * @return string SQL statement
3162
     */
3163
    protected function compileWhereHaving(string $qbKey): string
3164
    {
3165
        if (! empty($this->{$qbKey})) {
1,080✔
3166
            foreach ($this->{$qbKey} as &$qbkey) {
959✔
3167
                // Is this condition already compiled?
3168
                if (is_string($qbkey)) {
959✔
3169
                    continue;
23✔
3170
                }
3171

3172
                if ($qbkey instanceof RawSql) {
959✔
3173
                    continue;
2✔
3174
                }
3175

3176
                if ($qbkey['condition'] instanceof RawSql) {
959✔
3177
                    $qbkey = $qbkey['condition'];
4✔
3178

3179
                    continue;
4✔
3180
                }
3181

3182
                if ($qbkey['escape'] === false) {
957✔
3183
                    $qbkey = $qbkey['condition'];
107✔
3184

3185
                    continue;
107✔
3186
                }
3187

3188
                // Split multiple conditions
3189
                $conditions = preg_split(
947✔
3190
                    '/((?:^|\s+)AND\s+|(?:^|\s+)OR\s+)/i',
947✔
3191
                    $qbkey['condition'],
947✔
3192
                    -1,
947✔
3193
                    PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY,
947✔
3194
                );
947✔
3195

3196
                foreach ($conditions as &$condition) {
947✔
3197
                    $op = $this->getOperator($condition);
947✔
3198
                    if (
3199
                        $op === false
947✔
3200
                        || preg_match(
947✔
3201
                            '/^(\(?)(.*)(' . preg_quote($op, '/') . ')\s*(.*(?<!\)))?(\)?)$/i',
947✔
3202
                            $condition,
947✔
3203
                            $matches,
947✔
3204
                        ) !== 1
947✔
3205
                    ) {
3206
                        continue;
842✔
3207
                    }
3208

3209
                    // $matches = [
3210
                    //  0 => '(test <= foo)',   /* the whole thing */
3211
                    //  1 => '(',               /* optional */
3212
                    //  2 => 'test',            /* the field name */
3213
                    //  3 => ' <= ',            /* $op */
3214
                    //  4 => 'foo',                    /* optional, if $op is e.g. 'IS NULL' */
3215
                    //  5 => ')'                /* optional */
3216
                    // ];
3217

3218
                    if ($matches[4] !== '') {
947✔
3219
                        $protectIdentifiers = false;
910✔
3220
                        if (str_contains($matches[4], '.')) {
910✔
3221
                            $protectIdentifiers = true;
86✔
3222
                        }
3223

3224
                        if (! str_contains($matches[4], ':')) {
910✔
3225
                            $matches[4] = $this->db->protectIdentifiers(trim($matches[4]), false, $protectIdentifiers);
14✔
3226
                        }
3227

3228
                        $matches[4] = ' ' . $matches[4];
910✔
3229
                    }
3230

3231
                    $condition = $matches[1] . $this->db->protectIdentifiers(trim($matches[2]))
947✔
3232
                        . ' ' . trim($matches[3]) . $matches[4] . $matches[5];
947✔
3233
                }
3234

3235
                $qbkey = implode('', $conditions);
947✔
3236
            }
3237

3238
            return ($qbKey === 'QBHaving' ? "\nHAVING " : "\nWHERE ")
959✔
3239
                . implode("\n", $this->{$qbKey});
959✔
3240
        }
3241

3242
        return '';
1,062✔
3243
    }
3244

3245
    /**
3246
     * Escapes identifiers in GROUP BY statements at execution time.
3247
     *
3248
     * Required so that aliases are tracked properly, regardless of whether
3249
     * groupBy() is called prior to from(), join() and prefixTable is added
3250
     * only if needed.
3251
     */
3252
    protected function compileGroupBy(): string
3253
    {
3254
        if (! empty($this->QBGroupBy)) {
1,063✔
3255
            foreach ($this->QBGroupBy as &$groupBy) {
56✔
3256
                // Is it already compiled?
3257
                if (is_string($groupBy)) {
56✔
3258
                    continue;
2✔
3259
                }
3260

3261
                $groupBy = ($groupBy['escape'] === false || $this->isLiteral($groupBy['field']))
56✔
3262
                    ? $groupBy['field']
×
3263
                    : $this->db->protectIdentifiers($groupBy['field']);
56✔
3264
            }
3265

3266
            return "\nGROUP BY " . implode(', ', $this->QBGroupBy);
56✔
3267
        }
3268

3269
        return '';
1,037✔
3270
    }
3271

3272
    /**
3273
     * Escapes identifiers in ORDER BY statements at execution time.
3274
     *
3275
     * Required so that aliases are tracked properly, regardless of whether
3276
     * orderBy() is called prior to from(), join() and prefixTable is added
3277
     * only if needed.
3278
     */
3279
    protected function compileOrderBy(): string
3280
    {
3281
        if (is_array($this->QBOrderBy) && $this->QBOrderBy !== []) {
1,075✔
3282
            foreach ($this->QBOrderBy as &$orderBy) {
838✔
3283
                if (is_string($orderBy)) {
838✔
3284
                    continue;
1✔
3285
                }
3286
                if ($orderBy['escape'] !== false && ! $this->isLiteral($orderBy['field'])) {
838✔
3287
                    $orderBy['field'] = $this->db->protectIdentifiers($orderBy['field']);
836✔
3288
                }
3289

3290
                $orderBy = $orderBy['field'] . $orderBy['direction'];
838✔
3291
            }
3292

3293
            return "\nORDER BY " . implode(', ', $this->QBOrderBy);
838✔
3294
        }
3295

3296
        return '';
1,048✔
3297
    }
3298

3299
    protected function unionInjection(string $sql): string
3300
    {
3301
        if ($this->QBUnion === []) {
1,063✔
3302
            return $sql;
1,063✔
3303
        }
3304

3305
        return 'SELECT * FROM (' . $sql . ') '
6✔
3306
            . ($this->db->protectIdentifiers ? $this->db->escapeIdentifiers('uwrp0') : 'uwrp0')
6✔
3307
            . implode("\n", $this->QBUnion);
6✔
3308
    }
3309

3310
    /**
3311
     * Takes an object as input and converts the class variables to array key/vals
3312
     *
3313
     * @param array|object $object
3314
     *
3315
     * @return array
3316
     */
3317
    protected function objectToArray($object)
3318
    {
3319
        if (! is_object($object)) {
861✔
3320
            return $object;
858✔
3321
        }
3322

3323
        if ($object instanceof RawSql) {
8✔
3324
            throw new InvalidArgumentException('RawSql "' . $object . '" cannot be used here.');
1✔
3325
        }
3326

3327
        $array = [];
7✔
3328

3329
        foreach (get_object_vars($object) as $key => $val) {
7✔
3330
            if ((! is_object($val) || $val instanceof RawSql) && ! is_array($val)) {
7✔
3331
                $array[$key] = $val;
7✔
3332
            }
3333
        }
3334

3335
        return $array;
7✔
3336
    }
3337

3338
    /**
3339
     * Takes an object as input and converts the class variables to array key/vals
3340
     *
3341
     * @param array|object $object
3342
     *
3343
     * @return array
3344
     */
3345
    protected function batchObjectToArray($object)
3346
    {
3347
        if (! is_object($object)) {
68✔
3348
            return $object;
68✔
3349
        }
3350

3351
        $array  = [];
×
3352
        $out    = get_object_vars($object);
×
3353
        $fields = array_keys($out);
×
3354

3355
        foreach ($fields as $val) {
×
3356
            $i = 0;
×
3357

3358
            foreach ($out[$val] as $data) {
×
3359
                $array[$i++][$val] = $data;
×
3360
            }
3361
        }
3362

3363
        return $array;
×
3364
    }
3365

3366
    /**
3367
     * Determines if a string represents a literal value or a field name
3368
     */
3369
    protected function isLiteral(string $str): bool
3370
    {
3371
        $str = trim($str);
866✔
3372

3373
        if ($str === ''
866✔
3374
            || ctype_digit($str)
866✔
3375
            || (string) (float) $str === $str
866✔
3376
            || in_array(strtoupper($str), ['TRUE', 'FALSE'], true)
866✔
3377
        ) {
3378
            return true;
×
3379
        }
3380

3381
        if ($this->isLiteralStr === []) {
866✔
3382
            $this->isLiteralStr = $this->db->escapeChar !== '"' ? ['"', "'"] : ["'"];
866✔
3383
        }
3384

3385
        return in_array($str[0], $this->isLiteralStr, true);
866✔
3386
    }
3387

3388
    /**
3389
     * Publicly-visible method to reset the QB values.
3390
     *
3391
     * @return $this
3392
     */
3393
    public function resetQuery()
3394
    {
3395
        $this->resetSelect();
1✔
3396
        $this->resetWrite();
1✔
3397

3398
        return $this;
1✔
3399
    }
3400

3401
    /**
3402
     * Resets the query builder values.  Called by the get() function
3403
     *
3404
     * @param array $qbResetItems An array of fields to reset
3405
     *
3406
     * @return void
3407
     */
3408
    protected function resetRun(array $qbResetItems)
3409
    {
3410
        foreach ($qbResetItems as $item => $defaultValue) {
1,085✔
3411
            $this->{$item} = $defaultValue;
1,085✔
3412
        }
3413
    }
3414

3415
    /**
3416
     * Resets the query builder values.  Called by the get() function
3417
     *
3418
     * @return void
3419
     */
3420
    protected function resetSelect()
3421
    {
3422
        $this->resetRun([
1,058✔
3423
            'QBSelect'   => [],
1,058✔
3424
            'QBJoin'     => [],
1,058✔
3425
            'QBWhere'    => [],
1,058✔
3426
            'QBGroupBy'  => [],
1,058✔
3427
            'QBHaving'   => [],
1,058✔
3428
            'QBOrderBy'  => [],
1,058✔
3429
            'QBNoEscape' => [],
1,058✔
3430
            'QBDistinct' => false,
1,058✔
3431
            'QBLimit'    => false,
1,058✔
3432
            'QBOffset'   => false,
1,058✔
3433
            'QBUnion'    => [],
1,058✔
3434
        ]);
1,058✔
3435

3436
        if ($this->db instanceof BaseConnection) {
1,058✔
3437
            $this->db->setAliasedTables([]);
1,058✔
3438
        }
3439

3440
        // Reset QBFrom part
3441
        if (! empty($this->QBFrom)) {
1,058✔
3442
            $this->from(array_shift($this->QBFrom), true);
1,058✔
3443
        }
3444
    }
3445

3446
    /**
3447
     * Resets the query builder "write" values.
3448
     *
3449
     * Called by the insert() update() insertBatch() updateBatch() and delete() functions
3450
     *
3451
     * @return void
3452
     */
3453
    protected function resetWrite()
3454
    {
3455
        $this->resetRun([
868✔
3456
            'QBSet'     => [],
868✔
3457
            'QBJoin'    => [],
868✔
3458
            'QBWhere'   => [],
868✔
3459
            'QBOrderBy' => [],
868✔
3460
            'QBKeys'    => [],
868✔
3461
            'QBLimit'   => false,
868✔
3462
            'QBIgnore'  => false,
868✔
3463
            'QBOptions' => [],
868✔
3464
        ]);
868✔
3465
    }
3466

3467
    /**
3468
     * Tests whether the string has an SQL operator
3469
     */
3470
    protected function hasOperator(string $str): bool
3471
    {
3472
        return preg_match(
157✔
3473
            '/(<|>|!|=|\sIS NULL|\sIS NOT NULL|\sEXISTS|\sBETWEEN|\sLIKE|\sIN\s*\(|\s)/i',
157✔
3474
            trim($str),
157✔
3475
        ) === 1;
157✔
3476
    }
3477

3478
    /**
3479
     * Returns the SQL string operator
3480
     *
3481
     * @return array|false|string
3482
     */
3483
    protected function getOperator(string $str, bool $list = false)
3484
    {
3485
        if ($this->pregOperators === []) {
956✔
3486
            $_les = $this->db->likeEscapeStr !== ''
956✔
3487
                ? '\s+' . preg_quote(trim(sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar)), '/')
956✔
3488
                : '';
×
3489
            $this->pregOperators = [
956✔
3490
                '\s*(?:<|>|!)?=\s*', // =, <=, >=, !=
956✔
3491
                '\s*<>?\s*',         // <, <>
956✔
3492
                '\s*>\s*',           // >
956✔
3493
                '\s+IS NULL',             // IS NULL
956✔
3494
                '\s+IS NOT NULL',         // IS NOT NULL
956✔
3495
                '\s+EXISTS\s*\(.*\)',     // EXISTS (sql)
956✔
3496
                '\s+NOT EXISTS\s*\(.*\)', // NOT EXISTS(sql)
956✔
3497
                '\s+BETWEEN\s+',          // BETWEEN value AND value
956✔
3498
                '\s+IN\s*\(.*\)',         // IN (list)
956✔
3499
                '\s+NOT IN\s*\(.*\)',     // NOT IN (list)
956✔
3500
                '\s+LIKE\s+\S.*(' . $_les . ')?',     // LIKE 'expr'[ ESCAPE '%s']
956✔
3501
                '\s+NOT LIKE\s+\S.*(' . $_les . ')?', // NOT LIKE 'expr'[ ESCAPE '%s']
956✔
3502
            ];
956✔
3503
        }
3504

3505
        return preg_match_all(
956✔
3506
            '/' . implode('|', $this->pregOperators) . '/i',
956✔
3507
            $str,
956✔
3508
            $match,
956✔
3509
        ) >= 1 ? ($list ? $match[0] : $match[0][0]) : false;
956✔
3510
    }
3511

3512
    /**
3513
     * Returns the SQL string operator from where key
3514
     *
3515
     * @return false|list<string>
3516
     */
3517
    private function getOperatorFromWhereKey(string $whereKey)
3518
    {
3519
        $whereKey = trim($whereKey);
909✔
3520

3521
        $pregOperators = [
909✔
3522
            '\s*(?:<|>|!)?=',         // =, <=, >=, !=
909✔
3523
            '\s*<>?',                 // <, <>
909✔
3524
            '\s*>',                   // >
909✔
3525
            '\s+IS NULL',             // IS NULL
909✔
3526
            '\s+IS NOT NULL',         // IS NOT NULL
909✔
3527
            '\s+EXISTS\s*\(.*\)',     // EXISTS (sql)
909✔
3528
            '\s+NOT EXISTS\s*\(.*\)', // NOT EXISTS (sql)
909✔
3529
            '\s+BETWEEN\s+',          // BETWEEN value AND value
909✔
3530
            '\s+IN\s*\(.*\)',         // IN (list)
909✔
3531
            '\s+NOT IN\s*\(.*\)',     // NOT IN (list)
909✔
3532
            '\s+LIKE',                // LIKE
909✔
3533
            '\s+NOT LIKE',            // NOT LIKE
909✔
3534
        ];
909✔
3535

3536
        return preg_match_all(
909✔
3537
            '/' . implode('|', $pregOperators) . '/i',
909✔
3538
            $whereKey,
909✔
3539
            $match,
909✔
3540
        ) >= 1 ? $match[0] : false;
909✔
3541
    }
3542

3543
    /**
3544
     * Stores a bind value after ensuring that it's unique.
3545
     * While it might be nicer to have named keys for our binds array
3546
     * with PHP 7+ we get a huge memory/performance gain with indexed
3547
     * arrays instead, so lets take advantage of that here.
3548
     *
3549
     * @param mixed $value
3550
     */
3551
    protected function setBind(string $key, $value = null, bool $escape = true): string
3552
    {
3553
        if (! array_key_exists($key, $this->binds)) {
956✔
3554
            $this->binds[$key] = [
956✔
3555
                $value,
956✔
3556
                $escape,
956✔
3557
            ];
956✔
3558

3559
            return $key;
956✔
3560
        }
3561

3562
        if (! array_key_exists($key, $this->bindsKeyCount)) {
45✔
3563
            $this->bindsKeyCount[$key] = 1;
45✔
3564
        }
3565

3566
        $count = $this->bindsKeyCount[$key]++;
45✔
3567

3568
        $this->binds[$key . '.' . $count] = [
45✔
3569
            $value,
45✔
3570
            $escape,
45✔
3571
        ];
45✔
3572

3573
        return $key . '.' . $count;
45✔
3574
    }
3575

3576
    /**
3577
     * @param mixed $value
3578
     */
3579
    protected function isSubquery($value): bool
3580
    {
3581
        return $value instanceof BaseBuilder || $value instanceof Closure;
918✔
3582
    }
3583

3584
    /**
3585
     * @param BaseBuilder|Closure(BaseBuilder): BaseBuilder $builder
3586
     * @param bool                                          $wrapped Wrap the subquery in brackets
3587
     * @param string                                        $alias   Subquery alias
3588
     */
3589
    protected function buildSubquery($builder, bool $wrapped = false, string $alias = ''): string
3590
    {
3591
        if ($builder instanceof Closure) {
21✔
3592
            $builder($builder = $this->db->newQuery());
11✔
3593
        }
3594

3595
        if ($builder === $this) {
21✔
3596
            throw new DatabaseException('The subquery cannot be the same object as the main query object.');
1✔
3597
        }
3598

3599
        $subquery = strtr($builder->getCompiledSelect(false), "\n", ' ');
20✔
3600

3601
        if ($wrapped) {
20✔
3602
            $subquery = '(' . $subquery . ')';
20✔
3603
            $alias    = trim($alias);
20✔
3604

3605
            if ($alias !== '') {
20✔
3606
                $subquery .= ' ' . ($this->db->protectIdentifiers ? $this->db->escapeIdentifiers($alias) : $alias);
11✔
3607
            }
3608
        }
3609

3610
        return $subquery;
20✔
3611
    }
3612
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc