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

codeigniter4 / CodeIgniter4 / 26047669551

18 May 2026 04:53PM UTC coverage: 88.487% (+0.02%) from 88.464%
26047669551

Pull #10215

github

web-flow
Merge 8213211e3 into 0be36657e
Pull Request #10215: feat: add Query Builder exists and doesntExist methods

51 of 55 new or added lines in 2 files covered. (92.73%)

2 existing lines in 1 file now uncovered.

24188 of 27335 relevant lines covered (88.49%)

219.81 hits per line

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

94.79
/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,224✔
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,224✔
326

327
        if ($tableName instanceof TableName) {
1,224✔
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,220✔
334
            $this->tableName = $tableName;  // @TODO remove alias if exists
1,217✔
335
            $this->from($tableName);
1,217✔
336
        } else {
337
            $this->tableName = '';
24✔
338
            $this->from($tableName);
24✔
339
        }
340

341
        if ($options !== null && $options !== []) {
1,224✔
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;
95✔
368

369
        return $this;
95✔
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;
65✔
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)) {
936✔
416
            $escape = $this->db->protectIdentifiers;
925✔
417
        }
418

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

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

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

432
                continue;
5✔
433
            }
434

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

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

450
                    continue;
2✔
451
                }
452

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

457
        return $this;
936✔
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);
832✔
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');
9✔
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 === '') {
846✔
536
            throw DataException::forEmptyInputGiven('Select');
1✔
537
        }
538

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

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

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

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

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

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

559
        return $this;
844✔
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, '.')) {
837✔
568
            $item = explode('.', $item);
1✔
569

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

573
        return $item;
836✔
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;
840✔
584

585
        return $this;
840✔
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,224✔
598
            $this->QBFrom = [];
1,119✔
599
            $this->db->setAliasedTables([]);
1,119✔
600
        }
601

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

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

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

617
        return $this;
1,224✔
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);
969✔
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
        $prefix = $this->{$qbKey} === [] ? $this->groupGetType('') : $this->groupGetType($type);
22✔
859

860
        $this->{$qbKey}[] = [
22✔
861
            'columnComparison' => true,
22✔
862
            'condition'        => $prefix,
22✔
863
            'escape'           => $escape,
22✔
864
            'first'            => $first,
22✔
865
            'operator'         => $operator,
22✔
866
            'second'           => $second,
22✔
867
        ];
22✔
868

869
        return $this;
22✔
870
    }
871

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

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

887
        return [$first, '='];
13✔
888
    }
889

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

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

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

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

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

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

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

977
        $lowerBind = $this->setBind($key, $values[0], $escape);
8✔
978
        $upperBind = $this->setBind($key, $values[1], $escape);
8✔
979
        $not       = $not ? ' NOT' : '';
8✔
980
        $prefix    = $this->{$qbKey} === [] ? $this->groupGetType('') : $this->groupGetType($type);
8✔
981

982
        $this->{$qbKey}[] = [
8✔
983
            'betweenComparison' => true,
8✔
984
            'condition'         => $prefix,
8✔
985
            'escape'            => $escape,
8✔
986
            'key'               => $key,
8✔
987
            'lowerBind'         => $lowerBind,
8✔
988
            'not'               => $not,
8✔
989
            'upperBind'         => $upperBind,
8✔
990
        ];
8✔
991

992
        return $this;
8✔
993
    }
994

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

1013
        $prefix   = $this->QBWhere === [] ? $this->groupGetType('') : $this->groupGetType($type);
8✔
1014
        $operator = $not ? 'NOT EXISTS' : 'EXISTS';
8✔
1015

1016
        $this->QBWhere[] = [
8✔
1017
            'condition' => "{$prefix}{$operator} {$this->buildSubquery($subquery, true)}",
8✔
1018
            'escape'    => false,
8✔
1019
        ];
8✔
1020

1021
        return $this;
7✔
1022
    }
1023

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

1039
        if ($key instanceof RawSql) {
977✔
1040
            if ($value === null) {
4✔
1041
                $keyValue   = [(string) $key => $key];
1✔
1042
                $rawSqlOnly = true;
1✔
1043
            } else {
1044
                $keyValue = [(string) $key => $value];
3✔
1045
            }
1046
        } elseif (! is_array($key)) {
973✔
1047
            $keyValue = [$key => $value];
963✔
1048
        } else {
1049
            $keyValue = $key;
243✔
1050
        }
1051

1052
        // If the escape value was not set will base it on the global setting
1053
        if (! is_bool($escape)) {
977✔
1054
            $escape = $this->db->protectIdentifiers;
971✔
1055
        }
1056

1057
        foreach ($keyValue as $k => $v) {
977✔
1058
            $prefix = empty($this->{$qbKey}) ? $this->groupGetType('') : $this->groupGetType($type);
977✔
1059

1060
            if ($rawSqlOnly) {
977✔
1061
                $k  = '';
1✔
1062
                $op = '';
1✔
1063
            } elseif ($v !== null) {
976✔
1064
                $op = $this->getOperatorFromWhereKey($k);
956✔
1065

1066
                if (! empty($op)) {
956✔
1067
                    $k = trim($k);
63✔
1068

1069
                    end($op);
63✔
1070
                    $op = trim(current($op));
63✔
1071

1072
                    // Does the key end with operator?
1073
                    if (str_ends_with($k, $op)) {
63✔
1074
                        $k  = rtrim(substr($k, 0, -strlen($op)));
63✔
1075
                        $op = " {$op}";
63✔
1076
                    } else {
1077
                        $op = '';
×
1078
                    }
1079
                } else {
1080
                    $op = ' =';
930✔
1081
                }
1082

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

1107
            if ($v instanceof RawSql) {
977✔
1108
                $this->{$qbKey}[] = [
1✔
1109
                    'condition' => $v->with($prefix . $k . $op . $v),
1✔
1110
                    'escape'    => $escape,
1✔
1111
                ];
1✔
1112
            } else {
1113
                $this->{$qbKey}[] = [
976✔
1114
                    'condition' => $prefix . $k . $op . $v,
976✔
1115
                    'escape'    => $escape,
976✔
1116
                ];
976✔
1117
            }
1118
        }
1119

1120
        return $this;
977✔
1121
    }
1122

1123
    /**
1124
     * Generates a WHERE field IN('item', 'item') SQL query,
1125
     * joined with 'AND' if appropriate.
1126
     *
1127
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1128
     *
1129
     * @return $this
1130
     */
1131
    public function whereIn(?string $key = null, $values = null, ?bool $escape = null)
1132
    {
1133
        return $this->_whereIn($key, $values, false, 'AND ', $escape);
68✔
1134
    }
1135

1136
    /**
1137
     * Generates a WHERE field IN('item', 'item') SQL query,
1138
     * joined with 'OR' 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 orWhereIn(?string $key = null, $values = null, ?bool $escape = null)
1145
    {
1146
        return $this->_whereIn($key, $values, false, 'OR ', $escape);
3✔
1147
    }
1148

1149
    /**
1150
     * Generates a WHERE field NOT IN('item', 'item') SQL query,
1151
     * joined with 'AND' 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 whereNotIn(?string $key = null, $values = null, ?bool $escape = null)
1158
    {
1159
        return $this->_whereIn($key, $values, true, 'AND ', $escape);
3✔
1160
    }
1161

1162
    /**
1163
     * Generates a WHERE field NOT IN('item', 'item') SQL query,
1164
     * joined with 'OR' 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 orWhereNotIn(?string $key = null, $values = null, ?bool $escape = null)
1171
    {
1172
        return $this->_whereIn($key, $values, true, 'OR ', $escape);
2✔
1173
    }
1174

1175
    /**
1176
     * Generates a HAVING field IN('item', 'item') SQL query,
1177
     * joined with 'AND' 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 havingIn(?string $key = null, $values = null, ?bool $escape = null)
1184
    {
1185
        return $this->_whereIn($key, $values, false, 'AND ', $escape, 'QBHaving');
6✔
1186
    }
1187

1188
    /**
1189
     * Generates a HAVING field IN('item', 'item') SQL query,
1190
     * joined with 'OR' 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 orHavingIn(?string $key = null, $values = null, ?bool $escape = null)
1197
    {
1198
        return $this->_whereIn($key, $values, false, 'OR ', $escape, 'QBHaving');
3✔
1199
    }
1200

1201
    /**
1202
     * Generates a HAVING field NOT IN('item', 'item') SQL query,
1203
     * joined with 'AND' 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 havingNotIn(?string $key = null, $values = null, ?bool $escape = null)
1210
    {
1211
        return $this->_whereIn($key, $values, true, 'AND ', $escape, 'QBHaving');
5✔
1212
    }
1213

1214
    /**
1215
     * Generates a HAVING field NOT IN('item', 'item') SQL query,
1216
     * joined with 'OR' 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 orHavingNotIn(?string $key = null, $values = null, ?bool $escape = null)
1223
    {
1224
        return $this->_whereIn($key, $values, true, 'OR ', $escape, 'QBHaving');
3✔
1225
    }
1226

1227
    /**
1228
     * @used-by WhereIn()
1229
     * @used-by orWhereIn()
1230
     * @used-by whereNotIn()
1231
     * @used-by orWhereNotIn()
1232
     *
1233
     * @param non-empty-string|null                                            $key
1234
     * @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|list<mixed>|null $values The values searched on, or anonymous function with subquery
1235
     *
1236
     * @return $this
1237
     *
1238
     * @throws InvalidArgumentException
1239
     */
1240
    protected function _whereIn(?string $key = null, $values = null, bool $not = false, string $type = 'AND ', ?bool $escape = null, string $clause = 'QBWhere')
1241
    {
1242
        if ($key === null || $key === '') {
88✔
1243
            throw new InvalidArgumentException(sprintf('%s() expects $key to be a non-empty string', debug_backtrace(0, 2)[1]['function']));
2✔
1244
        }
1245

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

1250
        if (! is_bool($escape)) {
83✔
1251
            $escape = $this->db->protectIdentifiers;
83✔
1252
        }
1253

1254
        $ok = $key;
83✔
1255

1256
        if ($escape === true) {
83✔
1257
            $key = $this->db->protectIdentifiers($key);
83✔
1258
        }
1259

1260
        $not = ($not) ? ' NOT' : '';
83✔
1261

1262
        if ($this->isSubquery($values)) {
83✔
1263
            $whereIn = $this->buildSubquery($values, true);
8✔
1264
            $escape  = false;
8✔
1265
        } else {
1266
            $whereIn = array_values($values);
75✔
1267
        }
1268

1269
        $ok = $this->setBind($ok, $whereIn, $escape);
83✔
1270

1271
        $prefix = empty($this->{$clause}) ? $this->groupGetType('') : $this->groupGetType($type);
83✔
1272

1273
        $whereIn = [
83✔
1274
            'condition' => "{$prefix}{$key}{$not} IN :{$ok}:",
83✔
1275
            'escape'    => false,
83✔
1276
        ];
83✔
1277

1278
        $this->{$clause}[] = $whereIn;
83✔
1279

1280
        return $this;
83✔
1281
    }
1282

1283
    /**
1284
     * Generates a %LIKE% portion of the query.
1285
     * Separates multiple calls with 'AND'.
1286
     *
1287
     * @param array|RawSql|string $field
1288
     *
1289
     * @return $this
1290
     */
1291
    public function like($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1292
    {
1293
        return $this->_like($field, $match, 'AND ', $side, '', $escape, $insensitiveSearch);
25✔
1294
    }
1295

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

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

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

1335
    /**
1336
     * Generates a %LIKE% portion of the query.
1337
     * Separates multiple calls with 'AND'.
1338
     *
1339
     * @param array|RawSql|string $field
1340
     *
1341
     * @return $this
1342
     */
1343
    public function havingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1344
    {
1345
        return $this->_like($field, $match, 'AND ', $side, '', $escape, $insensitiveSearch, 'QBHaving');
15✔
1346
    }
1347

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

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

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

1387
    /**
1388
     * @used-by like()
1389
     * @used-by orLike()
1390
     * @used-by notLike()
1391
     * @used-by orNotLike()
1392
     * @used-by havingLike()
1393
     * @used-by orHavingLike()
1394
     * @used-by notHavingLike()
1395
     * @used-by orNotHavingLike()
1396
     *
1397
     * @param array<string, string>|RawSql|string $field
1398
     *
1399
     * @return $this
1400
     */
1401
    protected function _like($field, string $match = '', string $type = 'AND ', string $side = 'both', string $not = '', ?bool $escape = null, bool $insensitiveSearch = false, string $clause = 'QBWhere')
1402
    {
1403
        $escape = is_bool($escape) ? $escape : $this->db->protectIdentifiers;
47✔
1404
        $side   = strtolower($side);
47✔
1405

1406
        if ($field instanceof RawSql) {
47✔
1407
            $k                 = (string) $field;
3✔
1408
            $v                 = $match;
3✔
1409
            $insensitiveSearch = false;
3✔
1410

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

1413
            if ($side === 'none') {
3✔
1414
                $bind = $this->setBind($field->getBindingKey(), $v, $escape);
×
1415
            } elseif ($side === 'before') {
3✔
1416
                $bind = $this->setBind($field->getBindingKey(), "%{$v}", $escape);
×
1417
            } elseif ($side === 'after') {
3✔
1418
                $bind = $this->setBind($field->getBindingKey(), "{$v}%", $escape);
×
1419
            } else {
1420
                $bind = $this->setBind($field->getBindingKey(), "%{$v}%", $escape);
3✔
1421
            }
1422

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

1425
            // some platforms require an escape sequence definition for LIKE wildcards
1426
            if ($escape === true && $this->db->likeEscapeStr !== '') {
3✔
1427
                $likeStatement .= sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar);
3✔
1428
            }
1429

1430
            $this->{$clause}[] = [
3✔
1431
                'condition' => $field->with($likeStatement),
3✔
1432
                'escape'    => $escape,
3✔
1433
            ];
3✔
1434

1435
            return $this;
3✔
1436
        }
1437

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

1440
        foreach ($keyValue as $k => $v) {
44✔
1441
            if ($insensitiveSearch) {
44✔
1442
                $v = mb_strtolower($v, 'UTF-8');
7✔
1443
            }
1444

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

1447
            if ($side === 'none') {
44✔
1448
                $bind = $this->setBind($k, $v, $escape);
1✔
1449
            } elseif ($side === 'before') {
43✔
1450
                $bind = $this->setBind($k, "%{$v}", $escape);
9✔
1451
            } elseif ($side === 'after') {
34✔
1452
                $bind = $this->setBind($k, "{$v}%", $escape);
5✔
1453
            } else {
1454
                $bind = $this->setBind($k, "%{$v}%", $escape);
29✔
1455
            }
1456

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

1459
            // some platforms require an escape sequence definition for LIKE wildcards
1460
            if ($escape === true && $this->db->likeEscapeStr !== '') {
44✔
1461
                $likeStatement .= sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar);
44✔
1462
            }
1463

1464
            $this->{$clause}[] = [
44✔
1465
                'condition' => $likeStatement,
44✔
1466
                'escape'    => $escape,
44✔
1467
            ];
44✔
1468
        }
