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

codeigniter4 / CodeIgniter4 / 27340411392

11 Jun 2026 10:25AM UTC coverage: 89.024% (+0.03%) from 88.991%
27340411392

Pull #10266

github

web-flow
Merge 445e6d6a7 into f88932939
Pull Request #10266: feat: add Query Builder date helpers

100 of 102 new or added lines in 5 files covered. (98.04%)

24867 of 27933 relevant lines covered (89.02%)

224.34 hits per line

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

95.15
/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 BackedEnum;
17
use Closure;
18
use CodeIgniter\Database\Exceptions\DatabaseException;
19
use CodeIgniter\Database\Exceptions\DataException;
20
use CodeIgniter\Exceptions\InvalidArgumentException;
21
use CodeIgniter\Traits\ConditionalTrait;
22
use Config\Feature;
23
use DateTimeInterface;
24
use TypeError;
25

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

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

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

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

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

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

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

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

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

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

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

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

115
    /**
116
     * QB FOR UPDATE flag
117
     */
118
    protected bool $QBLockForUpdate = false;
119

120
    /**
121
     * QB SELECT aggregate helper flag
122
     */
123
    protected bool $QBSelectUsesAggregate = false;
124

125
    /**
126
     * QB ORDER BY data
127
     *
128
     * @var array|string|null
129
     */
130
    public $QBOrderBy = [];
131

132
    /**
133
     * QB UNION data
134
     *
135
     * @var list<string>
136
     */
137
    protected array $QBUnion = [];
138

139
    /**
140
     * Whether to protect identifiers in SELECT
141
     *
142
     * @var list<bool|null> true=protect, false=not protect
143
     */
144
    public $QBNoEscape = [];
145

146
    /**
147
     * QB data sets
148
     *
149
     * @var array<string, string>|list<list<int|string>>
150
     */
151
    protected $QBSet = [];
152

153
    /**
154
     * QB WHERE group started flag
155
     *
156
     * @var bool
157
     */
158
    protected $QBWhereGroupStarted = false;
159

160
    /**
161
     * QB WHERE group count
162
     *
163
     * @var int
164
     */
165
    protected $QBWhereGroupCount = 0;
166

167
    /**
168
     * Ignore data that cause certain
169
     * exceptions, for example in case of
170
     * duplicate keys.
171
     *
172
     * @var bool
173
     */
174
    protected $QBIgnore = false;
175

176
    /**
177
     * QB Options data
178
     * Holds additional options and data used to render SQL
179
     * and is reset by resetWrite()
180
     *
181
     * @var array{
182
     *   updateFieldsAdditional?: array,
183
     *   tableIdentity?: string,
184
     *   updateFields?: array,
185
     *   constraints?: array,
186
     *   setQueryAsData?: string,
187
     *   sql?: string,
188
     *   alias?: string,
189
     *   fieldTypes?: array<string, array<string, string>>
190
     * }
191
     *
192
     * fieldTypes: [ProtectedTableName => [FieldName => Type]]
193
     */
194
    protected $QBOptions;
195

196
    /**
197
     * A reference to the database connection.
198
     *
199
     * @var BaseConnection
200
     */
201
    protected $db;
202

203
    /**
204
     * Name of the primary table for this instance.
205
     * Tracked separately because $QBFrom gets escaped
206
     * and prefixed.
207
     *
208
     * When $tableName to the constructor has multiple tables,
209
     * the value is empty string.
210
     *
211
     * @var string
212
     */
213
    protected $tableName;
214

215
    /**
216
     * ORDER BY random keyword
217
     *
218
     * @var array
219
     */
220
    protected $randomKeyword = [
221
        'RAND()',
222
        'RAND(%d)',
223
    ];
224

225
    /**
226
     * COUNT string
227
     *
228
     * @used-by CI_DB_driver::count_all()
229
     * @used-by BaseBuilder::count_all_results()
230
     *
231
     * @var string
232
     */
233
    protected $countString = 'SELECT COUNT(*) AS ';
234

235
    /**
236
     * Collects the named parameters and
237
     * their values for later binding
238
     * in the Query object.
239
     *
240
     * @var array
241
     */
242
    protected $binds = [];
243

244
    /**
245
     * Collects the key count for named parameters
246
     * in the Query object.
247
     *
248
     * @var array
249
     */
250
    protected $bindsKeyCount = [];
251

252
    /**
253
     * Some databases, like SQLite, do not by default
254
     * allow limiting of delete clauses.
255
     *
256
     * @var bool
257
     */
258
    protected $canLimitDeletes = true;
259

260
    /**
261
     * Some databases do not by default
262
     * allow limit update queries with WHERE.
263
     *
264
     * @var bool
265
     */
266
    protected $canLimitWhereUpdates = true;
267

268
    /**
269
     * Specifies which sql statements
270
     * support the ignore option.
271
     *
272
     * @var array<string, string>
273
     */
274
    protected $supportedIgnoreStatements = [];
275

276
    /**
277
     * Builder testing mode status.
278
     *
279
     * @var bool
280
     */
281
    protected $testMode = false;
282

283
    /**
284
     * Tables relation types
285
     *
286
     * @var array
287
     */
288
    protected $joinTypes = [
289
        'LEFT',
290
        'RIGHT',
291
        'OUTER',
292
        'INNER',
293
        'LEFT OUTER',
294
        'RIGHT OUTER',
295
    ];
296

297
    /**
298
     * Strings that determine if a string represents a literal value or a field name
299
     *
300
     * @var list<string>
301
     */
302
    protected $isLiteralStr = [];
303

304
    /**
305
     * RegExp used to get operators
306
     *
307
     * @var list<string>
308
     */
309
    protected $pregOperators = [];
310

311
    /**
312
     * Constructor
313
     *
314
     * @param array|string|TableName $tableName tablename or tablenames with or without aliases
315
     *
316
     * Examples of $tableName: `mytable`, `jobs j`, `jobs j, users u`, `['jobs j','users u']`
317
     *
318
     * @throws DatabaseException
319
     */
320
    public function __construct($tableName, ConnectionInterface $db, ?array $options = null)
321
    {
322
        if (empty($tableName)) {
1,329✔
323
            throw new DatabaseException('A table must be specified when creating a new Query Builder.');
×
324
        }
325

326
        /** @var BaseConnection $db */
327
        $this->db = $db;
1,329✔
328

329
        if ($tableName instanceof TableName) {
1,329✔
330
            $this->tableName = $tableName->getTableName();
8✔
331
            $this->QBFrom[]  = $this->db->escapeIdentifier($tableName);
8✔
332
            $this->db->addTableAlias($tableName->getAlias());
8✔
333
        }
334
        // If it contains `,`, it has multiple tables
335
        elseif (is_string($tableName) && ! str_contains($tableName, ',')) {
1,328✔
336
            $this->tableName = $tableName;  // @TODO remove alias if exists
1,324✔
337
            $this->from($tableName);
1,324✔
338
        } else {
339
            $this->tableName = '';
25✔
340
            $this->from($tableName);
25✔
341
        }
342

343
        if ($options !== null && $options !== []) {
1,329✔
344
            foreach ($options as $key => $value) {
×
345
                if (property_exists($this, $key)) {
×
346
                    $this->{$key} = $value;
×
347
                }
348
            }
349
        }
350
    }
351

352
    /**
353
     * Returns the current database connection
354
     *
355
     * @return BaseConnection
356
     */
357
    public function db(): ConnectionInterface
358
    {
359
        return $this->db;
1✔
360
    }
361

362
    /**
363
     * Sets a test mode status.
364
     *
365
     * @return $this
366
     */
367
    public function testMode(bool $mode = true)
368
    {
369
        $this->testMode = $mode;
105✔
370

371
        return $this;
105✔
372
    }
373

374
    /**
375
     * Gets the name of the primary table.
376
     */
377
    public function getTable(): string
378
    {
379
        return $this->tableName;
5✔
380
    }
381

382
    /**
383
     * Returns an array of bind values and their
384
     * named parameters for binding in the Query object later.
385
     */
386
    public function getBinds(): array
387
    {
388
        return $this->binds;
85✔
389
    }
390

391
    /**
392
     * Ignore
393
     *
394
     * Set ignore Flag for next insert,
395
     * update or delete query.
396
     *
397
     * @return $this
398
     */
399
    public function ignore(bool $ignore = true)
400
    {
401
        $this->QBIgnore = $ignore;
1✔
402

403
        return $this;
1✔
404
    }
405

406
    /**
407
     * Generates the SELECT portion of the query
408
     *
409
     * @param list<RawSql|string>|RawSql|string $select
410
     * @param bool|null                         $escape Whether to protect identifiers
411
     *
412
     * @return $this
413
     */
414
    public function select($select = '*', ?bool $escape = null)
415
    {
416
        // If the escape value was not set, we will base it on the global setting
417
        if (! is_bool($escape)) {
981✔
418
            $escape = $this->db->protectIdentifiers;
970✔
419
        }
420

421
        if ($select instanceof RawSql) {
981✔
422
            $select = [$select];
1✔
423
        }
424

425
        if (is_string($select)) {
981✔
426
            $select = ($escape === false) ? [$select] : explode(',', $select);
974✔
427
        }
428

429
        foreach ($select as $val) {
981✔
430
            if ($val instanceof RawSql) {
981✔
431
                $this->QBSelect[]   = $val;
5✔
432
                $this->QBNoEscape[] = false;
5✔
433

434
                continue;
5✔
435
            }
436

437
            $val = trim($val);
979✔
438

439
            if ($val !== '') {
979✔
440
                $this->QBSelect[] = $val;
979✔
441

442
                /*
443
                 * When doing 'SELECT NULL as field_alias FROM table'
444
                 * null gets taken as a field, and therefore escaped
445
                 * with backticks.
446
                 * This prevents NULL being escaped
447
                 * @see https://github.com/codeigniter4/CodeIgniter4/issues/1169
448
                 */
449
                if (mb_stripos($val, 'NULL') === 0) {
979✔
450
                    $this->QBNoEscape[] = false;
2✔
451

452
                    continue;
2✔
453
                }
454

455
                $this->QBNoEscape[] = $escape;
979✔
456
            }
457
        }
458

459
        return $this;
981✔
460
    }
461

462
    /**
463
     * Generates a SELECT MAX(field) portion of a query
464
     *
465
     * @return $this
466
     */
467
    public function selectMax(string $select = '', string $alias = '')
468
    {
469
        return $this->maxMinAvgSum($select, $alias);
873✔
470
    }
471

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

482
    /**
483
     * Generates a SELECT AVG(field) portion of a query
484
     *
485
     * @return $this
486
     */
487
    public function selectAvg(string $select = '', string $alias = '')
488
    {
489
        return $this->maxMinAvgSum($select, $alias, 'AVG');
4✔
490
    }
491

492
    /**
493
     * Generates a SELECT SUM(field) portion of a query
494
     *
495
     * @return $this
496
     */
497
    public function selectSum(string $select = '', string $alias = '')
498
    {
499
        return $this->maxMinAvgSum($select, $alias, 'SUM');
6✔
500
    }
501

502
    /**
503
     * Generates a SELECT COUNT(field) portion of a query
504
     *
505
     * @return $this
506
     */
507
    public function selectCount(string $select = '', string $alias = '')
508
    {
509
        return $this->maxMinAvgSum($select, $alias, 'COUNT');
9✔
510
    }
511

512
    /**
513
     * Adds a subquery to the selection
514
     */
515
    public function selectSubquery(BaseBuilder $subquery, string $as): self
516
    {
517
        $this->QBSelect[] = $this->buildSubquery($subquery, true, $as);
2✔
518

519
        return $this;
2✔
520
    }
521

522
    /**
523
     * SELECT [MAX|MIN|AVG|SUM|COUNT]()
524
     *
525
     * @used-by selectMax()
526
     * @used-by selectMin()
527
     * @used-by selectAvg()
528
     * @used-by selectSum()
529
     *
530
     * @return $this
531
     *
532
     * @throws DatabaseException
533
     * @throws DataException
534
     */
535
    protected function maxMinAvgSum(string $select = '', string $alias = '', string $type = 'MAX')
536
    {
537
        if ($select === '') {
887✔
538
            throw DataException::forEmptyInputGiven('Select');
1✔
539
        }
540

541
        if (str_contains($select, ',')) {
886✔
542
            throw DataException::forInvalidArgument('column name not separated by comma');
1✔
543
        }
544

545
        $type = strtoupper($type);
885✔
546

547
        if (! in_array($type, ['MAX', 'MIN', 'AVG', 'SUM', 'COUNT'], true)) {
885✔
548
            throw new DatabaseException('Invalid function type: ' . $type);
×
549
        }
550

551
        if ($alias === '') {
885✔
552
            $alias = $this->createAliasFromTable(trim($select));
878✔
553
        }
554

555
        $sql = $type . '(' . $this->db->protectIdentifiers(trim($select)) . ') AS ' . $this->db->escapeIdentifiers(trim($alias));
885✔
556

557
        $this->QBSelect[]            = $sql;
885✔
558
        $this->QBNoEscape[]          = null;
885✔
559
        $this->QBSelectUsesAggregate = true;
885✔
560

561
        return $this;
885✔
562
    }
563

564
    /**
565
     * Determines the alias name based on the table
566
     */
567
    protected function createAliasFromTable(string $item): string
568
    {
569
        if (str_contains($item, '.')) {
878✔
570
            $item = explode('.', $item);
1✔
571

572
            return end($item);
1✔
573
        }
574

575
        return $item;
877✔
576
    }
577

578
    /**
579
     * Sets a flag which tells the query string compiler to add DISTINCT
580
     *
581
     * @return $this
582
     */
583
    public function distinct(bool $val = true)
584
    {
585
        $this->QBDistinct = $val;
879✔
586

587
        return $this;
879✔
588
    }
589

590
    /**
591
     * Generates the FROM portion of the query
592
     *
593
     * @param array|string $from
594
     *
595
     * @return $this
596
     */
597
    public function from($from, bool $overwrite = false): self
598
    {
599
        if ($overwrite) {
1,329✔
600
            $this->QBFrom = [];
1,193✔
601
            $this->db->setAliasedTables([]);
1,193✔
602
        }
603

604
        foreach ((array) $from as $table) {
1,329✔
605
            if (str_contains($table, ',')) {
1,329✔
606
                $this->from(explode(',', $table));
26✔
607
            } else {
608
                $table = trim($table);
1,329✔
609

610
                if ($table === '') {
1,329✔
611
                    continue;
23✔
612
                }
613

614
                $this->trackAliases($table);
1,329✔
615
                $this->QBFrom[] = $this->db->protectIdentifiers($table, true, null, false);
1,329✔
616
            }
617
        }
618

619
        return $this;
1,329✔
620
    }
621

622
    /**
623
     * @param BaseBuilder $from  Expected subquery
624
     * @param string      $alias Subquery alias
625
     *
626
     * @return $this
627
     */
628
    public function fromSubquery(BaseBuilder $from, string $alias): self
629
    {
630
        $table = $this->buildSubquery($from, true, $alias);
7✔
631

632
        $this->db->addTableAlias($alias);
6✔
633
        $this->QBFrom[] = $table;
6✔
634

635
        return $this;
6✔
636
    }
637

638
    /**
639
     * Generates the JOIN portion of the query
640
     *
641
     * @param RawSql|string $cond
642
     *
643
     * @return $this
644
     */
645
    public function join(string $table, $cond, string $type = '', ?bool $escape = null)
646
    {
647
        $type = $this->compileJoinType($type);
19✔
648

649
        // Extract any aliases that might exist. We use this information
650
        // in the protectIdentifiers to know whether to add a table prefix
651
        $this->trackAliases($table);
19✔
652

653
        if (! is_bool($escape)) {
19✔
654
            $escape = $this->db->protectIdentifiers;
19✔
655
        }
656

657
        $table = $this->compileJoinTable($table, $escape);
19✔
658
        $cond  = $this->compileJoinCondition($cond, $escape);
19✔
659

660
        // Assemble the JOIN statement
661
        $this->QBJoin[] = $type . 'JOIN ' . $table . $cond;
19✔
662

663
        return $this;
19✔
664
    }
665

666
    /**
667
     * Compiles the JOIN table name.
668
     */
669
    protected function compileJoinTable(string $table, bool $escape): string
670
    {
671
        if ($escape) {
15✔
672
            return $this->db->protectIdentifiers($table, true, null, false);
15✔
673
        }
674

675
        return $table;
×
676
    }
677

678
    private function compileJoinType(string $type): string
679
    {
680
        if ($type === '') {
19✔
681
            return '';
7✔
682
        }
683

684
        $type = strtoupper(trim($type));
12✔
685

686
        return in_array($type, $this->joinTypes, true) ? $type . ' ' : '';
12✔
687
    }
688

689
    /**
690
     * Compiles the JOIN ON or USING condition.
691
     */
692
    private function compileJoinCondition(RawSql|string $condition, bool $escape): string
693
    {
694
        if ($condition instanceof RawSql) {
19✔
695
            return ' ON ' . $condition;
2✔
696
        }
697

698
        if (! $this->hasOperator($condition)) {
17✔
699
            return ' USING (' . ($escape ? $this->db->escapeIdentifiers($condition) : $condition) . ')';
×
700
        }
701

702
        if ($escape === false) {
17✔
703
            return ' ON ' . $condition;
×
704
        }
705

706
        return $this->compileProtectedJoinCondition($condition);
17✔
707
    }
708

709
    private function compileProtectedJoinCondition(string $condition): string
710
    {
711
        // Split multiple conditions
712
        // @TODO This does not parse `BETWEEN a AND b` correctly.
713
        if (preg_match_all('/\sAND\s|\sOR\s/i', $condition, $joints, PREG_OFFSET_CAPTURE) >= 1) {
17✔
714
            $conditions = [];
3✔
715
            $joints     = $joints[0];
3✔
716
            array_unshift($joints, ['', 0]);
3✔
717

718
            for ($i = count($joints) - 1, $pos = strlen($condition); $i >= 0; $i--) {
3✔
719
                $joints[$i][1] += strlen($joints[$i][0]); // offset
3✔
720
                $conditions[$i] = substr($condition, $joints[$i][1], $pos - $joints[$i][1]);
3✔
721
                $pos            = $joints[$i][1] - strlen($joints[$i][0]);
3✔
722
                $joints[$i]     = $joints[$i][0];
3✔
723
            }
724
            ksort($conditions);
3✔
725
        } else {
726
            $conditions = [$condition];
14✔
727
            $joints     = [''];
14✔
728
        }
729

730
        $condition = ' ON ';
17✔
731

732
        foreach ($conditions as $i => $conditionPart) {
17✔
733
            $operator = $this->getOperator($conditionPart);
17✔
734

735
            // Workaround for BETWEEN
736
            if ($operator === false) {
17✔
737
                $condition .= $joints[$i] . $conditionPart;
1✔
738

739
                continue;
1✔
740
            }
741

742
            $condition .= $joints[$i];
17✔
743
            $condition .= preg_match('/(\(*)?([\[\]\w\.\'-]+)' . preg_quote($operator, '/') . '(.*)/i', $conditionPart, $match) ? $match[1] . $this->db->protectIdentifiers($match[2]) . $operator . $this->db->protectIdentifiers($match[3]) : $conditionPart;
17✔
744
        }
745

746
        return $condition;
17✔
747
    }
748

749
    /**
750
     * Generates the WHERE portion of the query.
751
     * Separates multiple calls with 'AND'.
752
     *
753
     * @param array|RawSql|string $key
754
     * @param mixed               $value
755
     *
756
     * @return $this
757
     */
758
    public function where($key, $value = null, ?bool $escape = null)
759
    {
760
        return $this->whereHaving('QBWhere', $key, $value, 'AND ', $escape);
1,024✔
761
    }
762

763
    /**
764
     * OR WHERE
765
     *
766
     * Generates the WHERE portion of the query.
767
     * Separates multiple calls with 'OR'.
768
     *
769
     * @param array|RawSql|string $key
770
     * @param mixed               $value
771
     *
772
     * @return $this
773
     */
