• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In
No new info detected.

codeigniter4 / CodeIgniter4 / 25200881735

01 May 2026 03:29AM UTC coverage: 88.259% (+0.01%) from 88.248%
25200881735

Pull #10150

github

web-flow
Merge 00f5e8a20 into 2aa849140
Pull Request #10150: feat: add Query Builder whereColumn methods

32 of 33 new or added lines in 1 file covered. (96.97%)

50 existing lines in 1 file now uncovered.

23468 of 26590 relevant lines covered (88.26%)

217.13 hits per line

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

94.18
/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,108✔
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,108✔
315

316
        if ($tableName instanceof TableName) {
1,108✔
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,104✔
323
            $this->tableName = $tableName;  // @TODO remove alias if exists
1,102✔
324
            $this->from($tableName);
1,102✔
325
        } else {
326
            $this->tableName = '';
16✔
327
            $this->from($tableName);
16✔
328
        }
329

330
        if ($options !== null && $options !== []) {
1,108✔
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;
50✔
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)) {
882✔
405
            $escape = $this->db->protectIdentifiers;
879✔
406
        }
407

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

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

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

421
                continue;
5✔
422
            }
423

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

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

439
                    continue;
2✔
440
                }
441

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

446
        return $this;
882✔
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);
786✔
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 === '') {
796✔
525
            throw DataException::forEmptyInputGiven('Select');
1✔
526
        }
527

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

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

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

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

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

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

547
        return $this;
794✔
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, '.')) {
789✔
556
            $item = explode('.', $item);
1✔
557

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

561
        return $item;
788✔
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;
792✔
572

573
        return $this;
792✔
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,108✔
586
            $this->QBFrom = [];
1,039✔
587
            $this->db->setAliasedTables([]);
1,039✔
588
        }
589

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

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

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

605
        return $this;
1,108✔
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);
900✔
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
743
     * @param non-empty-string|null $operator Comparison operator, or second column name when $second is null
744
     * @param non-empty-string|null $second   Second column name
745
     * @param bool|null             $escape   Whether to protect identifiers
746
     *
747
     * @return $this
748
     *
749
     * @throws InvalidArgumentException
750
     */
751
    public function whereColumn(string $first, ?string $operator = null, ?string $second = null, ?bool $escape = null)
752
    {
753
        return $this->whereColumnHaving('QBWhere', $first, $operator, $second, 'AND ', $escape);
11✔
754
    }
755

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

773
    /**
774
     * @used-by whereColumn()
775
     * @used-by orWhereColumn()
776
     *
777
     * @param 'QBHaving'|'QBWhere'  $qbKey
778
     * @param non-empty-string      $first    First column name
779
     * @param non-empty-string|null $operator Comparison operator, or second column name when $second is null
780
     * @param non-empty-string|null $second   Second column name
781
     * @param non-empty-string      $type
782
     * @param bool|null             $escape   Whether to protect identifiers
783
     *
784
     * @return $this
785
     *
786
     * @throws InvalidArgumentException
787
     */
788
    protected function whereColumnHaving(string $qbKey, string $first, ?string $operator = null, ?string $second = null, string $type = 'AND ', ?bool $escape = null)
789
    {
790
        if ($second === null) {
12✔
791
            $second   = $operator;
5✔
792
            $operator = '=';
5✔
793
        } elseif ($operator === null) {
8✔
NEW
794
            $operator = '=';
×
795
        }
796

797
        $first    = trim($first);
12✔
798
        $operator = trim($operator);
12✔
799
        $second   = trim((string) $second);
12✔
800

801
        if ($first === '' || $second === '') {
12✔
802
            throw new InvalidArgumentException(sprintf('%s() expects $first and $second to be non-empty strings', debug_backtrace(0, 2)[1]['function']));
4✔
803
        }
804

805
        if (! in_array($operator, ['=', '!=', '<>', '<', '>', '<=', '>='], true)) {
8✔
806
            throw new InvalidArgumentException(sprintf('%s() expects $operator to be one of: =, !=, <>, <, >, <=, >=', debug_backtrace(0, 2)[1]['function']));
1✔
807
        }
808

809
        if (! is_bool($escape)) {
7✔
810
            $escape = $this->db->protectIdentifiers;
6✔
811
        }
812

813
        $prefix = $this->{$qbKey} === [] ? $this->groupGetType('') : $this->groupGetType($type);
7✔
814

815
        $this->{$qbKey}[] = [
7✔
816
            'columnComparison' => true,
7✔
817
            'condition'        => $prefix,
7✔
818
            'escape'           => $escape,
7✔
819
            'first'            => $first,
7✔
820
            'operator'         => $operator,
7✔
821
            'second'           => $second,
7✔
822
        ];
7✔
823

824
        return $this;
7✔
825
    }
