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

codeigniter4 / CodeIgniter4 / 25458229238

06 May 2026 08:05PM UTC coverage: 88.308% (+0.03%) from 88.274%
25458229238

Pull #10158

github

web-flow
Merge 8f3ba7ad6 into f300ca0e1
Pull Request #10158: feat: add typed FormRequest accessors

87 of 96 new or added lines in 5 files covered. (90.63%)

123 existing lines in 4 files now uncovered.

23640 of 26770 relevant lines covered (88.31%)

218.13 hits per line

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

94.25
/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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

358
        return $this;
80✔
359
    }
360

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

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

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

390
        return $this;
1✔
391
    }
392

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

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

412
        if (is_string($select)) {
894✔
413
            $select = ($escape === false) ? [$select] : explode(',', $select);
887✔
414
        }
415

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

421
                continue;
5✔
422
            }
423

424
            $val = trim($val);
892✔
425

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

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

439
                    continue;
2✔
440
                }
441

442
                $this->QBNoEscape[] = $escape;
892✔
443
            }
444
        }
445

446
        return $this;
894✔
447
    }
448

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

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

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

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

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

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

506
        return $this;
2✔
507
    }
508

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

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

532
        $type = strtoupper($type);
806✔
533

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

538
        if ($alias === '') {
806✔
539
            $alias = $this->createAliasFromTable(trim($select));
801✔
540
        }
541

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

544
        $this->QBSelect[]   = $sql;
806✔
545
        $this->QBNoEscape[] = null;
806✔
546

547
        return $this;
806✔
548
    }
549

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

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

561
        return $item;
800✔
562
    }
563

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

573
        return $this;
804✔
574
    }
575

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

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

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

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

605
        return $this;
1,125✔
606
    }
607

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

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

621
        return $this;
4✔
622
    }
623

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

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

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

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

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

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

659
            return $this;
1✔
660
        }
661

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

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

686
            $cond = ' ON ';
14✔
687

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

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

695
                    continue;
1✔
696
                }
697

698
                $cond .= $joints[$i];
14✔
699
                $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✔
700
            }
701
        }
702

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

706
        return $this;
14✔
707
    }
708

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

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

739
    /**
740
     * Generates a WHERE clause that compares two columns.
741
     *
742
     * @param non-empty-string $first  First column name, optionally with comparison operator
743
     * @param non-empty-string $second Second column name
744
     * @param bool|null        $escape Whether to protect identifiers
745
     *
746
     * @return $this
747
     *
748
     * @throws InvalidArgumentException
749
     */
750
    public function whereColumn(string $first, string $second, ?bool $escape = null): static
751
    {
752
        return $this->whereColumnHaving('QBWhere', $first, $second, 'AND ', $escape);
16✔
753
    }
754

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

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

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

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

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

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

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

809
        return $this;
15✔
810
    }
811

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

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

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

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

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

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

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

866
            if ($rawSqlOnly) {
918✔
867
                $k  = '';
1✔
868
                $op = '';
1✔
869
            } elseif ($v !== null) {
917✔
870
                $op = $this->getOperatorFromWhereKey($k);
897✔
871

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

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

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

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

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

926
        return $this;
918✔
927
    }
928

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

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

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

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

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

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

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

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

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

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

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

1060
        $ok = $key;
81✔
1061

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

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

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

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

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

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

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

1086
        return $this;
81✔
1087
    }
1088

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1241
            return $this;
3✔
1242
        }
1243

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

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

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

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

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

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

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

1276
        return $this;
44✔
1277
    }
1278

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

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

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

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

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

1330
        return $this;
6✔
1331
    }
1332

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

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

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

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

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

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

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

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

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

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

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

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

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

1451
        return $this;
17✔
1452
    }
1453

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

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

1469
        return $this;
17✔
1470
    }
1471

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

1487
        return $type;
960✔
1488
    }
1489

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

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

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

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

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

1518
        return $this;
56✔
1519
    }
1520

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

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

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

1558
        $qbOrderBy = [];
824✔
1559

1560
        $direction = strtoupper(trim($direction));
824✔
1561

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

