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

codeigniter4 / CodeIgniter4 / 25607924014

09 May 2026 05:56PM UTC coverage: 88.403% (+0.1%) from 88.287%
25607924014

Pull #10162

github

web-flow
Merge e474eb9b7 into 2c774cae8
Pull Request #10162: feat: classify retryable transaction exceptions

35 of 36 new or added lines in 6 files covered. (97.22%)

110 existing lines in 6 files now uncovered.

23760 of 26877 relevant lines covered (88.4%)

218.62 hits per line

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

94.38
/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,137✔
UNCOV
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,137✔
316

317
        if ($tableName instanceof TableName) {
1,137✔
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,133✔
324
            $this->tableName = $tableName;  // @TODO remove alias if exists
1,131✔
325
            $this->from($tableName);
1,131✔
326
        } else {
327
            $this->tableName = '';
16✔
328
            $this->from($tableName);
16✔
329
        }
330

331
        if ($options !== null && $options !== []) {
1,137✔
332
            foreach ($options as $key => $value) {
×
333
                if (property_exists($this, $key)) {
×
UNCOV
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;
58✔
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)) {
906✔
406
            $escape = $this->db->protectIdentifiers;
903✔
407
        }
408

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

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

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

422
                continue;
5✔
423
            }
424

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

427
            if ($val !== '') {
904✔
428
                $this->QBSelect[] = $val;
904✔
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) {
904✔
438
                    $this->QBNoEscape[] = false;
2✔
439

440
                    continue;
2✔
441
                }
442

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

447
        return $this;
906✔
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);
810✔
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 === '') {
820✔
526
            throw DataException::forEmptyInputGiven('Select');
1✔
527
        }
528

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

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

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

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

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

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

548
        return $this;
818✔
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, '.')) {
813✔
557
            $item = explode('.', $item);
1✔
558

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

562
        return $item;
812✔
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;
816✔
573

574
        return $this;
816✔
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,137✔
587
            $this->QBFrom = [];
1,071✔
588
            $this->db->setAliasedTables([]);
1,071✔
589
        }
590

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

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

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

606
        return $this;
1,137✔
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✔
UNCOV
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✔
UNCOV
664
            $cond = ' USING (' . ($escape ? $this->db->escapeIdentifiers($cond) : $cond) . ')';
×
665
        } elseif ($escape === false) {
14✔
UNCOV
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
     * Generates a WHERE clause that compares two columns.
742
     *
743
     * @param non-empty-string $first  First column name, optionally with comparison operator
744
     * @param non-empty-string $second Second column name
745
     * @param bool|null        $escape Whether to protect identifiers
746
     *
747
     * @return $this
748
     *
749
     * @throws InvalidArgumentException
750
     */
751
    public function whereColumn(string $first, string $second, ?bool $escape = null): static
752
    {
753
        return $this->whereColumnHaving('QBWhere', $first, $second, 'AND ', $escape);
16✔
754
    }
755

756
    /**
757
     * Generates an OR WHERE clause that compares two columns.
758
     *
759
     * @param non-empty-string $first  First column name, optionally with comparison operator
760
     * @param non-empty-string $second Second column name
761
     * @param bool|null        $escape Whether to protect identifiers
762
     *
763
     * @return $this
764
     *
765
     * @throws InvalidArgumentException
766
     */
767
    public function orWhereColumn(string $first, string $second, ?bool $escape = null): static
768
    {
769
        return $this->whereColumnHaving('QBWhere', $first, $second, 'OR ', $escape);
2✔
770
    }
771

772
    /**
773
     * @used-by whereColumn()
774
     * @used-by orWhereColumn()
775
     *
776
     * @param 'QBHaving'|'QBWhere' $qbKey
777
     * @param non-empty-string     $first  First column name, optionally with comparison operator
778
     * @param non-empty-string     $second Second column name
779
     * @param non-empty-string     $type
780
     * @param bool|null            $escape Whether to protect identifiers
781
     *
782
     * @return $this
783
     *
784
     * @throws InvalidArgumentException
785
     */
786
    protected function whereColumnHaving(string $qbKey, string $first, string $second, string $type = 'AND ', ?bool $escape = null): static
787
    {
788
        [$first, $operator] = $this->parseWhereColumnFirst($first);
17✔
789
        $second             = trim($second);
17✔
790

791
        if ($first === '' || $second === '') {
17✔
792
            $caller = debug_backtrace(0, 2)[1]['function'];
2✔
793

794
            throw new InvalidArgumentException(sprintf('%s() expects $first and $second to be non-empty strings', $caller));
2✔
795
        }
796

797
        $escape ??= $this->db->protectIdentifiers;
15✔
798

799
        $prefix = $this->{$qbKey} === [] ? $this->groupGetType('') : $this->groupGetType($type);
15✔
800

801
        $this->{$qbKey}[] = [
15✔
802
            'columnComparison' => true,
15✔
803
            'condition'        => $prefix,
15✔
804
            'escape'           => $escape,
15✔
805
            'first'            => $first,
15✔
806
            'operator'         => $operator,
15✔
807
            'second'           => $second,
15✔
808
        ];
15✔
809

810
        return $this;
15✔
811
    }
812

813
    /**
814
     * Extracts the operator from the first whereColumn() column.
815
     *
816
     * @param string $first The first column, optionally ending with a comparison operator
817
     *
818
     * @return array{string, string}
819
     */
820
    private function parseWhereColumnFirst(string $first): array
821
    {
822
        $first = trim($first);
17✔
823

824
        if (preg_match('/\s*(!=|<>|<=|>=|=|<|>)\s*$/', $first, $match) === 1) {
17✔
825
            return [rtrim(substr($first, 0, -strlen($match[0]))), trim($match[1])];
12✔
826
        }
827

828
        return [$first, '='];
6✔
829
    }
830

831
    /**
832
     * @used-by where()
833
     * @used-by orWhere()
834
     * @used-by having()
835
     * @used-by orHaving()
836
     *
837
     * @param array|RawSql|string $key
838
     * @param mixed               $value
839
     *
840
     * @return $this
841
     */
842
    protected function whereHaving(string $qbKey, $key, $value = null, string $type = 'AND ', ?bool $escape = null)
843
    {
844
        $rawSqlOnly = false;
930✔
845

846
        if ($key instanceof RawSql) {
930✔
847
            if ($value === null) {
4✔
848
                $keyValue   = [(string) $key => $key];
1✔
849
                $rawSqlOnly = true;
1✔
850
            } else {
851
                $keyValue = [(string) $key => $value];
3✔
852
            }
853
        } elseif (! is_array($key)) {
926✔
854
            $keyValue = [$key => $value];
916✔
855
        } else {
856
            $keyValue = $key;
235✔
857
        }
858

859
        // If the escape value was not set will base it on the global setting
860
        if (! is_bool($escape)) {
930✔
861
            $escape = $this->db->protectIdentifiers;
926✔
862
        }
863

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

867
            if ($rawSqlOnly) {
930✔
868
                $k  = '';
1✔
869
                $op = '';
1✔
870
            } elseif ($v !== null) {
929✔
871
                $op = $this->getOperatorFromWhereKey($k);
909✔
872

873
                if (! empty($op)) {
909✔
874
                    $k = trim($k);
49✔
875

876
                    end($op);
49✔
877
                    $op = trim(current($op));
49✔
878

879
                    // Does the key end with operator?
880
                    if (str_ends_with($k, $op)) {
49✔
881
                        $k  = rtrim(substr($k, 0, -strlen($op)));
49✔
882
                        $op = " {$op}";
49✔
883
                    } else {
UNCOV
884
                        $op = '';
×
885
                    }
886
                } else {
887
                    $op = ' =';
897✔
888
                }
889

890
                if ($this->isSubquery($v)) {
909✔
891
                    $v = $this->buildSubquery($v, true);
1✔
892
                } else {
893
                    $bind = $this->setBind($k, $v, $escape);
909✔
894
                    $v    = " :{$bind}:";
909✔
895
                }
896
            } elseif (! $this->hasOperator($k) && $qbKey !== 'QBHaving') {
147✔
897
                // value appears not to have been set, assign the test to IS NULL
898
                $op = ' IS NULL';
95✔
899
            } elseif (
900
                // The key ends with !=, =, <>, IS, IS NOT
901
                preg_match(
61✔
902
                    '/\s*(!?=|<>|IS(?:\s+NOT)?)\s*$/i',
61✔
903
                    $k,
61✔
904
                    $match,
61✔
905
                    PREG_OFFSET_CAPTURE,
61✔
906
                )
61✔
907
            ) {
908
                $k  = substr($k, 0, $match[0][1]);
1✔
909
                $op = $match[1][0] === '=' ? ' IS NULL' : ' IS NOT NULL';
1✔
910
            } else {
911
                $op = '';
60✔
912
            }
913

914
            if ($v instanceof RawSql) {
930✔
915
                $this->{$qbKey}[] = [
1✔
916
                    'condition' => $v->with($prefix . $k . $op . $v),
1✔
917
                    'escape'    => $escape,
1✔
918
                ];
1✔
919
            } else {
920
                $this->{$qbKey}[] = [
929✔
921
                    'condition' => $prefix . $k . $op . $v,
929✔
922
                    'escape'    => $escape,
929✔
923
                ];
929✔
924
            }
925
        }
926

927
        return $this;
930✔
928
    }
929

930
    /**
931
     * Generates a WHERE field IN('item', 'item') SQL query,
932
     * joined with 'AND' 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 whereIn(?string $key = null, $values = null, ?bool $escape = null)
939
    {
940
        return $this->_whereIn($key, $values, false, 'AND ', $escape);
66✔
941
    }
942

943
    /**
944
     * Generates a WHERE field IN('item', 'item') SQL query,
945
     * joined with 'OR' if appropriate.
946
     *
947
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
948
     *
949
     * @return $this
950
     */
951
    public function orWhereIn(?string $key = null, $values = null, ?bool $escape = null)
952
    {
953
        return $this->_whereIn($key, $values, false, 'OR ', $escape);
3✔
954
    }
955

956
    /**
957
     * Generates a WHERE field NOT IN('item', 'item') SQL query,
958
     * joined with 'AND' if appropriate.
959
     *
960
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
961
     *
962
     * @return $this
963
     */
964
    public function whereNotIn(?string $key = null, $values = null, ?bool $escape = null)
965
    {
966
        return $this->_whereIn($key, $values, true, 'AND ', $escape);
3✔
967
    }
968

969
    /**
970
     * Generates a WHERE field NOT IN('item', 'item') SQL query,
971
     * joined with 'OR' if appropriate.
972
     *
973
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
974
     *
975
     * @return $this
976
     */
977
    public function orWhereNotIn(?string $key = null, $values = null, ?bool $escape = null)
978
    {
979
        return $this->_whereIn($key, $values, true, 'OR ', $escape);
2✔
980
    }
981

982
    /**
983
     * Generates a HAVING field IN('item', 'item') SQL query,
984
     * joined with 'AND' if appropriate.
985
     *
986
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
987
     *
988
     * @return $this
989
     */
990
    public function havingIn(?string $key = null, $values = null, ?bool $escape = null)
991
    {
992
        return $this->_whereIn($key, $values, false, 'AND ', $escape, 'QBHaving');
6✔
993
    }
994

995
    /**
996
     * Generates a HAVING field IN('item', 'item') SQL query,
997
     * joined with 'OR' if appropriate.
998
     *
999
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1000
     *
1001
     * @return $this
1002
     */
1003
    public function orHavingIn(?string $key = null, $values = null, ?bool $escape = null)
1004
    {
1005
        return $this->_whereIn($key, $values, false, 'OR ', $escape, 'QBHaving');
3✔
1006
    }
1007

1008
    /**
1009
     * Generates a HAVING field NOT IN('item', 'item') SQL query,
1010
     * joined with 'AND' if appropriate.
1011
     *
1012
     * @param array|BaseBuilder|(Closure(BaseBuilder):BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1013
     *
1014
     * @return $this
1015
     */
1016
    public function havingNotIn(?string $key = null, $values = null, ?bool $escape = null)
1017
    {
1018
        return $this->_whereIn($key, $values, true, 'AND ', $escape, 'QBHaving');
5✔
1019
    }
1020

1021
    /**
1022
     * Generates a HAVING field NOT IN('item', 'item') SQL query,
1023
     * joined with 'OR' if appropriate.
1024
     *
1025
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1026
     *
1027
     * @return $this
1028
     */
1029
    public function orHavingNotIn(?string $key = null, $values = null, ?bool $escape = null)
1030
    {
1031
        return $this->_whereIn($key, $values, true, 'OR ', $escape, 'QBHaving');
3✔
1032
    }
1033

1034
    /**
1035
     * @used-by WhereIn()
1036
     * @used-by orWhereIn()
1037
     * @used-by whereNotIn()
1038
     * @used-by orWhereNotIn()
1039
     *
1040
     * @param non-empty-string|null                                            $key
1041
     * @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|list<mixed>|null $values The values searched on, or anonymous function with subquery
1042
     *
1043
     * @return $this
1044
     *
1045
     * @throws InvalidArgumentException
1046
     */
1047
    protected function _whereIn(?string $key = null, $values = null, bool $not = false, string $type = 'AND ', ?bool $escape = null, string $clause = 'QBWhere')