1469

1470
        return $this;
44✔
1471
    }
1472

1473
    /**
1474
     * Platform independent LIKE statement builder.
1475
     */
1476
    protected function _like_statement(?string $prefix, string $column, ?string $not, string $bind, bool $insensitiveSearch = false): string
1477
    {
1478
        if ($insensitiveSearch) {
47✔
1479
            return "{$prefix} LOWER(" . $this->db->escapeIdentifiers($column) . ") {$not} LIKE :{$bind}:";
7✔
1480
        }
1481

1482
        return "{$prefix} {$column} {$not} LIKE :{$bind}:";
40✔
1483
    }
1484

1485
    /**
1486
     * Add UNION statement
1487
     *
1488
     * @param BaseBuilder|Closure(BaseBuilder): BaseBuilder $union
1489
     *
1490
     * @return $this
1491
     */
1492
    public function union($union)
1493
    {
1494
        return $this->addUnionStatement($union);
7✔
1495
    }
1496

1497
    /**
1498
     * Add UNION ALL statement
1499
     *
1500
     * @param BaseBuilder|Closure(BaseBuilder): BaseBuilder $union
1501
     *
1502
     * @return $this
1503
     */
1504
    public function unionAll($union)
1505
    {
1506
        return $this->addUnionStatement($union, true);
2✔
1507
    }
1508

1509
    /**
1510
     * @used-by union()
1511
     * @used-by unionAll()
1512
     *
1513
     * @param BaseBuilder|Closure(BaseBuilder): BaseBuilder $union
1514
     *
1515
     * @return $this
1516
     */
1517
    protected function addUnionStatement($union, bool $all = false)
1518
    {
1519
        $this->QBUnion[] = "\nUNION "
9✔
1520
            . ($all ? 'ALL ' : '')
9✔
1521
            . 'SELECT * FROM '
9✔
1522
            . $this->buildSubquery($union, true, 'uwrp' . (count($this->QBUnion) + 1));
9✔
1523

1524
        return $this;
9✔
1525
    }
1526

1527
    /**
1528
     * Starts a query group.
1529
     *
1530
     * @return $this
1531
     */
1532
    public function groupStart()
1533
    {
1534
        return $this->groupStartPrepare();
5✔
1535
    }
1536

1537
    /**
1538
     * Starts a query group, but ORs the group
1539
     *
1540
     * @return $this
1541
     */
1542
    public function orGroupStart()
1543
    {
1544
        return $this->groupStartPrepare('', 'OR ');
2✔
1545
    }
1546

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

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

1567
    /**
1568
     * Ends a query group
1569
     *
1570
     * @return $this
1571
     */
1572
    public function groupEnd()
1573
    {
1574
        return $this->groupEndPrepare();
11✔
1575
    }
1576

1577
    /**
1578
     * Starts a query group for HAVING clause.
1579
     *
1580
     * @return $this
1581
     */
1582
    public function havingGroupStart()
1583
    {
1584
        return $this->groupStartPrepare('', 'AND ', 'QBHaving');
2✔
1585
    }
1586

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

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

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

1617
    /**
1618
     * Ends a query group for HAVING clause.
1619
     *
1620
     * @return $this
1621
     */
1622
    public function havingGroupEnd()
1623
    {
1624
        return $this->groupEndPrepare('QBHaving');
8✔
1625
    }
1626

1627
    /**
1628
     * Prepate a query group start.
1629
     *
1630
     * @return $this
1631
     */
1632
    protected function groupStartPrepare(string $not = '', string $type = 'AND ', string $clause = 'QBWhere')
1633
    {
1634
        $type = $this->groupGetType($type);
19✔
1635

1636
        $this->QBWhereGroupStarted = true;
19✔
1637
        $prefix                    = empty($this->{$clause}) ? '' : $type;
19✔
1638
        $where                     = [
19✔
1639
            'condition' => $prefix . $not . str_repeat(' ', ++$this->QBWhereGroupCount) . ' (',
19✔
1640
            'escape'    => false,
19✔
1641
        ];
19✔
1642

1643
        $this->{$clause}[] = $where;
19✔
1644

1645
        return $this;
19✔
1646
    }
1647

1648
    /**
1649
     * Prepate a query group end.
1650
     *
1651
     * @return $this
1652
     */
1653
    protected function groupEndPrepare(string $clause = 'QBWhere')
1654
    {
1655
        $this->QBWhereGroupStarted = false;
19✔
1656
        $where                     = [
19✔
1657
            'condition' => str_repeat(' ', $this->QBWhereGroupCount--) . ')',
19✔
1658
            'escape'    => false,
19✔
1659
        ];
19✔
1660

1661
        $this->{$clause}[] = $where;
19✔
1662

1663
        return $this;
19✔
1664
    }
1665

1666
    /**
1667
     * @used-by groupStart()
1668
     * @used-by _like()
1669
     * @used-by whereHaving()
1670
     * @used-by _whereIn()
1671
     * @used-by whereColumnHaving()
1672
     * @used-by havingGroupStart()
1673
     */
1674
    protected function groupGetType(string $type): string
1675
    {
1676
        if ($this->QBWhereGroupStarted) {
1,026✔
1677
            $type                      = '';
19✔
1678
            $this->QBWhereGroupStarted = false;
19✔
1679
        }
1680

1681
        return $type;
1,026✔
1682
    }
1683

1684
    /**
1685
     * @param array|string $by
1686
     *
1687
     * @return $this
1688
     */
1689
    public function groupBy($by, ?bool $escape = null)
1690
    {
1691
        if (! is_bool($escape)) {
59✔
1692
            $escape = $this->db->protectIdentifiers;
59✔
1693
        }
1694

1695
        if (is_string($by)) {
59✔
1696
            $by = ($escape === true) ? explode(',', $by) : [$by];
59✔
1697
        }
1698

1699
        foreach ($by as $val) {
59✔
1700
            $val = trim($val);
59✔
1701

1702
            if ($val !== '') {
59✔
1703
                $val = [
59✔
1704
                    'field'  => $val,
59✔
1705
                    'escape' => $escape,
59✔
1706
                ];
59✔
1707

1708
                $this->QBGroupBy[] = $val;
59✔
1709
            }
1710
        }
1711

1712
        return $this;
59✔
1713
    }
1714

1715
    /**
1716
     * Separates multiple calls with 'AND'.
1717
     *
1718
     * @param array|RawSql|string $key
1719
     * @param mixed               $value
1720
     *
1721
     * @return $this
1722
     */
1723
    public function having($key, $value = null, ?bool $escape = null)
1724
    {
1725
        return $this->whereHaving('QBHaving', $key, $value, 'AND ', $escape);
19✔
1726
    }
1727

1728
    /**
1729
     * Separates multiple calls with 'OR'.
1730
     *
1731
     * @param array|RawSql|string $key
1732
     * @param mixed               $value
1733
     *
1734
     * @return $this
1735
     */
1736
    public function orHaving($key, $value = null, ?bool $escape = null)
1737
    {
1738
        return $this->whereHaving('QBHaving', $key, $value, 'OR ', $escape);
2✔
1739
    }
1740

1741
    /**
1742
     * @param string $direction ASC, DESC or RANDOM
1743
     *
1744
     * @return $this
1745
     */
1746
    public function orderBy(string $orderBy, string $direction = '', ?bool $escape = null)
1747
    {
1748
        if ($orderBy === '') {
862✔
1749
            return $this;
×
1750
        }
1751

1752
        $qbOrderBy = [];
862✔
1753

1754
        $direction = strtoupper(trim($direction));
862✔
1755

1756
        if ($direction === 'RANDOM') {
862✔
1757
            $direction = '';
3✔
1758
            $orderBy   = ctype_digit($orderBy) ? sprintf($this->randomKeyword[1], $orderBy) : $this->randomKeyword[0];
3✔
1759
            $escape    = false;
3✔
1760
        } elseif ($direction !== '') {
860✔
1761
            $direction = in_array($direction, ['ASC', 'DESC'], true) ? ' ' . $direction : '';
860✔
1762
        }
1763

1764
        if ($escape === null) {
862✔
1765
            $escape = $this->db->protectIdentifiers;
860✔
1766
        }
1767

1768
        if ($escape === false) {
862✔
1769
            $qbOrderBy[] = [
3✔
1770
                'field'     => $orderBy,
3✔
1771
                'direction' => $direction,
3✔
1772
                'escape'    => false,
3✔
1773
            ];
3✔
1774
        } else {
1775
            foreach (explode(',', $orderBy) as $field) {
860✔
1776
                $qbOrderBy[] = ($direction === '' && preg_match('/\s+(ASC|DESC)$/i', rtrim($field), $match, PREG_OFFSET_CAPTURE))
860✔
1777
                    ? [
×
1778
                        'field'     => ltrim(substr($field, 0, $match[0][1])),
×
1779
                        'direction' => ' ' . $match[1][0],
×
1780
                        'escape'    => true,
×
1781
                    ]
×
1782
                    : [
860✔
1783
                        'field'     => trim($field),
860✔
1784
                        'direction' => $direction,
860✔
1785
                        'escape'    => true,
860✔
1786
                    ];
860✔
1787
            }
1788
        }
1789

1790
        $this->QBOrderBy = array_merge($this->QBOrderBy, $qbOrderBy);
862✔
1791

1792
        return $this;
862✔
1793
    }
1794

1795
    /**
1796
     * @return $this
1797
     */
1798
    public function limit(?int $value = null, ?int $offset = 0)
1799
    {
1800
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
129✔
1801
        if ($limitZeroAsAll && $value === 0) {
129✔
1802
            $value = null;
13✔
1803
        }
1804

1805
        if ($value !== null) {
129✔
1806
            $this->QBLimit = $value;
117✔
1807
        }
1808

1809
        if ($offset !== null && $offset !== 0) {
129✔
1810
            $this->QBOffset = $offset;
12✔
1811
        }
1812

1813
        return $this;
129✔
1814
    }
1815

1816
    /**
1817
     * Locks the selected rows for update.
1818
     */
1819
    public function lockForUpdate(): static
1820
    {
1821
        $this->QBLockForUpdate = true;
28✔
1822

1823
        return $this;
28✔
1824
    }
1825

1826
    /**
1827
     * Sets the OFFSET value
1828
     *
1829
     * @return $this
1830
     */
1831
    public function offset(int $offset)
1832
    {
1833
        if ($offset !== 0) {
1✔
1834
            $this->QBOffset = $offset;
1✔
1835
        }
1836

1837
        return $this;
1✔
1838
    }
1839

1840
    /**
1841
     * Generates a platform-specific LIMIT clause.
1842
     */
1843
    protected function _limit(string $sql, bool $offsetIgnore = false): string
1844
    {
1845
        return $sql . ' LIMIT ' . ($offsetIgnore === false && $this->QBOffset ? $this->QBOffset . ', ' : '') . $this->QBLimit;
132✔
1846
    }
1847

1848
    /**
1849
     * Allows key/value pairs to be set for insert(), update() or replace().
1850
     *
1851
     * @param array|object|string $key    Field name, or an array of field/value pairs, or an object
1852
     * @param mixed               $value  Field value, if $key is a single field
1853
     * @param bool|null           $escape Whether to escape values
1854
     *
1855
     * @return $this
1856
     */
1857
    public function set($key, $value = '', ?bool $escape = null)