826

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

842
        if ($key instanceof RawSql) {
906✔
843
            if ($value === null) {
4✔
844
                $keyValue   = [(string) $key => $key];
1✔
845
                $rawSqlOnly = true;
1✔
846
            } else {
847
                $keyValue = [(string) $key => $value];
3✔
848
            }
849
        } elseif (! is_array($key)) {
902✔
850
            $keyValue = [$key => $value];
892✔
851
        } else {
852
            $keyValue = $key;
216✔
853
        }
854

855
        // If the escape value was not set will base it on the global setting
856
        if (! is_bool($escape)) {
906✔
857
            $escape = $this->db->protectIdentifiers;
902✔
858
        }
859

860
        foreach ($keyValue as $k => $v) {
906✔
861
            $prefix = empty($this->{$qbKey}) ? $this->groupGetType('') : $this->groupGetType($type);
906✔
862

863
            if ($rawSqlOnly) {
906✔
864
                $k  = '';
1✔
865
                $op = '';
1✔
866
            } elseif ($v !== null) {
905✔
867
                $op = $this->getOperatorFromWhereKey($k);
885✔
868

869
                if (! empty($op)) {
885✔
870
                    $k = trim($k);
49✔
871

872
                    end($op);
49✔
873
                    $op = trim(current($op));
49✔
874

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

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

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

923
        return $this;
906✔
924
    }
925

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

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

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

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

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

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

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

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

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

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

1053
        if (! is_bool($escape)) {
81✔
1054
            $escape = $this->db->protectIdentifiers;
81✔
1055
        }
1056

1057
        $ok = $key;
81✔
1058

1059
        if ($escape === true) {
81✔
1060
            $key = $this->db->protectIdentifiers($key);
81✔
1061
        }
1062

1063
        $not = ($not) ? ' NOT' : '';
81✔
1064

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

1072
        $ok = $this->setBind($ok, $whereIn, $escape);
81✔
1073

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

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

1081
        $this->{$clause}[] = $whereIn;
81✔
1082

1083
        return $this;
81✔
1084
    }
1085

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

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

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

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

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

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

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

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

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

1209
        if ($field instanceof RawSql) {
47✔
1210
            $k                 = (string) $field;
3✔
1211
            $v                 = $match;
3✔
1212
            $insensitiveSearch = false;
3✔
1213

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

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

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

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

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

1238
            return $this;
3✔
1239
        }
1240

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

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

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

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

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

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

1267
            $this->{$clause}[] = [
44✔
1268
                'condition' => $likeStatement,
44✔
1269
                'escape'    => $escape,
44✔
1270
            ];
44✔
1271
        }
1272

1273
        return $this;
44✔
1274
    }
1275

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

1285
        return "{$prefix} {$column} {$not} LIKE :{$bind}:";
40✔
1286
    }
1287

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

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

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

1327
        return $this;
6✔
1328
    }
1329

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

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

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

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

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

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

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

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

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

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

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

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

1446
        $this->{$clause}[] = $where;
17✔
1447

1448
        return $this;
17✔
1449
    }
1450

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

1464
        $this->{$clause}[] = $where;
17✔
1465

1466
        return $this;
17✔
1467
    }
1468

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

1484
        return $type;
940✔
1485
    }
1486

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

1498
        if (is_string($by)) {
56✔
1499
            $by = ($escape === true) ? explode(',', $by) : [$by];
56✔
1500
        }
1501

1502
        foreach ($by as $val) {
56✔
1503
            $val = trim($val);
56✔
1504

1505
            if ($val !== '') {
56✔
1506
                $val = [
56✔
1507
                    'field'  => $val,
56✔
1508
                    'escape' => $escape,
56✔
1509
                ];
56✔
1510

1511
                $this->QBGroupBy[] = $val;
56✔
1512
            }
1513
        }
1514

1515
        return $this;
56✔
1516
    }
1517

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

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

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

1555
        $qbOrderBy = [];
812✔
1556

1557
        $direction = strtoupper(trim($direction));
812✔
1558

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

1567
        if ($escape === null) {
812✔
1568
            $escape = $this->db->protectIdentifiers;
810✔
1569
        }
1570

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

1593
        $this->QBOrderBy = array_merge($this->QBOrderBy, $qbOrderBy);
812✔
1594

1595
        return $this;
812✔
1596
    }
1597

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

1608
        if ($value !== null) {
123✔
1609
            $this->QBLimit = $value;
111✔
1610
        }
1611

1612
        if ($offset !== null && $offset !== 0) {
123✔
1613
            $this->QBOffset = $offset;
10✔
1614
        }
1615