774
    public function orWhere($key, $value = null, ?bool $escape = null)
775
    {
776
        return $this->whereHaving('QBWhere', $key, $value, 'OR ', $escape);
5✔
777
    }
778

779
    /**
780
     * Generates a WHERE clause that compares the date portion of a field.
781
     *
782
     * @param non-empty-string $key   Field name, optionally with comparison operator
783
     * @param mixed            $value
784
     *
785
     * @return $this
786
     *
787
     * @throws InvalidArgumentException
788
     */
789
    public function whereDate(string $key, $value, ?bool $escape = null): static
790
    {
791
        return $this->whereDatePart('date', $key, $value, 'AND ', $escape);
21✔
792
    }
793

794
    /**
795
     * Generates an OR WHERE clause that compares the date portion of a field.
796
     *
797
     * @param non-empty-string $key   Field name, optionally with comparison operator
798
     * @param mixed            $value
799
     *
800
     * @return $this
801
     *
802
     * @throws InvalidArgumentException
803
     */
804
    public function orWhereDate(string $key, $value, ?bool $escape = null): static
805
    {
806
        return $this->whereDatePart('date', $key, $value, 'OR ', $escape);
1✔
807
    }
808

809
    /**
810
     * Generates a WHERE clause that compares the year portion of a field.
811
     *
812
     * @param non-empty-string $key   Field name, optionally with comparison operator
813
     * @param mixed            $value
814
     *
815
     * @return $this
816
     *
817
     * @throws InvalidArgumentException
818
     */
819
    public function whereYear(string $key, $value, ?bool $escape = null): static
820
    {
821
        return $this->whereDatePart('year', $key, $value, 'AND ', $escape);
4✔
822
    }
823

824
    /**
825
     * Generates an OR WHERE clause that compares the year portion of a field.
826
     *
827
     * @param non-empty-string $key   Field name, optionally with comparison operator
828
     * @param mixed            $value
829
     *
830
     * @return $this
831
     *
832
     * @throws InvalidArgumentException
833
     */
834
    public function orWhereYear(string $key, $value, ?bool $escape = null): static
835
    {
836
        return $this->whereDatePart('year', $key, $value, 'OR ', $escape);
1✔
837
    }
838

839
    /**
840
     * Generates a WHERE clause that compares the month portion of a field.
841
     *
842
     * @param non-empty-string $key   Field name, optionally with comparison operator
843
     * @param mixed            $value
844
     *
845
     * @return $this
846
     *
847
     * @throws InvalidArgumentException
848
     */
849
    public function whereMonth(string $key, $value, ?bool $escape = null): static
850
    {
851
        return $this->whereDatePart('month', $key, $value, 'AND ', $escape);
4✔
852
    }
853

854
    /**
855
     * Generates an OR WHERE clause that compares the month portion of a field.
856
     *
857
     * @param non-empty-string $key   Field name, optionally with comparison operator
858
     * @param mixed            $value
859
     *
860
     * @return $this
861
     *
862
     * @throws InvalidArgumentException
863
     */
864
    public function orWhereMonth(string $key, $value, ?bool $escape = null): static
865
    {
866
        return $this->whereDatePart('month', $key, $value, 'OR ', $escape);
1✔
867
    }
868

869
    /**
870
     * Generates a WHERE clause that compares the day portion of a field.
871
     *
872
     * @param non-empty-string $key   Field name, optionally with comparison operator
873
     * @param mixed            $value
874
     *
875
     * @return $this
876
     *
877
     * @throws InvalidArgumentException
878
     */
879
    public function whereDay(string $key, $value, ?bool $escape = null): static
880
    {
881
        return $this->whereDatePart('day', $key, $value, 'AND ', $escape);
3✔
882
    }
883

884
    /**
885
     * Generates an OR WHERE clause that compares the day portion of a field.
886
     *
887
     * @param non-empty-string $key   Field name, optionally with comparison operator
888
     * @param mixed            $value
889
     *
890
     * @return $this
891
     *
892
     * @throws InvalidArgumentException
893
     */
894
    public function orWhereDay(string $key, $value, ?bool $escape = null): static
895
    {
NEW
896
        return $this->whereDatePart('day', $key, $value, 'OR ', $escape);
×
897
    }
898

899
    /**
900
     * @used-by whereDate()
901
     * @used-by orWhereDate()
902
     * @used-by whereYear()
903
     * @used-by orWhereYear()
904
     * @used-by whereMonth()
905
     * @used-by orWhereMonth()
906
     * @used-by whereDay()
907
     * @used-by orWhereDay()
908
     *
909
     * @param 'date'|'day'|'month'|'year' $part
910
     * @param non-empty-string            $key   Field name, optionally with comparison operator
911
     * @param mixed                       $value
912
     *
913
     * @return $this
914
     *
915
     * @throws InvalidArgumentException
916
     */
917
    private function whereDatePart(string $part, string $key, $value, string $type = 'AND ', ?bool $escape = null): static
918
    {
919
        [$key, $operator] = $this->parseDatePartKey($key);
29✔
920

921
        if ($key === '') {
21✔
922
            throw new InvalidArgumentException(sprintf('%s() expects $key to be a non-empty string', debug_backtrace(0, 2)[1]['function']));
1✔
923
        }
924

925
        $escape ??= $this->db->protectIdentifiers;
20✔
926

927
        if (is_array($value) || $this->isSubquery($value)) {
20✔
928
            throw new InvalidArgumentException(sprintf('%s() does not accept array or subquery values', debug_backtrace(0, 2)[1]['function']));
2✔
929
        }
930

931
        if ($value === null) {
18✔
932
            $nullOperator = match ($operator) {
2✔
933
                '='        => 'IS NULL',
1✔
934
                '!=', '<>' => 'IS NOT NULL',
1✔
935
                default    => throw new InvalidArgumentException(sprintf('%s() supports null values only with =, !=, or <> operators', debug_backtrace(0, 2)[1]['function'])),
1✔
936
            };
2✔
937

938
            $this->addWhereHavingCondition('QBWhere', [
1✔
939
                'condition'          => '',
1✔
940
                'datePartComparison' => true,
1✔
941
                'escape'             => $escape,
1✔
942
                'key'                => $key,
1✔
943
                'nullComparison'     => true,
1✔
944
                'nullOperator'       => $nullOperator,
1✔
945
                'part'               => $part,
1✔
946
            ], $type);
1✔
947

948
            return $this;
1✔
949
        }
950

951
        $value = $this->normalizeDatePartValue($part, $value);
16✔
952
        $bind  = $this->setBind($key, $value, $escape);
16✔
953

954
        $this->addWhereHavingCondition('QBWhere', [
16✔
955
            'condition'          => '',
16✔
956
            'datePartComparison' => true,
16✔
957
            'escape'             => $escape,
16✔
958
            'key'                => $key,
16✔
959
            'operator'           => $operator,
16✔
960
            'part'               => $part,
16✔
961
            'rawValue'           => $value instanceof RawSql,
16✔
962
            'valueBind'          => $bind,
16✔
963
        ], $type);
16✔
964

965
        return $this;
16✔
966
    }
967

968
    /**
969
     * Extracts the comparison operator from date helper keys.
970
     *
971
     * @return array{string, string}
972
     *
973
     * @throws InvalidArgumentException
974
     */
975
    private function parseDatePartKey(string $key): array
976
    {
977
        $key = trim($key);
29✔
978

979
        if (
980
            preg_match('/\s+(?:IS(?:\s+NOT)?(?:\s+NULL)?|(?:NOT\s+)?(?:LIKE|IN|BETWEEN|EXISTS|REGEXP|RLIKE))(?:\s+.*|\s*\(.*\))?$/i', $key) === 1
29✔
981
            || preg_match('/\s*<=>\s*$/', $key) === 1
29✔
982
        ) {
983
            throw new InvalidArgumentException(sprintf('%s() supports only comparison operators: =, !=, <>, <, >, <=, >=', debug_backtrace(0, 3)[2]['function']));
8✔
984
        }
985

986
        if (preg_match('/\s*(!=|<>|<=|>=|=|<|>)\s*$/', $key, $match) === 1) {
21✔
987
            return [rtrim(substr($key, 0, -strlen($match[0]))), trim($match[1])];
3✔
988
        }
989

990
        return [$key, '='];
19✔
991
    }
992

993
    /**
994
     * Converts DateTime values into deterministic date helper comparison values.
995
     *
996
     * @param 'date'|'day'|'month'|'year' $part
997
     * @param mixed                       $value
998
     *
999
     * @return mixed
1000
     */
1001
    private function normalizeDatePartValue(string $part, $value)
1002
    {
1003
        if ($value instanceof DateTimeInterface) {
16✔
1004
            return match ($part) {
1✔
1005
                'date'  => $value->format('Y-m-d'),
1✔
NEW
1006
                'year'  => (int) $value->format('Y'),
×
1007
                'month' => (int) $value->format('m'),
1✔
1008
                'day'   => (int) $value->format('d'),
1✔
1009
            };
1✔
1010
        }
1011

1012
        if (! $value instanceof RawSql && $part !== 'date' && (is_int($value) || (is_string($value) && preg_match('/^-?\d+$/', $value) === 1))) {
15✔
1013
            return (int) $value;
9✔
1014
        }
1015

1016
        return $value;
7✔
1017
    }
1018

1019
    /**
1020
     * Generates a WHERE clause that compares two columns.
1021
     *
1022
     * @param non-empty-string $first  First column name, optionally with comparison operator
1023
     * @param non-empty-string $second Second column name
1024
     * @param bool|null        $escape Whether to protect identifiers
1025
     *
1026
     * @return $this
1027
     *
1028
     * @throws InvalidArgumentException
1029
     */
1030
    public function whereColumn(string $first, string $second, ?bool $escape = null): static
1031
    {
1032
        return $this->whereColumnHaving('QBWhere', $first, $second, 'AND ', $escape);
23✔
1033
    }
1034

1035
    /**
1036
     * Generates an OR WHERE clause that compares two columns.
1037
     *
1038
     * @param non-empty-string $first  First column name, optionally with comparison operator
1039
     * @param non-empty-string $second Second column name
1040
     * @param bool|null        $escape Whether to protect identifiers
1041
     *
1042
     * @return $this
1043
     *
1044
     * @throws InvalidArgumentException
1045
     */
1046
    public function orWhereColumn(string $first, string $second, ?bool $escape = null): static
1047
    {
1048
        return $this->whereColumnHaving('QBWhere', $first, $second, 'OR ', $escape);
2✔
1049
    }
1050

1051
    /**
1052
     * Generates a WHERE EXISTS subquery.
1053
     *
1054
     * @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery
1055
     *
1056
     * @return $this
1057
     */
1058
    public function whereExists($subquery): static
1059
    {
1060
        return $this->whereExistsSubquery($subquery);
9✔
1061
    }
1062

1063
    /**
1064
     * Generates an OR WHERE EXISTS subquery.
1065
     *
1066
     * @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery
1067
     *
1068
     * @return $this
1069
     */
1070
    public function orWhereExists($subquery): static
1071
    {
1072
        return $this->whereExistsSubquery($subquery, false, 'OR ');
1✔
1073
    }
1074

1075
    /**
1076
     * Generates a WHERE NOT EXISTS subquery.
1077
     *
1078
     * @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery
1079
     *
1080
     * @return $this
1081
     */
1082
    public function whereNotExists($subquery): static
1083
    {
1084
        return $this->whereExistsSubquery($subquery, true);
1✔
1085
    }
1086

1087
    /**
1088
     * Generates an OR WHERE NOT EXISTS subquery.
1089
     *
1090
     * @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery
1091
     *
1092
     * @return $this
1093
     */
1094
    public function orWhereNotExists($subquery): static
1095
    {
1096
        return $this->whereExistsSubquery($subquery, true, 'OR ');
2✔
1097
    }
1098

1099
    /**
1100
     * @used-by whereColumn()
1101
     * @used-by orWhereColumn()
1102
     *
1103
     * @param 'QBHaving'|'QBWhere' $qbKey
1104
     * @param non-empty-string     $first  First column name, optionally with comparison operator
1105
     * @param non-empty-string     $second Second column name
1106
     * @param non-empty-string     $type
1107
     * @param bool|null            $escape Whether to protect identifiers
1108
     *
1109
     * @return $this
1110
     *
1111
     * @throws InvalidArgumentException
1112
     */
1113
    protected function whereColumnHaving(string $qbKey, string $first, string $second, string $type = 'AND ', ?bool $escape = null): static