1048
    {
1049
        if ($key === null || $key === '') {
86✔
1050
            throw new InvalidArgumentException(sprintf('%s() expects $key to be a non-empty string', debug_backtrace(0, 2)[1]['function']));
2✔
1051
        }
1052

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

1057
        if (! is_bool($escape)) {
81✔
1058
            $escape = $this->db->protectIdentifiers;
81✔
1059
        }
1060

1061
        $ok = $key;
81✔
1062

1063
        if ($escape === true) {
81✔
1064
            $key = $this->db->protectIdentifiers($key);
81✔
1065
        }
1066

1067
        $not = ($not) ? ' NOT' : '';
81✔
1068

1069
        if ($this->isSubquery($values)) {
81✔
1070
            $whereIn = $this->buildSubquery($values, true);
8✔
1071
            $escape  = false;
8✔
1072
        } else {
1073
            $whereIn = array_values($values);
73✔
1074
        }
1075

1076
        $ok = $this->setBind($ok, $whereIn, $escape);
81✔
1077

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

1080
        $whereIn = [
81✔
1081
            'condition' => "{$prefix}{$key}{$not} IN :{$ok}:",
81✔
1082
            'escape'    => false,
81✔
1083
        ];
81✔
1084

1085
        $this->{$clause}[] = $whereIn;
81✔
1086

1087
        return $this;
81✔
1088
    }
1089

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

1103
    /**
1104
     * Generates a NOT LIKE portion of the query.
1105
     * Separates multiple calls with 'AND'.
1106
     *
1107
     * @param array|RawSql|string $field
1108
     *
1109
     * @return $this
1110
     */
1111
    public function notLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1112
    {
1113
        return $this->_like($field, $match, 'AND ', $side, 'NOT', $escape, $insensitiveSearch);
2✔
1114
    }
1115

1116
    /**
1117
     * Generates a %LIKE% portion of the query.
1118
     * Separates multiple calls with 'OR'.
1119
     *
1120
     * @param array|RawSql|string $field
1121
     *
1122
     * @return $this
1123
     */
1124
    public function orLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1125
    {
1126
        return $this->_like($field, $match, 'OR ', $side, '', $escape, $insensitiveSearch);
2✔
1127
    }
1128

1129
    /**
1130
     * Generates a NOT LIKE portion of the query.
1131
     * Separates multiple calls with 'OR'.
1132
     *
1133
     * @param array|RawSql|string $field
1134
     *
1135
     * @return $this
1136
     */
1137
    public function orNotLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1138
    {
1139
        return $this->_like($field, $match, 'OR ', $side, 'NOT', $escape, $insensitiveSearch);
2✔
1140
    }
1141

1142
    /**
1143
     * Generates a %LIKE% portion of the query.
1144
     * Separates multiple calls with 'AND'.
1145
     *
1146
     * @param array|RawSql|string $field
1147
     *
1148
     * @return $this
1149
     */
1150
    public function havingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1151
    {
1152
        return $this->_like($field, $match, 'AND ', $side, '', $escape, $insensitiveSearch, 'QBHaving');
15✔
1153
    }
1154

1155
    /**
1156
     * Generates a NOT LIKE portion of the query.
1157
     * Separates multiple calls with 'AND'.
1158
     *
1159
     * @param array|RawSql|string $field
1160
     *
1161
     * @return $this
1162
     */
1163
    public function notHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1164
    {
1165
        return $this->_like($field, $match, 'AND ', $side, 'NOT', $escape, $insensitiveSearch, 'QBHaving');
4✔
1166
    }
1167

1168
    /**
1169
     * Generates a %LIKE% portion of the query.
1170
     * Separates multiple calls with 'OR'.
1171
     *
1172
     * @param array|RawSql|string $field
1173
     *
1174
     * @return $this
1175
     */
1176
    public function orHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1177
    {
1178
        return $this->_like($field, $match, 'OR ', $side, '', $escape, $insensitiveSearch, 'QBHaving');
4✔
1179
    }
1180

1181
    /**
1182
     * Generates a NOT LIKE portion of the query.
1183
     * Separates multiple calls with 'OR'.
1184
     *
1185
     * @param array|RawSql|string $field
1186
     *
1187
     * @return $this
1188
     */
1189
    public function orNotHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1190
    {
1191
        return $this->_like($field, $match, 'OR ', $side, 'NOT', $escape, $insensitiveSearch, 'QBHaving');
4✔
1192
    }
1193

1194
    /**
1195
     * @used-by like()
1196
     * @used-by orLike()
1197
     * @used-by notLike()
1198
     * @used-by orNotLike()
1199
     * @used-by havingLike()
1200
     * @used-by orHavingLike()
1201
     * @used-by notHavingLike()
1202
     * @used-by orNotHavingLike()
1203
     *
1204
     * @param array<string, string>|RawSql|string $field
1205
     *
1206
     * @return $this
1207
     */
1208
    protected function _like($field, string $match = '', string $type = 'AND ', string $side = 'both', string $not = '', ?bool $escape = null, bool $insensitiveSearch = false, string $clause = 'QBWhere')
1209
    {
1210
        $escape = is_bool($escape) ? $escape : $this->db->protectIdentifiers;
47✔
1211
        $side   = strtolower($side);
47✔
1212

1213
        if ($field instanceof RawSql) {
47✔
1214
            $k                 = (string) $field;
3✔
1215
            $v                 = $match;
3✔
1216
            $insensitiveSearch = false;
3✔
1217

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

1220
            if ($side === 'none') {
3✔
UNCOV
1221
                $bind = $this->setBind($field->getBindingKey(), $v, $escape);
×
1222
            } elseif ($side === 'before') {
3✔
UNCOV
1223
                $bind = $this->setBind($field->getBindingKey(), "%{$v}", $escape);
×
1224
            } elseif ($side === 'after') {
3✔
UNCOV
1225
                $bind = $this->setBind($field->getBindingKey(), "{$v}%", $escape);
×
1226
            } else {
1227
                $bind = $this->setBind($field->getBindingKey(), "%{$v}%", $escape);
3✔
1228
            }
1229

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

1232
            // some platforms require an escape sequence definition for LIKE wildcards
1233
            if ($escape === true && $this->db->likeEscapeStr !== '') {
3✔
1234
                $likeStatement .= sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar);
3✔
1235
            }
1236

1237
            $this->{$clause}[] = [
3✔
1238
                'condition' => $field->with($likeStatement),
3✔
1239
                'escape'    => $escape,
3✔
1240
            ];
3✔
1241

1242
            return $this;
3✔
1243
        }
1244

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

1247
        foreach ($keyValue as $k => $v) {
44✔
1248
            if ($insensitiveSearch) {
44✔
1249
                $v = mb_strtolower($v, 'UTF-8');
7✔
1250
            }
1251

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

1254
            if ($side === 'none') {
44✔
1255
                $bind = $this->setBind($k, $v, $escape);
1✔
1256
            } elseif ($side === 'before') {
43✔
1257
                $bind = $this->setBind($k, "%{$v}", $escape);
9✔
1258
            } elseif ($side === 'after') {
34✔
1259
                $bind = $this->setBind($k, "{$v}%", $escape);
5✔
1260
            } else {
1261
                $bind = $this->setBind($k, "%{$v}%", $escape);
29✔
1262
            }
1263

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

1266
            // some platforms require an escape sequence definition for LIKE wildcards
1267
            if ($escape === true && $this->db->likeEscapeStr !== '') {
44✔
1268
                $likeStatement .= sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar);
44✔
1269
            }
1270

1271
            $this->{$clause}[] = [
44✔
1272
                'condition' => $likeStatement,
44✔
1273
                'escape'    => $escape,
44✔
1274
            ];
44✔
1275
        }
1276

1277
        return $this;
44✔
1278
    }
1279

1280
    /**
1281
     * Platform independent LIKE statement builder.
1282
     */
1283
    protected function _like_statement(?string $prefix, string $column, ?string $not, string $bind, bool $insensitiveSearch = false): string
1284
    {
1285
        if ($insensitiveSearch) {
47✔
1286
            return "{$prefix} LOWER(" . $this->db->escapeIdentifiers($column) . ") {$not} LIKE :{$bind}:";
7✔
1287
        }
1288

1289
        return "{$prefix} {$column} {$not} LIKE :{$bind}:";
40✔
1290
    }
1291

1292
    /**
1293
     * Add UNION statement
1294
     *
1295
     * @param BaseBuilder|Closure(BaseBuilder): BaseBuilder $union
1296
     *
1297
     * @return $this
1298
     */
1299
    public function union($union)
1300
    {
1301
        return $this->addUnionStatement($union);
4✔
1302
    }
1303

1304
    /**
1305
     * Add UNION ALL statement
1306
     *
1307
     * @param BaseBuilder|Closure(BaseBuilder): BaseBuilder $union
1308
     *
1309
     * @return $this
1310
     */
1311
    public function unionAll($union)
1312
    {
1313
        return $this->addUnionStatement($union, true);
2✔
1314
    }
1315

1316
    /**
1317
     * @used-by union()
1318
     * @used-by unionAll()
1319
     *
1320
     * @param BaseBuilder|Closure(BaseBuilder): BaseBuilder $union
1321
     *
1322
     * @return $this
1323
     */
1324
    protected function addUnionStatement($union, bool $all = false)
1325
    {
1326
        $this->QBUnion[] = "\nUNION "
6✔
1327
            . ($all ? 'ALL ' : '')
6✔
1328
            . 'SELECT * FROM '
6✔
1329
            . $this->buildSubquery($union, true, 'uwrp' . (count($this->QBUnion) + 1));
6✔
1330

1331
        return $this;
6✔
1332
    }
1333

1334
    /**
1335
     * Starts a query group.
1336
     *
1337
     * @return $this
1338
     */
1339
    public function groupStart()
1340
    {
1341
        return $this->groupStartPrepare();
3✔
1342
    }
1343

1344
    /**
1345
     * Starts a query group, but ORs the group
1346
     *
1347
     * @return $this
1348
     */
1349
    public function orGroupStart()
1350
    {
1351
        return $this->groupStartPrepare('', 'OR ');
2✔
1352
    }
1353

1354
    /**
1355
     * Starts a query group, but NOTs the group
1356
     *
1357
     * @return $this
1358
     */
1359
    public function notGroupStart()
1360
    {
1361
        return $this->groupStartPrepare('NOT ');
2✔
1362
    }
1363

1364
    /**
1365
     * Starts a query group, but OR NOTs the group
1366
     *
1367
     * @return $this
1368
     */
1369
    public function orNotGroupStart()
1370
    {
1371
        return $this->groupStartPrepare('NOT ', 'OR ');
2✔
1372
    }
1373

1374
    /**
1375
     * Ends a query group
1376
     *
1377
     * @return $this
1378
     */
1379
    public function groupEnd()
1380
    {
1381
        return $this->groupEndPrepare();
9✔
1382
    }
1383

1384
    /**
1385
     * Starts a query group for HAVING clause.
1386
     *
1387
     * @return $this
1388
     */
1389
    public function havingGroupStart()
1390
    {
1391
        return $this->groupStartPrepare('', 'AND ', 'QBHaving');
2✔
1392
    }
1393

1394
    /**
1395
     * Starts a query group for HAVING clause, but ORs the group.
1396
     *
1397
     * @return $this
1398
     */
1399
    public function orHavingGroupStart()
1400
    {
1401
        return $this->groupStartPrepare('', 'OR ', 'QBHaving');
2✔
1402
    }
1403

1404
    /**
1405
     * Starts a query group for HAVING clause, but NOTs the group.
1406
     *
1407
     * @return $this
1408
     */
1409
    public function notHavingGroupStart()
1410
    {
1411
        return $this->groupStartPrepare('NOT ', 'AND ', 'QBHaving');
2✔
1412
    }
1413

1414
    /**
1415
     * Starts a query group for HAVING clause, but OR NOTs the group.
1416
     *
1417
     * @return $this
1418
     */
1419
    public function orNotHavingGroupStart()
1420
    {
1421
        return $this->groupStartPrepare('NOT ', 'OR ', 'QBHaving');
2✔
1422
    }
1423

1424
    /**
1425
     * Ends a query group for HAVING clause.
1426
     *
1427
     * @return $this
1428
     */
1429
    public function havingGroupEnd()
1430
    {
1431
        return $this->groupEndPrepare('QBHaving');
8✔
1432
    }
1433

1434
    /**
1435
     * Prepate a query group start.
1436
     *
1437
     * @return $this
1438
     */
1439
    protected function groupStartPrepare(string $not = '', string $type = 'AND ', string $clause = 'QBWhere')
1440
    {
1441
        $type = $this->groupGetType($type);
17✔
1442

1443
        $this->QBWhereGroupStarted = true;
17✔
1444
        $prefix                    = empty($this->{$clause}) ? '' : $type;
17✔
1445
        $where                     = [
17✔
1446
            'condition' => $prefix . $not . str_repeat(' ', ++$this->QBWhereGroupCount) . ' (',
17✔
1447
            'escape'    => false,
17✔
1448
        ];
17✔
1449

1450
        $this->{$clause}[] = $where;
17✔
1451

1452
        return $this;
17✔
1453
    }
1454

1455
    /**
1456
     * Prepate a query group end.
1457
     *
1458
     * @return $this
1459
     */
1460
    protected function groupEndPrepare(string $clause = 'QBWhere')
1461
    {
1462
        $this->QBWhereGroupStarted = false;
17✔
1463
        $where                     = [
17✔
1464
            'condition' => str_repeat(' ', $this->QBWhereGroupCount--) . ')',
17✔
1465
            'escape'    => false,
17✔
1466
        ];
17✔
1467

1468
        $this->{$clause}[] = $where;
17✔
1469

1470
        return $this;
17✔
1471
    }
1472