1616
        return $this;
123✔
1617
    }
1618

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

1630
        return $this;
1✔
1631
    }
1632

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

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

1654
        if (! is_array($key)) {
828✔
1655
            $key = [$key => $value];
122✔
1656
        }
1657

1658
        $escape = is_bool($escape) ? $escape : $this->db->protectIdentifiers;
828✔
1659

1660
        foreach ($key as $k => $v) {
828✔
1661
            if ($escape) {
828✔
1662
                $bind = $this->setBind($k, $v, $escape);
827✔
1663

1664
                $this->QBSet[$this->db->protectIdentifiers($k, false)] = ":{$bind}:";
827✔
1665
            } else {
1666
                $this->QBSet[$this->db->protectIdentifiers($k, false)] = $v;
10✔
1667
            }
1668
        }
1669

1670
        return $this;
828✔
1671
    }
1672

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

UNCOV
1680
        if ($clean) {
×
UNCOV
1681
            $this->QBSet = [];
×
1682
        }
1683

1684
        return $data;
×
1685
    }
1686

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

1694
        if ($reset) {
210✔
1695
            $this->resetSelect();
208✔
1696
        }
1697

1698
        return $this->compileFinalQuery($select);
210✔
1699
    }
1700

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

1710
        if (! empty($this->db->swapPre) && ! empty($this->db->DBPrefix)) {
235✔
UNCOV
1711
            $query->swapPrefix($this->db->DBPrefix, $this->db->swapPre);
×
1712
        }
1713

1714
        return $query->getQuery();
235✔
1715
    }
1716

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

1730
        if ($limit !== null) {
842✔
1731
            $this->limit($limit, $offset);
8✔
1732
        }
1733

1734
        $result = $this->testMode
842✔
1735
            ? $this->getCompiledSelect($reset)
2✔
1736
            : $this->db->query($this->compileSelect(), $this->binds, false);
840✔
1737

1738
        if ($reset) {
842✔
1739
            $this->resetSelect();
842✔
1740

1741
            // Clear our binds so we don't eat up memory
1742
            $this->binds = [];
842✔
1743
        }
1744

1745
        return $result;
842✔
1746
    }
1747

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

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

1761
        if ($this->testMode) {
6✔
1762
            return $sql;
1✔
1763
        }
1764

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

1767
        if (empty($query->getResult())) {
5✔
UNCOV
1768
            return 0;
×
1769
        }
1770

1771
        $query = $query->getRow();
5✔
1772

1773
        if ($reset) {
5✔
1774
            $this->resetSelect();
5✔
1775
        }
1776

1777
        return (int) $query->numrows;
5✔
1778
    }
1779

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

1793
        if (! empty($this->QBOrderBy)) {
212✔
UNCOV
1794
            $orderBy = $this->QBOrderBy;
×
1795

UNCOV
1796
            $this->QBOrderBy = null;
×
1797
        }
1798

1799
        // We cannot use a LIMIT when getting the single row COUNT(*) result
1800
        $limit = $this->QBLimit;
212✔
1801

1802
        $this->QBLimit = false;
212✔
1803

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

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

1816
        if ($this->testMode) {
212✔
1817
            return $sql;
9✔
1818
        }
1819

1820
        $result = $this->db->query($sql, $this->binds, false);
207✔
1821

1822
        if ($reset) {
207✔
1823
            $this->resetSelect();
191✔
1824
        } elseif (! isset($this->QBOrderBy)) {
22✔
UNCOV
1825
            $this->QBOrderBy = $orderBy;
×
1826
        }
1827

1828
        // Restore the LIMIT setting
1829
        $this->QBLimit = $limit;
207✔
1830

1831
        $row = $result instanceof ResultInterface ? $result->getRow() : null;
207✔
1832

1833
        if (empty($row)) {
207✔
UNCOV
1834
            return 0;
×
1835
        }
1836

1837
        return (int) $row->numrows;
207✔
1838
    }
1839

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

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

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

1868
        if ($limit !== null) {
17✔
1869
            $this->limit($limit, $offset);
3✔
1870
        }
1871

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

1876
        if ($reset) {
17✔
1877
            $this->resetSelect();
17✔
1878

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

1883
        return $result;
17✔
1884
    }
1885

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

1902
            return false; // @codeCoverageIgnore
1903
        }
1904

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

1907
        $affectedRows = 0;
68✔
1908
        $savedSQL     = [];
68✔
1909
        $cnt          = count($this->QBSet);
68✔
1910

1911
        // batch size 0 for unlimited
1912
        if ($batchSize === 0) {
68✔
UNCOV
1913
            $batchSize = $cnt;
×
1914
        }
1915