1858
    {
1859
        $key = $this->objectToArray($key);
874✔
1860

1861
        if (! is_array($key)) {
874✔
1862
            $key = [$key => $value];
122✔
1863
        }
1864

1865
        $escape = is_bool($escape) ? $escape : $this->db->protectIdentifiers;
874✔
1866

1867
        foreach ($key as $k => $v) {
874✔
1868
            if ($escape) {
874✔
1869
                $bind = $this->setBind($k, $v, $escape);
873✔
1870

1871
                $this->QBSet[$this->db->protectIdentifiers($k, false)] = ":{$bind}:";
873✔
1872
            } else {
1873
                $this->QBSet[$this->db->protectIdentifiers($k, false)] = $v;
10✔
1874
            }
1875
        }
1876

1877
        return $this;
874✔
1878
    }
1879

1880
    /**
1881
     * Returns the previously set() data, alternatively resetting it if needed.
1882
     */
1883
    public function getSetData(bool $clean = false): array
1884
    {
1885
        $data = $this->QBSet;
×
1886

1887
        if ($clean) {
×
1888
            $this->QBSet = [];
×
1889
        }
1890

1891
        return $data;
×
1892
    }
1893

1894
    /**
1895
     * Compiles a SELECT query string and returns the sql.
1896
     */
1897
    public function getCompiledSelect(bool $reset = true): string
1898
    {
1899
        $select = $this->compileSelect();
267✔
1900

1901
        if ($reset) {
256✔
1902
            $this->resetSelect();
239✔
1903
        }
1904

1905
        return $this->compileFinalQuery($select);
256✔
1906
    }
1907

1908
    /**
1909
     * Returns a finalized, compiled query string with the bindings
1910
     * inserted and prefixes swapped out.
1911
     */
1912
    protected function compileFinalQuery(string $sql): string
1913
    {
1914
        $query = new Query($this->db);
281✔
1915
        $query->setQuery($sql, $this->binds, false);
281✔
1916

1917
        if (! empty($this->db->swapPre) && ! empty($this->db->DBPrefix)) {
281✔
1918
            $query->swapPrefix($this->db->DBPrefix, $this->db->swapPre);
×
1919
        }
1920

1921
        return $query->getQuery();
281✔
1922
    }
1923

1924
    /**
1925
     * Compiles the select statement based on the other functions called
1926
     * and runs the query
1927
     *
1928
     * @return false|ResultInterface
1929
     */
1930
    public function get(?int $limit = null, int $offset = 0, bool $reset = true)
1931
    {
1932
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
886✔
1933
        if ($limitZeroAsAll && $limit === 0) {
886✔
1934
            $limit = null;
1✔
1935
        }
1936

1937
        if ($limit !== null) {
886✔
1938
            $this->limit($limit, $offset);
8✔
1939
        }
1940

1941
        $result = $this->testMode
886✔
1942
            ? $this->getCompiledSelect($reset)
2✔
1943
            : $this->db->query($this->compileSelect(), $this->binds, false);
884✔
1944

1945
        if ($reset) {
886✔
1946
            $this->resetSelect();
886✔
1947

1948
            // Clear our binds so we don't eat up memory
1949
            $this->binds = [];
886✔
1950
        }
1951

1952
        return $result;
886✔
1953
    }
1954

1955
    /**
1956
     * Generates a platform-specific query string that counts all records in
1957
     * the particular table
1958
     *
1959
     * @return int|string
1960
     */
1961
    public function countAll(bool $reset = true)
1962
    {
1963
        $table = $this->QBFrom[0];
6✔
1964

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

1968
        if ($this->testMode) {
6✔
1969
            return $sql;
1✔
1970
        }
1971

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

1974
        if (empty($query->getResult())) {
5✔
1975
            return 0;
×
1976
        }
1977

1978
        $query = $query->getRow();
5✔
1979

1980
        if ($reset) {
5✔
1981
            $this->resetSelect();
5✔
1982
        }
1983

1984
        return (int) $query->numrows;
5✔
1985
    }
1986

1987
    /**
1988
     * Determines whether the current Query Builder query would return at least one row.
1989
     *
1990
     * @return bool|string SQL string when test mode is enabled.
1991
     */
1992
    public function exists(bool $reset = true)
1993
    {
1994
        $exists = $this->doExists($reset);
16✔
1995

1996
        return $exists ?? false;
16✔
1997
    }
1998

1999
    /**
2000
     * Determines whether the current Query Builder query would not return any rows.
2001
     *
2002
     * @return bool|string SQL string when test mode is enabled.
2003
     */
2004
    public function doesntExist(bool $reset = true)
2005
    {
2006
        $exists = $this->doExists($reset);
5✔
2007

2008
        return is_string($exists) ? $exists : $exists === false;
5✔
2009
    }
2010

2011
    /**
2012
     * Runs an existence probe for the current Query Builder query.
2013
     *
2014
     * @return bool|string|null SQL string when test mode is enabled, or null when the query fails.
2015
     */
2016
    protected function doExists(bool $reset = true)
2017
    {
2018
        $sql = $this->compileExists();
20✔
2019

2020
        if ($this->testMode) {
20✔
2021
            if ($reset) {
11✔
2022
                $this->resetSelect();
1✔
2023

2024
                // Clear our binds so we don't eat up memory
2025
                $this->binds = [];
1✔
2026
            }
2027

2028
            return $sql;
11✔
2029
        }
2030

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

2033
        if ($reset) {
9✔
2034
            $this->resetSelect();
9✔
2035

2036
            // Clear our binds so we don't eat up memory
2037
            $this->binds = [];
9✔
2038
        }
2039

2040
        return $result instanceof ResultInterface ? $result->getRow() !== null : null;
9✔
2041
    }
2042

2043
    /**
2044
     * Compiles an existence probe for the current Query Builder query.
2045
     */
2046
    protected function compileExists(): string
2047
    {
2048
        // ORDER BY and FOR UPDATE are unnecessary for checking row existence,
2049
        // and can produce invalid or surprising SQL on some drivers.
2050
        $orderBy       = $this->QBOrderBy;
20✔
2051
        $limit         = $this->QBLimit;
20✔
2052
        $offset        = $this->QBOffset;
20✔
2053
        $lockForUpdate = $this->QBLockForUpdate;
20✔
2054
        $select        = $this->QBSelect;
20✔
2055
        $noEscape      = $this->QBNoEscape;
20✔
2056
        $needsSubquery = $this->QBSelectUsesAggregate || $this->QBUnion !== [] || $this->QBGroupBy !== [] || $this->QBHaving !== [] || $this->QBOffset !== false;
20✔
2057

2058
        $this->QBOrderBy       = null;
20✔
2059
        $this->QBLockForUpdate = false;
20✔
2060

2061
        if (! $needsSubquery && $this->QBLimit !== 0) {
20✔
2062
            $this->QBLimit = 1;
14✔
2063
        }
2064

2065
        try {
2066
            if ($needsSubquery) {
20✔
2067
                $sql = "SELECT 1 FROM (\n" . $this->compileSelect() . "\n) CI_exists";
5✔
2068

2069
                $this->QBLimit  = 1;
5✔
2070
                $this->QBOffset = false;
5✔
2071

2072
                return $this->_limit($sql . "\n");
5✔
2073
            }
2074

2075
            return $this->compileSelect('SELECT 1');
15✔
2076
        } finally {
2077
            $this->QBOrderBy       = $orderBy;
20✔
2078
            $this->QBLimit         = $limit;
20✔
2079
            $this->QBOffset        = $offset;
20✔
2080
            $this->QBLockForUpdate = $lockForUpdate;
20✔
2081
            $this->QBSelect        = $select;
20✔
2082
            $this->QBNoEscape      = $noEscape;
20✔
2083
        }
2084
    }
2085

2086
    /**
2087
     * Generates a platform-specific query string that counts all records
2088
     * returned by an Query Builder query.
2089
     *
2090
     * @return int|string
2091
     */
2092
    public function countAllResults(bool $reset = true)
2093
    {
2094
        // ORDER BY usage is often problematic here (most notably
2095
        // on Microsoft SQL Server) and ultimately unnecessary
2096
        // for selecting COUNT(*) ...
2097
        $orderBy = [];
237✔
2098

2099
        if (! empty($this->QBOrderBy)) {
237✔
NEW
2100
            $orderBy = $this->QBOrderBy;
×
2101

NEW
2102
            $this->QBOrderBy = null;
×
2103
        }
2104

2105
        // We cannot use a LIMIT when getting the single row COUNT(*) result
2106
        $limit         = $this->QBLimit;
237✔
2107
        $lockForUpdate = $this->QBLockForUpdate;
237✔
2108

2109
        $this->QBLimit         = false;
237✔
2110
        $this->QBLockForUpdate = false;
237✔
2111

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

2118
                // Restore SELECT part
2119
                $this->QBSelect = $select;
4✔
2120
                unset($select);
4✔
2121
            } else {
2122
                $sql = $this->compileSelect($this->countString . $this->db->protectIdentifiers('numrows'));
233✔
2123
            }
2124
        } finally {
2125
            $this->QBLockForUpdate = $lockForUpdate;
237✔
2126
        }
2127

2128
        if ($this->testMode) {
237✔
2129
            return $sql;
11✔
2130
        }
2131

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

2134
        if ($reset) {
230✔
2135
            $this->resetSelect();
214✔
2136
        } elseif (! isset($this->QBOrderBy)) {
22✔
NEW
2137
            $this->QBOrderBy = $orderBy;
×
2138
        }
2139

2140
        // Restore the LIMIT setting
2141
        $this->QBLimit = $limit;
230✔
2142

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

2145
        if (empty($row)) {
230✔
NEW
2146
            return 0;
×
2147
        }
2148

2149
        return (int) $row->numrows;
230✔
2150
    }
2151

2152
    /**
2153
     * Compiles the set conditions and returns the sql statement
2154
     *
2155
     * @return array
2156
     */
2157
    public function getCompiledQBWhere()
2158
    {
2159
        return $this->QBWhere;
65✔
2160
    }
2161

2162
    /**
2163
     * Allows the where clause, limit and offset to be added directly
2164
     *
2165
     * @param array|string $where
2166
     *
2167
     * @return ResultInterface
2168
     */
2169
    public function getWhere($where = null, ?int $limit = null, ?int $offset = 0, bool $reset = true)
2170
    {
2171
        if ($where !== null) {
17✔
2172
            $this->where($where);
16✔
2173
        }
2174

2175
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
17✔
2176
        if ($limitZeroAsAll && $limit === 0) {
17✔
2177
            $limit = null;
×
2178
        }
2179

2180
        if ($limit !== null) {
17✔
2181
            $this->limit($limit, $offset);
3✔
2182
        }
2183

2184
        $result = $this->testMode
17✔
2185
            ? $this->getCompiledSelect($reset)
4✔
2186
            : $this->db->query($this->compileSelect(), $this->binds, false);
13✔
2187

2188
        if ($reset) {
17✔
2189
            $this->resetSelect();
17✔
2190

2191
            // Clear our binds so we don't eat up memory
2192
            $this->binds = [];
17✔
2193
        }
2194

2195
        return $result;
17✔
2196
    }
2197

2198
    /**
2199
     * Compiles batch insert/update/upsert strings and runs the queries
2200
     *
2201
     * @param '_deleteBatch'|'_insertBatch'|'_updateBatch'|'_upsertBatch' $renderMethod
2202
     *
2203
     * @return false|int|list<string> Number of rows inserted or FALSE on failure, SQL array when testMode
2204
     *
2205
     * @throws DatabaseException
2206
     */
2207
    protected function batchExecute(string $renderMethod, int $batchSize = 100)
2208
    {
2209
        if (empty($this->QBSet)) {
73✔
2210
            if ($this->db->DBDebug) {
5✔
2211
                throw new DatabaseException(trim($renderMethod, '_') . '() has no data.');
5✔
2212
            }
2213

2214
            return false; // @codeCoverageIgnore
2215
        }
2216

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

2219
        $affectedRows = 0;
68✔
2220
        $savedSQL     = [];
68✔
2221
        $cnt          = count($this->QBSet);
68✔
2222

2223
        // batch size 0 for unlimited
2224
        if ($batchSize === 0) {
68✔
2225
            $batchSize = $cnt;
×
2226
        }
2227

2228
        for ($i = 0, $total = $cnt; $i < $total; $i += $batchSize) {
68✔
2229
            $QBSet = array_slice($this->QBSet, $i, $batchSize);
68✔
2230

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

2233
            if ($sql === '') {
65✔
2234
                return false; // @codeCoverageIgnore
2235
            }
2236

2237
            if ($this->testMode) {
65✔
2238
                $savedSQL[] = $sql;
3✔
2239
            } else {
2240
                $this->db->query($sql, null, false);
62✔
2241
                $affectedRows += $this->db->affectedRows();
60✔
2242
            }
2243
        }
2244

2245
        if (! $this->testMode) {
63✔
2246
            $this->resetWrite();
60✔
2247
        }
2248

2249
        return $this->testMode ? $savedSQL : $affectedRows;
63✔
2250
    }
2251

2252
    /**
2253
     * Allows a row or multiple rows to be set for batch inserts/upserts/updates
2254
     *
2255
     * @param array|object $set
2256
     * @param string       $alias alias for sql table
2257
     *
2258
     * @return $this|null
2259
     */