1473
    /**
1474
     * @used-by groupStart()
1475
     * @used-by _like()
1476
     * @used-by whereHaving()
1477
     * @used-by _whereIn()
1478
     * @used-by whereColumnHaving()
1479
     * @used-by havingGroupStart()
1480
     */
1481
    protected function groupGetType(string $type): string
1482
    {
1483
        if ($this->QBWhereGroupStarted) {
972✔
1484
            $type                      = '';
17✔
1485
            $this->QBWhereGroupStarted = false;
17✔
1486
        }
1487

1488
        return $type;
972✔
1489
    }
1490

1491
    /**
1492
     * @param array|string $by
1493
     *
1494
     * @return $this
1495
     */
1496
    public function groupBy($by, ?bool $escape = null)
1497
    {
1498
        if (! is_bool($escape)) {
56✔
1499
            $escape = $this->db->protectIdentifiers;
56✔
1500
        }
1501

1502
        if (is_string($by)) {
56✔
1503
            $by = ($escape === true) ? explode(',', $by) : [$by];
56✔
1504
        }
1505

1506
        foreach ($by as $val) {
56✔
1507
            $val = trim($val);
56✔
1508

1509
            if ($val !== '') {
56✔
1510
                $val = [
56✔
1511
                    'field'  => $val,
56✔
1512
                    'escape' => $escape,
56✔
1513
                ];
56✔
1514

1515
                $this->QBGroupBy[] = $val;
56✔
1516
            }
1517
        }
1518

1519
        return $this;
56✔
1520
    }
1521

1522
    /**
1523
     * Separates multiple calls with 'AND'.
1524
     *
1525
     * @param array|RawSql|string $key
1526
     * @param mixed               $value
1527
     *
1528
     * @return $this
1529
     */
1530
    public function having($key, $value = null, ?bool $escape = null)
1531
    {
1532
        return $this->whereHaving('QBHaving', $key, $value, 'AND ', $escape);
16✔
1533
    }
1534

1535
    /**
1536
     * Separates multiple calls with 'OR'.
1537
     *
1538
     * @param array|RawSql|string $key
1539
     * @param mixed               $value
1540
     *
1541
     * @return $this
1542
     */
1543
    public function orHaving($key, $value = null, ?bool $escape = null)
1544
    {
1545
        return $this->whereHaving('QBHaving', $key, $value, 'OR ', $escape);
2✔
1546
    }
1547

1548
    /**
1549
     * @param string $direction ASC, DESC or RANDOM
1550
     *
1551
     * @return $this
1552
     */
1553
    public function orderBy(string $orderBy, string $direction = '', ?bool $escape = null)
1554
    {
1555
        if ($orderBy === '') {
836✔
UNCOV
1556
            return $this;
×
1557
        }
1558

1559
        $qbOrderBy = [];
836✔
1560

1561
        $direction = strtoupper(trim($direction));
836✔
1562

1563
        if ($direction === 'RANDOM') {
836✔
1564
            $direction = '';
3✔
1565
            $orderBy   = ctype_digit($orderBy) ? sprintf($this->randomKeyword[1], $orderBy) : $this->randomKeyword[0];
3✔
1566
            $escape    = false;
3✔
1567
        } elseif ($direction !== '') {
834✔
1568
            $direction = in_array($direction, ['ASC', 'DESC'], true) ? ' ' . $direction : '';
834✔
1569
        }
1570

1571
        if ($escape === null) {
836✔
1572
            $escape = $this->db->protectIdentifiers;
834✔
1573
        }
1574

1575
        if ($escape === false) {
836✔
1576
            $qbOrderBy[] = [
3✔
1577
                'field'     => $orderBy,
3✔
1578
                'direction' => $direction,
3✔
1579
                'escape'    => false,
3✔
1580
            ];
3✔
1581
        } else {
1582
            foreach (explode(',', $orderBy) as $field) {
834✔
1583
                $qbOrderBy[] = ($direction === '' && preg_match('/\s+(ASC|DESC)$/i', rtrim($field), $match, PREG_OFFSET_CAPTURE))
834✔
UNCOV
1584
                    ? [
×
UNCOV
1585
                        'field'     => ltrim(substr($field, 0, $match[0][1])),
×
UNCOV
1586
                        'direction' => ' ' . $match[1][0],
×
UNCOV
1587
                        'escape'    => true,
×
UNCOV
1588
                    ]
×
1589
                    : [
834✔
1590
                        'field'     => trim($field),
834✔
1591
                        'direction' => $direction,
834✔
1592
                        'escape'    => true,
834✔
1593
                    ];
834✔
1594
            }
1595
        }
1596

1597
        $this->QBOrderBy = array_merge($this->QBOrderBy, $qbOrderBy);
836✔
1598

1599
        return $this;
836✔
1600
    }
1601

1602
    /**
1603
     * @return $this
1604
     */
1605
    public function limit(?int $value = null, ?int $offset = 0)
1606
    {
1607
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
123✔
1608
        if ($limitZeroAsAll && $value === 0) {
123✔
1609
            $value = null;
13✔
1610
        }
1611

1612
        if ($value !== null) {
123✔
1613
            $this->QBLimit = $value;
111✔
1614
        }
1615

1616
        if ($offset !== null && $offset !== 0) {
123✔
1617
            $this->QBOffset = $offset;
10✔
1618
        }
1619

1620
        return $this;
123✔
1621
    }
1622

1623
    /**
1624
     * Sets the OFFSET value
1625
     *
1626
     * @return $this
1627
     */
1628
    public function offset(int $offset)
1629
    {
1630
        if ($offset !== 0) {
1✔
1631
            $this->QBOffset = $offset;
1✔
1632
        }
1633

1634
        return $this;
1✔
1635
    }
1636

1637
    /**
1638
     * Generates a platform-specific LIMIT clause.
1639
     */
1640
    protected function _limit(string $sql, bool $offsetIgnore = false): string
1641
    {
1642
        return $sql . ' LIMIT ' . ($offsetIgnore === false && $this->QBOffset ? $this->QBOffset . ', ' : '') . $this->QBLimit;
112✔
1643
    }
1644

1645
    /**
1646
     * Allows key/value pairs to be set for insert(), update() or replace().
1647
     *
1648
     * @param array|object|string $key    Field name, or an array of field/value pairs, or an object
1649
     * @param mixed               $value  Field value, if $key is a single field
1650
     * @param bool|null           $escape Whether to escape values
1651
     *
1652
     * @return $this
1653
     */
1654
    public function set($key, $value = '', ?bool $escape = null)
1655
    {
1656
        $key = $this->objectToArray($key);
852✔
1657

1658
        if (! is_array($key)) {
852✔
1659
            $key = [$key => $value];
122✔
1660
        }
1661

1662
        $escape = is_bool($escape) ? $escape : $this->db->protectIdentifiers;
852✔
1663

1664
        foreach ($key as $k => $v) {
852✔
1665
            if ($escape) {
852✔
1666
                $bind = $this->setBind($k, $v, $escape);
851✔
1667

1668
                $this->QBSet[$this->db->protectIdentifiers($k, false)] = ":{$bind}:";
851✔
1669
            } else {
1670
                $this->QBSet[$this->db->protectIdentifiers($k, false)] = $v;
10✔
1671
            }
1672
        }
1673

1674
        return $this;
852✔
1675
    }
1676

1677
    /**
1678
     * Returns the previously set() data, alternatively resetting it if needed.
1679
     */
1680
    public function getSetData(bool $clean = false): array
1681
    {
UNCOV
1682
        $data = $this->QBSet;
×
1683

UNCOV
1684
        if ($clean) {
×
UNCOV
1685
            $this->QBSet = [];
×
1686
        }
1687

UNCOV
1688
        return $data;
×
1689
    }
1690

1691
    /**
1692
     * Compiles a SELECT query string and returns the sql.
1693
     */
1694
    public function getCompiledSelect(bool $reset = true): string
1695
    {
1696
        $select = $this->compileSelect();
218✔
1697

1698
        if ($reset) {
218✔
1699
            $this->resetSelect();
216✔
1700
        }
1701

1702
        return $this->compileFinalQuery($select);
218✔
1703
    }
1704

1705
    /**
1706
     * Returns a finalized, compiled query string with the bindings
1707
     * inserted and prefixes swapped out.
1708
     */
1709
    protected function compileFinalQuery(string $sql): string
1710
    {
1711
        $query = new Query($this->db);
243✔
1712
        $query->setQuery($sql, $this->binds, false);
243✔
1713

1714
        if (! empty($this->db->swapPre) && ! empty($this->db->DBPrefix)) {
243✔
UNCOV
1715
            $query->swapPrefix($this->db->DBPrefix, $this->db->swapPre);
×
1716
        }
1717

1718
        return $query->getQuery();
243✔
1719
    }
1720

1721
    /**
1722
     * Compiles the select statement based on the other functions called
1723
     * and runs the query
1724
     *
1725
     * @return false|ResultInterface
1726
     */
1727
    public function get(?int $limit = null, int $offset = 0, bool $reset = true)
1728
    {
1729
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
866✔
1730
        if ($limitZeroAsAll && $limit === 0) {
866✔
1731
            $limit = null;
1✔
1732
        }
1733

1734
        if ($limit !== null) {
866✔
1735
            $this->limit($limit, $offset);
8✔
1736
        }
1737

1738
        $result = $this->testMode
866✔
1739
            ? $this->getCompiledSelect($reset)
2✔
1740
            : $this->db->query($this->compileSelect(), $this->binds, false);
864✔
1741

1742
        if ($reset) {
866✔
1743
            $this->resetSelect();
866✔
1744

1745
            // Clear our binds so we don't eat up memory
1746
            $this->binds = [];
866✔
1747
        }
1748

1749
        return $result;
866✔
1750
    }
1751

1752
    /**
1753
     * Generates a platform-specific query string that counts all records in
1754
     * the particular table
1755
     *
1756
     * @return int|string
1757
     */
1758
    public function countAll(bool $reset = true)
1759
    {
1760
        $table = $this->QBFrom[0];
6✔
1761

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

1765
        if ($this->testMode) {
6✔
1766
            return $sql;
1✔
1767
        }
1768

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

1771
        if (empty($query->getResult())) {
5✔
UNCOV
1772
            return 0;
×
1773
        }
1774

1775
        $query = $query->getRow();
5✔
1776

1777
        if ($reset) {
5✔
1778
            $this->resetSelect();
5✔
1779
        }
1780

1781
        return (int) $query->numrows;
5✔
1782
    }
1783

1784
    /**
1785
     * Generates a platform-specific query string that counts all records
1786
     * returned by an Query Builder query.
1787
     *
1788
     * @return int|string
1789
     */
1790
    public function countAllResults(bool $reset = true)
1791
    {
1792
        // ORDER BY usage is often problematic here (most notably
1793
        // on Microsoft SQL Server) and ultimately unnecessary
1794
        // for selecting COUNT(*) ...
1795
        $orderBy = [];
227✔
1796

1797
        if (! empty($this->QBOrderBy)) {
227✔
UNCOV
1798
            $orderBy = $this->QBOrderBy;
×
1799

UNCOV
1800
            $this->QBOrderBy = null;
×
1801
        }
1802

1803
        // We cannot use a LIMIT when getting the single row COUNT(*) result
1804
        $limit = $this->QBLimit;
227✔
1805

1806
        $this->QBLimit = false;
227✔
1807

1808
        if ($this->QBDistinct === true || ! empty($this->QBGroupBy)) {
227✔
1809
            // We need to backup the original SELECT in case DBPrefix is used
1810
            $select = $this->QBSelect;
4✔
1811
            $sql    = $this->countString . $this->db->protectIdentifiers('numrows') . "\nFROM (\n" . $this->compileSelect() . "\n) CI_count_all_results";
4✔
1812

1813
            // Restore SELECT part
1814
            $this->QBSelect = $select;
4✔
1815
            unset($select);
4✔
1816
        } else {
1817
            $sql = $this->compileSelect($this->countString . $this->db->protectIdentifiers('numrows'));
223✔
1818
        }
1819

1820
        if ($this->testMode) {
227✔
1821
            return $sql;
9✔
1822
        }
1823

1824
        $result = $this->db->query($sql, $this->binds, false);
222✔
1825

1826
        if ($reset) {
222✔
1827
            $this->resetSelect();
206✔
1828
        } elseif (! isset($this->QBOrderBy)) {
22✔
UNCOV
1829
            $this->QBOrderBy = $orderBy;
×
1830
        }
1831

1832
        // Restore the LIMIT setting
1833
        $this->QBLimit = $limit;
222✔
1834

1835
        $row = $result instanceof ResultInterface ? $result->getRow() : null;
222✔
1836

1837
        if (empty($row)) {
222✔
UNCOV
1838
            return 0;
×
1839
        }
1840

1841
        return (int) $row->numrows;
222✔
1842
    }
1843

1844
    /**
1845
     * Compiles the set conditions and returns the sql statement
1846
     *
1847
     * @return array
1848
     */
1849
    public function getCompiledQBWhere()
1850
    {
1851
        return $this->QBWhere;
63✔
1852
    }
1853

1854
    /**
1855
     * Allows the where clause, limit and offset to be added directly
1856
     *
1857
     * @param array|string $where
1858
     *
1859
     * @return ResultInterface
1860
     */
1861
    public function getWhere($where = null, ?int $limit = null, ?int $offset = 0, bool $reset = true)