1916
        for ($i = 0, $total = $cnt; $i < $total; $i += $batchSize) {
68✔
1917
            $QBSet = array_slice($this->QBSet, $i, $batchSize);
68✔
1918

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

1921
            if ($sql === '') {
65✔
1922
                return false; // @codeCoverageIgnore
1923
            }
1924

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

1933
        if (! $this->testMode) {
63✔
1934
            $this->resetWrite();
60✔
1935
        }
1936

1937
        return $this->testMode ? $savedSQL : $affectedRows;
63✔
1938
    }
1939

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

1955
            return null; // @codeCoverageIgnore
1956
        }
1957

1958
        $this->setAlias($alias);
68✔
1959

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

1965
        $set = $this->batchObjectToArray($set);
68✔
1966

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

1969
        $keys = array_keys($this->objectToArray(current($set)));
68✔
1970
        sort($keys);
68✔
1971

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

UNCOV
1978
                return null;
×
1979
            }
1980

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

1983
            $clean = [];
68✔
1984

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

1989
            $row = $clean;
68✔
1990

1991
            $this->QBSet[] = $row;
68✔
1992
        }
1993

1994
        foreach ($keys as $k) {
68✔
1995
            $k = $this->db->protectIdentifiers($k, false);
68✔
1996

1997
            if (! in_array($k, $this->QBKeys, true)) {
68✔
1998
                $this->QBKeys[] = $k;
68✔
1999
            }
2000
        }
2001

2002
        return $this;
68✔
2003
    }
2004

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

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

2018
        $this->testMode = $currentTestMode;
3✔
2019

2020
        return $this->compileFinalQuery($sql);
3✔
2021
    }
2022

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

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

2044
            $this->binds = [];
2✔
2045

2046
            $this->resetRun([
2✔
2047
                'QBSet'  => [],
2✔
2048
                'QBKeys' => [],
2✔
2049
            ]);
2✔
2050

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

2056
        return $this->batchExecute('_upsertBatch');
10✔
2057
    }
2058

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

2073
            if ($sql === '') {
1✔
2074
                return false; // @codeCoverageIgnore
2075
            }
2076

2077
            if ($this->testMode === false) {
1✔
2078
                $this->db->query($sql, null, false);
1✔
2079
            }
2080

2081
            $this->resetWrite();
1✔
2082

2083
            return $this->testMode ? $sql : $this->db->affectedRows();
1✔
2084
        }
2085

2086
        if ($set !== null) {
11✔
2087
            $this->setData($set, $escape);
9✔
2088
        }
2089

2090
        return $this->batchExecute('_upsertBatch', $batchSize);
11✔
2091
    }
2092

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

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

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

2121
            $this->QBOptions['sql'] = $sql;
19✔
2122
        }
2123

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

2130
        return str_replace('{:_table_:}', $data, $sql);
19✔
2131
    }
2132

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

2143
        return $this;
68✔
2144
    }
2145

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

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

2167
                if (is_numeric($key)) {
38✔
2168
                    $key = $value;
38✔
2169
                }
2170

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

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

2183
                unset($this->QBOptions['updateFieldsAdditional']);
3✔
2184
            }
2185
        }
2186

2187
        return $this;
38✔
2188
    }
2189

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

2203
                $set = array_map(trim(...), $set);
27✔
2204
            }
2205

2206
            if ($set instanceof RawSql) {
44✔
2207
                $set = [$set];
2✔
2208
            }
2209

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

2215
                if (is_string($key)) {
44✔
2216
                    $key = $this->db->protectIdentifiers($key);
3✔
2217
                }
2218

2219
                $this->QBOptions['constraints'][$key] = $value;
44✔
2220
            }
2221
        }
2222

2223
        return $this;
47✔
2224
    }
2225

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

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

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

2250
            $columns = (array) $columns;
5✔
2251

2252
            if ($columns === []) {
5✔
2253
                $columns = $this->fieldsFromQuery($query);
4✔
2254
            }
2255

2256
            if ($alias !== null) {
5✔
2257
                $this->setAlias($alias);
1✔
2258
            }
2259

2260
            foreach ($columns as $key => $value) {
5✔
2261
                $columns[$key] = $this->db->escapeChar . $value . $this->db->escapeChar;
5✔
2262
            }
2263

2264
            $this->QBOptions['setQueryAsData'] = $query;
5✔
2265
            $this->QBKeys                      = $columns;
5✔
2266
            $this->QBSet                       = [];
5✔
2267
        }
2268

2269
        return $this;
5✔
2270
    }
2271

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

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

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

2300
            if ($sql === '') {
2✔
2301
                return false; // @codeCoverageIgnore
2302
            }
2303