1114
    {
1115
        [$first, $operator] = $this->parseWhereColumnFirst($first);
24✔
1116
        $second             = trim($second);
24✔
1117

1118
        if ($first === '' || $second === '') {
24✔
1119
            $caller = debug_backtrace(0, 2)[1]['function'];
2✔
1120

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

1124
        $escape ??= $this->db->protectIdentifiers;
22✔
1125

1126
        $this->addWhereHavingCondition($qbKey, [
22✔
1127
            'columnComparison' => true,
22✔
1128
            'condition'        => '',
22✔
1129
            'escape'           => $escape,
22✔
1130
            'first'            => $first,
22✔
1131
            'operator'         => $operator,
22✔
1132
            'second'           => $second,
22✔
1133
        ], $type);
22✔
1134

1135
        return $this;
22✔
1136
    }
1137

1138
    /**
1139
     * Extracts the operator from the first whereColumn() column.
1140
     *
1141
     * @param string $first The first column, optionally ending with a comparison operator
1142
     *
1143
     * @return array{string, string}
1144
     */
1145
    private function parseWhereColumnFirst(string $first): array
1146
    {
1147
        $first = trim($first);
24✔
1148

1149
        if (preg_match('/\s*(!=|<>|<=|>=|=|<|>)\s*$/', $first, $match) === 1) {
24✔
1150
            return [rtrim(substr($first, 0, -strlen($match[0]))), trim($match[1])];
12✔
1151
        }
1152

1153
        return [$first, '='];
13✔
1154
    }
1155

1156
    /**
1157
     * Generates a WHERE field BETWEEN minimum AND maximum SQL query,
1158
     * joined with 'AND' if appropriate.
1159
     *
1160
     * @param array<array-key, mixed>|null $values The range values searched on
1161
     *
1162
     * @return $this
1163
     *
1164
     * @throws InvalidArgumentException
1165
     */
1166
    public function whereBetween(?string $key = null, $values = null, ?bool $escape = null): static
1167
    {
1168
        return $this->whereBetweenHaving('QBWhere', $key, $values, false, 'AND ', $escape);
13✔
1169
    }
1170

1171
    /**
1172
     * Generates a WHERE field BETWEEN minimum AND maximum SQL query,
1173
     * joined with 'OR' if appropriate.
1174
     *
1175
     * @param array<array-key, mixed>|null $values The range values searched on
1176
     *
1177
     * @return $this
1178
     *
1179
     * @throws InvalidArgumentException
1180
     */
1181
    public function orWhereBetween(?string $key = null, $values = null, ?bool $escape = null): static
1182
    {
1183
        return $this->whereBetweenHaving('QBWhere', $key, $values, false, 'OR ', $escape);
1✔
1184
    }
1185

1186
    /**
1187
     * Generates a WHERE field NOT BETWEEN minimum AND maximum SQL query,
1188
     * joined with 'AND' if appropriate.
1189
     *
1190
     * @param array<array-key, mixed>|null $values The range values searched on
1191
     *
1192
     * @return $this
1193
     *
1194
     * @throws InvalidArgumentException
1195
     */
1196
    public function whereNotBetween(?string $key = null, $values = null, ?bool $escape = null): static
1197
    {
1198
        return $this->whereBetweenHaving('QBWhere', $key, $values, true, 'AND ', $escape);
1✔
1199
    }
1200

1201
    /**
1202
     * Generates a WHERE field NOT BETWEEN minimum AND maximum SQL query,
1203
     * joined with 'OR' if appropriate.
1204
     *
1205
     * @param array<array-key, mixed>|null $values The range values searched on
1206
     *
1207
     * @return $this
1208
     *
1209
     * @throws InvalidArgumentException
1210
     */
1211
    public function orWhereNotBetween(?string $key = null, $values = null, ?bool $escape = null): static
1212
    {
1213
        return $this->whereBetweenHaving('QBWhere', $key, $values, true, 'OR ', $escape);
2✔
1214
    }
1215

1216
    /**
1217
     * Generates a HAVING field BETWEEN minimum AND maximum SQL query,
1218
     * joined with 'AND' if appropriate.
1219
     *
1220
     * @param array<array-key, mixed>|null $values The range values searched on
1221
     *
1222
     * @return $this
1223
     *
1224
     * @throws InvalidArgumentException
1225
     */
1226
    public function havingBetween(?string $key = null, $values = null, ?bool $escape = null): static
1227
    {
1228
        return $this->whereBetweenHaving('QBHaving', $key, $values, false, 'AND ', $escape);
8✔
1229
    }
1230

1231
    /**
1232
     * Generates a HAVING field BETWEEN minimum AND maximum SQL query,
1233
     * joined with 'OR' if appropriate.
1234
     *
1235
     * @param array<array-key, mixed>|null $values The range values searched on
1236
     *
1237
     * @return $this
1238
     *
1239
     * @throws InvalidArgumentException
1240
     */
1241
    public function orHavingBetween(?string $key = null, $values = null, ?bool $escape = null): static
1242
    {
1243
        return $this->whereBetweenHaving('QBHaving', $key, $values, false, 'OR ', $escape);
1✔
1244
    }
1245

1246
    /**
1247
     * Generates a HAVING field NOT BETWEEN minimum AND maximum SQL query,
1248
     * joined with 'AND' if appropriate.
1249
     *
1250
     * @param array<array-key, mixed>|null $values The range values searched on
1251
     *
1252
     * @return $this
1253
     *
1254
     * @throws InvalidArgumentException
1255
     */
1256
    public function havingNotBetween(?string $key = null, $values = null, ?bool $escape = null): static
1257
    {
1258
        return $this->whereBetweenHaving('QBHaving', $key, $values, true, 'AND ', $escape);
1✔
1259
    }
1260

1261
    /**
1262
     * Generates a HAVING field NOT BETWEEN minimum AND maximum SQL query,
1263
     * joined with 'OR' if appropriate.
1264
     *
1265
     * @param array<array-key, mixed>|null $values The range values searched on
1266
     *
1267
     * @return $this
1268
     *
1269
     * @throws InvalidArgumentException
1270
     */
1271
    public function orHavingNotBetween(?string $key = null, $values = null, ?bool $escape = null): static
1272
    {
1273
        return $this->whereBetweenHaving('QBHaving', $key, $values, true, 'OR ', $escape);
2✔
1274
    }
1275

1276
    /**
1277
     * @used-by whereBetween()
1278
     * @used-by orWhereBetween()
1279
     * @used-by whereNotBetween()
1280
     * @used-by orWhereNotBetween()
1281
     * @used-by havingBetween()
1282
     * @used-by orHavingBetween()
1283
     * @used-by havingNotBetween()
1284
     * @used-by orHavingNotBetween()
1285
     *
1286
     * @param 'QBHaving'|'QBWhere'         $qbKey
1287
     * @param non-empty-string|null        $key
1288
     * @param array<array-key, mixed>|null $values The range values searched on
1289
     *
1290
     * @return $this
1291
     *
1292
     * @throws InvalidArgumentException
1293
     */
1294
    private function whereBetweenHaving(string $qbKey, ?string $key = null, $values = null, bool $not = false, string $type = 'AND ', ?bool $escape = null): static
1295
    {
1296
        if ($key === null || $key === '') {
27✔
1297
            throw new InvalidArgumentException(sprintf('%s() expects $key to be a non-empty string', debug_backtrace(0, 2)[1]['function']));
2✔
1298
        }
1299

1300
        if (! is_array($values) || count($values) !== 2) {
25✔
1301
            throw new InvalidArgumentException(sprintf('%s() expects $values to be an array containing exactly two values', debug_backtrace(0, 2)[1]['function']));
10✔
1302
        }
1303

1304
        $escape ??= $this->db->protectIdentifiers;
15✔
1305
        $values = array_values($values);
15✔
1306

1307
        $lowerBind = $this->setBind($key, $values[0], $escape);
15✔
1308
        $upperBind = $this->setBind($key, $values[1], $escape);
15✔
1309
        $not       = $not ? ' NOT' : '';
15✔
1310
        $this->addWhereHavingCondition($qbKey, [
15✔
1311
            'betweenComparison' => true,
15✔
1312
            'condition'         => '',
15✔
1313
            'escape'            => $escape,
15✔
1314
            'key'               => $key,
15✔
1315
            'lowerBind'         => $lowerBind,
15✔
1316
            'not'               => $not,
15✔
1317
            'upperBind'         => $upperBind,
15✔
1318
        ], $type);
15✔
1319

1320
        return $this;
15✔
1321
    }
1322

1323
    /**
1324
     * @used-by whereExists()
1325
     * @used-by orWhereExists()
1326
     * @used-by whereNotExists()
1327
     * @used-by orWhereNotExists()
1328
     *
1329
     * @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery
1330
     *
1331
     * @return $this
1332
     *
1333
     * @throws InvalidArgumentException
1334
     */
1335
    protected function whereExistsSubquery($subquery, bool $not = false, string $type = 'AND '): static
1336
    {
1337
        if (! $this->isSubquery($subquery)) {
12✔
1338
            throw new InvalidArgumentException(sprintf('%s() expects $subquery to be of type BaseBuilder or closure', debug_backtrace(0, 2)[1]['function']));
4✔
1339
        }
1340

1341
        $operator = $not ? 'NOT EXISTS' : 'EXISTS';
8✔
1342

1343
        $this->addWhereHavingCondition('QBWhere', [
8✔
1344
            'condition' => "{$operator} {$this->buildSubquery($subquery, true)}",
8✔
1345
            'escape'    => false,
8✔
1346
        ], $type);
8✔
1347

1348
        return $this;
7✔
1349
    }
1350

1351
    /**
1352
     * @used-by where()
1353
     * @used-by orWhere()
1354
     * @used-by having()
1355
     * @used-by orHaving()
1356
     *
1357
     * @param array|RawSql|string $key
1358
     * @param mixed               $value
1359
     *
1360
     * @return $this
1361
     */
1362
    protected function whereHaving(string $qbKey, $key, $value = null, string $type = 'AND ', ?bool $escape = null)
1363
    {
1364
        $rawSqlOnly = false;
1,035✔
1365

1366
        if ($key instanceof RawSql) {
1,035✔
1367
            if ($value === null) {
4✔
1368
                $keyValue   = [(string) $key => $key];
1✔
1369
                $rawSqlOnly = true;
1✔
1370
            } else {
1371
                $keyValue = [(string) $key => $value];
3✔
1372
            }
1373
        } elseif (! is_array($key)) {
1,031✔
1374
            $keyValue = [$key => $value];
1,021✔
1375
        } else {
1376
            $keyValue = $key;
246✔
1377
        }
1378

1379
        // If the escape value was not set will base it on the global setting
1380
        if (! is_bool($escape)) {
1,035✔
1381
            $escape = $this->db->protectIdentifiers;
1,029✔
1382
        }
1383

1384
        foreach ($keyValue as $k => $v) {
1,035✔
1385
            if ($rawSqlOnly) {
1,035✔
1386
                $k  = '';
1✔
1387
                $op = '';
1✔
1388
            } elseif ($v !== null) {
1,034✔
1389
                $op = $this->getOperatorFromWhereKey($k);
1,012✔
1390

1391
                if (! empty($op)) {
1,012✔
1392
                    $k = trim($k);
69✔
1393

1394
                    end($op);
69✔
1395
                    $op = trim(current($op));
69✔
1396

1397
                    // Does the key end with operator?
1398
                    if (str_ends_with($k, $op)) {
69✔
1399
                        $k  = rtrim(substr($k, 0, -strlen($op)));
69✔
1400
                        $op = " {$op}";
69✔
1401
                    } else {
1402
                        $op = '';
×
1403
                    }
1404
                } else {
1405
                    $op = ' =';
980✔
1406
                }
1407

1408
                if ($this->isSubquery($v)) {
1,012✔
1409
                    $v = $this->buildSubquery($v, true);
1✔
1410
                } else {
1411
                    $bind = $this->setBind($k, $v, $escape);
1,012✔
1412
                    $v    = " :{$bind}:";
1,012✔
1413
                }
1414
            } elseif (! $this->hasOperator($k) && $qbKey !== 'QBHaving') {
152✔
1415
                // value appears not to have been set, assign the test to IS NULL
1416
                $op = ' IS NULL';
98✔
1417
            } elseif (
1418
                // The key ends with !=, =, <>, IS, IS NOT
1419
                preg_match(
63✔
1420
                    '/\s*(!?=|<>|IS(?:\s+NOT)?)\s*$/i',
63✔
1421
                    $k,
63✔
1422
                    $match,
63✔
1423
                    PREG_OFFSET_CAPTURE,
63✔
1424
                )
63✔
1425
            ) {
1426
                $k  = substr($k, 0, $match[0][1]);
3✔
1427
                $op = $match[1][0] === '=' ? ' IS NULL' : ' IS NOT NULL';
3✔
1428
            } else {
1429
                $op = '';
60✔
1430
            }
1431

1432
            $condition = $k . $op . $v;
1,035✔
1433

1434
            if ($v instanceof RawSql) {
1,035✔
1435
                $this->{$qbKey}[] = [
1✔
1436
                    'condition' => $v->with($this->getWhereHavingPrefix($qbKey, $type) . $condition),
1✔
1437
                    'escape'    => $escape,
1✔
1438
                ];
1✔
1439

1440
                continue;
1✔
1441
            }
1442

1443
            $this->addWhereHavingCondition($qbKey, [
1,034✔
1444
                'condition' => $condition,
1,034✔
1445
                'escape'    => $escape,
1,034✔
1446
            ], $type);
1,034✔
1447
        }
1448

1449
        return $this;
1,035✔
1450
    }
1451

1452
    /**
1453
     * @param 'QBHaving'|'QBWhere' $clause
1454
     * @param array<string, mixed> $condition
1455
     * @param non-empty-string     $type
1456
     */
1457
    private function addWhereHavingCondition(string $clause, array $condition, string $type): void
1458
    {
1459
        $condition['condition'] = $this->getWhereHavingPrefix($clause, $type) . $condition['condition'];
1,078✔
1460

1461
        $this->{$clause}[] = $condition;
1,078✔
1462
    }
1463

1464
    /**
1465
     * @param 'QBHaving'|'QBWhere' $clause
1466
     */
1467
    private function getWhereHavingPrefix(string $clause, string $type): string
1468
    {
1469
        return $this->{$clause} === [] ? $this->groupGetType('') : $this->groupGetType($type);
1,105✔
1470
    }
1471

1472
    /**
1473
     * Generates a WHERE field IN('item', 'item') SQL query,
1474
     * joined with 'AND' if appropriate.
1475
     *
1476
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1477
     *
1478
     * @return $this
1479
     */
1480
    public function whereIn(?string $key = null, $values = null, ?bool $escape = null)
1481
    {
1482
        return $this->_whereIn($key, $values, false, 'AND ', $escape);
69✔
1483
    }
1484

1485
    /**
1486
     * Generates a WHERE field IN('item', 'item') SQL query,
1487
     * joined with 'OR' if appropriate.
1488
     *
1489
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1490
     *
1491
     * @return $this
1492
     */
1493
    public function orWhereIn(?string $key = null, $values = null, ?bool $escape = null)
1494
    {
1495
        return $this->_whereIn($key, $values, false, 'OR ', $escape);
3✔
1496
    }
1497

1498
    /**
1499
     * Generates a WHERE field NOT IN('item', 'item') SQL query,
1500
     * joined with 'AND' if appropriate.
1501
     *
1502
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1503
     *
1504
     * @return $this
1505
     */
1506
    public function whereNotIn(?string $key = null, $values = null, ?bool $escape = null)
1507
    {
1508
        return $this->_whereIn($key, $values, true, 'AND ', $escape);
3✔
1509
    }
1510

1511
    /**
1512
     * Generates a WHERE field NOT IN('item', 'item') SQL query,
1513
     * joined with 'OR' if appropriate.
1514
     *
1515
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1516
     *
1517
     * @return $this
1518
     */
1519
    public function orWhereNotIn(?string $key = null, $values = null, ?bool $escape = null)
1520
    {
1521
        return $this->_whereIn($key, $values, true, 'OR ', $escape);
2✔
1522
    }
1523

1524
    /**
1525
     * Generates a HAVING field IN('item', 'item') SQL query,
1526
     * joined with 'AND' if appropriate.
1527
     *
1528
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1529
     *
1530
     * @return $this
1531
     */
1532
    public function havingIn(?string $key = null, $values = null, ?bool $escape = null)
1533
    {
1534
        return $this->_whereIn($key, $values, false, 'AND ', $escape, 'QBHaving');
6✔
1535
    }
1536

1537
    /**
1538
     * Generates a HAVING field IN('item', 'item') SQL query,
1539
     * joined with 'OR' if appropriate.
1540
     *
1541
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1542
     *
1543
     * @return $this
1544
     */
1545
    public function orHavingIn(?string $key = null, $values = null, ?bool $escape = null)
1546
    {
1547
        return $this->_whereIn($key, $values, false, 'OR ', $escape, 'QBHaving');
3✔
1548
    }
1549

1550
    /**
1551
     * Generates a HAVING field NOT IN('item', 'item') SQL query,
1552
     * joined with 'AND' if appropriate.
1553
     *
1554
     * @param array|BaseBuilder|(Closure(BaseBuilder):BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1555
     *
1556
     * @return $this
1557
     */
1558
    public function havingNotIn(?string $key = null, $values = null, ?bool $escape = null)
1559
    {
1560
        return $this->_whereIn($key, $values, true, 'AND ', $escape, 'QBHaving');
5✔
1561
    }
1562

1563
    /**
1564
     * Generates a HAVING field NOT IN('item', 'item') SQL query,
1565
     * joined with 'OR' if appropriate.
1566
     *
1567
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1568
     *
1569
     * @return $this
1570
     */
1571
    public function orHavingNotIn(?string $key = null, $values = null, ?bool $escape = null)
1572
    {
1573
        return $this->_whereIn($key, $values, true, 'OR ', $escape, 'QBHaving');
3✔
1574
    }
1575

1576
    /**
1577
     * @used-by WhereIn()
1578
     * @used-by orWhereIn()
1579
     * @used-by whereNotIn()
1580
     * @used-by orWhereNotIn()
1581
     *
1582
     * @param non-empty-string|null                                            $key
1583
     * @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|list<mixed>|null $values The values searched on, or anonymous function with subquery
1584
     *
1585
     * @return $this
1586
     *
1587
     * @throws InvalidArgumentException
1588
     */
1589
    protected function _whereIn(?string $key = null, $values = null, bool $not = false, string $type = 'AND ', ?bool $escape = null, string $clause = 'QBWhere')
1590
    {
1591
        if ($key === null || $key === '') {
89✔
1592
            throw new InvalidArgumentException(sprintf('%s() expects $key to be a non-empty string', debug_backtrace(0, 2)[1]['function']));
2✔
1593
        }
1594

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

1599
        if (! is_bool($escape)) {
84✔
1600
            $escape = $this->db->protectIdentifiers;
84✔
1601
        }
1602

1603
        $ok = $key;
84✔
1604

1605
        if ($escape === true) {
84✔
1606
            $key = $this->db->protectIdentifiers($key);
84✔
1607
        }
1608

1609
        $not = ($not) ? ' NOT' : '';
84✔
1610

1611
        if ($this->isSubquery($values)) {
84✔
1612
            $whereIn = $this->buildSubquery($values, true);
8✔
1613
            $escape  = false;
8✔
1614
        } else {
1615
            $whereIn = array_values($values);
76✔
1616
        }
1617

1618
        $ok = $this->setBind($ok, $whereIn, $escape);
84✔
1619

1620
        $this->addWhereHavingCondition($clause, [
84✔
1621
            'condition' => "{$key}{$not} IN :{$ok}:",
84✔
1622
            'escape'    => false,
84✔
1623
        ], $type);
84✔
1624

1625
        return $this;
84✔
1626
    }
1627

1628
    /**
1629
     * Generates a %LIKE% portion of the query.
1630
     * Separates multiple calls with 'AND'.
1631
     *
1632
     * @param array|RawSql|string $field
1633
     *
1634
     * @return $this
1635
     */
1636
    public function like($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1637
    {
1638
        return $this->_like($field, $match, 'AND ', $side, '', $escape, $insensitiveSearch);
25✔
1639
    }
1640

1641
    /**
1642
     * Generates grouped LIKE portions of the query joined with OR.
1643
     *
1644
     * @param list<non-empty-string|RawSql> $fields
1645
     *
1646
     * @return $this
1647
     *
1648
     * @throws InvalidArgumentException
1649
     */
1650
    public function likeAny(array $fields, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false): static
1651
    {
1652
        return $this->likeAnyGroup($fields, $match, 'AND ', $side, $escape, $insensitiveSearch, __FUNCTION__);
10✔
1653
    }
1654

1655
    /**
1656
     * Generates a NOT LIKE portion of the query.
1657
     * Separates multiple calls with 'AND'.
1658
     *
1659
     * @param array|RawSql|string $field
1660
     *
1661
     * @return $this
1662
     */
1663
    public function notLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1664
    {
1665
        return $this->_like($field, $match, 'AND ', $side, 'NOT', $escape, $insensitiveSearch);
2✔
1666
    }
1667

1668
    /**
1669
     * Generates a %LIKE% portion of the query.
1670
     * Separates multiple calls with 'OR'.
1671
     *
1672
     * @param array|RawSql|string $field
1673
     *
1674
     * @return $this
1675
     */
1676
    public function orLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1677
    {
1678
        return $this->_like($field, $match, 'OR ', $side, '', $escape, $insensitiveSearch);
2✔
1679
    }
1680

1681
    /**
1682
     * Generates grouped LIKE portions of the query joined with OR.
1683
     *
1684
     * @param list<non-empty-string|RawSql> $fields
1685
     *
1686
     * @return $this
1687
     *
1688
     * @throws InvalidArgumentException
1689
     */
1690
    public function orLikeAny(array $fields, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false): static
1691
    {
1692
        return $this->likeAnyGroup($fields, $match, 'OR ', $side, $escape, $insensitiveSearch, __FUNCTION__);
1✔
1693
    }
1694

1695
    /**
1696
     * Generates a NOT LIKE portion of the query.
1697
     * Separates multiple calls with 'OR'.
1698
     *
1699
     * @param array|RawSql|string $field
1700
     *
1701
     * @return $this
1702
     */
1703
    public function orNotLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1704
    {
1705
        return $this->_like($field, $match, 'OR ', $side, 'NOT', $escape, $insensitiveSearch);
2✔
1706
    }
1707

1708
    /**
1709
     * Generates a %LIKE% portion of the query.
1710
     * Separates multiple calls with 'AND'.
1711
     *
1712
     * @param array|RawSql|string $field
1713
     *
1714
     * @return $this
1715
     */
1716
    public function havingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1717
    {
1718
        return $this->_like($field, $match, 'AND ', $side, '', $escape, $insensitiveSearch, 'QBHaving');
15✔
1719
    }
1720

1721
    /**
1722
     * Generates a NOT LIKE portion of the query.
1723
     * Separates multiple calls with 'AND'.
1724
     *
1725
     * @param array|RawSql|string $field
1726
     *
1727
     * @return $this
1728
     */
1729
    public function notHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1730
    {
1731
        return $this->_like($field, $match, 'AND ', $side, 'NOT', $escape, $insensitiveSearch, 'QBHaving');
4✔
1732
    }
1733

1734
    /**
1735
     * Generates a %LIKE% portion of the query.
1736
     * Separates multiple calls with 'OR'.
1737
     *
1738
     * @param array|RawSql|string $field
1739
     *
1740
     * @return $this
1741
     */
1742
    public function orHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1743
    {
1744
        return $this->_like($field, $match, 'OR ', $side, '', $escape, $insensitiveSearch, 'QBHaving');
4✔
1745
    }
1746

1747
    /**
1748
     * Generates a NOT LIKE portion of the query.
1749
     * Separates multiple calls with 'OR'.
1750
     *
1751
     * @param array|RawSql|string $field
1752
     *
1753
     * @return $this
1754
     */
1755
    public function orNotHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1756
    {
1757
        return $this->_like($field, $match, 'OR ', $side, 'NOT', $escape, $insensitiveSearch, 'QBHaving');
4✔
1758
    }
1759

1760
    /**
1761
     * @param list<non-empty-string|RawSql> $fields
1762
     *
1763
     * @return $this
1764
     *
1765
     * @throws InvalidArgumentException
1766
     */
1767
    private function likeAnyGroup(array $fields, string $match, string $type, string $side, ?bool $escape, bool $insensitiveSearch, string $caller): static
1768
    {
1769
        $this->validateLikeAnyFields($fields, $caller);
11✔
1770

1771
        $this->groupStartPrepare('', $type);
6✔
1772

1773
        foreach ($fields as $index => $field) {
6✔
1774
            $this->_like($field, $match, $index === 0 ? 'AND ' : 'OR ', $side, '', $escape, $insensitiveSearch);
6✔
1775
        }
1776

1777
        return $this->groupEndPrepare();
6✔
1778
    }
1779

1780
    /**
1781
     * @param list<non-empty-string|RawSql> $fields
1782
     *
1783
     * @throws InvalidArgumentException
1784
     */
1785
    private function validateLikeAnyFields(array $fields, string $caller): void
1786
    {
1787
        if ($fields === [] || ! array_is_list($fields)) {
11✔
1788
            throw new InvalidArgumentException(sprintf('%s() expects $fields to be a non-empty list of field names', $caller));
2✔
1789
        }
1790

1791
        foreach ($fields as $field) {
9✔
1792
            if ($field instanceof RawSql) {
9✔
1793
                continue;
1✔
1794
            }
1795

1796
            if (! is_string($field) || trim($field) === '') {
9✔
1797
                throw new InvalidArgumentException(sprintf('%s() expects $fields to contain only non-empty strings or RawSql instances', $caller));
3✔
1798
            }
1799
        }
1800
    }
1801

1802
    /**
1803
     * @used-by like()
1804
     * @used-by likeAny()
1805
     * @used-by orLike()
1806
     * @used-by orLikeAny()
1807
     * @used-by notLike()
1808
     * @used-by orNotLike()
1809
     * @used-by havingLike()
1810
     * @used-by orHavingLike()
1811
     * @used-by notHavingLike()
1812
     * @used-by orNotHavingLike()
1813
     *
1814
     * @param array<string, string>|RawSql|string $field
1815
     *
1816
     * @return $this
1817
     */
1818
    protected function _like($field, string $match = '', string $type = 'AND ', string $side = 'both', string $not = '', ?bool $escape = null, bool $insensitiveSearch = false, string $clause = 'QBWhere')
1819
    {
1820
        $escape = is_bool($escape) ? $escape : $this->db->protectIdentifiers;
53✔
1821
        $side   = strtolower($side);
53✔
1822

1823
        if ($field instanceof RawSql) {
53✔
1824
            $k                 = (string) $field;
4✔
1825
            $v                 = $match;
4✔
1826
            $insensitiveSearch = false;
4✔
1827

1828
            $prefix = $this->getWhereHavingPrefix($clause, $type);
4✔
1829

1830
            if ($side === 'none') {
4✔
1831
                $bind = $this->setBind($field->getBindingKey(), $v, $escape);
×
1832
            } elseif ($side === 'before') {
4✔
1833
                $bind = $this->setBind($field->getBindingKey(), "%{$v}", $escape);
×
1834
            } elseif ($side === 'after') {
4✔
1835
                $bind = $this->setBind($field->getBindingKey(), "{$v}%", $escape);
1✔
1836
            } else {
1837
                $bind = $this->setBind($field->getBindingKey(), "%{$v}%", $escape);
3✔
1838
            }
1839

1840
            $likeStatement = $this->_like_statement($prefix, $k, $not, $bind, $insensitiveSearch);
4✔
1841

1842
            // some platforms require an escape sequence definition for LIKE wildcards
1843
            if ($escape === true && $this->db->likeEscapeStr !== '') {
4✔
1844
                $likeStatement .= sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar);
4✔
1845
            }
1846

1847
            $this->{$clause}[] = [
4✔
1848
                'condition' => $field->with($likeStatement),
4✔
1849
                'escape'    => $escape,
4✔
1850
            ];
4✔
1851

1852
            return $this;
4✔
1853
        }
1854

1855
        $keyValue = is_array($field) ? $field : [$field => $match];
50✔
1856

1857
        foreach ($keyValue as $k => $v) {
50✔
1858
            if ($insensitiveSearch) {
50✔
1859
                $v = mb_strtolower($v, 'UTF-8');
9✔
1860
            }
1861

1862
            $prefix = $this->getWhereHavingPrefix($clause, $type);
50✔
1863

1864
            if ($side === 'none') {
50✔
1865
                $bind = $this->setBind($k, $v, $escape);
1✔
1866
            } elseif ($side === 'before') {
49✔
1867
                $bind = $this->setBind($k, "%{$v}", $escape);
9✔
1868
            } elseif ($side === 'after') {
40✔
1869
                $bind = $this->setBind($k, "{$v}%", $escape);
6✔
1870
            } else {
1871
                $bind = $this->setBind($k, "%{$v}%", $escape);
34✔
1872
            }
1873

1874
            $likeStatement = $this->_like_statement($prefix, $k, $not, $bind, $insensitiveSearch);
50✔
1875

1876
            // some platforms require an escape sequence definition for LIKE wildcards
1877
            if ($escape === true && $this->db->likeEscapeStr !== '') {
50✔
1878
                $likeStatement .= sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar);
50✔
1879
            }
1880

1881
            $this->{$clause}[] = [
50✔
1882
                'condition' => $likeStatement,
50✔
1883
                'escape'    => $escape,
50✔
1884
            ];
50✔
1885
        }
1886

1887
        return $this;
50✔
1888
    }