1570
        if ($escape === null) {
824✔
1571
            $escape = $this->db->protectIdentifiers;
822✔
1572
        }
1573

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

1596
        $this->QBOrderBy = array_merge($this->QBOrderBy, $qbOrderBy);
824✔
1597

1598
        return $this;
824✔
1599
    }
1600

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

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

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

1619
        return $this;
123✔
1620
    }
1621

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

1633
        return $this;
1✔
1634
    }
1635

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

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

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

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

1663
        foreach ($key as $k => $v) {
840✔
1664
            if ($escape) {
840✔
1665
                $bind = $this->setBind($k, $v, $escape);
839✔
1666

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

1673
        return $this;
840✔
1674
    }
1675

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

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

UNCOV
1687
        return $data;
×
1688
    }
1689

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

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

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

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

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

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

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

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

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

1741
        if ($reset) {
854✔
1742
            $this->resetSelect();
854✔
1743

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

1748
        return $result;
854✔
1749
    }
1750

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

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

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

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

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

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

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

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

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

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

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

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

1805
        $this->QBLimit = false;
219✔
1806

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

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

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

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

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

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

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

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

1840
        return (int) $row->numrows;
214✔
1841
    }
1842

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

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

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

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

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

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

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

1886
        return $result;
17✔
1887
    }
1888

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

1905
            return false; // @codeCoverageIgnore
1906
        }
1907

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

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

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

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

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

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

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

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

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

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

1958
            return null; // @codeCoverageIgnore
1959
        }
1960

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

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

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

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

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

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

UNCOV
1981
                return null;
×
1982
            }
1983

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

1986
            $clean = [];
68✔
1987

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

1992
            $row = $clean;
68✔
1993

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

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

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

2005
        return $this;
68✔
2006
    }
2007

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2146
        return $this;
68✔
2147
    }
2148

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

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

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

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

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

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

2190
        return $this;
38✔
2191
    }
2192

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

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

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

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

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

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

2226
        return $this;
47✔
2227
    }
2228

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

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

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

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

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

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

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

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

2272
        return $this;
5✔
2273
    }
2274

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2414
        if (! $this->testMode) {
826✔
2415
            $this->resetWrite();
822✔
2416

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

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

2422
            return $result;
822✔
2423
        }
2424

2425
        return false;
5✔
2426
    }
2427

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

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

2445
        return $from;
831✔
2446
    }
2447

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

2462
            return false; // @codeCoverageIgnore
2463
        }
2464

2465
        return true;
826✔
2466
    }
2467

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

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

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

2498
            return false; // @codeCoverageIgnore
2499
        }
2500

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2599
                return true;
87✔
2600
            }
2601

2602
            return false;
3✔
2603
        }
2604

2605
        return true;
13✔
2606
    }
2607

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

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

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

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

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

2650
            return false; // @codeCoverageIgnore
2651
        }
2652

2653
        return true;
104✔
2654
    }
2655

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2815
        $sql = $this->_truncate($table);
745✔
2816

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

2821
        $this->resetWrite();
744✔
2822

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

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

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

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

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

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

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

2872
            return false; // @codeCoverageIgnore
2873
        }
2874

2875
        $sql = $this->_delete($this->removeAlias($table));
786✔
2876

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

3026
        $sql = $this->_update($this->QBFrom[0], [$column => "{$column} + {$value}"]);
3✔
3027

3028
        if (! $this->testMode) {
3✔
3029
            $this->resetWrite();
3✔
3030

3031
            return $this->db->query($sql, $this->binds, false);
3✔
3032
        }
3033

UNCOV
3034
        return true;
×
3035
    }
3036

3037
    /**
3038
     * Decrements a numeric column by the specified value.
3039
     *
3040
     * @return bool
3041
     */
3042
    public function decrement(string $column, int $value = 1)
3043
    {
3044
        $column = $this->db->protectIdentifiers($column);
3✔
3045

3046
        $sql = $this->_update($this->QBFrom[0], [$column => "{$column}-{$value}"]);
3✔
3047

3048
        if (! $this->testMode) {
3✔
3049
            $this->resetWrite();
3✔
3050

3051
            return $this->db->query($sql, $this->binds, false);
3✔
3052
        }
3053

UNCOV
3054
        return true;
×
3055
    }