2304
            if ($this->testMode === false) {
2✔
2305
                $this->db->query($sql, null, false);
2✔
2306
            }
2307

2308
            $this->resetWrite();
2✔
2309

2310
            return $this->testMode ? $sql : $this->db->affectedRows();
2✔
2311
        }
2312

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

2317
        return $this->batchExecute('_insertBatch', $batchSize);
29✔
2318
    }
2319

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

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

2338
            $this->QBOptions['sql'] = $sql;
27✔
2339
        }
2340

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

2347
        return str_replace('{:_table_:}', $data, $sql);
27✔
2348
    }
2349

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

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

2374
        if ($reset) {
6✔
2375
            $this->resetWrite();
6✔
2376
        }
2377

2378
        return $this->compileFinalQuery($sql);
6✔
2379
    }
2380

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

2396
        if ($this->validateInsert() === false) {
815✔
UNCOV
2397
            return false;
×
2398
        }
2399

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

2411
        if (! $this->testMode) {
814✔
2412
            $this->resetWrite();
810✔
2413

2414
            $result = $this->db->query($sql, $this->binds, false);
810✔
2415

2416
            // Clear our binds so we don't eat up memory
2417
            $this->binds = [];
810✔
2418

2419
            return $result;
810✔
2420
        }
2421

2422
        return false;
5✔
2423
    }
2424

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

2438
            $parts = explode(' ', $from);
2✔
2439
            $from  = $parts[0];
2✔
2440
        }
2441

2442
        return $from;
819✔
2443
    }
2444

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

2459
            return false; // @codeCoverageIgnore
2460
        }
2461

2462
        return true;
814✔
2463
    }
2464

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

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

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

2495
            return false; // @codeCoverageIgnore
2496
        }
2497

2498
        $table = $this->QBFrom[0];
7✔
2499

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

2502
        $this->resetWrite();
7✔
2503

2504
        return $this->testMode ? $sql : $this->db->query($sql, $this->binds, false);
7✔
2505
    }
2506

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

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

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

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

2543
        if ($reset) {
13✔
2544
            $this->resetWrite();
13✔
2545
        }
2546

2547
        return $this->compileFinalQuery($sql);
13✔
2548
    }
2549

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

2564
        if ($this->validateUpdate() === false) {
104✔
UNCOV
2565
            return false;
×
2566
        }
2567

2568
        if ($where !== null) {
103✔
2569
            $this->where($where);
7✔
2570
        }
2571

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

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

2582
            $this->limit($limit);
3✔
2583
        }
2584

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

2587
        if (! $this->testMode) {
103✔
2588
            $this->resetWrite();
90✔
2589

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

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

2596
                return true;
87✔
2597
            }
2598

2599
            return false;
3✔
2600
        }
2601

2602
        return true;
13✔
2603
    }
2604

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

2615
        foreach ($values as $key => $val) {
110✔
2616
            $valStr[] = $key . ' = ' . $val;
110✔
2617
        }
2618

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

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

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

2647
            return false; // @codeCoverageIgnore
2648
        }
2649

2650
        return true;
104✔
2651
    }
2652

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

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

2668
            if ($sql === '') {
1✔
2669
                return false; // @codeCoverageIgnore
2670
            }
2671

2672
            if ($this->testMode === false) {
1✔
2673
                $this->db->query($sql, null, false);
1✔
2674
            }
2675

2676
            $this->resetWrite();
1✔
2677

2678
            return $this->testMode ? $sql : $this->db->affectedRows();
1✔
2679
        }
2680

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

2685
        return $this->batchExecute('_updateBatch', $batchSize);
22✔
2686
    }
2687

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

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

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

2710
                return ''; // @codeCoverageIgnore
2711
            }
2712

2713
            $updateFields = $this->QBOptions['updateFields'] ??
17✔
2714
                $this->updateFields($keys, false, $constraints)->QBOptions['updateFields'] ??
17✔
2715
                [];
14✔
2716

2717
            $alias = $this->QBOptions['alias'] ?? '_u';
17✔
2718

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

2721
            $sql .= "SET\n";
17✔
2722

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

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

2736
            $sql .= ') ' . $alias . "\n";
17✔
2737

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

2759
            $this->QBOptions['sql'] = $sql;
17✔
2760
        }
2761

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

2778
        return str_replace('{:_table_:}', $data, $sql);
17✔
2779
    }
2780

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

2790
        $sql = $this->_delete($table);
4✔
2791

2792
        if ($this->testMode) {
4✔
2793
            return $sql;
1✔
2794
        }
2795

2796
        $this->resetWrite();
3✔
2797

2798
        return $this->db->query($sql, null, false);
3✔
2799
    }
2800

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

2812
        $sql = $this->_truncate($table);
733✔
2813