2260
    public function setData($set, ?bool $escape = null, string $alias = '')
2261
    {
2262
        if (empty($set)) {
68✔
2263
            if ($this->db->DBDebug) {
×
2264
                throw new DatabaseException('setData() has no data.');
×
2265
            }
2266

2267
            return null; // @codeCoverageIgnore
2268
        }
2269

2270
        $this->setAlias($alias);
68✔
2271

2272
        // this allows to set just one row at a time
2273
        if (is_object($set) || (! is_array(current($set)) && ! is_object(current($set)))) {
68✔
2274
            $set = [$set];
11✔
2275
        }
2276

2277
        $set = $this->batchObjectToArray($set);
68✔
2278

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

2281
        $keys = array_keys($this->objectToArray(current($set)));
68✔
2282
        sort($keys);
68✔
2283

2284
        foreach ($set as $row) {
68✔
2285
            $row = $this->objectToArray($row);
68✔
2286
            if (array_diff($keys, array_keys($row)) !== [] || array_diff(array_keys($row), $keys) !== []) {
68✔
2287
                // batchExecute() function returns an error on an empty array
2288
                $this->QBSet[] = [];
×
2289

2290
                return null;
×
2291
            }
2292

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

2295
            $clean = [];
68✔
2296

2297
            foreach ($row as $rowValue) {
68✔
2298
                $clean[] = $escape ? $this->db->escape($rowValue) : $rowValue;
68✔
2299
            }
2300

2301
            $row = $clean;
68✔
2302

2303
            $this->QBSet[] = $row;
68✔
2304
        }
2305

2306
        foreach ($keys as $k) {
68✔
2307
            $k = $this->db->protectIdentifiers($k, false);
68✔
2308

2309
            if (! in_array($k, $this->QBKeys, true)) {
68✔
2310
                $this->QBKeys[] = $k;
68✔
2311
            }
2312
        }
2313

2314
        return $this;
68✔
2315
    }
2316

2317
    /**
2318
     * Compiles an upsert query and returns the sql
2319
     *
2320
     * @return string
2321
     *
2322
     * @throws DatabaseException
2323
     */
2324
    public function getCompiledUpsert()
2325
    {
2326
        [$currentTestMode, $this->testMode] = [$this->testMode, true];
3✔
2327

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

2330
        $this->testMode = $currentTestMode;
3✔
2331

2332
        return $this->compileFinalQuery($sql);
3✔
2333
    }
2334

2335
    /**
2336
     * Converts call to batchUpsert
2337
     *
2338
     * @param array|object|null $set
2339
     *
2340
     * @return false|int|list<string> Number of affected rows or FALSE on failure, SQL array when testMode
2341
     *
2342
     * @throws DatabaseException
2343
     */
2344
    public function upsert($set = null, ?bool $escape = null)
2345
    {
2346
        // if set() has been used merge QBSet with binds and then setData()
2347
        if ($set === null && ! is_array(current($this->QBSet))) {
10✔
2348
            $set = [];
2✔
2349

2350
            foreach ($this->QBSet as $field => $value) {
2✔
2351
                $k = trim($field, $this->db->escapeChar);
2✔
2352
                // use binds if available else use QBSet value but with RawSql to avoid escape
2353
                $set[$k] = isset($this->binds[$k]) ? $this->binds[$k][0] : new RawSql($value);
2✔
2354
            }
2355

2356
            $this->binds = [];
2✔
2357

2358
            $this->resetRun([
2✔
2359
                'QBSet'  => [],
2✔
2360
                'QBKeys' => [],
2✔
2361
            ]);
2✔
2362

2363
            $this->setData($set, true); // unescaped items are RawSql now
2✔
2364
        } elseif ($set !== null) {
8✔
2365
            $this->setData($set, $escape);
7✔
2366
        } // else setData() has already been used and we need to do nothing
2367

2368
        return $this->batchExecute('_upsertBatch');
10✔
2369
    }
2370

2371
    /**
2372
     * Compiles batch upsert strings and runs the queries
2373
     *
2374
     * @param array|object|null $set a dataset
2375
     *
2376
     * @return false|int|list<string> Number of affected rows or FALSE on failure, SQL array when testMode
2377
     *
2378
     * @throws DatabaseException
2379
     */
2380
    public function upsertBatch($set = null, ?bool $escape = null, int $batchSize = 100)
2381
    {
2382
        if (isset($this->QBOptions['setQueryAsData'])) {
12✔
2383
            $sql = $this->_upsertBatch($this->QBFrom[0], $this->QBKeys, []);
1✔
2384

2385
            if ($sql === '') {
1✔
2386
                return false; // @codeCoverageIgnore
2387
            }
2388

2389
            if ($this->testMode === false) {
1✔
2390
                $this->db->query($sql, null, false);
1✔
2391
            }
2392

2393
            $this->resetWrite();
1✔
2394

2395
            return $this->testMode ? $sql : $this->db->affectedRows();
1✔
2396
        }
2397

2398
        if ($set !== null) {
11✔
2399
            $this->setData($set, $escape);
9✔
2400
        }
2401

2402
        return $this->batchExecute('_upsertBatch', $batchSize);
11✔
2403
    }
2404

2405
    /**
2406
     * Generates a platform-specific upsertBatch string from the supplied data
2407
     *
2408
     * @used-by batchExecute()
2409
     *
2410
     * @param string                 $table  Protected table name
2411
     * @param list<string>           $keys   QBKeys
2412
     * @param list<list<int|string>> $values QBSet
2413
     */
2414
    protected function _upsertBatch(string $table, array $keys, array $values): string
2415
    {
2416
        $sql = $this->QBOptions['sql'] ?? '';
19✔
2417

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

2422
            $sql = 'INSERT INTO ' . $table . ' (' . implode(', ', $keys) . ")\n{:_table_:}ON DUPLICATE KEY UPDATE\n" . implode(
19✔
2423
                ",\n",
19✔
2424
                array_map(
19✔
2425
                    static fn ($key, $value): string => $table . '.' . $key . ($value instanceof RawSql ?
19✔
2426
                        ' = ' . $value :
2✔
2427
                        ' = VALUES(' . $value . ')'),
19✔
2428
                    array_keys($updateFields),
19✔
2429
                    $updateFields,
19✔
2430
                ),
19✔
2431
            );
19✔
2432

2433
            $this->QBOptions['sql'] = $sql;
19✔
2434
        }
2435

2436
        if (isset($this->QBOptions['setQueryAsData'])) {
19✔
2437
            $data = $this->QBOptions['setQueryAsData'] . "\n";
1✔
2438
        } else {
2439
            $data = 'VALUES ' . implode(', ', $this->formatValues($values)) . "\n";
18✔
2440
        }
2441

2442
        return str_replace('{:_table_:}', $data, $sql);
19✔
2443
    }
2444

2445
    /**
2446
     * Set table alias for dataset pseudo table.
2447
     */
2448
    private function setAlias(string $alias): BaseBuilder
2449
    {
2450
        if ($alias !== '') {
68✔
2451
            $this->db->addTableAlias($alias);
7✔
2452
            $this->QBOptions['alias'] = $this->db->protectIdentifiers($alias);
7✔
2453
        }
2454

2455
        return $this;
68✔
2456
    }
2457

2458
    /**
2459
     * Sets update fields for upsert, update
2460
     *
2461
     * @param list<RawSql>|list<string>|string $set
2462
     * @param bool                             $addToDefault adds update fields to the default ones
2463
     * @param array|null                       $ignore       ignores items in set
2464
     *
2465
     * @return $this
2466
     */
2467
    public function updateFields($set, bool $addToDefault = false, ?array $ignore = null)
2468
    {
2469
        if (! empty($set)) {
38✔
2470
            if (! is_array($set)) {
38✔
2471
                $set = explode(',', $set);
5✔
2472
            }
2473

2474
            foreach ($set as $key => $value) {
38✔
2475
                if (! ($value instanceof RawSql)) {
38✔
2476
                    $value = $this->db->protectIdentifiers($value);
38✔
2477
                }
2478

2479
                if (is_numeric($key)) {
38✔
2480
                    $key = $value;
38✔
2481
                }
2482

2483
                if ($ignore === null || ! in_array($key, $ignore, true)) {
38✔
2484
                    if ($addToDefault) {
38✔
2485
                        $this->QBOptions['updateFieldsAdditional'][$this->db->protectIdentifiers($key)] = $value;
3✔
2486
                    } else {
2487
                        $this->QBOptions['updateFields'][$this->db->protectIdentifiers($key)] = $value;
38✔
2488
                    }
2489
                }
2490
            }
2491

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

2495
                unset($this->QBOptions['updateFieldsAdditional']);
3✔
2496
            }
2497
        }
2498

2499
        return $this;
38✔
2500
    }
2501

2502
    /**
2503
     * Sets constraints for batch upsert, update
2504
     *
2505
     * @param array|RawSql|string $set a string of columns, key value pairs, or RawSql
2506
     *
2507
     * @return $this
2508
     */
2509
    public function onConstraint($set)
2510
    {
2511
        if (! empty($set)) {
47✔
2512
            if (is_string($set)) {
44✔
2513
                $set = explode(',', $set);
27✔
2514

2515
                $set = array_map(trim(...), $set);
27✔
2516
            }
2517

2518
            if ($set instanceof RawSql) {
44✔
2519
                $set = [$set];
2✔
2520
            }
2521

2522
            foreach ($set as $key => $value) {
44✔
2523
                if (! ($value instanceof RawSql)) {
44✔
2524
                    $value = $this->db->protectIdentifiers($value);
41✔
2525
                }
2526

2527
                if (is_string($key)) {
44✔
2528
                    $key = $this->db->protectIdentifiers($key);
3✔
2529
                }
2530

2531
                $this->QBOptions['constraints'][$key] = $value;
44✔
2532
            }
2533
        }
2534

2535
        return $this;
47✔
2536
    }
2537

2538
    /**
2539
     * Sets data source as a query for insertBatch()/updateBatch()/upsertBatch()/deleteBatch()
2540
     *
2541
     * @param BaseBuilder|RawSql $query
2542
     * @param array|string|null  $columns an array or comma delimited string of columns
2543
     */
2544
    public function setQueryAsData($query, ?string $alias = null, $columns = null): BaseBuilder
2545
    {
2546
        if (is_string($query)) {
5✔
2547
            throw new InvalidArgumentException('$query parameter must be BaseBuilder or RawSql class.');
×
2548
        }
2549

2550
        if ($query instanceof BaseBuilder) {
5✔
2551
            $query = $query->getCompiledSelect();
4✔
2552
        } elseif ($query instanceof RawSql) {
1✔
2553
            $query = $query->__toString();
1✔
2554
        }
2555

2556
        if (is_string($query)) {
5✔
2557
            if ($columns !== null && is_string($columns)) {
5✔
2558
                $columns = explode(',', $columns);
1✔
2559
                $columns = array_map(trim(...), $columns);
1✔
2560
            }
2561

2562
            $columns = (array) $columns;
5✔
2563

2564
            if ($columns === []) {
5✔
2565
                $columns = $this->fieldsFromQuery($query);
4✔
2566
            }
2567

2568
            if ($alias !== null) {
5✔
2569
                $this->setAlias($alias);
1✔
2570
            }
2571

2572
            foreach ($columns as $key => $value) {
5✔
2573
                $columns[$key] = $this->db->escapeChar . $value . $this->db->escapeChar;
5✔
2574
            }
2575

2576
            $this->QBOptions['setQueryAsData'] = $query;
5✔
2577
            $this->QBKeys                      = $columns;
5✔
2578
            $this->QBSet                       = [];
5✔
2579
        }
2580

2581
        return $this;
5✔
2582
    }
2583

2584
    /**
2585
     * Gets column names from a select query
2586
     */
2587
    protected function fieldsFromQuery(string $sql): array
2588
    {
2589
        return $this->db->query('SELECT * FROM (' . $sql . ') _u_ LIMIT 1')->getFieldNames();
4✔
2590
    }
2591

2592
    /**
2593
     * Converts value array of array to array of strings
2594
     */
2595
    protected function formatValues(array $values): array
2596
    {
2597
        return array_map(static fn ($index): string => '(' . implode(',', $index) . ')', $values);
45✔
2598
    }
2599

2600
    /**
2601
     * Compiles batch insert strings and runs the queries
2602
     *
2603
     * @param array|object|null $set a dataset
2604
     *
2605
     * @return false|int|list<string> Number of rows inserted or FALSE on no data to perform an insert operation, SQL array when testMode
2606
     */
2607
    public function insertBatch($set = null, ?bool $escape = null, int $batchSize = 100)
2608
    {
2609
        if (isset($this->QBOptions['setQueryAsData'])) {
29✔
2610
            $sql = $this->_insertBatch($this->QBFrom[0], $this->QBKeys, []);
2✔
2611

2612
            if ($sql === '') {
2✔
2613
                return false; // @codeCoverageIgnore
2614
            }
2615

2616
            if ($this->testMode === false) {
2✔
2617
                $this->db->query($sql, null, false);
2✔
2618
            }
2619

2620
            $this->resetWrite();
2✔
2621

2622
            return $this->testMode ? $sql : $this->db->affectedRows();
2✔
2623
        }
2624

2625
        if ($set !== null && $set !== []) {
29✔
2626
            $this->setData($set, $escape);
27✔
2627
        }
2628

2629
        return $this->batchExecute('_insertBatch', $batchSize);
29✔
2630
    }
