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

codeigniter4 / CodeIgniter4 / 26045269388

18 May 2026 04:06PM UTC coverage: 88.459% (-0.003%) from 88.462%
26045269388

Pull #10217

github

web-flow
Merge 0645d3de8 into 0be36657e
Pull Request #10217: refactor: Query Builder `WHERE`/`HAVING` condition handling

57 of 58 new or added lines in 1 file covered. (98.28%)

46 existing lines in 1 file now uncovered.

24137 of 27286 relevant lines covered (88.46%)

219.61 hits per line

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

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

3
declare(strict_types=1);
4

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

14
namespace CodeIgniter\Database;
15

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

324
        /** @var BaseConnection $db */
325
        $this->db = $db;
1,207✔
326

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

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

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

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

369
        return $this;
82✔
370
    }
371

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

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

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

401
        return $this;
1✔
402
    }
403

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

419
        if ($select instanceof RawSql) {
928✔
420
            $select = [$select];
1✔
421
        }
422

423
        if (is_string($select)) {
928✔
424
            $select = ($escape === false) ? [$select] : explode(',', $select);
921✔
425
        }
426

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

432
                continue;
5✔
433
            }
434

435
            $val = trim($val);
926✔
436

437
            if ($val !== '') {
926✔
438
                $this->QBSelect[] = $val;
926✔
439

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

450
                    continue;
2✔
451
                }
452

453
                $this->QBNoEscape[] = $escape;
926✔
454
            }
455
        }
456

457
        return $this;
928✔
458
    }
459

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

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

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

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

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

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

517
        return $this;
2✔
518
    }
519

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

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

543
        $type = strtoupper($type);
834✔
544

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

549
        if ($alias === '') {
834✔
550
            $alias = $this->createAliasFromTable(trim($select));
829✔
551
        }
552

553
        $sql = $type . '(' . $this->db->protectIdentifiers(trim($select)) . ') AS ' . $this->db->escapeIdentifiers(trim($alias));
834✔
554

555
        $this->QBSelect[]            = $sql;
834✔
556
        $this->QBNoEscape[]          = null;
834✔
557
        $this->QBSelectUsesAggregate = true;
834✔
558

559
        return $this;
834✔
560
    }
561

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

570
            return end($item);
1✔
571
        }
572

573
        return $item;
828✔
574
    }
575

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

585
        return $this;
832✔
586
    }
587

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

602
        foreach ((array) $from as $table) {
1,207✔
603
            if (str_contains($table, ',')) {
1,207✔
604
                $this->from(explode(',', $table));
25✔
605
            } else {
606
                $table = trim($table);
1,207✔
607

608
                if ($table === '') {
1,207✔
609
                    continue;
22✔
610
                }
611

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

617
        return $this;
1,207✔
618
    }
619

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

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

633
        return $this;
6✔
634
    }
635

636
    /**
637
     * Generates the JOIN portion of the query
638
     *
639
     * @param RawSql|string $cond
640
     *
641
     * @return $this
642
     */
643
    public function join(string $table, $cond, string $type = '', ?bool $escape = null)
644
    {
645
        if ($type !== '') {
15✔
646
            $type = strtoupper(trim($type));
8✔
647

648
            if (! in_array($type, $this->joinTypes, true)) {
8✔
649
                $type = '';
×
650
            } else {
651
                $type .= ' ';
8✔
652
            }
653
        }
654

655
        // Extract any aliases that might exist. We use this information
656
        // in the protectIdentifiers to know whether to add a table prefix
657
        $this->trackAliases($table);
15✔
658

659
        if (! is_bool($escape)) {
15✔
660
            $escape = $this->db->protectIdentifiers;
15✔
661
        }
662

663
        // Do we want to escape the table name?
664
        if ($escape === true) {
15✔
665
            $table = $this->db->protectIdentifiers($table, true, null, false);
15✔
666
        }
667

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

671
            return $this;
1✔
672
        }
673

674
        if (! $this->hasOperator($cond)) {
14✔
675
            $cond = ' USING (' . ($escape ? $this->db->escapeIdentifiers($cond) : $cond) . ')';
×
676
        } elseif ($escape === false) {
14✔
677
            $cond = ' ON ' . $cond;
×
678
        } else {
679
            // Split multiple conditions
680
            // @TODO This does not parse `BETWEEN a AND b` correctly.
681
            if (preg_match_all('/\sAND\s|\sOR\s/i', $cond, $joints, PREG_OFFSET_CAPTURE) >= 1) {
14✔
682
                $conditions = [];
2✔
683
                $joints     = $joints[0];
2✔
684
                array_unshift($joints, ['', 0]);
2✔
685

686
                for ($i = count($joints) - 1, $pos = strlen($cond); $i >= 0; $i--) {
2✔
687
                    $joints[$i][1] += strlen($joints[$i][0]); // offset
2✔
688
                    $conditions[$i] = substr($cond, $joints[$i][1], $pos - $joints[$i][1]);
2✔
689
                    $pos            = $joints[$i][1] - strlen($joints[$i][0]);
2✔
690
                    $joints[$i]     = $joints[$i][0];
2✔
691
                }
692
                ksort($conditions);
2✔
693
            } else {
694
                $conditions = [$cond];
12✔
695
                $joints     = [''];
12✔
696
            }
697

698
            $cond = ' ON ';
14✔
699

700
            foreach ($conditions as $i => $condition) {
14✔
701
                $operator = $this->getOperator($condition);
14✔
702

703
                // Workaround for BETWEEN
704
                if ($operator === false) {
14✔
705
                    $cond .= $joints[$i] . $condition;
1✔
706

707
                    continue;
1✔
708
                }
709

710
                $cond .= $joints[$i];
14✔
711
                $cond .= preg_match('/(\(*)?([\[\]\w\.\'-]+)' . preg_quote($operator, '/') . '(.*)/i', $condition, $match) ? $match[1] . $this->db->protectIdentifiers($match[2]) . $operator . $this->db->protectIdentifiers($match[3]) : $condition;
14✔
712
            }
713
        }
714

715
        // Assemble the JOIN statement
716
        $this->QBJoin[] = $type . 'JOIN ' . $table . $cond;
14✔
717

718
        return $this;
14✔
719
    }
720

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

735
    /**
736
     * OR WHERE
737
     *
738
     * Generates the WHERE portion of the query.
739
     * Separates multiple calls with 'OR'.
740
     *
741
     * @param array|RawSql|string $key
742
     * @param mixed               $value
743
     *
744
     * @return $this
745
     */
746
    public function orWhere($key, $value = null, ?bool $escape = null)
747
    {
748
        return $this->whereHaving('QBWhere', $key, $value, 'OR ', $escape);
5✔
749
    }
750

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

767
    /**
768
     * Generates an OR WHERE clause that compares two columns.
769
     *
770
     * @param non-empty-string $first  First column name, optionally with comparison operator
771
     * @param non-empty-string $second Second column name
772
     * @param bool|null        $escape Whether to protect identifiers
773
     *
774
     * @return $this
775
     *
776
     * @throws InvalidArgumentException
777
     */
778
    public function orWhereColumn(string $first, string $second, ?bool $escape = null): static
779
    {
780
        return $this->whereColumnHaving('QBWhere', $first, $second, 'OR ', $escape);
2✔
781
    }
782

783
    /**
784
     * Generates a WHERE EXISTS subquery.
785
     *
786
     * @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery
787
     *
788
     * @return $this
789
     */
790
    public function whereExists($subquery): static
791
    {
792
        return $this->whereExistsSubquery($subquery);
9✔
793
    }
794

795
    /**
796
     * Generates an OR WHERE EXISTS subquery.
797
     *
798
     * @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery
799
     *
800
     * @return $this
801
     */
802
    public function orWhereExists($subquery): static
803
    {
804
        return $this->whereExistsSubquery($subquery, false, 'OR ');
1✔
805
    }
806

807
    /**
808
     * Generates a WHERE NOT EXISTS subquery.
809
     *
810
     * @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery
811
     *
812
     * @return $this
813
     */
814
    public function whereNotExists($subquery): static
815
    {
816
        return $this->whereExistsSubquery($subquery, true);
1✔
817
    }
818

819
    /**
820
     * Generates an OR WHERE NOT EXISTS subquery.
821
     *
822
     * @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery
823
     *
824
     * @return $this
825
     */
826
    public function orWhereNotExists($subquery): static
827
    {
828
        return $this->whereExistsSubquery($subquery, true, 'OR ');
2✔
829
    }
830

831
    /**
832
     * @used-by whereColumn()
833
     * @used-by orWhereColumn()
834
     *
835
     * @param 'QBHaving'|'QBWhere' $qbKey
836
     * @param non-empty-string     $first  First column name, optionally with comparison operator
837
     * @param non-empty-string     $second Second column name
838
     * @param non-empty-string     $type
839
     * @param bool|null            $escape Whether to protect identifiers
840
     *
841
     * @return $this
842
     *
843
     * @throws InvalidArgumentException
844
     */
845
    protected function whereColumnHaving(string $qbKey, string $first, string $second, string $type = 'AND ', ?bool $escape = null): static