3056

3057
    /**
3058
     * Generates a platform-specific delete string from the supplied data
3059
     *
3060
     * @param string $table Protected table name
3061
     */
3062
    protected function _delete(string $table): string
3063
    {
3064
        return 'DELETE ' . $this->compileIgnore('delete') . 'FROM ' . $table . $this->compileWhereHaving('QBWhere');
789✔
3065
    }
3066

3067
    /**
3068
     * Used to track SQL statements written with aliased tables.
3069
     *
3070
     * @param array|string $table The table to inspect
3071
     *
3072
     * @return string|null
3073
     */
3074
    protected function trackAliases($table)
3075
    {
3076
        if (is_array($table)) {
1,125✔
UNCOV
3077
            foreach ($table as $t) {
×
UNCOV
3078
                $this->trackAliases($t);
×
3079
            }
3080

UNCOV
3081
            return null;
×
3082
        }
3083

3084
        // Does the string contain a comma?  If so, we need to separate
3085
        // the string into discreet statements
3086
        if (str_contains($table, ',')) {
1,125✔
UNCOV
3087
            return $this->trackAliases(explode(',', $table));
×
3088
        }
3089

3090
        // if a table alias is used we can recognize it by a space
3091
        if (str_contains($table, ' ')) {
1,125✔
3092
            // if the alias is written with the AS keyword, remove it
3093
            $table = preg_replace('/\s+AS\s+/i', ' ', $table);
17✔
3094

3095
            // Grab the alias
3096
            $alias = trim(strrchr($table, ' '));
17✔
3097

3098
            // Store the alias, if it doesn't already exist
3099
            $this->db->addTableAlias($alias);
17✔
3100
        }
3101

3102
        return null;
1,125✔
3103
    }
3104

3105
    /**
3106
     * Compile the SELECT statement
3107
     *
3108
     * Generates a query string based on which functions were used.
3109
     * Should not be called directly.
3110
     *
3111
     * @param mixed $selectOverride
3112
     */
3113
    protected function compileSelect($selectOverride = false): string
3114
    {
3115
        if ($selectOverride !== false) {
1,057✔
3116
            $sql = $selectOverride;
215✔
3117
        } else {
3118
            $sql = $this->QBDistinct ? 'SELECT DISTINCT ' : 'SELECT ';
1,054✔
3119

3120
            if (empty($this->QBSelect)) {
1,054✔
3121
                $sql .= '*';
945✔
3122
            } else {
3123
                // Cycle through the "select" portion of the query and prep each column name.
3124
                // The reason we protect identifiers here rather than in the select() function
3125
                // is because until the user calls the from() function we don't know if there are aliases
3126
                foreach ($this->QBSelect as $key => $val) {
915✔
3127
                    if ($val instanceof RawSql) {
915✔
3128
                        $this->QBSelect[$key] = (string) $val;
5✔
3129
                    } else {
3130
                        $protect              = $this->QBNoEscape[$key] ?? null;
913✔
3131
                        $this->QBSelect[$key] = $this->db->protectIdentifiers($val, false, $protect);
913✔
3132
                    }
3133
                }
3134

3135
                $sql .= implode(', ', $this->QBSelect);
915✔
3136
            }
3137
        }
3138

3139
        if (! empty($this->QBFrom)) {
1,057✔
3140
            $sql .= "\nFROM " . $this->_fromTables();
1,057✔
3141
        }
3142

3143
        if (! empty($this->QBJoin)) {
1,057✔
3144
            $sql .= "\n" . implode("\n", $this->QBJoin);
15✔
3145
        }
3146

3147
        $sql .= $this->compileWhereHaving('QBWhere')
1,057✔
3148
            . $this->compileGroupBy()
1,057✔
3149
            . $this->compileWhereHaving('QBHaving')
1,057✔
3150
            . $this->compileOrderBy();
1,057✔
3151

3152
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
1,057✔
3153
        if ($limitZeroAsAll) {
1,057✔
3154
            if ($this->QBLimit) {
1,056✔
3155
                $sql = $this->_limit($sql . "\n");
105✔
3156
            }
3157
        } elseif ($this->QBLimit !== false || $this->QBOffset) {
2✔
3158
            $sql = $this->_limit($sql . "\n");
2✔
3159
        }
3160

3161
        return $this->unionInjection($sql);
1,057✔
3162
    }