1862
    {
1863
        if ($where !== null) {
17✔
1864
            $this->where($where);
16✔
1865
        }
1866

1867
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
17✔
1868
        if ($limitZeroAsAll && $limit === 0) {
17✔
UNCOV
1869
            $limit = null;
×
1870
        }
1871

1872
        if ($limit !== null) {
17✔
1873
            $this->limit($limit, $offset);
3✔
1874
        }
1875

1876
        $result = $this->testMode
17✔
1877
            ? $this->getCompiledSelect($reset)
4✔
1878
            : $this->db->query($this->compileSelect(), $this->binds, false);
13✔
1879

1880
        if ($reset) {
17✔
1881
            $this->resetSelect();
17✔
1882

1883
            // Clear our binds so we don't eat up memory
1884
            $this->binds = [];
17✔
1885
        }
1886

1887
        return $result;
17✔
1888
    }
1889

1890
    /**
1891
     * Compiles batch insert/update/upsert strings and runs the queries
1892
     *
1893
     * @param '_deleteBatch'|'_insertBatch'|'_updateBatch'|'_upsertBatch' $renderMethod
1894
     *
1895
     * @return false|int|list<string> Number of rows inserted or FALSE on failure, SQL array when testMode
1896
     *
1897
     * @throws DatabaseException
1898
     */
1899
    protected function batchExecute(string $renderMethod, int $batchSize = 100)
1900
    {
1901
        if (empty($this->QBSet)) {
73✔
1902
            if ($this->db->DBDebug) {
5✔
1903
                throw new DatabaseException(trim($renderMethod, '_') . '() has no data.');
5✔
1904
            }
1905

1906
            return false; // @codeCoverageIgnore
1907
        }
1908

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

1911
        $affectedRows = 0;
68✔
1912
        $savedSQL     = [];
68✔
1913
        $cnt          = count($this->QBSet);
68✔
1914

1915
        // batch size 0 for unlimited
1916
        if ($batchSize === 0) {
68✔
UNCOV
1917
            $batchSize = $cnt;
×
1918
        }
1919

1920
        for ($i = 0, $total = $cnt; $i < $total; $i += $batchSize) {
68✔
1921
            $QBSet = array_slice($this->QBSet, $i, $batchSize);
68✔
1922

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

1925
            if ($sql === '') {
65✔
1926
                return false; // @codeCoverageIgnore
1927
            }
1928

1929
            if ($this->testMode) {
65✔
1930
                $savedSQL[] = $sql;
3✔
1931
            } else {
1932
                $this->db->query($sql, null, false);
62✔
1933
                $affectedRows += $this->db->affectedRows();
60✔
1934
            }
1935
        }
1936

1937
        if (! $this->testMode) {
63✔
1938
            $this->resetWrite();
60✔
1939
        }
1940

1941
        return $this->testMode ? $savedSQL : $affectedRows;
63✔
1942
    }
1943

1944
    /**
1945
     * Allows a row or multiple rows to be set for batch inserts/upserts/updates
1946
     *
1947
     * @param array|object $set
1948
     * @param string       $alias alias for sql table
1949
     *
1950
     * @return $this|null
1951
     */
1952
    public function setData($set, ?bool $escape = null, string $alias = '')
1953
    {
1954
        if (empty($set)) {
68✔
UNCOV
1955
            if ($this->db->DBDebug) {
×
UNCOV
1956
                throw new DatabaseException('setData() has no data.');
×
1957
            }
1958

1959
            return null; // @codeCoverageIgnore
1960
        }
1961

1962
        $this->setAlias($alias);
68✔
1963

1964
        // this allows to set just one row at a time
1965
        if (is_object($set) || (! is_array(current($set)) && ! is_object(current($set)))) {
68✔
1966
            $set = [$set];
11✔
1967
        }
1968

1969
        $set = $this->batchObjectToArray($set);
68✔
1970

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

1973
        $keys = array_keys($this->objectToArray(current($set)));
68✔
1974
        sort($keys);
68✔
1975

1976
        foreach ($set as $row) {
68✔
1977
            $row = $this->objectToArray($row);
68✔
1978
            if (array_diff($keys, array_keys($row)) !== [] || array_diff(array_keys($row), $keys) !== []) {
68✔
1979
                // batchExecute() function returns an error on an empty array
UNCOV
1980
                $this->QBSet[] = [];
×
1981

UNCOV
1982
                return null;
×
1983
            }
1984

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

1987
            $clean = [];
68✔
1988

1989
            foreach ($row as $rowValue) {
68✔
1990
                $clean[] = $escape ? $this->db->escape($rowValue) : $rowValue;
68✔
1991
            }
1992

1993
            $row = $clean;
68✔
1994

1995
            $this->QBSet[] = $row;
68✔
1996
        }
1997

1998
        foreach ($keys as $k) {
68✔
1999
            $k = $this->db->protectIdentifiers($k, false);
68✔
2000

2001
            if (! in_array($k, $this->QBKeys, true)) {
68✔
2002
                $this->QBKeys[] = $k;
68✔
2003
            }
2004
        }
2005

2006
        return $this;
68✔
2007
    }
2008

2009
    /**
2010
     * Compiles an upsert query and returns the sql
2011
     *
2012
     * @return string
2013
     *
2014
     * @throws DatabaseException
2015
     */
2016
    public function getCompiledUpsert()
2017
    {
2018
        [$currentTestMode, $this->testMode] = [$this->testMode, true];
3✔
2019

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

2022
        $this->testMode = $currentTestMode;
3✔
2023

2024
        return $this->compileFinalQuery($sql);
3✔
2025
    }
2026

2027
    /**
2028
     * Converts call to batchUpsert
2029
     *
2030
     * @param array|object|null $set
2031
     *
2032
     * @return false|int|list<string> Number of affected rows or FALSE on failure, SQL array when testMode
2033
     *
2034
     * @throws DatabaseException
2035
     */
2036
    public function upsert($set = null, ?bool $escape = null)
2037
    {
2038
        // if set() has been used merge QBSet with binds and then setData()
2039
        if ($set === null && ! is_array(current($this->QBSet))) {
10✔
2040
            $set = [];
2✔
2041

2042
            foreach ($this->QBSet as $field => $value) {
2✔
2043
                $k = trim($field, $this->db->escapeChar);
2✔
2044
                // use binds if available else use QBSet value but with RawSql to avoid escape
2045
                $set[$k] = isset($this->binds[$k]) ? $this->binds[$k][0] : new RawSql($value);
2✔
2046
            }
2047

2048
            $this->binds = [];
2✔
2049

2050
            $this->resetRun([
2✔
2051
                'QBSet'  => [],
2✔
2052
                'QBKeys' => [],
2✔
2053
            ]);
2✔
2054

2055
            $this->setData($set, true); // unescaped items are RawSql now
2✔
2056
        } elseif ($set !== null) {
8✔
2057
            $this->setData($set, $escape);
7✔
2058
        } // else setData() has already been used and we need to do nothing
2059

2060
        return $this->batchExecute('_upsertBatch');
10✔
2061
    }
2062

2063
    /**
2064
     * Compiles batch upsert strings and runs the queries
2065
     *
2066
     * @param array|object|null $set a dataset
2067
     *
2068
     * @return false|int|list<string> Number of affected rows or FALSE on failure, SQL array when testMode
2069
     *
2070
     * @throws DatabaseException
2071
     */
2072
    public function upsertBatch($set = null, ?bool $escape = null, int $batchSize = 100)
2073
    {
2074
        if (isset($this->QBOptions['setQueryAsData'])) {
12✔
2075
            $sql = $this->_upsertBatch($this->QBFrom[0], $this->QBKeys, []);
1✔
2076

2077
            if ($sql === '') {
1✔
2078
                return false; // @codeCoverageIgnore
2079
            }
2080

2081
            if ($this->testMode === false) {
1✔
2082
                $this->db->query($sql, null, false);
1✔
2083
            }
2084

2085
            $this->resetWrite();
1✔
2086

2087
            return $this->testMode ? $sql : $this->db->affectedRows();
1✔
2088
        }
2089

2090
        if ($set !== null) {
11✔
2091
            $this->setData($set, $escape);
9✔
2092
        }
2093

2094
        return $this->batchExecute('_upsertBatch', $batchSize);
11✔
2095
    }
2096

2097
    /**
2098
     * Generates a platform-specific upsertBatch string from the supplied data
2099
     *
2100
     * @used-by batchExecute()
2101
     *
2102
     * @param string                 $table  Protected table name
2103
     * @param list<string>           $keys   QBKeys
2104
     * @param list<list<int|string>> $values QBSet
2105
     */
2106
    protected function _upsertBatch(string $table, array $keys, array $values): string
2107
    {
2108
        $sql = $this->QBOptions['sql'] ?? '';
19✔
2109

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

2114
            $sql = 'INSERT INTO ' . $table . ' (' . implode(', ', $keys) . ")\n{:_table_:}ON DUPLICATE KEY UPDATE\n" . implode(
19✔
2115
                ",\n",
19✔
2116
                array_map(
19✔
2117
                    static fn ($key, $value): string => $table . '.' . $key . ($value instanceof RawSql ?
19✔
2118
                        ' = ' . $value :
2✔
2119
                        ' = VALUES(' . $value . ')'),
19✔
2120
                    array_keys($updateFields),
19✔
2121
                    $updateFields,
19✔
2122
                ),
19✔
2123
            );
19✔
2124

2125
            $this->QBOptions['sql'] = $sql;
19✔
2126
        }
2127

2128
        if (isset($this->QBOptions['setQueryAsData'])) {
19✔
2129
            $data = $this->QBOptions['setQueryAsData'] . "\n";
1✔
2130
        } else {
2131
            $data = 'VALUES ' . implode(', ', $this->formatValues($values)) . "\n";
18✔
2132
        }
2133

2134
        return str_replace('{:_table_:}', $data, $sql);
19✔
2135
    }
2136

2137
    /**
2138
     * Set table alias for dataset pseudo table.
2139
     */
2140
    private function setAlias(string $alias): BaseBuilder
2141
    {
2142
        if ($alias !== '') {
68✔
2143
            $this->db->addTableAlias($alias);
7✔
2144
            $this->QBOptions['alias'] = $this->db->protectIdentifiers($alias);
7✔
2145
        }
2146

2147
        return $this;
68✔
2148
    }
2149

2150
    /**
2151
     * Sets update fields for upsert, update
2152
     *
2153
     * @param list<RawSql>|list<string>|string $set
2154
     * @param bool                             $addToDefault adds update fields to the default ones
2155
     * @param array|null                       $ignore       ignores items in set
2156
     *
2157
     * @return $this
2158
     */
2159
    public function updateFields($set, bool $addToDefault = false, ?array $ignore = null)
2160
    {
2161
        if (! empty($set)) {
38✔
2162
            if (! is_array($set)) {
38✔
2163
                $set = explode(',', $set);
5✔
2164
            }
2165

2166
            foreach ($set as $key => $value) {
38✔
2167
                if (! ($value instanceof RawSql)) {
38✔
2168
                    $value = $this->db->protectIdentifiers($value);
38✔
2169
                }
2170

2171
                if (is_numeric($key)) {
38✔
2172
                    $key = $value;
38✔
2173
                }
2174

2175
                if ($ignore === null || ! in_array($key, $ignore, true)) {
38✔
2176
                    if ($addToDefault) {
38✔
2177
                        $this->QBOptions['updateFieldsAdditional'][$this->db->protectIdentifiers($key)] = $value;
3✔
2178
                    } else {
2179
                        $this->QBOptions['updateFields'][$this->db->protectIdentifiers($key)] = $value;
38✔
2180
                    }
2181
                }
2182
            }
2183

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

2187
                unset($this->QBOptions['updateFieldsAdditional']);
3✔
2188
            }
2189
        }
2190

2191
        return $this;
38✔
2192
    }
2193

2194
    /**
2195
     * Sets constraints for batch upsert, update
2196
     *
2197
     * @param array|RawSql|string $set a string of columns, key value pairs, or RawSql
2198
     *
2199
     * @return $this
2200
     */
2201
    public function onConstraint($set)
2202
    {
2203
        if (! empty($set)) {
47✔
2204
            if (is_string($set)) {
44✔
2205
                $set = explode(',', $set);
27✔
2206

2207
                $set = array_map(trim(...), $set);
27✔
2208
            }
2209

2210
            if ($set instanceof RawSql) {
44✔
2211
                $set = [$set];
2✔
2212
            }
2213

2214
            foreach ($set as $key => $value) {
44✔
2215
                if (! ($value instanceof RawSql)) {
44✔
2216
                    $value = $this->db->protectIdentifiers($value);
41✔
2217
                }
2218

2219
                if (is_string($key)) {
44✔
2220
                    $key = $this->db->protectIdentifiers($key);
3✔
2221
                }
2222

2223
                $this->QBOptions['constraints'][$key] = $value;
44✔
2224
            }
2225
        }
2226

2227
        return $this;
47✔
2228
    }
2229

2230
    /**
2231
     * Sets data source as a query for insertBatch()/updateBatch()/upsertBatch()/deleteBatch()
2232
     *
2233
     * @param BaseBuilder|RawSql $query
2234
     * @param array|string|null  $columns an array or comma delimited string of columns
2235
     */
2236
    public function setQueryAsData($query, ?string $alias = null, $columns = null): BaseBuilder