846
    {
847
        [$first, $operator] = $this->parseWhereColumnFirst($first);
24✔
848
        $second             = trim($second);
24✔
849

850
        if ($first === '' || $second === '') {
24✔
851
            $caller = debug_backtrace(0, 2)[1]['function'];
2✔
852

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

856
        $escape ??= $this->db->protectIdentifiers;
22✔
857

858
        $this->addWhereHavingCondition($qbKey, [
22✔
859
            'columnComparison' => true,
22✔
860
            'condition'        => '',
22✔
861
            'escape'           => $escape,
22✔
862
            'first'            => $first,
22✔
863
            'operator'         => $operator,
22✔
864
            'second'           => $second,
22✔
865
        ], $type);
22✔
866

867
        return $this;
22✔
868
    }
869

870
    /**
871
     * Extracts the operator from the first whereColumn() column.
872
     *
873
     * @param string $first The first column, optionally ending with a comparison operator
874
     *
875
     * @return array{string, string}
876
     */
877
    private function parseWhereColumnFirst(string $first): array
878
    {
879
        $first = trim($first);
24✔
880

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

885
        return [$first, '='];
13✔
886
    }
887

888
    /**
889
     * Generates a WHERE field BETWEEN minimum AND maximum SQL query,
890
     * joined with 'AND' if appropriate.
891
     *
892
     * @param array<array-key, mixed>|null $values The range values searched on
893
     *
894
     * @return $this
895
     *
896
     * @throws InvalidArgumentException
897
     */
898
    public function whereBetween(?string $key = null, $values = null, ?bool $escape = null): static
899
    {
900
        return $this->whereBetweenHaving('QBWhere', $key, $values, false, 'AND ', $escape);
12✔
901
    }
902

903
    /**
904
     * Generates a WHERE field BETWEEN minimum AND maximum SQL query,
905
     * joined with 'OR' if appropriate.
906
     *
907
     * @param array<array-key, mixed>|null $values The range values searched on
908
     *
909
     * @return $this
910
     *
911
     * @throws InvalidArgumentException
912
     */
913
    public function orWhereBetween(?string $key = null, $values = null, ?bool $escape = null): static
914
    {
915
        return $this->whereBetweenHaving('QBWhere', $key, $values, false, 'OR ', $escape);
1✔
916
    }
917

918
    /**
919
     * Generates a WHERE field NOT BETWEEN minimum AND maximum SQL query,
920
     * joined with 'AND' if appropriate.
921
     *
922
     * @param array<array-key, mixed>|null $values The range values searched on
923
     *
924
     * @return $this
925
     *
926
     * @throws InvalidArgumentException
927
     */
928
    public function whereNotBetween(?string $key = null, $values = null, ?bool $escape = null): static
929
    {
930
        return $this->whereBetweenHaving('QBWhere', $key, $values, true, 'AND ', $escape);
1✔
931
    }
932

933
    /**
934
     * Generates a WHERE field NOT BETWEEN minimum AND maximum SQL query,
935
     * joined with 'OR' if appropriate.
936
     *
937
     * @param array<array-key, mixed>|null $values The range values searched on
938
     *
939
     * @return $this
940
     *
941
     * @throws InvalidArgumentException
942
     */
943
    public function orWhereNotBetween(?string $key = null, $values = null, ?bool $escape = null): static
944
    {
945
        return $this->whereBetweenHaving('QBWhere', $key, $values, true, 'OR ', $escape);
2✔
946
    }
947

948
    /**
949
     * @used-by whereBetween()
950
     * @used-by orWhereBetween()
951
     * @used-by whereNotBetween()
952
     * @used-by orWhereNotBetween()
953
     *
954
     * @param 'QBHaving'|'QBWhere'         $qbKey
955
     * @param non-empty-string|null        $key
956
     * @param array<array-key, mixed>|null $values The range values searched on
957
     *
958
     * @return $this
959
     *
960
     * @throws InvalidArgumentException
961
     */
962
    private function whereBetweenHaving(string $qbKey, ?string $key = null, $values = null, bool $not = false, string $type = 'AND ', ?bool $escape = null): static
963
    {
964
        if ($key === null || $key === '') {
15✔
965
            throw new InvalidArgumentException(sprintf('%s() expects $key to be a non-empty string', debug_backtrace(0, 2)[1]['function']));
2✔
966
        }
967

968
        if (! is_array($values) || count($values) !== 2) {
13✔
969
            throw new InvalidArgumentException(sprintf('%s() expects $values to be an array containing exactly two values', debug_backtrace(0, 2)[1]['function']));
5✔
970
        }
971

972
        $escape ??= $this->db->protectIdentifiers;
8✔
973
        $values = array_values($values);
8✔
974

975
        $lowerBind = $this->setBind($key, $values[0], $escape);
8✔
976
        $upperBind = $this->setBind($key, $values[1], $escape);
8✔
977
        $not       = $not ? ' NOT' : '';
8✔
978
        $this->addWhereHavingCondition($qbKey, [
8✔
979
            'betweenComparison' => true,
8✔
980
            'condition'         => '',
8✔
981
            'escape'            => $escape,
8✔
982
            'key'               => $key,
8✔
983
            'lowerBind'         => $lowerBind,
8✔
984
            'not'               => $not,
8✔
985
            'upperBind'         => $upperBind,
8✔
986
        ], $type);
8✔
987

988
        return $this;
8✔
989
    }
990

991
    /**
992
     * @used-by whereExists()
993
     * @used-by orWhereExists()
994
     * @used-by whereNotExists()
995
     * @used-by orWhereNotExists()
996
     *
997
     * @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery
998
     *
999
     * @return $this
1000
     *
1001
     * @throws InvalidArgumentException
1002
     */
1003
    protected function whereExistsSubquery($subquery, bool $not = false, string $type = 'AND '): static
1004
    {
1005
        if (! $this->isSubquery($subquery)) {
12✔
1006
            throw new InvalidArgumentException(sprintf('%s() expects $subquery to be of type BaseBuilder or closure', debug_backtrace(0, 2)[1]['function']));
4✔
1007
        }
1008

1009
        $operator = $not ? 'NOT EXISTS' : 'EXISTS';
8✔
1010

1011
        $this->addWhereHavingCondition('QBWhere', [
8✔
1012
            'condition' => "{$operator} {$this->buildSubquery($subquery, true)}",
8✔
1013
            'escape'    => false,
8✔
1014
        ], $type);
8✔
1015

1016
        return $this;
7✔
1017
    }
1018

1019
    /**
1020
     * @used-by where()
1021
     * @used-by orWhere()
1022
     * @used-by having()
1023
     * @used-by orHaving()
1024
     *
1025
     * @param array|RawSql|string $key
1026
     * @param mixed               $value
1027
     *
1028
     * @return $this
1029
     */
1030
    protected function whereHaving(string $qbKey, $key, $value = null, string $type = 'AND ', ?bool $escape = null)
1031
    {
1032
        $rawSqlOnly = false;
962✔
1033

1034
        if ($key instanceof RawSql) {
962✔
1035
            if ($value === null) {
4✔
1036
                $keyValue   = [(string) $key => $key];
1✔
1037
                $rawSqlOnly = true;
1✔
1038
            } else {
1039
                $keyValue = [(string) $key => $value];
3✔
1040
            }
1041
        } elseif (! is_array($key)) {
958✔
1042
            $keyValue = [$key => $value];
948✔
1043
        } else {
1044
            $keyValue = $key;
243✔
1045
        }
1046

1047
        // If the escape value was not set will base it on the global setting
1048
        if (! is_bool($escape)) {
962✔
1049
            $escape = $this->db->protectIdentifiers;
956✔
1050
        }
1051

1052
        foreach ($keyValue as $k => $v) {
962✔
1053
            if ($rawSqlOnly) {
962✔
1054
                $k  = '';
1✔
1055
                $op = '';
1✔
1056
            } elseif ($v !== null) {
961✔
1057
                $op = $this->getOperatorFromWhereKey($k);
939✔
1058

1059
                if (! empty($op)) {
939✔
1060
                    $k = trim($k);
54✔
1061

1062
                    end($op);
54✔
1063
                    $op = trim(current($op));
54✔
1064

1065
                    // Does the key end with operator?
1066
                    if (str_ends_with($k, $op)) {
54✔
1067
                        $k  = rtrim(substr($k, 0, -strlen($op)));
54✔
1068
                        $op = " {$op}";
54✔
1069
                    } else {
1070
                        $op = '';
×
1071
                    }
1072
                } else {
1073
                    $op = ' =';
922✔
1074
                }
1075

1076
                if ($this->isSubquery($v)) {
939✔
1077
                    $v = $this->buildSubquery($v, true);
1✔
1078
                } else {
1079
                    $bind = $this->setBind($k, $v, $escape);
939✔
1080
                    $v    = " :{$bind}:";
939✔
1081
                }
1082
            } elseif (! $this->hasOperator($k) && $qbKey !== 'QBHaving') {
149✔
1083
                // value appears not to have been set, assign the test to IS NULL
1084
                $op = ' IS NULL';
95✔
1085
            } elseif (
1086
                // The key ends with !=, =, <>, IS, IS NOT
1087
                preg_match(
63✔
1088
                    '/\s*(!?=|<>|IS(?:\s+NOT)?)\s*$/i',
63✔
1089
                    $k,
63✔
1090
                    $match,
63✔
1091
                    PREG_OFFSET_CAPTURE,
63✔
1092
                )
63✔
1093
            ) {
1094
                $k  = substr($k, 0, $match[0][1]);
3✔
1095
                $op = $match[1][0] === '=' ? ' IS NULL' : ' IS NOT NULL';
3✔
1096
            } else {
1097
                $op = '';
60✔
1098
            }
1099

1100
            $condition = $k . $op . $v;
962✔
1101

1102
            if ($v instanceof RawSql) {
962✔
1103
                $condition = $v->with($condition);
1✔
1104
            }
1105

1106
            $this->addWhereHavingCondition($qbKey, [
962✔
1107
                'condition' => $condition,
962✔
1108
                'escape'    => $escape,
962✔
1109
            ], $type);
962✔
1110
        }
1111

1112
        return $this;
962✔
1113
    }
1114

1115
    /**
1116
     * @param array<string, mixed> $condition
1117
     */
1118
    private function addWhereHavingCondition(string $clause, array $condition, string $type): void
1119
    {
1120
        $prefix = $this->getWhereHavingPrefix($clause, $type);
987✔
1121

1122
        if ($condition['condition'] instanceof RawSql) {
987✔
1123
            $condition['condition'] = $condition['condition']->with($prefix . $condition['condition']);
1✔
1124
        } else {
1125
            $condition['condition'] = $prefix . $condition['condition'];
986✔
1126
        }
1127

1128
        $this->{$clause}[] = $condition;
987✔
1129
    }
1130

1131
    private function getWhereHavingPrefix(string $clause, string $type): string
1132
    {
1133
        return $this->{$clause} === [] ? $this->groupGetType('') : $this->groupGetType($type);
1,010✔
1134
    }
1135

1136
    /**
1137
     * Generates a WHERE field IN('item', 'item') SQL query,
1138
     * joined with 'AND' if appropriate.
1139
     *
1140
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1141
     *
1142
     * @return $this
1143
     */
1144
    public function whereIn(?string $key = null, $values = null, ?bool $escape = null)
1145
    {
1146
        return $this->_whereIn($key, $values, false, 'AND ', $escape);
66✔
1147
    }
1148

1149
    /**
1150
     * Generates a WHERE field IN('item', 'item') SQL query,
1151
     * joined with 'OR' if appropriate.
1152
     *
1153
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1154
     *
1155
     * @return $this
1156
     */
1157
    public function orWhereIn(?string $key = null, $values = null, ?bool $escape = null)
1158
    {
1159
        return $this->_whereIn($key, $values, false, 'OR ', $escape);
3✔
1160
    }
1161

1162
    /**
1163
     * Generates a WHERE field NOT IN('item', 'item') SQL query,
1164
     * joined with 'AND' if appropriate.
1165
     *
1166
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1167
     *
1168
     * @return $this
1169
     */
1170
    public function whereNotIn(?string $key = null, $values = null, ?bool $escape = null)
1171
    {
1172
        return $this->_whereIn($key, $values, true, 'AND ', $escape);
3✔
1173
    }
1174

1175
    /**
1176
     * Generates a WHERE field NOT IN('item', 'item') SQL query,
1177
     * joined with 'OR' if appropriate.
1178
     *
1179
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1180
     *
1181
     * @return $this
1182
     */
1183
    public function orWhereNotIn(?string $key = null, $values = null, ?bool $escape = null)
1184
    {
1185
        return $this->_whereIn($key, $values, true, 'OR ', $escape);
2✔
1186
    }
1187

1188
    /**
1189
     * Generates a HAVING field IN('item', 'item') SQL query,
1190
     * joined with 'AND' if appropriate.
1191
     *
1192
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1193
     *
1194
     * @return $this
1195
     */
1196
    public function havingIn(?string $key = null, $values = null, ?bool $escape = null)
1197
    {
1198
        return $this->_whereIn($key, $values, false, 'AND ', $escape, 'QBHaving');
6✔
1199
    }
1200

1201
    /**
1202
     * Generates a HAVING field IN('item', 'item') SQL query,
1203
     * joined with 'OR' if appropriate.
1204
     *
1205
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1206
     *
1207
     * @return $this
1208
     */
1209
    public function orHavingIn(?string $key = null, $values = null, ?bool $escape = null)
1210
    {
1211
        return $this->_whereIn($key, $values, false, 'OR ', $escape, 'QBHaving');
3✔
1212
    }
1213

1214
    /**
1215
     * Generates a HAVING field NOT IN('item', 'item') SQL query,
1216
     * joined with 'AND' if appropriate.
1217
     *
1218
     * @param array|BaseBuilder|(Closure(BaseBuilder):BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1219
     *
1220
     * @return $this
1221
     */
1222
    public function havingNotIn(?string $key = null, $values = null, ?bool $escape = null)
1223
    {
1224
        return $this->_whereIn($key, $values, true, 'AND ', $escape, 'QBHaving');
5✔
1225
    }
1226

1227
    /**
1228
     * Generates a HAVING field NOT IN('item', 'item') SQL query,
1229
     * joined with 'OR' if appropriate.
1230
     *
1231
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1232
     *
1233
     * @return $this
1234
     */
1235
    public function orHavingNotIn(?string $key = null, $values = null, ?bool $escape = null)
1236
    {
1237
        return $this->_whereIn($key, $values, true, 'OR ', $escape, 'QBHaving');
3✔
1238
    }
1239

1240
    /**
1241
     * @used-by WhereIn()
1242
     * @used-by orWhereIn()
1243
     * @used-by whereNotIn()
1244
     * @used-by orWhereNotIn()
1245
     *
1246
     * @param non-empty-string|null                                            $key
1247
     * @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|list<mixed>|null $values The values searched on, or anonymous function with subquery
1248
     *
1249
     * @return $this
1250
     *
1251
     * @throws InvalidArgumentException
1252
     */
1253
    protected function _whereIn(?string $key = null, $values = null, bool $not = false, string $type = 'AND ', ?bool $escape = null, string $clause = 'QBWhere')
1254
    {
1255
        if ($key === null || $key === '') {
86✔
1256
            throw new InvalidArgumentException(sprintf('%s() expects $key to be a non-empty string', debug_backtrace(0, 2)[1]['function']));
2✔
1257
        }
1258

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

1263
        if (! is_bool($escape)) {
81✔
1264
            $escape = $this->db->protectIdentifiers;
81✔
1265
        }
1266

1267
        $ok = $key;
81✔
1268

1269
        if ($escape === true) {
81✔
1270
            $key = $this->db->protectIdentifiers($key);
81✔
1271
        }
1272

1273
        $not = ($not) ? ' NOT' : '';
81✔
1274

1275
        if ($this->isSubquery($values)) {
81✔
1276
            $whereIn = $this->buildSubquery($values, true);
8✔
1277
            $escape  = false;
8✔
1278
        } else {
1279
            $whereIn = array_values($values);
73✔
1280
        }
1281

1282
        $ok = $this->setBind($ok, $whereIn, $escape);
81✔
1283

1284
        $this->addWhereHavingCondition($clause, [
81✔
1285
            'condition' => "{$key}{$not} IN :{$ok}:",
81✔
1286
            'escape'    => false,
81✔
1287
        ], $type);
81✔
1288

1289
        return $this;
81✔
1290
    }
1291

1292
    /**
1293
     * Generates a %LIKE% portion of the query.
1294
     * Separates multiple calls with 'AND'.
1295
     *
1296
     * @param array|RawSql|string $field
1297
     *
1298
     * @return $this
1299
     */
1300
    public function like($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1301
    {
1302
        return $this->_like($field, $match, 'AND ', $side, '', $escape, $insensitiveSearch);
25✔
1303
    }
1304

1305
    /**
1306
     * Generates a NOT LIKE portion of the query.
1307
     * Separates multiple calls with 'AND'.
1308
     *
1309
     * @param array|RawSql|string $field
1310
     *
1311
     * @return $this
1312
     */
1313
    public function notLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1314
    {
1315
        return $this->_like($field, $match, 'AND ', $side, 'NOT', $escape, $insensitiveSearch);
2✔
1316
    }
1317

1318
    /**
1319
     * Generates a %LIKE% portion of the query.
1320
     * Separates multiple calls with 'OR'.
1321
     *
1322
     * @param array|RawSql|string $field
1323
     *
1324
     * @return $this
1325
     */
1326
    public function orLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1327
    {
1328
        return $this->_like($field, $match, 'OR ', $side, '', $escape, $insensitiveSearch);
2✔
1329
    }
1330

1331
    /**
1332
     * Generates a NOT LIKE portion of the query.
1333
     * Separates multiple calls with 'OR'.
1334
     *
1335
     * @param array|RawSql|string $field
1336
     *
1337
     * @return $this
1338
     */
1339
    public function orNotLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1340
    {
1341
        return $this->_like($field, $match, 'OR ', $side, 'NOT', $escape, $insensitiveSearch);
2✔
1342
    }
1343

1344
    /**
1345
     * Generates a %LIKE% portion of the query.
1346
     * Separates multiple calls with 'AND'.
1347
     *
1348
     * @param array|RawSql|string $field
1349
     *
1350
     * @return $this
1351
     */
1352
    public function havingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1353
    {
1354
        return $this->_like($field, $match, 'AND ', $side, '', $escape, $insensitiveSearch, 'QBHaving');
15✔
1355
    }
1356

1357
    /**
1358
     * Generates a NOT LIKE portion of the query.
1359
     * Separates multiple calls with 'AND'.
1360
     *
1361
     * @param array|RawSql|string $field
1362
     *
1363
     * @return $this
1364
     */
1365
    public function notHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1366
    {
1367
        return $this->_like($field, $match, 'AND ', $side, 'NOT', $escape, $insensitiveSearch, 'QBHaving');
4✔
1368
    }
1369

1370
    /**
1371
     * Generates a %LIKE% portion of the query.
1372
     * Separates multiple calls with 'OR'.
1373
     *
1374
     * @param array|RawSql|string $field
1375
     *
1376
     * @return $this
1377
     */
1378
    public function orHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1379
    {
1380
        return $this->_like($field, $match, 'OR ', $side, '', $escape, $insensitiveSearch, 'QBHaving');
4✔
1381
    }
1382

1383
    /**
1384
     * Generates a NOT LIKE portion of the query.
1385
     * Separates multiple calls with 'OR'.
1386
     *
1387
     * @param array|RawSql|string $field
1388
     *
1389
     * @return $this
1390
     */
1391
    public function orNotHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1392
    {
1393
        return $this->_like($field, $match, 'OR ', $side, 'NOT', $escape, $insensitiveSearch, 'QBHaving');
4✔
1394
    }
1395

1396
    /**
1397
     * @used-by like()
1398
     * @used-by orLike()
1399
     * @used-by notLike()
1400
     * @used-by orNotLike()
1401
     * @used-by havingLike()
1402
     * @used-by orHavingLike()
1403
     * @used-by notHavingLike()
1404
     * @used-by orNotHavingLike()
1405
     *
1406
     * @param array<string, string>|RawSql|string $field
1407
     *
1408
     * @return $this
1409
     */
1410
    protected function _like($field, string $match = '', string $type = 'AND ', string $side = 'both', string $not = '', ?bool $escape = null, bool $insensitiveSearch = false, string $clause = 'QBWhere')
1411
    {
1412
        $escape = is_bool($escape) ? $escape : $this->db->protectIdentifiers;
47✔
1413
        $side   = strtolower($side);
47✔
1414

1415
        if ($field instanceof RawSql) {
47✔
1416
            $k                 = (string) $field;
3✔
1417
            $v                 = $match;
3✔
1418
            $insensitiveSearch = false;
3✔
1419

1420
            $prefix = $this->getWhereHavingPrefix($clause, $type);
3✔
1421

1422
            if ($side === 'none') {
3✔
NEW
1423
                $bind = $this->setBind($field->getBindingKey(), $v, $escape);
×
1424
            } elseif ($side === 'before') {
3✔
UNCOV
1425
                $bind = $this->setBind($field->getBindingKey(), "%{$v}", $escape);
×
1426
            } elseif ($side === 'after') {
3✔
UNCOV
1427
                $bind = $this->setBind($field->getBindingKey(), "{$v}%", $escape);
×
1428
            } else {
1429
                $bind = $this->setBind($field->getBindingKey(), "%{$v}%", $escape);
3✔
1430
            }
1431

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

1434
            // some platforms require an escape sequence definition for LIKE wildcards
1435
            if ($escape === true && $this->db->likeEscapeStr !== '') {
3✔
1436
                $likeStatement .= sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar);
3✔
1437
            }
1438

1439
            $this->{$clause}[] = [
3✔
1440
                'condition' => $field->with($likeStatement),
3✔
1441
                'escape'    => $escape,
3✔
1442
            ];
3✔
1443

1444
            return $this;
3✔
1445
        }
1446

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

1449
        foreach ($keyValue as $k => $v) {
44✔
1450
            if ($insensitiveSearch) {
44✔
1451
                $v = mb_strtolower($v, 'UTF-8');
7✔
1452
            }
1453

1454
            $prefix = $this->getWhereHavingPrefix($clause, $type);
44✔
1455

1456
            if ($side === 'none') {
44✔
1457
                $bind = $this->setBind($k, $v, $escape);
1✔
1458
            } elseif ($side === 'before') {
43✔
1459
                $bind = $this->setBind($k, "%{$v}", $escape);
9✔
1460
            } elseif ($side === 'after') {
34✔
1461
                $bind = $this->setBind($k, "{$v}%", $escape);
5✔
1462
            } else {
1463
                $bind = $this->setBind($k, "%{$v}%", $escape);
29✔
1464
            }
1465

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

1468
            // some platforms require an escape sequence definition for LIKE wildcards
1469
            if ($escape === true && $this->db->likeEscapeStr !== '') {
44✔
1470
                $likeStatement .= sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar);
44✔
1471
            }
1472

1473
            $this->{$clause}[] = [
44✔
1474
                'condition' => $likeStatement,
44✔
1475
                'escape'    => $escape,
44✔
1476
            ];
44✔
1477
        }
1478

1479
        return $this;
44✔
1480
    }