2631

2632
    /**
2633
     * Generates a platform-specific insert string from the supplied data.
2634
     *
2635
     * @used-by batchExecute()
2636
     *
2637
     * @param string                 $table  Protected table name
2638
     * @param list<string>           $keys   QBKeys
2639
     * @param list<list<int|string>> $values QBSet
2640
     */
2641
    protected function _insertBatch(string $table, array $keys, array $values): string
2642
    {
2643
        $sql = $this->QBOptions['sql'] ?? '';
27✔
2644

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

2650
            $this->QBOptions['sql'] = $sql;
27✔
2651
        }
2652

2653
        if (isset($this->QBOptions['setQueryAsData'])) {
27✔
2654
            $data = $this->QBOptions['setQueryAsData'];
2✔
2655
        } else {
2656
            $data = 'VALUES ' . implode(', ', $this->formatValues($values));
27✔
2657
        }
2658

2659
        return str_replace('{:_table_:}', $data, $sql);
27✔
2660
    }
2661

2662
    /**
2663
     * Compiles an insert query and returns the sql
2664
     *
2665
     * @return bool|string
2666
     *
2667
     * @throws DatabaseException
2668
     */
2669
    public function getCompiledInsert(bool $reset = true)
2670
    {
2671
        if ($this->validateInsert() === false) {
6✔
2672
            return false;
×
2673
        }
2674

2675
        $sql = $this->_insert(
6✔
2676
            $this->db->protectIdentifiers(
6✔
2677
                $this->removeAlias($this->QBFrom[0]),
6✔
2678
                true,
6✔
2679
                null,
6✔
2680
                false,
6✔
2681
            ),
6✔
2682
            array_keys($this->QBSet),
6✔
2683
            array_values($this->QBSet),
6✔
2684
        );
6✔
2685

2686
        if ($reset) {
6✔
2687
            $this->resetWrite();
6✔
2688
        }
2689

2690
        return $this->compileFinalQuery($sql);
6✔
2691
    }
2692

2693
    /**
2694
     * Compiles an insert string and runs the query
2695
     *
2696
     * @param array|object|null $set
2697
     *
2698
     * @return BaseResult|bool|Query
2699
     *
2700
     * @throws DatabaseException
2701
     */
2702
    public function insert($set = null, ?bool $escape = null)
2703
    {
2704
        if ($set !== null) {
861✔
2705
            $this->set($set, '', $escape);
827✔
2706
        }
2707

2708
        if ($this->validateInsert() === false) {
861✔
2709
            return false;
×
2710
        }
2711

2712
        $sql = $this->_insert(
860✔
2713
            $this->db->protectIdentifiers(
860✔
2714
                $this->removeAlias($this->QBFrom[0]),
860✔
2715
                true,
860✔
2716
                $escape,
860✔
2717
                false,
860✔
2718
            ),
860✔
2719
            array_keys($this->QBSet),
860✔
2720
            array_values($this->QBSet),
860✔
2721
        );
860✔
2722

2723
        if (! $this->testMode) {
860✔
2724
            $this->resetWrite();
856✔
2725

2726
            $result = $this->db->query($sql, $this->binds, false);
856✔
2727

2728
            // Clear our binds so we don't eat up memory
2729
            $this->binds = [];
856✔
2730

2731
            return $result;
856✔
2732
        }
2733

2734
        return false;
5✔
2735
    }
2736

2737
    /**
2738
     * @internal This is a temporary solution.
2739
     *
2740
     * @see https://github.com/codeigniter4/CodeIgniter4/pull/5376
2741
     *
2742
     * @TODO Fix a root cause, and this method should be removed.
2743
     */
2744
    protected function removeAlias(string $from): string
2745
    {
2746
        if (str_contains($from, ' ')) {
865✔
2747
            // if the alias is written with the AS keyword, remove it
2748
            $from = preg_replace('/\s+AS\s+/i', ' ', $from);
2✔
2749

2750
            $parts = explode(' ', $from);
2✔
2751
            $from  = $parts[0];
2✔
2752
        }
2753

2754
        return $from;
865✔
2755
    }
2756

2757
    /**
2758
     * This method is used by both insert() and getCompiledInsert() to
2759
     * validate that the there data is actually being set and that table
2760
     * has been chosen to be inserted into.
2761
     *
2762
     * @throws DatabaseException
2763
     */
2764
    protected function validateInsert(): bool
2765
    {
2766
        if (empty($this->QBSet)) {
861✔
2767
            if ($this->db->DBDebug) {
1✔
2768
                throw new DatabaseException('You must use the "set" method to insert an entry.');
1✔
2769
            }
2770

2771
            return false; // @codeCoverageIgnore
2772
        }
2773

2774
        return true;
860✔
2775
    }
2776

2777
    /**
2778
     * Generates a platform-specific insert string from the supplied data
2779
     *
2780
     * @param string           $table         Protected table name
2781
     * @param list<string>     $keys          Keys of QBSet
2782
     * @param list<int|string> $unescapedKeys Values of QBSet
2783
     */
2784
    protected function _insert(string $table, array $keys, array $unescapedKeys): string
2785
    {
2786
        return 'INSERT ' . $this->compileIgnore('insert') . 'INTO ' . $table . ' (' . implode(', ', $keys) . ') VALUES (' . implode(', ', $unescapedKeys) . ')';
851✔
2787
    }
2788

2789
    /**
2790
     * Compiles a replace into string and runs the query
2791
     *
2792
     * @return BaseResult|false|Query|string
2793
     *
2794
     * @throws DatabaseException
2795
     */
2796
    public function replace(?array $set = null)
2797
    {
2798
        if ($set !== null) {
8✔
2799
            $this->set($set);
7✔
2800
        }
2801

2802
        if (empty($this->QBSet)) {
8✔
2803
            if ($this->db->DBDebug) {
1✔
2804
                throw new DatabaseException('You must use the "set" method to update an entry.');
1✔
2805
            }
2806

2807
            return false; // @codeCoverageIgnore
2808
        }
2809

2810
        $table = $this->QBFrom[0];
7✔
2811

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

2814
        $this->resetWrite();
7✔
2815

2816
        return $this->testMode ? $sql : $this->db->query($sql, $this->binds, false);
7✔
2817
    }
2818

2819
    /**
2820
     * Generates a platform-specific replace string from the supplied data
2821
     *
2822
     * @param string           $table  Protected table name
2823
     * @param list<string>     $keys   Keys of QBSet
2824
     * @param list<int|string> $values Values of QBSet
2825
     */
2826
    protected function _replace(string $table, array $keys, array $values): string
2827
    {
2828
        return 'REPLACE INTO ' . $table . ' (' . implode(', ', $keys) . ') VALUES (' . implode(', ', $values) . ')';
7✔
2829
    }
2830

2831
    /**
2832
     * Groups tables in FROM clauses if needed, so there is no confusion
2833
     * about operator precedence.
2834
     *
2835
     * Note: This is only used (and overridden) by MySQL and SQLSRV.
2836
     */
2837
    protected function _fromTables(): string
2838
    {
2839
        return implode(', ', $this->QBFrom);
1,108✔
2840
    }
2841

2842
    /**
2843
     * Compiles an update query and returns the sql
2844
     *
2845
     * @return bool|string
2846
     */
2847
    public function getCompiledUpdate(bool $reset = true)
2848
    {
2849
        if ($this->validateUpdate() === false) {
13✔
2850
            return false;
×
2851
        }
2852

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

2855
        if ($reset) {
13✔
2856
            $this->resetWrite();
13✔
2857
        }
2858

2859
        return $this->compileFinalQuery($sql);
13✔
2860
    }
2861

2862
    /**
2863
     * Compiles an update string and runs the query.
2864
     *
2865
     * @param array|object|null        $set
2866
     * @param array|RawSql|string|null $where
2867
     *
2868
     * @throws DatabaseException
2869
     */
2870
    public function update($set = null, $where = null, ?int $limit = null): bool
2871
    {
2872
        if ($set !== null) {
106✔
2873
            $this->set($set);
52✔
2874
        }
2875

2876
        if ($this->validateUpdate() === false) {
106✔
2877
            return false;
×
2878
        }
2879

2880
        if ($where !== null) {
105✔
2881
            $this->where($where);
7✔
2882
        }
2883

2884
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
105✔
2885
        if ($limitZeroAsAll && $limit === 0) {
105✔
2886
            $limit = null;
×
2887
        }
2888

2889
        if ($limit !== null) {
105✔
2890
            if (! $this->canLimitWhereUpdates) {
3✔
2891
                throw new DatabaseException('This driver does not allow LIMITs on UPDATE queries using WHERE.');
2✔
2892
            }
2893

2894
            $this->limit($limit);
3✔
2895
        }
2896

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

2899
        if (! $this->testMode) {
105✔
2900
            $this->resetWrite();
92✔
2901

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

2904
            if ($result !== false) {
92✔
2905
                // Clear our binds so we don't eat up memory
2906
                $this->binds = [];
89✔
2907

2908
                return true;
89✔
2909
            }
2910

2911
            return false;
3✔
2912
        }
2913

2914
        return true;
13✔
2915
    }
2916

2917
    /**
2918
     * Generates a platform-specific update string from the supplied data
2919
     *
2920
     * @param string                $table  Protected table name
2921
     * @param array<string, string> $values QBSet
2922
     */
2923
    protected function _update(string $table, array $values): string
2924
    {
2925
        $valStr = [];
124✔
2926

2927
        foreach ($values as $key => $val) {
124✔
2928
            $valStr[] = $key . ' = ' . $val;
124✔
2929
        }
2930

2931
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
124✔
2932
        if ($limitZeroAsAll) {
124✔
2933
            return 'UPDATE ' . $this->compileIgnore('update') . $table . ' SET ' . implode(', ', $valStr)
124✔
2934
                . $this->compileWhereHaving('QBWhere')
124✔
2935
                . $this->compileOrderBy()
124✔
2936
                . ($this->QBLimit ? $this->_limit(' ', true) : '');
124✔
2937
        }
2938

2939
        return 'UPDATE ' . $this->compileIgnore('update') . $table . ' SET ' . implode(', ', $valStr)
×
2940
            . $this->compileWhereHaving('QBWhere')
×
2941
            . $this->compileOrderBy()
×
2942
            . ($this->QBLimit !== false ? $this->_limit(' ', true) : '');
×
2943
    }
2944

2945
    /**
2946
     * This method is used by both update() and getCompiledUpdate() to
2947
     * validate that data is actually being set and that a table has been
2948
     * chosen to be updated.
2949
     *
2950
     * @throws DatabaseException
2951
     */
2952
    protected function validateUpdate(): bool
2953
    {
2954
        if (empty($this->QBSet)) {
107✔
2955
            if ($this->db->DBDebug) {
1✔
2956
                throw new DatabaseException('You must use the "set" method to update an entry.');
1✔
2957
            }
2958

2959
            return false; // @codeCoverageIgnore
2960
        }
2961

2962
        return true;
106✔
2963
    }
2964

2965
    /**
2966
     * Sets data and calls batchExecute to run queries
2967
     *
2968
     * @param array|object|null        $set         a dataset
2969
     * @param array|RawSql|string|null $constraints
2970
     *
2971
     * @return false|int|list<string> Number of rows affected or FALSE on failure, SQL array when testMode
2972
     */
2973
    public function updateBatch($set = null, $constraints = null, int $batchSize = 100)
2974
    {
2975
        $this->onConstraint($constraints);
23✔
2976

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

2980
            if ($sql === '') {
1✔
2981
                return false; // @codeCoverageIgnore
2982
            }
2983

2984
            if ($this->testMode === false) {
1✔
2985
                $this->db->query($sql, null, false);
1✔
2986
            }
2987

2988
            $this->resetWrite();
1✔
2989

2990
            return $this->testMode ? $sql : $this->db->affectedRows();
1✔
2991
        }
2992

2993
        if ($set !== null && $set !== []) {
22✔
2994
            $this->setData($set, true);
16✔
2995
        }
2996

2997
        return $this->batchExecute('_updateBatch', $batchSize);
22✔
2998
    }
2999

3000
    /**
3001
     * Generates a platform-specific batch update string from the supplied data
3002
     *
3003
     * @used-by batchExecute()
3004
     *
3005
     * @param string                 $table  Protected table name
3006
     * @param list<string>           $keys   QBKeys
3007
     * @param list<list<int|string>> $values QBSet
3008
     */
3009
    protected function _updateBatch(string $table, array $keys, array $values): string