2237
    {
2238
        if (is_string($query)) {
5✔
UNCOV
2239
            throw new InvalidArgumentException('$query parameter must be BaseBuilder or RawSql class.');
×
2240
        }
2241

2242
        if ($query instanceof BaseBuilder) {
5✔
2243
            $query = $query->getCompiledSelect();
4✔
2244
        } elseif ($query instanceof RawSql) {
1✔
2245
            $query = $query->__toString();
1✔
2246
        }
2247

2248
        if (is_string($query)) {
5✔
2249
            if ($columns !== null && is_string($columns)) {
5✔
2250
                $columns = explode(',', $columns);
1✔
2251
                $columns = array_map(trim(...), $columns);
1✔
2252
            }
2253

2254
            $columns = (array) $columns;
5✔
2255

2256
            if ($columns === []) {
5✔
2257
                $columns = $this->fieldsFromQuery($query);
4✔
2258
            }
2259

2260
            if ($alias !== null) {
5✔
2261
                $this->setAlias($alias);
1✔
2262
            }
2263

2264
            foreach ($columns as $key => $value) {
5✔
2265
                $columns[$key] = $this->db->escapeChar . $value . $this->db->escapeChar;
5✔
2266
            }
2267

2268
            $this->QBOptions['setQueryAsData'] = $query;
5✔
2269
            $this->QBKeys                      = $columns;
5✔
2270
            $this->QBSet                       = [];
5✔
2271
        }
2272

2273
        return $this;
5✔
2274
    }
2275

2276
    /**
2277
     * Gets column names from a select query
2278
     */
2279
    protected function fieldsFromQuery(string $sql): array
2280
    {
2281
        return $this->db->query('SELECT * FROM (' . $sql . ') _u_ LIMIT 1')->getFieldNames();
4✔
2282
    }
2283

2284
    /**
2285
     * Converts value array of array to array of strings
2286
     */
2287
    protected function formatValues(array $values): array
2288
    {
2289
        return array_map(static fn ($index): string => '(' . implode(',', $index) . ')', $values);
45✔
2290
    }
2291

2292
    /**
2293
     * Compiles batch insert strings and runs the queries
2294
     *
2295
     * @param array|object|null $set a dataset
2296
     *
2297
     * @return false|int|list<string> Number of rows inserted or FALSE on no data to perform an insert operation, SQL array when testMode
2298
     */
2299
    public function insertBatch($set = null, ?bool $escape = null, int $batchSize = 100)
2300
    {
2301
        if (isset($this->QBOptions['setQueryAsData'])) {
29✔
2302
            $sql = $this->_insertBatch($this->QBFrom[0], $this->QBKeys, []);
2✔
2303

2304
            if ($sql === '') {
2✔
2305
                return false; // @codeCoverageIgnore
2306
            }
2307

2308
            if ($this->testMode === false) {
2✔
2309
                $this->db->query($sql, null, false);
2✔
2310
            }
2311

2312
            $this->resetWrite();
2✔
2313

2314
            return $this->testMode ? $sql : $this->db->affectedRows();
2✔
2315
        }
2316

2317
        if ($set !== null && $set !== []) {
29✔
2318
            $this->setData($set, $escape);
27✔
2319
        }
2320

2321
        return $this->batchExecute('_insertBatch', $batchSize);
29✔
2322
    }
2323

2324
    /**
2325
     * Generates a platform-specific insert string from the supplied data.
2326
     *
2327
     * @used-by batchExecute()
2328
     *
2329
     * @param string                 $table  Protected table name
2330
     * @param list<string>           $keys   QBKeys
2331
     * @param list<list<int|string>> $values QBSet
2332
     */
2333
    protected function _insertBatch(string $table, array $keys, array $values): string
2334
    {
2335
        $sql = $this->QBOptions['sql'] ?? '';
27✔
2336

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

2342
            $this->QBOptions['sql'] = $sql;
27✔
2343
        }
2344

2345
        if (isset($this->QBOptions['setQueryAsData'])) {
27✔
2346
            $data = $this->QBOptions['setQueryAsData'];
2✔
2347
        } else {
2348
            $data = 'VALUES ' . implode(', ', $this->formatValues($values));
27✔
2349
        }
2350

2351
        return str_replace('{:_table_:}', $data, $sql);
27✔
2352
    }
2353

2354
    /**
2355
     * Compiles an insert query and returns the sql
2356
     *
2357
     * @return bool|string
2358
     *
2359
     * @throws DatabaseException
2360
     */
2361
    public function getCompiledInsert(bool $reset = true)
2362
    {
2363
        if ($this->validateInsert() === false) {
6✔
UNCOV
2364
            return false;
×
2365
        }
2366

2367
        $sql = $this->_insert(
6✔
2368
            $this->db->protectIdentifiers(
6✔
2369
                $this->removeAlias($this->QBFrom[0]),
6✔
2370
                true,
6✔
2371
                null,
6✔
2372
                false,
6✔
2373
            ),
6✔
2374
            array_keys($this->QBSet),
6✔
2375
            array_values($this->QBSet),
6✔
2376
        );
6✔
2377

2378
        if ($reset) {
6✔
2379
            $this->resetWrite();
6✔
2380
        }
2381

2382
        return $this->compileFinalQuery($sql);
6✔
2383
    }
2384

2385
    /**
2386
     * Compiles an insert string and runs the query
2387
     *
2388
     * @param array|object|null $set
2389
     *
2390
     * @return BaseResult|bool|Query
2391
     *
2392
     * @throws DatabaseException
2393
     */
2394
    public function insert($set = null, ?bool $escape = null)
2395
    {
2396
        if ($set !== null) {
839✔
2397
            $this->set($set, '', $escape);
805✔
2398
        }
2399

2400
        if ($this->validateInsert() === false) {
839✔
UNCOV
2401
            return false;
×
2402
        }
2403

2404
        $sql = $this->_insert(
838✔
2405
            $this->db->protectIdentifiers(
838✔
2406
                $this->removeAlias($this->QBFrom[0]),
838✔
2407
                true,
838✔
2408
                $escape,
838✔
2409
                false,
838✔
2410
            ),
838✔
2411
            array_keys($this->QBSet),
838✔
2412
            array_values($this->QBSet),
838✔
2413
        );
838✔
2414

2415
        if (! $this->testMode) {
838✔
2416
            $this->resetWrite();
834✔
2417

2418
            $result = $this->db->query($sql, $this->binds, false);
834✔
2419

2420
            // Clear our binds so we don't eat up memory
2421
            $this->binds = [];
834✔
2422

2423
            return $result;
834✔
2424
        }
2425

2426
        return false;
5✔
2427
    }
2428

2429
    /**
2430
     * @internal This is a temporary solution.
2431
     *
2432
     * @see https://github.com/codeigniter4/CodeIgniter4/pull/5376
2433
     *
2434
     * @TODO Fix a root cause, and this method should be removed.
2435
     */
2436
    protected function removeAlias(string $from): string
2437
    {
2438
        if (str_contains($from, ' ')) {
843✔
2439
            // if the alias is written with the AS keyword, remove it
2440
            $from = preg_replace('/\s+AS\s+/i', ' ', $from);
2✔
2441

2442
            $parts = explode(' ', $from);
2✔
2443
            $from  = $parts[0];
2✔
2444
        }
2445

2446
        return $from;
843✔
2447
    }
2448

2449
    /**
2450
     * This method is used by both insert() and getCompiledInsert() to
2451
     * validate that the there data is actually being set and that table
2452
     * has been chosen to be inserted into.
2453
     *
2454
     * @throws DatabaseException
2455
     */
2456
    protected function validateInsert(): bool
2457
    {
2458
        if (empty($this->QBSet)) {
839✔
2459
            if ($this->db->DBDebug) {
1✔
2460
                throw new DatabaseException('You must use the "set" method to insert an entry.');
1✔
2461
            }
2462

2463
            return false; // @codeCoverageIgnore
2464
        }
2465

2466
        return true;
838✔
2467
    }
2468

2469
    /**
2470
     * Generates a platform-specific insert string from the supplied data
2471
     *
2472
     * @param string           $table         Protected table name
2473
     * @param list<string>     $keys          Keys of QBSet
2474
     * @param list<int|string> $unescapedKeys Values of QBSet
2475
     */
2476
    protected function _insert(string $table, array $keys, array $unescapedKeys): string
2477
    {
2478
        return 'INSERT ' . $this->compileIgnore('insert') . 'INTO ' . $table . ' (' . implode(', ', $keys) . ') VALUES (' . implode(', ', $unescapedKeys) . ')';
831✔
2479
    }
2480

2481
    /**
2482
     * Compiles a replace into string and runs the query
2483
     *
2484
     * @return BaseResult|false|Query|string
2485
     *
2486
     * @throws DatabaseException
2487
     */
2488
    public function replace(?array $set = null)
2489
    {
2490
        if ($set !== null) {
8✔
2491
            $this->set($set);
7✔
2492
        }
2493

2494
        if (empty($this->QBSet)) {
8✔
2495
            if ($this->db->DBDebug) {
1✔
2496
                throw new DatabaseException('You must use the "set" method to update an entry.');
1✔
2497
            }
2498

2499
            return false; // @codeCoverageIgnore
2500
        }
2501

2502
        $table = $this->QBFrom[0];
7✔
2503

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

2506
        $this->resetWrite();
7✔
2507

2508
        return $this->testMode ? $sql : $this->db->query($sql, $this->binds, false);
7✔
2509
    }
2510

2511
    /**
2512
     * Generates a platform-specific replace string from the supplied data
2513
     *
2514
     * @param string           $table  Protected table name
2515
     * @param list<string>     $keys   Keys of QBSet
2516
     * @param list<int|string> $values Values of QBSet
2517
     */
2518
    protected function _replace(string $table, array $keys, array $values): string
2519
    {
2520
        return 'REPLACE INTO ' . $table . ' (' . implode(', ', $keys) . ') VALUES (' . implode(', ', $values) . ')';
7✔
2521
    }
2522

2523
    /**
2524
     * Groups tables in FROM clauses if needed, so there is no confusion
2525
     * about operator precedence.
2526
     *
2527
     * Note: This is only used (and overridden) by MySQL and SQLSRV.
2528
     */
2529
    protected function _fromTables(): string
2530
    {
2531
        return implode(', ', $this->QBFrom);
1,045✔
2532
    }
2533

2534
    /**
2535
     * Compiles an update query and returns the sql
2536
     *
2537
     * @return bool|string
2538
     */
2539
    public function getCompiledUpdate(bool $reset = true)
2540
    {
2541
        if ($this->validateUpdate() === false) {
13✔
UNCOV
2542
            return false;
×
2543
        }
2544

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

2547
        if ($reset) {
13✔
2548
            $this->resetWrite();
13✔
2549
        }
2550

2551
        return $this->compileFinalQuery($sql);
13✔
2552
    }
2553

2554
    /**
2555
     * Compiles an update string and runs the query.
2556
     *
2557
     * @param array|object|null        $set
2558
     * @param array|RawSql|string|null $where
2559
     *
2560
     * @throws DatabaseException
2561
     */
2562
    public function update($set = null, $where = null, ?int $limit = null): bool
2563
    {
2564
        if ($set !== null) {
104✔
2565
            $this->set($set);
50✔
2566
        }
2567

2568
        if ($this->validateUpdate() === false) {
104✔
UNCOV
2569
            return false;
×
2570
        }
2571

2572
        if ($where !== null) {
103✔
2573
            $this->where($where);
7✔
2574
        }
2575

2576
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
103✔
2577
        if ($limitZeroAsAll && $limit === 0) {
103✔
UNCOV
2578
            $limit = null;
×
2579
        }
2580

2581
        if ($limit !== null) {
103✔
2582
            if (! $this->canLimitWhereUpdates) {
3✔
2583
                throw new DatabaseException('This driver does not allow LIMITs on UPDATE queries using WHERE.');
2✔
2584
            }
2585

2586
            $this->limit($limit);
3✔
2587
        }
2588

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

2591
        if (! $this->testMode) {
103✔
2592
            $this->resetWrite();
90✔
2593

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

2596
            if ($result !== false) {
90✔
2597
                // Clear our binds so we don't eat up memory
2598
                $this->binds = [];
87✔
2599

2600
                return true;
87✔
2601
            }
2602

2603
            return false;
3✔
2604
        }
2605

2606
        return true;
13✔
2607
    }
2608

2609
    /**
2610
     * Generates a platform-specific update string from the supplied data
2611
     *
2612
     * @param string                $table  Protected table name
2613
     * @param array<string, string> $values QBSet
2614
     */
2615
    protected function _update(string $table, array $values): string
2616
    {
2617
        $valStr = [];
118✔
2618

2619
        foreach ($values as $key => $val) {
118✔
2620
            $valStr[] = $key . ' = ' . $val;
118✔
2621
        }
2622

2623
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
118✔
2624
        if ($limitZeroAsAll) {
118✔
2625
            return 'UPDATE ' . $this->compileIgnore('update') . $table . ' SET ' . implode(', ', $valStr)
118✔
2626
                . $this->compileWhereHaving('QBWhere')
118✔
2627
                . $this->compileOrderBy()
118✔
2628
                . ($this->QBLimit ? $this->_limit(' ', true) : '');
118✔
2629
        }
2630

UNCOV
2631
        return 'UPDATE ' . $this->compileIgnore('update') . $table . ' SET ' . implode(', ', $valStr)
×
UNCOV
2632
            . $this->compileWhereHaving('QBWhere')
×
UNCOV
2633
            . $this->compileOrderBy()
×
UNCOV
2634
            . ($this->QBLimit !== false ? $this->_limit(' ', true) : '');
×
2635
    }
2636

2637
    /**
2638
     * This method is used by both update() and getCompiledUpdate() to
2639
     * validate that data is actually being set and that a table has been
2640
     * chosen to be updated.
2641
     *
2642
     * @throws DatabaseException
2643
     */
2644
    protected function validateUpdate(): bool