1481

1482
    /**
1483
     * Platform independent LIKE statement builder.
1484
     */
1485
    protected function _like_statement(?string $prefix, string $column, ?string $not, string $bind, bool $insensitiveSearch = false): string
1486
    {
1487
        if ($insensitiveSearch) {
47✔
1488
            return "{$prefix} LOWER(" . $this->db->escapeIdentifiers($column) . ") {$not} LIKE :{$bind}:";
7✔
1489
        }
1490

1491
        return "{$prefix} {$column} {$not} LIKE :{$bind}:";
40✔
1492
    }
1493

1494
    /**
1495
     * Add UNION statement
1496
     *
1497
     * @param BaseBuilder|Closure(BaseBuilder): BaseBuilder $union
1498
     *
1499
     * @return $this
1500
     */
1501
    public function union($union)
1502
    {
1503
        return $this->addUnionStatement($union);
6✔
1504
    }
1505

1506
    /**
1507
     * Add UNION ALL statement
1508
     *
1509
     * @param BaseBuilder|Closure(BaseBuilder): BaseBuilder $union
1510
     *
1511
     * @return $this
1512
     */
1513
    public function unionAll($union)
1514
    {
1515
        return $this->addUnionStatement($union, true);
2✔
1516
    }
1517

1518
    /**
1519
     * @used-by union()
1520
     * @used-by unionAll()
1521
     *
1522
     * @param BaseBuilder|Closure(BaseBuilder): BaseBuilder $union
1523
     *
1524
     * @return $this
1525
     */
1526
    protected function addUnionStatement($union, bool $all = false)
1527
    {
1528
        $this->QBUnion[] = "\nUNION "
8✔
1529
            . ($all ? 'ALL ' : '')
8✔
1530
            . 'SELECT * FROM '
8✔
1531
            . $this->buildSubquery($union, true, 'uwrp' . (count($this->QBUnion) + 1));
8✔
1532

1533
        return $this;
8✔
1534
    }
1535

1536
    /**
1537
     * Starts a query group.
1538
     *
1539
     * @return $this
1540
     */
1541
    public function groupStart()
1542
    {
1543
        return $this->groupStartPrepare();
5✔
1544
    }
1545

1546
    /**
1547
     * Starts a query group, but ORs the group
1548
     *
1549
     * @return $this
1550
     */
1551
    public function orGroupStart()
1552
    {
1553
        return $this->groupStartPrepare('', 'OR ');
2✔
1554
    }
1555

1556
    /**
1557
     * Starts a query group, but NOTs the group
1558
     *
1559
     * @return $this
1560
     */
1561
    public function notGroupStart()
1562
    {
1563
        return $this->groupStartPrepare('NOT ');
2✔
1564
    }
1565

1566
    /**
1567
     * Starts a query group, but OR NOTs the group
1568
     *
1569
     * @return $this
1570
     */
1571
    public function orNotGroupStart()
1572
    {
1573
        return $this->groupStartPrepare('NOT ', 'OR ');
2✔
1574
    }
1575

1576
    /**
1577
     * Ends a query group
1578
     *
1579
     * @return $this
1580
     */
1581
    public function groupEnd()
1582
    {
1583
        return $this->groupEndPrepare();
11✔
1584
    }
1585

1586
    /**
1587
     * Starts a query group for HAVING clause.
1588
     *
1589
     * @return $this
1590
     */
1591
    public function havingGroupStart()
1592
    {
1593
        return $this->groupStartPrepare('', 'AND ', 'QBHaving');
2✔
1594
    }
1595

1596
    /**
1597
     * Starts a query group for HAVING clause, but ORs the group.
1598
     *
1599
     * @return $this
1600
     */
1601
    public function orHavingGroupStart()
1602
    {
1603
        return $this->groupStartPrepare('', 'OR ', 'QBHaving');
2✔
1604
    }
1605

1606
    /**
1607
     * Starts a query group for HAVING clause, but NOTs the group.
1608
     *
1609
     * @return $this
1610
     */
1611
    public function notHavingGroupStart()
1612
    {
1613
        return $this->groupStartPrepare('NOT ', 'AND ', 'QBHaving');
2✔
1614
    }
1615

1616
    /**
1617
     * Starts a query group for HAVING clause, but OR NOTs the group.
1618
     *
1619
     * @return $this
1620
     */
1621
    public function orNotHavingGroupStart()
1622
    {
1623
        return $this->groupStartPrepare('NOT ', 'OR ', 'QBHaving');
2✔
1624
    }
1625

1626
    /**
1627
     * Ends a query group for HAVING clause.
1628
     *
1629
     * @return $this
1630
     */
1631
    public function havingGroupEnd()
1632
    {
1633
        return $this->groupEndPrepare('QBHaving');
8✔
1634
    }
1635

1636
    /**
1637
     * Prepate a query group start.
1638
     *
1639
     * @return $this
1640
     */
1641
    protected function groupStartPrepare(string $not = '', string $type = 'AND ', string $clause = 'QBWhere')
1642
    {
1643
        $type = $this->groupGetType($type);
19✔
1644

1645
        $this->QBWhereGroupStarted = true;
19✔
1646
        $prefix                    = empty($this->{$clause}) ? '' : $type;
19✔
1647
        $where                     = [
19✔
1648
            'condition' => $prefix . $not . str_repeat(' ', ++$this->QBWhereGroupCount) . ' (',
19✔
1649
            'escape'    => false,
19✔
1650
        ];
19✔
1651

1652
        $this->{$clause}[] = $where;
19✔
1653

1654
        return $this;
19✔
1655
    }
1656

1657
    /**
1658
     * Prepate a query group end.
1659
     *
1660
     * @return $this
1661
     */
1662
    protected function groupEndPrepare(string $clause = 'QBWhere')
1663
    {
1664
        $this->QBWhereGroupStarted = false;
19✔
1665
        $where                     = [
19✔
1666
            'condition' => str_repeat(' ', $this->QBWhereGroupCount--) . ')',
19✔
1667
            'escape'    => false,
19✔
1668
        ];
19✔
1669

1670
        $this->{$clause}[] = $where;
19✔
1671

1672
        return $this;
19✔
1673
    }
1674

1675
    /**
1676
     * @used-by groupStart()
1677
     * @used-by _like()
1678
     * @used-by whereHaving()
1679
     * @used-by _whereIn()
1680
     * @used-by whereColumnHaving()
1681
     * @used-by havingGroupStart()
1682
     */
1683
    protected function groupGetType(string $type): string
1684
    {
1685
        if ($this->QBWhereGroupStarted) {
1,010✔
1686
            $type                      = '';
19✔
1687
            $this->QBWhereGroupStarted = false;
19✔
1688
        }
1689

1690
        return $type;
1,010✔
1691
    }
1692

1693
    /**
1694
     * @param array|string $by
1695
     *
1696
     * @return $this
1697
     */
1698
    public function groupBy($by, ?bool $escape = null)
1699
    {
1700
        if (! is_bool($escape)) {
58✔
1701
            $escape = $this->db->protectIdentifiers;
58✔
1702
        }
1703

1704
        if (is_string($by)) {
58✔
1705
            $by = ($escape === true) ? explode(',', $by) : [$by];
58✔
1706
        }
1707

1708
        foreach ($by as $val) {
58✔
1709
            $val = trim($val);
58✔
1710

1711
            if ($val !== '') {
58✔
1712
                $val = [
58✔
1713
                    'field'  => $val,
58✔
1714
                    'escape' => $escape,
58✔
1715
                ];
58✔
1716

1717
                $this->QBGroupBy[] = $val;
58✔
1718
            }
1719
        }
1720

1721
        return $this;
58✔
1722
    }
1723

1724
    /**
1725
     * Separates multiple calls with 'AND'.
1726
     *
1727
     * @param array|RawSql|string $key
1728
     * @param mixed               $value
1729
     *
1730
     * @return $this
1731
     */
1732
    public function having($key, $value = null, ?bool $escape = null)
1733
    {
1734
        return $this->whereHaving('QBHaving', $key, $value, 'AND ', $escape);
18✔
1735
    }
1736

1737
    /**
1738
     * Separates multiple calls with 'OR'.
1739
     *
1740
     * @param array|RawSql|string $key
1741
     * @param mixed               $value
1742
     *
1743
     * @return $this
1744
     */
1745
    public function orHaving($key, $value = null, ?bool $escape = null)
1746
    {
1747
        return $this->whereHaving('QBHaving', $key, $value, 'OR ', $escape);
2✔
1748
    }
1749

1750
    /**
1751
     * @param string $direction ASC, DESC or RANDOM
1752
     *
1753
     * @return $this
1754
     */
1755
    public function orderBy(string $orderBy, string $direction = '', ?bool $escape = null)
1756
    {
1757
        if ($orderBy === '') {
852✔
UNCOV
1758
            return $this;
×
1759
        }
1760

1761
        $qbOrderBy = [];
852✔
1762

1763
        $direction = strtoupper(trim($direction));
852✔
1764

1765
        if ($direction === 'RANDOM') {
852✔
1766
            $direction = '';
3✔
1767
            $orderBy   = ctype_digit($orderBy) ? sprintf($this->randomKeyword[1], $orderBy) : $this->randomKeyword[0];
3✔
1768
            $escape    = false;
3✔
1769
        } elseif ($direction !== '') {
850✔
1770
            $direction = in_array($direction, ['ASC', 'DESC'], true) ? ' ' . $direction : '';
850✔
1771
        }
1772

1773
        if ($escape === null) {
852✔
1774
            $escape = $this->db->protectIdentifiers;
850✔
1775
        }
1776

1777
        if ($escape === false) {
852✔
1778
            $qbOrderBy[] = [
3✔
1779
                'field'     => $orderBy,
3✔
1780
                'direction' => $direction,
3✔
1781
                'escape'    => false,
3✔
1782
            ];
3✔
1783
        } else {
1784
            foreach (explode(',', $orderBy) as $field) {
850✔
1785
                $qbOrderBy[] = ($direction === '' && preg_match('/\s+(ASC|DESC)$/i', rtrim($field), $match, PREG_OFFSET_CAPTURE))
850✔
UNCOV
1786
                    ? [
×
UNCOV
1787
                        'field'     => ltrim(substr($field, 0, $match[0][1])),
×
UNCOV
1788
                        'direction' => ' ' . $match[1][0],
×
1789
                        'escape'    => true,
×
1790
                    ]
×
1791
                    : [
850✔
1792
                        'field'     => trim($field),
850✔
1793
                        'direction' => $direction,
850✔
1794
                        'escape'    => true,
850✔
1795
                    ];
850✔
1796
            }
1797
        }
1798

1799
        $this->QBOrderBy = array_merge($this->QBOrderBy, $qbOrderBy);
852✔
1800

1801
        return $this;
852✔
1802
    }
1803

1804
    /**
1805
     * @return $this
1806
     */
1807
    public function limit(?int $value = null, ?int $offset = 0)
1808
    {
1809
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
126✔
1810
        if ($limitZeroAsAll && $value === 0) {
126✔
1811
            $value = null;
13✔
1812
        }
1813

1814
        if ($value !== null) {
126✔
1815
            $this->QBLimit = $value;
114✔
1816
        }
1817

1818
        if ($offset !== null && $offset !== 0) {
126✔
1819
            $this->QBOffset = $offset;
10✔
1820
        }
1821

1822
        return $this;
126✔
1823
    }
1824

1825
    /**
1826
     * Locks the selected rows for update.
1827
     */
1828
    public function lockForUpdate(): static
1829
    {
1830
        $this->QBLockForUpdate = true;
26✔
1831

1832
        return $this;
26✔
1833
    }
1834

1835
    /**
1836
     * Sets the OFFSET value
1837
     *
1838
     * @return $this
1839
     */
1840
    public function offset(int $offset)
1841
    {
1842
        if ($offset !== 0) {
1✔
1843
            $this->QBOffset = $offset;
1✔
1844
        }
1845

1846
        return $this;
1✔
1847
    }
1848

1849
    /**
1850
     * Generates a platform-specific LIMIT clause.
1851
     */
1852
    protected function _limit(string $sql, bool $offsetIgnore = false): string
1853
    {
1854
        return $sql . ' LIMIT ' . ($offsetIgnore === false && $this->QBOffset ? $this->QBOffset . ', ' : '') . $this->QBLimit;
113✔
1855
    }
1856

1857
    /**
1858
     * Allows key/value pairs to be set for insert(), update() or replace().
1859
     *
1860
     * @param array|object|string $key    Field name, or an array of field/value pairs, or an object
1861
     * @param mixed               $value  Field value, if $key is a single field
1862
     * @param bool|null           $escape Whether to escape values
1863
     *
1864
     * @return $this
1865
     */
1866
    public function set($key, $value = '', ?bool $escape = null)
1867
    {
1868
        $key = $this->objectToArray($key);
866✔
1869

1870
        if (! is_array($key)) {
866✔
1871
            $key = [$key => $value];
122✔
1872
        }
1873

1874
        $escape = is_bool($escape) ? $escape : $this->db->protectIdentifiers;
866✔
1875

1876
        foreach ($key as $k => $v) {
866✔
1877
            if ($escape) {
866✔
1878
                $bind = $this->setBind($k, $v, $escape);
865✔
1879

1880
                $this->QBSet[$this->db->protectIdentifiers($k, false)] = ":{$bind}:";
865✔
1881
            } else {
1882
                $this->QBSet[$this->db->protectIdentifiers($k, false)] = $v;
10✔
1883
            }
1884
        }
1885

1886
        return $this;
866✔
1887
    }