3010
    {
3011
        $sql = $this->QBOptions['sql'] ?? '';
19✔
3012

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

3017
            if ($constraints === []) {
19✔
3018
                if ($this->db->DBDebug) {
2✔
3019
                    throw new DatabaseException('You must specify a constraint to match on for batch updates.'); // @codeCoverageIgnore
3020
                }
3021

3022
                return ''; // @codeCoverageIgnore
3023
            }
3024

3025
            $updateFields = $this->QBOptions['updateFields'] ??
17✔
3026
                $this->updateFields($keys, false, $constraints)->QBOptions['updateFields'] ??
17✔
3027
                [];
14✔
3028

3029
            $alias = $this->QBOptions['alias'] ?? '_u';
17✔
3030

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

3033
            $sql .= "SET\n";
17✔
3034

3035
            $sql .= implode(
17✔
3036
                ",\n",
17✔
3037
                array_map(
17✔
3038
                    static fn ($key, $value): string => $key . ($value instanceof RawSql ?
17✔
3039
                        ' = ' . $value :
2✔
3040
                        ' = ' . $alias . '.' . $value),
17✔
3041
                    array_keys($updateFields),
17✔
3042
                    $updateFields,
17✔
3043
                ),
17✔
3044
            ) . "\n";
17✔
3045

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

3048
            $sql .= ') ' . $alias . "\n";
17✔
3049

3050
            $sql .= 'WHERE ' . implode(
17✔
3051
                ' AND ',
17✔
3052
                array_map(
17✔
3053
                    static fn ($key, $value) => (
17✔
3054
                        ($value instanceof RawSql && is_string($key))
17✔
3055
                        ?
17✔
3056
                        $table . '.' . $key . ' = ' . $value
1✔
3057
                        :
17✔
3058
                        (
17✔
3059
                            $value instanceof RawSql
16✔
3060
                            ?
16✔
3061
                            $value
3✔
3062
                            :
16✔
3063
                            $table . '.' . $value . ' = ' . $alias . '.' . $value
17✔
3064
                        )
17✔
3065
                    ),
17✔
3066
                    array_keys($constraints),
17✔
3067
                    $constraints,
17✔
3068
                ),
17✔
3069
            );
17✔
3070

3071
            $this->QBOptions['sql'] = $sql;
17✔
3072
        }
3073

3074
        if (isset($this->QBOptions['setQueryAsData'])) {
17✔
3075
            $data = $this->QBOptions['setQueryAsData'];
1✔
3076
        } else {
3077
            $data = implode(
16✔
3078
                " UNION ALL\n",
16✔
3079
                array_map(
16✔
3080
                    static fn ($value): string => 'SELECT ' . implode(', ', array_map(
16✔
3081
                        static fn ($key, $index): string => $index . ' ' . $key,
16✔
3082
                        $keys,
16✔
3083
                        $value,
16✔
3084
                    )),
16✔
3085
                    $values,
16✔
3086
                ),
16✔
3087
            ) . "\n";
16✔
3088
        }
3089

3090
        return str_replace('{:_table_:}', $data, $sql);
17✔
3091
    }
3092

3093
    /**
3094
     * Compiles a delete string and runs "DELETE FROM table"
3095
     *
3096
     * @return bool|string TRUE on success, FALSE on failure, string on testMode
3097
     */
3098
    public function emptyTable()
3099
    {
3100
        $table = $this->QBFrom[0];
4✔
3101

3102
        $sql = $this->_delete($table);
4✔
3103

3104
        if ($this->testMode) {
4✔
3105
            return $sql;
1✔
3106
        }
3107

3108
        $this->resetWrite();
3✔
3109

3110
        return $this->db->query($sql, null, false);
3✔
3111
    }
3112

3113
    /**
3114
     * Compiles a truncate string and runs the query
3115
     * If the database does not support the truncate() command
3116
     * This function maps to "DELETE FROM table"
3117
     *
3118
     * @return bool|string TRUE on success, FALSE on failure, string on testMode
3119
     */
3120
    public function truncate()
3121
    {
3122
        $table = $this->QBFrom[0];
779✔
3123

3124
        $sql = $this->_truncate($table);
779✔
3125

3126
        if ($this->testMode) {
779✔
3127
            return $sql;
2✔
3128
        }
3129

3130
        $this->resetWrite();
778✔
3131

3132
        return $this->db->query($sql, null, false);
778✔
3133
    }
3134

3135
    /**
3136
     * Generates a platform-specific truncate string from the supplied data
3137
     *
3138
     * If the database does not support the truncate() command,
3139
     * then this method maps to 'DELETE FROM table'
3140
     *
3141
     * @param string $table Protected table name
3142
     */
3143
    protected function _truncate(string $table): string
3144
    {
3145
        return 'TRUNCATE ' . $table;
764✔
3146
    }
3147

3148
    /**
3149
     * Compiles a delete query string and returns the sql
3150
     */
3151
    public function getCompiledDelete(bool $reset = true): string
3152
    {
3153
        $sql = $this->testMode()->delete('', null, $reset);
3✔
3154
        $this->testMode(false);
3✔
3155

3156
        return $this->compileFinalQuery($sql);
3✔
3157
    }
3158

3159
    /**
3160
     * Compiles a delete string and runs the query
3161
     *
3162
     * @param array|RawSql|string $where
3163
     *
3164
     * @return bool|string Returns a SQL string if in test mode.
3165
     *
3166
     * @throws DatabaseException
3167
     */
3168
    public function delete($where = '', ?int $limit = null, bool $resetData = true)
3169
    {
3170
        $table = $this->db->protectIdentifiers($this->QBFrom[0], true, null, false);
818✔
3171

3172
        if ($where !== '') {
818✔
3173
            $this->where($where);
4✔
3174
        }
3175

3176
        if (empty($this->QBWhere)) {
818✔
3177
            if ($this->db->DBDebug) {
2✔
3178
                throw new DatabaseException('Deletes are not allowed unless they contain a "where" or "like" clause.');
2✔
3179
            }
3180

3181
            return false; // @codeCoverageIgnore
3182
        }
3183

3184
        $sql = $this->_delete($this->removeAlias($table));
818✔
3185

3186
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
818✔
3187
        if ($limitZeroAsAll && $limit === 0) {
818✔
3188
            $limit = null;
×
3189
        }
3190

3191
        if ($limit !== null) {
818✔
3192
            $this->QBLimit = $limit;
1✔
3193
        }
3194

3195
        if (! empty($this->QBLimit)) {
818✔
3196
            if (! $this->canLimitDeletes) {
2✔
3197
                throw new DatabaseException('SQLite3 does not allow LIMITs on DELETE queries.');
1✔
3198
            }
3199

3200
            $sql = $this->_limit($sql, true);
2✔
3201
        }
3202

3203
        if ($resetData) {
818✔
3204
            $this->resetWrite();
818✔
3205
        }
3206

3207
        return $this->testMode ? $sql : $this->db->query($sql, $this->binds, false);
818✔
3208
    }
3209

3210
    /**
3211
     * Sets data and calls batchExecute to run queries
3212
     *
3213
     * @param array|object|null $set         a dataset
3214
     * @param array|RawSql|null $constraints
3215
     *
3216
     * @return false|int|list<string> Number of rows affected or FALSE on failure, SQL array when testMode
3217
     */
3218
    public function deleteBatch($set = null, $constraints = null, int $batchSize = 100)
3219
    {
3220
        $this->onConstraint($constraints);
3✔
3221

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

3225
            if ($sql === '') {
1✔
3226
                return false; // @codeCoverageIgnore
3227
            }
3228

3229
            if ($this->testMode === false) {
1✔
3230
                $this->db->query($sql, null, false);
1✔
3231
            }
3232

3233
            $this->resetWrite();
1✔
3234

3235
            return $this->testMode ? $sql : $this->db->affectedRows();
1✔
3236
        }
3237

3238
        if ($set !== null && $set !== []) {
2✔
3239
            $this->setData($set, true);
×
3240
        }
3241

3242
        return $this->batchExecute('_deleteBatch', $batchSize);
2✔
3243
    }
3244

3245
    /**
3246
     * Generates a platform-specific batch update string from the supplied data
3247
     *
3248
     * @used-by batchExecute()
3249
     *
3250
     * @param string           $table  Protected table name
3251
     * @param list<string>     $keys   QBKeys
3252
     * @param list<int|string> $values QBSet
3253
     */
3254
    protected function _deleteBatch(string $table, array $keys, array $values): string
3255
    {
3256
        $sql = $this->QBOptions['sql'] ?? '';
3✔
3257

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

3262
            if ($constraints === []) {
3✔
3263
                if ($this->db->DBDebug) {
×
3264
                    throw new DatabaseException('You must specify a constraint to match on for batch deletes.'); // @codeCoverageIgnore
3265
                }
3266

3267
                return ''; // @codeCoverageIgnore
3268
            }
3269

3270
            $alias = $this->QBOptions['alias'] ?? '_u';
3✔
3271

3272
            $sql = 'DELETE ' . $table . ' FROM ' . $table . "\n";
3✔
3273

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

3276
            $sql .= ') ' . $alias . "\n";
3✔
3277

3278
            $sql .= 'ON ' . implode(
3✔
3279
                ' AND ',
3✔
3280
                array_map(
3✔
3281
                    static fn ($key, $value) => (
3✔
3282
                        $value instanceof RawSql ?
3✔
3283
                        $value :
×
3284
                        (
3✔
3285
                            is_string($key) ?
3✔
3286
                            $table . '.' . $key . ' = ' . $alias . '.' . $value :
2✔
3287
                            $table . '.' . $value . ' = ' . $alias . '.' . $value
3✔
3288
                        )
3✔
3289
                    ),
3✔
3290
                    array_keys($constraints),
3✔
3291
                    $constraints,
3✔
3292
                ),
3✔
3293
            );
3✔
3294

3295
            // convert binds in where
3296
            foreach ($this->QBWhere as $key => $where) {
3✔
3297
                foreach ($this->binds as $field => $bind) {
2✔
3298
                    $this->QBWhere[$key]['condition'] = str_replace(':' . $field . ':', $bind[0], $where['condition']);
×
3299
                }
3300
            }
3301

3302
            $sql .= ' ' . $this->compileWhereHaving('QBWhere');
3✔
3303

3304
            $this->QBOptions['sql'] = trim($sql);
3✔
3305
        }
3306

3307
        if (isset($this->QBOptions['setQueryAsData'])) {
3✔
3308
            $data = $this->QBOptions['setQueryAsData'];
1✔
3309
        } else {
3310
            $data = implode(
2✔
3311
                " UNION ALL\n",
2✔
3312
                array_map(
2✔
3313
                    static fn ($value): string => 'SELECT ' . implode(', ', array_map(
2✔
3314
                        static fn ($key, $index): string => $index . ' ' . $key,
2✔
3315
                        $keys,
2✔
3316
                        $value,
2✔
3317
                    )),
2✔
3318
                    $values,
2✔
3319
                ),
2✔
3320
            ) . "\n";
2✔
3321
        }
3322

3323
        return str_replace('{:_table_:}', $data, $sql);
3✔
3324
    }
3325

3326
    /**
3327
     * Increments a numeric column by the specified value.
3328
     *
3329
     * @return bool
3330
     */
3331
    public function increment(string $column, int $value = 1)
3332
    {
3333
        return $this->incrementMany([$column], $value);
6✔
3334
    }
3335

3336
    /**
3337
     * Increments multiple numeric columns by the specified value(s).
3338
     *
3339
     * @param array<string, int>|list<string> $columns A list of columns or array of column => value pairs to increment.
3340
     * @param int                             $value   The value to increment by if $columns is a list of column names.
3341
     */
3342
    public function incrementMany(array $columns, int $value = 1): bool
3343
    {
3344
        if ($columns === []) {
11✔
3345
            throw new InvalidArgumentException('Argument #1 ($columns) cannot be empty.');
1✔
3346
        }
3347

3348
        if (array_is_list($columns)) {
10✔
3349
            $columns = array_fill_keys($columns, $value);
7✔
3350
        }
3351

3352
        $fields = [];
10✔
3353

3354
        foreach ($columns as $col => $val) {
10✔
3355
            if (! is_int($val)) {
10✔
3356
                throw new TypeError(sprintf(
1✔
3357
                    'Argument #1 ($columns) must contain only int values, %s given for "%s".',
1✔
3358
                    get_debug_type($val),
1✔
3359
                    $col,
1✔
3360
                ));
1✔
3361
            }
3362

3363
            $col          = $this->db->protectIdentifiers($col);
10✔
3364
            $fields[$col] = "{$col} + {$val}";
10✔
3365
        }
3366

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

3369
        if (! $this->testMode) {
9✔
3370
            $this->resetWrite();
9✔
3371

3372
            return $this->db->query($sql, $this->binds, false);
9✔
3373
        }
3374

3375
        return true;
×
3376
    }
3377

3378
    /**
3379
     * Decrements a numeric column by the specified value.
3380
     *
3381
     * @return bool
3382
     */
3383
    public function decrement(string $column, int $value = 1)
3384
    {
3385
        return $this->decrementMany([$column], $value);
6✔
3386
    }
3387

3388
    /**
3389
     * Decrements multiple numeric columns by the specified value(s).
3390
     *
3391
     * @param array<string, int>|list<string> $columns A list of columns or array of column => value pairs to decrement.
3392
     * @param int                             $value   The value to decrement by if $columns is a list of column names.
3393
     */
3394
    public function decrementMany(array $columns, int $value = 1): bool