3163

3164
    /**
3165
     * Checks if the ignore option is supported by
3166
     * the Database Driver for the specific statement.
3167
     *
3168
     * @return string
3169
     */
3170
    protected function compileIgnore(string $statement)
3171
    {
3172
        if ($this->QBIgnore && isset($this->supportedIgnoreStatements[$statement])) {
850✔
3173
            return trim($this->supportedIgnoreStatements[$statement]) . ' ';
1✔
3174
        }
3175

3176
        return '';
849✔
3177
    }
3178

3179
    /**
3180
     * Escapes identifiers in WHERE and HAVING statements at execution time.
3181
     *
3182
     * Required so that aliases are tracked properly, regardless of whether
3183
     * where(), orWhere(), having(), orHaving are called prior to from(),
3184
     * join() and prefixTable is added only if needed.
3185
     *
3186
     * @param string $qbKey 'QBWhere' or 'QBHaving'
3187
     *
3188
     * @return string SQL statement
3189
     */
3190
    protected function compileWhereHaving(string $qbKey): string
3191
    {
3192
        if (! empty($this->{$qbKey})) {
1,081✔
3193
            foreach ($this->{$qbKey} as &$qbkey) {
960✔
3194
                // Is this condition already compiled?
3195
                if (is_string($qbkey)) {
960✔
3196
                    continue;
23✔
3197
                }
3198

3199
                if ($qbkey instanceof RawSql) {
960✔
3200
                    continue;
2✔
3201
                }
3202

3203
                if ($qbkey['condition'] instanceof RawSql) {
960✔
3204
                    $qbkey = $qbkey['condition'];
4✔
3205

3206
                    continue;
4✔
3207
                }
3208

3209
                if (($qbkey['columnComparison'] ?? false) === true) {
958✔
3210
                    $qbkey = $this->compileColumnComparison($qbkey);
15✔
3211

3212
                    continue;
15✔
3213
                }
3214

3215
                if ($qbkey['escape'] === false) {
945✔
3216
                    $qbkey = $qbkey['condition'];
108✔
3217

3218
                    continue;
108✔
3219
                }
3220

3221
                // Split multiple conditions
3222
                $conditions = preg_split(
935✔
3223
                    '/((?:^|\s+)AND\s+|(?:^|\s+)OR\s+)/i',
935✔
3224
                    $qbkey['condition'],
935✔
3225
                    -1,
935✔
3226
                    PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY,
935✔
3227
                );
935✔
3228

3229
                foreach ($conditions as &$condition) {
935✔
3230
                    $op = $this->getOperator($condition);
935✔
3231
                    if (
3232
                        $op === false
935✔
3233
                        || preg_match(
935✔
3234
                            '/^(\(?)(.*)(' . preg_quote($op, '/') . ')\s*(.*(?<!\)))?(\)?)$/i',
935✔
3235
                            $condition,
935✔
3236
                            $matches,
935✔
3237
                        ) !== 1
935✔
3238
                    ) {
3239
                        continue;
829✔
3240
                    }
3241

3242
                    // $matches = [
3243
                    //  0 => '(test <= foo)',   /* the whole thing */
3244
                    //  1 => '(',               /* optional */
3245
                    //  2 => 'test',            /* the field name */
3246
                    //  3 => ' <= ',            /* $op */
3247
                    //  4 => 'foo',                    /* optional, if $op is e.g. 'IS NULL' */
3248
                    //  5 => ')'                /* optional */
3249
                    // ];
3250

3251
                    if ($matches[4] !== '') {
935✔
3252
                        $protectIdentifiers = false;
898✔
3253
                        if (str_contains($matches[4], '.')) {
898✔
3254
                            $protectIdentifiers = true;
84✔
3255
                        }
3256

3257
                        if (! str_contains($matches[4], ':')) {
898✔
3258
                            $matches[4] = $this->db->protectIdentifiers(trim($matches[4]), false, $protectIdentifiers);
14✔
3259
                        }
3260

3261
                        $matches[4] = ' ' . $matches[4];
898✔
3262
                    }
3263

3264
                    $condition = $matches[1] . $this->db->protectIdentifiers(trim($matches[2]))
935✔
3265
                        . ' ' . trim($matches[3]) . $matches[4] . $matches[5];
935✔
3266
                }
3267

3268
                $qbkey = implode('', $conditions);
935✔
3269
            }
3270

3271
            return ($qbKey === 'QBHaving' ? "\nHAVING " : "\nWHERE ")
960✔
3272
                . implode("\n", $this->{$qbKey});
960✔
3273
        }
3274

3275
        return '';
1,063✔
3276
    }