1888

1889
    /**
1890
     * Returns the previously set() data, alternatively resetting it if needed.
1891
     */
1892
    public function getSetData(bool $clean = false): array
1893
    {
UNCOV
1894
        $data = $this->QBSet;
×
1895

UNCOV
1896
        if ($clean) {
×
1897
            $this->QBSet = [];
×
1898
        }
1899

1900
        return $data;
×
1901
    }
1902

1903
    /**
1904
     * Compiles a SELECT query string and returns the sql.
1905
     */
1906
    public function getCompiledSelect(bool $reset = true): string
1907
    {
1908
        $select = $this->compileSelect();
262✔
1909

1910
        if ($reset) {
251✔
1911
            $this->resetSelect();
242✔
1912
        }
1913

1914
        return $this->compileFinalQuery($select);
251✔
1915
    }
1916

1917
    /**
1918
     * Returns a finalized, compiled query string with the bindings
1919
     * inserted and prefixes swapped out.
1920
     */
1921
    protected function compileFinalQuery(string $sql): string
1922
    {
1923
        $query = new Query($this->db);
276✔
1924
        $query->setQuery($sql, $this->binds, false);
276✔
1925

1926
        if (! empty($this->db->swapPre) && ! empty($this->db->DBPrefix)) {
276✔
UNCOV
1927
            $query->swapPrefix($this->db->DBPrefix, $this->db->swapPre);
×
1928
        }
1929

1930
        return $query->getQuery();
276✔
1931
    }
1932

1933
    /**
1934
     * Compiles the select statement based on the other functions called
1935
     * and runs the query
1936
     *
1937
     * @return false|ResultInterface
1938
     */
1939
    public function get(?int $limit = null, int $offset = 0, bool $reset = true)
1940
    {
1941
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
878✔
1942
        if ($limitZeroAsAll && $limit === 0) {
878✔
1943
            $limit = null;
1✔
1944
        }
1945

1946
        if ($limit !== null) {
878✔
1947
            $this->limit($limit, $offset);
8✔
1948
        }
1949

1950
        $result = $this->testMode
878✔
1951
            ? $this->getCompiledSelect($reset)
2✔
1952
            : $this->db->query($this->compileSelect(), $this->binds, false);
876✔
1953

1954
        if ($reset) {
878✔
1955
            $this->resetSelect();
878✔
1956

1957
            // Clear our binds so we don't eat up memory
1958
            $this->binds = [];
878✔
1959
        }
1960

1961
        return $result;
878✔
1962
    }
1963

1964
    /**
1965
     * Generates a platform-specific query string that counts all records in
1966
     * the particular table
1967
     *
1968
     * @return int|string
1969
     */
1970
    public function countAll(bool $reset = true)
1971
    {
1972
        $table = $this->QBFrom[0];
6✔
1973

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

1977
        if ($this->testMode) {
6✔
1978
            return $sql;
1✔
1979
        }
1980

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

1983
        if (empty($query->getResult())) {
5✔
UNCOV
1984
            return 0;
×
1985
        }
1986

1987
        $query = $query->getRow();
5✔
1988

1989
        if ($reset) {
5✔
1990
            $this->resetSelect();
5✔
1991
        }
1992

1993
        return (int) $query->numrows;
5✔
1994
    }
1995

1996
    /**
1997
     * Generates a platform-specific query string that counts all records
1998
     * returned by an Query Builder query.
1999
     *
2000
     * @return int|string
2001
     */
2002
    public function countAllResults(bool $reset = true)
2003
    {
2004
        // ORDER BY usage is often problematic here (most notably
2005
        // on Microsoft SQL Server) and ultimately unnecessary
2006
        // for selecting COUNT(*) ...
2007
        $orderBy = [];
237✔
2008

2009
        if (! empty($this->QBOrderBy)) {
237✔
UNCOV
2010
            $orderBy = $this->QBOrderBy;
×
2011

UNCOV
2012
            $this->QBOrderBy = null;
×
2013
        }
2014

2015
        // We cannot use a LIMIT when getting the single row COUNT(*) result
2016
        $limit         = $this->QBLimit;
237✔
2017
        $lockForUpdate = $this->QBLockForUpdate;
237✔
2018

2019
        $this->QBLimit         = false;
237✔
2020
        $this->QBLockForUpdate = false;
237✔
2021

2022
        try {
2023
            if ($this->QBDistinct === true || ! empty($this->QBGroupBy)) {
237✔
2024
                // We need to backup the original SELECT in case DBPrefix is used
2025
                $select = $this->QBSelect;
4✔
2026
                $sql    = $this->countString . $this->db->protectIdentifiers('numrows') . "\nFROM (\n" . $this->compileSelect() . "\n) CI_count_all_results";
4✔
2027

2028
                // Restore SELECT part
2029
                $this->QBSelect = $select;
4✔
2030
                unset($select);
4✔
2031
            } else {
2032
                $sql = $this->compileSelect($this->countString . $this->db->protectIdentifiers('numrows'));
233✔
2033
            }
2034
        } finally {
2035
            $this->QBLockForUpdate = $lockForUpdate;
237✔
2036
        }
2037

2038
        if ($this->testMode) {
237✔
2039
            return $sql;
11✔
2040
        }
2041

2042
        $result = $this->db->query($sql, $this->binds, false);
230✔
2043

2044
        if ($reset) {
230✔
2045
            $this->resetSelect();
214✔
2046
        } elseif (! isset($this->QBOrderBy)) {
22✔
UNCOV
2047
            $this->QBOrderBy = $orderBy;
×
2048
        }
2049

2050
        // Restore the LIMIT setting
2051
        $this->QBLimit = $limit;
230✔
2052

2053
        $row = $result instanceof ResultInterface ? $result->getRow() : null;
230✔
2054

2055
        if (empty($row)) {
230✔
UNCOV
2056
            return 0;
×
2057
        }
2058

2059
        return (int) $row->numrows;
230✔
2060
    }
2061

2062
    /**
2063
     * Compiles the set conditions and returns the sql statement
2064
     *
2065
     * @return array
2066
     */
2067
    public function getCompiledQBWhere()
2068
    {
2069
        return $this->QBWhere;
63✔
2070
    }
2071

2072
    /**
2073
     * Allows the where clause, limit and offset to be added directly
2074
     *
2075
     * @param array|string $where
2076
     *
2077
     * @return ResultInterface
2078
     */
2079
    public function getWhere($where = null, ?int $limit = null, ?int $offset = 0, bool $reset = true)
2080
    {
2081
        if ($where !== null) {
17✔
2082
            $this->where($where);
16✔
2083
        }
2084

2085
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
17✔
2086
        if ($limitZeroAsAll && $limit === 0) {
17✔
UNCOV
2087
            $limit = null;
×
2088
        }
2089

2090
        if ($limit !== null) {
17✔
2091
            $this->limit($limit, $offset);
3✔
2092
        }
2093

2094
        $result = $this->testMode
17✔
2095
            ? $this->getCompiledSelect($reset)
4✔
2096
            : $this->db->query($this->compileSelect(), $this->binds, false);
13✔
2097

2098
        if ($reset) {
17✔
2099
            $this->resetSelect();
17✔
2100

2101
            // Clear our binds so we don't eat up memory
2102
            $this->binds = [];
17✔
2103
        }
2104

2105
        return $result;
17✔
2106
    }
2107

2108
    /**
2109
     * Compiles batch insert/update/upsert strings and runs the queries
2110
     *
2111
     * @param '_deleteBatch'|'_insertBatch'|'_updateBatch'|'_upsertBatch' $renderMethod
2112
     *
2113
     * @return false|int|list<string> Number of rows inserted or FALSE on failure, SQL array when testMode
2114
     *
2115
     * @throws DatabaseException
2116
     */
2117
    protected function batchExecute(string $renderMethod, int $batchSize = 100)
2118
    {
2119
        if (empty($this->QBSet)) {
73✔
2120
            if ($this->db->DBDebug) {
5✔
2121
                throw new DatabaseException(trim($renderMethod, '_') . '() has no data.');
5✔
2122
            }
2123

2124
            return false; // @codeCoverageIgnore
2125
        }
2126

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

2129
        $affectedRows = 0;
68✔
2130
        $savedSQL     = [];
68✔
2131
        $cnt          = count($this->QBSet);
68✔
2132

2133
        // batch size 0 for unlimited
2134
        if ($batchSize === 0) {
68✔
UNCOV
2135
            $batchSize = $cnt;
×
2136
        }
2137

2138
        for ($i = 0, $total = $cnt; $i < $total; $i += $batchSize) {
68✔
2139
            $QBSet = array_slice($this->QBSet, $i, $batchSize);
68✔
2140

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

2143
            if ($sql === '') {
65✔
2144
                return false; // @codeCoverageIgnore
2145
            }
2146

2147
            if ($this->testMode) {
65✔
2148
                $savedSQL[] = $sql;
3✔
2149
            } else {
2150
                $this->db->query($sql, null, false);
62✔
2151
                $affectedRows += $this->db->affectedRows();
60✔
2152
            }
2153
        }
2154

2155
        if (! $this->testMode) {
63✔
2156
            $this->resetWrite();
60✔
2157
        }
2158

2159
        return $this->testMode ? $savedSQL : $affectedRows;
63✔
2160
    }
2161

2162
    /**
2163
     * Allows a row or multiple rows to be set for batch inserts/upserts/updates
2164
     *
2165
     * @param array|object $set
2166
     * @param string       $alias alias for sql table
2167
     *
2168
     * @return $this|null
2169
     */
2170
    public function setData($set, ?bool $escape = null, string $alias = '')
2171
    {
2172
        if (empty($set)) {
68✔
UNCOV
2173
            if ($this->db->DBDebug) {
×
UNCOV
2174
                throw new DatabaseException('setData() has no data.');
×
2175
            }
2176

2177
            return null; // @codeCoverageIgnore
2178
        }
2179

2180
        $this->setAlias($alias);
68✔
2181

2182
        // this allows to set just one row at a time
2183
        if (is_object($set) || (! is_array(current($set)) && ! is_object(current($set)))) {
68✔
2184
            $set = [$set];
11✔
2185
        }
2186

2187
        $set = $this->batchObjectToArray($set);
68✔
2188

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

2191
        $keys = array_keys($this->objectToArray(current($set)));
68✔
2192
        sort($keys);
68✔
2193

2194
        foreach ($set as $row) {
68✔
2195
            $row = $this->objectToArray($row);
68✔
2196
            if (array_diff($keys, array_keys($row)) !== [] || array_diff(array_keys($row), $keys) !== []) {
68✔
2197
                // batchExecute() function returns an error on an empty array
UNCOV
2198
                $this->QBSet[] = [];
×
2199

UNCOV
2200
                return null;
×
2201
            }
2202

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

2205
            $clean = [];
68✔
2206

2207
            foreach ($row as $rowValue) {
68✔
2208
                $clean[] = $escape ? $this->db->escape($rowValue) : $rowValue;
68✔
2209
            }
2210

2211
            $row = $clean;
68✔
2212

2213
            $this->QBSet[] = $row;
68✔
2214
        }
2215

2216
        foreach ($keys as $k) {
68✔
2217
            $k = $this->db->protectIdentifiers($k, false);
68✔
2218

2219
            if (! in_array($k, $this->QBKeys, true)) {
68✔
2220
                $this->QBKeys[] = $k;
68✔
2221
            }
2222
        }
2223

2224
        return $this;
68✔
2225
    }
2226

2227
    /**
2228
     * Compiles an upsert query and returns the sql
2229
     *
2230
     * @return string
2231
     *
2232
     * @throws DatabaseException
2233
     */
2234
    public function getCompiledUpsert()
2235
    {
2236
        [$currentTestMode, $this->testMode] = [$this->testMode, true];
3✔
2237

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

2240
        $this->testMode = $currentTestMode;
3✔
2241

2242
        return $this->compileFinalQuery($sql);
3✔
2243
    }
2244

2245
    /**
2246
     * Converts call to batchUpsert
2247
     *
2248
     * @param array|object|null $set
2249
     *
2250
     * @return false|int|list<string> Number of affected rows or FALSE on failure, SQL array when testMode
2251
     *
2252
     * @throws DatabaseException
2253
     */
2254
    public function upsert($set = null, ?bool $escape = null)
2255
    {
2256
        // if set() has been used merge QBSet with binds and then setData()
2257
        if ($set === null && ! is_array(current($this->QBSet))) {
10✔
2258
            $set = [];
2✔
2259

2260
            foreach ($this->QBSet as $field => $value) {
2✔
2261
                $k = trim($field, $this->db->escapeChar);
2✔
2262
                // use binds if available else use QBSet value but with RawSql to avoid escape
2263
                $set[$k] = isset($this->binds[$k]) ? $this->binds[$k][0] : new RawSql($value);
2✔
2264
            }
2265

2266
            $this->binds = [];
2✔
2267

2268
            $this->resetRun([
2✔
2269
                'QBSet'  => [],
2✔
2270
                'QBKeys' => [],
2✔
2271
            ]);
2✔
2272

2273
            $this->setData($set, true); // unescaped items are RawSql now
2✔
2274
        } elseif ($set !== null) {
8✔
2275
            $this->setData($set, $escape);
7✔
2276
        } // else setData() has already been used and we need to do nothing
2277

2278
        return $this->batchExecute('_upsertBatch');
10✔
2279
    }
2280

2281
    /**
2282
     * Compiles batch upsert strings and runs the queries
2283
     *
2284
     * @param array|object|null $set a dataset
2285
     *
2286
     * @return false|int|list<string> Number of affected rows or FALSE on failure, SQL array when testMode
2287
     *
2288
     * @throws DatabaseException
2289
     */
2290
    public function upsertBatch($set = null, ?bool $escape = null, int $batchSize = 100)
2291
    {
2292
        if (isset($this->QBOptions['setQueryAsData'])) {
12✔
2293
            $sql = $this->_upsertBatch($this->QBFrom[0], $this->QBKeys, []);
1✔
2294

2295
            if ($sql === '') {
1✔
2296
                return false; // @codeCoverageIgnore
2297
            }
2298

2299
            if ($this->testMode === false) {
1✔
2300
                $this->db->query($sql, null, false);
1✔
2301
            }
2302

2303
            $this->resetWrite();
1✔
2304

2305
            return $this->testMode ? $sql : $this->db->affectedRows();
1✔
2306
        }
2307

2308
        if ($set !== null) {
11✔
2309
            $this->setData($set, $escape);
9✔
2310
        }
2311

2312
        return $this->batchExecute('_upsertBatch', $batchSize);
11✔
2313
    }
2314

2315
    /**
2316
     * Generates a platform-specific upsertBatch string from the supplied data
2317
     *
2318
     * @used-by batchExecute()
2319
     *
2320
     * @param string                 $table  Protected table name
2321
     * @param list<string>           $keys   QBKeys
2322
     * @param list<list<int|string>> $values QBSet
2323
     */
2324
    protected function _upsertBatch(string $table, array $keys, array $values): string