2814
        if ($this->testMode) {
733✔
2815
            return $sql;
2✔
2816
        }
2817

2818
        $this->resetWrite();
732✔
2819

2820
        return $this->db->query($sql, null, false);
732✔
2821
    }
2822

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

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

2844
        return $this->compileFinalQuery($sql);
3✔
2845
    }
2846

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

2860
        if ($where !== '') {
774✔
2861
            $this->where($where);
4✔
2862
        }
2863

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

2869
            return false; // @codeCoverageIgnore
2870
        }
2871

2872
        $sql = $this->_delete($this->removeAlias($table));
774✔
2873

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

2879
        if ($limit !== null) {
774✔
2880
            $this->QBLimit = $limit;
1✔
2881
        }
2882

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

2888
            $sql = $this->_limit($sql, true);
2✔
2889
        }
2890

2891
        if ($resetData) {
774✔
2892
            $this->resetWrite();
774✔
2893
        }
2894

2895
        return $this->testMode ? $sql : $this->db->query($sql, $this->binds, false);
774✔
2896
    }
2897

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

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

2913
            if ($sql === '') {
1✔
2914
                return false; // @codeCoverageIgnore
2915
            }
2916

2917
            if ($this->testMode === false) {
1✔
2918
                $this->db->query($sql, null, false);
1✔
2919
            }
2920

2921
            $this->resetWrite();
1✔
2922

2923
            return $this->testMode ? $sql : $this->db->affectedRows();
1✔
2924
        }
2925

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

2930
        return $this->batchExecute('_deleteBatch', $batchSize);
2✔
2931
    }
2932

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

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

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

2955
                return ''; // @codeCoverageIgnore
2956
            }
2957

2958
            $alias = $this->QBOptions['alias'] ?? '_u';
3✔
2959

2960
            $sql = 'DELETE ' . $table . ' FROM ' . $table . "\n";
3✔
2961

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

2964
            $sql .= ') ' . $alias . "\n";
3✔
2965

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

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

2990
            $sql .= ' ' . $this->compileWhereHaving('QBWhere');
3✔
2991

2992
            $this->QBOptions['sql'] = trim($sql);
3✔
2993
        }
2994

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

3011
        return str_replace('{:_table_:}', $data, $sql);
3✔
3012
    }
3013

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

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

3025
        if (! $this->testMode) {
3✔
3026
            $this->resetWrite();
3✔
3027

3028
            return $this->db->query($sql, $this->binds, false);
3✔
3029
        }
3030

UNCOV
3031
        return true;
×
3032
    }
3033

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

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

3045
        if (! $this->testMode) {
3✔
3046
            $this->resetWrite();
3✔
3047

3048
            return $this->db->query($sql, $this->binds, false);
3✔
3049
        }
3050

UNCOV
3051
        return true;
×
3052
    }
3053

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

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

3078
            return null;
×
3079
        }
3080

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

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

3092
            // Grab the alias
3093
            $alias = trim(strrchr($table, ' '));
17✔
3094

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

3099
        return null;
1,108✔
3100
    }
3101

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

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

3132
                $sql .= implode(', ', $this->QBSelect);
903✔
3133
            }
3134
        }
3135

3136
        if (! empty($this->QBFrom)) {
1,037✔
3137
            $sql .= "\nFROM " . $this->_fromTables();
1,037✔
3138
        }
3139

3140
        if (! empty($this->QBJoin)) {
1,037✔
3141
            $sql .= "\n" . implode("\n", $this->QBJoin);
15✔
3142
        }
3143

3144
        $sql .= $this->compileWhereHaving('QBWhere')
1,037✔
3145
            . $this->compileGroupBy()
1,037✔
3146
            . $this->compileWhereHaving('QBHaving')
1,037✔
3147
            . $this->compileOrderBy();
1,037✔
3148

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

3158
        return $this->unionInjection($sql);
1,037✔
3159
    }
3160

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

3173
        return '';
837✔
3174
    }
3175

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

3196
                if ($qbkey instanceof RawSql) {
940✔
3197
                    continue;
2✔
3198
                }
3199

3200
                if ($qbkey['condition'] instanceof RawSql) {
940✔
3201
                    $qbkey = $qbkey['condition'];
4✔
3202

3203
                    continue;
4✔
3204
                }
3205

3206
                if (($qbkey['columnComparison'] ?? false) === true) {
938✔
3207
                    $qbkey = $this->compileColumnComparison($qbkey);
7✔
3208

3209
                    continue;
7✔
3210
                }
3211

3212
                if ($qbkey['escape'] === false) {
933✔
3213
                    $qbkey = $qbkey['condition'];
108✔
3214

3215
                    continue;
108✔
3216
                }