1889

1890
    /**
1891
     * Platform independent LIKE statement builder.
1892
     */
1893
    protected function _like_statement(?string $prefix, string $column, ?string $not, string $bind, bool $insensitiveSearch = false): string
1894
    {
1895
        if ($insensitiveSearch) {
53✔
1896
            return "{$prefix} LOWER(" . $this->db->escapeIdentifiers($column) . ") {$not} LIKE :{$bind}:";
9✔
1897
        }
1898

1899
        return "{$prefix} {$column} {$not} LIKE :{$bind}:";
44✔
1900
    }
1901

1902
    /**
1903
     * Add UNION statement
1904
     *
1905
     * @param BaseBuilder|Closure(BaseBuilder): BaseBuilder $union
1906
     *
1907
     * @return $this
1908
     */
1909
    public function union($union)
1910
    {
1911
        return $this->addUnionStatement($union);
8✔
1912
    }
1913

1914
    /**
1915
     * Add UNION ALL statement
1916
     *
1917
     * @param BaseBuilder|Closure(BaseBuilder): BaseBuilder $union
1918
     *
1919
     * @return $this
1920
     */
1921
    public function unionAll($union)
1922
    {
1923
        return $this->addUnionStatement($union, true);
2✔
1924
    }
1925

1926
    /**
1927
     * @used-by union()
1928
     * @used-by unionAll()
1929
     *
1930
     * @param BaseBuilder|Closure(BaseBuilder): BaseBuilder $union
1931
     *
1932
     * @return $this
1933
     */
1934
    protected function addUnionStatement($union, bool $all = false)
1935
    {
1936
        $this->QBUnion[] = "\nUNION "
10✔
1937
            . ($all ? 'ALL ' : '')
10✔
1938
            . 'SELECT * FROM '
10✔
1939
            . $this->buildSubquery($union, true, 'uwrp' . (count($this->QBUnion) + 1));
10✔
1940

1941
        return $this;
10✔
1942
    }
1943

1944
    /**
1945
     * Starts a query group.
1946
     *
1947
     * @return $this
1948
     */
1949
    public function groupStart()
1950
    {
1951
        return $this->groupStartPrepare();
6✔
1952
    }
1953

1954
    /**
1955
     * Starts a query group, but ORs the group
1956
     *
1957
     * @return $this
1958
     */
1959
    public function orGroupStart()
1960
    {
1961
        return $this->groupStartPrepare('', 'OR ');
2✔
1962
    }
1963

1964
    /**
1965
     * Starts a query group, but NOTs the group
1966
     *
1967
     * @return $this
1968
     */
1969
    public function notGroupStart()
1970
    {
1971
        return $this->groupStartPrepare('NOT ');
2✔
1972
    }
1973

1974
    /**
1975
     * Starts a query group, but OR NOTs the group
1976
     *
1977
     * @return $this
1978
     */
1979
    public function orNotGroupStart()
1980
    {
1981
        return $this->groupStartPrepare('NOT ', 'OR ');
2✔
1982
    }
1983

1984
    /**
1985
     * Ends a query group
1986
     *
1987
     * @return $this
1988
     */
1989
    public function groupEnd()
1990
    {
1991
        return $this->groupEndPrepare();
12✔
1992
    }
1993

1994
    /**
1995
     * Starts a query group for HAVING clause.
1996
     *
1997
     * @return $this
1998
     */
1999
    public function havingGroupStart()
2000
    {
2001
        return $this->groupStartPrepare('', 'AND ', 'QBHaving');
3✔
2002
    }
2003

2004
    /**
2005
     * Starts a query group for HAVING clause, but ORs the group.
2006
     *
2007
     * @return $this
2008
     */
2009
    public function orHavingGroupStart()
2010
    {
2011
        return $this->groupStartPrepare('', 'OR ', 'QBHaving');
2✔
2012
    }
2013

2014
    /**
2015
     * Starts a query group for HAVING clause, but NOTs the group.
2016
     *
2017
     * @return $this
2018
     */
2019
    public function notHavingGroupStart()
2020
    {
2021
        return $this->groupStartPrepare('NOT ', 'AND ', 'QBHaving');
2✔
2022
    }
2023

2024
    /**
2025
     * Starts a query group for HAVING clause, but OR NOTs the group.
2026
     *
2027
     * @return $this
2028
     */
2029
    public function orNotHavingGroupStart()
2030
    {
2031
        return $this->groupStartPrepare('NOT ', 'OR ', 'QBHaving');
2✔
2032
    }
2033

2034
    /**
2035
     * Ends a query group for HAVING clause.
2036
     *
2037
     * @return $this
2038
     */
2039
    public function havingGroupEnd()
2040
    {
2041
        return $this->groupEndPrepare('QBHaving');
9✔
2042
    }
2043

2044
    /**
2045
     * Prepate a query group start.
2046
     *
2047
     * @return $this
2048
     */
2049
    protected function groupStartPrepare(string $not = '', string $type = 'AND ', string $clause = 'QBWhere')
2050
    {
2051
        $type = $this->groupGetType($type);
27✔
2052

2053
        $this->QBWhereGroupStarted = true;
27✔
2054
        $prefix                    = empty($this->{$clause}) ? '' : $type;
27✔
2055
        $where                     = [
27✔
2056
            'condition' => $prefix . $not . str_repeat(' ', ++$this->QBWhereGroupCount) . ' (',
27✔
2057
            'escape'    => false,
27✔
2058
        ];
27✔
2059

2060
        $this->{$clause}[] = $where;
27✔
2061

2062
        return $this;
27✔
2063
    }
2064

2065
    /**
2066
     * Prepate a query group end.
2067
     *
2068
     * @return $this
2069
     */
2070
    protected function groupEndPrepare(string $clause = 'QBWhere')
2071
    {
2072
        $this->QBWhereGroupStarted = false;
27✔
2073
        $where                     = [
27✔
2074
            'condition' => str_repeat(' ', $this->QBWhereGroupCount--) . ')',
27✔
2075
            'escape'    => false,
27✔
2076
        ];
27✔
2077

2078
        $this->{$clause}[] = $where;
27✔
2079

2080
        return $this;
27✔
2081
    }
2082

2083
    /**
2084
     * @used-by groupStart()
2085
     * @used-by _like()
2086
     * @used-by whereHaving()
2087
     * @used-by _whereIn()
2088
     * @used-by whereColumnHaving()
2089
     * @used-by havingGroupStart()
2090
     */
2091
    protected function groupGetType(string $type): string
2092
    {
2093
        if ($this->QBWhereGroupStarted) {
1,105✔
2094
            $type                      = '';
27✔
2095
            $this->QBWhereGroupStarted = false;
27✔
2096
        }
2097

2098
        return $type;
1,105✔
2099
    }
2100

2101
    /**
2102
     * @param array|string $by
2103
     *
2104
     * @return $this
2105
     */
2106
    public function groupBy($by, ?bool $escape = null)
2107
    {
2108
        if (! is_bool($escape)) {
65✔
2109
            $escape = $this->db->protectIdentifiers;
65✔
2110
        }
2111

2112
        if (is_string($by)) {
65✔
2113
            $by = ($escape === true) ? explode(',', $by) : [$by];
65✔
2114
        }
2115

2116
        foreach ($by as $val) {
65✔
2117
            $val = trim($val);
65✔
2118

2119
            if ($val !== '') {
65✔
2120
                $val = [
65✔
2121
                    'field'  => $val,
65✔
2122
                    'escape' => $escape,
65✔
2123
                ];
65✔
2124

2125
                $this->QBGroupBy[] = $val;
65✔
2126
            }
2127
        }
2128

2129
        return $this;
65✔
2130
    }
2131

2132
    /**
2133
     * Separates multiple calls with 'AND'.
2134
     *
2135
     * @param array|RawSql|string $key
2136
     * @param mixed               $value
2137
     *
2138
     * @return $this
2139
     */
2140
    public function having($key, $value = null, ?bool $escape = null)
2141
    {
2142
        return $this->whereHaving('QBHaving', $key, $value, 'AND ', $escape);
22✔
2143
    }
2144

2145
    /**
2146
     * Separates multiple calls with 'OR'.
2147
     *
2148
     * @param array|RawSql|string $key
2149
     * @param mixed               $value
2150
     *
2151
     * @return $this
2152
     */
2153
    public function orHaving($key, $value = null, ?bool $escape = null)
2154
    {
2155
        return $this->whereHaving('QBHaving', $key, $value, 'OR ', $escape);
2✔
2156
    }
2157

2158
    /**
2159
     * @param string $direction ASC, DESC or RANDOM
2160
     *
2161
     * @return $this
2162
     */
2163
    public function orderBy(string $orderBy, string $direction = '', ?bool $escape = null)
2164
    {
2165
        if ($orderBy === '') {
899✔
2166
            return $this;
×
2167
        }
2168

2169
        $qbOrderBy = [];
899✔
2170

2171
        $direction = strtoupper(trim($direction));
899✔
2172

2173
        if ($direction === 'RANDOM') {
899✔
2174
            $direction = '';
3✔
2175
            $orderBy   = ctype_digit($orderBy) ? sprintf($this->randomKeyword[1], $orderBy) : $this->randomKeyword[0];
3✔
2176
            $escape    = false;
3✔
2177
        } elseif ($direction !== '') {
897✔
2178
            $direction = in_array($direction, ['ASC', 'DESC'], true) ? ' ' . $direction : '';
897✔
2179
        }
2180

2181
        if ($escape === null) {
899✔
2182
            $escape = $this->db->protectIdentifiers;
897✔
2183
        }
2184

2185
        if ($escape === false) {
899✔
2186
            $qbOrderBy[] = [
3✔
2187
                'field'     => $orderBy,
3✔
2188
                'direction' => $direction,
3✔
2189
                'escape'    => false,
3✔
2190
            ];
3✔
2191
        } else {
2192
            foreach (explode(',', $orderBy) as $field) {
897✔
2193
                $qbOrderBy[] = ($direction === '' && preg_match('/\s+(ASC|DESC)$/i', rtrim($field), $match, PREG_OFFSET_CAPTURE))
897✔
2194
                    ? [
×
2195
                        'field'     => ltrim(substr($field, 0, $match[0][1])),
×
2196
                        'direction' => ' ' . $match[1][0],
×
2197
                        'escape'    => true,
×
2198
                    ]
×
2199
                    : [
897✔
2200
                        'field'     => trim($field),
897✔
2201
                        'direction' => $direction,
897✔
2202
                        'escape'    => true,
897✔
2203
                    ];
897✔
2204
            }
2205
        }
2206

2207
        $this->QBOrderBy = array_merge($this->QBOrderBy, $qbOrderBy);
899✔
2208

2209
        return $this;
899✔
2210
    }
2211

2212
    /**
2213
     * @return $this
2214
     */
2215
    public function limit(?int $value = null, ?int $offset = 0)
2216
    {
2217
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
130✔
2218
        if ($limitZeroAsAll && $value === 0) {
130✔
2219
            $value = null;
13✔
2220
        }
2221

2222
        if ($value !== null) {
130✔
2223
            $this->QBLimit = $value;
118✔
2224
        }
2225

2226
        if ($offset !== null && $offset !== 0) {
130✔
2227
            $this->QBOffset = $offset;
12✔
2228
        }
2229

2230
        return $this;
130✔
2231
    }
2232

2233
    /**
2234
     * Locks the selected rows for update.
2235
     */
2236
    public function lockForUpdate(): static
2237
    {
2238
        $this->QBLockForUpdate = true;
29✔
2239

2240
        return $this;
29✔
2241
    }
2242

2243
    /**
2244
     * Sets the OFFSET value
2245
     *
2246
     * @return $this
2247
     */
2248
    public function offset(int $offset)
2249
    {
2250
        if ($offset !== 0) {
1✔
2251
            $this->QBOffset = $offset;
1✔
2252
        }
2253

2254
        return $this;
1✔
2255
    }
2256

2257
    /**
2258
     * Generates a platform-specific LIMIT clause.
2259
     */
2260
    protected function _limit(string $sql, bool $offsetIgnore = false): string
2261
    {
2262
        return $sql . ' LIMIT ' . ($offsetIgnore === false && $this->QBOffset ? $this->QBOffset . ', ' : '') . $this->QBLimit;
133✔
2263
    }
2264

2265
    /**
2266
     * Allows key/value pairs to be set for insert(), update() or replace().
2267
     *
2268
     * @param array|object|string $key    Field name, or an array of field/value pairs, or an object
2269
     * @param mixed               $value  Field value, if $key is a single field
2270
     * @param bool|null           $escape Whether to escape values
2271
     *
2272
     * @return $this
2273
     */
2274
    public function set($key, $value = '', ?bool $escape = null)
2275
    {
2276
        $key = $this->objectToArray($key);
914✔
2277

2278
        if (! is_array($key)) {
914✔
2279
            $key = [$key => $value];
122✔
2280
        }
2281

2282
        $escape = is_bool($escape) ? $escape : $this->db->protectIdentifiers;
914✔
2283

2284
        foreach ($key as $k => $v) {
914✔
2285
            if ($escape) {
914✔
2286
                $bind = $this->setBind($k, $v, $escape);
913✔
2287

2288
                $this->QBSet[$this->db->protectIdentifiers($k, false)] = ":{$bind}:";
913✔
2289
            } else {
2290
                $this->QBSet[$this->db->protectIdentifiers($k, false)] = $v;
10✔
2291
            }
2292
        }
2293

2294
        return $this;
914✔
2295
    }
2296

2297
    /**
2298
     * Returns the previously set() data, alternatively resetting it if needed.
2299
     */
2300
    public function getSetData(bool $clean = false): array
2301
    {
2302
        $data = $this->QBSet;
×
2303

2304
        if ($clean) {
×
2305
            $this->QBSet = [];
×
2306
        }
2307

2308
        return $data;
×
2309
    }
2310

2311
    /**
2312
     * Compiles a SELECT query string and returns the sql.
2313
     */
2314
    public function getCompiledSelect(bool $reset = true): string
2315
    {
2316
        $select = $this->compileSelect();
306✔
2317

2318
        if ($reset) {
295✔
2319
            $this->resetSelect();
274✔
2320
        }
2321

2322
        return $this->compileFinalQuery($select);
295✔
2323
    }
2324

2325
    /**
2326
     * Returns a finalized, compiled query string with the bindings
2327
     * inserted and prefixes swapped out.
2328
     */
2329
    protected function compileFinalQuery(string $sql): string
2330
    {
2331
        $query = new Query($this->db);
326✔
2332
        $query->setQuery($sql, $this->binds, false);
326✔
2333

2334
        if (! empty($this->db->swapPre) && ! empty($this->db->DBPrefix)) {
326✔
2335
            $query->swapPrefix($this->db->DBPrefix, $this->db->swapPre);
×
2336
        }
2337

2338
        return $query->getQuery();
326✔
2339
    }
2340

2341
    /**
2342
     * Compiles the select statement based on the other functions called
2343
     * and runs the query
2344
     *
2345
     * @return false|ResultInterface
2346
     */
2347
    public function get(?int $limit = null, int $offset = 0, bool $reset = true)
2348
    {
2349
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
923✔
2350
        if ($limitZeroAsAll && $limit === 0) {
923✔
2351
            $limit = null;
1✔
2352
        }
2353

2354
        if ($limit !== null) {
923✔
2355
            $this->limit($limit, $offset);
8✔
2356
        }
2357

2358
        $result = $this->testMode
923✔
2359
            ? $this->getCompiledSelect($reset)
2✔
2360
            : $this->db->query($this->compileSelect(), $this->binds, false);
921✔
2361

2362
        if ($reset) {
923✔
2363
            $this->resetSelect();
923✔
2364

2365
            // Clear our binds so we don't eat up memory
2366
            $this->binds = [];
923✔
2367
        }
2368

2369
        return $result;
923✔
2370
    }
2371

2372
    /**
2373
     * Explains the select statement based on the other functions called
2374
     * and runs the query.
2375
     *
2376
     * @return BaseResult|false|Query|string SQL string when test mode is enabled.
2377
     */
2378
    public function explain(bool $reset = true)
2379
    {
2380
        $this->assertExplainSupported();
12✔
2381

2382
        $sql = $this->compileExplain($this->compileSelect());
8✔
2383

2384
        $result = $this->testMode
8✔
2385
            ? $this->compileFinalQuery($sql)
6✔
2386
            : $this->db->query($sql, $this->binds, false);
2✔
2387

2388
        if ($reset) {
8✔
2389
            $this->resetSelect();
5✔
2390

2391
            // Clear our binds so we don't eat up memory
2392
            $this->binds = [];
5✔
2393
        }
2394

2395
        return $result;
8✔
2396
    }
2397

2398
    /**
2399
     * Ensures the current driver supports explaining Query Builder selects.
2400
     */
2401
    protected function assertExplainSupported(): void
2402
    {
2403
    }
8✔
2404

2405
    /**
2406
     * Compiles an execution-plan query for the current SELECT query.
2407
     */
2408
    protected function compileExplain(string $sql): string
2409
    {
2410
        return 'EXPLAIN ' . $sql;
7✔
2411
    }
2412

2413
    /**
2414
     * Generates a platform-specific query string that counts all records in
2415
     * the particular table
2416
     *
2417
     * @return int|string
2418
     */
2419
    public function countAll(bool $reset = true)