2325
    {
2326
        $sql = $this->QBOptions['sql'] ?? '';
19✔
2327

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

2332
            $sql = 'INSERT INTO ' . $table . ' (' . implode(', ', $keys) . ")\n{:_table_:}ON DUPLICATE KEY UPDATE\n" . implode(
19✔
2333
                ",\n",
19✔
2334
                array_map(
19✔
2335
                    static fn ($key, $value): string => $table . '.' . $key . ($value instanceof RawSql ?
19✔
2336
                        ' = ' . $value :
2✔
2337
                        ' = VALUES(' . $value . ')'),
19✔
2338
                    array_keys($updateFields),
19✔
2339
                    $updateFields,
19✔
2340
                ),
19✔
2341
            );
19✔
2342

2343
            $this->QBOptions['sql'] = $sql;
19✔
2344
        }
2345

2346
        if (isset($this->QBOptions['setQueryAsData'])) {
19✔
2347
            $data = $this->QBOptions['setQueryAsData'] . "\n";
1✔
2348
        } else {
2349
            $data = 'VALUES ' . implode(', ', $this->formatValues($values)) . "\n";
18✔
2350
        }
2351

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

2355
    /**
2356
     * Set table alias for dataset pseudo table.
2357
     */
2358
    private function setAlias(string $alias): BaseBuilder
2359
    {
2360
        if ($alias !== '') {
68✔
2361
            $this->db->addTableAlias($alias);
7✔
2362
            $this->QBOptions['alias'] = $this->db->protectIdentifiers($alias);
7✔
2363
        }
2364

2365
        return $this;
68✔
2366
    }
2367

2368
    /**
2369
     * Sets update fields for upsert, update
2370
     *
2371
     * @param list<RawSql>|list<string>|string $set
2372
     * @param bool                             $addToDefault adds update fields to the default ones
2373
     * @param array|null                       $ignore       ignores items in set
2374
     *
2375
     * @return $this
2376
     */
2377
    public function updateFields($set, bool $addToDefault = false, ?array $ignore = null)
2378
    {
2379
        if (! empty($set)) {
38✔
2380
            if (! is_array($set)) {
38✔
2381
                $set = explode(',', $set);
5✔
2382
            }
2383

2384
            foreach ($set as $key => $value) {
38✔
2385
                if (! ($value instanceof RawSql)) {
38✔
2386
                    $value = $this->db->protectIdentifiers($value);
38✔
2387
                }
2388

2389
                if (is_numeric($key)) {
38✔
2390
                    $key = $value;
38✔
2391
                }
2392

2393
                if ($ignore === null || ! in_array($key, $ignore, true)) {
38✔
2394
                    if ($addToDefault) {
38✔
2395
                        $this->QBOptions['updateFieldsAdditional'][$this->db->protectIdentifiers($key)] = $value;
3✔
2396
                    } else {
2397
                        $this->QBOptions['updateFields'][$this->db->protectIdentifiers($key)] = $value;
38✔
2398
                    }
2399
                }
2400
            }
2401

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

2405
                unset($this->QBOptions['updateFieldsAdditional']);
3✔
2406
            }
2407
        }
2408

2409
        return $this;
38✔
2410
    }
2411

2412
    /**
2413
     * Sets constraints for batch upsert, update
2414
     *
2415
     * @param array|RawSql|string $set a string of columns, key value pairs, or RawSql
2416
     *
2417
     * @return $this
2418
     */
2419
    public function onConstraint($set)
2420
    {
2421
        if (! empty($set)) {
47✔
2422
            if (is_string($set)) {
44✔
2423
                $set = explode(',', $set);
27✔
2424

2425
                $set = array_map(trim(...), $set);
27✔
2426
            }
2427

2428
            if ($set instanceof RawSql) {
44✔
2429
                $set = [$set];
2✔
2430
            }
2431

2432
            foreach ($set as $key => $value) {
44✔
2433
                if (! ($value instanceof RawSql)) {
44✔
2434
                    $value = $this->db->protectIdentifiers($value);
41✔
2435
                }
2436

2437
                if (is_string($key)) {
44✔
2438
                    $key = $this->db->protectIdentifiers($key);
3✔
2439
                }
2440

2441
                $this->QBOptions['constraints'][$key] = $value;
44✔
2442
            }
2443
        }
2444

2445
        return $this;
47✔
2446
    }
2447

2448
    /**
2449
     * Sets data source as a query for insertBatch()/updateBatch()/upsertBatch()/deleteBatch()
2450
     *
2451
     * @param BaseBuilder|RawSql $query
2452
     * @param array|string|null  $columns an array or comma delimited string of columns
2453
     */
2454
    public function setQueryAsData($query, ?string $alias = null, $columns = null): BaseBuilder
2455
    {
2456
        if (is_string($query)) {
5✔
UNCOV
2457
            throw new InvalidArgumentException('$query parameter must be BaseBuilder or RawSql class.');
×
2458
        }
2459

2460
        if ($query instanceof BaseBuilder) {
5✔
2461
            $query = $query->getCompiledSelect();
4✔
2462
        } elseif ($query instanceof RawSql) {
1✔
2463
            $query = $query->__toString();
1✔
2464
        }
2465

2466
        if (is_string($query)) {
5✔
2467
            if ($columns !== null && is_string($columns)) {
5✔
2468
                $columns = explode(',', $columns);
1✔
2469
                $columns = array_map(trim(...), $columns);
1✔
2470
            }
2471

2472
            $columns = (array) $columns;
5✔
2473

2474
            if ($columns === []) {
5✔
2475
                $columns = $this->fieldsFromQuery($query);
4✔
2476
            }
2477

2478
            if ($alias !== null) {
5✔
2479
                $this->setAlias($alias);
1✔
2480
            }
2481

2482
            foreach ($columns as $key => $value) {
5✔
2483
                $columns[$key] = $this->db->escapeChar . $value . $this->db->escapeChar;
5✔
2484
            }
2485

2486
            $this->QBOptions['setQueryAsData'] = $query;
5✔
2487
            $this->QBKeys                      = $columns;
5✔
2488
            $this->QBSet                       = [];
5✔
2489
        }
2490

2491
        return $this;
5✔
2492
    }
2493

2494
    /**
2495
     * Gets column names from a select query
2496
     */
2497
    protected function fieldsFromQuery(string $sql): array
2498
    {
2499
        return $this->db->query('SELECT * FROM (' . $sql . ') _u_ LIMIT 1')->getFieldNames();
4✔
2500
    }
2501

2502
    /**
2503
     * Converts value array of array to array of strings
2504
     */
2505
    protected function formatValues(array $values): array
2506
    {
2507
        return array_map(static fn ($index): string => '(' . implode(',', $index) . ')', $values);
45✔
2508
    }
2509

2510
    /**
2511
     * Compiles batch insert strings and runs the queries
2512
     *
2513
     * @param array|object|null $set a dataset
2514
     *
2515
     * @return false|int|list<string> Number of rows inserted or FALSE on no data to perform an insert operation, SQL array when testMode
2516
     */
2517
    public function insertBatch($set = null, ?bool $escape = null, int $batchSize = 100)
2518
    {
2519
        if (isset($this->QBOptions['setQueryAsData'])) {
29✔
2520
            $sql = $this->_insertBatch($this->QBFrom[0], $this->QBKeys, []);
2✔
2521

2522
            if ($sql === '') {
2✔
2523
                return false; // @codeCoverageIgnore
2524
            }
2525

2526
            if ($this->testMode === false) {
2✔
2527
                $this->db->query($sql, null, false);
2✔
2528
            }
2529

2530
            $this->resetWrite();
2✔
2531

2532
            return $this->testMode ? $sql : $this->db->affectedRows();
2✔
2533
        }
2534

2535
        if ($set !== null && $set !== []) {
29✔
2536
            $this->setData($set, $escape);
27✔
2537
        }
2538

2539
        return $this->batchExecute('_insertBatch', $batchSize);
29✔
2540
    }
2541

2542
    /**
2543
     * Generates a platform-specific insert string from the supplied data.
2544
     *
2545
     * @used-by batchExecute()
2546
     *
2547
     * @param string                 $table  Protected table name
2548
     * @param list<string>           $keys   QBKeys
2549
     * @param list<list<int|string>> $values QBSet
2550
     */
2551
    protected function _insertBatch(string $table, array $keys, array $values): string
2552
    {
2553
        $sql = $this->QBOptions['sql'] ?? '';
27✔
2554

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

2560
            $this->QBOptions['sql'] = $sql;
27✔
2561
        }
2562

2563
        if (isset($this->QBOptions['setQueryAsData'])) {
27✔
2564
            $data = $this->QBOptions['setQueryAsData'];
2✔
2565
        } else {
2566
            $data = 'VALUES ' . implode(', ', $this->formatValues($values));
27✔
2567
        }
2568

2569
        return str_replace('{:_table_:}', $data, $sql);
27✔
2570
    }
2571

2572
    /**
2573
     * Compiles an insert query and returns the sql
2574
     *
2575
     * @return bool|string
2576
     *
2577
     * @throws DatabaseException
2578
     */
2579
    public function getCompiledInsert(bool $reset = true)
2580
    {
2581
        if ($this->validateInsert() === false) {
6✔
UNCOV
2582
            return false;
×
2583
        }
2584

2585
        $sql = $this->_insert(
6✔
2586
            $this->db->protectIdentifiers(
6✔
2587
                $this->removeAlias($this->QBFrom[0]),
6✔
2588
                true,
6✔
2589
                null,
6✔
2590
                false,
6✔
2591
            ),
6✔
2592
            array_keys($this->QBSet),
6✔
2593
            array_values($this->QBSet),
6✔
2594
        );
6✔
2595

2596
        if ($reset) {
6✔
2597
            $this->resetWrite();
6✔
2598
        }
2599

2600
        return $this->compileFinalQuery($sql);
6✔
2601
    }
2602

2603
    /**
2604
     * Compiles an insert string and runs the query
2605
     *
2606
     * @param array|object|null $set
2607
     *
2608
     * @return BaseResult|bool|Query
2609
     *
2610
     * @throws DatabaseException
2611
     */
2612
    public function insert($set = null, ?bool $escape = null)
2613
    {
2614
        if ($set !== null) {
853✔
2615
            $this->set($set, '', $escape);
819✔
2616
        }
2617

2618
        if ($this->validateInsert() === false) {
853✔
UNCOV
2619
            return false;
×
2620
        }
2621

2622
        $sql = $this->_insert(
852✔
2623
            $this->db->protectIdentifiers(
852✔
2624
                $this->removeAlias($this->QBFrom[0]),
852✔
2625
                true,
852✔
2626
                $escape,
852✔
2627
                false,
852✔
2628
            ),
852✔
2629
            array_keys($this->QBSet),
852✔
2630
            array_values($this->QBSet),
852✔
2631
        );
852✔
2632

2633
        if (! $this->testMode) {
852✔
2634
            $this->resetWrite();
848✔
2635

2636
            $result = $this->db->query($sql, $this->binds, false);
848✔
2637

2638
            // Clear our binds so we don't eat up memory
2639
            $this->binds = [];
848✔
2640

2641
            return $result;
848✔
2642
        }
2643

2644
        return false;
5✔
2645
    }
2646

2647
    /**
2648
     * @internal This is a temporary solution.
2649
     *
2650
     * @see https://github.com/codeigniter4/CodeIgniter4/pull/5376
2651
     *
2652
     * @TODO Fix a root cause, and this method should be removed.
2653
     */
2654
    protected function removeAlias(string $from): string
2655
    {
2656
        if (str_contains($from, ' ')) {
857✔
2657
            // if the alias is written with the AS keyword, remove it
2658
            $from = preg_replace('/\s+AS\s+/i', ' ', $from);
2✔
2659

2660
            $parts = explode(' ', $from);
2✔
2661
            $from  = $parts[0];
2✔
2662
        }
2663

2664
        return $from;
857✔
2665
    }
2666

2667
    /**
2668
     * This method is used by both insert() and getCompiledInsert() to
2669
     * validate that the there data is actually being set and that table
2670
     * has been chosen to be inserted into.
2671
     *
2672
     * @throws DatabaseException
2673
     */
2674
    protected function validateInsert(): bool
2675
    {
2676
        if (empty($this->QBSet)) {
853✔
2677
            if ($this->db->DBDebug) {
1✔
2678
                throw new DatabaseException('You must use the "set" method to insert an entry.');
1✔
2679
            }
2680

2681
            return false; // @codeCoverageIgnore
2682
        }
2683

2684
        return true;
852✔
2685
    }
2686

2687
    /**
2688
     * Generates a platform-specific insert string from the supplied data
2689
     *
2690
     * @param string           $table         Protected table name
2691
     * @param list<string>     $keys          Keys of QBSet
2692
     * @param list<int|string> $unescapedKeys Values of QBSet
2693
     */
2694
    protected function _insert(string $table, array $keys, array $unescapedKeys): string
2695
    {
2696
        return 'INSERT ' . $this->compileIgnore('insert') . 'INTO ' . $table . ' (' . implode(', ', $keys) . ') VALUES (' . implode(', ', $unescapedKeys) . ')';
843✔
2697
    }
2698

2699
    /**
2700
     * Compiles a replace into string and runs the query
2701
     *
2702
     * @return BaseResult|false|Query|string
2703
     *
2704
     * @throws DatabaseException
2705
     */
2706
    public function replace(?array $set = null)
2707
    {
2708
        if ($set !== null) {
8✔
2709
            $this->set($set);
7✔
2710
        }
2711

2712
        if (empty($this->QBSet)) {
8✔
2713
            if ($this->db->DBDebug) {
1✔
2714
                throw new DatabaseException('You must use the "set" method to update an entry.');
1✔
2715
            }
2716

2717
            return false; // @codeCoverageIgnore
2718
        }
2719

2720
        $table = $this->QBFrom[0];
7✔
2721

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

2724
        $this->resetWrite();
7✔
2725

2726
        return $this->testMode ? $sql : $this->db->query($sql, $this->binds, false);
7✔
2727
    }
2728

2729
    /**
2730
     * Generates a platform-specific replace string from the supplied data
2731
     *
2732
     * @param string           $table  Protected table name
2733
     * @param list<string>     $keys   Keys of QBSet
2734
     * @param list<int|string> $values Values of QBSet
2735
     */
2736
    protected function _replace(string $table, array $keys, array $values): string
2737
    {
2738
        return 'REPLACE INTO ' . $table . ' (' . implode(', ', $keys) . ') VALUES (' . implode(', ', $values) . ')';
7✔
2739
    }
2740

2741
    /**
2742
     * Groups tables in FROM clauses if needed, so there is no confusion
2743
     * about operator precedence.
2744
     *
2745
     * Note: This is only used (and overridden) by MySQL and SQLSRV.
2746
     */
2747
    protected function _fromTables(): string
2748
    {
2749
        return implode(', ', $this->QBFrom);
1,092✔
2750
    }
2751

2752
    /**
2753
     * Compiles an update query and returns the sql
2754
     *
2755
     * @return bool|string
2756
     */
2757
    public function getCompiledUpdate(bool $reset = true)