3395
    {
3396
        if ($columns === []) {
11✔
3397
            throw new InvalidArgumentException('Argument #1 ($columns) cannot be empty.');
1✔
3398
        }
3399

3400
        if (array_is_list($columns)) {
10✔
3401
            $columns = array_fill_keys($columns, $value);
7✔
3402
        }
3403

3404
        $fields = [];
10✔
3405

3406
        foreach ($columns as $col => $val) {
10✔
3407
            if (! is_int($val)) {
10✔
3408
                throw new TypeError(sprintf(
1✔
3409
                    'Argument #1 ($columns) must contain only int values, %s given for "%s".',
1✔
3410
                    get_debug_type($val),
1✔
3411
                    $col,
1✔
3412
                ));
1✔
3413
            }
3414

3415
            $col          = $this->db->protectIdentifiers($col);
10✔
3416
            $fields[$col] = "{$col} - {$val}";
10✔
3417
        }
3418

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

3421
        if (! $this->testMode) {
9✔
3422
            $this->resetWrite();
9✔
3423

3424
            return $this->db->query($sql, $this->binds, false);
9✔
3425
        }
3426

3427
        return true;
×
3428
    }
3429

3430
    /**
3431
     * Generates a platform-specific delete string from the supplied data
3432
     *
3433
     * @param string $table Protected table name
3434
     */
3435
    protected function _delete(string $table): string
3436
    {
3437
        return 'DELETE ' . $this->compileIgnore('delete') . 'FROM ' . $table . $this->compileWhereHaving('QBWhere');
821✔
3438
    }
3439

3440
    /**
3441
     * Used to track SQL statements written with aliased tables.
3442
     *
3443
     * @param array|string $table The table to inspect
3444
     *
3445
     * @return string|null
3446
     */
3447
    protected function trackAliases($table)
3448
    {
3449
        if (is_array($table)) {
1,224✔
3450
            foreach ($table as $t) {
×
3451
                $this->trackAliases($t);
×
3452
            }
3453

3454
            return null;
×
3455
        }
3456

3457
        // Does the string contain a comma?  If so, we need to separate
3458
        // the string into discreet statements
3459
        if (str_contains($table, ',')) {
1,224✔
3460
            return $this->trackAliases(explode(',', $table));
×
3461
        }
3462

3463
        // if a table alias is used we can recognize it by a space
3464
        if (str_contains($table, ' ')) {
1,224✔
3465
            // if the alias is written with the AS keyword, remove it
3466
            $table = preg_replace('/\s+AS\s+/i', ' ', $table);
20✔
3467

3468
            // Grab the alias
3469
            $alias = trim(strrchr($table, ' '));
20✔
3470

3471
            // Store the alias, if it doesn't already exist
3472
            $this->db->addTableAlias($alias);
20✔
3473
        }
3474

3475
        return null;
1,224✔
3476
    }
3477

3478
    /**
3479
     * Compile the SELECT statement
3480
     *
3481
     * Generates a query string based on which functions were used.
3482
     * Should not be called directly.
3483
     *
3484
     * @param mixed $selectOverride
3485
     */
3486
    protected function compileSelect($selectOverride = false): string
3487
    {
3488
        if ($selectOverride !== false) {
1,133✔
3489
            $sql = $selectOverride;
244✔
3490
        } else {
3491
            $sql = $this->QBDistinct ? 'SELECT DISTINCT ' : 'SELECT ';
1,126✔
3492

3493
            if (empty($this->QBSelect)) {
1,126✔
3494
                $sql .= '*';
1,013✔
3495
            } else {
3496
                // Cycle through the "select" portion of the query and prep each column name.
3497
                // The reason we protect identifiers here rather than in the select() function
3498
                // is because until the user calls the from() function we don't know if there are aliases
3499
                foreach ($this->QBSelect as $key => $val) {
958✔
3500
                    if ($val instanceof RawSql) {
958✔
3501
                        $this->QBSelect[$key] = (string) $val;
5✔
3502
                    } else {
3503
                        $protect              = $this->QBNoEscape[$key] ?? null;
956✔
3504
                        $this->QBSelect[$key] = $this->db->protectIdentifiers($val, false, $protect);
956✔
3505
                    }
3506
                }
3507

3508
                $sql .= implode(', ', $this->QBSelect);
958✔
3509
            }
3510
        }
3511

3512
        if (! empty($this->QBFrom)) {
1,133✔
3513
            $sql .= "\nFROM " . $this->_fromTables();
1,133✔
3514
        }
3515

3516
        if (! empty($this->QBJoin)) {
1,133✔
3517
            $sql .= "\n" . implode("\n", $this->QBJoin);
15✔
3518
        }
3519

3520
        $sql .= $this->compileWhereHaving('QBWhere')
1,133✔
3521
            . $this->compileGroupBy()
1,133✔
3522
            . $this->compileWhereHaving('QBHaving')
1,133✔
3523
            . $this->compileOrderBy();
1,133✔
3524

3525
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
1,133✔
3526
        if ($limitZeroAsAll) {
1,133✔
3527
            if ($this->QBLimit) {
1,131✔
3528
                $sql = $this->_limit($sql . "\n");
122✔
3529
            }
3530
        } elseif ($this->QBLimit !== false || $this->QBOffset) {
3✔
3531
            $sql = $this->_limit($sql . "\n");
3✔
3532
        }
3533

3534
        $sql .= $this->compileLockForUpdate();
1,133✔
3535

3536
        return $this->unionInjection($sql);
1,123✔
3537
    }
3538

3539
    /**
3540
     * Compile the SELECT lock clause.
3541
     */
3542
    protected function compileLockForUpdate(): string
3543
    {
3544
        if ($this->QBLockForUpdate && $this->QBUnion !== []) {
238✔
3545
            throw new DatabaseException('Query Builder does not support lockForUpdate() with union() or unionAll().');
1✔
3546
        }
3547

3548
        return $this->QBLockForUpdate ? "\nFOR UPDATE" : '';
238✔
3549
    }
3550

3551
    /**
3552
     * Checks if the ignore option is supported by
3553
     * the Database Driver for the specific statement.
3554
     *
3555
     * @return string
3556
     */
3557
    protected function compileIgnore(string $statement)
3558
    {
3559
        if ($this->QBIgnore && isset($this->supportedIgnoreStatements[$statement])) {
882✔
3560
            return trim($this->supportedIgnoreStatements[$statement]) . ' ';
1✔
3561
        }
3562

3563
        return '';
881✔
3564
    }
3565

3566
    /**
3567
     * Escapes identifiers in WHERE and HAVING statements at execution time.
3568
     *
3569
     * Required so that aliases are tracked properly, regardless of whether
3570
     * where(), orWhere(), having(), orHaving are called prior to from(),
3571
     * join() and prefixTable is added only if needed.
3572
     *
3573
     * @param string $qbKey 'QBWhere' or 'QBHaving'
3574
     *
3575
     * @return string SQL statement
3576
     */
3577
    protected function compileWhereHaving(string $qbKey): string
3578
    {
3579
        if (! empty($this->{$qbKey})) {
1,168✔
3580
            foreach ($this->{$qbKey} as &$qbkey) {
1,025✔
3581
                // Is this condition already compiled?
3582
                if (is_string($qbkey)) {
1,025✔
3583
                    continue;
32✔
3584
                }
3585

3586
                if ($qbkey instanceof RawSql) {
1,025✔
3587
                    continue;
2✔
3588
                }
3589

3590
                if ($qbkey['condition'] instanceof RawSql) {
1,025✔
3591
                    $qbkey = $qbkey['condition'];
4✔
3592

3593
                    continue;
4✔
3594
                }
3595

3596
                if (($qbkey['columnComparison'] ?? false) === true) {
1,023✔
3597
                    $qbkey = $this->compileColumnComparison($qbkey);
22✔
3598

3599
                    continue;
22✔
3600
                }
3601

3602
                if (($qbkey['betweenComparison'] ?? false) === true) {
1,010✔
3603
                    $qbkey = $this->compileBetweenComparison($qbkey);
8✔
3604

3605
                    continue;
8✔
3606
                }
3607

3608
                if ($qbkey['escape'] === false) {
1,005✔
3609
                    $qbkey = $qbkey['condition'];
120✔
3610

3611
                    continue;
120✔
3612
                }
3613

3614
                // Split multiple conditions
3615
                $conditions = preg_split(
992✔
3616
                    '/((?:^|\s+)AND\s+|(?:^|\s+)OR\s+)/i',
992✔
3617
                    $qbkey['condition'],
992✔
3618
                    -1,
992✔
3619
                    PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY,
992✔
3620
                );
992✔
3621

3622
                foreach ($conditions as &$condition) {
992✔
3623
                    $op = $this->getOperator($condition);
992✔
3624
                    if (
3625
                        $op === false
992✔
3626
                        || preg_match(
992✔
3627
                            '/^(\(?)(.*)(' . preg_quote($op, '/') . ')\s*(.*(?<!\)))?(\)?)$/i',
992✔
3628
                            $condition,
992✔
3629
                            $matches,
992✔
3630
                        ) !== 1
992✔
3631
                    ) {
3632
                        continue;
865✔
3633
                    }
3634

3635
                    // $matches = [
3636
                    //  0 => '(test <= foo)',   /* the whole thing */
3637
                    //  1 => '(',               /* optional */
3638
                    //  2 => 'test',            /* the field name */
3639
                    //  3 => ' <= ',            /* $op */
3640
                    //  4 => 'foo',                    /* optional, if $op is e.g. 'IS NULL' */
3641
                    //  5 => ')'                /* optional */
3642
                    // ];
3643

3644
                    if ($matches[4] !== '') {
992✔
3645
                        $protectIdentifiers = false;
955✔
3646
                        if (str_contains($matches[4], '.')) {
955✔
3647
                            $protectIdentifiers = true;
87✔
3648
                        }
3649

3650
                        if (! str_contains($matches[4], ':')) {
955✔
3651
                            $matches[4] = $this->db->protectIdentifiers(trim($matches[4]), false, $protectIdentifiers);
14✔
3652
                        }
3653

3654
                        $matches[4] = ' ' . $matches[4];
955✔
3655
                    }
3656

3657
                    $condition = $matches[1] . $this->db->protectIdentifiers(trim($matches[2]))
992✔
3658
                        . ' ' . trim($matches[3]) . $matches[4] . $matches[5];
992✔
3659
                }
3660

3661
                $qbkey = implode('', $conditions);
992✔
3662
            }
3663

3664
            return ($qbKey === 'QBHaving' ? "\nHAVING " : "\nWHERE ")
1,025✔
3665
                . implode("\n", $this->{$qbKey});
1,025✔
3666
        }
3667

3668
        return '';
1,149✔
3669
    }
3670

3671
    /**
3672
     * @used-by compileWhereHaving()
3673
     *
3674
     * @param array{columnComparison: true, condition: string, escape: bool, first: string, operator: string, second: string} $condition
3675
     */
3676
    private function compileColumnComparison(array $condition): string
3677
    {
3678
        if ($condition['escape']) {
22✔
3679
            $condition['first']  = $this->db->protectIdentifiers($condition['first'], false, true);
20✔
3680
            $condition['second'] = $this->db->protectIdentifiers($condition['second'], false, true);
20✔
3681
        }
3682

3683
        return $condition['condition'] . $condition['first'] . ' ' . $condition['operator'] . ' ' . $condition['second'];
22✔
3684
    }
3685

3686
    /**
3687
     * @used-by compileWhereHaving()
3688
     *
3689
     * @param array{betweenComparison: true, condition: string, escape: bool, key: string, lowerBind: string, not: string, upperBind: string} $condition
3690
     */
3691
    private function compileBetweenComparison(array $condition): string
3692
    {
3693
        if ($condition['escape']) {
8✔
3694
            $condition['key'] = $this->db->protectIdentifiers($condition['key'], false, true);
7✔
3695
        }
3696

3697
        return $condition['condition'] . $condition['key'] . $condition['not'] . ' BETWEEN :' . $condition['lowerBind'] . ': AND :' . $condition['upperBind'] . ':';
8✔
3698
    }
3699

3700
    /**
3701
     * Escapes identifiers in GROUP BY statements at execution time.
3702
     *
3703
     * Required so that aliases are tracked properly, regardless of whether
3704
     * groupBy() is called prior to from(), join() and prefixTable is added
3705
     * only if needed.
3706
     */
3707
    protected function compileGroupBy(): string
3708
    {
3709
        if (! empty($this->QBGroupBy)) {
1,151✔
3710
            foreach ($this->QBGroupBy as &$groupBy) {
59✔
3711
                // Is it already compiled?
3712
                if (is_string($groupBy)) {
59✔
3713
                    continue;
3✔
3714
                }
3715

3716
                $groupBy = ($groupBy['escape'] === false || $this->isLiteral($groupBy['field']))
59✔
3717
                    ? $groupBy['field']
×
3718
                    : $this->db->protectIdentifiers($groupBy['field']);
59✔
3719
            }
3720

3721
            return "\nGROUP BY " . implode(', ', $this->QBGroupBy);
59✔
3722
        }
3723

3724
        return '';
1,122✔
3725
    }
3726

3727
    /**
3728
     * Escapes identifiers in ORDER BY statements at execution time.
3729
     *
3730
     * Required so that aliases are tracked properly, regardless of whether
3731
     * orderBy() is called prior to from(), join() and prefixTable is added
3732
     * only if needed.
3733
     */
3734
    protected function compileOrderBy(): string