2420
    {
2421
        $table = $this->QBFrom[0];
6✔
2422

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

2426
        if ($this->testMode) {
6✔
2427
            return $sql;
1✔
2428
        }
2429

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

2432
        if (empty($query->getResult())) {
5✔
2433
            return 0;
×
2434
        }
2435

2436
        $query = $query->getRow();
5✔
2437

2438
        if ($reset) {
5✔
2439
            $this->resetSelect();
5✔
2440
        }
2441

2442
        return (int) $query->numrows;
5✔
2443
    }
2444

2445
    /**
2446
     * Determines whether the current Query Builder query would return at least one row.
2447
     *
2448
     * @return bool|string SQL string when test mode is enabled.
2449
     */
2450
    public function exists(bool $reset = true)
2451
    {
2452
        $exists = $this->doExists($reset);
16✔
2453

2454
        return $exists ?? false;
16✔
2455
    }
2456

2457
    /**
2458
     * Determines whether the current Query Builder query would not return any rows.
2459
     *
2460
     * @return bool|string SQL string when test mode is enabled.
2461
     */
2462
    public function doesntExist(bool $reset = true)
2463
    {
2464
        $exists = $this->doExists($reset);
5✔
2465

2466
        return is_string($exists) ? $exists : $exists === false;
5✔
2467
    }
2468

2469
    /**
2470
     * Runs an existence probe for the current Query Builder query.
2471
     *
2472
     * @return bool|string|null SQL string when test mode is enabled, or null when the query fails.
2473
     */
2474
    protected function doExists(bool $reset = true)
2475
    {
2476
        $sql = $this->compileExists();
20✔
2477

2478
        if ($this->testMode) {
20✔
2479
            if ($reset) {
11✔
2480
                $this->resetSelect();
1✔
2481

2482
                // Clear our binds so we don't eat up memory
2483
                $this->binds = [];
1✔
2484
            }
2485

2486
            return $sql;
11✔
2487
        }
2488

2489
        $result = $this->db->query($sql, $this->binds, false);
9✔
2490

2491
        if ($reset) {
9✔
2492
            $this->resetSelect();
9✔
2493

2494
            // Clear our binds so we don't eat up memory
2495
            $this->binds = [];
9✔
2496
        }
2497

2498
        return $result instanceof ResultInterface ? $result->getRow() !== null : null;
9✔
2499
    }
2500

2501
    /**
2502
     * Compiles an existence probe for the current Query Builder query.
2503
     */
2504
    protected function compileExists(): string
2505
    {
2506
        // ORDER BY and FOR UPDATE are unnecessary for checking row existence,
2507
        // and can produce invalid or surprising SQL on some drivers.
2508
        $orderBy       = $this->QBOrderBy;
20✔
2509
        $limit         = $this->QBLimit;
20✔
2510
        $offset        = $this->QBOffset;
20✔
2511
        $lockForUpdate = $this->QBLockForUpdate;
20✔
2512
        $select        = $this->QBSelect;
20✔
2513
        $noEscape      = $this->QBNoEscape;
20✔
2514
        $needsSubquery = $this->QBSelectUsesAggregate || $this->QBUnion !== [] || $this->QBGroupBy !== [] || $this->QBHaving !== [] || $this->QBOffset !== false;
20✔
2515

2516
        $this->QBOrderBy       = null;
20✔
2517
        $this->QBLockForUpdate = false;
20✔
2518

2519
        if (! $needsSubquery && $this->QBLimit !== 0) {
20✔
2520
            $this->QBLimit = 1;
14✔
2521
        }
2522

2523
        try {
2524
            if ($needsSubquery) {
20✔
2525
                $sql = "SELECT 1 FROM (\n" . $this->compileSelect() . "\n) CI_exists";
5✔
2526

2527
                $this->QBLimit  = 1;
5✔
2528
                $this->QBOffset = false;
5✔
2529

2530
                return $this->_limit($sql . "\n");
5✔
2531
            }
2532

2533
            return $this->compileSelect('SELECT 1');
15✔
2534
        } finally {
2535
            $this->QBOrderBy       = $orderBy;
20✔
2536
            $this->QBLimit         = $limit;
20✔
2537
            $this->QBOffset        = $offset;
20✔
2538
            $this->QBLockForUpdate = $lockForUpdate;
20✔
2539
            $this->QBSelect        = $select;
20✔
2540
            $this->QBNoEscape      = $noEscape;
20✔
2541
        }
2542
    }
2543

2544
    /**
2545
     * Generates a platform-specific query string that counts all records
2546
     * returned by an Query Builder query.
2547
     *
2548
     * @return int|string
2549
     */
2550
    public function countAllResults(bool $reset = true)
2551
    {
2552
        // ORDER BY usage is often problematic here (most notably
2553
        // on Microsoft SQL Server) and ultimately unnecessary
2554
        // for selecting COUNT(*) ...
2555
        $orderBy = [];
242✔
2556

2557
        if (! empty($this->QBOrderBy)) {
242✔
2558
            $orderBy = $this->QBOrderBy;
×
2559

2560
            $this->QBOrderBy = null;
×
2561
        }
2562

2563
        // We cannot use a LIMIT when getting the single row COUNT(*) result
2564
        $limit         = $this->QBLimit;
242✔
2565
        $lockForUpdate = $this->QBLockForUpdate;
242✔
2566

2567
        $this->QBLimit         = false;
242✔
2568
        $this->QBLockForUpdate = false;
242✔
2569

2570
        try {
2571
            if ($this->QBDistinct === true || ! empty($this->QBGroupBy)) {
242✔
2572
                // We need to backup the original SELECT in case DBPrefix is used
2573
                $select = $this->QBSelect;
4✔
2574
                $sql    = $this->countString . $this->db->protectIdentifiers('numrows') . "\nFROM (\n" . $this->compileSelect() . "\n) CI_count_all_results";
4✔
2575

2576
                // Restore SELECT part
2577
                $this->QBSelect = $select;
4✔
2578
                unset($select);
4✔
2579
            } else {
2580
                $sql = $this->compileSelect($this->countString . $this->db->protectIdentifiers('numrows'));
238✔
2581
            }
2582
        } finally {
2583
            $this->QBLockForUpdate = $lockForUpdate;
242✔
2584
        }
2585

2586
        if ($this->testMode) {
242✔
2587
            return $sql;
11✔
2588
        }
2589

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

2592
        if ($reset) {
235✔
2593
            $this->resetSelect();
219✔
2594
        } elseif (! isset($this->QBOrderBy)) {
22✔
2595
            $this->QBOrderBy = $orderBy;
×
2596
        }
2597

2598
        // Restore the LIMIT setting
2599
        $this->QBLimit = $limit;
235✔
2600

2601
        $row = $result instanceof ResultInterface ? $result->getRow() : null;
235✔
2602

2603
        if (empty($row)) {
235✔
2604
            return 0;
×
2605
        }
2606

2607
        return (int) $row->numrows;
235✔
2608
    }
2609

2610
    /**
2611
     * Compiles the set conditions and returns the sql statement
2612
     *
2613
     * @return array
2614
     */
2615
    public function getCompiledQBWhere()
2616
    {
2617
        return $this->QBWhere;
65✔
2618
    }
2619

2620
    /**
2621
     * Allows the where clause, limit and offset to be added directly
2622
     *
2623
     * @param array|string $where
2624
     *
2625
     * @return ResultInterface
2626
     */
2627
    public function getWhere($where = null, ?int $limit = null, ?int $offset = 0, bool $reset = true)
2628
    {
2629
        if ($where !== null) {
17✔
2630
            $this->where($where);
16✔
2631
        }
2632

2633
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
17✔
2634
        if ($limitZeroAsAll && $limit === 0) {
17✔
2635
            $limit = null;
×
2636
        }
2637

2638
        if ($limit !== null) {
17✔
2639
            $this->limit($limit, $offset);
3✔
2640
        }
2641

2642
        $result = $this->testMode
17✔
2643
            ? $this->getCompiledSelect($reset)
4✔
2644
            : $this->db->query($this->compileSelect(), $this->binds, false);
13✔
2645

2646
        if ($reset) {
17✔
2647
            $this->resetSelect();
17✔
2648

2649
            // Clear our binds so we don't eat up memory
2650
            $this->binds = [];
17✔
2651
        }
2652

2653
        return $result;
17✔
2654
    }
2655

2656
    /**
2657
     * Compiles batch insert/update/upsert strings and runs the queries
2658
     *
2659
     * @param '_deleteBatch'|'_insertBatch'|'_updateBatch'|'_upsertBatch' $renderMethod
2660
     *
2661
     * @return false|int|list<string> Number of rows inserted or FALSE on failure, SQL array when testMode
2662
     *
2663
     * @throws DatabaseException
2664
     */
2665
    protected function batchExecute(string $renderMethod, int $batchSize = 100)
2666
    {
2667
        if (empty($this->QBSet)) {
73✔
2668
            if ($this->db->DBDebug) {
5✔
2669
                throw new DatabaseException(trim($renderMethod, '_') . '() has no data.');
5✔
2670
            }
2671

2672
            return false; // @codeCoverageIgnore
2673
        }
2674

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

2677
        $affectedRows = 0;
68✔
2678
        $savedSQL     = [];
68✔
2679
        $cnt          = count($this->QBSet);
68✔
2680

2681
        // batch size 0 for unlimited
2682
        if ($batchSize === 0) {
68✔
2683
            $batchSize = $cnt;
×
2684
        }
2685

2686
        for ($i = 0, $total = $cnt; $i < $total; $i += $batchSize) {
68✔
2687
            $QBSet = array_slice($this->QBSet, $i, $batchSize);
68✔
2688

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

2691
            if ($sql === '') {
65✔
2692
                return false; // @codeCoverageIgnore
2693
            }
2694

2695
            if ($this->testMode) {
65✔
2696
                $savedSQL[] = $sql;
3✔
2697
            } else {
2698
                $this->db->query($sql, null, false);
62✔
2699
                $affectedRows += $this->db->affectedRows();
60✔
2700
            }
2701
        }
2702

2703
        if (! $this->testMode) {
63✔
2704
            $this->resetWrite();
60✔
2705
        }
2706

2707
        return $this->testMode ? $savedSQL : $affectedRows;
63✔
2708
    }
2709

2710
    /**
2711
     * Allows a row or multiple rows to be set for batch inserts/upserts/updates
2712
     *
2713
     * @param array|object $set
2714
     * @param string       $alias alias for sql table
2715
     *
2716
     * @return $this|null
2717
     */
2718
    public function setData($set, ?bool $escape = null, string $alias = '')
2719
    {
2720
        if (empty($set)) {
68✔
2721
            if ($this->db->DBDebug) {
×
2722
                throw new DatabaseException('setData() has no data.');
×
2723
            }
2724

2725
            return null; // @codeCoverageIgnore
2726
        }
2727

2728
        $this->setAlias($alias);
68✔
2729

2730
        // this allows to set just one row at a time
2731
        if (is_object($set) || (! is_array(current($set)) && ! is_object(current($set)))) {
68✔
2732
            $set = [$set];
11✔
2733
        }
2734

2735
        $set = $this->batchObjectToArray($set);
68✔
2736

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

2739
        $keys = array_keys($this->objectToArray(current($set)));
68✔
2740
        sort($keys);
68✔
2741

2742
        foreach ($set as $row) {
68✔
2743
            $row = $this->objectToArray($row);
68✔
2744
            if (array_diff($keys, array_keys($row)) !== [] || array_diff(array_keys($row), $keys) !== []) {
68✔
2745
                // batchExecute() function returns an error on an empty array
2746
                $this->QBSet[] = [];
×
2747

2748
                return null;
×
2749
            }
2750

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

2753
            $clean = [];
68✔
2754

2755
            foreach ($row as $rowValue) {
68✔
2756
                $clean[] = $escape ? $this->db->escape($rowValue) : $rowValue;
68✔
2757
            }
2758

2759
            $row = $clean;
68✔
2760

2761
            $this->QBSet[] = $row;
68✔
2762
        }
2763

2764
        foreach ($keys as $k) {
68✔
2765
            $k = $this->db->protectIdentifiers($k, false);
68✔
2766

2767
            if (! in_array($k, $this->QBKeys, true)) {
68✔
2768
                $this->QBKeys[] = $k;
68✔
2769
            }
2770
        }
2771

2772
        return $this;
68✔
2773
    }
2774

2775
    /**
2776
     * Compiles an upsert query and returns the sql
2777
     *
2778
     * @return string
2779
     *
2780
     * @throws DatabaseException
2781
     */
2782
    public function getCompiledUpsert()
2783
    {
2784
        [$currentTestMode, $this->testMode] = [$this->testMode, true];
3✔
2785

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

2788
        $this->testMode = $currentTestMode;
3✔
2789

2790
        return $this->compileFinalQuery($sql);
3✔
2791
    }
2792

2793
    /**
2794
     * Converts call to batchUpsert
2795
     *
2796
     * @param array|object|null $set
2797
     *
2798
     * @return false|int|list<string> Number of affected rows or FALSE on failure, SQL array when testMode
2799
     *
2800
     * @throws DatabaseException
2801
     */
2802
    public function upsert($set = null, ?bool $escape = null)
2803
    {
2804
        // if set() has been used merge QBSet with binds and then setData()
2805
        if ($set === null && ! is_array(current($this->QBSet))) {
10✔
2806
            $set = [];
2✔
2807

2808
            foreach ($this->QBSet as $field => $value) {
2✔
2809
                $k = trim($field, $this->db->escapeChar);
2✔
2810
                // use binds if available else use QBSet value but with RawSql to avoid escape
2811
                $set[$k] = isset($this->binds[$k]) ? $this->binds[$k][0] : new RawSql($value);
2✔
2812
            }
2813

2814
            $this->binds = [];
2✔
2815

2816
            $this->resetRun([
2✔
2817
                'QBSet'  => [],
2✔
2818
                'QBKeys' => [],
2✔
2819
            ]);
2✔
2820

2821
            $this->setData($set, true); // unescaped items are RawSql now
2✔
2822
        } elseif ($set !== null) {
8✔
2823
            $this->setData($set, $escape);
7✔
2824
        } // else setData() has already been used and we need to do nothing
2825

2826
        return $this->batchExecute('_upsertBatch');
10✔
2827
    }
2828

2829
    /**
2830
     * Compiles batch upsert strings and runs the queries
2831
     *
2832
     * @param array|object|null $set a dataset
2833
     *
2834
     * @return false|int|list<string> Number of affected rows or FALSE on failure, SQL array when testMode
2835
     *
2836
     * @throws DatabaseException
2837
     */
2838
    public function upsertBatch($set = null, ?bool $escape = null, int $batchSize = 100)
2839
    {
2840
        if (isset($this->QBOptions['setQueryAsData'])) {
12✔
2841
            $sql = $this->_upsertBatch($this->QBFrom[0], $this->QBKeys, []);
1✔
2842

2843
            if ($sql === '') {
1✔
2844
                return false; // @codeCoverageIgnore
2845
            }
2846

2847
            if ($this->testMode === false) {
1✔
2848
                $this->db->query($sql, null, false);
1✔
2849
            }
2850

2851
            $this->resetWrite();
1✔
2852

2853
            return $this->testMode ? $sql : $this->db->affectedRows();
1✔
2854
        }
2855

2856
        if ($set !== null) {
11✔
2857
            $this->setData($set, $escape);
9✔
2858
        }
2859

2860
        return $this->batchExecute('_upsertBatch', $batchSize);
11✔
2861
    }
2862

2863
    /**
2864
     * Generates a platform-specific upsertBatch string from the supplied data
2865
     *
2866
     * @used-by batchExecute()
2867
     *
2868
     * @param string                 $table  Protected table name
2869
     * @param list<string>           $keys   QBKeys
2870
     * @param list<list<int|string>> $values QBSet
2871
     */
2872
    protected function _upsertBatch(string $table, array $keys, array $values): string
2873
    {
2874
        $sql = $this->QBOptions['sql'] ?? '';
19✔
2875

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

2880
            $sql = 'INSERT INTO ' . $table . ' (' . implode(', ', $keys) . ")\n{:_table_:}ON DUPLICATE KEY UPDATE\n" . implode(
19✔
2881
                ",\n",
19✔
2882
                array_map(
19✔
2883
                    static fn ($key, $value): string => $table . '.' . $key . ($value instanceof RawSql ?
19✔
2884
                        ' = ' . $value :
2✔
2885
                        ' = VALUES(' . $value . ')'),
19✔
2886
                    array_keys($updateFields),
19✔
2887
                    $updateFields,
19✔
2888
                ),
19✔
2889
            );
19✔
2890

2891
            $this->QBOptions['sql'] = $sql;
19✔
2892
        }
2893

2894
        if (isset($this->QBOptions['setQueryAsData'])) {
19✔
2895
            $data = $this->QBOptions['setQueryAsData'] . "\n";
1✔
2896
        } else {
2897
            $data = 'VALUES ' . implode(', ', $this->formatValues($values)) . "\n";
18✔
2898
        }
2899

2900
        return str_replace('{:_table_:}', $data, $sql);
19✔
2901
    }
2902

2903
    /**
2904
     * Set table alias for dataset pseudo table.
2905
     */
2906
    private function setAlias(string $alias): BaseBuilder
2907
    {
2908
        if ($alias !== '') {
68✔
2909
            $this->db->addTableAlias($alias);
7✔
2910
            $this->QBOptions['alias'] = $this->db->protectIdentifiers($alias);
7✔
2911
        }
2912

2913
        return $this;
68✔
2914
    }
2915

2916
    /**
2917
     * Sets update fields for upsert, update
2918
     *
2919
     * @param list<RawSql>|list<string>|string $set
2920
     * @param bool                             $addToDefault adds update fields to the default ones
2921
     * @param array|null                       $ignore       ignores items in set
2922
     *
2923
     * @return $this
2924
     */
2925
    public function updateFields($set, bool $addToDefault = false, ?array $ignore = null)
2926
    {
2927
        if (! empty($set)) {
38✔
2928
            if (! is_array($set)) {
38✔
2929
                $set = explode(',', $set);
5✔
2930
            }
2931

2932
            foreach ($set as $key => $value) {
38✔
2933
                if (! ($value instanceof RawSql)) {
38✔
2934
                    $value = $this->db->protectIdentifiers($value);
38✔
2935
                }
2936

2937
                if (is_numeric($key)) {
38✔
2938
                    $key = $value;
38✔
2939
                }
2940

2941
                if ($ignore === null || ! in_array($key, $ignore, true)) {
38✔
2942
                    if ($addToDefault) {
38✔
2943
                        $this->QBOptions['updateFieldsAdditional'][$this->db->protectIdentifiers($key)] = $value;
3✔
2944
                    } else {
2945
                        $this->QBOptions['updateFields'][$this->db->protectIdentifiers($key)] = $value;
38✔
2946
                    }
2947
                }
2948
            }
2949

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

2953
                unset($this->QBOptions['updateFieldsAdditional']);
3✔
2954
            }
2955
        }
2956

2957
        return $this;
38✔
2958
    }
2959

2960
    /**
2961
     * Sets constraints for batch upsert, update
2962
     *
2963
     * @param array|RawSql|string $set a string of columns, key value pairs, or RawSql
2964
     *
2965
     * @return $this
2966
     */
2967
    public function onConstraint($set)
2968
    {
2969
        if (! empty($set)) {
47✔
2970
            if (is_string($set)) {
44✔
2971
                $set = explode(',', $set);
27✔
2972

2973
                $set = array_map(trim(...), $set);
27✔
2974
            }
2975

2976
            if ($set instanceof RawSql) {
44✔
2977
                $set = [$set];
2✔
2978
            }
2979

2980
            foreach ($set as $key => $value) {
44✔
2981
                if (! ($value instanceof RawSql)) {
44✔
2982
                    $value = $this->db->protectIdentifiers($value);
41✔
2983
                }
2984

2985
                if (is_string($key)) {
44✔
2986
                    $key = $this->db->protectIdentifiers($key);
3✔
2987
                }
2988

2989
                $this->QBOptions['constraints'][$key] = $value;
44✔
2990
            }
2991
        }
2992

2993
        return $this;
47✔
2994
    }