2758
    {
2759
        if ($this->validateUpdate() === false) {
13✔
UNCOV
2760
            return false;
×
2761
        }
2762

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

2765
        if ($reset) {
13✔
2766
            $this->resetWrite();
13✔
2767
        }
2768

2769
        return $this->compileFinalQuery($sql);
13✔
2770
    }
2771

2772
    /**
2773
     * Compiles an update string and runs the query.
2774
     *
2775
     * @param array|object|null        $set
2776
     * @param array|RawSql|string|null $where
2777
     *
2778
     * @throws DatabaseException
2779
     */
2780
    public function update($set = null, $where = null, ?int $limit = null): bool
2781
    {
2782
        if ($set !== null) {
104✔
2783
            $this->set($set);
50✔
2784
        }
2785

2786
        if ($this->validateUpdate() === false) {
104✔
UNCOV
2787
            return false;
×
2788
        }
2789

2790
        if ($where !== null) {
103✔
2791
            $this->where($where);
7✔
2792
        }
2793

2794
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
103✔
2795
        if ($limitZeroAsAll && $limit === 0) {
103✔
UNCOV
2796
            $limit = null;
×
2797
        }
2798

2799
        if ($limit !== null) {
103✔
2800
            if (! $this->canLimitWhereUpdates) {
3✔
2801
                throw new DatabaseException('This driver does not allow LIMITs on UPDATE queries using WHERE.');
2✔
2802
            }
2803

2804
            $this->limit($limit);
3✔
2805
        }
2806

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

2809
        if (! $this->testMode) {
103✔
2810
            $this->resetWrite();
90✔
2811

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

2814
            if ($result !== false) {
90✔
2815
                // Clear our binds so we don't eat up memory
2816
                $this->binds = [];
87✔
2817

2818
                return true;
87✔
2819
            }
2820

2821
            return false;
3✔
2822
        }
2823

2824
        return true;
13✔
2825
    }
2826

2827
    /**
2828
     * Generates a platform-specific update string from the supplied data
2829
     *
2830
     * @param string                $table  Protected table name
2831
     * @param array<string, string> $values QBSet
2832
     */
2833
    protected function _update(string $table, array $values): string
2834
    {
2835
        $valStr = [];
122✔
2836

2837
        foreach ($values as $key => $val) {
122✔
2838
            $valStr[] = $key . ' = ' . $val;
122✔
2839
        }
2840

2841
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
122✔
2842
        if ($limitZeroAsAll) {
122✔
2843
            return 'UPDATE ' . $this->compileIgnore('update') . $table . ' SET ' . implode(', ', $valStr)
122✔
2844
                . $this->compileWhereHaving('QBWhere')
122✔
2845
                . $this->compileOrderBy()
122✔
2846
                . ($this->QBLimit ? $this->_limit(' ', true) : '');
122✔
2847
        }
2848

UNCOV
2849
        return 'UPDATE ' . $this->compileIgnore('update') . $table . ' SET ' . implode(', ', $valStr)
×
UNCOV
2850
            . $this->compileWhereHaving('QBWhere')
×
UNCOV
2851
            . $this->compileOrderBy()
×
2852
            . ($this->QBLimit !== false ? $this->_limit(' ', true) : '');
×
2853
    }
2854

2855
    /**
2856
     * This method is used by both update() and getCompiledUpdate() to
2857
     * validate that data is actually being set and that a table has been
2858
     * chosen to be updated.
2859
     *
2860
     * @throws DatabaseException
2861
     */
2862
    protected function validateUpdate(): bool
2863
    {
2864
        if (empty($this->QBSet)) {
105✔
2865
            if ($this->db->DBDebug) {
1✔
2866
                throw new DatabaseException('You must use the "set" method to update an entry.');
1✔
2867
            }
2868

2869
            return false; // @codeCoverageIgnore
2870
        }
2871

2872
        return true;
104✔
2873
    }
2874

2875
    /**
2876
     * Sets data and calls batchExecute to run queries
2877
     *
2878
     * @param array|object|null        $set         a dataset
2879
     * @param array|RawSql|string|null $constraints
2880
     *
2881
     * @return false|int|list<string> Number of rows affected or FALSE on failure, SQL array when testMode
2882
     */
2883
    public function updateBatch($set = null, $constraints = null, int $batchSize = 100)
2884
    {
2885
        $this->onConstraint($constraints);
23✔
2886

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

2890
            if ($sql === '') {
1✔
2891
                return false; // @codeCoverageIgnore
2892
            }
2893

2894
            if ($this->testMode === false) {
1✔
2895
                $this->db->query($sql, null, false);
1✔
2896
            }
2897

2898
            $this->resetWrite();
1✔
2899

2900
            return $this->testMode ? $sql : $this->db->affectedRows();
1✔
2901
        }
2902

2903
        if ($set !== null && $set !== []) {
22✔
2904
            $this->setData($set, true);
16✔
2905
        }
2906

2907
        return $this->batchExecute('_updateBatch', $batchSize);
22✔
2908
    }
2909

2910
    /**
2911
     * Generates a platform-specific batch update string from the supplied data
2912
     *
2913
     * @used-by batchExecute()
2914
     *
2915
     * @param string                 $table  Protected table name
2916
     * @param list<string>           $keys   QBKeys
2917
     * @param list<list<int|string>> $values QBSet
2918
     */
2919
    protected function _updateBatch(string $table, array $keys, array $values): string
2920
    {
2921
        $sql = $this->QBOptions['sql'] ?? '';
19✔
2922

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

2927
            if ($constraints === []) {
19✔
2928
                if ($this->db->DBDebug) {
2✔
2929
                    throw new DatabaseException('You must specify a constraint to match on for batch updates.'); // @codeCoverageIgnore
2930
                }
2931

2932
                return ''; // @codeCoverageIgnore
2933
            }
2934

2935
            $updateFields = $this->QBOptions['updateFields'] ??
17✔
2936
                $this->updateFields($keys, false, $constraints)->QBOptions['updateFields'] ??
17✔
2937
                [];
14✔
2938

2939
            $alias = $this->QBOptions['alias'] ?? '_u';
17✔
2940

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

2943
            $sql .= "SET\n";
17✔
2944

2945
            $sql .= implode(
17✔
2946
                ",\n",
17✔
2947
                array_map(
17✔
2948
                    static fn ($key, $value): string => $key . ($value instanceof RawSql ?
17✔
2949
                        ' = ' . $value :
2✔
2950
                        ' = ' . $alias . '.' . $value),
17✔
2951
                    array_keys($updateFields),
17✔
2952
                    $updateFields,
17✔
2953
                ),
17✔
2954
            ) . "\n";
17✔
2955

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

2958
            $sql .= ') ' . $alias . "\n";
17✔
2959

2960
            $sql .= 'WHERE ' . implode(
17✔
2961
                ' AND ',
17✔
2962
                array_map(
17✔
2963
                    static fn ($key, $value) => (
17✔
2964
                        ($value instanceof RawSql && is_string($key))
17✔
2965
                        ?
17✔
2966
                        $table . '.' . $key . ' = ' . $value
1✔
2967
                        :
17✔
2968
                        (
17✔
2969
                            $value instanceof RawSql
16✔
2970
                            ?
16✔
2971
                            $value
3✔
2972
                            :
16✔
2973
                            $table . '.' . $value . ' = ' . $alias . '.' . $value
17✔
2974
                        )
17✔
2975
                    ),
17✔
2976
                    array_keys($constraints),
17✔
2977
                    $constraints,
17✔
2978
                ),
17✔
2979
            );
17✔
2980

2981
            $this->QBOptions['sql'] = $sql;
17✔
2982
        }
2983

2984
        if (isset($this->QBOptions['setQueryAsData'])) {
17✔
2985
            $data = $this->QBOptions['setQueryAsData'];
1✔
2986
        } else {
2987
            $data = implode(
16✔
2988
                " UNION ALL\n",
16✔
2989
                array_map(
16✔
2990
                    static fn ($value): string => 'SELECT ' . implode(', ', array_map(
16✔
2991
                        static fn ($key, $index): string => $index . ' ' . $key,
16✔
2992
                        $keys,
16✔
2993
                        $value,
16✔
2994
                    )),
16✔
2995
                    $values,
16✔
2996
                ),
16✔
2997
            ) . "\n";
16✔
2998
        }
2999

3000
        return str_replace('{:_table_:}', $data, $sql);
17✔
3001
    }
3002

3003
    /**
3004
     * Compiles a delete string and runs "DELETE FROM table"
3005
     *
3006
     * @return bool|string TRUE on success, FALSE on failure, string on testMode
3007
     */
3008
    public function emptyTable()
3009
    {
3010
        $table = $this->QBFrom[0];
4✔
3011

3012
        $sql = $this->_delete($table);
4✔
3013

3014
        if ($this->testMode) {
4✔
3015
            return $sql;
1✔
3016
        }
3017

3018
        $this->resetWrite();
3✔
3019

3020
        return $this->db->query($sql, null, false);
3✔
3021
    }
3022

3023
    /**
3024
     * Compiles a truncate string and runs the query
3025
     * If the database does not support the truncate() command
3026
     * This function maps to "DELETE FROM table"
3027
     *
3028
     * @return bool|string TRUE on success, FALSE on failure, string on testMode
3029
     */
3030
    public function truncate()
3031
    {
3032
        $table = $this->QBFrom[0];
771✔
3033

3034
        $sql = $this->_truncate($table);
771✔
3035

3036
        if ($this->testMode) {
771✔
3037
            return $sql;
2✔
3038
        }
3039

3040
        $this->resetWrite();
770✔
3041

3042
        return $this->db->query($sql, null, false);
770✔
3043
    }
3044

3045
    /**
3046
     * Generates a platform-specific truncate string from the supplied data
3047
     *
3048
     * If the database does not support the truncate() command,
3049
     * then this method maps to 'DELETE FROM table'
3050
     *
3051
     * @param string $table Protected table name
3052
     */
3053
    protected function _truncate(string $table): string
3054
    {
3055
        return 'TRUNCATE ' . $table;
756✔
3056
    }
3057

3058
    /**
3059
     * Compiles a delete query string and returns the sql
3060
     */
3061
    public function getCompiledDelete(bool $reset = true): string
3062
    {
3063
        $sql = $this->testMode()->delete('', null, $reset);
3✔
3064
        $this->testMode(false);
3✔
3065

3066
        return $this->compileFinalQuery($sql);
3✔
3067
    }
3068

3069
    /**
3070
     * Compiles a delete string and runs the query
3071
     *
3072
     * @param array|RawSql|string $where
3073
     *
3074
     * @return bool|string Returns a SQL string if in test mode.
3075
     *
3076
     * @throws DatabaseException
3077
     */
3078
    public function delete($where = '', ?int $limit = null, bool $resetData = true)
3079
    {
3080
        $table = $this->db->protectIdentifiers($this->QBFrom[0], true, null, false);
810✔
3081

3082
        if ($where !== '') {
810✔
3083
            $this->where($where);
4✔
3084
        }
3085

3086
        if (empty($this->QBWhere)) {
810✔
3087
            if ($this->db->DBDebug) {
2✔
3088
                throw new DatabaseException('Deletes are not allowed unless they contain a "where" or "like" clause.');
2✔
3089
            }
3090

3091
            return false; // @codeCoverageIgnore
3092
        }
3093

3094
        $sql = $this->_delete($this->removeAlias($table));
810✔
3095

3096
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
810✔
3097
        if ($limitZeroAsAll && $limit === 0) {
810✔
UNCOV
3098
            $limit = null;
×
3099
        }
3100

3101
        if ($limit !== null) {
810✔
3102
            $this->QBLimit = $limit;
1✔
3103
        }
3104

3105
        if (! empty($this->QBLimit)) {
810✔
3106
            if (! $this->canLimitDeletes) {
2✔
3107
                throw new DatabaseException('SQLite3 does not allow LIMITs on DELETE queries.');
1✔
3108
            }
3109

3110
            $sql = $this->_limit($sql, true);
2✔
3111
        }
3112

3113
        if ($resetData) {
810✔
3114
            $this->resetWrite();
810✔
3115
        }
3116

3117
        return $this->testMode ? $sql : $this->db->query($sql, $this->binds, false);
810✔
3118
    }
3119

3120
    /**
3121
     * Sets data and calls batchExecute to run queries
3122
     *
3123
     * @param array|object|null $set         a dataset
3124
     * @param array|RawSql|null $constraints
3125
     *
3126
     * @return false|int|list<string> Number of rows affected or FALSE on failure, SQL array when testMode
3127
     */
3128
    public function deleteBatch($set = null, $constraints = null, int $batchSize = 100)
3129
    {
3130
        $this->onConstraint($constraints);
3✔
3131

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

3135
            if ($sql === '') {
1✔
3136
                return false; // @codeCoverageIgnore
3137
            }
3138

3139
            if ($this->testMode === false) {
1✔
3140
                $this->db->query($sql, null, false);
1✔
3141
            }
3142

3143
            $this->resetWrite();
1✔
3144

3145
            return $this->testMode ? $sql : $this->db->affectedRows();
1✔
3146
        }
3147

3148
        if ($set !== null && $set !== []) {
2✔
UNCOV
3149
            $this->setData($set, true);
×
3150
        }
3151

3152
        return $this->batchExecute('_deleteBatch', $batchSize);
2✔
3153
    }
3154

3155
    /**
3156
     * Generates a platform-specific batch update string from the supplied data
3157
     *
3158
     * @used-by batchExecute()
3159
     *
3160
     * @param string           $table  Protected table name
3161
     * @param list<string>     $keys   QBKeys
3162
     * @param list<int|string> $values QBSet
3163
     */
3164
    protected function _deleteBatch(string $table, array $keys, array $values): string
3165
    {
3166
        $sql = $this->QBOptions['sql'] ?? '';
3✔
3167

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

3172
            if ($constraints === []) {
3✔
UNCOV
3173
                if ($this->db->DBDebug) {
×
3174
                    throw new DatabaseException('You must specify a constraint to match on for batch deletes.'); // @codeCoverageIgnore
3175
                }
3176

3177
                return ''; // @codeCoverageIgnore
3178
            }
3179

3180
            $alias = $this->QBOptions['alias'] ?? '_u';
3✔
3181

3182
            $sql = 'DELETE ' . $table . ' FROM ' . $table . "\n";
3✔
3183

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

3186
            $sql .= ') ' . $alias . "\n";
3✔
3187

3188
            $sql .= 'ON ' . implode(
3✔
3189
                ' AND ',
3✔
3190
                array_map(
3✔
3191
                    static fn ($key, $value) => (
3✔
3192
                        $value instanceof RawSql ?
3✔
UNCOV
3193
                        $value :
×
3194
                        (
3✔
3195
                            is_string($key) ?
3✔
3196
                            $table . '.' . $key . ' = ' . $alias . '.' . $value :
2✔
3197
                            $table . '.' . $value . ' = ' . $alias . '.' . $value
3✔
3198
                        )
3✔
3199
                    ),
3✔
3200
                    array_keys($constraints),
3✔
3201
                    $constraints,
3✔
3202
                ),
3✔
3203
            );
3✔
3204

3205
            // convert binds in where
3206
            foreach ($this->QBWhere as $key => $where) {
3✔
3207
                foreach ($this->binds as $field => $bind) {
2✔
UNCOV
3208
                    $this->QBWhere[$key]['condition'] = str_replace(':' . $field . ':', $bind[0], $where['condition']);
×
3209
                }
3210
            }
3211

3212
            $sql .= ' ' . $this->compileWhereHaving('QBWhere');
3✔
3213

3214
            $this->QBOptions['sql'] = trim($sql);
3✔
3215
        }