2645
    {
2646
        if (empty($this->QBSet)) {
105✔
2647
            if ($this->db->DBDebug) {
1✔
2648
                throw new DatabaseException('You must use the "set" method to update an entry.');
1✔
2649
            }
2650

2651
            return false; // @codeCoverageIgnore
2652
        }
2653

2654
        return true;
104✔
2655
    }
2656

2657
    /**
2658
     * Sets data and calls batchExecute to run queries
2659
     *
2660
     * @param array|object|null        $set         a dataset
2661
     * @param array|RawSql|string|null $constraints
2662
     *
2663
     * @return false|int|list<string> Number of rows affected or FALSE on failure, SQL array when testMode
2664
     */
2665
    public function updateBatch($set = null, $constraints = null, int $batchSize = 100)
2666
    {
2667
        $this->onConstraint($constraints);
23✔
2668

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

2672
            if ($sql === '') {
1✔
2673
                return false; // @codeCoverageIgnore
2674
            }
2675

2676
            if ($this->testMode === false) {
1✔
2677
                $this->db->query($sql, null, false);
1✔
2678
            }
2679

2680
            $this->resetWrite();
1✔
2681

2682
            return $this->testMode ? $sql : $this->db->affectedRows();
1✔
2683
        }
2684

2685
        if ($set !== null && $set !== []) {
22✔
2686
            $this->setData($set, true);
16✔
2687
        }
2688

2689
        return $this->batchExecute('_updateBatch', $batchSize);
22✔
2690
    }
2691

2692
    /**
2693
     * Generates a platform-specific batch update string from the supplied data
2694
     *
2695
     * @used-by batchExecute()
2696
     *
2697
     * @param string                 $table  Protected table name
2698
     * @param list<string>           $keys   QBKeys
2699
     * @param list<list<int|string>> $values QBSet
2700
     */
2701
    protected function _updateBatch(string $table, array $keys, array $values): string
2702
    {
2703
        $sql = $this->QBOptions['sql'] ?? '';
19✔
2704

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

2709
            if ($constraints === []) {
19✔
2710
                if ($this->db->DBDebug) {
2✔
2711
                    throw new DatabaseException('You must specify a constraint to match on for batch updates.'); // @codeCoverageIgnore
2712
                }
2713

2714
                return ''; // @codeCoverageIgnore
2715
            }
2716

2717
            $updateFields = $this->QBOptions['updateFields'] ??
17✔
2718
                $this->updateFields($keys, false, $constraints)->QBOptions['updateFields'] ??
17✔
2719
                [];
14✔
2720

2721
            $alias = $this->QBOptions['alias'] ?? '_u';
17✔
2722

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

2725
            $sql .= "SET\n";
17✔
2726

2727
            $sql .= implode(
17✔
2728
                ",\n",
17✔
2729
                array_map(
17✔
2730
                    static fn ($key, $value): string => $key . ($value instanceof RawSql ?
17✔
2731
                        ' = ' . $value :
2✔
2732
                        ' = ' . $alias . '.' . $value),
17✔
2733
                    array_keys($updateFields),
17✔
2734
                    $updateFields,
17✔
2735
                ),
17✔
2736
            ) . "\n";
17✔
2737

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

2740
            $sql .= ') ' . $alias . "\n";
17✔
2741

2742
            $sql .= 'WHERE ' . implode(
17✔
2743
                ' AND ',
17✔
2744
                array_map(
17✔
2745
                    static fn ($key, $value) => (
17✔
2746
                        ($value instanceof RawSql && is_string($key))
17✔
2747
                        ?
17✔
2748
                        $table . '.' . $key . ' = ' . $value
1✔
2749
                        :
17✔
2750
                        (
17✔
2751
                            $value instanceof RawSql
16✔
2752
                            ?
16✔
2753
                            $value
3✔
2754
                            :
16✔
2755
                            $table . '.' . $value . ' = ' . $alias . '.' . $value
17✔
2756
                        )
17✔
2757
                    ),
17✔
2758
                    array_keys($constraints),
17✔
2759
                    $constraints,
17✔
2760
                ),
17✔
2761
            );
17✔
2762

2763
            $this->QBOptions['sql'] = $sql;
17✔
2764
        }
2765

2766
        if (isset($this->QBOptions['setQueryAsData'])) {
17✔
2767
            $data = $this->QBOptions['setQueryAsData'];
1✔
2768
        } else {
2769
            $data = implode(
16✔
2770
                " UNION ALL\n",
16✔
2771
                array_map(
16✔
2772
                    static fn ($value): string => 'SELECT ' . implode(', ', array_map(
16✔
2773
                        static fn ($key, $index): string => $index . ' ' . $key,
16✔
2774
                        $keys,
16✔
2775
                        $value,
16✔
2776
                    )),
16✔
2777
                    $values,
16✔
2778
                ),
16✔
2779
            ) . "\n";
16✔
2780
        }
2781

2782
        return str_replace('{:_table_:}', $data, $sql);
17✔
2783
    }
2784

2785
    /**
2786
     * Compiles a delete string and runs "DELETE FROM table"
2787
     *
2788
     * @return bool|string TRUE on success, FALSE on failure, string on testMode
2789
     */
2790
    public function emptyTable()
2791
    {
2792
        $table = $this->QBFrom[0];
4✔
2793

2794
        $sql = $this->_delete($table);
4✔
2795

2796
        if ($this->testMode) {
4✔
2797
            return $sql;
1✔
2798
        }
2799

2800
        $this->resetWrite();
3✔
2801

2802
        return $this->db->query($sql, null, false);
3✔
2803
    }
2804

2805
    /**
2806
     * Compiles a truncate string and runs the query
2807
     * If the database does not support the truncate() command
2808
     * This function maps to "DELETE FROM table"
2809
     *
2810
     * @return bool|string TRUE on success, FALSE on failure, string on testMode
2811
     */
2812
    public function truncate()
2813
    {
2814
        $table = $this->QBFrom[0];
757✔
2815

2816
        $sql = $this->_truncate($table);
757✔
2817

2818
        if ($this->testMode) {
757✔
2819
            return $sql;
2✔
2820
        }
2821

2822
        $this->resetWrite();
756✔
2823

2824
        return $this->db->query($sql, null, false);
756✔
2825
    }
2826

2827
    /**
2828
     * Generates a platform-specific truncate string from the supplied data
2829
     *
2830
     * If the database does not support the truncate() command,
2831
     * then this method maps to 'DELETE FROM table'
2832
     *
2833
     * @param string $table Protected table name
2834
     */
2835
    protected function _truncate(string $table): string
2836
    {
2837
        return 'TRUNCATE ' . $table;
744✔
2838
    }
2839

2840
    /**
2841
     * Compiles a delete query string and returns the sql
2842
     */
2843
    public function getCompiledDelete(bool $reset = true): string
2844
    {
2845
        $sql = $this->testMode()->delete('', null, $reset);
3✔
2846
        $this->testMode(false);
3✔
2847

2848
        return $this->compileFinalQuery($sql);
3✔
2849
    }
2850

2851
    /**
2852
     * Compiles a delete string and runs the query
2853
     *
2854
     * @param array|RawSql|string $where
2855
     *
2856
     * @return bool|string Returns a SQL string if in test mode.
2857
     *
2858
     * @throws DatabaseException
2859
     */
2860
    public function delete($where = '', ?int $limit = null, bool $resetData = true)
2861
    {
2862
        $table = $this->db->protectIdentifiers($this->QBFrom[0], true, null, false);
798✔
2863

2864
        if ($where !== '') {
798✔
2865
            $this->where($where);
4✔
2866
        }
2867

2868
        if (empty($this->QBWhere)) {
798✔
2869
            if ($this->db->DBDebug) {
2✔
2870
                throw new DatabaseException('Deletes are not allowed unless they contain a "where" or "like" clause.');
2✔
2871
            }
2872

2873
            return false; // @codeCoverageIgnore
2874
        }
2875

2876
        $sql = $this->_delete($this->removeAlias($table));
798✔
2877

2878
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
798✔
2879
        if ($limitZeroAsAll && $limit === 0) {
798✔
UNCOV
2880
            $limit = null;
×
2881
        }
2882

2883
        if ($limit !== null) {
798✔
2884
            $this->QBLimit = $limit;
1✔
2885
        }
2886

2887
        if (! empty($this->QBLimit)) {
798✔
2888
            if (! $this->canLimitDeletes) {
2✔
2889
                throw new DatabaseException('SQLite3 does not allow LIMITs on DELETE queries.');
1✔
2890
            }
2891

2892
            $sql = $this->_limit($sql, true);
2✔
2893
        }
2894

2895
        if ($resetData) {
798✔
2896
            $this->resetWrite();
798✔
2897
        }
2898

2899
        return $this->testMode ? $sql : $this->db->query($sql, $this->binds, false);
798✔
2900
    }
2901

2902
    /**
2903
     * Sets data and calls batchExecute to run queries
2904
     *
2905
     * @param array|object|null $set         a dataset
2906
     * @param array|RawSql|null $constraints
2907
     *
2908
     * @return false|int|list<string> Number of rows affected or FALSE on failure, SQL array when testMode
2909
     */
2910
    public function deleteBatch($set = null, $constraints = null, int $batchSize = 100)
2911
    {
2912
        $this->onConstraint($constraints);
3✔
2913

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

2917
            if ($sql === '') {
1✔
2918
                return false; // @codeCoverageIgnore
2919
            }
2920

2921
            if ($this->testMode === false) {
1✔
2922
                $this->db->query($sql, null, false);
1✔
2923
            }
2924

2925
            $this->resetWrite();
1✔
2926

2927
            return $this->testMode ? $sql : $this->db->affectedRows();
1✔
2928
        }
2929

2930
        if ($set !== null && $set !== []) {
2✔
UNCOV
2931
            $this->setData($set, true);
×
2932
        }
2933

2934
        return $this->batchExecute('_deleteBatch', $batchSize);
2✔
2935
    }
2936

2937
    /**
2938
     * Generates a platform-specific batch update string from the supplied data
2939
     *
2940
     * @used-by batchExecute()
2941
     *
2942
     * @param string           $table  Protected table name
2943
     * @param list<string>     $keys   QBKeys
2944
     * @param list<int|string> $values QBSet
2945
     */
2946
    protected function _deleteBatch(string $table, array $keys, array $values): string
2947
    {
2948
        $sql = $this->QBOptions['sql'] ?? '';
3✔
2949

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

2954
            if ($constraints === []) {
3✔
UNCOV
2955
                if ($this->db->DBDebug) {
×
2956
                    throw new DatabaseException('You must specify a constraint to match on for batch deletes.'); // @codeCoverageIgnore
2957
                }
2958

2959
                return ''; // @codeCoverageIgnore
2960
            }
2961

2962
            $alias = $this->QBOptions['alias'] ?? '_u';
3✔
2963

2964
            $sql = 'DELETE ' . $table . ' FROM ' . $table . "\n";
3✔
2965

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

2968
            $sql .= ') ' . $alias . "\n";
3✔
2969

2970
            $sql .= 'ON ' . implode(
3✔
2971
                ' AND ',
3✔
2972
                array_map(
3✔
2973
                    static fn ($key, $value) => (
3✔
2974
                        $value instanceof RawSql ?
3✔
UNCOV
2975
                        $value :
×
2976
                        (
3✔
2977
                            is_string($key) ?
3✔
2978
                            $table . '.' . $key . ' = ' . $alias . '.' . $value :
2✔
2979
                            $table . '.' . $value . ' = ' . $alias . '.' . $value
3✔
2980
                        )
3✔
2981
                    ),
3✔
2982
                    array_keys($constraints),
3✔
2983
                    $constraints,
3✔
2984
                ),
3✔
2985
            );
3✔
2986

2987
            // convert binds in where
2988
            foreach ($this->QBWhere as $key => $where) {
3✔
2989
                foreach ($this->binds as $field => $bind) {
2✔
UNCOV
2990
                    $this->QBWhere[$key]['condition'] = str_replace(':' . $field . ':', $bind[0], $where['condition']);
×
2991
                }
2992
            }
2993

2994
            $sql .= ' ' . $this->compileWhereHaving('QBWhere');
3✔
2995

2996
            $this->QBOptions['sql'] = trim($sql);
3✔
2997
        }
2998

2999
        if (isset($this->QBOptions['setQueryAsData'])) {
3✔
3000
            $data = $this->QBOptions['setQueryAsData'];
1✔
3001
        } else {
3002
            $data = implode(
2✔
3003
                " UNION ALL\n",
2✔
3004
                array_map(
2✔
3005
                    static fn ($value): string => 'SELECT ' . implode(', ', array_map(
2✔
3006
                        static fn ($key, $index): string => $index . ' ' . $key,
2✔
3007
                        $keys,
2✔
3008
                        $value,
2✔
3009
                    )),
2✔
3010
                    $values,
2✔
3011
                ),
2✔
3012
            ) . "\n";
2✔
3013
        }
3014

3015
        return str_replace('{:_table_:}', $data, $sql);
3✔
3016
    }
3017

3018
    /**
3019
     * Increments a numeric column by the specified value.
3020
     *
3021
     * @return bool
3022
     */
3023
    public function increment(string $column, int $value = 1)
3024
    {
3025
        return $this->incrementMany([$column], $value);
3✔
3026
    }
3027

3028
    /**
3029
     * Increments multiple numeric columns by the specified value(s).
3030
     *
3031
     * @param array<string, int>|list<string> $columns A list of columns or array of column => value pairs to increment.
3032
     * @param int                             $value   The value to increment by if $columns is a list of column names.
3033
     */
3034
    public function incrementMany(array $columns, int $value = 1): bool