3277

3278
    /**
3279
     * @used-by compileWhereHaving()
3280
     *
3281
     * @param array{columnComparison: true, condition: string, escape: bool, first: string, operator: string, second: string} $condition
3282
     */
3283
    private function compileColumnComparison(array $condition): string
3284
    {
3285
        if ($condition['escape']) {
15✔
3286
            $condition['first']  = $this->db->protectIdentifiers($condition['first'], false, true);
13✔
3287
            $condition['second'] = $this->db->protectIdentifiers($condition['second'], false, true);
13✔
3288
        }
3289

3290
        return $condition['condition'] . $condition['first'] . ' ' . $condition['operator'] . ' ' . $condition['second'];
15✔
3291
    }
3292

3293
    /**
3294
     * Escapes identifiers in GROUP BY statements at execution time.
3295
     *
3296
     * Required so that aliases are tracked properly, regardless of whether
3297
     * groupBy() is called prior to from(), join() and prefixTable is added
3298
     * only if needed.
3299
     */
3300
    protected function compileGroupBy(): string
3301
    {
3302
        if (! empty($this->QBGroupBy)) {
1,064✔
3303
            foreach ($this->QBGroupBy as &$groupBy) {
56✔
3304
                // Is it already compiled?
3305
                if (is_string($groupBy)) {
56✔
3306
                    continue;
2✔
3307
                }
3308

3309
                $groupBy = ($groupBy['escape'] === false || $this->isLiteral($groupBy['field']))
56✔
UNCOV
3310
                    ? $groupBy['field']
×
3311
                    : $this->db->protectIdentifiers($groupBy['field']);
56✔
3312
            }
3313

3314
            return "\nGROUP BY " . implode(', ', $this->QBGroupBy);
56✔
3315
        }
3316

3317
        return '';
1,038✔
3318
    }
3319

3320
    /**
3321
     * Escapes identifiers in ORDER BY statements at execution time.
3322
     *
3323
     * Required so that aliases are tracked properly, regardless of whether
3324
     * orderBy() is called prior to from(), join() and prefixTable is added
3325
     * only if needed.
3326
     */
3327
    protected function compileOrderBy(): string
3328
    {
3329
        if (is_array($this->QBOrderBy) && $this->QBOrderBy !== []) {
1,076✔
3330
            foreach ($this->QBOrderBy as &$orderBy) {
824✔
3331
                if (is_string($orderBy)) {
824✔
3332
                    continue;
1✔
3333
                }
3334
                if ($orderBy['escape'] !== false && ! $this->isLiteral($orderBy['field'])) {
824✔
3335
                    $orderBy['field'] = $this->db->protectIdentifiers($orderBy['field']);
822✔
3336
                }
3337

3338
                $orderBy = $orderBy['field'] . $orderBy['direction'];
824✔
3339
            }
3340

3341
            return "\nORDER BY " . implode(', ', $this->QBOrderBy);
824✔
3342
        }
3343

3344
        return '';
1,049✔
3345
    }
3346

3347
    protected function unionInjection(string $sql): string
3348
    {
3349
        if ($this->QBUnion === []) {
1,064✔
3350
            return $sql;
1,064✔
3351
        }
3352

3353
        return 'SELECT * FROM (' . $sql . ') '
6✔
3354
            . ($this->db->protectIdentifiers ? $this->db->escapeIdentifiers('uwrp0') : 'uwrp0')
6✔
3355
            . implode("\n", $this->QBUnion);
6✔
3356
    }