2995

2996
    /**
2997
     * Sets data source as a query for insertBatch()/updateBatch()/upsertBatch()/deleteBatch()
2998
     *
2999
     * @param BaseBuilder|RawSql $query
3000
     * @param array|string|null  $columns an array or comma delimited string of columns
3001
     */
3002
    public function setQueryAsData($query, ?string $alias = null, $columns = null): BaseBuilder
3003
    {
3004
        if (is_string($query)) {
5✔
3005
            throw new InvalidArgumentException('$query parameter must be BaseBuilder or RawSql class.');
×
3006
        }
3007

3008
        if ($query instanceof BaseBuilder) {
5✔
3009
            $query = $query->getCompiledSelect();
4✔
3010
        } elseif ($query instanceof RawSql) {
1✔
3011
            $query = $query->__toString();
1✔
3012
        }
3013

3014
        if (is_string($query)) {
5✔
3015
            if ($columns !== null && is_string($columns)) {
5✔
3016
                $columns = explode(',', $columns);
1✔
3017
                $columns = array_map(trim(...), $columns);
1✔
3018
            }
3019

3020
            $columns = (array) $columns;
5✔
3021

3022
            if ($columns === []) {
5✔
3023
                $columns = $this->fieldsFromQuery($query);
4✔
3024
            }
3025

3026
            if ($alias !== null) {
5✔
3027
                $this->setAlias($alias);
1✔
3028
            }
3029

3030
            foreach ($columns as $key => $value) {
5✔
3031
                $columns[$key] = $this->db->escapeChar . $value . $this->db->escapeChar;
5✔
3032
            }
3033

3034
            $this->QBOptions['setQueryAsData'] = $query;
5✔
3035
            $this->QBKeys                      = $columns;
5✔
3036
            $this->QBSet                       = [];
5✔
3037
        }
3038

3039
        return $this;
5✔
3040
    }
3041

3042
    /**
3043
     * Gets column names from a select query
3044
     */
3045
    protected function fieldsFromQuery(string $sql): array
3046
    {
3047
        return $this->db->query('SELECT * FROM (' . $sql . ') _u_ LIMIT 1')->getFieldNames();
4✔
3048
    }
3049

3050
    /**
3051
     * Converts value array of array to array of strings
3052
     */
3053
    protected function formatValues(array $values): array
3054
    {
3055
        return array_map(static fn ($index): string => '(' . implode(',', $index) . ')', $values);
45✔
3056
    }
3057

3058
    /**
3059
     * Compiles batch insert strings and runs the queries
3060
     *
3061
     * @param array|object|null $set a dataset
3062
     *
3063
     * @return false|int|list<string> Number of rows inserted or FALSE on no data to perform an insert operation, SQL array when testMode
3064
     */
3065
    public function insertBatch($set = null, ?bool $escape = null, int $batchSize = 100)
3066
    {
3067
        if (isset($this->QBOptions['setQueryAsData'])) {
29✔
3068
            $sql = $this->_insertBatch($this->QBFrom[0], $this->QBKeys, []);
2✔
3069

3070
            if ($sql === '') {
2✔
3071
                return false; // @codeCoverageIgnore
3072
            }
3073

3074
            if ($this->testMode === false) {
2✔
3075
                $this->db->query($sql, null, false);
2✔
3076
            }
3077

3078
            $this->resetWrite();
2✔
3079

3080
            return $this->testMode ? $sql : $this->db->affectedRows();
2✔
3081
        }
3082

3083
        if ($set !== null && $set !== []) {
29✔
3084
            $this->setData($set, $escape);
27✔
3085
        }
3086

3087
        return $this->batchExecute('_insertBatch', $batchSize);
29✔
3088
    }
3089

3090
    /**
3091
     * Generates a platform-specific insert string from the supplied data.
3092
     *
3093
     * @used-by batchExecute()
3094
     *
3095
     * @param string                 $table  Protected table name
3096
     * @param list<string>           $keys   QBKeys
3097
     * @param list<list<int|string>> $values QBSet
3098
     */
3099
    protected function _insertBatch(string $table, array $keys, array $values): string
3100
    {
3101
        $sql = $this->QBOptions['sql'] ?? '';
27✔
3102

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

3108
            $this->QBOptions['sql'] = $sql;
27✔
3109
        }
3110

3111
        if (isset($this->QBOptions['setQueryAsData'])) {
27✔
3112
            $data = $this->QBOptions['setQueryAsData'];
2✔
3113
        } else {
3114
            $data = 'VALUES ' . implode(', ', $this->formatValues($values));
27✔
3115
        }
3116

3117
        return str_replace('{:_table_:}', $data, $sql);
27✔
3118
    }
3119

3120
    /**
3121
     * Compiles an insert query and returns the sql
3122
     *
3123
     * @return bool|string
3124
     *
3125
     * @throws DatabaseException
3126
     */
3127
    public function getCompiledInsert(bool $reset = true)
3128
    {
3129
        if ($this->validateInsert() === false) {
8✔
3130
            return false;
×
3131
        }
3132

3133
        $sql = $this->_insert(
8✔
3134
            $this->db->protectIdentifiers(
8✔
3135
                $this->removeAlias($this->QBFrom[0]),
8✔
3136
                true,
8✔
3137
                null,
8✔
3138
                false,
8✔
3139
            ),
8✔
3140
            array_keys($this->QBSet),
8✔
3141
            array_values($this->QBSet),
8✔
3142
        );
8✔
3143

3144
        if ($reset) {
8✔
3145
            $this->resetWrite();
8✔
3146
        }
3147

3148
        return $this->compileFinalQuery($sql);
8✔
3149
    }
3150

3151
    /**
3152
     * Compiles an insert string and runs the query
3153
     *
3154
     * @param array|object|null $set
3155
     *
3156
     * @return BaseResult|bool|Query
3157
     *
3158
     * @throws DatabaseException
3159
     */
3160
    public function insert($set = null, ?bool $escape = null)
3161
    {
3162
        if ($set !== null) {
901✔
3163
            $this->set($set, '', $escape);
867✔
3164
        }
3165

3166
        if ($this->validateInsert() === false) {
901✔
3167
            return false;
×
3168
        }
3169

3170
        $sql = $this->_insert(
900✔
3171
            $this->db->protectIdentifiers(
900✔
3172
                $this->removeAlias($this->QBFrom[0]),
900✔
3173
                true,
900✔
3174
                $escape,
900✔
3175
                false,
900✔
3176
            ),
900✔
3177
            array_keys($this->QBSet),
900✔
3178
            array_values($this->QBSet),
900✔
3179
        );
900✔
3180

3181
        if (! $this->testMode) {
900✔
3182
            $this->resetWrite();
894✔
3183

3184
            $result = $this->db->query($sql, $this->binds, false);
894✔
3185

3186
            // Clear our binds so we don't eat up memory
3187
            $this->binds = [];
894✔
3188

3189
            return $result;
894✔
3190
        }
3191

3192
        return false;
7✔
3193
    }
3194

3195
    /**
3196
     * @internal This is a temporary solution.
3197
     *
3198
     * @see https://github.com/codeigniter4/CodeIgniter4/pull/5376
3199
     *
3200
     * @TODO Fix a root cause, and this method should be removed.
3201
     */
3202
    protected function removeAlias(string $from): string
3203
    {
3204
        if (str_contains($from, ' ')) {
905✔
3205
            // if the alias is written with the AS keyword, remove it
3206
            $from = preg_replace('/\s+AS\s+/i', ' ', $from);
2✔
3207

3208
            $parts = explode(' ', $from);
2✔
3209
            $from  = $parts[0];
2✔
3210
        }
3211

3212
        return $from;
905✔
3213
    }
3214

3215
    /**
3216
     * This method is used by both insert() and getCompiledInsert() to
3217
     * validate that the there data is actually being set and that table
3218
     * has been chosen to be inserted into.
3219
     *
3220
     * @throws DatabaseException
3221
     */
3222
    protected function validateInsert(): bool
3223
    {
3224
        if (empty($this->QBSet)) {
901✔
3225
            if ($this->db->DBDebug) {
1✔
3226
                throw new DatabaseException('You must use the "set" method to insert an entry.');
1✔
3227
            }
3228

3229
            return false; // @codeCoverageIgnore
3230
        }
3231

3232
        return true;
900✔
3233
    }
3234

3235
    /**
3236
     * Generates a platform-specific insert string from the supplied data
3237
     *
3238
     * @param string           $table         Protected table name
3239
     * @param list<string>     $keys          Keys of QBSet
3240
     * @param list<int|string> $unescapedKeys Values of QBSet
3241
     */
3242
    protected function _insert(string $table, array $keys, array $unescapedKeys): string
3243
    {
3244
        return 'INSERT ' . $this->compileIgnore('insert') . 'INTO ' . $table . ' (' . implode(', ', $keys) . ') VALUES (' . implode(', ', $unescapedKeys) . ')';
891✔
3245
    }
3246

3247
    /**
3248
     * Compiles a replace into string and runs the query
3249
     *
3250
     * @return BaseResult|false|Query|string
3251
     *
3252
     * @throws DatabaseException
3253
     */
3254
    public function replace(?array $set = null)
3255
    {
3256
        if ($set !== null) {
8✔
3257
            $this->set($set);
7✔
3258
        }
3259

3260
        if (empty($this->QBSet)) {
8✔
3261
            if ($this->db->DBDebug) {
1✔
3262
                throw new DatabaseException('You must use the "set" method to update an entry.');
1✔
3263
            }
3264

3265
            return false; // @codeCoverageIgnore
3266
        }
3267

3268
        $table = $this->QBFrom[0];
7✔
3269

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

3272
        $this->resetWrite();
7✔
3273

3274
        return $this->testMode ? $sql : $this->db->query($sql, $this->binds, false);
7✔
3275
    }
3276

3277
    /**
3278
     * Generates a platform-specific replace string from the supplied data
3279
     *
3280
     * @param string           $table  Protected table name
3281
     * @param list<string>     $keys   Keys of QBSet
3282
     * @param list<int|string> $values Values of QBSet
3283
     */
3284
    protected function _replace(string $table, array $keys, array $values): string
3285
    {
3286
        return 'REPLACE INTO ' . $table . ' (' . implode(', ', $keys) . ') VALUES (' . implode(', ', $values) . ')';
7✔
3287
    }
3288

3289
    /**
3290
     * Groups tables in FROM clauses if needed, so there is no confusion
3291
     * about operator precedence.
3292
     *
3293
     * Note: This is only used (and overridden) by MySQL and SQLSRV.
3294
     */
3295
    protected function _fromTables(): string
3296
    {
3297
        return implode(', ', $this->QBFrom);
1,181✔
3298
    }
3299

3300
    /**
3301
     * Compiles an update query and returns the sql
3302
     *
3303
     * @return bool|string
3304
     */
3305
    public function getCompiledUpdate(bool $reset = true)
3306
    {
3307
        if ($this->validateUpdate() === false) {
13✔
3308
            return false;
×
3309
        }
3310

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

3313
        if ($reset) {
13✔
3314
            $this->resetWrite();
13✔
3315
        }
3316

3317
        return $this->compileFinalQuery($sql);
13✔
3318
    }
3319

3320
    /**
3321
     * Compiles an update string and runs the query.
3322
     *
3323
     * @param array|object|null        $set
3324
     * @param array|RawSql|string|null $where
3325
     *
3326
     * @throws DatabaseException
3327
     */
3328
    public function update($set = null, $where = null, ?int $limit = null): bool
3329
    {
3330
        if ($set !== null) {
106✔
3331
            $this->set($set);
52✔
3332
        }
3333

3334
        if ($this->validateUpdate() === false) {
106✔
3335
            return false;
×
3336
        }
3337

3338
        if ($where !== null) {
105✔
3339
            $this->where($where);
7✔
3340
        }
3341

3342
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
105✔
3343
        if ($limitZeroAsAll && $limit === 0) {
105✔
3344
            $limit = null;
×
3345
        }
3346

3347
        if ($limit !== null) {
105✔
3348
            if (! $this->canLimitWhereUpdates) {
3✔
3349
                throw new DatabaseException('This driver does not allow LIMITs on UPDATE queries using WHERE.');
2✔
3350
            }
3351

3352
            $this->limit($limit);
3✔
3353
        }
3354

3355
        $sql = $this->_update($this->QBFrom[0], $this->QBSet);
105✔
3356

3357
        if (! $this->testMode) {
105✔
3358
            $this->resetWrite();
92✔
3359

3360
            $result = $this->db->query($sql, $this->binds, false);
92✔
3361

3362
            if ($result !== false) {
92✔
3363
                // Clear our binds so we don't eat up memory
3364
                $this->binds = [];
89✔
3365

3366
                return true;
89✔
3367
            }
3368

3369
            return false;
3✔
3370
        }
3371

3372
        return true;
13✔
3373
    }
3374

3375
    /**
3376
     * Generates a platform-specific update string from the supplied data
3377
     *
3378
     * @param string                $table  Protected table name
3379
     * @param array<string, string> $values QBSet
3380
     */
3381
    protected function _update(string $table, array $values): string
3382
    {
3383
        $valStr = [];
124✔
3384

3385
        foreach ($values as $key => $val) {
124✔
3386
            $valStr[] = $key . ' = ' . $val;
124✔
3387
        }
3388

3389
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
124✔
3390
        if ($limitZeroAsAll) {
124✔
3391
            return 'UPDATE ' . $this->compileIgnore('update') . $table . ' SET ' . implode(', ', $valStr)
124✔
3392
                . $this->compileWhereHaving('QBWhere')
124✔
3393
                . $this->compileOrderBy()
124✔
3394
                . ($this->QBLimit ? $this->_limit(' ', true) : '');
124✔
3395
        }
3396

3397
        return 'UPDATE ' . $this->compileIgnore('update') . $table . ' SET ' . implode(', ', $valStr)
×
3398
            . $this->compileWhereHaving('QBWhere')
×
3399
            . $this->compileOrderBy()
×
3400
            . ($this->QBLimit !== false ? $this->_limit(' ', true) : '');
×
3401
    }
3402

3403
    /**
3404
     * This method is used by both update() and getCompiledUpdate() to
3405
     * validate that data is actually being set and that a table has been
3406
     * chosen to be updated.
3407
     *
3408
     * @throws DatabaseException
3409
     */
3410
    protected function validateUpdate(): bool
3411
    {
3412
        if (empty($this->QBSet)) {
107✔
3413
            if ($this->db->DBDebug) {
1✔
3414
                throw new DatabaseException('You must use the "set" method to update an entry.');
1✔
3415
            }
3416

3417
            return false; // @codeCoverageIgnore
3418
        }
3419

3420
        return true;
106✔
3421
    }
3422

3423
    /**
3424
     * Sets data and calls batchExecute to run queries
3425
     *
3426
     * @param array|object|null        $set         a dataset
3427
     * @param array|RawSql|string|null $constraints
3428
     *
3429
     * @return false|int|list<string> Number of rows affected or FALSE on failure, SQL array when testMode
3430
     */
3431
    public function updateBatch($set = null, $constraints = null, int $batchSize = 100)
3432
    {
3433
        if ($this->QBWhere !== []) {
24✔
3434
            throw new DatabaseException(
1✔
3435
                'updateBatch() cannot be safely combined with existing Query Builder WHERE conditions. '
1✔
3436
                . 'Use updateBatch($data, $constraints), onConstraint(), or include all required constraint fields in the batch data.',
1✔
3437
            );
1✔
3438
        }
3439

3440
        $this->onConstraint($constraints);
23✔
3441

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

3445
            if ($sql === '') {
1✔
3446
                return false; // @codeCoverageIgnore
3447
            }
3448

3449
            if ($this->testMode === false) {
1✔
3450
                $this->db->query($sql, null, false);
1✔
3451
            }
3452

3453
            $this->resetWrite();
1✔
3454

3455
            return $this->testMode ? $sql : $this->db->affectedRows();
1✔
3456
        }
3457

3458
        if ($set !== null && $set !== []) {
22✔
3459
            $this->setData($set, true);
16✔
3460
        }
3461

3462
        return $this->batchExecute('_updateBatch', $batchSize);
22✔
3463
    }
3464

3465
    /**
3466
     * Generates a platform-specific batch update string from the supplied data
3467
     *
3468
     * @used-by batchExecute()
3469
     *
3470
     * @param string                 $table  Protected table name
3471
     * @param list<string>           $keys   QBKeys
3472
     * @param list<list<int|string>> $values QBSet
3473
     */
3474
    protected function _updateBatch(string $table, array $keys, array $values): string
3475
    {
3476
        $sql = $this->QBOptions['sql'] ?? '';
19✔
3477

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

3482
            if ($constraints === []) {
19✔
3483
                if ($this->db->DBDebug) {
2✔
3484
                    throw new DatabaseException('You must specify a constraint to match on for batch updates.'); // @codeCoverageIgnore
3485
                }
3486

3487
                return ''; // @codeCoverageIgnore
3488
            }
3489

3490
            $updateFields = $this->QBOptions['updateFields'] ??
17✔
3491
                $this->updateFields($keys, false, $constraints)->QBOptions['updateFields'] ??
17✔
3492
                [];
14✔
3493

3494
            $alias = $this->QBOptions['alias'] ?? '_u';
17✔
3495

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

3498
            $sql .= "SET\n";
17✔
3499

3500
            $sql .= implode(
17✔
3501
                ",\n",
17✔
3502
                array_map(
17✔
3503
                    static fn ($key, $value): string => $key . ($value instanceof RawSql ?
17✔
3504
                        ' = ' . $value :
2✔
3505
                        ' = ' . $alias . '.' . $value),
17✔
3506
                    array_keys($updateFields),
17✔
3507
                    $updateFields,
17✔
3508
                ),
17✔
3509
            ) . "\n";
17✔
3510

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

3513
            $sql .= ') ' . $alias . "\n";
17✔
3514

3515
            $sql .= 'WHERE ' . implode(
17✔
3516
                ' AND ',
17✔
3517
                array_map(
17✔
3518
                    static fn ($key, $value) => (
17✔
3519
                        ($value instanceof RawSql && is_string($key))
17✔
3520
                        ?
17✔
3521
                        $table . '.' . $key . ' = ' . $value
1✔
3522
                        :
17✔
3523
                        (
17✔
3524
                            $value instanceof RawSql
16✔
3525
                            ?
16✔
3526
                            $value
3✔
3527
                            :
16✔
3528
                            $table . '.' . $value . ' = ' . $alias . '.' . $value
17✔
3529
                        )
17✔
3530
                    ),
17✔
3531
                    array_keys($constraints),
17✔
3532
                    $constraints,
17✔
3533
                ),
17✔
3534
            );
17✔
3535

3536
            $this->QBOptions['sql'] = $sql;
17✔
3537
        }
3538

3539
        if (isset($this->QBOptions['setQueryAsData'])) {
17✔
3540
            $data = $this->QBOptions['setQueryAsData'];
1✔
3541
        } else {
3542
            $data = implode(
16✔
3543
                " UNION ALL\n",
16✔
3544
                array_map(
16✔
3545
                    static fn ($value): string => 'SELECT ' . implode(', ', array_map(
16✔
3546
                        static fn ($key, $index): string => $index . ' ' . $key,
16✔
3547
                        $keys,
16✔
3548
                        $value,
16✔
3549
                    )),
16✔
3550
                    $values,
16✔
3551
                ),
16✔
3552
            ) . "\n";
16✔
3553
        }
3554

3555
        return str_replace('{:_table_:}', $data, $sql);
17✔
3556
    }
3557

3558
    /**
3559
     * Compiles a delete string and runs "DELETE FROM table"
3560
     *
3561
     * @return bool|string TRUE on success, FALSE on failure, string on testMode
3562
     */
3563
    public function emptyTable()