3035
    {
3036
        if ($columns === []) {
9✔
3037
            throw new InvalidArgumentException('Argument #1 ($columns) cannot be empty.');
1✔
3038
        }
3039

3040
        if (array_is_list($columns)) {
8✔
3041
            $columns = array_fill_keys($columns, $value);
5✔
3042
        }
3043

3044
        $fields = [];
8✔
3045

3046
        foreach ($columns as $col => $val) {
8✔
3047
            if (! is_int($val)) {
8✔
3048
                throw new TypeError(sprintf(
1✔
3049
                    'Argument #1 ($columns) must contain only int values, %s given for "%s".',
1✔
3050
                    get_debug_type($val),
1✔
3051
                    $col,
1✔
3052
                ));
1✔
3053
            }
3054

3055
            $col          = $this->db->protectIdentifiers($col);
8✔
3056
            $fields[$col] = "{$col} + {$val}";
8✔
3057
        }
3058

3059
        $sql = $this->_update($this->QBFrom[0], $fields);
7✔
3060

3061
        if (! $this->testMode) {
7✔
3062
            $this->resetWrite();
7✔
3063

3064
            return $this->db->query($sql, $this->binds, false);
7✔
3065
        }
3066

UNCOV
3067
        return true;
×
3068
    }
3069

3070
    /**
3071
     * Decrements a numeric column by the specified value.
3072
     *
3073
     * @return bool
3074
     */
3075
    public function decrement(string $column, int $value = 1)
3076
    {
3077
        return $this->decrementMany([$column], $value);
3✔
3078
    }
3079

3080
    /**
3081
     * Decrements multiple numeric columns by the specified value(s).
3082
     *
3083
     * @param array<string, int>|list<string> $columns A list of columns or array of column => value pairs to decrement.
3084
     * @param int                             $value   The value to decrement by if $columns is a list of column names.
3085
     */
3086
    public function decrementMany(array $columns, int $value = 1): bool
3087
    {
3088
        if ($columns === []) {
9✔
3089
            throw new InvalidArgumentException('Argument #1 ($columns) cannot be empty.');
1✔
3090
        }
3091

3092
        if (array_is_list($columns)) {
8✔
3093
            $columns = array_fill_keys($columns, $value);
5✔
3094
        }
3095

3096
        $fields = [];
8✔
3097

3098
        foreach ($columns as $col => $val) {
8✔
3099
            if (! is_int($val)) {
8✔
3100
                throw new TypeError(sprintf(
1✔
3101
                    'Argument #1 ($columns) must contain only int values, %s given for "%s".',
1✔
3102
                    get_debug_type($val),
1✔
3103
                    $col,
1✔
3104
                ));
1✔
3105
            }
3106

3107
            $col          = $this->db->protectIdentifiers($col);
8✔
3108
            $fields[$col] = "{$col} - {$val}";
8✔
3109
        }
3110

3111
        $sql = $this->_update($this->QBFrom[0], $fields);
7✔
3112

3113
        if (! $this->testMode) {
7✔
3114
            $this->resetWrite();
7✔
3115

3116
            return $this->db->query($sql, $this->binds, false);
7✔
3117
        }
3118

UNCOV
3119
        return true;
×
3120
    }
3121

3122
    /**
3123
     * Generates a platform-specific delete string from the supplied data
3124
     *
3125
     * @param string $table Protected table name
3126
     */
3127
    protected function _delete(string $table): string
3128
    {
3129
        return 'DELETE ' . $this->compileIgnore('delete') . 'FROM ' . $table . $this->compileWhereHaving('QBWhere');
801✔
3130
    }
3131

3132
    /**
3133
     * Used to track SQL statements written with aliased tables.
3134
     *
3135
     * @param array|string $table The table to inspect
3136
     *
3137
     * @return string|null
3138
     */
3139
    protected function trackAliases($table)
3140
    {
3141
        if (is_array($table)) {
1,137✔
UNCOV
3142
            foreach ($table as $t) {
×
UNCOV
3143
                $this->trackAliases($t);
×
3144
            }
3145

UNCOV
3146
            return null;
×
3147
        }
3148

3149
        // Does the string contain a comma?  If so, we need to separate
3150
        // the string into discreet statements
3151
        if (str_contains($table, ',')) {
1,137✔
UNCOV
3152
            return $this->trackAliases(explode(',', $table));
×
3153
        }
3154

3155
        // if a table alias is used we can recognize it by a space
3156
        if (str_contains($table, ' ')) {
1,137✔
3157
            // if the alias is written with the AS keyword, remove it
3158
            $table = preg_replace('/\s+AS\s+/i', ' ', $table);
17✔
3159

3160
            // Grab the alias
3161
            $alias = trim(strrchr($table, ' '));
17✔
3162

3163
            // Store the alias, if it doesn't already exist
3164
            $this->db->addTableAlias($alias);
17✔
3165
        }
3166

3167
        return null;
1,137✔
3168
    }
3169

3170
    /**
3171
     * Compile the SELECT statement
3172
     *
3173
     * Generates a query string based on which functions were used.
3174
     * Should not be called directly.
3175
     *
3176
     * @param mixed $selectOverride
3177
     */
3178
    protected function compileSelect($selectOverride = false): string
3179
    {
3180
        if ($selectOverride !== false) {
1,069✔
3181
            $sql = $selectOverride;
223✔
3182
        } else {
3183
            $sql = $this->QBDistinct ? 'SELECT DISTINCT ' : 'SELECT ';
1,066✔
3184

3185
            if (empty($this->QBSelect)) {
1,066✔
3186
                $sql .= '*';
957✔
3187
            } else {
3188
                // Cycle through the "select" portion of the query and prep each column name.
3189
                // The reason we protect identifiers here rather than in the select() function
3190
                // is because until the user calls the from() function we don't know if there are aliases
3191
                foreach ($this->QBSelect as $key => $val) {
927✔
3192
                    if ($val instanceof RawSql) {
927✔
3193
                        $this->QBSelect[$key] = (string) $val;
5✔
3194
                    } else {
3195
                        $protect              = $this->QBNoEscape[$key] ?? null;
925✔
3196
                        $this->QBSelect[$key] = $this->db->protectIdentifiers($val, false, $protect);
925✔
3197
                    }
3198
                }
3199

3200
                $sql .= implode(', ', $this->QBSelect);
927✔
3201
            }
3202
        }
3203

3204
        if (! empty($this->QBFrom)) {
1,069✔
3205
            $sql .= "\nFROM " . $this->_fromTables();
1,069✔
3206
        }
3207

3208
        if (! empty($this->QBJoin)) {
1,069✔
3209
            $sql .= "\n" . implode("\n", $this->QBJoin);
15✔
3210
        }
3211

3212
        $sql .= $this->compileWhereHaving('QBWhere')
1,069✔
3213
            . $this->compileGroupBy()
1,069✔
3214
            . $this->compileWhereHaving('QBHaving')
1,069✔
3215
            . $this->compileOrderBy();
1,069✔
3216

3217
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
1,069✔
3218
        if ($limitZeroAsAll) {
1,069✔
3219
            if ($this->QBLimit) {
1,068✔
3220
                $sql = $this->_limit($sql . "\n");
105✔
3221
            }
3222
        } elseif ($this->QBLimit !== false || $this->QBOffset) {
2✔
3223
            $sql = $this->_limit($sql . "\n");
2✔
3224
        }
3225

3226
        return $this->unionInjection($sql);
1,069✔
3227
    }
3228

3229
    /**
3230
     * Checks if the ignore option is supported by
3231
     * the Database Driver for the specific statement.
3232
     *
3233
     * @return string
3234
     */
3235
    protected function compileIgnore(string $statement)
3236
    {
3237
        if ($this->QBIgnore && isset($this->supportedIgnoreStatements[$statement])) {
862✔
3238
            return trim($this->supportedIgnoreStatements[$statement]) . ' ';
1✔
3239
        }
3240

3241
        return '';
861✔
3242
    }
3243

3244
    /**
3245
     * Escapes identifiers in WHERE and HAVING statements at execution time.
3246
     *
3247
     * Required so that aliases are tracked properly, regardless of whether
3248
     * where(), orWhere(), having(), orHaving are called prior to from(),
3249
     * join() and prefixTable is added only if needed.
3250
     *
3251
     * @param string $qbKey 'QBWhere' or 'QBHaving'
3252
     *
3253
     * @return string SQL statement
3254
     */
3255
    protected function compileWhereHaving(string $qbKey): string
3256
    {
3257
        if (! empty($this->{$qbKey})) {
1,093✔
3258
            foreach ($this->{$qbKey} as &$qbkey) {
972✔
3259
                // Is this condition already compiled?
3260
                if (is_string($qbkey)) {
972✔
3261
                    continue;
23✔
3262
                }
3263

3264
                if ($qbkey instanceof RawSql) {
972✔
3265
                    continue;
2✔
3266
                }
3267

3268
                if ($qbkey['condition'] instanceof RawSql) {
972✔
3269
                    $qbkey = $qbkey['condition'];
4✔
3270

3271
                    continue;
4✔
3272
                }
3273

3274
                if (($qbkey['columnComparison'] ?? false) === true) {
970✔
3275
                    $qbkey = $this->compileColumnComparison($qbkey);
15✔
3276

3277
                    continue;
15✔
3278
                }
3279

3280
                if ($qbkey['escape'] === false) {
957✔
3281
                    $qbkey = $qbkey['condition'];
108✔
3282

3283
                    continue;
108✔
3284
                }
3285

3286
                // Split multiple conditions
3287
                $conditions = preg_split(
947✔
3288
                    '/((?:^|\s+)AND\s+|(?:^|\s+)OR\s+)/i',
947✔
3289
                    $qbkey['condition'],
947✔
3290
                    -1,
947✔
3291
                    PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY,
947✔
3292
                );
947✔
3293

3294
                foreach ($conditions as &$condition) {
947✔
3295
                    $op = $this->getOperator($condition);
947✔
3296
                    if (
3297
                        $op === false
947✔
3298
                        || preg_match(
947✔
3299
                            '/^(\(?)(.*)(' . preg_quote($op, '/') . ')\s*(.*(?<!\)))?(\)?)$/i',
947✔
3300
                            $condition,
947✔
3301
                            $matches,
947✔
3302
                        ) !== 1
947✔
3303
                    ) {
3304
                        continue;
841✔
3305
                    }
3306

3307
                    // $matches = [
3308
                    //  0 => '(test <= foo)',   /* the whole thing */
3309
                    //  1 => '(',               /* optional */
3310
                    //  2 => 'test',            /* the field name */
3311
                    //  3 => ' <= ',            /* $op */
3312
                    //  4 => 'foo',                    /* optional, if $op is e.g. 'IS NULL' */
3313
                    //  5 => ')'                /* optional */
3314
                    // ];
3315

3316
                    if ($matches[4] !== '') {
947✔
3317
                        $protectIdentifiers = false;
910✔
3318
                        if (str_contains($matches[4], '.')) {
910✔
3319
                            $protectIdentifiers = true;
86✔
3320
                        }
3321

3322
                        if (! str_contains($matches[4], ':')) {
910✔
3323
                            $matches[4] = $this->db->protectIdentifiers(trim($matches[4]), false, $protectIdentifiers);
14✔
3324
                        }
3325

3326
                        $matches[4] = ' ' . $matches[4];
910✔
3327
                    }
3328

3329
                    $condition = $matches[1] . $this->db->protectIdentifiers(trim($matches[2]))
947✔
3330
                        . ' ' . trim($matches[3]) . $matches[4] . $matches[5];
947✔
3331
                }
3332

3333
                $qbkey = implode('', $conditions);
947✔
3334
            }
3335

3336
            return ($qbKey === 'QBHaving' ? "\nHAVING " : "\nWHERE ")
972✔
3337
                . implode("\n", $this->{$qbKey});
972✔
3338
        }
3339

3340
        return '';
1,075✔
3341
    }
3342

3343
    /**
3344
     * @used-by compileWhereHaving()
3345
     *
3346
     * @param array{columnComparison: true, condition: string, escape: bool, first: string, operator: string, second: string} $condition
3347
     */
3348
    private function compileColumnComparison(array $condition): string
3349
    {
3350
        if ($condition['escape']) {
15✔
3351
            $condition['first']  = $this->db->protectIdentifiers($condition['first'], false, true);
13✔
3352
            $condition['second'] = $this->db->protectIdentifiers($condition['second'], false, true);
13✔
3353
        }
3354

3355
        return $condition['condition'] . $condition['first'] . ' ' . $condition['operator'] . ' ' . $condition['second'];
15✔
3356
    }
3357

3358
    /**
3359
     * Escapes identifiers in GROUP BY statements at execution time.
3360
     *
3361
     * Required so that aliases are tracked properly, regardless of whether
3362
     * groupBy() is called prior to from(), join() and prefixTable is added
3363
     * only if needed.
3364
     */
3365
    protected function compileGroupBy(): string
3366
    {
3367
        if (! empty($this->QBGroupBy)) {
1,076✔
3368
            foreach ($this->QBGroupBy as &$groupBy) {
56✔
3369
                // Is it already compiled?
3370
                if (is_string($groupBy)) {
56✔
3371
                    continue;
2✔
3372
                }
3373

3374
                $groupBy = ($groupBy['escape'] === false || $this->isLiteral($groupBy['field']))
56✔
UNCOV
3375
                    ? $groupBy['field']
×
3376
                    : $this->db->protectIdentifiers($groupBy['field']);
56✔
3377
            }
3378

3379
            return "\nGROUP BY " . implode(', ', $this->QBGroupBy);
56✔
3380
        }
3381

3382
        return '';
1,050✔
3383
    }