3735
    {
3736
        if (is_array($this->QBOrderBy) && $this->QBOrderBy !== []) {
1,163✔
3737
            foreach ($this->QBOrderBy as &$orderBy) {
862✔
3738
                if (is_string($orderBy)) {
862✔
3739
                    continue;
1✔
3740
                }
3741
                if ($orderBy['escape'] !== false && ! $this->isLiteral($orderBy['field'])) {
862✔
3742
                    $orderBy['field'] = $this->db->protectIdentifiers($orderBy['field']);
860✔
3743
                }
3744

3745
                $orderBy = $orderBy['field'] . $orderBy['direction'];
862✔
3746
            }
3747

3748
            return "\nORDER BY " . implode(', ', $this->QBOrderBy);
862✔
3749
        }
3750

3751
        return '';
1,134✔
3752
    }
3753

3754
    protected function unionInjection(string $sql): string
3755
    {
3756
        if ($this->QBUnion === []) {
1,140✔
3757
            return $sql;
1,140✔
3758
        }
3759

3760
        return 'SELECT * FROM (' . $sql . ') '
7✔
3761
            . ($this->db->protectIdentifiers ? $this->db->escapeIdentifiers('uwrp0') : 'uwrp0')
7✔
3762
            . implode("\n", $this->QBUnion);
7✔
3763
    }
3764

3765
    /**
3766
     * Takes an object as input and converts the class variables to array key/vals
3767
     *
3768
     * @param array|object $object
3769
     *
3770
     * @return array
3771
     */
3772
    protected function objectToArray($object)
3773
    {
3774
        if (! is_object($object)) {
881✔
3775
            return $object;
878✔
3776
        }
3777

3778
        if ($object instanceof RawSql) {
8✔
3779
            throw new InvalidArgumentException('RawSql "' . $object . '" cannot be used here.');
1✔
3780
        }
3781

3782
        $array = [];
7✔
3783

3784
        foreach (get_object_vars($object) as $key => $val) {
7✔
3785
            if ((! is_object($val) || $val instanceof RawSql) && ! is_array($val)) {
7✔
3786
                $array[$key] = $val;
7✔
3787
            }
3788
        }
3789

3790
        return $array;
7✔
3791
    }
3792

3793
    /**
3794
     * Takes an object as input and converts the class variables to array key/vals
3795
     *
3796
     * @param array|object $object
3797
     *
3798
     * @return array
3799
     */
3800
    protected function batchObjectToArray($object)
3801
    {
3802
        if (! is_object($object)) {
68✔
3803
            return $object;
68✔
3804
        }
3805

3806
        $array  = [];
×
3807
        $out    = get_object_vars($object);
×
3808
        $fields = array_keys($out);
×
3809

3810
        foreach ($fields as $val) {
×
3811
            $i = 0;
×
3812

3813
            foreach ($out[$val] as $data) {
×
3814
                $array[$i++][$val] = $data;
×
3815
            }
3816
        }
3817

3818
        return $array;
×
3819
    }
3820

3821
    /**
3822
     * Determines if a string represents a literal value or a field name
3823
     */
3824
    protected function isLiteral(string $str): bool
3825
    {
3826
        $str = trim($str);
893✔
3827

3828
        if ($str === ''
893✔
3829
            || ctype_digit($str)
893✔
3830
            || (string) (float) $str === $str
893✔
3831
            || in_array(strtoupper($str), ['TRUE', 'FALSE'], true)
893✔
3832
        ) {
3833
            return true;
×
3834
        }
3835

3836
        if ($this->isLiteralStr === []) {
893✔
3837
            $this->isLiteralStr = $this->db->escapeChar !== '"' ? ['"', "'"] : ["'"];
893✔
3838
        }
3839

3840
        return in_array($str[0], $this->isLiteralStr, true);
893✔
3841
    }
3842

3843
    /**
3844
     * Publicly-visible method to reset the QB values.
3845
     *
3846
     * @return $this
3847
     */
3848
    public function resetQuery()
3849
    {
3850
        $this->resetSelect();
1✔
3851
        $this->resetWrite();
1✔
3852

3853
        return $this;
1✔
3854
    }
3855

3856
    /**
3857
     * Resets the query builder values.  Called by the get() function
3858
     *
3859
     * @param array $qbResetItems An array of fields to reset
3860
     *
3861
     * @return void
3862
     */
3863
    protected function resetRun(array $qbResetItems)
3864
    {
3865
        foreach ($qbResetItems as $item => $defaultValue) {
1,145✔
3866
            $this->{$item} = $defaultValue;
1,145✔
3867
        }
3868
    }
3869

3870
    /**
3871
     * Resets the query builder values.  Called by the get() function
3872
     *
3873
     * @return void
3874
     */
3875
    protected function resetSelect()
3876
    {
3877
        $this->resetRun([
1,118✔
3878
            'QBSelect'              => [],
1,118✔
3879
            'QBJoin'                => [],
1,118✔
3880
            'QBWhere'               => [],
1,118✔
3881
            'QBGroupBy'             => [],
1,118✔
3882
            'QBHaving'              => [],
1,118✔
3883
            'QBOrderBy'             => [],
1,118✔
3884
            'QBNoEscape'            => [],
1,118✔
3885
            'QBDistinct'            => false,
1,118✔
3886
            'QBLimit'               => false,
1,118✔
3887
            'QBOffset'              => false,
1,118✔
3888
            'QBLockForUpdate'       => false,
1,118✔
3889
            'QBSelectUsesAggregate' => false,
1,118✔
3890
            'QBUnion'               => [],
1,118✔
3891
        ]);
1,118✔
3892

3893
        if ($this->db instanceof BaseConnection) {
1,118✔
3894
            $this->db->setAliasedTables([]);
1,118✔
3895
        }
3896

3897
        // Reset QBFrom part
3898
        if (! empty($this->QBFrom)) {
1,118✔
3899
            $this->from(array_shift($this->QBFrom), true);
1,118✔
3900
        }
3901
    }
3902

3903
    /**
3904
     * Resets the query builder "write" values.
3905
     *
3906
     * Called by the insert() update() insertBatch() updateBatch() and delete() functions
3907
     *
3908
     * @return void
3909
     */
3910
    protected function resetWrite()
3911
    {
3912
        $this->resetRun([
888✔
3913
            'QBSet'     => [],
888✔
3914
            'QBJoin'    => [],
888✔
3915
            'QBWhere'   => [],
888✔
3916
            'QBOrderBy' => [],
888✔
3917
            'QBKeys'    => [],
888✔
3918
            'QBLimit'   => false,
888✔
3919
            'QBIgnore'  => false,
888✔
3920
            'QBOptions' => [],
888✔
3921
        ]);
888✔
3922
    }
3923

3924
    /**
3925
     * Tests whether the string has an SQL operator
3926
     */
3927
    protected function hasOperator(string $str): bool
3928
    {
3929
        return preg_match(
160✔
3930
            '/(<|>|!|=|\sIS NULL|\sIS NOT NULL|\sEXISTS|\sBETWEEN|\sLIKE|\sIN\s*\(|\s)/i',
160✔
3931
            trim($str),
160✔
3932
        ) === 1;
160✔
3933
    }
3934

3935
    /**
3936
     * Returns the SQL string operator
3937
     *
3938
     * @return array|false|string
3939
     */
3940
    protected function getOperator(string $str, bool $list = false)
3941
    {
3942
        if ($this->pregOperators === []) {
1,002✔
3943
            $_les = $this->db->likeEscapeStr !== ''
1,002✔
3944
                ? '\s+' . preg_quote(trim(sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar)), '/')
1,002✔
3945
                : '';
×
3946
            $this->pregOperators = [
1,002✔
3947
                '\s*(?:<|>|!)?=\s*', // =, <=, >=, !=
1,002✔
3948
                '\s*<>?\s*',         // <, <>
1,002✔
3949
                '\s*>\s*',           // >
1,002✔
3950
                '\s+IS NULL',             // IS NULL
1,002✔
3951
                '\s+IS NOT NULL',         // IS NOT NULL
1,002✔
3952
                '\s+EXISTS\s*\(.*\)',     // EXISTS (sql)
1,002✔
3953
                '\s+NOT EXISTS\s*\(.*\)', // NOT EXISTS(sql)
1,002✔
3954
                '\s+BETWEEN\s+',          // BETWEEN value AND value
1,002✔
3955
                '\s+IN\s*\(.*\)',         // IN (list)
1,002✔
3956
                '\s+NOT IN\s*\(.*\)',     // NOT IN (list)
1,002✔
3957
                '\s+LIKE\s+\S.*(' . $_les . ')?',     // LIKE 'expr'[ ESCAPE '%s']
1,002✔
3958
                '\s+NOT LIKE\s+\S.*(' . $_les . ')?', // NOT LIKE 'expr'[ ESCAPE '%s']
1,002✔
3959
            ];
1,002✔
3960
        }
3961

3962
        return preg_match_all(
1,002✔
3963
            '/' . implode('|', $this->pregOperators) . '/i',
1,002✔
3964
            $str,
1,002✔
3965
            $match,
1,002✔
3966
        ) >= 1 ? ($list ? $match[0] : $match[0][0]) : false;
1,002✔
3967
    }
3968

3969
    /**
3970
     * Returns the SQL string operator from where key
3971
     *
3972
     * @return false|list<string>
3973
     */
3974
    private function getOperatorFromWhereKey(string $whereKey)
3975
    {
3976
        $whereKey = trim($whereKey);
956✔
3977

3978
        $pregOperators = [
956✔
3979
            '\s*(?:<|>|!)?=',         // =, <=, >=, !=
956✔
3980
            '\s*<>?',                 // <, <>
956✔
3981
            '\s*>',                   // >
956✔
3982
            '\s+IS NULL',             // IS NULL
956✔
3983
            '\s+IS NOT NULL',         // IS NOT NULL
956✔
3984
            '\s+EXISTS\s*\(.*\)',     // EXISTS (sql)
956✔
3985
            '\s+NOT EXISTS\s*\(.*\)', // NOT EXISTS (sql)
956✔
3986
            '\s+BETWEEN\s+',          // BETWEEN value AND value
956✔
3987
            '\s+IN\s*\(.*\)',         // IN (list)
956✔
3988
            '\s+NOT IN\s*\(.*\)',     // NOT IN (list)
956✔
3989
            '\s+LIKE',                // LIKE
956✔
3990
            '\s+NOT LIKE',            // NOT LIKE
956✔
3991
        ];
956✔
3992

3993
        return preg_match_all(
956✔
3994
            '/' . implode('|', $pregOperators) . '/i',
956✔
3995
            $whereKey,
956✔
3996
            $match,
956✔
3997
        ) >= 1 ? $match[0] : false;
956✔
3998
    }
3999

4000
    /**
4001
     * Stores a bind value after ensuring that it's unique.
4002
     * While it might be nicer to have named keys for our binds array
4003
     * with PHP 7+ we get a huge memory/performance gain with indexed
4004
     * arrays instead, so lets take advantage of that here.
4005
     *
4006
     * @param mixed $value
4007
     */
4008
    protected function setBind(string $key, $value = null, bool $escape = true): string
4009
    {
4010
        if (! array_key_exists($key, $this->binds)) {
1,008✔
4011
            $this->binds[$key] = [
1,008✔
4012
                $value,
1,008✔
4013
                $escape,
1,008✔
4014
            ];
1,008✔
4015

4016
            return $key;
1,008✔
4017
        }
4018

4019
        if (! array_key_exists($key, $this->bindsKeyCount)) {
53✔
4020
            $this->bindsKeyCount[$key] = 1;
53✔
4021
        }
4022

4023
        $count = $this->bindsKeyCount[$key]++;
53✔
4024

4025
        $this->binds[$key . '.' . $count] = [
53✔
4026
            $value,
53✔
4027
            $escape,
53✔
4028
        ];
53✔
4029

4030
        return $key . '.' . $count;
53✔
4031
    }
4032

4033
    /**
4034
     * @param mixed $value
4035
     */
4036
    protected function isSubquery($value): bool
4037
    {
4038
        return $value instanceof BaseBuilder || $value instanceof Closure;
971✔
4039
    }
4040

4041
    /**
4042
     * @param BaseBuilder|Closure(BaseBuilder): BaseBuilder $builder
4043
     * @param bool                                          $wrapped Wrap the subquery in brackets
4044
     * @param string                                        $alias   Subquery alias
4045
     */
4046
    protected function buildSubquery($builder, bool $wrapped = false, string $alias = ''): string
4047
    {
4048
        if ($builder instanceof Closure) {
34✔
4049
            $builder($builder = $this->db->newQuery());
18✔
4050
        }
4051

4052
        if ($builder === $this) {
34✔
4053
            throw new DatabaseException('The subquery cannot be the same object as the main query object.');
2✔
4054
        }
4055

4056
        $subquery = strtr($builder->getCompiledSelect(false), "\n", ' ');
32✔
4057

4058
        if ($wrapped) {
32✔
4059
            $subquery = '(' . $subquery . ')';
32✔
4060
            $alias    = trim($alias);
32✔
4061

4062
            if ($alias !== '') {
32✔
4063
                $subquery .= ' ' . ($this->db->protectIdentifiers ? $this->db->escapeIdentifiers($alias) : $alias);
16✔
4064
            }
4065
        }
4066

4067
        return $subquery;
32✔
4068
    }
4069
}
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