3564
    {
3565
        $table = $this->QBFrom[0];
4✔
3566

3567
        $sql = $this->_delete($table);
4✔
3568

3569
        if ($this->testMode) {
4✔
3570
            return $sql;
1✔
3571
        }
3572

3573
        $this->resetWrite();
3✔
3574

3575
        return $this->db->query($sql, null, false);
3✔
3576
    }
3577

3578
    /**
3579
     * Compiles a truncate string and runs the query
3580
     * If the database does not support the truncate() command
3581
     * This function maps to "DELETE FROM table"
3582
     *
3583
     * @return bool|string TRUE on success, FALSE on failure, string on testMode
3584
     */
3585
    public function truncate()
3586
    {
3587
        $table = $this->QBFrom[0];
809✔
3588

3589
        $sql = $this->_truncate($table);
809✔
3590

3591
        if ($this->testMode) {
809✔
3592
            return $sql;
2✔
3593
        }
3594

3595
        $this->resetWrite();
808✔
3596

3597
        return $this->db->query($sql, null, false);
808✔
3598
    }
3599

3600
    /**
3601
     * Generates a platform-specific truncate string from the supplied data
3602
     *
3603
     * If the database does not support the truncate() command,
3604
     * then this method maps to 'DELETE FROM table'
3605
     *
3606
     * @param string $table Protected table name
3607
     */
3608
    protected function _truncate(string $table): string
3609
    {
3610
        return 'TRUNCATE ' . $table;
793✔
3611
    }
3612

3613
    /**
3614
     * Compiles a delete query string and returns the sql
3615
     */
3616
    public function getCompiledDelete(bool $reset = true): string
3617
    {
3618
        $sql = $this->testMode()->delete('', null, $reset);
3✔
3619
        $this->testMode(false);
3✔
3620

3621
        return $this->compileFinalQuery($sql);
3✔
3622
    }
3623

3624
    /**
3625
     * Compiles a delete string and runs the query
3626
     *
3627
     * @param array|RawSql|string $where
3628
     *
3629
     * @return bool|string Returns a SQL string if in test mode.
3630
     *
3631
     * @throws DatabaseException
3632
     */
3633
    public function delete($where = '', ?int $limit = null, bool $resetData = true)
3634
    {
3635
        $table = $this->db->protectIdentifiers($this->QBFrom[0], true, null, false);
857✔
3636

3637
        if ($where !== '') {
857✔
3638
            $this->where($where);
4✔
3639
        }
3640

3641
        if (empty($this->QBWhere)) {
857✔
3642
            if ($this->db->DBDebug) {
2✔
3643
                throw new DatabaseException('Deletes are not allowed unless they contain a "where" or "like" clause.');
2✔
3644
            }
3645

3646
            return false; // @codeCoverageIgnore
3647
        }
3648

3649
        $sql = $this->_delete($this->removeAlias($table));
857✔
3650

3651
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
857✔
3652
        if ($limitZeroAsAll && $limit === 0) {
857✔
3653
            $limit = null;
×
3654
        }
3655

3656
        if ($limit !== null) {
857✔
3657
            $this->QBLimit = $limit;
1✔
3658
        }
3659

3660
        if (! empty($this->QBLimit)) {
857✔
3661
            if (! $this->canLimitDeletes) {
2✔
3662
                throw new DatabaseException('SQLite3 does not allow LIMITs on DELETE queries.');
1✔
3663
            }
3664

3665
            $sql = $this->_limit($sql, true);
2✔
3666
        }
3667

3668
        if ($resetData) {
857✔
3669
            $this->resetWrite();
857✔
3670
        }
3671

3672
        return $this->testMode ? $sql : $this->db->query($sql, $this->binds, false);
857✔
3673
    }
3674

3675
    /**
3676
     * Sets data and calls batchExecute to run queries
3677
     *
3678
     * @param array|object|null $set         a dataset
3679
     * @param array|RawSql|null $constraints
3680
     *
3681
     * @return false|int|list<string> Number of rows affected or FALSE on failure, SQL array when testMode
3682
     */
3683
    public function deleteBatch($set = null, $constraints = null, int $batchSize = 100)
3684
    {
3685
        $this->onConstraint($constraints);
3✔
3686

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

3690
            if ($sql === '') {
1✔
3691
                return false; // @codeCoverageIgnore
3692
            }
3693

3694
            if ($this->testMode === false) {
1✔
3695
                $this->db->query($sql, null, false);
1✔
3696
            }
3697

3698
            $this->resetWrite();
1✔
3699

3700
            return $this->testMode ? $sql : $this->db->affectedRows();
1✔
3701
        }
3702

3703
        if ($set !== null && $set !== []) {
2✔
3704
            $this->setData($set, true);
×
3705
        }
3706

3707
        return $this->batchExecute('_deleteBatch', $batchSize);
2✔
3708
    }
3709

3710
    /**
3711
     * Generates a platform-specific batch update string from the supplied data
3712
     *
3713
     * @used-by batchExecute()
3714
     *
3715
     * @param string           $table  Protected table name
3716
     * @param list<string>     $keys   QBKeys
3717
     * @param list<int|string> $values QBSet
3718
     */
3719
    protected function _deleteBatch(string $table, array $keys, array $values): string
3720
    {
3721
        $sql = $this->QBOptions['sql'] ?? '';
3✔
3722

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

3727
            if ($constraints === []) {
3✔
3728
                if ($this->db->DBDebug) {
×
3729
                    throw new DatabaseException('You must specify a constraint to match on for batch deletes.'); // @codeCoverageIgnore
3730
                }
3731

3732
                return ''; // @codeCoverageIgnore
3733
            }
3734

3735
            $alias = $this->QBOptions['alias'] ?? '_u';
3✔
3736

3737
            $sql = 'DELETE ' . $table . ' FROM ' . $table . "\n";
3✔
3738

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

3741
            $sql .= ') ' . $alias . "\n";
3✔
3742

3743
            $sql .= 'ON ' . implode(
3✔
3744
                ' AND ',
3✔
3745
                array_map(
3✔
3746
                    static fn ($key, $value) => (
3✔
3747
                        $value instanceof RawSql ?
3✔
3748
                        $value :
×
3749
                        (
3✔
3750
                            is_string($key) ?
3✔
3751
                            $table . '.' . $key . ' = ' . $alias . '.' . $value :
2✔
3752
                            $table . '.' . $value . ' = ' . $alias . '.' . $value
3✔
3753
                        )
3✔
3754
                    ),
3✔
3755
                    array_keys($constraints),
3✔
3756
                    $constraints,
3✔
3757
                ),
3✔
3758
            );
3✔
3759

3760
            // convert binds in where
3761
            foreach ($this->QBWhere as $key => $where) {
3✔
3762
                foreach ($this->binds as $field => $bind) {
2✔
3763
                    $this->QBWhere[$key]['condition'] = str_replace(':' . $field . ':', $bind[0], $where['condition']);
×
3764
                }
3765
            }
3766

3767
            $sql .= ' ' . $this->compileWhereHaving('QBWhere');
3✔
3768

3769
            $this->QBOptions['sql'] = trim($sql);
3✔
3770
        }
3771

3772
        if (isset($this->QBOptions['setQueryAsData'])) {
3✔
3773
            $data = $this->QBOptions['setQueryAsData'];
1✔
3774
        } else {
3775
            $data = implode(
2✔
3776
                " UNION ALL\n",
2✔
3777
                array_map(
2✔
3778
                    static fn ($value): string => 'SELECT ' . implode(', ', array_map(
2✔
3779
                        static fn ($key, $index): string => $index . ' ' . $key,
2✔
3780
                        $keys,
2✔
3781
                        $value,
2✔
3782
                    )),
2✔
3783
                    $values,
2✔
3784
                ),
2✔
3785
            ) . "\n";
2✔
3786
        }
3787

3788
        return str_replace('{:_table_:}', $data, $sql);
3✔
3789
    }
3790

3791
    /**
3792
     * Increments a numeric column by the specified value.
3793
     *
3794
     * @return bool
3795
     */
3796
    public function increment(string $column, int $value = 1)
3797
    {
3798
        return $this->incrementMany([$column], $value);
6✔
3799
    }
3800

3801
    /**
3802
     * Increments multiple numeric columns by the specified value(s).
3803
     *
3804
     * @param array<string, int>|list<string> $columns A list of columns or array of column => value pairs to increment.
3805
     * @param int                             $value   The value to increment by if $columns is a list of column names.
3806
     */
3807
    public function incrementMany(array $columns, int $value = 1): bool
3808
    {
3809
        if ($columns === []) {
11✔
3810
            throw new InvalidArgumentException('Argument #1 ($columns) cannot be empty.');
1✔
3811
        }
3812

3813
        if (array_is_list($columns)) {
10✔
3814
            $columns = array_fill_keys($columns, $value);
7✔
3815
        }
3816

3817
        $fields = [];
10✔
3818

3819
        foreach ($columns as $col => $val) {
10✔
3820
            if (! is_int($val)) {
10✔
3821
                throw new TypeError(sprintf(
1✔
3822
                    'Argument #1 ($columns) must contain only int values, %s given for "%s".',
1✔
3823
                    get_debug_type($val),
1✔
3824
                    $col,
1✔
3825
                ));
1✔
3826
            }
3827

3828
            $col          = $this->db->protectIdentifiers($col);
10✔
3829
            $fields[$col] = "{$col} + {$val}";
10✔
3830
        }
3831

3832
        $sql = $this->_update($this->QBFrom[0], $fields);
9✔
3833

3834
        if (! $this->testMode) {
9✔
3835
            $this->resetWrite();
9✔
3836

3837
            return $this->db->query($sql, $this->binds, false);
9✔
3838
        }
3839

3840
        return true;
×
3841
    }
3842

3843
    /**
3844
     * Decrements a numeric column by the specified value.
3845
     *
3846
     * @return bool
3847
     */
3848
    public function decrement(string $column, int $value = 1)
3849
    {
3850
        return $this->decrementMany([$column], $value);
6✔
3851
    }
3852

3853
    /**
3854
     * Decrements multiple numeric columns by the specified value(s).
3855
     *
3856
     * @param array<string, int>|list<string> $columns A list of columns or array of column => value pairs to decrement.
3857
     * @param int                             $value   The value to decrement by if $columns is a list of column names.
3858
     */
3859
    public function decrementMany(array $columns, int $value = 1): bool
3860
    {
3861
        if ($columns === []) {
11✔
3862
            throw new InvalidArgumentException('Argument #1 ($columns) cannot be empty.');
1✔
3863
        }
3864

3865
        if (array_is_list($columns)) {
10✔
3866
            $columns = array_fill_keys($columns, $value);
7✔
3867
        }
3868

3869
        $fields = [];
10✔
3870

3871
        foreach ($columns as $col => $val) {
10✔
3872
            if (! is_int($val)) {
10✔
3873
                throw new TypeError(sprintf(
1✔
3874
                    'Argument #1 ($columns) must contain only int values, %s given for "%s".',
1✔
3875
                    get_debug_type($val),
1✔
3876
                    $col,
1✔
3877
                ));
1✔
3878
            }
3879

3880
            $col          = $this->db->protectIdentifiers($col);
10✔
3881
            $fields[$col] = "{$col} - {$val}";
10✔
3882
        }
3883

3884
        $sql = $this->_update($this->QBFrom[0], $fields);
9✔
3885

3886
        if (! $this->testMode) {
9✔
3887
            $this->resetWrite();
9✔
3888

3889
            return $this->db->query($sql, $this->binds, false);
9✔
3890
        }
3891

3892
        return true;
×
3893
    }
3894

3895
    /**
3896
     * Generates a platform-specific delete string from the supplied data
3897
     *
3898
     * @param string $table Protected table name
3899
     */
3900
    protected function _delete(string $table): string
3901
    {
3902
        return 'DELETE ' . $this->compileIgnore('delete') . 'FROM ' . $table . $this->compileWhereHaving('QBWhere');
860✔
3903
    }
3904

3905
    /**
3906
     * Used to track SQL statements written with aliased tables.
3907
     *
3908
     * @param array|string $table The table to inspect
3909
     *
3910
     * @return string|null
3911
     */
3912
    protected function trackAliases($table)
3913
    {
3914
        if (is_array($table)) {
1,329✔
3915
            foreach ($table as $t) {
×
3916
                $this->trackAliases($t);
×
3917
            }
3918

3919
            return null;
×
3920
        }
3921

3922
        // Does the string contain a comma?  If so, we need to separate
3923
        // the string into discreet statements
3924
        if (str_contains($table, ',')) {
1,329✔
3925
            return $this->trackAliases(explode(',', $table));
×
3926
        }
3927

3928
        // if a table alias is used we can recognize it by a space
3929
        if (str_contains($table, ' ')) {
1,329✔
3930
            // if the alias is written with the AS keyword, remove it
3931
            $table = preg_replace('/\s+AS\s+/i', ' ', $table);
26✔
3932

3933
            // Grab the alias
3934
            $alias = trim(strrchr($table, ' '));
26✔
3935

3936
            // Store the alias, if it doesn't already exist
3937
            $this->db->addTableAlias($alias);
26✔
3938
        }
3939

3940
        return null;
1,329✔
3941
    }
3942

3943
    /**
3944
     * Compile the SELECT statement
3945
     *
3946
     * Generates a query string based on which functions were used.
3947
     * Should not be called directly.
3948
     *
3949
     * @param mixed $selectOverride
3950
     */
3951
    protected function compileSelect($selectOverride = false): string
3952
    {
3953
        if ($selectOverride !== false) {
1,207✔
3954
            $sql = $selectOverride;
249✔
3955
        } else {
3956
            $sql = $this->QBDistinct ? 'SELECT DISTINCT ' : 'SELECT ';
1,200✔
3957

3958
            if (empty($this->QBSelect)) {
1,200✔
3959
                $sql .= '*';
1,081✔
3960
            } else {
3961
                // Cycle through the "select" portion of the query and prep each column name.
3962
                // The reason we protect identifiers here rather than in the select() function
3963
                // is because until the user calls the from() function we don't know if there are aliases
3964
                foreach ($this->QBSelect as $key => $val) {
1,005✔
3965
                    if ($val instanceof RawSql) {
1,005✔
3966
                        $this->QBSelect[$key] = (string) $val;
5✔
3967
                    } else {
3968
                        $protect              = $this->QBNoEscape[$key] ?? null;
1,003✔
3969
                        $this->QBSelect[$key] = $this->db->protectIdentifiers($val, false, $protect);
1,003✔
3970
                    }
3971
                }
3972

3973
                $sql .= implode(', ', $this->QBSelect);
1,005✔
3974
            }
3975
        }
3976

3977
        if (! empty($this->QBFrom)) {
1,207✔
3978
            $sql .= "\nFROM " . $this->_fromTables();
1,207✔
3979
        }
3980

3981
        if (! empty($this->QBJoin)) {
1,207✔
3982
            $sql .= "\n" . implode("\n", $this->QBJoin);
15✔
3983
        }
3984

3985
        $sql .= $this->compileWhereHaving('QBWhere')
1,207✔
3986
            . $this->compileGroupBy()
1,207✔
3987
            . $this->compileWhereHaving('QBHaving')
1,207✔
3988
            . $this->compileOrderBy();
1,207✔
3989

3990
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
1,207✔
3991
        if ($limitZeroAsAll) {
1,207✔
3992
            if ($this->QBLimit) {
1,205✔
3993
                $sql = $this->_limit($sql . "\n");
123✔
3994
            }
3995
        } elseif ($this->QBLimit !== false || $this->QBOffset) {
3✔
3996
            $sql = $this->_limit($sql . "\n");
3✔
3997
        }
3998

3999
        $sql .= $this->compileLockForUpdate();
1,207✔
4000

4001
        return $this->unionInjection($sql);
1,197✔
4002
    }
4003

4004
    /**
4005
     * Compile the SELECT lock clause.
4006
     */
4007
    protected function compileLockForUpdate(): string
4008
    {
4009
        if ($this->QBLockForUpdate && $this->QBUnion !== []) {
270✔
4010
            throw new DatabaseException('Query Builder does not support lockForUpdate() with union() or unionAll().');
1✔
4011
        }
4012

4013
        return $this->QBLockForUpdate ? "\nFOR UPDATE" : '';
270✔
4014
    }
4015

4016
    /**
4017
     * Checks if the ignore option is supported by
4018
     * the Database Driver for the specific statement.
4019
     *
4020
     * @return string
4021
     */
4022
    protected function compileIgnore(string $statement)
4023
    {
4024
        if ($this->QBIgnore && isset($this->supportedIgnoreStatements[$statement])) {
922✔
4025
            return trim($this->supportedIgnoreStatements[$statement]) . ' ';
1✔
4026
        }
4027

4028
        return '';
921✔
4029
    }
4030

4031
    /**
4032
     * Escapes identifiers in WHERE and HAVING statements at execution time.
4033
     *
4034
     * Required so that aliases are tracked properly, regardless of whether
4035
     * where(), orWhere(), having(), orHaving are called prior to from(),
4036
     * join() and prefixTable is added only if needed.
4037
     *
4038
     * @param string $qbKey 'QBWhere' or 'QBHaving'
4039
     *
4040
     * @return string SQL statement
4041
     */
4042
    protected function compileWhereHaving(string $qbKey): string
4043
    {
4044
        if (! empty($this->{$qbKey})) {
1,246✔
4045
            foreach ($this->{$qbKey} as &$qbkey) {
1,104✔
4046
                $qbkey = $this->compileWhereHavingCondition($qbkey);
1,104✔
4047
            }
4048

4049
            return ($qbKey === 'QBHaving' ? "\nHAVING " : "\nWHERE ")
1,104✔
4050
                . implode("\n", $this->{$qbKey});
1,104✔
4051
        }
4052

4053
        return '';
1,227✔
4054
    }
4055

4056
    /**
4057
     * @used-by compileWhereHaving()
4058
     *
4059
     * @param array<string, mixed>|RawSql|string $condition
4060
     */
4061
    private function compileWhereHavingCondition(array|RawSql|string $condition): RawSql|string
4062
    {
4063
        // Is this condition already compiled?
4064
        if (is_string($condition) || $condition instanceof RawSql) {
1,104✔
4065
            return $condition;
35✔
4066
        }
4067

4068
        if ($condition['condition'] instanceof RawSql) {
1,104✔
4069
            return $condition['condition'];
5✔
4070
        }
4071

4072
        if (($condition['columnComparison'] ?? false) === true) {
1,102✔
4073
            return $this->compileColumnComparison($condition);
22✔
4074
        }
4075

4076
        if (($condition['betweenComparison'] ?? false) === true) {
1,089✔
4077
            return $this->compileBetweenComparison($condition);
15✔
4078
        }
4079

4080
        if (($condition['datePartComparison'] ?? false) === true) {
1,080✔
4081
            return $this->compileDatePartComparison($condition);
17✔
4082
        }
4083

4084
        if ($condition['escape'] === false) {
1,066✔
4085
            return $condition['condition'];
129✔
4086
        }
4087

4088
        return $this->compileEscapedCondition($condition['condition']);
1,052✔
4089
    }
4090

4091
    /**
4092
     * @used-by compileWhereHavingCondition()
4093
     */
4094
    private function compileEscapedCondition(string $condition): string