3216

3217
        if (isset($this->QBOptions['setQueryAsData'])) {
3✔
3218
            $data = $this->QBOptions['setQueryAsData'];
1✔
3219
        } else {
3220
            $data = implode(
2✔
3221
                " UNION ALL\n",
2✔
3222
                array_map(
2✔
3223
                    static fn ($value): string => 'SELECT ' . implode(', ', array_map(
2✔
3224
                        static fn ($key, $index): string => $index . ' ' . $key,
2✔
3225
                        $keys,
2✔
3226
                        $value,
2✔
3227
                    )),
2✔
3228
                    $values,
2✔
3229
                ),
2✔
3230
            ) . "\n";
2✔
3231
        }
3232

3233
        return str_replace('{:_table_:}', $data, $sql);
3✔
3234
    }
3235

3236
    /**
3237
     * Increments a numeric column by the specified value.
3238
     *
3239
     * @return bool
3240
     */
3241
    public function increment(string $column, int $value = 1)
3242
    {
3243
        return $this->incrementMany([$column], $value);
6✔
3244
    }
3245

3246
    /**
3247
     * Increments multiple numeric columns by the specified value(s).
3248
     *
3249
     * @param array<string, int>|list<string> $columns A list of columns or array of column => value pairs to increment.
3250
     * @param int                             $value   The value to increment by if $columns is a list of column names.
3251
     */
3252
    public function incrementMany(array $columns, int $value = 1): bool
3253
    {
3254
        if ($columns === []) {
11✔
3255
            throw new InvalidArgumentException('Argument #1 ($columns) cannot be empty.');
1✔
3256
        }
3257

3258
        if (array_is_list($columns)) {
10✔
3259
            $columns = array_fill_keys($columns, $value);
7✔
3260
        }
3261

3262
        $fields = [];
10✔
3263

3264
        foreach ($columns as $col => $val) {
10✔
3265
            if (! is_int($val)) {
10✔
3266
                throw new TypeError(sprintf(
1✔
3267
                    'Argument #1 ($columns) must contain only int values, %s given for "%s".',
1✔
3268
                    get_debug_type($val),
1✔
3269
                    $col,
1✔
3270
                ));
1✔
3271
            }
3272

3273
            $col          = $this->db->protectIdentifiers($col);
10✔
3274
            $fields[$col] = "{$col} + {$val}";
10✔
3275
        }
3276

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

3279
        if (! $this->testMode) {
9✔
3280
            $this->resetWrite();
9✔
3281

3282
            return $this->db->query($sql, $this->binds, false);
9✔
3283
        }
3284

UNCOV
3285
        return true;
×
3286
    }
3287

3288
    /**
3289
     * Decrements a numeric column by the specified value.
3290
     *
3291
     * @return bool
3292
     */
3293
    public function decrement(string $column, int $value = 1)
3294
    {
3295
        return $this->decrementMany([$column], $value);
6✔
3296
    }
3297

3298
    /**
3299
     * Decrements multiple numeric columns by the specified value(s).
3300
     *
3301
     * @param array<string, int>|list<string> $columns A list of columns or array of column => value pairs to decrement.
3302
     * @param int                             $value   The value to decrement by if $columns is a list of column names.
3303
     */
3304
    public function decrementMany(array $columns, int $value = 1): bool
3305
    {
3306
        if ($columns === []) {
11✔
3307
            throw new InvalidArgumentException('Argument #1 ($columns) cannot be empty.');
1✔
3308
        }
3309

3310
        if (array_is_list($columns)) {
10✔
3311
            $columns = array_fill_keys($columns, $value);
7✔
3312
        }
3313

3314
        $fields = [];
10✔
3315

3316
        foreach ($columns as $col => $val) {
10✔
3317
            if (! is_int($val)) {
10✔
3318
                throw new TypeError(sprintf(
1✔
3319
                    'Argument #1 ($columns) must contain only int values, %s given for "%s".',
1✔
3320
                    get_debug_type($val),
1✔
3321
                    $col,
1✔
3322
                ));
1✔
3323
            }
3324

3325
            $col          = $this->db->protectIdentifiers($col);
10✔
3326
            $fields[$col] = "{$col} - {$val}";
10✔
3327
        }
3328

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

3331
        if (! $this->testMode) {
9✔
3332
            $this->resetWrite();
9✔
3333

3334
            return $this->db->query($sql, $this->binds, false);
9✔
3335
        }
3336

UNCOV
3337
        return true;
×
3338
    }
3339

3340
    /**
3341
     * Generates a platform-specific delete string from the supplied data
3342
     *
3343
     * @param string $table Protected table name
3344
     */
3345
    protected function _delete(string $table): string
3346
    {
3347
        return 'DELETE ' . $this->compileIgnore('delete') . 'FROM ' . $table . $this->compileWhereHaving('QBWhere');
813✔
3348
    }
3349

3350
    /**
3351
     * Used to track SQL statements written with aliased tables.
3352
     *
3353
     * @param array|string $table The table to inspect
3354
     *
3355
     * @return string|null
3356
     */
3357
    protected function trackAliases($table)
3358
    {
3359
        if (is_array($table)) {
1,207✔
UNCOV
3360
            foreach ($table as $t) {
×
UNCOV
3361
                $this->trackAliases($t);
×
3362
            }
3363

3364
            return null;
×
3365
        }
3366

3367
        // Does the string contain a comma?  If so, we need to separate
3368
        // the string into discreet statements
3369
        if (str_contains($table, ',')) {
1,207✔
UNCOV
3370
            return $this->trackAliases(explode(',', $table));
×
3371
        }
3372

3373
        // if a table alias is used we can recognize it by a space
3374
        if (str_contains($table, ' ')) {
1,207✔
3375
            // if the alias is written with the AS keyword, remove it
3376
            $table = preg_replace('/\s+AS\s+/i', ' ', $table);
23✔
3377

3378
            // Grab the alias
3379
            $alias = trim(strrchr($table, ' '));
23✔
3380

3381
            // Store the alias, if it doesn't already exist
3382
            $this->db->addTableAlias($alias);
23✔
3383
        }
3384

3385
        return null;
1,207✔
3386
    }
3387

3388
    /**
3389
     * Compile the SELECT statement
3390
     *
3391
     * Generates a query string based on which functions were used.
3392
     * Should not be called directly.
3393
     *
3394
     * @param mixed $selectOverride
3395
     */
3396
    protected function compileSelect($selectOverride = false): string
3397
    {
3398
        if ($selectOverride !== false) {
1,117✔
3399
            $sql = $selectOverride;
230✔
3400
        } else {
3401
            $sql = $this->QBDistinct ? 'SELECT DISTINCT ' : 'SELECT ';
1,114✔
3402

3403
            if (empty($this->QBSelect)) {
1,114✔
3404
                $sql .= '*';
1,003✔
3405
            } else {
3406
                // Cycle through the "select" portion of the query and prep each column name.
3407
                // The reason we protect identifiers here rather than in the select() function
3408
                // is because until the user calls the from() function we don't know if there are aliases
3409
                foreach ($this->QBSelect as $key => $val) {
948✔
3410
                    if ($val instanceof RawSql) {
948✔
3411
                        $this->QBSelect[$key] = (string) $val;
5✔
3412
                    } else {
3413
                        $protect              = $this->QBNoEscape[$key] ?? null;
946✔
3414
                        $this->QBSelect[$key] = $this->db->protectIdentifiers($val, false, $protect);
946✔
3415
                    }
3416
                }
3417

3418
                $sql .= implode(', ', $this->QBSelect);
948✔
3419
            }
3420
        }
3421

3422
        if (! empty($this->QBFrom)) {
1,117✔
3423
            $sql .= "\nFROM " . $this->_fromTables();
1,117✔
3424
        }
3425

3426
        if (! empty($this->QBJoin)) {
1,117✔
3427
            $sql .= "\n" . implode("\n", $this->QBJoin);
15✔
3428
        }
3429

3430
        $sql .= $this->compileWhereHaving('QBWhere')
1,117✔
3431
            . $this->compileGroupBy()
1,117✔
3432
            . $this->compileWhereHaving('QBHaving')
1,117✔
3433
            . $this->compileOrderBy();
1,117✔
3434

3435
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
1,117✔
3436
        if ($limitZeroAsAll) {
1,117✔
3437
            if ($this->QBLimit) {
1,116✔
3438
                $sql = $this->_limit($sql . "\n");
107✔
3439
            }
3440
        } elseif ($this->QBLimit !== false || $this->QBOffset) {
2✔
3441
            $sql = $this->_limit($sql . "\n");
2✔
3442
        }
3443

3444
        $sql .= $this->compileLockForUpdate();
1,117✔
3445

3446
        return $this->unionInjection($sql);
1,107✔
3447
    }
3448

3449
    /**
3450
     * Compile the SELECT lock clause.
3451
     */
3452
    protected function compileLockForUpdate(): string
3453
    {
3454
        if ($this->QBLockForUpdate && $this->QBUnion !== []) {
230✔
3455
            throw new DatabaseException('Query Builder does not support lockForUpdate() with union() or unionAll().');
1✔
3456
        }
3457

3458
        return $this->QBLockForUpdate ? "\nFOR UPDATE" : '';
230✔
3459
    }
3460

3461
    /**
3462
     * Checks if the ignore option is supported by
3463
     * the Database Driver for the specific statement.
3464
     *
3465
     * @return string
3466
     */
3467
    protected function compileIgnore(string $statement)
3468
    {
3469
        if ($this->QBIgnore && isset($this->supportedIgnoreStatements[$statement])) {
874✔
3470
            return trim($this->supportedIgnoreStatements[$statement]) . ' ';
1✔
3471
        }
3472

3473
        return '';
873✔
3474
    }
3475

3476
    /**
3477
     * Escapes identifiers in WHERE and HAVING statements at execution time.
3478
     *
3479
     * Required so that aliases are tracked properly, regardless of whether
3480
     * where(), orWhere(), having(), orHaving are called prior to from(),
3481
     * join() and prefixTable is added only if needed.
3482
     *
3483
     * @param string $qbKey 'QBWhere' or 'QBHaving'
3484
     *
3485
     * @return string SQL statement
3486
     */
3487
    protected function compileWhereHaving(string $qbKey): string
3488
    {
3489
        if (! empty($this->{$qbKey})) {
1,151✔
3490
            foreach ($this->{$qbKey} as &$qbkey) {
1,010✔
3491
                $qbkey = $this->compileWhereHavingCondition($qbkey);
1,010✔
3492
            }
3493

3494
            return ($qbKey === 'QBHaving' ? "\nHAVING " : "\nWHERE ")
1,010✔
3495
                . implode("\n", $this->{$qbKey});
1,010✔
3496
        }
3497

3498
        return '';
1,133✔
3499
    }
3500

3501
    /**
3502
     * @used-by compileWhereHaving()
3503
     *
3504
     * @param array<string, mixed>|RawSql|string $condition
3505
     */
3506
    private function compileWhereHavingCondition(array|RawSql|string $condition): RawSql|string
3507
    {
3508
        // Is this condition already compiled?
3509
        if (is_string($condition) || $condition instanceof RawSql) {
1,010✔
3510
            return $condition;
27✔
3511
        }
3512

3513
        if ($condition['condition'] instanceof RawSql) {
1,010✔
3514
            return $condition['condition'];
4✔
3515
        }
3516

3517
        if (($condition['columnComparison'] ?? false) === true) {
1,008✔
3518
            return $this->compileColumnComparison($condition);
22✔
3519
        }
3520

3521
        if (($condition['betweenComparison'] ?? false) === true) {
995✔
3522
            return $this->compileBetweenComparison($condition);
8✔
3523
        }
3524

3525
        if ($condition['escape'] === false) {
990✔
3526
            return $condition['condition'];
118✔
3527
        }
3528

3529
        return $this->compileEscapedCondition($condition['condition']);
977✔
3530
    }
3531

3532
    /**
3533
     * @used-by compileWhereHavingCondition()
3534
     */
3535
    private function compileEscapedCondition(string $condition): string
3536
    {
3537
        // Split multiple conditions
3538
        $conditions = preg_split(
977✔
3539
            '/((?:^|\s+)AND\s+|(?:^|\s+)OR\s+)/i',
977✔
3540
            $condition,
977✔
3541
            -1,
977✔
3542
            PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY,
977✔
3543
        );
977✔
3544

3545
        foreach ($conditions as &$condition) {
977✔
3546
            $op = $this->getOperator($condition);
977✔
3547
            if (
3548
                $op === false
977✔
3549
                || preg_match(
977✔
3550
                    '/^(\(?)(.*)(' . preg_quote($op, '/') . ')\s*(.*(?<!\)))?(\)?)$/i',
977✔
3551
                    $condition,
977✔
3552
                    $matches,
977✔
3553
                ) !== 1
977✔
3554
            ) {
3555
                continue;
857✔
3556
            }
3557

3558
            // $matches = [
3559
            //  0 => '(test <= foo)',   /* the whole thing */
3560
            //  1 => '(',               /* optional */
3561
            //  2 => 'test',            /* the field name */
3562
            //  3 => ' <= ',            /* $op */
3563
            //  4 => 'foo',             /* optional, if $op is e.g. 'IS NULL' */
3564
            //  5 => ')'                /* optional */
3565
            // ];
3566

3567
            if ($matches[4] !== '') {
977✔
3568
                $protectIdentifiers = false;
937✔
3569
                if (str_contains($matches[4], '.')) {
937✔
3570
                    $protectIdentifiers = true;
87✔
3571
                }
3572

3573
                if (! str_contains($matches[4], ':')) {
937✔
3574
                    $matches[4] = $this->db->protectIdentifiers(trim($matches[4]), false, $protectIdentifiers);
14✔
3575
                }
3576

3577
                $matches[4] = ' ' . $matches[4];
937✔
3578
            }
3579

3580
            $condition = $matches[1] . $this->db->protectIdentifiers(trim($matches[2]))
977✔
3581
                . ' ' . trim($matches[3]) . $matches[4] . $matches[5];
977✔
3582
        }
3583

3584
        return implode('', $conditions);
977✔
3585
    }
3586

3587
    /**
3588
     * @used-by compileWhereHavingCondition()
3589
     *
3590
     * @param array{columnComparison: true, condition: string, escape: bool, first: string, operator: string, second: string} $condition
3591
     */
3592
    private function compileColumnComparison(array $condition): string
3593
    {
3594
        if ($condition['escape']) {
22✔
3595
            $condition['first']  = $this->db->protectIdentifiers($condition['first'], false, true);
20✔
3596
            $condition['second'] = $this->db->protectIdentifiers($condition['second'], false, true);
20✔
3597
        }
3598

3599
        return $condition['condition'] . $condition['first'] . ' ' . $condition['operator'] . ' ' . $condition['second'];
22✔
3600
    }
3601

3602
    /**
3603
     * @used-by compileWhereHavingCondition()
3604
     *
3605
     * @param array{betweenComparison: true, condition: string, escape: bool, key: string, lowerBind: string, not: string, upperBind: string} $condition
3606
     */