3384

3385
    /**
3386
     * Escapes identifiers in ORDER BY statements at execution time.
3387
     *
3388
     * Required so that aliases are tracked properly, regardless of whether
3389
     * orderBy() is called prior to from(), join() and prefixTable is added
3390
     * only if needed.
3391
     */
3392
    protected function compileOrderBy(): string
3393
    {
3394
        if (is_array($this->QBOrderBy) && $this->QBOrderBy !== []) {
1,088✔
3395
            foreach ($this->QBOrderBy as &$orderBy) {
836✔
3396
                if (is_string($orderBy)) {
836✔
3397
                    continue;
1✔
3398
                }
3399
                if ($orderBy['escape'] !== false && ! $this->isLiteral($orderBy['field'])) {
836✔
3400
                    $orderBy['field'] = $this->db->protectIdentifiers($orderBy['field']);
834✔
3401
                }
3402

3403
                $orderBy = $orderBy['field'] . $orderBy['direction'];
836✔
3404
            }
3405

3406
            return "\nORDER BY " . implode(', ', $this->QBOrderBy);
836✔
3407
        }
3408

3409
        return '';
1,061✔
3410
    }
3411

3412
    protected function unionInjection(string $sql): string
3413
    {
3414
        if ($this->QBUnion === []) {
1,076✔
3415
            return $sql;
1,076✔
3416
        }
3417

3418
        return 'SELECT * FROM (' . $sql . ') '
6✔
3419
            . ($this->db->protectIdentifiers ? $this->db->escapeIdentifiers('uwrp0') : 'uwrp0')
6✔
3420
            . implode("\n", $this->QBUnion);
6✔
3421
    }
3422

3423
    /**
3424
     * Takes an object as input and converts the class variables to array key/vals
3425
     *
3426
     * @param array|object $object
3427
     *
3428
     * @return array
3429
     */
3430
    protected function objectToArray($object)
3431
    {
3432
        if (! is_object($object)) {
859✔
3433
            return $object;
856✔
3434
        }
3435

3436
        if ($object instanceof RawSql) {
8✔
3437
            throw new InvalidArgumentException('RawSql "' . $object . '" cannot be used here.');
1✔
3438
        }
3439

3440
        $array = [];
7✔
3441

3442
        foreach (get_object_vars($object) as $key => $val) {
7✔
3443
            if ((! is_object($val) || $val instanceof RawSql) && ! is_array($val)) {
7✔
3444
                $array[$key] = $val;
7✔
3445
            }
3446
        }
3447

3448
        return $array;
7✔
3449
    }
3450

3451
    /**
3452
     * Takes an object as input and converts the class variables to array key/vals
3453
     *
3454
     * @param array|object $object
3455
     *
3456
     * @return array
3457
     */
3458
    protected function batchObjectToArray($object)
3459
    {
3460
        if (! is_object($object)) {
68✔
3461
            return $object;
68✔
3462
        }
3463

UNCOV
3464
        $array  = [];
×
UNCOV
3465
        $out    = get_object_vars($object);
×
UNCOV
3466
        $fields = array_keys($out);
×
3467

UNCOV
3468
        foreach ($fields as $val) {
×
UNCOV
3469
            $i = 0;
×
3470

UNCOV
3471
            foreach ($out[$val] as $data) {
×
UNCOV
3472
                $array[$i++][$val] = $data;
×
3473
            }
3474
        }
3475

UNCOV
3476
        return $array;
×
3477
    }
3478

3479
    /**
3480
     * Determines if a string represents a literal value or a field name
3481
     */
3482
    protected function isLiteral(string $str): bool
3483
    {
3484
        $str = trim($str);
864✔
3485

3486
        if ($str === ''
864✔
3487
            || ctype_digit($str)
864✔
3488
            || (string) (float) $str === $str
864✔
3489
            || in_array(strtoupper($str), ['TRUE', 'FALSE'], true)
864✔
3490
        ) {
UNCOV
3491
            return true;
×
3492
        }
3493

3494
        if ($this->isLiteralStr === []) {
864✔
3495
            $this->isLiteralStr = $this->db->escapeChar !== '"' ? ['"', "'"] : ["'"];
864✔
3496
        }
3497

3498
        return in_array($str[0], $this->isLiteralStr, true);
864✔
3499
    }
3500

3501
    /**
3502
     * Publicly-visible method to reset the QB values.
3503
     *
3504
     * @return $this
3505
     */
3506
    public function resetQuery()
3507
    {
3508
        $this->resetSelect();
1✔
3509
        $this->resetWrite();
1✔
3510

3511
        return $this;
1✔
3512
    }
3513

3514
    /**
3515
     * Resets the query builder values.  Called by the get() function
3516
     *
3517
     * @param array $qbResetItems An array of fields to reset
3518
     *
3519
     * @return void
3520
     */
3521
    protected function resetRun(array $qbResetItems)
3522
    {
3523
        foreach ($qbResetItems as $item => $defaultValue) {
1,098✔
3524
            $this->{$item} = $defaultValue;
1,098✔
3525
        }
3526
    }
3527

3528
    /**
3529
     * Resets the query builder values.  Called by the get() function
3530
     *
3531
     * @return void
3532
     */
3533
    protected function resetSelect()
3534
    {
3535
        $this->resetRun([
1,071✔
3536
            'QBSelect'   => [],
1,071✔
3537
            'QBJoin'     => [],
1,071✔
3538
            'QBWhere'    => [],
1,071✔
3539
            'QBGroupBy'  => [],
1,071✔
3540
            'QBHaving'   => [],
1,071✔
3541
            'QBOrderBy'  => [],
1,071✔
3542
            'QBNoEscape' => [],
1,071✔
3543
            'QBDistinct' => false,
1,071✔
3544
            'QBLimit'    => false,
1,071✔
3545
            'QBOffset'   => false,
1,071✔
3546
            'QBUnion'    => [],
1,071✔
3547
        ]);
1,071✔
3548

3549
        if ($this->db instanceof BaseConnection) {
1,071✔
3550
            $this->db->setAliasedTables([]);
1,071✔
3551
        }
3552

3553
        // Reset QBFrom part
3554
        if (! empty($this->QBFrom)) {
1,071✔
3555
            $this->from(array_shift($this->QBFrom), true);
1,071✔
3556
        }
3557
    }
3558

3559
    /**
3560
     * Resets the query builder "write" values.
3561
     *
3562
     * Called by the insert() update() insertBatch() updateBatch() and delete() functions
3563
     *
3564
     * @return void
3565
     */
3566
    protected function resetWrite()
3567
    {
3568
        $this->resetRun([
866✔
3569
            'QBSet'     => [],
866✔
3570
            'QBJoin'    => [],
866✔
3571
            'QBWhere'   => [],
866✔
3572
            'QBOrderBy' => [],
866✔
3573
            'QBKeys'    => [],
866✔
3574
            'QBLimit'   => false,
866✔
3575
            'QBIgnore'  => false,
866✔
3576
            'QBOptions' => [],
866✔
3577
        ]);
866✔
3578
    }
3579

3580
    /**
3581
     * Tests whether the string has an SQL operator
3582
     */
3583
    protected function hasOperator(string $str): bool
3584
    {
3585
        return preg_match(
157✔
3586
            '/(<|>|!|=|\sIS NULL|\sIS NOT NULL|\sEXISTS|\sBETWEEN|\sLIKE|\sIN\s*\(|\s)/i',
157✔
3587
            trim($str),
157✔
3588
        ) === 1;
157✔
3589
    }
3590

3591
    /**
3592
     * Returns the SQL string operator
3593
     *
3594
     * @return array|false|string
3595
     */
3596
    protected function getOperator(string $str, bool $list = false)
3597
    {
3598
        if ($this->pregOperators === []) {
956✔
3599
            $_les = $this->db->likeEscapeStr !== ''
956✔
3600
                ? '\s+' . preg_quote(trim(sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar)), '/')
956✔
UNCOV
3601
                : '';
×
3602
            $this->pregOperators = [
956✔
3603
                '\s*(?:<|>|!)?=\s*', // =, <=, >=, !=
956✔
3604
                '\s*<>?\s*',         // <, <>
956✔
3605
                '\s*>\s*',           // >
956✔
3606
                '\s+IS NULL',             // IS NULL
956✔
3607
                '\s+IS NOT NULL',         // IS NOT NULL
956✔
3608
                '\s+EXISTS\s*\(.*\)',     // EXISTS (sql)
956✔
3609
                '\s+NOT EXISTS\s*\(.*\)', // NOT EXISTS(sql)
956✔
3610
                '\s+BETWEEN\s+',          // BETWEEN value AND value
956✔
3611
                '\s+IN\s*\(.*\)',         // IN (list)
956✔
3612
                '\s+NOT IN\s*\(.*\)',     // NOT IN (list)
956✔
3613
                '\s+LIKE\s+\S.*(' . $_les . ')?',     // LIKE 'expr'[ ESCAPE '%s']
956✔
3614
                '\s+NOT LIKE\s+\S.*(' . $_les . ')?', // NOT LIKE 'expr'[ ESCAPE '%s']
956✔
3615
            ];
956✔
3616
        }
3617

3618
        return preg_match_all(
956✔
3619
            '/' . implode('|', $this->pregOperators) . '/i',
956✔
3620
            $str,
956✔
3621
            $match,
956✔
3622
        ) >= 1 ? ($list ? $match[0] : $match[0][0]) : false;
956✔
3623
    }
3624

3625
    /**
3626
     * Returns the SQL string operator from where key
3627
     *
3628
     * @return false|list<string>
3629
     */
3630
    private function getOperatorFromWhereKey(string $whereKey)
3631
    {
3632
        $whereKey = trim($whereKey);
909✔
3633

3634
        $pregOperators = [
909✔
3635
            '\s*(?:<|>|!)?=',         // =, <=, >=, !=
909✔
3636
            '\s*<>?',                 // <, <>
909✔
3637
            '\s*>',                   // >
909✔
3638
            '\s+IS NULL',             // IS NULL
909✔
3639
            '\s+IS NOT NULL',         // IS NOT NULL
909✔
3640
            '\s+EXISTS\s*\(.*\)',     // EXISTS (sql)
909✔
3641
            '\s+NOT EXISTS\s*\(.*\)', // NOT EXISTS (sql)
909✔
3642
            '\s+BETWEEN\s+',          // BETWEEN value AND value
909✔
3643
            '\s+IN\s*\(.*\)',         // IN (list)
909✔
3644
            '\s+NOT IN\s*\(.*\)',     // NOT IN (list)
909✔
3645
            '\s+LIKE',                // LIKE
909✔
3646
            '\s+NOT LIKE',            // NOT LIKE
909✔
3647
        ];
909✔
3648

3649
        return preg_match_all(
909✔
3650
            '/' . implode('|', $pregOperators) . '/i',
909✔
3651
            $whereKey,
909✔
3652
            $match,
909✔
3653
        ) >= 1 ? $match[0] : false;
909✔
3654
    }
3655

3656
    /**
3657
     * Stores a bind value after ensuring that it's unique.
3658
     * While it might be nicer to have named keys for our binds array
3659
     * with PHP 7+ we get a huge memory/performance gain with indexed
3660
     * arrays instead, so lets take advantage of that here.
3661
     *
3662
     * @param mixed $value
3663
     */
3664
    protected function setBind(string $key, $value = null, bool $escape = true): string
3665
    {
3666
        if (! array_key_exists($key, $this->binds)) {
956✔
3667
            $this->binds[$key] = [
956✔
3668
                $value,
956✔
3669
                $escape,
956✔
3670
            ];
956✔
3671

3672
            return $key;
956✔
3673
        }
3674

3675
        if (! array_key_exists($key, $this->bindsKeyCount)) {
45✔
3676
            $this->bindsKeyCount[$key] = 1;
45✔
3677
        }
3678

3679
        $count = $this->bindsKeyCount[$key]++;
45✔
3680

3681
        $this->binds[$key . '.' . $count] = [
45✔
3682
            $value,
45✔
3683
            $escape,
45✔
3684
        ];
45✔
3685

3686
        return $key . '.' . $count;
45✔
3687
    }
3688

3689
    /**
3690
     * @param mixed $value
3691
     */
3692
    protected function isSubquery($value): bool
3693
    {
3694
        return $value instanceof BaseBuilder || $value instanceof Closure;
918✔
3695
    }
3696

3697
    /**
3698
     * @param BaseBuilder|Closure(BaseBuilder): BaseBuilder $builder
3699
     * @param bool                                          $wrapped Wrap the subquery in brackets
3700
     * @param string                                        $alias   Subquery alias
3701
     */
3702
    protected function buildSubquery($builder, bool $wrapped = false, string $alias = ''): string
3703
    {
3704
        if ($builder instanceof Closure) {
21✔
3705
            $builder($builder = $this->db->newQuery());
11✔
3706
        }
3707

3708
        if ($builder === $this) {
21✔
3709
            throw new DatabaseException('The subquery cannot be the same object as the main query object.');
1✔
3710
        }
3711

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

3714
        if ($wrapped) {
20✔
3715
            $subquery = '(' . $subquery . ')';
20✔
3716
            $alias    = trim($alias);
20✔
3717

3718
            if ($alias !== '') {
20✔
3719
                $subquery .= ' ' . ($this->db->protectIdentifiers ? $this->db->escapeIdentifiers($alias) : $alias);
11✔
3720
            }
3721
        }
3722

3723
        return $subquery;
20✔
3724
    }
3725
}
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