3357

3358
    /**
3359
     * Takes an object as input and converts the class variables to array key/vals
3360
     *
3361
     * @param array|object $object
3362
     *
3363
     * @return array
3364
     */
3365
    protected function objectToArray($object)
3366
    {
3367
        if (! is_object($object)) {
847✔
3368
            return $object;
844✔
3369
        }
3370

3371
        if ($object instanceof RawSql) {
8✔
3372
            throw new InvalidArgumentException('RawSql "' . $object . '" cannot be used here.');
1✔
3373
        }
3374

3375
        $array = [];
7✔
3376

3377
        foreach (get_object_vars($object) as $key => $val) {
7✔
3378
            if ((! is_object($val) || $val instanceof RawSql) && ! is_array($val)) {
7✔
3379
                $array[$key] = $val;
7✔
3380
            }
3381
        }
3382

3383
        return $array;
7✔
3384
    }
3385

3386
    /**
3387
     * Takes an object as input and converts the class variables to array key/vals
3388
     *
3389
     * @param array|object $object
3390
     *
3391
     * @return array
3392
     */
3393
    protected function batchObjectToArray($object)
3394
    {
3395
        if (! is_object($object)) {
68✔
3396
            return $object;
68✔
3397
        }
3398

UNCOV
3399
        $array  = [];
×
UNCOV
3400
        $out    = get_object_vars($object);
×
UNCOV
3401
        $fields = array_keys($out);
×
3402

UNCOV
3403
        foreach ($fields as $val) {
×
UNCOV
3404
            $i = 0;
×
3405

UNCOV
3406
            foreach ($out[$val] as $data) {
×
UNCOV
3407
                $array[$i++][$val] = $data;
×
3408
            }
3409
        }
3410

UNCOV
3411
        return $array;
×
3412
    }
3413

3414
    /**
3415
     * Determines if a string represents a literal value or a field name
3416
     */
3417
    protected function isLiteral(string $str): bool
3418
    {
3419
        $str = trim($str);
852✔
3420

3421
        if ($str === ''
852✔
3422
            || ctype_digit($str)
852✔
3423
            || (string) (float) $str === $str
852✔
3424
            || in_array(strtoupper($str), ['TRUE', 'FALSE'], true)
852✔
3425
        ) {
UNCOV
3426
            return true;
×
3427
        }
3428

3429
        if ($this->isLiteralStr === []) {
852✔
3430
            $this->isLiteralStr = $this->db->escapeChar !== '"' ? ['"', "'"] : ["'"];
852✔
3431
        }
3432

3433
        return in_array($str[0], $this->isLiteralStr, true);
852✔
3434
    }
3435

3436
    /**
3437
     * Publicly-visible method to reset the QB values.
3438
     *
3439
     * @return $this
3440
     */
3441
    public function resetQuery()
3442
    {
3443
        $this->resetSelect();
1✔
3444
        $this->resetWrite();
1✔
3445

3446
        return $this;
1✔
3447
    }
3448

3449
    /**
3450
     * Resets the query builder values.  Called by the get() function
3451
     *
3452
     * @param array $qbResetItems An array of fields to reset
3453
     *
3454
     * @return void
3455
     */
3456
    protected function resetRun(array $qbResetItems)
3457
    {
3458
        foreach ($qbResetItems as $item => $defaultValue) {
1,086✔
3459
            $this->{$item} = $defaultValue;
1,086✔
3460
        }
3461
    }
3462

3463
    /**
3464
     * Resets the query builder values.  Called by the get() function
3465
     *
3466
     * @return void
3467
     */
3468
    protected function resetSelect()