3217

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

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

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

3248
                    if ($matches[4] !== '') {
923✔
3249
                        $protectIdentifiers = false;
886✔
3250
                        if (str_contains($matches[4], '.')) {
886✔
3251
                            $protectIdentifiers = true;
84✔
3252
                        }
3253

3254
                        if (! str_contains($matches[4], ':')) {
886✔
3255
                            $matches[4] = $this->db->protectIdentifiers(trim($matches[4]), false, $protectIdentifiers);
14✔
3256
                        }
3257

3258
                        $matches[4] = ' ' . $matches[4];
886✔
3259
                    }
3260

3261
                    $condition = $matches[1] . $this->db->protectIdentifiers(trim($matches[2]))
923✔
3262
                        . ' ' . trim($matches[3]) . $matches[4] . $matches[5];
923✔
3263
                }
3264

3265
                $qbkey = implode('', $conditions);
923✔
3266
            }
3267

3268
            return ($qbKey === 'QBHaving' ? "\nHAVING " : "\nWHERE ")
940✔
3269
                . implode("\n", $this->{$qbKey});
940✔
3270
        }
3271

3272
        return '';
1,043✔
3273
    }
3274

3275
    /**
3276
     * @param array{columnComparison: true, condition: string, escape: bool, first: string, operator: string, second: string} $condition
3277
     */
3278
    private function compileColumnComparison(array $condition): string
3279
    {
3280
        if ($condition['escape']) {
7✔
3281
            $condition['first']  = $this->db->protectIdentifiers($condition['first'], false, true);
6✔
3282
            $condition['second'] = $this->db->protectIdentifiers($condition['second'], false, true);
6✔
3283
        }
3284

3285
        return $condition['condition'] . $condition['first'] . ' ' . $condition['operator'] . ' ' . $condition['second'];
7✔
3286
    }
3287

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

3304
                $groupBy = ($groupBy['escape'] === false || $this->isLiteral($groupBy['field']))
56✔
UNCOV
3305
                    ? $groupBy['field']
×
3306
                    : $this->db->protectIdentifiers($groupBy['field']);
56✔
3307
            }
3308

3309
            return "\nGROUP BY " . implode(', ', $this->QBGroupBy);
56✔
3310
        }
3311

3312
        return '';
1,018✔
3313
    }
3314

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

3333
                $orderBy = $orderBy['field'] . $orderBy['direction'];
812✔
3334
            }
3335

3336
            return "\nORDER BY " . implode(', ', $this->QBOrderBy);
812✔
3337
        }
3338

3339
        return '';
1,029✔
3340
    }
3341

3342
    protected function unionInjection(string $sql): string
3343
    {
3344
        if ($this->QBUnion === []) {
1,044✔
3345
            return $sql;
1,044✔
3346
        }
3347

3348
        return 'SELECT * FROM (' . $sql . ') '
6✔
3349
            . ($this->db->protectIdentifiers ? $this->db->escapeIdentifiers('uwrp0') : 'uwrp0')
6✔
3350
            . implode("\n", $this->QBUnion);
6✔
3351
    }
3352

3353
    /**
3354
     * Takes an object as input and converts the class variables to array key/vals
3355
     *
3356
     * @param array|object $object
3357
     *
3358
     * @return array
3359
     */
3360
    protected function objectToArray($object)
3361
    {
3362
        if (! is_object($object)) {
835✔
3363
            return $object;
832✔
3364
        }
3365

3366
        if ($object instanceof RawSql) {
8✔
3367
            throw new InvalidArgumentException('RawSql "' . $object . '" cannot be used here.');
1✔
3368
        }
3369

3370
        $array = [];
7✔
3371

3372
        foreach (get_object_vars($object) as $key => $val) {
7✔
3373
            if ((! is_object($val) || $val instanceof RawSql) && ! is_array($val)) {
7✔
3374
                $array[$key] = $val;
7✔
3375
            }
3376
        }
3377

3378
        return $array;
7✔
3379
    }
3380

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

UNCOV
3394
        $array  = [];
×
UNCOV
3395
        $out    = get_object_vars($object);
×
UNCOV
3396
        $fields = array_keys($out);
×
3397

3398
        foreach ($fields as $val) {
×
3399
            $i = 0;
×
3400

UNCOV
3401
            foreach ($out[$val] as $data) {
×
3402
                $array[$i++][$val] = $data;
×
3403
            }
3404
        }
3405

3406
        return $array;
×
3407
    }
3408

3409
    /**
3410
     * Determines if a string represents a literal value or a field name
3411
     */
3412
    protected function isLiteral(string $str): bool