3607
    private function compileBetweenComparison(array $condition): string
3608
    {
3609
        if ($condition['escape']) {
8✔
3610
            $condition['key'] = $this->db->protectIdentifiers($condition['key'], false, true);
7✔
3611
        }
3612

3613
        return $condition['condition'] . $condition['key'] . $condition['not'] . ' BETWEEN :' . $condition['lowerBind'] . ': AND :' . $condition['upperBind'] . ':';
8✔
3614
    }
3615

3616
    /**
3617
     * Escapes identifiers in GROUP BY statements at execution time.
3618
     *
3619
     * Required so that aliases are tracked properly, regardless of whether
3620
     * groupBy() is called prior to from(), join() and prefixTable is added
3621
     * only if needed.
3622
     */
3623
    protected function compileGroupBy(): string
3624
    {
3625
        if (! empty($this->QBGroupBy)) {
1,134✔
3626
            foreach ($this->QBGroupBy as &$groupBy) {
58✔
3627
                // Is it already compiled?
3628
                if (is_string($groupBy)) {
58✔
3629
                    continue;
2✔
3630
                }
3631

3632
                $groupBy = ($groupBy['escape'] === false || $this->isLiteral($groupBy['field']))
58✔
UNCOV
3633
                    ? $groupBy['field']
×
3634
                    : $this->db->protectIdentifiers($groupBy['field']);
58✔
3635
            }
3636

3637
            return "\nGROUP BY " . implode(', ', $this->QBGroupBy);
58✔
3638
        }
3639

3640
        return '';
1,106✔
3641
    }
3642

3643
    /**
3644
     * Escapes identifiers in ORDER BY statements at execution time.
3645
     *
3646
     * Required so that aliases are tracked properly, regardless of whether
3647
     * orderBy() is called prior to from(), join() and prefixTable is added
3648
     * only if needed.
3649
     */
3650
    protected function compileOrderBy(): string
3651
    {
3652
        if (is_array($this->QBOrderBy) && $this->QBOrderBy !== []) {
1,146✔
3653
            foreach ($this->QBOrderBy as &$orderBy) {
852✔
3654
                if (is_string($orderBy)) {
852✔
3655
                    continue;
1✔
3656
                }
3657
                if ($orderBy['escape'] !== false && ! $this->isLiteral($orderBy['field'])) {
852✔
3658
                    $orderBy['field'] = $this->db->protectIdentifiers($orderBy['field']);
850✔
3659
                }
3660

3661
                $orderBy = $orderBy['field'] . $orderBy['direction'];
852✔
3662
            }
3663

3664
            return "\nORDER BY " . implode(', ', $this->QBOrderBy);
852✔
3665
        }
3666

3667
        return '';
1,117✔
3668
    }
3669

3670
    protected function unionInjection(string $sql): string
3671
    {
3672
        if ($this->QBUnion === []) {
1,123✔
3673
            return $sql;
1,123✔
3674
        }
3675

3676
        return 'SELECT * FROM (' . $sql . ') '
6✔
3677
            . ($this->db->protectIdentifiers ? $this->db->escapeIdentifiers('uwrp0') : 'uwrp0')
6✔
3678
            . implode("\n", $this->QBUnion);
6✔
3679
    }
3680

3681
    /**
3682
     * Takes an object as input and converts the class variables to array key/vals
3683
     *
3684
     * @param array|object $object
3685
     *
3686
     * @return array
3687
     */
3688
    protected function objectToArray($object)
3689
    {
3690
        if (! is_object($object)) {
873✔
3691
            return $object;
870✔
3692
        }
3693

3694
        if ($object instanceof RawSql) {
8✔
3695
            throw new InvalidArgumentException('RawSql "' . $object . '" cannot be used here.');
1✔
3696
        }
3697

3698
        $array = [];
7✔
3699

3700
        foreach (get_object_vars($object) as $key => $val) {
7✔
3701
            if ((! is_object($val) || $val instanceof RawSql) && ! is_array($val)) {
7✔
3702
                $array[$key] = $val;
7✔
3703
            }
3704
        }
3705

3706
        return $array;
7✔
3707
    }
3708

3709
    /**
3710
     * Takes an object as input and converts the class variables to array key/vals
3711
     *
3712
     * @param array|object $object
3713
     *
3714
     * @return array
3715
     */
3716
    protected function batchObjectToArray($object)
3717
    {
3718
        if (! is_object($object)) {
68✔
3719
            return $object;
68✔
3720
        }
3721

UNCOV
3722
        $array  = [];
×
UNCOV
3723
        $out    = get_object_vars($object);
×
UNCOV
3724
        $fields = array_keys($out);
×
3725

3726
        foreach ($fields as $val) {
×
3727
            $i = 0;
×
3728

3729
            foreach ($out[$val] as $data) {
×
3730
                $array[$i++][$val] = $data;
×
3731
            }
3732
        }
3733

UNCOV
3734
        return $array;
×
3735
    }
3736

3737
    /**
3738
     * Determines if a string represents a literal value or a field name
3739
     */
3740
    protected function isLiteral(string $str): bool
3741
    {
3742
        $str = trim($str);
882✔
3743

3744
        if ($str === ''
882✔
3745
            || ctype_digit($str)
882✔
3746
            || (string) (float) $str === $str
882✔
3747
            || in_array(strtoupper($str), ['TRUE', 'FALSE'], true)
882✔
3748
        ) {
UNCOV
3749
            return true;
×
3750
        }
3751

3752
        if ($this->isLiteralStr === []) {
882✔
3753
            $this->isLiteralStr = $this->db->escapeChar !== '"' ? ['"', "'"] : ["'"];
882✔
3754
        }
3755

3756
        return in_array($str[0], $this->isLiteralStr, true);
882✔
3757
    }
3758

3759
    /**
3760
     * Publicly-visible method to reset the QB values.
3761
     *
3762
     * @return $this
3763
     */
3764
    public function resetQuery()
3765
    {
3766
        $this->resetSelect();
1✔
3767
        $this->resetWrite();
1✔
3768

3769
        return $this;
1✔
3770
    }
3771

3772
    /**
3773
     * Resets the query builder values.  Called by the get() function
3774
     *
3775
     * @param array $qbResetItems An array of fields to reset
3776
     *
3777
     * @return void
3778
     */
3779
    protected function resetRun(array $qbResetItems)
3780
    {
3781
        foreach ($qbResetItems as $item => $defaultValue) {
1,138✔
3782
            $this->{$item} = $defaultValue;
1,138✔
3783
        }
3784
    }
3785

3786
    /**
3787
     * Resets the query builder values.  Called by the get() function
3788
     *
3789
     * @return void
3790
     */
3791
    protected function resetSelect()
3792
    {
3793
        $this->resetRun([
1,111✔
3794
            'QBSelect'              => [],
1,111✔
3795
            'QBJoin'                => [],
1,111✔
3796
            'QBWhere'               => [],
1,111✔
3797
            'QBGroupBy'             => [],
1,111✔
3798
            'QBHaving'              => [],
1,111✔
3799
            'QBOrderBy'             => [],
1,111✔
3800
            'QBNoEscape'            => [],
1,111✔
3801
            'QBDistinct'            => false,
1,111✔
3802
            'QBLimit'               => false,
1,111✔
3803
            'QBOffset'              => false,
1,111✔
3804
            'QBLockForUpdate'       => false,
1,111✔
3805
            'QBSelectUsesAggregate' => false,
1,111✔
3806
            'QBUnion'               => [],
1,111✔
3807
        ]);
1,111✔
3808

3809
        if ($this->db instanceof BaseConnection) {
1,111✔
3810
            $this->db->setAliasedTables([]);
1,111✔
3811
        }
3812

3813
        // Reset QBFrom part
3814
        if (! empty($this->QBFrom)) {
1,111✔
3815
            $this->from(array_shift($this->QBFrom), true);
1,111✔
3816
        }
3817
    }
3818

3819
    /**
3820
     * Resets the query builder "write" values.
3821
     *
3822
     * Called by the insert() update() insertBatch() updateBatch() and delete() functions
3823
     *
3824
     * @return void
3825
     */
3826
    protected function resetWrite()
3827
    {
3828
        $this->resetRun([
880✔
3829
            'QBSet'     => [],
880✔
3830
            'QBJoin'    => [],
880✔
3831
            'QBWhere'   => [],
880✔
3832
            'QBOrderBy' => [],
880✔
3833
            'QBKeys'    => [],
880✔
3834
            'QBLimit'   => false,
880✔
3835
            'QBIgnore'  => false,
880✔
3836
            'QBOptions' => [],
880✔
3837
        ]);
880✔
3838
    }
3839

3840
    /**
3841
     * Tests whether the string has an SQL operator
3842
     */
3843
    protected function hasOperator(string $str): bool
3844
    {
3845
        return preg_match(
160✔
3846
            '/(<|>|!|=|\sIS NULL|\sIS NOT NULL|\sEXISTS|\sBETWEEN|\sLIKE|\sIN\s*\(|\s)/i',
160✔
3847
            trim($str),
160✔
3848
        ) === 1;
160✔
3849
    }
3850

3851
    /**
3852
     * Returns the SQL string operator
3853
     *
3854
     * @return array|false|string
3855
     */
3856
    protected function getOperator(string $str, bool $list = false)
3857
    {
3858
        if ($this->pregOperators === []) {
987✔
3859
            $_les = $this->db->likeEscapeStr !== ''
987✔
3860
                ? '\s+' . preg_quote(trim(sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar)), '/')
987✔
UNCOV
3861
                : '';
×
3862
            $this->pregOperators = [
987✔
3863
                '\s*(?:<|>|!)?=\s*', // =, <=, >=, !=
987✔
3864
                '\s*<>?\s*',         // <, <>
987✔
3865
                '\s*>\s*',           // >
987✔
3866
                '\s+IS NULL',             // IS NULL
987✔
3867
                '\s+IS NOT NULL',         // IS NOT NULL
987✔
3868
                '\s+EXISTS\s*\(.*\)',     // EXISTS (sql)
987✔
3869
                '\s+NOT EXISTS\s*\(.*\)', // NOT EXISTS(sql)
987✔
3870
                '\s+BETWEEN\s+',          // BETWEEN value AND value
987✔
3871
                '\s+IN\s*\(.*\)',         // IN (list)
987✔
3872
                '\s+NOT IN\s*\(.*\)',     // NOT IN (list)
987✔
3873
                '\s+LIKE\s+\S.*(' . $_les . ')?',     // LIKE 'expr'[ ESCAPE '%s']
987✔
3874
                '\s+NOT LIKE\s+\S.*(' . $_les . ')?', // NOT LIKE 'expr'[ ESCAPE '%s']
987✔
3875
            ];
987✔
3876
        }
3877

3878
        return preg_match_all(
987✔
3879
            '/' . implode('|', $this->pregOperators) . '/i',
987✔
3880
            $str,
987✔
3881
            $match,
987✔
3882
        ) >= 1 ? ($list ? $match[0] : $match[0][0]) : false;
987✔
3883
    }
3884

3885
    /**
3886
     * Returns the SQL string operator from where key
3887
     *
3888
     * @return false|list<string>
3889
     */
3890
    private function getOperatorFromWhereKey(string $whereKey)
3891
    {
3892
        $whereKey = trim($whereKey);
939✔
3893

3894
        $pregOperators = [
939✔
3895
            '\s*(?:<|>|!)?=',         // =, <=, >=, !=
939✔
3896
            '\s*<>?',                 // <, <>
939✔
3897
            '\s*>',                   // >
939✔
3898
            '\s+IS NULL',             // IS NULL
939✔
3899
            '\s+IS NOT NULL',         // IS NOT NULL
939✔
3900
            '\s+EXISTS\s*\(.*\)',     // EXISTS (sql)
939✔
3901
            '\s+NOT EXISTS\s*\(.*\)', // NOT EXISTS (sql)
939✔
3902
            '\s+BETWEEN\s+',          // BETWEEN value AND value
939✔
3903
            '\s+IN\s*\(.*\)',         // IN (list)
939✔
3904
            '\s+NOT IN\s*\(.*\)',     // NOT IN (list)
939✔
3905
            '\s+LIKE',                // LIKE
939✔
3906
            '\s+NOT LIKE',            // NOT LIKE
939✔
3907
        ];
939✔
3908

3909
        return preg_match_all(
939✔
3910
            '/' . implode('|', $pregOperators) . '/i',
939✔
3911
            $whereKey,
939✔
3912
            $match,
939✔
3913
        ) >= 1 ? $match[0] : false;
939✔
3914
    }
3915

3916
    /**
3917
     * Stores a bind value after ensuring that it's unique.
3918
     * While it might be nicer to have named keys for our binds array
3919
     * with PHP 7+ we get a huge memory/performance gain with indexed
3920
     * arrays instead, so lets take advantage of that here.
3921
     *
3922
     * @param mixed $value
3923
     */
3924
    protected function setBind(string $key, $value = null, bool $escape = true): string
3925
    {
3926
        if (! array_key_exists($key, $this->binds)) {
991✔
3927
            $this->binds[$key] = [
991✔
3928
                $value,
991✔
3929
                $escape,
991✔
3930
            ];
991✔
3931

3932
            return $key;
991✔
3933
        }
3934

3935
        if (! array_key_exists($key, $this->bindsKeyCount)) {
53✔
3936
            $this->bindsKeyCount[$key] = 1;
53✔
3937
        }
3938

3939
        $count = $this->bindsKeyCount[$key]++;
53✔
3940

3941
        $this->binds[$key . '.' . $count] = [
53✔
3942
            $value,
53✔
3943
            $escape,
53✔
3944
        ];
53✔
3945

3946
        return $key . '.' . $count;
53✔
3947
    }
3948

3949
    /**
3950
     * @param mixed $value
3951
     */
3952
    protected function isSubquery($value): bool
3953
    {
3954
        return $value instanceof BaseBuilder || $value instanceof Closure;
954✔
3955
    }
3956

3957
    /**
3958
     * @param BaseBuilder|Closure(BaseBuilder): BaseBuilder $builder
3959
     * @param bool                                          $wrapped Wrap the subquery in brackets
3960
     * @param string                                        $alias   Subquery alias
3961
     */
3962
    protected function buildSubquery($builder, bool $wrapped = false, string $alias = ''): string
3963
    {
3964
        if ($builder instanceof Closure) {
33✔
3965
            $builder($builder = $this->db->newQuery());
18✔
3966
        }
3967

3968
        if ($builder === $this) {
33✔
3969
            throw new DatabaseException('The subquery cannot be the same object as the main query object.');
2✔
3970
        }
3971

3972
        $subquery = strtr($builder->getCompiledSelect(false), "\n", ' ');
31✔
3973

3974
        if ($wrapped) {
31✔
3975
            $subquery = '(' . $subquery . ')';
31✔
3976
            $alias    = trim($alias);
31✔
3977

3978
            if ($alias !== '') {
31✔
3979
                $subquery .= ' ' . ($this->db->protectIdentifiers ? $this->db->escapeIdentifiers($alias) : $alias);
15✔
3980
            }
3981
        }
3982

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

© 2026 Coveralls, Inc