4095
    {
4096
        // Split multiple conditions
4097
        $conditions = preg_split(
1,052✔
4098
            '/((?:^|\s+)AND\s+|(?:^|\s+)OR\s+)/i',
1,052✔
4099
            $condition,
1,052✔
4100
            -1,
1,052✔
4101
            PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY,
1,052✔
4102
        );
1,052✔
4103

4104
        foreach ($conditions as &$condition) {
1,052✔
4105
            $op = $this->getOperator($condition);
1,052✔
4106
            if (
4107
                $op === false
1,052✔
4108
                || preg_match(
1,052✔
4109
                    '/^(\(?)(.*)(' . preg_quote($op, '/') . ')\s*(.*(?<!\)))?(\)?)$/i',
1,052✔
4110
                    $condition,
1,052✔
4111
                    $matches,
1,052✔
4112
                ) !== 1
1,052✔
4113
            ) {
4114
                continue;
918✔
4115
            }
4116

4117
            // $matches = [
4118
            //  0 => '(test <= foo)',   /* the whole thing */
4119
            //  1 => '(',               /* optional */
4120
            //  2 => 'test',            /* the field name */
4121
            //  3 => ' <= ',            /* $op */
4122
            //  4 => 'foo',             /* optional, if $op is e.g. 'IS NULL' */
4123
            //  5 => ')'                /* optional */
4124
            // ];
4125

4126
            if ($matches[4] !== '') {
1,052✔
4127
                $protectIdentifiers = false;
1,009✔
4128
                if (str_contains($matches[4], '.')) {
1,009✔
4129
                    $protectIdentifiers = true;
87✔
4130
                }
4131

4132
                if (! str_contains($matches[4], ':')) {
1,009✔
4133
                    $matches[4] = $this->db->protectIdentifiers(trim($matches[4]), false, $protectIdentifiers);
14✔
4134
                }
4135

4136
                $matches[4] = ' ' . $matches[4];
1,009✔
4137
            }
4138

4139
            $condition = $matches[1] . $this->db->protectIdentifiers(trim($matches[2]))
1,052✔
4140
                . ' ' . trim($matches[3]) . $matches[4] . $matches[5];
1,052✔
4141
        }
4142

4143
        return implode('', $conditions);
1,052✔
4144
    }
4145

4146
    /**
4147
     * @used-by compileWhereHavingCondition()
4148
     *
4149
     * @param array{columnComparison: true, condition: string, escape: bool, first: string, operator: string, second: string} $condition
4150
     */
4151
    private function compileColumnComparison(array $condition): string
4152
    {
4153
        if ($condition['escape']) {
22✔
4154
            $condition['first']  = $this->db->protectIdentifiers($condition['first'], false, true);
20✔
4155
            $condition['second'] = $this->db->protectIdentifiers($condition['second'], false, true);
20✔
4156
        }
4157

4158
        return $condition['condition'] . $condition['first'] . ' ' . $condition['operator'] . ' ' . $condition['second'];
22✔
4159
    }
4160

4161
    /**
4162
     * @used-by compileWhereHavingCondition()
4163
     *
4164
     * @param array{betweenComparison: true, condition: string, escape: bool, key: string, lowerBind: string, not: string, upperBind: string} $condition
4165
     */
4166
    private function compileBetweenComparison(array $condition): string
4167
    {
4168
        if ($condition['escape']) {
15✔
4169
            $condition['key'] = $this->db->protectIdentifiers($condition['key'], false, true);
13✔
4170
        }
4171

4172
        return $condition['condition'] . $condition['key'] . $condition['not'] . ' BETWEEN :' . $condition['lowerBind'] . ': AND :' . $condition['upperBind'] . ':';
15✔
4173
    }
4174

4175
    /**
4176
     * @used-by compileWhereHavingCondition()
4177
     *
4178
     * @param array{condition: string, datePartComparison: true, escape: bool, key: string, nullComparison?: true, nullOperator?: string, operator?: string, part: 'date'|'day'|'month'|'year', rawValue?: bool, valueBind?: string} $condition
4179
     */
4180
    private function compileDatePartComparison(array $condition): string
4181
    {
4182
        if ($condition['escape']) {
17✔
4183
            $condition['key'] = $this->db->protectIdentifiers($condition['key'], false, true);
16✔
4184
        }
4185

4186
        $expression = $this->compileDatePartExpression($condition['part'], $condition['key']);
17✔
4187

4188
        if (($condition['nullComparison'] ?? false) === true) {
17✔
4189
            return $condition['condition']
1✔
4190
                . $expression
1✔
4191
                . ' ' . $condition['nullOperator'];
1✔
4192
        }
4193

4194
        return $condition['condition']
16✔
4195
            . $expression
16✔
4196
            . ' ' . $condition['operator'] . ' '
16✔
4197
            . $this->compileDatePartValue($condition['part'], $condition['valueBind'], $condition['rawValue']);
16✔
4198
    }
4199

4200
    /**
4201
     * Compiles a driver-specific SQL expression for Query Builder date helpers.
4202
     *
4203
     * @param 'date'|'day'|'month'|'year' $part
4204
     */
4205
    protected function compileDatePartExpression(string $part, string $field): string
4206
    {
4207
        return match ($part) {
13✔
4208
            'date'  => 'CAST(' . $field . ' AS DATE)',
7✔
4209
            'year'  => 'EXTRACT(YEAR FROM ' . $field . ')',
5✔
4210
            'month' => 'EXTRACT(MONTH FROM ' . $field . ')',
4✔
4211
            'day'   => 'EXTRACT(DAY FROM ' . $field . ')',
13✔
4212
        };
13✔
4213
    }
4214

4215
    /**
4216
     * Compiles the value side for Query Builder date helpers.
4217
     *
4218
     * @param 'date'|'day'|'month'|'year' $part
4219
     */
4220
    protected function compileDatePartValue(string $part, string $bind, bool $rawValue): string
4221
    {
4222
        return ':' . $bind . ':';
16✔
4223
    }
4224

4225
    /**
4226
     * Escapes identifiers in GROUP BY statements at execution time.
4227
     *
4228
     * Required so that aliases are tracked properly, regardless of whether
4229
     * groupBy() is called prior to from(), join() and prefixTable is added
4230
     * only if needed.
4231
     */
4232
    protected function compileGroupBy(): string
4233
    {
4234
        if (! empty($this->QBGroupBy)) {
1,229✔
4235
            foreach ($this->QBGroupBy as &$groupBy) {
65✔
4236
                // Is it already compiled?
4237
                if (is_string($groupBy)) {
65✔
4238
                    continue;
3✔
4239
                }
4240

4241
                $groupBy = ($groupBy['escape'] === false || $this->isLiteral($groupBy['field']))
65✔
4242
                    ? $groupBy['field']
×
4243
                    : $this->db->protectIdentifiers($groupBy['field']);
65✔
4244
            }
4245

4246
            return "\nGROUP BY " . implode(', ', $this->QBGroupBy);
65✔
4247
        }
4248

4249
        return '';
1,194✔
4250
    }
4251

4252
    /**
4253
     * Escapes identifiers in ORDER BY statements at execution time.
4254
     *
4255
     * Required so that aliases are tracked properly, regardless of whether
4256
     * orderBy() is called prior to from(), join() and prefixTable is added
4257
     * only if needed.
4258
     */
4259
    protected function compileOrderBy(): string
4260
    {
4261
        if (is_array($this->QBOrderBy) && $this->QBOrderBy !== []) {
1,241✔
4262
            foreach ($this->QBOrderBy as &$orderBy) {
899✔
4263
                if (is_string($orderBy)) {
899✔
4264
                    continue;
1✔
4265
                }
4266
                if ($orderBy['escape'] !== false && ! $this->isLiteral($orderBy['field'])) {
899✔
4267
                    $orderBy['field'] = $this->db->protectIdentifiers($orderBy['field']);
897✔
4268
                }
4269

4270
                $orderBy = $orderBy['field'] . $orderBy['direction'];
899✔
4271
            }
4272

4273
            return "\nORDER BY " . implode(', ', $this->QBOrderBy);
899✔
4274
        }
4275

4276
        return '';
1,216✔
4277
    }
4278

4279
    protected function unionInjection(string $sql): string
4280
    {
4281
        if ($this->QBUnion === []) {
1,218✔
4282
            return $sql;
1,218✔
4283
        }
4284

4285
        return 'SELECT * FROM (' . $sql . ') '
7✔
4286
            . ($this->db->protectIdentifiers ? $this->db->escapeIdentifiers('uwrp0') : 'uwrp0')
7✔
4287
            . implode("\n", $this->QBUnion);
7✔
4288
    }
4289

4290
    /**
4291
     * Takes an object as input and converts the class variables to array key/vals
4292
     *
4293
     * @param array|object $object
4294
     *
4295
     * @return array
4296
     */
4297
    protected function objectToArray($object)
4298
    {
4299
        if (! is_object($object)) {
921✔
4300
            return $object;
917✔
4301
        }
4302

4303
        if ($object instanceof RawSql) {
9✔
4304
            throw new InvalidArgumentException('RawSql "' . $object . '" cannot be used here.');
1✔
4305
        }
4306

4307
        $array = [];
8✔
4308

4309
        foreach (get_object_vars($object) as $key => $val) {
8✔
4310
            if (($val instanceof BackedEnum || ! is_object($val) || $val instanceof RawSql) && ! is_array($val)) {
8✔
4311
                $array[$key] = $val;
8✔
4312
            }
4313
        }
4314

4315
        return $array;
8✔
4316
    }
4317

4318
    /**
4319
     * Takes an object as input and converts the class variables to array key/vals
4320
     *
4321
     * @param array|object $object
4322
     *
4323
     * @return array
4324
     */
4325
    protected function batchObjectToArray($object)
4326
    {
4327
        if (! is_object($object)) {
68✔
4328
            return $object;
68✔
4329
        }
4330

4331
        $array  = [];
×
4332
        $out    = get_object_vars($object);
×
4333
        $fields = array_keys($out);
×
4334

4335
        foreach ($fields as $val) {
×
4336
            $i = 0;
×
4337

4338
            foreach ($out[$val] as $data) {
×
4339
                $array[$i++][$val] = $data;
×
4340
            }
4341
        }
4342

4343
        return $array;
×
4344
    }
4345

4346
    /**
4347
     * Determines if a string represents a literal value or a field name
4348
     */
4349
    protected function isLiteral(string $str): bool
4350
    {
4351
        $str = trim($str);
936✔
4352

4353
        if ($str === ''
936✔
4354
            || ctype_digit($str)
936✔
4355
            || (string) (float) $str === $str
936✔
4356
            || in_array(strtoupper($str), ['TRUE', 'FALSE'], true)
936✔
4357
        ) {
4358
            return true;
×
4359
        }
4360

4361
        if ($this->isLiteralStr === []) {
936✔
4362
            $this->isLiteralStr = $this->db->escapeChar !== '"' ? ['"', "'"] : ["'"];
936✔
4363
        }
4364

4365
        return in_array($str[0], $this->isLiteralStr, true);
936✔
4366
    }
4367

4368
    /**
4369
     * Publicly-visible method to reset the QB values.
4370
     *
4371
     * @return $this
4372
     */
4373
    public function resetQuery()
4374
    {
4375
        $this->resetSelect();
1✔
4376
        $this->resetWrite();
1✔
4377

4378
        return $this;
1✔
4379
    }
4380

4381
    /**
4382
     * Resets the query builder values.  Called by the get() function
4383
     *
4384
     * @param array $qbResetItems An array of fields to reset
4385
     *
4386
     * @return void
4387
     */
4388
    protected function resetRun(array $qbResetItems)
4389
    {
4390
        foreach ($qbResetItems as $item => $defaultValue) {
1,221✔
4391
            $this->{$item} = $defaultValue;
1,221✔
4392
        }
4393
    }
4394

4395
    /**
4396
     * Resets the query builder values.  Called by the get() function
4397
     *
4398
     * @return void
4399
     */
4400
    protected function resetSelect()
4401
    {
4402
        $this->resetRun([
1,192✔
4403
            'QBSelect'              => [],
1,192✔
4404
            'QBJoin'                => [],
1,192✔
4405
            'QBWhere'               => [],
1,192✔
4406
            'QBGroupBy'             => [],
1,192✔
4407
            'QBHaving'              => [],
1,192✔
4408
            'QBOrderBy'             => [],
1,192✔
4409
            'QBNoEscape'            => [],
1,192✔
4410
            'QBDistinct'            => false,
1,192✔
4411
            'QBLimit'               => false,
1,192✔
4412
            'QBOffset'              => false,
1,192✔
4413
            'QBLockForUpdate'       => false,
1,192✔
4414
            'QBSelectUsesAggregate' => false,
1,192✔
4415
            'QBUnion'               => [],
1,192✔
4416
        ]);
1,192✔
4417

4418
        if ($this->db instanceof BaseConnection) {
1,192✔
4419
            $this->db->setAliasedTables([]);
1,192✔
4420
        }
4421

4422
        // Reset QBFrom part
4423
        if (! empty($this->QBFrom)) {
1,192✔
4424
            $this->from(array_shift($this->QBFrom), true);
1,192✔
4425
        }
4426
    }
4427

4428
    /**
4429
     * Resets the query builder "write" values.
4430
     *
4431
     * Called by the insert() update() insertBatch() updateBatch() and delete() functions
4432
     *
4433
     * @return void
4434
     */
4435
    protected function resetWrite()
4436
    {
4437
        $this->resetRun([
928✔
4438
            'QBSet'     => [],
928✔
4439
            'QBJoin'    => [],
928✔
4440
            'QBWhere'   => [],
928✔
4441
            'QBOrderBy' => [],
928✔
4442
            'QBKeys'    => [],
928✔
4443
            'QBLimit'   => false,
928✔
4444
            'QBIgnore'  => false,
928✔
4445
            'QBOptions' => [],
928✔
4446
        ]);
928✔
4447
    }
4448

4449
    /**
4450
     * Tests whether the string has an SQL operator
4451
     */
4452
    protected function hasOperator(string $str): bool
4453
    {
4454
        return preg_match(
164✔
4455
            '/(<|>|!|=|\sIS NULL|\sIS NOT NULL|\sEXISTS|\sBETWEEN|\sLIKE|\sIN\s*\(|\s)/i',
164✔
4456
            trim($str),
164✔
4457
        ) === 1;
164✔
4458
    }
4459

4460
    /**
4461
     * Returns the SQL string operator
4462
     *
4463
     * @return array|false|string
4464
     */
4465
    protected function getOperator(string $str, bool $list = false)
4466
    {
4467
        if ($this->pregOperators === []) {
1,063✔
4468
            $_les = $this->db->likeEscapeStr !== ''
1,063✔
4469
                ? '\s+' . preg_quote(trim(sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar)), '/')
1,063✔
4470
                : '';
×
4471
            $this->pregOperators = [
1,063✔
4472
                '\s*(?:<|>|!)?=\s*', // =, <=, >=, !=
1,063✔
4473
                '\s*<>?\s*',         // <, <>
1,063✔
4474
                '\s*>\s*',           // >
1,063✔
4475
                '\s+IS NULL',             // IS NULL
1,063✔
4476
                '\s+IS NOT NULL',         // IS NOT NULL
1,063✔
4477
                '\s+EXISTS\s*\(.*\)',     // EXISTS (sql)
1,063✔
4478
                '\s+NOT EXISTS\s*\(.*\)', // NOT EXISTS(sql)
1,063✔
4479
                '\s+BETWEEN\s+',          // BETWEEN value AND value
1,063✔
4480
                '\s+IN\s*\(.*\)',         // IN (list)
1,063✔
4481
                '\s+NOT IN\s*\(.*\)',     // NOT IN (list)
1,063✔
4482
                '\s+LIKE\s+\S.*(' . $_les . ')?',     // LIKE 'expr'[ ESCAPE '%s']
1,063✔
4483
                '\s+NOT LIKE\s+\S.*(' . $_les . ')?', // NOT LIKE 'expr'[ ESCAPE '%s']
1,063✔
4484
            ];
1,063✔
4485
        }
4486

4487
        return preg_match_all(
1,063✔
4488
            '/' . implode('|', $this->pregOperators) . '/i',
1,063✔
4489
            $str,
1,063✔
4490
            $match,
1,063✔
4491
        ) >= 1 ? ($list ? $match[0] : $match[0][0]) : false;
1,063✔
4492
    }
4493

4494
    /**
4495
     * Returns the SQL string operator from where key
4496
     *
4497
     * @return false|list<string>
4498
     */
4499
    private function getOperatorFromWhereKey(string $whereKey)
4500
    {
4501
        $whereKey = trim($whereKey);
1,012✔
4502

4503
        $pregOperators = [
1,012✔
4504
            '\s*(?:<|>|!)?=',         // =, <=, >=, !=
1,012✔
4505
            '\s*<>?',                 // <, <>
1,012✔
4506
            '\s*>',                   // >
1,012✔
4507
            '\s+IS NULL',             // IS NULL
1,012✔
4508
            '\s+IS NOT NULL',         // IS NOT NULL
1,012✔
4509
            '\s+EXISTS\s*\(.*\)',     // EXISTS (sql)
1,012✔
4510
            '\s+NOT EXISTS\s*\(.*\)', // NOT EXISTS (sql)
1,012✔
4511
            '\s+BETWEEN\s+',          // BETWEEN value AND value
1,012✔
4512
            '\s+IN\s*\(.*\)',         // IN (list)
1,012✔
4513
            '\s+NOT IN\s*\(.*\)',     // NOT IN (list)
1,012✔
4514
            '\s+LIKE',                // LIKE
1,012✔
4515
            '\s+NOT LIKE',            // NOT LIKE
1,012✔
4516
        ];
1,012✔
4517

4518
        return preg_match_all(
1,012✔
4519
            '/' . implode('|', $pregOperators) . '/i',
1,012✔
4520
            $whereKey,
1,012✔
4521
            $match,
1,012✔
4522
        ) >= 1 ? $match[0] : false;
1,012✔
4523
    }
4524

4525
    /**
4526
     * Stores a bind value after ensuring that it's unique.
4527
     * While it might be nicer to have named keys for our binds array
4528
     * with PHP 7+ we get a huge memory/performance gain with indexed
4529
     * arrays instead, so lets take advantage of that here.
4530
     *
4531
     * @param mixed $value
4532
     */
4533
    protected function setBind(string $key, $value = null, bool $escape = true): string
4534
    {
4535
        if (! array_key_exists($key, $this->binds)) {
1,086✔
4536
            $this->binds[$key] = [
1,086✔
4537
                $value,
1,086✔
4538
                $escape,
1,086✔
4539
            ];
1,086✔
4540

4541
            return $key;
1,086✔
4542
        }
4543

4544
        if (! array_key_exists($key, $this->bindsKeyCount)) {
63✔
4545
            $this->bindsKeyCount[$key] = 1;
63✔
4546
        }
4547

4548
        $count = $this->bindsKeyCount[$key]++;
63✔
4549

4550
        $this->binds[$key . '.' . $count] = [
63✔
4551
            $value,
63✔
4552
            $escape,
63✔
4553
        ];
63✔
4554

4555
        return $key . '.' . $count;
63✔
4556
    }
4557

4558
    /**
4559
     * @param mixed $value
4560
     */
4561
    protected function isSubquery($value): bool
4562
    {
4563
        return $value instanceof BaseBuilder || $value instanceof Closure;
1,044✔
4564
    }
4565

4566
    /**
4567
     * @param BaseBuilder|Closure(BaseBuilder): BaseBuilder $builder
4568
     * @param bool                                          $wrapped Wrap the subquery in brackets
4569
     * @param string                                        $alias   Subquery alias
4570
     */
4571
    protected function buildSubquery($builder, bool $wrapped = false, string $alias = ''): string
4572
    {
4573
        if ($builder instanceof Closure) {
35✔
4574
            $builder($builder = $this->db->newQuery());
18✔
4575
        }
4576

4577
        if ($builder === $this) {
35✔
4578
            throw new DatabaseException('The subquery cannot be the same object as the main query object.');
2✔
4579
        }
4580

4581
        $subquery = strtr($builder->getCompiledSelect(false), "\n", ' ');
33✔
4582

4583
        if ($wrapped) {
33✔
4584
            $subquery = '(' . $subquery . ')';
33✔
4585
            $alias    = trim($alias);
33✔
4586

4587
            if ($alias !== '') {
33✔
4588
                $subquery .= ' ' . ($this->db->protectIdentifiers ? $this->db->escapeIdentifiers($alias) : $alias);
17✔
4589
            }
4590
        }
4591

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

© 2026 Coveralls, Inc