3469
    {
3470
        $this->resetRun([
1,059✔
3471
            'QBSelect'   => [],
1,059✔
3472
            'QBJoin'     => [],
1,059✔
3473
            'QBWhere'    => [],
1,059✔
3474
            'QBGroupBy'  => [],
1,059✔
3475
            'QBHaving'   => [],
1,059✔
3476
            'QBOrderBy'  => [],
1,059✔
3477
            'QBNoEscape' => [],
1,059✔
3478
            'QBDistinct' => false,
1,059✔
3479
            'QBLimit'    => false,
1,059✔
3480
            'QBOffset'   => false,
1,059✔
3481
            'QBUnion'    => [],
1,059✔
3482
        ]);
1,059✔
3483

3484
        if ($this->db instanceof BaseConnection) {
1,059✔
3485
            $this->db->setAliasedTables([]);
1,059✔
3486
        }
3487

3488
        // Reset QBFrom part
3489
        if (! empty($this->QBFrom)) {
1,059✔
3490
            $this->from(array_shift($this->QBFrom), true);
1,059✔
3491
        }
3492
    }
3493

3494
    /**
3495
     * Resets the query builder "write" values.
3496
     *
3497
     * Called by the insert() update() insertBatch() updateBatch() and delete() functions
3498
     *
3499
     * @return void
3500
     */
3501
    protected function resetWrite()
3502
    {
3503
        $this->resetRun([
854✔
3504
            'QBSet'     => [],
854✔
3505
            'QBJoin'    => [],
854✔
3506
            'QBWhere'   => [],
854✔
3507
            'QBOrderBy' => [],
854✔
3508
            'QBKeys'    => [],
854✔
3509
            'QBLimit'   => false,
854✔
3510
            'QBIgnore'  => false,
854✔
3511
            'QBOptions' => [],
854✔
3512
        ]);
854✔
3513
    }
3514

3515
    /**
3516
     * Tests whether the string has an SQL operator
3517
     */
3518
    protected function hasOperator(string $str): bool
3519
    {
3520
        return preg_match(
157✔
3521
            '/(<|>|!|=|\sIS NULL|\sIS NOT NULL|\sEXISTS|\sBETWEEN|\sLIKE|\sIN\s*\(|\s)/i',
157✔
3522
            trim($str),
157✔
3523
        ) === 1;
157✔
3524
    }
3525

3526
    /**
3527
     * Returns the SQL string operator
3528
     *
3529
     * @return array|false|string
3530
     */
3531
    protected function getOperator(string $str, bool $list = false)
3532
    {
3533
        if ($this->pregOperators === []) {
944✔
3534
            $_les = $this->db->likeEscapeStr !== ''
944✔
3535
                ? '\s+' . preg_quote(trim(sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar)), '/')
944✔
UNCOV
3536
                : '';
×
3537
            $this->pregOperators = [
944✔
3538
                '\s*(?:<|>|!)?=\s*', // =, <=, >=, !=
944✔
3539
                '\s*<>?\s*',         // <, <>
944✔
3540
                '\s*>\s*',           // >
944✔
3541
                '\s+IS NULL',             // IS NULL
944✔
3542
                '\s+IS NOT NULL',         // IS NOT NULL
944✔
3543
                '\s+EXISTS\s*\(.*\)',     // EXISTS (sql)
944✔
3544
                '\s+NOT EXISTS\s*\(.*\)', // NOT EXISTS(sql)
944✔
3545
                '\s+BETWEEN\s+',          // BETWEEN value AND value
944✔
3546
                '\s+IN\s*\(.*\)',         // IN (list)
944✔
3547
                '\s+NOT IN\s*\(.*\)',     // NOT IN (list)
944✔
3548
                '\s+LIKE\s+\S.*(' . $_les . ')?',     // LIKE 'expr'[ ESCAPE '%s']
944✔
3549
                '\s+NOT LIKE\s+\S.*(' . $_les . ')?', // NOT LIKE 'expr'[ ESCAPE '%s']
944✔
3550
            ];
944✔
3551
        }
3552

3553
        return preg_match_all(
944✔
3554
            '/' . implode('|', $this->pregOperators) . '/i',
944✔
3555
            $str,
944✔
3556
            $match,
944✔
3557
        ) >= 1 ? ($list ? $match[0] : $match[0][0]) : false;
944✔
3558
    }
3559