3413
    {
3414
        $str = trim($str);
840✔
3415

3416
        if ($str === ''
840✔
3417
            || ctype_digit($str)
840✔
3418
            || (string) (float) $str === $str
840✔
3419
            || in_array(strtoupper($str), ['TRUE', 'FALSE'], true)
840✔
3420
        ) {
UNCOV
3421
            return true;
×
3422
        }
3423

3424
        if ($this->isLiteralStr === []) {
840✔
3425
            $this->isLiteralStr = $this->db->escapeChar !== '"' ? ['"', "'"] : ["'"];
840✔
3426
        }
3427

3428
        return in_array($str[0], $this->isLiteralStr, true);
840✔
3429
    }
3430

3431
    /**
3432
     * Publicly-visible method to reset the QB values.
3433
     *
3434
     * @return $this
3435
     */
3436
    public function resetQuery()
3437
    {
3438
        $this->resetSelect();
1✔
3439
        $this->resetWrite();
1✔
3440

3441
        return $this;
1✔
3442
    }
3443

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

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

3479
        if ($this->db instanceof BaseConnection) {
1,039✔
3480
            $this->db->setAliasedTables([]);
1,039✔
3481
        }
3482

3483
        // Reset QBFrom part
3484
        if (! empty($this->QBFrom)) {
1,039✔
3485
            $this->from(array_shift($this->QBFrom), true);
1,039✔
3486
        }
3487
    }
3488

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

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

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

3548
        return preg_match_all(
932✔
3549
            '/' . implode('|', $this->pregOperators) . '/i',
932✔
3550
            $str,
932✔
3551
            $match,
932✔
3552
        ) >= 1 ? ($list ? $match[0] : $match[0][0]) : false;
932✔
3553
    }
3554

3555
    /**
3556
     * Returns the SQL string operator from where key
3557
     *
3558
     * @return false|list<string>
3559
     */
3560
    private function getOperatorFromWhereKey(string $whereKey)
3561
    {
3562
        $whereKey = trim($whereKey);
885✔
3563

3564
        $pregOperators = [
885✔
3565
            '\s*(?:<|>|!)?=',         // =, <=, >=, !=
885✔
3566
            '\s*<>?',                 // <, <>
885✔
3567
            '\s*>',                   // >
885✔
3568
            '\s+IS NULL',             // IS NULL
885✔
3569
            '\s+IS NOT NULL',         // IS NOT NULL
885✔
3570
            '\s+EXISTS\s*\(.*\)',     // EXISTS (sql)
885✔
3571
            '\s+NOT EXISTS\s*\(.*\)', // NOT EXISTS (sql)
885✔
3572
            '\s+BETWEEN\s+',          // BETWEEN value AND value
885✔
3573
            '\s+IN\s*\(.*\)',         // IN (list)
885✔
3574
            '\s+NOT IN\s*\(.*\)',     // NOT IN (list)
885✔
3575
            '\s+LIKE',                // LIKE
885✔
3576
            '\s+NOT LIKE',            // NOT LIKE
885✔
3577
        ];
885✔
3578

3579
        return preg_match_all(
885✔
3580
            '/' . implode('|', $pregOperators) . '/i',
885✔
3581
            $whereKey,
885✔
3582
            $match,
885✔
3583
        ) >= 1 ? $match[0] : false;
885✔
3584
    }
3585

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

3602
            return $key;
932✔
3603
        }
3604

3605
        if (! array_key_exists($key, $this->bindsKeyCount)) {
43✔
3606
            $this->bindsKeyCount[$key] = 1;
43✔
3607
        }
3608

3609
        $count = $this->bindsKeyCount[$key]++;
43✔
3610

3611
        $this->binds[$key . '.' . $count] = [
43✔
3612
            $value,
43✔
3613
            $escape,
43✔
3614
        ];
43✔
3615

3616
        return $key . '.' . $count;
43✔
3617
    }
3618

3619
    /**
3620
     * @param mixed $value
3621
     */
3622
    protected function isSubquery($value): bool
3623
    {
3624
        return $value instanceof BaseBuilder || $value instanceof Closure;
894✔
3625
    }
3626

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

3638
        if ($builder === $this) {
21✔
3639
            throw new DatabaseException('The subquery cannot be the same object as the main query object.');
1✔
3640
        }
3641

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

3644
        if ($wrapped) {
20✔
3645
            $subquery = '(' . $subquery . ')';
20✔
3646
            $alias    = trim($alias);
20✔
3647

3648
            if ($alias !== '') {
20✔
3649
                $subquery .= ' ' . ($this->db->protectIdentifiers ? $this->db->escapeIdentifiers($alias) : $alias);
11✔
3650
            }
3651
        }
3652

3653
        return $subquery;
20✔
3654
    }
3655
}
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