3560
    /**
3561
     * Returns the SQL string operator from where key
3562
     *
3563
     * @return false|list<string>
3564
     */
3565
    private function getOperatorFromWhereKey(string $whereKey)
3566
    {
3567
        $whereKey = trim($whereKey);
897✔
3568

3569
        $pregOperators = [
897✔
3570
            '\s*(?:<|>|!)?=',         // =, <=, >=, !=
897✔
3571
            '\s*<>?',                 // <, <>
897✔
3572
            '\s*>',                   // >
897✔
3573
            '\s+IS NULL',             // IS NULL
897✔
3574
            '\s+IS NOT NULL',         // IS NOT NULL
897✔
3575
            '\s+EXISTS\s*\(.*\)',     // EXISTS (sql)
897✔
3576
            '\s+NOT EXISTS\s*\(.*\)', // NOT EXISTS (sql)
897✔
3577
            '\s+BETWEEN\s+',          // BETWEEN value AND value
897✔
3578
            '\s+IN\s*\(.*\)',         // IN (list)
897✔
3579
            '\s+NOT IN\s*\(.*\)',     // NOT IN (list)
897✔
3580
            '\s+LIKE',                // LIKE
897✔
3581
            '\s+NOT LIKE',            // NOT LIKE
897✔
3582
        ];
897✔
3583

3584
        return preg_match_all(
897✔
3585
            '/' . implode('|', $pregOperators) . '/i',
897✔
3586
            $whereKey,
897✔
3587
            $match,
897✔
3588
        ) >= 1 ? $match[0] : false;
897✔
3589
    }
3590

3591
    /**
3592
     * Stores a bind value after ensuring that it's unique.
3593
     * While it might be nicer to have named keys for our binds array
3594
     * with PHP 7+ we get a huge memory/performance gain with indexed
3595
     * arrays instead, so lets take advantage of that here.
3596
     *
3597
     * @param mixed $value
3598
     */
3599
    protected function setBind(string $key, $value = null, bool $escape = true): string
3600
    {
3601
        if (! array_key_exists($key, $this->binds)) {
944✔
3602
            $this->binds[$key] = [
944✔
3603
                $value,
944✔
3604
                $escape,
944✔
3605
            ];
944✔
3606

3607
            return $key;
944✔
3608
        }
3609

3610
        if (! array_key_exists($key, $this->bindsKeyCount)) {
43✔
3611
            $this->bindsKeyCount[$key] = 1;
43✔
3612
        }
3613

3614
        $count = $this->bindsKeyCount[$key]++;
43✔
3615

3616
        $this->binds[$key . '.' . $count] = [
43✔
3617
            $value,
43✔
3618
            $escape,
43✔
3619
        ];
43✔
3620

3621
        return $key . '.' . $count;
43✔
3622
    }
3623

3624
    /**
3625
     * @param mixed $value
3626
     */
3627
    protected function isSubquery($value): bool
3628
    {
3629
        return $value instanceof BaseBuilder || $value instanceof Closure;
906✔
3630
    }
3631

3632
    /**
3633
     * @param BaseBuilder|Closure(BaseBuilder): BaseBuilder $builder
3634
     * @param bool                                          $wrapped Wrap the subquery in brackets
3635
     * @param string                                        $alias   Subquery alias
3636
     */
3637
    protected function buildSubquery($builder, bool $wrapped = false, string $alias = ''): string
3638
    {
3639
        if ($builder instanceof Closure) {
21✔
3640
            $builder($builder = $this->db->newQuery());
11✔
3641
        }
3642

3643
        if ($builder === $this) {
21✔
3644
            throw new DatabaseException('The subquery cannot be the same object as the main query object.');
1✔
3645
        }
3646

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

3649
        if ($wrapped) {
20✔
3650
            $subquery = '(' . $subquery . ')';
20✔
3651
            $alias    = trim($alias);
20✔
3652

3653
            if ($alias !== '') {
20✔
3654
                $subquery .= ' ' . ($this->db->protectIdentifiers ? $this->db->escapeIdentifiers($alias) : $alias);
11✔
3655
            }
3656
        }
3657

3658
        return $subquery;
20✔
3659
    }
3660
}
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