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

codeigniter4 / CodeIgniter4 / 26659134712

29 May 2026 07:57PM UTC coverage: 88.502% (+0.02%) from 88.486%
26659134712

Pull #10244

github

web-flow
Merge 60fad714f into 800a72b54
Pull Request #10244: feat: add scoped transaction options

35 of 36 new or added lines in 1 file covered. (97.22%)

22 existing lines in 2 files now uncovered.

24245 of 27395 relevant lines covered (88.5%)

222.7 hits per line

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

94.81
/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,251✔
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,251✔
326

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

341
        if ($options !== null && $options !== []) {
1,251✔
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;
71✔
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)) {
958✔
416
            $escape = $this->db->protectIdentifiers;
947✔
417
        }
418

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

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

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

432
                continue;
5✔
433
            }
434

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

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

450
                    continue;
2✔
451
                }
452

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

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

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

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

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

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

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

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

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

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

573
        return $item;
852✔
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;
856✔
584

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

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

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

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

617
        return $this;
1,251✔
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);
989✔
733
    }
734

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

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

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

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

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

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

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

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

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

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

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

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

867
        return $this;
22✔
868
    }
869

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

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

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

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

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

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

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

948
    /**
949
     * Generates a HAVING field BETWEEN minimum AND maximum SQL query,
950
     * joined with 'AND' if appropriate.
951
     *
952
     * @param array<array-key, mixed>|null $values The range values searched on
953
     *
954
     * @return $this
955
     *
956
     * @throws InvalidArgumentException
957
     */
958
    public function havingBetween(?string $key = null, $values = null, ?bool $escape = null): static
959
    {
960
        return $this->whereBetweenHaving('QBHaving', $key, $values, false, 'AND ', $escape);
8✔
961
    }
962

963
    /**
964
     * Generates a HAVING field BETWEEN minimum AND maximum SQL query,
965
     * joined with 'OR' if appropriate.
966
     *
967
     * @param array<array-key, mixed>|null $values The range values searched on
968
     *
969
     * @return $this
970
     *
971
     * @throws InvalidArgumentException
972
     */
973
    public function orHavingBetween(?string $key = null, $values = null, ?bool $escape = null): static
974
    {
975
        return $this->whereBetweenHaving('QBHaving', $key, $values, false, 'OR ', $escape);
1✔
976
    }
977

978
    /**
979
     * Generates a HAVING field NOT BETWEEN minimum AND maximum SQL query,
980
     * joined with 'AND' if appropriate.
981
     *
982
     * @param array<array-key, mixed>|null $values The range values searched on
983
     *
984
     * @return $this
985
     *
986
     * @throws InvalidArgumentException
987
     */
988
    public function havingNotBetween(?string $key = null, $values = null, ?bool $escape = null): static
989
    {
990
        return $this->whereBetweenHaving('QBHaving', $key, $values, true, 'AND ', $escape);
1✔
991
    }
992

993
    /**
994
     * Generates a HAVING field NOT BETWEEN minimum AND maximum SQL query,
995
     * joined with 'OR' if appropriate.
996
     *
997
     * @param array<array-key, mixed>|null $values The range values searched on
998
     *
999
     * @return $this
1000
     *
1001
     * @throws InvalidArgumentException
1002
     */
1003
    public function orHavingNotBetween(?string $key = null, $values = null, ?bool $escape = null): static
1004
    {
1005
        return $this->whereBetweenHaving('QBHaving', $key, $values, true, 'OR ', $escape);
2✔
1006
    }
1007

1008
    /**
1009
     * @used-by whereBetween()
1010
     * @used-by orWhereBetween()
1011
     * @used-by whereNotBetween()
1012
     * @used-by orWhereNotBetween()
1013
     * @used-by havingBetween()
1014
     * @used-by orHavingBetween()
1015
     * @used-by havingNotBetween()
1016
     * @used-by orHavingNotBetween()
1017
     *
1018
     * @param 'QBHaving'|'QBWhere'         $qbKey
1019
     * @param non-empty-string|null        $key
1020
     * @param array<array-key, mixed>|null $values The range values searched on
1021
     *
1022
     * @return $this
1023
     *
1024
     * @throws InvalidArgumentException
1025
     */
1026
    private function whereBetweenHaving(string $qbKey, ?string $key = null, $values = null, bool $not = false, string $type = 'AND ', ?bool $escape = null): static
1027
    {
1028
        if ($key === null || $key === '') {
26✔
1029
            throw new InvalidArgumentException(sprintf('%s() expects $key to be a non-empty string', debug_backtrace(0, 2)[1]['function']));
2✔
1030
        }
1031

1032
        if (! is_array($values) || count($values) !== 2) {
24✔
1033
            throw new InvalidArgumentException(sprintf('%s() expects $values to be an array containing exactly two values', debug_backtrace(0, 2)[1]['function']));
10✔
1034
        }
1035

1036
        $escape ??= $this->db->protectIdentifiers;
14✔
1037
        $values = array_values($values);
14✔
1038

1039
        $lowerBind = $this->setBind($key, $values[0], $escape);
14✔
1040
        $upperBind = $this->setBind($key, $values[1], $escape);
14✔
1041
        $not       = $not ? ' NOT' : '';
14✔
1042
        $this->addWhereHavingCondition($qbKey, [
14✔
1043
            'betweenComparison' => true,
14✔
1044
            'condition'         => '',
14✔
1045
            'escape'            => $escape,
14✔
1046
            'key'               => $key,
14✔
1047
            'lowerBind'         => $lowerBind,
14✔
1048
            'not'               => $not,
14✔
1049
            'upperBind'         => $upperBind,
14✔
1050
        ], $type);
14✔
1051

1052
        return $this;
14✔
1053
    }
1054

1055
    /**
1056
     * @used-by whereExists()
1057
     * @used-by orWhereExists()
1058
     * @used-by whereNotExists()
1059
     * @used-by orWhereNotExists()
1060
     *
1061
     * @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery
1062
     *
1063
     * @return $this
1064
     *
1065
     * @throws InvalidArgumentException
1066
     */
1067
    protected function whereExistsSubquery($subquery, bool $not = false, string $type = 'AND '): static
1068
    {
1069
        if (! $this->isSubquery($subquery)) {
12✔
1070
            throw new InvalidArgumentException(sprintf('%s() expects $subquery to be of type BaseBuilder or closure', debug_backtrace(0, 2)[1]['function']));
4✔
1071
        }
1072

1073
        $operator = $not ? 'NOT EXISTS' : 'EXISTS';
8✔
1074

1075
        $this->addWhereHavingCondition('QBWhere', [
8✔
1076
            'condition' => "{$operator} {$this->buildSubquery($subquery, true)}",
8✔
1077
            'escape'    => false,
8✔
1078
        ], $type);
8✔
1079

1080
        return $this;
7✔
1081
    }
1082

1083
    /**
1084
     * @used-by where()
1085
     * @used-by orWhere()
1086
     * @used-by having()
1087
     * @used-by orHaving()
1088
     *
1089
     * @param array|RawSql|string $key
1090
     * @param mixed               $value
1091
     *
1092
     * @return $this
1093
     */
1094
    protected function whereHaving(string $qbKey, $key, $value = null, string $type = 'AND ', ?bool $escape = null)
1095
    {
1096
        $rawSqlOnly = false;
1,000✔
1097

1098
        if ($key instanceof RawSql) {
1,000✔
1099
            if ($value === null) {
4✔
1100
                $keyValue   = [(string) $key => $key];
1✔
1101
                $rawSqlOnly = true;
1✔
1102
            } else {
1103
                $keyValue = [(string) $key => $value];
3✔
1104
            }
1105
        } elseif (! is_array($key)) {
996✔
1106
            $keyValue = [$key => $value];
986✔
1107
        } else {
1108
            $keyValue = $key;
246✔
1109
        }
1110

1111
        // If the escape value was not set will base it on the global setting
1112
        if (! is_bool($escape)) {
1,000✔
1113
            $escape = $this->db->protectIdentifiers;
994✔
1114
        }
1115

1116
        foreach ($keyValue as $k => $v) {
1,000✔
1117
            if ($rawSqlOnly) {
1,000✔
1118
                $k  = '';
1✔
1119
                $op = '';
1✔
1120
            } elseif ($v !== null) {
999✔
1121
                $op = $this->getOperatorFromWhereKey($k);
977✔
1122

1123
                if (! empty($op)) {
977✔
1124
                    $k = trim($k);
64✔
1125

1126
                    end($op);
64✔
1127
                    $op = trim(current($op));
64✔
1128

1129
                    // Does the key end with operator?
1130
                    if (str_ends_with($k, $op)) {
64✔
1131
                        $k  = rtrim(substr($k, 0, -strlen($op)));
64✔
1132
                        $op = " {$op}";
64✔
1133
                    } else {
1134
                        $op = '';
×
1135
                    }
1136
                } else {
1137
                    $op = ' =';
950✔
1138
                }
1139

1140
                if ($this->isSubquery($v)) {
977✔
1141
                    $v = $this->buildSubquery($v, true);
1✔
1142
                } else {
1143
                    $bind = $this->setBind($k, $v, $escape);
977✔
1144
                    $v    = " :{$bind}:";
977✔
1145
                }
1146
            } elseif (! $this->hasOperator($k) && $qbKey !== 'QBHaving') {
151✔
1147
                // value appears not to have been set, assign the test to IS NULL
1148
                $op = ' IS NULL';
97✔
1149
            } elseif (
1150
                // The key ends with !=, =, <>, IS, IS NOT
1151
                preg_match(
63✔
1152
                    '/\s*(!?=|<>|IS(?:\s+NOT)?)\s*$/i',
63✔
1153
                    $k,
63✔
1154
                    $match,
63✔
1155
                    PREG_OFFSET_CAPTURE,
63✔
1156
                )
63✔
1157
            ) {
1158
                $k  = substr($k, 0, $match[0][1]);
3✔
1159
                $op = $match[1][0] === '=' ? ' IS NULL' : ' IS NOT NULL';
3✔
1160
            } else {
1161
                $op = '';
60✔
1162
            }
1163

1164
            $condition = $k . $op . $v;
1,000✔
1165

1166
            if ($v instanceof RawSql) {
1,000✔
1167
                $this->{$qbKey}[] = [
1✔
1168
                    'condition' => $v->with($this->getWhereHavingPrefix($qbKey, $type) . $condition),
1✔
1169
                    'escape'    => $escape,
1✔
1170
                ];
1✔
1171

1172
                continue;
1✔
1173
            }
1174

1175
            $this->addWhereHavingCondition($qbKey, [
999✔
1176
                'condition' => $condition,
999✔
1177
                'escape'    => $escape,
999✔
1178
            ], $type);
999✔
1179
        }
1180

1181
        return $this;
1,000✔
1182
    }
1183

1184
    /**
1185
     * @param 'QBHaving'|'QBWhere' $clause
1186
     * @param array<string, mixed> $condition
1187
     * @param non-empty-string     $type
1188
     */
1189
    private function addWhereHavingCondition(string $clause, array $condition, string $type): void
1190
    {
1191
        $condition['condition'] = $this->getWhereHavingPrefix($clause, $type) . $condition['condition'];
1,027✔
1192

1193
        $this->{$clause}[] = $condition;
1,027✔
1194
    }
1195

1196
    /**
1197
     * @param 'QBHaving'|'QBWhere' $clause
1198
     */
1199
    private function getWhereHavingPrefix(string $clause, string $type): string
1200
    {
1201
        return $this->{$clause} === [] ? $this->groupGetType('') : $this->groupGetType($type);
1,051✔
1202
    }
1203

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

1217
    /**
1218
     * Generates a WHERE field IN('item', 'item') SQL query,
1219
     * joined with 'OR' if appropriate.
1220
     *
1221
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1222
     *
1223
     * @return $this
1224
     */
1225
    public function orWhereIn(?string $key = null, $values = null, ?bool $escape = null)
1226
    {
1227
        return $this->_whereIn($key, $values, false, 'OR ', $escape);
3✔
1228
    }
1229

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

1243
    /**
1244
     * Generates a WHERE field NOT IN('item', 'item') SQL query,
1245
     * joined with 'OR' if appropriate.
1246
     *
1247
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1248
     *
1249
     * @return $this
1250
     */
1251
    public function orWhereNotIn(?string $key = null, $values = null, ?bool $escape = null)
1252
    {
1253
        return $this->_whereIn($key, $values, true, 'OR ', $escape);
2✔
1254
    }
1255

1256
    /**
1257
     * Generates a HAVING field IN('item', 'item') SQL query,
1258
     * joined with 'AND' if appropriate.
1259
     *
1260
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1261
     *
1262
     * @return $this
1263
     */
1264
    public function havingIn(?string $key = null, $values = null, ?bool $escape = null)
1265
    {
1266
        return $this->_whereIn($key, $values, false, 'AND ', $escape, 'QBHaving');
6✔
1267
    }
1268

1269
    /**
1270
     * Generates a HAVING field IN('item', 'item') SQL query,
1271
     * joined with 'OR' if appropriate.
1272
     *
1273
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1274
     *
1275
     * @return $this
1276
     */
1277
    public function orHavingIn(?string $key = null, $values = null, ?bool $escape = null)
1278
    {
1279
        return $this->_whereIn($key, $values, false, 'OR ', $escape, 'QBHaving');
3✔
1280
    }
1281

1282
    /**
1283
     * Generates a HAVING field NOT IN('item', 'item') SQL query,
1284
     * joined with 'AND' if appropriate.
1285
     *
1286
     * @param array|BaseBuilder|(Closure(BaseBuilder):BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1287
     *
1288
     * @return $this
1289
     */
1290
    public function havingNotIn(?string $key = null, $values = null, ?bool $escape = null)
1291
    {
1292
        return $this->_whereIn($key, $values, true, 'AND ', $escape, 'QBHaving');
5✔
1293
    }
1294

1295
    /**
1296
     * Generates a HAVING field NOT IN('item', 'item') SQL query,
1297
     * joined with 'OR' if appropriate.
1298
     *
1299
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1300
     *
1301
     * @return $this
1302
     */
1303
    public function orHavingNotIn(?string $key = null, $values = null, ?bool $escape = null)
1304
    {
1305
        return $this->_whereIn($key, $values, true, 'OR ', $escape, 'QBHaving');
3✔
1306
    }
1307

1308
    /**
1309
     * @used-by WhereIn()
1310
     * @used-by orWhereIn()
1311
     * @used-by whereNotIn()
1312
     * @used-by orWhereNotIn()
1313
     *
1314
     * @param non-empty-string|null                                            $key
1315
     * @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|list<mixed>|null $values The values searched on, or anonymous function with subquery
1316
     *
1317
     * @return $this
1318
     *
1319
     * @throws InvalidArgumentException
1320
     */
1321
    protected function _whereIn(?string $key = null, $values = null, bool $not = false, string $type = 'AND ', ?bool $escape = null, string $clause = 'QBWhere')
1322
    {
1323
        if ($key === null || $key === '') {
88✔
1324
            throw new InvalidArgumentException(sprintf('%s() expects $key to be a non-empty string', debug_backtrace(0, 2)[1]['function']));
2✔
1325
        }
1326

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

1331
        if (! is_bool($escape)) {
83✔
1332
            $escape = $this->db->protectIdentifiers;
83✔
1333
        }
1334

1335
        $ok = $key;
83✔
1336

1337
        if ($escape === true) {
83✔
1338
            $key = $this->db->protectIdentifiers($key);
83✔
1339
        }
1340

1341
        $not = ($not) ? ' NOT' : '';
83✔
1342

1343
        if ($this->isSubquery($values)) {
83✔
1344
            $whereIn = $this->buildSubquery($values, true);
8✔
1345
            $escape  = false;
8✔
1346
        } else {
1347
            $whereIn = array_values($values);
75✔
1348
        }
1349

1350
        $ok = $this->setBind($ok, $whereIn, $escape);
83✔
1351

1352
        $this->addWhereHavingCondition($clause, [
83✔
1353
            'condition' => "{$key}{$not} IN :{$ok}:",
83✔
1354
            'escape'    => false,
83✔
1355
        ], $type);
83✔
1356

1357
        return $this;
83✔
1358
    }
1359

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

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

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

1399
    /**
1400
     * Generates a NOT LIKE portion of the query.
1401
     * Separates multiple calls with 'OR'.
1402
     *
1403
     * @param array|RawSql|string $field
1404
     *
1405
     * @return $this
1406
     */
1407
    public function orNotLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1408
    {
1409
        return $this->_like($field, $match, 'OR ', $side, 'NOT', $escape, $insensitiveSearch);
2✔
1410
    }
1411

1412
    /**
1413
     * Generates a %LIKE% portion of the query.
1414
     * Separates multiple calls with 'AND'.
1415
     *
1416
     * @param array|RawSql|string $field
1417
     *
1418
     * @return $this
1419
     */
1420
    public function havingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1421
    {
1422
        return $this->_like($field, $match, 'AND ', $side, '', $escape, $insensitiveSearch, 'QBHaving');
15✔
1423
    }
1424

1425
    /**
1426
     * Generates a NOT LIKE portion of the query.
1427
     * Separates multiple calls with 'AND'.
1428
     *
1429
     * @param array|RawSql|string $field
1430
     *
1431
     * @return $this
1432
     */
1433
    public function notHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1434
    {
1435
        return $this->_like($field, $match, 'AND ', $side, 'NOT', $escape, $insensitiveSearch, 'QBHaving');
4✔
1436
    }
1437

1438
    /**
1439
     * Generates a %LIKE% portion of the query.
1440
     * Separates multiple calls with 'OR'.
1441
     *
1442
     * @param array|RawSql|string $field
1443
     *
1444
     * @return $this
1445
     */
1446
    public function orHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1447
    {
1448
        return $this->_like($field, $match, 'OR ', $side, '', $escape, $insensitiveSearch, 'QBHaving');
4✔
1449
    }
1450

1451
    /**
1452
     * Generates a NOT LIKE portion of the query.
1453
     * Separates multiple calls with 'OR'.
1454
     *
1455
     * @param array|RawSql|string $field
1456
     *
1457
     * @return $this
1458
     */
1459
    public function orNotHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1460
    {
1461
        return $this->_like($field, $match, 'OR ', $side, 'NOT', $escape, $insensitiveSearch, 'QBHaving');
4✔
1462
    }
1463

1464
    /**
1465
     * @used-by like()
1466
     * @used-by orLike()
1467
     * @used-by notLike()
1468
     * @used-by orNotLike()
1469
     * @used-by havingLike()
1470
     * @used-by orHavingLike()
1471
     * @used-by notHavingLike()
1472
     * @used-by orNotHavingLike()
1473
     *
1474
     * @param array<string, string>|RawSql|string $field
1475
     *
1476
     * @return $this
1477
     */
1478
    protected function _like($field, string $match = '', string $type = 'AND ', string $side = 'both', string $not = '', ?bool $escape = null, bool $insensitiveSearch = false, string $clause = 'QBWhere')
1479
    {
1480
        $escape = is_bool($escape) ? $escape : $this->db->protectIdentifiers;
47✔
1481
        $side   = strtolower($side);
47✔
1482

1483
        if ($field instanceof RawSql) {
47✔
1484
            $k                 = (string) $field;
3✔
1485
            $v                 = $match;
3✔
1486
            $insensitiveSearch = false;
3✔
1487

1488
            $prefix = $this->getWhereHavingPrefix($clause, $type);
3✔
1489

1490
            if ($side === 'none') {
3✔
1491
                $bind = $this->setBind($field->getBindingKey(), $v, $escape);
×
1492
            } elseif ($side === 'before') {
3✔
1493
                $bind = $this->setBind($field->getBindingKey(), "%{$v}", $escape);
×
1494
            } elseif ($side === 'after') {
3✔
1495
                $bind = $this->setBind($field->getBindingKey(), "{$v}%", $escape);
×
1496
            } else {
1497
                $bind = $this->setBind($field->getBindingKey(), "%{$v}%", $escape);
3✔
1498
            }
1499

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

1502
            // some platforms require an escape sequence definition for LIKE wildcards
1503
            if ($escape === true && $this->db->likeEscapeStr !== '') {
3✔
1504
                $likeStatement .= sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar);
3✔
1505
            }
1506

1507
            $this->{$clause}[] = [
3✔
1508
                'condition' => $field->with($likeStatement),
3✔
1509
                'escape'    => $escape,
3✔
1510
            ];
3✔
1511

1512
            return $this;
3✔
1513
        }
1514

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

1517
        foreach ($keyValue as $k => $v) {
44✔
1518
            if ($insensitiveSearch) {
44✔
1519
                $v = mb_strtolower($v, 'UTF-8');
7✔
1520
            }
1521

1522
            $prefix = $this->getWhereHavingPrefix($clause, $type);
44✔
1523

1524
            if ($side === 'none') {
44✔
1525
                $bind = $this->setBind($k, $v, $escape);
1✔
1526
            } elseif ($side === 'before') {
43✔
1527
                $bind = $this->setBind($k, "%{$v}", $escape);
9✔
1528
            } elseif ($side === 'after') {
34✔
1529
                $bind = $this->setBind($k, "{$v}%", $escape);
5✔
1530
            } else {
1531
                $bind = $this->setBind($k, "%{$v}%", $escape);
29✔
1532
            }
1533

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

1536
            // some platforms require an escape sequence definition for LIKE wildcards
1537
            if ($escape === true && $this->db->likeEscapeStr !== '') {
44✔
1538
                $likeStatement .= sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar);
44✔
1539
            }
1540

1541
            $this->{$clause}[] = [
44✔
1542
                'condition' => $likeStatement,
44✔
1543
                'escape'    => $escape,
44✔
1544
            ];
44✔
1545
        }
1546

1547
        return $this;
44✔
1548
    }
1549

1550
    /**
1551
     * Platform independent LIKE statement builder.
1552
     */
1553
    protected function _like_statement(?string $prefix, string $column, ?string $not, string $bind, bool $insensitiveSearch = false): string
1554
    {
1555
        if ($insensitiveSearch) {
47✔
1556
            return "{$prefix} LOWER(" . $this->db->escapeIdentifiers($column) . ") {$not} LIKE :{$bind}:";
7✔
1557
        }
1558

1559
        return "{$prefix} {$column} {$not} LIKE :{$bind}:";
40✔
1560
    }
1561

1562
    /**
1563
     * Add UNION statement
1564
     *
1565
     * @param BaseBuilder|Closure(BaseBuilder): BaseBuilder $union
1566
     *
1567
     * @return $this
1568
     */
1569
    public function union($union)
1570
    {
1571
        return $this->addUnionStatement($union);
7✔
1572
    }
1573

1574
    /**
1575
     * Add UNION ALL statement
1576
     *
1577
     * @param BaseBuilder|Closure(BaseBuilder): BaseBuilder $union
1578
     *
1579
     * @return $this
1580
     */
1581
    public function unionAll($union)
1582
    {
1583
        return $this->addUnionStatement($union, true);
2✔
1584
    }
1585

1586
    /**
1587
     * @used-by union()
1588
     * @used-by unionAll()
1589
     *
1590
     * @param BaseBuilder|Closure(BaseBuilder): BaseBuilder $union
1591
     *
1592
     * @return $this
1593
     */
1594
    protected function addUnionStatement($union, bool $all = false)
1595
    {
1596
        $this->QBUnion[] = "\nUNION "
9✔
1597
            . ($all ? 'ALL ' : '')
9✔
1598
            . 'SELECT * FROM '
9✔
1599
            . $this->buildSubquery($union, true, 'uwrp' . (count($this->QBUnion) + 1));
9✔
1600

1601
        return $this;
9✔
1602
    }
1603

1604
    /**
1605
     * Starts a query group.
1606
     *
1607
     * @return $this
1608
     */
1609
    public function groupStart()
1610
    {
1611
        return $this->groupStartPrepare();
5✔
1612
    }
1613

1614
    /**
1615
     * Starts a query group, but ORs the group
1616
     *
1617
     * @return $this
1618
     */
1619
    public function orGroupStart()
1620
    {
1621
        return $this->groupStartPrepare('', 'OR ');
2✔
1622
    }
1623

1624
    /**
1625
     * Starts a query group, but NOTs the group
1626
     *
1627
     * @return $this
1628
     */
1629
    public function notGroupStart()
1630
    {
1631
        return $this->groupStartPrepare('NOT ');
2✔
1632
    }
1633

1634
    /**
1635
     * Starts a query group, but OR NOTs the group
1636
     *
1637
     * @return $this
1638
     */
1639
    public function orNotGroupStart()
1640
    {
1641
        return $this->groupStartPrepare('NOT ', 'OR ');
2✔
1642
    }
1643

1644
    /**
1645
     * Ends a query group
1646
     *
1647
     * @return $this
1648
     */
1649
    public function groupEnd()
1650
    {
1651
        return $this->groupEndPrepare();
11✔
1652
    }
1653

1654
    /**
1655
     * Starts a query group for HAVING clause.
1656
     *
1657
     * @return $this
1658
     */
1659
    public function havingGroupStart()
1660
    {
1661
        return $this->groupStartPrepare('', 'AND ', 'QBHaving');
3✔
1662
    }
1663

1664
    /**
1665
     * Starts a query group for HAVING clause, but ORs the group.
1666
     *
1667
     * @return $this
1668
     */
1669
    public function orHavingGroupStart()
1670
    {
1671
        return $this->groupStartPrepare('', 'OR ', 'QBHaving');
2✔
1672
    }
1673

1674
    /**
1675
     * Starts a query group for HAVING clause, but NOTs the group.
1676
     *
1677
     * @return $this
1678
     */
1679
    public function notHavingGroupStart()
1680
    {
1681
        return $this->groupStartPrepare('NOT ', 'AND ', 'QBHaving');
2✔
1682
    }
1683

1684
    /**
1685
     * Starts a query group for HAVING clause, but OR NOTs the group.
1686
     *
1687
     * @return $this
1688
     */
1689
    public function orNotHavingGroupStart()
1690
    {
1691
        return $this->groupStartPrepare('NOT ', 'OR ', 'QBHaving');
2✔
1692
    }
1693

1694
    /**
1695
     * Ends a query group for HAVING clause.
1696
     *
1697
     * @return $this
1698
     */
1699
    public function havingGroupEnd()
1700
    {
1701
        return $this->groupEndPrepare('QBHaving');
9✔
1702
    }
1703

1704
    /**
1705
     * Prepate a query group start.
1706
     *
1707
     * @return $this
1708
     */
1709
    protected function groupStartPrepare(string $not = '', string $type = 'AND ', string $clause = 'QBWhere')
1710
    {
1711
        $type = $this->groupGetType($type);
20✔
1712

1713
        $this->QBWhereGroupStarted = true;
20✔
1714
        $prefix                    = empty($this->{$clause}) ? '' : $type;
20✔
1715
        $where                     = [
20✔
1716
            'condition' => $prefix . $not . str_repeat(' ', ++$this->QBWhereGroupCount) . ' (',
20✔
1717
            'escape'    => false,
20✔
1718
        ];
20✔
1719

1720
        $this->{$clause}[] = $where;
20✔
1721

1722
        return $this;
20✔
1723
    }
1724

1725
    /**
1726
     * Prepate a query group end.
1727
     *
1728
     * @return $this
1729
     */
1730
    protected function groupEndPrepare(string $clause = 'QBWhere')
1731
    {
1732
        $this->QBWhereGroupStarted = false;
20✔
1733
        $where                     = [
20✔
1734
            'condition' => str_repeat(' ', $this->QBWhereGroupCount--) . ')',
20✔
1735
            'escape'    => false,
20✔
1736
        ];
20✔
1737

1738
        $this->{$clause}[] = $where;
20✔
1739

1740
        return $this;
20✔
1741
    }
1742

1743
    /**
1744
     * @used-by groupStart()
1745
     * @used-by _like()
1746
     * @used-by whereHaving()
1747
     * @used-by _whereIn()
1748
     * @used-by whereColumnHaving()
1749
     * @used-by havingGroupStart()
1750
     */
1751
    protected function groupGetType(string $type): string
1752
    {
1753
        if ($this->QBWhereGroupStarted) {
1,051✔
1754
            $type                      = '';
20✔
1755
            $this->QBWhereGroupStarted = false;
20✔
1756
        }
1757

1758
        return $type;
1,051✔
1759
    }
1760

1761
    /**
1762
     * @param array|string $by
1763
     *
1764
     * @return $this
1765
     */
1766
    public function groupBy($by, ?bool $escape = null)
1767
    {
1768
        if (! is_bool($escape)) {
65✔
1769
            $escape = $this->db->protectIdentifiers;
65✔
1770
        }
1771

1772
        if (is_string($by)) {
65✔
1773
            $by = ($escape === true) ? explode(',', $by) : [$by];
65✔
1774
        }
1775

1776
        foreach ($by as $val) {
65✔
1777
            $val = trim($val);
65✔
1778

1779
            if ($val !== '') {
65✔
1780
                $val = [
65✔
1781
                    'field'  => $val,
65✔
1782
                    'escape' => $escape,
65✔
1783
                ];
65✔
1784

1785
                $this->QBGroupBy[] = $val;
65✔
1786
            }
1787
        }
1788

1789
        return $this;
65✔
1790
    }
1791

1792
    /**
1793
     * Separates multiple calls with 'AND'.
1794
     *
1795
     * @param array|RawSql|string $key
1796
     * @param mixed               $value
1797
     *
1798
     * @return $this
1799
     */
1800
    public function having($key, $value = null, ?bool $escape = null)
1801
    {
1802
        return $this->whereHaving('QBHaving', $key, $value, 'AND ', $escape);
22✔
1803
    }
1804

1805
    /**
1806
     * Separates multiple calls with 'OR'.
1807
     *
1808
     * @param array|RawSql|string $key
1809
     * @param mixed               $value
1810
     *
1811
     * @return $this
1812
     */
1813
    public function orHaving($key, $value = null, ?bool $escape = null)
1814
    {
1815
        return $this->whereHaving('QBHaving', $key, $value, 'OR ', $escape);
2✔
1816
    }
1817

1818
    /**
1819
     * @param string $direction ASC, DESC or RANDOM
1820
     *
1821
     * @return $this
1822
     */
1823
    public function orderBy(string $orderBy, string $direction = '', ?bool $escape = null)
1824
    {
1825
        if ($orderBy === '') {
874✔
1826
            return $this;
×
1827
        }
1828

1829
        $qbOrderBy = [];
874✔
1830

1831
        $direction = strtoupper(trim($direction));
874✔
1832

1833
        if ($direction === 'RANDOM') {
874✔
1834
            $direction = '';
3✔
1835
            $orderBy   = ctype_digit($orderBy) ? sprintf($this->randomKeyword[1], $orderBy) : $this->randomKeyword[0];
3✔
1836
            $escape    = false;
3✔
1837
        } elseif ($direction !== '') {
872✔
1838
            $direction = in_array($direction, ['ASC', 'DESC'], true) ? ' ' . $direction : '';
872✔
1839
        }
1840

1841
        if ($escape === null) {
874✔
1842
            $escape = $this->db->protectIdentifiers;
872✔
1843
        }
1844

1845
        if ($escape === false) {
874✔
1846
            $qbOrderBy[] = [
3✔
1847
                'field'     => $orderBy,
3✔
1848
                'direction' => $direction,
3✔
1849
                'escape'    => false,
3✔
1850
            ];
3✔
1851
        } else {
1852
            foreach (explode(',', $orderBy) as $field) {
872✔
1853
                $qbOrderBy[] = ($direction === '' && preg_match('/\s+(ASC|DESC)$/i', rtrim($field), $match, PREG_OFFSET_CAPTURE))
872✔
1854
                    ? [
×
1855
                        'field'     => ltrim(substr($field, 0, $match[0][1])),
×
1856
                        'direction' => ' ' . $match[1][0],
×
1857
                        'escape'    => true,
×
1858
                    ]
×
1859
                    : [
872✔
1860
                        'field'     => trim($field),
872✔
1861
                        'direction' => $direction,
872✔
1862
                        'escape'    => true,
872✔
1863
                    ];
872✔
1864
            }
1865
        }
1866

1867
        $this->QBOrderBy = array_merge($this->QBOrderBy, $qbOrderBy);
874✔
1868

1869
        return $this;
874✔
1870
    }
1871

1872
    /**
1873
     * @return $this
1874
     */
1875
    public function limit(?int $value = null, ?int $offset = 0)
1876
    {
1877
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
129✔
1878
        if ($limitZeroAsAll && $value === 0) {
129✔
1879
            $value = null;
13✔
1880
        }
1881

1882
        if ($value !== null) {
129✔
1883
            $this->QBLimit = $value;
117✔
1884
        }
1885

1886
        if ($offset !== null && $offset !== 0) {
129✔
1887
            $this->QBOffset = $offset;
12✔
1888
        }
1889

1890
        return $this;
129✔
1891
    }
1892

1893
    /**
1894
     * Locks the selected rows for update.
1895
     */
1896
    public function lockForUpdate(): static
1897
    {
1898
        $this->QBLockForUpdate = true;
28✔
1899

1900
        return $this;
28✔
1901
    }
1902

1903
    /**
1904
     * Sets the OFFSET value
1905
     *
1906
     * @return $this
1907
     */
1908
    public function offset(int $offset)
1909
    {
1910
        if ($offset !== 0) {
1✔
1911
            $this->QBOffset = $offset;
1✔
1912
        }
1913

1914
        return $this;
1✔
1915
    }
1916

1917
    /**
1918
     * Generates a platform-specific LIMIT clause.
1919
     */
1920
    protected function _limit(string $sql, bool $offsetIgnore = false): string
1921
    {
1922
        return $sql . ' LIMIT ' . ($offsetIgnore === false && $this->QBOffset ? $this->QBOffset . ', ' : '') . $this->QBLimit;
132✔
1923
    }
1924

1925
    /**
1926
     * Allows key/value pairs to be set for insert(), update() or replace().
1927
     *
1928
     * @param array|object|string $key    Field name, or an array of field/value pairs, or an object
1929
     * @param mixed               $value  Field value, if $key is a single field
1930
     * @param bool|null           $escape Whether to escape values
1931
     *
1932
     * @return $this
1933
     */
1934
    public function set($key, $value = '', ?bool $escape = null)
1935
    {
1936
        $key = $this->objectToArray($key);
889✔
1937

1938
        if (! is_array($key)) {
889✔
1939
            $key = [$key => $value];
122✔
1940
        }
1941

1942
        $escape = is_bool($escape) ? $escape : $this->db->protectIdentifiers;
889✔
1943

1944
        foreach ($key as $k => $v) {
889✔
1945
            if ($escape) {
889✔
1946
                $bind = $this->setBind($k, $v, $escape);
888✔
1947

1948
                $this->QBSet[$this->db->protectIdentifiers($k, false)] = ":{$bind}:";
888✔
1949
            } else {
1950
                $this->QBSet[$this->db->protectIdentifiers($k, false)] = $v;
10✔
1951
            }
1952
        }
1953

1954
        return $this;
889✔
1955
    }
1956

1957
    /**
1958
     * Returns the previously set() data, alternatively resetting it if needed.
1959
     */
1960
    public function getSetData(bool $clean = false): array
1961
    {
1962
        $data = $this->QBSet;
×
1963

1964
        if ($clean) {
×
1965
            $this->QBSet = [];
×
1966
        }
1967

1968
        return $data;
×
1969
    }
1970

1971
    /**
1972
     * Compiles a SELECT query string and returns the sql.
1973
     */
1974
    public function getCompiledSelect(bool $reset = true): string
1975
    {
1976
        $select = $this->compileSelect();
276✔
1977

1978
        if ($reset) {
265✔
1979
            $this->resetSelect();
248✔
1980
        }
1981

1982
        return $this->compileFinalQuery($select);
265✔
1983
    }
1984

1985
    /**
1986
     * Returns a finalized, compiled query string with the bindings
1987
     * inserted and prefixes swapped out.
1988
     */
1989
    protected function compileFinalQuery(string $sql): string
1990
    {
1991
        $query = new Query($this->db);
290✔
1992
        $query->setQuery($sql, $this->binds, false);
290✔
1993

1994
        if (! empty($this->db->swapPre) && ! empty($this->db->DBPrefix)) {
290✔
1995
            $query->swapPrefix($this->db->DBPrefix, $this->db->swapPre);
×
1996
        }
1997

1998
        return $query->getQuery();
290✔
1999
    }
2000

2001
    /**
2002
     * Compiles the select statement based on the other functions called
2003
     * and runs the query
2004
     *
2005
     * @return false|ResultInterface
2006
     */
2007
    public function get(?int $limit = null, int $offset = 0, bool $reset = true)
2008
    {
2009
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
898✔
2010
        if ($limitZeroAsAll && $limit === 0) {
898✔
2011
            $limit = null;
1✔
2012
        }
2013

2014
        if ($limit !== null) {
898✔
2015
            $this->limit($limit, $offset);
8✔
2016
        }
2017

2018
        $result = $this->testMode
898✔
2019
            ? $this->getCompiledSelect($reset)
2✔
2020
            : $this->db->query($this->compileSelect(), $this->binds, false);
896✔
2021

2022
        if ($reset) {
898✔
2023
            $this->resetSelect();
898✔
2024

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

2029
        return $result;
898✔
2030
    }
2031

2032
    /**
2033
     * Generates a platform-specific query string that counts all records in
2034
     * the particular table
2035
     *
2036
     * @return int|string
2037
     */
2038
    public function countAll(bool $reset = true)
2039
    {
2040
        $table = $this->QBFrom[0];
6✔
2041

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

2045
        if ($this->testMode) {
6✔
2046
            return $sql;
1✔
2047
        }
2048

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

2051
        if (empty($query->getResult())) {
5✔
2052
            return 0;
×
2053
        }
2054

2055
        $query = $query->getRow();
5✔
2056

2057
        if ($reset) {
5✔
2058
            $this->resetSelect();
5✔
2059
        }
2060

2061
        return (int) $query->numrows;
5✔
2062
    }
2063

2064
    /**
2065
     * Determines whether the current Query Builder query would return at least one row.
2066
     *
2067
     * @return bool|string SQL string when test mode is enabled.
2068
     */
2069
    public function exists(bool $reset = true)
2070
    {
2071
        $exists = $this->doExists($reset);
16✔
2072

2073
        return $exists ?? false;
16✔
2074
    }
2075

2076
    /**
2077
     * Determines whether the current Query Builder query would not return any rows.
2078
     *
2079
     * @return bool|string SQL string when test mode is enabled.
2080
     */
2081
    public function doesntExist(bool $reset = true)
2082
    {
2083
        $exists = $this->doExists($reset);
5✔
2084

2085
        return is_string($exists) ? $exists : $exists === false;
5✔
2086
    }
2087

2088
    /**
2089
     * Runs an existence probe for the current Query Builder query.
2090
     *
2091
     * @return bool|string|null SQL string when test mode is enabled, or null when the query fails.
2092
     */
2093
    protected function doExists(bool $reset = true)
2094
    {
2095
        $sql = $this->compileExists();
20✔
2096

2097
        if ($this->testMode) {
20✔
2098
            if ($reset) {
11✔
2099
                $this->resetSelect();
1✔
2100

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

2105
            return $sql;
11✔
2106
        }
2107

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

2110
        if ($reset) {
9✔
2111
            $this->resetSelect();
9✔
2112

2113
            // Clear our binds so we don't eat up memory
2114
            $this->binds = [];
9✔
2115
        }
2116

2117
        return $result instanceof ResultInterface ? $result->getRow() !== null : null;
9✔
2118
    }
2119

2120
    /**
2121
     * Compiles an existence probe for the current Query Builder query.
2122
     */
2123
    protected function compileExists(): string
2124
    {
2125
        // ORDER BY and FOR UPDATE are unnecessary for checking row existence,
2126
        // and can produce invalid or surprising SQL on some drivers.
2127
        $orderBy       = $this->QBOrderBy;
20✔
2128
        $limit         = $this->QBLimit;
20✔
2129
        $offset        = $this->QBOffset;
20✔
2130
        $lockForUpdate = $this->QBLockForUpdate;
20✔
2131
        $select        = $this->QBSelect;
20✔
2132
        $noEscape      = $this->QBNoEscape;
20✔
2133
        $needsSubquery = $this->QBSelectUsesAggregate || $this->QBUnion !== [] || $this->QBGroupBy !== [] || $this->QBHaving !== [] || $this->QBOffset !== false;
20✔
2134

2135
        $this->QBOrderBy       = null;
20✔
2136
        $this->QBLockForUpdate = false;
20✔
2137

2138
        if (! $needsSubquery && $this->QBLimit !== 0) {
20✔
2139
            $this->QBLimit = 1;
14✔
2140
        }
2141

2142
        try {
2143
            if ($needsSubquery) {
20✔
2144
                $sql = "SELECT 1 FROM (\n" . $this->compileSelect() . "\n) CI_exists";
5✔
2145

2146
                $this->QBLimit  = 1;
5✔
2147
                $this->QBOffset = false;
5✔
2148

2149
                return $this->_limit($sql . "\n");
5✔
2150
            }
2151

2152
            return $this->compileSelect('SELECT 1');
15✔
2153
        } finally {
2154
            $this->QBOrderBy       = $orderBy;
20✔
2155
            $this->QBLimit         = $limit;
20✔
2156
            $this->QBOffset        = $offset;
20✔
2157
            $this->QBLockForUpdate = $lockForUpdate;
20✔
2158
            $this->QBSelect        = $select;
20✔
2159
            $this->QBNoEscape      = $noEscape;
20✔
2160
        }
2161
    }
2162

2163
    /**
2164
     * Generates a platform-specific query string that counts all records
2165
     * returned by an Query Builder query.
2166
     *
2167
     * @return int|string
2168
     */
2169
    public function countAllResults(bool $reset = true)
2170
    {
2171
        // ORDER BY usage is often problematic here (most notably
2172
        // on Microsoft SQL Server) and ultimately unnecessary
2173
        // for selecting COUNT(*) ...
2174
        $orderBy = [];
240✔
2175

2176
        if (! empty($this->QBOrderBy)) {
240✔
2177
            $orderBy = $this->QBOrderBy;
×
2178

2179
            $this->QBOrderBy = null;
×
2180
        }
2181

2182
        // We cannot use a LIMIT when getting the single row COUNT(*) result
2183
        $limit         = $this->QBLimit;
240✔
2184
        $lockForUpdate = $this->QBLockForUpdate;
240✔
2185

2186
        $this->QBLimit         = false;
240✔
2187
        $this->QBLockForUpdate = false;
240✔
2188

2189
        try {
2190
            if ($this->QBDistinct === true || ! empty($this->QBGroupBy)) {
240✔
2191
                // We need to backup the original SELECT in case DBPrefix is used
2192
                $select = $this->QBSelect;
4✔
2193
                $sql    = $this->countString . $this->db->protectIdentifiers('numrows') . "\nFROM (\n" . $this->compileSelect() . "\n) CI_count_all_results";
4✔
2194

2195
                // Restore SELECT part
2196
                $this->QBSelect = $select;
4✔
2197
                unset($select);
4✔
2198
            } else {
2199
                $sql = $this->compileSelect($this->countString . $this->db->protectIdentifiers('numrows'));
236✔
2200
            }
2201
        } finally {
2202
            $this->QBLockForUpdate = $lockForUpdate;
240✔
2203
        }
2204

2205
        if ($this->testMode) {
240✔
2206
            return $sql;
11✔
2207
        }
2208

2209
        $result = $this->db->query($sql, $this->binds, false);
233✔
2210

2211
        if ($reset) {
233✔
2212
            $this->resetSelect();
217✔
2213
        } elseif (! isset($this->QBOrderBy)) {
22✔
2214
            $this->QBOrderBy = $orderBy;
×
2215
        }
2216

2217
        // Restore the LIMIT setting
2218
        $this->QBLimit = $limit;
233✔
2219

2220
        $row = $result instanceof ResultInterface ? $result->getRow() : null;
233✔
2221

2222
        if (empty($row)) {
233✔
2223
            return 0;
×
2224
        }
2225

2226
        return (int) $row->numrows;
233✔
2227
    }
2228

2229
    /**
2230
     * Compiles the set conditions and returns the sql statement
2231
     *
2232
     * @return array
2233
     */
2234
    public function getCompiledQBWhere()
2235
    {
2236
        return $this->QBWhere;
65✔
2237
    }
2238

2239
    /**
2240
     * Allows the where clause, limit and offset to be added directly
2241
     *
2242
     * @param array|string $where
2243
     *
2244
     * @return ResultInterface
2245
     */
2246
    public function getWhere($where = null, ?int $limit = null, ?int $offset = 0, bool $reset = true)
2247
    {
2248
        if ($where !== null) {
17✔
2249
            $this->where($where);
16✔
2250
        }
2251

2252
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
17✔
2253
        if ($limitZeroAsAll && $limit === 0) {
17✔
2254
            $limit = null;
×
2255
        }
2256

2257
        if ($limit !== null) {
17✔
2258
            $this->limit($limit, $offset);
3✔
2259
        }
2260

2261
        $result = $this->testMode
17✔
2262
            ? $this->getCompiledSelect($reset)
4✔
2263
            : $this->db->query($this->compileSelect(), $this->binds, false);
13✔
2264

2265
        if ($reset) {
17✔
2266
            $this->resetSelect();
17✔
2267

2268
            // Clear our binds so we don't eat up memory
2269
            $this->binds = [];
17✔
2270
        }
2271

2272
        return $result;
17✔
2273
    }
2274

2275
    /**
2276
     * Compiles batch insert/update/upsert strings and runs the queries
2277
     *
2278
     * @param '_deleteBatch'|'_insertBatch'|'_updateBatch'|'_upsertBatch' $renderMethod
2279
     *
2280
     * @return false|int|list<string> Number of rows inserted or FALSE on failure, SQL array when testMode
2281
     *
2282
     * @throws DatabaseException
2283
     */
2284
    protected function batchExecute(string $renderMethod, int $batchSize = 100)
2285
    {
2286
        if (empty($this->QBSet)) {
73✔
2287
            if ($this->db->DBDebug) {
5✔
2288
                throw new DatabaseException(trim($renderMethod, '_') . '() has no data.');
5✔
2289
            }
2290

2291
            return false; // @codeCoverageIgnore
2292
        }
2293

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

2296
        $affectedRows = 0;
68✔
2297
        $savedSQL     = [];
68✔
2298
        $cnt          = count($this->QBSet);
68✔
2299

2300
        // batch size 0 for unlimited
2301
        if ($batchSize === 0) {
68✔
2302
            $batchSize = $cnt;
×
2303
        }
2304

2305
        for ($i = 0, $total = $cnt; $i < $total; $i += $batchSize) {
68✔
2306
            $QBSet = array_slice($this->QBSet, $i, $batchSize);
68✔
2307

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

2310
            if ($sql === '') {
65✔
2311
                return false; // @codeCoverageIgnore
2312
            }
2313

2314
            if ($this->testMode) {
65✔
2315
                $savedSQL[] = $sql;
3✔
2316
            } else {
2317
                $this->db->query($sql, null, false);
62✔
2318
                $affectedRows += $this->db->affectedRows();
60✔
2319
            }
2320
        }
2321

2322
        if (! $this->testMode) {
63✔
2323
            $this->resetWrite();
60✔
2324
        }
2325

2326
        return $this->testMode ? $savedSQL : $affectedRows;
63✔
2327
    }
2328

2329
    /**
2330
     * Allows a row or multiple rows to be set for batch inserts/upserts/updates
2331
     *
2332
     * @param array|object $set
2333
     * @param string       $alias alias for sql table
2334
     *
2335
     * @return $this|null
2336
     */
2337
    public function setData($set, ?bool $escape = null, string $alias = '')
2338
    {
2339
        if (empty($set)) {
68✔
2340
            if ($this->db->DBDebug) {
×
2341
                throw new DatabaseException('setData() has no data.');
×
2342
            }
2343

2344
            return null; // @codeCoverageIgnore
2345
        }
2346

2347
        $this->setAlias($alias);
68✔
2348

2349
        // this allows to set just one row at a time
2350
        if (is_object($set) || (! is_array(current($set)) && ! is_object(current($set)))) {
68✔
2351
            $set = [$set];
11✔
2352
        }
2353

2354
        $set = $this->batchObjectToArray($set);
68✔
2355

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

2358
        $keys = array_keys($this->objectToArray(current($set)));
68✔
2359
        sort($keys);
68✔
2360

2361
        foreach ($set as $row) {
68✔
2362
            $row = $this->objectToArray($row);
68✔
2363
            if (array_diff($keys, array_keys($row)) !== [] || array_diff(array_keys($row), $keys) !== []) {
68✔
2364
                // batchExecute() function returns an error on an empty array
2365
                $this->QBSet[] = [];
×
2366

2367
                return null;
×
2368
            }
2369

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

2372
            $clean = [];
68✔
2373

2374
            foreach ($row as $rowValue) {
68✔
2375
                $clean[] = $escape ? $this->db->escape($rowValue) : $rowValue;
68✔
2376
            }
2377

2378
            $row = $clean;
68✔
2379

2380
            $this->QBSet[] = $row;
68✔
2381
        }
2382

2383
        foreach ($keys as $k) {
68✔
2384
            $k = $this->db->protectIdentifiers($k, false);
68✔
2385

2386
            if (! in_array($k, $this->QBKeys, true)) {
68✔
2387
                $this->QBKeys[] = $k;
68✔
2388
            }
2389
        }
2390

2391
        return $this;
68✔
2392
    }
2393

2394
    /**
2395
     * Compiles an upsert query and returns the sql
2396
     *
2397
     * @return string
2398
     *
2399
     * @throws DatabaseException
2400
     */
2401
    public function getCompiledUpsert()
2402
    {
2403
        [$currentTestMode, $this->testMode] = [$this->testMode, true];
3✔
2404

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

2407
        $this->testMode = $currentTestMode;
3✔
2408

2409
        return $this->compileFinalQuery($sql);
3✔
2410
    }
2411

2412
    /**
2413
     * Converts call to batchUpsert
2414
     *
2415
     * @param array|object|null $set
2416
     *
2417
     * @return false|int|list<string> Number of affected rows or FALSE on failure, SQL array when testMode
2418
     *
2419
     * @throws DatabaseException
2420
     */
2421
    public function upsert($set = null, ?bool $escape = null)
2422
    {
2423
        // if set() has been used merge QBSet with binds and then setData()
2424
        if ($set === null && ! is_array(current($this->QBSet))) {
10✔
2425
            $set = [];
2✔
2426

2427
            foreach ($this->QBSet as $field => $value) {
2✔
2428
                $k = trim($field, $this->db->escapeChar);
2✔
2429
                // use binds if available else use QBSet value but with RawSql to avoid escape
2430
                $set[$k] = isset($this->binds[$k]) ? $this->binds[$k][0] : new RawSql($value);
2✔
2431
            }
2432

2433
            $this->binds = [];
2✔
2434

2435
            $this->resetRun([
2✔
2436
                'QBSet'  => [],
2✔
2437
                'QBKeys' => [],
2✔
2438
            ]);
2✔
2439

2440
            $this->setData($set, true); // unescaped items are RawSql now
2✔
2441
        } elseif ($set !== null) {
8✔
2442
            $this->setData($set, $escape);
7✔
2443
        } // else setData() has already been used and we need to do nothing
2444

2445
        return $this->batchExecute('_upsertBatch');
10✔
2446
    }
2447

2448
    /**
2449
     * Compiles batch upsert strings and runs the queries
2450
     *
2451
     * @param array|object|null $set a dataset
2452
     *
2453
     * @return false|int|list<string> Number of affected rows or FALSE on failure, SQL array when testMode
2454
     *
2455
     * @throws DatabaseException
2456
     */
2457
    public function upsertBatch($set = null, ?bool $escape = null, int $batchSize = 100)
2458
    {
2459
        if (isset($this->QBOptions['setQueryAsData'])) {
12✔
2460
            $sql = $this->_upsertBatch($this->QBFrom[0], $this->QBKeys, []);
1✔
2461

2462
            if ($sql === '') {
1✔
2463
                return false; // @codeCoverageIgnore
2464
            }
2465

2466
            if ($this->testMode === false) {
1✔
2467
                $this->db->query($sql, null, false);
1✔
2468
            }
2469

2470
            $this->resetWrite();
1✔
2471

2472
            return $this->testMode ? $sql : $this->db->affectedRows();
1✔
2473
        }
2474

2475
        if ($set !== null) {
11✔
2476
            $this->setData($set, $escape);
9✔
2477
        }
2478

2479
        return $this->batchExecute('_upsertBatch', $batchSize);
11✔
2480
    }
2481

2482
    /**
2483
     * Generates a platform-specific upsertBatch string from the supplied data
2484
     *
2485
     * @used-by batchExecute()
2486
     *
2487
     * @param string                 $table  Protected table name
2488
     * @param list<string>           $keys   QBKeys
2489
     * @param list<list<int|string>> $values QBSet
2490
     */
2491
    protected function _upsertBatch(string $table, array $keys, array $values): string
2492
    {
2493
        $sql = $this->QBOptions['sql'] ?? '';
19✔
2494

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

2499
            $sql = 'INSERT INTO ' . $table . ' (' . implode(', ', $keys) . ")\n{:_table_:}ON DUPLICATE KEY UPDATE\n" . implode(
19✔
2500
                ",\n",
19✔
2501
                array_map(
19✔
2502
                    static fn ($key, $value): string => $table . '.' . $key . ($value instanceof RawSql ?
19✔
2503
                        ' = ' . $value :
2✔
2504
                        ' = VALUES(' . $value . ')'),
19✔
2505
                    array_keys($updateFields),
19✔
2506
                    $updateFields,
19✔
2507
                ),
19✔
2508
            );
19✔
2509

2510
            $this->QBOptions['sql'] = $sql;
19✔
2511
        }
2512

2513
        if (isset($this->QBOptions['setQueryAsData'])) {
19✔
2514
            $data = $this->QBOptions['setQueryAsData'] . "\n";
1✔
2515
        } else {
2516
            $data = 'VALUES ' . implode(', ', $this->formatValues($values)) . "\n";
18✔
2517
        }
2518

2519
        return str_replace('{:_table_:}', $data, $sql);
19✔
2520
    }
2521

2522
    /**
2523
     * Set table alias for dataset pseudo table.
2524
     */
2525
    private function setAlias(string $alias): BaseBuilder
2526
    {
2527
        if ($alias !== '') {
68✔
2528
            $this->db->addTableAlias($alias);
7✔
2529
            $this->QBOptions['alias'] = $this->db->protectIdentifiers($alias);
7✔
2530
        }
2531

2532
        return $this;
68✔
2533
    }
2534

2535
    /**
2536
     * Sets update fields for upsert, update
2537
     *
2538
     * @param list<RawSql>|list<string>|string $set
2539
     * @param bool                             $addToDefault adds update fields to the default ones
2540
     * @param array|null                       $ignore       ignores items in set
2541
     *
2542
     * @return $this
2543
     */
2544
    public function updateFields($set, bool $addToDefault = false, ?array $ignore = null)
2545
    {
2546
        if (! empty($set)) {
38✔
2547
            if (! is_array($set)) {
38✔
2548
                $set = explode(',', $set);
5✔
2549
            }
2550

2551
            foreach ($set as $key => $value) {
38✔
2552
                if (! ($value instanceof RawSql)) {
38✔
2553
                    $value = $this->db->protectIdentifiers($value);
38✔
2554
                }
2555

2556
                if (is_numeric($key)) {
38✔
2557
                    $key = $value;
38✔
2558
                }
2559

2560
                if ($ignore === null || ! in_array($key, $ignore, true)) {
38✔
2561
                    if ($addToDefault) {
38✔
2562
                        $this->QBOptions['updateFieldsAdditional'][$this->db->protectIdentifiers($key)] = $value;
3✔
2563
                    } else {
2564
                        $this->QBOptions['updateFields'][$this->db->protectIdentifiers($key)] = $value;
38✔
2565
                    }
2566
                }
2567
            }
2568

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

2572
                unset($this->QBOptions['updateFieldsAdditional']);
3✔
2573
            }
2574
        }
2575

2576
        return $this;
38✔
2577
    }
2578

2579
    /**
2580
     * Sets constraints for batch upsert, update
2581
     *
2582
     * @param array|RawSql|string $set a string of columns, key value pairs, or RawSql
2583
     *
2584
     * @return $this
2585
     */
2586
    public function onConstraint($set)
2587
    {
2588
        if (! empty($set)) {
47✔
2589
            if (is_string($set)) {
44✔
2590
                $set = explode(',', $set);
27✔
2591

2592
                $set = array_map(trim(...), $set);
27✔
2593
            }
2594

2595
            if ($set instanceof RawSql) {
44✔
2596
                $set = [$set];
2✔
2597
            }
2598

2599
            foreach ($set as $key => $value) {
44✔
2600
                if (! ($value instanceof RawSql)) {
44✔
2601
                    $value = $this->db->protectIdentifiers($value);
41✔
2602
                }
2603

2604
                if (is_string($key)) {
44✔
2605
                    $key = $this->db->protectIdentifiers($key);
3✔
2606
                }
2607

2608
                $this->QBOptions['constraints'][$key] = $value;
44✔
2609
            }
2610
        }
2611

2612
        return $this;
47✔
2613
    }
2614

2615
    /**
2616
     * Sets data source as a query for insertBatch()/updateBatch()/upsertBatch()/deleteBatch()
2617
     *
2618
     * @param BaseBuilder|RawSql $query
2619
     * @param array|string|null  $columns an array or comma delimited string of columns
2620
     */
2621
    public function setQueryAsData($query, ?string $alias = null, $columns = null): BaseBuilder
2622
    {
2623
        if (is_string($query)) {
5✔
2624
            throw new InvalidArgumentException('$query parameter must be BaseBuilder or RawSql class.');
×
2625
        }
2626

2627
        if ($query instanceof BaseBuilder) {
5✔
2628
            $query = $query->getCompiledSelect();
4✔
2629
        } elseif ($query instanceof RawSql) {
1✔
2630
            $query = $query->__toString();
1✔
2631
        }
2632

2633
        if (is_string($query)) {
5✔
2634
            if ($columns !== null && is_string($columns)) {
5✔
2635
                $columns = explode(',', $columns);
1✔
2636
                $columns = array_map(trim(...), $columns);
1✔
2637
            }
2638

2639
            $columns = (array) $columns;
5✔
2640

2641
            if ($columns === []) {
5✔
2642
                $columns = $this->fieldsFromQuery($query);
4✔
2643
            }
2644

2645
            if ($alias !== null) {
5✔
2646
                $this->setAlias($alias);
1✔
2647
            }
2648

2649
            foreach ($columns as $key => $value) {
5✔
2650
                $columns[$key] = $this->db->escapeChar . $value . $this->db->escapeChar;
5✔
2651
            }
2652

2653
            $this->QBOptions['setQueryAsData'] = $query;
5✔
2654
            $this->QBKeys                      = $columns;
5✔
2655
            $this->QBSet                       = [];
5✔
2656
        }
2657

2658
        return $this;
5✔
2659
    }
2660

2661
    /**
2662
     * Gets column names from a select query
2663
     */
2664
    protected function fieldsFromQuery(string $sql): array
2665
    {
2666
        return $this->db->query('SELECT * FROM (' . $sql . ') _u_ LIMIT 1')->getFieldNames();
4✔
2667
    }
2668

2669
    /**
2670
     * Converts value array of array to array of strings
2671
     */
2672
    protected function formatValues(array $values): array
2673
    {
2674
        return array_map(static fn ($index): string => '(' . implode(',', $index) . ')', $values);
45✔
2675
    }
2676

2677
    /**
2678
     * Compiles batch insert strings and runs the queries
2679
     *
2680
     * @param array|object|null $set a dataset
2681
     *
2682
     * @return false|int|list<string> Number of rows inserted or FALSE on no data to perform an insert operation, SQL array when testMode
2683
     */
2684
    public function insertBatch($set = null, ?bool $escape = null, int $batchSize = 100)
2685
    {
2686
        if (isset($this->QBOptions['setQueryAsData'])) {
29✔
2687
            $sql = $this->_insertBatch($this->QBFrom[0], $this->QBKeys, []);
2✔
2688

2689
            if ($sql === '') {
2✔
2690
                return false; // @codeCoverageIgnore
2691
            }
2692

2693
            if ($this->testMode === false) {
2✔
2694
                $this->db->query($sql, null, false);
2✔
2695
            }
2696

2697
            $this->resetWrite();
2✔
2698

2699
            return $this->testMode ? $sql : $this->db->affectedRows();
2✔
2700
        }
2701

2702
        if ($set !== null && $set !== []) {
29✔
2703
            $this->setData($set, $escape);
27✔
2704
        }
2705

2706
        return $this->batchExecute('_insertBatch', $batchSize);
29✔
2707
    }
2708

2709
    /**
2710
     * Generates a platform-specific insert string from the supplied data.
2711
     *
2712
     * @used-by batchExecute()
2713
     *
2714
     * @param string                 $table  Protected table name
2715
     * @param list<string>           $keys   QBKeys
2716
     * @param list<list<int|string>> $values QBSet
2717
     */
2718
    protected function _insertBatch(string $table, array $keys, array $values): string
2719
    {
2720
        $sql = $this->QBOptions['sql'] ?? '';
27✔
2721

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

2727
            $this->QBOptions['sql'] = $sql;
27✔
2728
        }
2729

2730
        if (isset($this->QBOptions['setQueryAsData'])) {
27✔
2731
            $data = $this->QBOptions['setQueryAsData'];
2✔
2732
        } else {
2733
            $data = 'VALUES ' . implode(', ', $this->formatValues($values));
27✔
2734
        }
2735

2736
        return str_replace('{:_table_:}', $data, $sql);
27✔
2737
    }
2738

2739
    /**
2740
     * Compiles an insert query and returns the sql
2741
     *
2742
     * @return bool|string
2743
     *
2744
     * @throws DatabaseException
2745
     */
2746
    public function getCompiledInsert(bool $reset = true)
2747
    {
2748
        if ($this->validateInsert() === false) {
6✔
2749
            return false;
×
2750
        }
2751

2752
        $sql = $this->_insert(
6✔
2753
            $this->db->protectIdentifiers(
6✔
2754
                $this->removeAlias($this->QBFrom[0]),
6✔
2755
                true,
6✔
2756
                null,
6✔
2757
                false,
6✔
2758
            ),
6✔
2759
            array_keys($this->QBSet),
6✔
2760
            array_values($this->QBSet),
6✔
2761
        );
6✔
2762

2763
        if ($reset) {
6✔
2764
            $this->resetWrite();
6✔
2765
        }
2766

2767
        return $this->compileFinalQuery($sql);
6✔
2768
    }
2769

2770
    /**
2771
     * Compiles an insert string and runs the query
2772
     *
2773
     * @param array|object|null $set
2774
     *
2775
     * @return BaseResult|bool|Query
2776
     *
2777
     * @throws DatabaseException
2778
     */
2779
    public function insert($set = null, ?bool $escape = null)
2780
    {
2781
        if ($set !== null) {
876✔
2782
            $this->set($set, '', $escape);
842✔
2783
        }
2784

2785
        if ($this->validateInsert() === false) {
876✔
2786
            return false;
×
2787
        }
2788

2789
        $sql = $this->_insert(
875✔
2790
            $this->db->protectIdentifiers(
875✔
2791
                $this->removeAlias($this->QBFrom[0]),
875✔
2792
                true,
875✔
2793
                $escape,
875✔
2794
                false,
875✔
2795
            ),
875✔
2796
            array_keys($this->QBSet),
875✔
2797
            array_values($this->QBSet),
875✔
2798
        );
875✔
2799

2800
        if (! $this->testMode) {
875✔
2801
            $this->resetWrite();
871✔
2802

2803
            $result = $this->db->query($sql, $this->binds, false);
871✔
2804

2805
            // Clear our binds so we don't eat up memory
2806
            $this->binds = [];
871✔
2807

2808
            return $result;
871✔
2809
        }
2810

2811
        return false;
5✔
2812
    }
2813

2814
    /**
2815
     * @internal This is a temporary solution.
2816
     *
2817
     * @see https://github.com/codeigniter4/CodeIgniter4/pull/5376
2818
     *
2819
     * @TODO Fix a root cause, and this method should be removed.
2820
     */
2821
    protected function removeAlias(string $from): string
2822
    {
2823
        if (str_contains($from, ' ')) {
880✔
2824
            // if the alias is written with the AS keyword, remove it
2825
            $from = preg_replace('/\s+AS\s+/i', ' ', $from);
2✔
2826

2827
            $parts = explode(' ', $from);
2✔
2828
            $from  = $parts[0];
2✔
2829
        }
2830

2831
        return $from;
880✔
2832
    }
2833

2834
    /**
2835
     * This method is used by both insert() and getCompiledInsert() to
2836
     * validate that the there data is actually being set and that table
2837
     * has been chosen to be inserted into.
2838
     *
2839
     * @throws DatabaseException
2840
     */
2841
    protected function validateInsert(): bool
2842
    {
2843
        if (empty($this->QBSet)) {
876✔
2844
            if ($this->db->DBDebug) {
1✔
2845
                throw new DatabaseException('You must use the "set" method to insert an entry.');
1✔
2846
            }
2847

2848
            return false; // @codeCoverageIgnore
2849
        }
2850

2851
        return true;
875✔
2852
    }
2853

2854
    /**
2855
     * Generates a platform-specific insert string from the supplied data
2856
     *
2857
     * @param string           $table         Protected table name
2858
     * @param list<string>     $keys          Keys of QBSet
2859
     * @param list<int|string> $unescapedKeys Values of QBSet
2860
     */
2861
    protected function _insert(string $table, array $keys, array $unescapedKeys): string
2862
    {
2863
        return 'INSERT ' . $this->compileIgnore('insert') . 'INTO ' . $table . ' (' . implode(', ', $keys) . ') VALUES (' . implode(', ', $unescapedKeys) . ')';
866✔
2864
    }
2865

2866
    /**
2867
     * Compiles a replace into string and runs the query
2868
     *
2869
     * @return BaseResult|false|Query|string
2870
     *
2871
     * @throws DatabaseException
2872
     */
2873
    public function replace(?array $set = null)
2874
    {
2875
        if ($set !== null) {
8✔
2876
            $this->set($set);
7✔
2877
        }
2878

2879
        if (empty($this->QBSet)) {
8✔
2880
            if ($this->db->DBDebug) {
1✔
2881
                throw new DatabaseException('You must use the "set" method to update an entry.');
1✔
2882
            }
2883

2884
            return false; // @codeCoverageIgnore
2885
        }
2886

2887
        $table = $this->QBFrom[0];
7✔
2888

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

2891
        $this->resetWrite();
7✔
2892

2893
        return $this->testMode ? $sql : $this->db->query($sql, $this->binds, false);
7✔
2894
    }
2895

2896
    /**
2897
     * Generates a platform-specific replace string from the supplied data
2898
     *
2899
     * @param string           $table  Protected table name
2900
     * @param list<string>     $keys   Keys of QBSet
2901
     * @param list<int|string> $values Values of QBSet
2902
     */
2903
    protected function _replace(string $table, array $keys, array $values): string
2904
    {
2905
        return 'REPLACE INTO ' . $table . ' (' . implode(', ', $keys) . ') VALUES (' . implode(', ', $values) . ')';
7✔
2906
    }
2907

2908
    /**
2909
     * Groups tables in FROM clauses if needed, so there is no confusion
2910
     * about operator precedence.
2911
     *
2912
     * Note: This is only used (and overridden) by MySQL and SQLSRV.
2913
     */
2914
    protected function _fromTables(): string
2915
    {
2916
        return implode(', ', $this->QBFrom);
1,129✔
2917
    }
2918

2919
    /**
2920
     * Compiles an update query and returns the sql
2921
     *
2922
     * @return bool|string
2923
     */
2924
    public function getCompiledUpdate(bool $reset = true)
2925
    {
2926
        if ($this->validateUpdate() === false) {
13✔
2927
            return false;
×
2928
        }
2929

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

2932
        if ($reset) {
13✔
2933
            $this->resetWrite();
13✔
2934
        }
2935

2936
        return $this->compileFinalQuery($sql);
13✔
2937
    }
2938

2939
    /**
2940
     * Compiles an update string and runs the query.
2941
     *
2942
     * @param array|object|null        $set
2943
     * @param array|RawSql|string|null $where
2944
     *
2945
     * @throws DatabaseException
2946
     */
2947
    public function update($set = null, $where = null, ?int $limit = null): bool
2948
    {
2949
        if ($set !== null) {
106✔
2950
            $this->set($set);
52✔
2951
        }
2952

2953
        if ($this->validateUpdate() === false) {
106✔
2954
            return false;
×
2955
        }
2956

2957
        if ($where !== null) {
105✔
2958
            $this->where($where);
7✔
2959
        }
2960

2961
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
105✔
2962
        if ($limitZeroAsAll && $limit === 0) {
105✔
2963
            $limit = null;
×
2964
        }
2965

2966
        if ($limit !== null) {
105✔
2967
            if (! $this->canLimitWhereUpdates) {
3✔
2968
                throw new DatabaseException('This driver does not allow LIMITs on UPDATE queries using WHERE.');
2✔
2969
            }
2970

2971
            $this->limit($limit);
3✔
2972
        }
2973

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

2976
        if (! $this->testMode) {
105✔
2977
            $this->resetWrite();
92✔
2978

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

2981
            if ($result !== false) {
92✔
2982
                // Clear our binds so we don't eat up memory
2983
                $this->binds = [];
89✔
2984

2985
                return true;
89✔
2986
            }
2987

2988
            return false;
3✔
2989
        }
2990

2991
        return true;
13✔
2992
    }
2993

2994
    /**
2995
     * Generates a platform-specific update string from the supplied data
2996
     *
2997
     * @param string                $table  Protected table name
2998
     * @param array<string, string> $values QBSet
2999
     */
3000
    protected function _update(string $table, array $values): string
3001
    {
3002
        $valStr = [];
124✔
3003

3004
        foreach ($values as $key => $val) {
124✔
3005
            $valStr[] = $key . ' = ' . $val;
124✔
3006
        }
3007

3008
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
124✔
3009
        if ($limitZeroAsAll) {
124✔
3010
            return 'UPDATE ' . $this->compileIgnore('update') . $table . ' SET ' . implode(', ', $valStr)
124✔
3011
                . $this->compileWhereHaving('QBWhere')
124✔
3012
                . $this->compileOrderBy()
124✔
3013
                . ($this->QBLimit ? $this->_limit(' ', true) : '');
124✔
3014
        }
3015

3016
        return 'UPDATE ' . $this->compileIgnore('update') . $table . ' SET ' . implode(', ', $valStr)
×
3017
            . $this->compileWhereHaving('QBWhere')
×
3018
            . $this->compileOrderBy()
×
3019
            . ($this->QBLimit !== false ? $this->_limit(' ', true) : '');
×
3020
    }
3021

3022
    /**
3023
     * This method is used by both update() and getCompiledUpdate() to
3024
     * validate that data is actually being set and that a table has been
3025
     * chosen to be updated.
3026
     *
3027
     * @throws DatabaseException
3028
     */
3029
    protected function validateUpdate(): bool
3030
    {
3031
        if (empty($this->QBSet)) {
107✔
3032
            if ($this->db->DBDebug) {
1✔
3033
                throw new DatabaseException('You must use the "set" method to update an entry.');
1✔
3034
            }
3035

3036
            return false; // @codeCoverageIgnore
3037
        }
3038

3039
        return true;
106✔
3040
    }
3041

3042
    /**
3043
     * Sets data and calls batchExecute to run queries
3044
     *
3045
     * @param array|object|null        $set         a dataset
3046
     * @param array|RawSql|string|null $constraints
3047
     *
3048
     * @return false|int|list<string> Number of rows affected or FALSE on failure, SQL array when testMode
3049
     */
3050
    public function updateBatch($set = null, $constraints = null, int $batchSize = 100)
3051
    {
3052
        if ($this->QBWhere !== []) {
24✔
3053
            throw new DatabaseException(
1✔
3054
                'updateBatch() cannot be safely combined with existing Query Builder WHERE conditions. '
1✔
3055
                . 'Use updateBatch($data, $constraints), onConstraint(), or include all required constraint fields in the batch data.',
1✔
3056
            );
1✔
3057
        }
3058

3059
        $this->onConstraint($constraints);
23✔
3060

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

3064
            if ($sql === '') {
1✔
3065
                return false; // @codeCoverageIgnore
3066
            }
3067

3068
            if ($this->testMode === false) {
1✔
3069
                $this->db->query($sql, null, false);
1✔
3070
            }
3071

3072
            $this->resetWrite();
1✔
3073

3074
            return $this->testMode ? $sql : $this->db->affectedRows();
1✔
3075
        }
3076

3077
        if ($set !== null && $set !== []) {
22✔
3078
            $this->setData($set, true);
16✔
3079
        }
3080

3081
        return $this->batchExecute('_updateBatch', $batchSize);
22✔
3082
    }
3083

3084
    /**
3085
     * Generates a platform-specific batch update string from the supplied data
3086
     *
3087
     * @used-by batchExecute()
3088
     *
3089
     * @param string                 $table  Protected table name
3090
     * @param list<string>           $keys   QBKeys
3091
     * @param list<list<int|string>> $values QBSet
3092
     */
3093
    protected function _updateBatch(string $table, array $keys, array $values): string
3094
    {
3095
        $sql = $this->QBOptions['sql'] ?? '';
19✔
3096

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

3101
            if ($constraints === []) {
19✔
3102
                if ($this->db->DBDebug) {
2✔
3103
                    throw new DatabaseException('You must specify a constraint to match on for batch updates.'); // @codeCoverageIgnore
3104
                }
3105

3106
                return ''; // @codeCoverageIgnore
3107
            }
3108

3109
            $updateFields = $this->QBOptions['updateFields'] ??
17✔
3110
                $this->updateFields($keys, false, $constraints)->QBOptions['updateFields'] ??
17✔
3111
                [];
14✔
3112

3113
            $alias = $this->QBOptions['alias'] ?? '_u';
17✔
3114

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

3117
            $sql .= "SET\n";
17✔
3118

3119
            $sql .= implode(
17✔
3120
                ",\n",
17✔
3121
                array_map(
17✔
3122
                    static fn ($key, $value): string => $key . ($value instanceof RawSql ?
17✔
3123
                        ' = ' . $value :
2✔
3124
                        ' = ' . $alias . '.' . $value),
17✔
3125
                    array_keys($updateFields),
17✔
3126
                    $updateFields,
17✔
3127
                ),
17✔
3128
            ) . "\n";
17✔
3129

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

3132
            $sql .= ') ' . $alias . "\n";
17✔
3133

3134
            $sql .= 'WHERE ' . implode(
17✔
3135
                ' AND ',
17✔
3136
                array_map(
17✔
3137
                    static fn ($key, $value) => (
17✔
3138
                        ($value instanceof RawSql && is_string($key))
17✔
3139
                        ?
17✔
3140
                        $table . '.' . $key . ' = ' . $value
1✔
3141
                        :
17✔
3142
                        (
17✔
3143
                            $value instanceof RawSql
16✔
3144
                            ?
16✔
3145
                            $value
3✔
3146
                            :
16✔
3147
                            $table . '.' . $value . ' = ' . $alias . '.' . $value
17✔
3148
                        )
17✔
3149
                    ),
17✔
3150
                    array_keys($constraints),
17✔
3151
                    $constraints,
17✔
3152
                ),
17✔
3153
            );
17✔
3154

3155
            $this->QBOptions['sql'] = $sql;
17✔
3156
        }
3157

3158
        if (isset($this->QBOptions['setQueryAsData'])) {
17✔
3159
            $data = $this->QBOptions['setQueryAsData'];
1✔
3160
        } else {
3161
            $data = implode(
16✔
3162
                " UNION ALL\n",
16✔
3163
                array_map(
16✔
3164
                    static fn ($value): string => 'SELECT ' . implode(', ', array_map(
16✔
3165
                        static fn ($key, $index): string => $index . ' ' . $key,
16✔
3166
                        $keys,
16✔
3167
                        $value,
16✔
3168
                    )),
16✔
3169
                    $values,
16✔
3170
                ),
16✔
3171
            ) . "\n";
16✔
3172
        }
3173

3174
        return str_replace('{:_table_:}', $data, $sql);
17✔
3175
    }
3176

3177
    /**
3178
     * Compiles a delete string and runs "DELETE FROM table"
3179
     *
3180
     * @return bool|string TRUE on success, FALSE on failure, string on testMode
3181
     */
3182
    public function emptyTable()
3183
    {
3184
        $table = $this->QBFrom[0];
4✔
3185

3186
        $sql = $this->_delete($table);
4✔
3187

3188
        if ($this->testMode) {
4✔
3189
            return $sql;
1✔
3190
        }
3191

3192
        $this->resetWrite();
3✔
3193

3194
        return $this->db->query($sql, null, false);
3✔
3195
    }
3196

3197
    /**
3198
     * Compiles a truncate string and runs the query
3199
     * If the database does not support the truncate() command
3200
     * This function maps to "DELETE FROM table"
3201
     *
3202
     * @return bool|string TRUE on success, FALSE on failure, string on testMode
3203
     */
3204
    public function truncate()
3205
    {
3206
        $table = $this->QBFrom[0];
796✔
3207

3208
        $sql = $this->_truncate($table);
796✔
3209

3210
        if ($this->testMode) {
796✔
3211
            return $sql;
2✔
3212
        }
3213

3214
        $this->resetWrite();
795✔
3215

3216
        return $this->db->query($sql, null, false);
795✔
3217
    }
3218

3219
    /**
3220
     * Generates a platform-specific truncate string from the supplied data
3221
     *
3222
     * If the database does not support the truncate() command,
3223
     * then this method maps to 'DELETE FROM table'
3224
     *
3225
     * @param string $table Protected table name
3226
     */
3227
    protected function _truncate(string $table): string
3228
    {
3229
        return 'TRUNCATE ' . $table;
781✔
3230
    }
3231

3232
    /**
3233
     * Compiles a delete query string and returns the sql
3234
     */
3235
    public function getCompiledDelete(bool $reset = true): string
3236
    {
3237
        $sql = $this->testMode()->delete('', null, $reset);
3✔
3238
        $this->testMode(false);
3✔
3239

3240
        return $this->compileFinalQuery($sql);
3✔
3241
    }
3242

3243
    /**
3244
     * Compiles a delete string and runs the query
3245
     *
3246
     * @param array|RawSql|string $where
3247
     *
3248
     * @return bool|string Returns a SQL string if in test mode.
3249
     *
3250
     * @throws DatabaseException
3251
     */
3252
    public function delete($where = '', ?int $limit = null, bool $resetData = true)
3253
    {
3254
        $table = $this->db->protectIdentifiers($this->QBFrom[0], true, null, false);
834✔
3255

3256
        if ($where !== '') {
834✔
3257
            $this->where($where);
4✔
3258
        }
3259

3260
        if (empty($this->QBWhere)) {
834✔
3261
            if ($this->db->DBDebug) {
2✔
3262
                throw new DatabaseException('Deletes are not allowed unless they contain a "where" or "like" clause.');
2✔
3263
            }
3264

3265
            return false; // @codeCoverageIgnore
3266
        }
3267

3268
        $sql = $this->_delete($this->removeAlias($table));
834✔
3269

3270
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
834✔
3271
        if ($limitZeroAsAll && $limit === 0) {
834✔
UNCOV
3272
            $limit = null;
×
3273
        }
3274

3275
        if ($limit !== null) {
834✔
3276
            $this->QBLimit = $limit;
1✔
3277
        }
3278

3279
        if (! empty($this->QBLimit)) {
834✔
3280
            if (! $this->canLimitDeletes) {
2✔
3281
                throw new DatabaseException('SQLite3 does not allow LIMITs on DELETE queries.');
1✔
3282
            }
3283

3284
            $sql = $this->_limit($sql, true);
2✔
3285
        }
3286

3287
        if ($resetData) {
834✔
3288
            $this->resetWrite();
834✔
3289
        }
3290

3291
        return $this->testMode ? $sql : $this->db->query($sql, $this->binds, false);
834✔
3292
    }
3293

3294
    /**
3295
     * Sets data and calls batchExecute to run queries
3296
     *
3297
     * @param array|object|null $set         a dataset
3298
     * @param array|RawSql|null $constraints
3299
     *
3300
     * @return false|int|list<string> Number of rows affected or FALSE on failure, SQL array when testMode
3301
     */
3302
    public function deleteBatch($set = null, $constraints = null, int $batchSize = 100)
3303
    {
3304
        $this->onConstraint($constraints);
3✔
3305

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

3309
            if ($sql === '') {
1✔
3310
                return false; // @codeCoverageIgnore
3311
            }
3312

3313
            if ($this->testMode === false) {
1✔
3314
                $this->db->query($sql, null, false);
1✔
3315
            }
3316

3317
            $this->resetWrite();
1✔
3318

3319
            return $this->testMode ? $sql : $this->db->affectedRows();
1✔
3320
        }
3321

3322
        if ($set !== null && $set !== []) {
2✔
UNCOV
3323
            $this->setData($set, true);
×
3324
        }
3325

3326
        return $this->batchExecute('_deleteBatch', $batchSize);
2✔
3327
    }
3328

3329
    /**
3330
     * Generates a platform-specific batch update string from the supplied data
3331
     *
3332
     * @used-by batchExecute()
3333
     *
3334
     * @param string           $table  Protected table name
3335
     * @param list<string>     $keys   QBKeys
3336
     * @param list<int|string> $values QBSet
3337
     */
3338
    protected function _deleteBatch(string $table, array $keys, array $values): string
3339
    {
3340
        $sql = $this->QBOptions['sql'] ?? '';
3✔
3341

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

3346
            if ($constraints === []) {
3✔
UNCOV
3347
                if ($this->db->DBDebug) {
×
3348
                    throw new DatabaseException('You must specify a constraint to match on for batch deletes.'); // @codeCoverageIgnore
3349
                }
3350

3351
                return ''; // @codeCoverageIgnore
3352
            }
3353

3354
            $alias = $this->QBOptions['alias'] ?? '_u';
3✔
3355

3356
            $sql = 'DELETE ' . $table . ' FROM ' . $table . "\n";
3✔
3357

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

3360
            $sql .= ') ' . $alias . "\n";
3✔
3361

3362
            $sql .= 'ON ' . implode(
3✔
3363
                ' AND ',
3✔
3364
                array_map(
3✔
3365
                    static fn ($key, $value) => (
3✔
3366
                        $value instanceof RawSql ?
3✔
UNCOV
3367
                        $value :
×
3368
                        (
3✔
3369
                            is_string($key) ?
3✔
3370
                            $table . '.' . $key . ' = ' . $alias . '.' . $value :
2✔
3371
                            $table . '.' . $value . ' = ' . $alias . '.' . $value
3✔
3372
                        )
3✔
3373
                    ),
3✔
3374
                    array_keys($constraints),
3✔
3375
                    $constraints,
3✔
3376
                ),
3✔
3377
            );
3✔
3378

3379
            // convert binds in where
3380
            foreach ($this->QBWhere as $key => $where) {
3✔
3381
                foreach ($this->binds as $field => $bind) {
2✔
UNCOV
3382
                    $this->QBWhere[$key]['condition'] = str_replace(':' . $field . ':', $bind[0], $where['condition']);
×
3383
                }
3384
            }
3385

3386
            $sql .= ' ' . $this->compileWhereHaving('QBWhere');
3✔
3387

3388
            $this->QBOptions['sql'] = trim($sql);
3✔
3389
        }
3390

3391
        if (isset($this->QBOptions['setQueryAsData'])) {
3✔
3392
            $data = $this->QBOptions['setQueryAsData'];
1✔
3393
        } else {
3394
            $data = implode(
2✔
3395
                " UNION ALL\n",
2✔
3396
                array_map(
2✔
3397
                    static fn ($value): string => 'SELECT ' . implode(', ', array_map(
2✔
3398
                        static fn ($key, $index): string => $index . ' ' . $key,
2✔
3399
                        $keys,
2✔
3400
                        $value,
2✔
3401
                    )),
2✔
3402
                    $values,
2✔
3403
                ),
2✔
3404
            ) . "\n";
2✔
3405
        }
3406

3407
        return str_replace('{:_table_:}', $data, $sql);
3✔
3408
    }
3409

3410
    /**
3411
     * Increments a numeric column by the specified value.
3412
     *
3413
     * @return bool
3414
     */
3415
    public function increment(string $column, int $value = 1)
3416
    {
3417
        return $this->incrementMany([$column], $value);
6✔
3418
    }
3419

3420
    /**
3421
     * Increments multiple numeric columns by the specified value(s).
3422
     *
3423
     * @param array<string, int>|list<string> $columns A list of columns or array of column => value pairs to increment.
3424
     * @param int                             $value   The value to increment by if $columns is a list of column names.
3425
     */
3426
    public function incrementMany(array $columns, int $value = 1): bool
3427
    {
3428
        if ($columns === []) {
11✔
3429
            throw new InvalidArgumentException('Argument #1 ($columns) cannot be empty.');
1✔
3430
        }
3431

3432
        if (array_is_list($columns)) {
10✔
3433
            $columns = array_fill_keys($columns, $value);
7✔
3434
        }
3435

3436
        $fields = [];
10✔
3437

3438
        foreach ($columns as $col => $val) {
10✔
3439
            if (! is_int($val)) {
10✔
3440
                throw new TypeError(sprintf(
1✔
3441
                    'Argument #1 ($columns) must contain only int values, %s given for "%s".',
1✔
3442
                    get_debug_type($val),
1✔
3443
                    $col,
1✔
3444
                ));
1✔
3445
            }
3446

3447
            $col          = $this->db->protectIdentifiers($col);
10✔
3448
            $fields[$col] = "{$col} + {$val}";
10✔
3449
        }
3450

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

3453
        if (! $this->testMode) {
9✔
3454
            $this->resetWrite();
9✔
3455

3456
            return $this->db->query($sql, $this->binds, false);
9✔
3457
        }
3458

UNCOV
3459
        return true;
×
3460
    }
3461

3462
    /**
3463
     * Decrements a numeric column by the specified value.
3464
     *
3465
     * @return bool
3466
     */
3467
    public function decrement(string $column, int $value = 1)
3468
    {
3469
        return $this->decrementMany([$column], $value);
6✔
3470
    }
3471

3472
    /**
3473
     * Decrements multiple numeric columns by the specified value(s).
3474
     *
3475
     * @param array<string, int>|list<string> $columns A list of columns or array of column => value pairs to decrement.
3476
     * @param int                             $value   The value to decrement by if $columns is a list of column names.
3477
     */
3478
    public function decrementMany(array $columns, int $value = 1): bool
3479
    {
3480
        if ($columns === []) {
11✔
3481
            throw new InvalidArgumentException('Argument #1 ($columns) cannot be empty.');
1✔
3482
        }
3483

3484
        if (array_is_list($columns)) {
10✔
3485
            $columns = array_fill_keys($columns, $value);
7✔
3486
        }
3487

3488
        $fields = [];
10✔
3489

3490
        foreach ($columns as $col => $val) {
10✔
3491
            if (! is_int($val)) {
10✔
3492
                throw new TypeError(sprintf(
1✔
3493
                    'Argument #1 ($columns) must contain only int values, %s given for "%s".',
1✔
3494
                    get_debug_type($val),
1✔
3495
                    $col,
1✔
3496
                ));
1✔
3497
            }
3498

3499
            $col          = $this->db->protectIdentifiers($col);
10✔
3500
            $fields[$col] = "{$col} - {$val}";
10✔
3501
        }
3502

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

3505
        if (! $this->testMode) {
9✔
3506
            $this->resetWrite();
9✔
3507

3508
            return $this->db->query($sql, $this->binds, false);
9✔
3509
        }
3510

UNCOV
3511
        return true;
×
3512
    }
3513

3514
    /**
3515
     * Generates a platform-specific delete string from the supplied data
3516
     *
3517
     * @param string $table Protected table name
3518
     */
3519
    protected function _delete(string $table): string
3520
    {
3521
        return 'DELETE ' . $this->compileIgnore('delete') . 'FROM ' . $table . $this->compileWhereHaving('QBWhere');
837✔
3522
    }
3523

3524
    /**
3525
     * Used to track SQL statements written with aliased tables.
3526
     *
3527
     * @param array|string $table The table to inspect
3528
     *
3529
     * @return string|null
3530
     */
3531
    protected function trackAliases($table)
3532
    {
3533
        if (is_array($table)) {
1,251✔
UNCOV
3534
            foreach ($table as $t) {
×
UNCOV
3535
                $this->trackAliases($t);
×
3536
            }
3537

UNCOV
3538
            return null;
×
3539
        }
3540

3541
        // Does the string contain a comma?  If so, we need to separate
3542
        // the string into discreet statements
3543
        if (str_contains($table, ',')) {
1,251✔
UNCOV
3544
            return $this->trackAliases(explode(',', $table));
×
3545
        }
3546

3547
        // if a table alias is used we can recognize it by a space
3548
        if (str_contains($table, ' ')) {
1,251✔
3549
            // if the alias is written with the AS keyword, remove it
3550
            $table = preg_replace('/\s+AS\s+/i', ' ', $table);
23✔
3551

3552
            // Grab the alias
3553
            $alias = trim(strrchr($table, ' '));
23✔
3554

3555
            // Store the alias, if it doesn't already exist
3556
            $this->db->addTableAlias($alias);
23✔
3557
        }
3558

3559
        return null;
1,251✔
3560
    }
3561

3562
    /**
3563
     * Compile the SELECT statement
3564
     *
3565
     * Generates a query string based on which functions were used.
3566
     * Should not be called directly.
3567
     *
3568
     * @param mixed $selectOverride
3569
     */
3570
    protected function compileSelect($selectOverride = false): string
3571
    {
3572
        if ($selectOverride !== false) {
1,154✔
3573
            $sql = $selectOverride;
247✔
3574
        } else {
3575
            $sql = $this->QBDistinct ? 'SELECT DISTINCT ' : 'SELECT ';
1,147✔
3576

3577
            if (empty($this->QBSelect)) {
1,147✔
3578
                $sql .= '*';
1,028✔
3579
            } else {
3580
                // Cycle through the "select" portion of the query and prep each column name.
3581
                // The reason we protect identifiers here rather than in the select() function
3582
                // is because until the user calls the from() function we don't know if there are aliases
3583
                foreach ($this->QBSelect as $key => $val) {
980✔
3584
                    if ($val instanceof RawSql) {
980✔
3585
                        $this->QBSelect[$key] = (string) $val;
5✔
3586
                    } else {
3587
                        $protect              = $this->QBNoEscape[$key] ?? null;
978✔
3588
                        $this->QBSelect[$key] = $this->db->protectIdentifiers($val, false, $protect);
978✔
3589
                    }
3590
                }
3591

3592
                $sql .= implode(', ', $this->QBSelect);
980✔
3593
            }
3594
        }
3595

3596
        if (! empty($this->QBFrom)) {
1,154✔
3597
            $sql .= "\nFROM " . $this->_fromTables();
1,154✔
3598
        }
3599

3600
        if (! empty($this->QBJoin)) {
1,154✔
3601
            $sql .= "\n" . implode("\n", $this->QBJoin);
15✔
3602
        }
3603

3604
        $sql .= $this->compileWhereHaving('QBWhere')
1,154✔
3605
            . $this->compileGroupBy()
1,154✔
3606
            . $this->compileWhereHaving('QBHaving')
1,154✔
3607
            . $this->compileOrderBy();
1,154✔
3608

3609
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
1,154✔
3610
        if ($limitZeroAsAll) {
1,154✔
3611
            if ($this->QBLimit) {
1,152✔
3612
                $sql = $this->_limit($sql . "\n");
122✔
3613
            }
3614
        } elseif ($this->QBLimit !== false || $this->QBOffset) {
3✔
3615
            $sql = $this->_limit($sql . "\n");
3✔
3616
        }
3617

3618
        $sql .= $this->compileLockForUpdate();
1,154✔
3619

3620
        return $this->unionInjection($sql);
1,144✔
3621
    }
3622

3623
    /**
3624
     * Compile the SELECT lock clause.
3625
     */
3626
    protected function compileLockForUpdate(): string
3627
    {
3628
        if ($this->QBLockForUpdate && $this->QBUnion !== []) {
247✔
3629
            throw new DatabaseException('Query Builder does not support lockForUpdate() with union() or unionAll().');
1✔
3630
        }
3631

3632
        return $this->QBLockForUpdate ? "\nFOR UPDATE" : '';
247✔
3633
    }
3634

3635
    /**
3636
     * Checks if the ignore option is supported by
3637
     * the Database Driver for the specific statement.
3638
     *
3639
     * @return string
3640
     */
3641
    protected function compileIgnore(string $statement)
3642
    {
3643
        if ($this->QBIgnore && isset($this->supportedIgnoreStatements[$statement])) {
897✔
3644
            return trim($this->supportedIgnoreStatements[$statement]) . ' ';
1✔
3645
        }
3646

3647
        return '';
896✔
3648
    }
3649

3650
    /**
3651
     * Escapes identifiers in WHERE and HAVING statements at execution time.
3652
     *
3653
     * Required so that aliases are tracked properly, regardless of whether
3654
     * where(), orWhere(), having(), orHaving are called prior to from(),
3655
     * join() and prefixTable is added only if needed.
3656
     *
3657
     * @param string $qbKey 'QBWhere' or 'QBHaving'
3658
     *
3659
     * @return string SQL statement
3660
     */
3661
    protected function compileWhereHaving(string $qbKey): string
3662
    {
3663
        if (! empty($this->{$qbKey})) {
1,189✔
3664
            foreach ($this->{$qbKey} as &$qbkey) {
1,050✔
3665
                $qbkey = $this->compileWhereHavingCondition($qbkey);
1,050✔
3666
            }
3667

3668
            return ($qbKey === 'QBHaving' ? "\nHAVING " : "\nWHERE ")
1,050✔
3669
                . implode("\n", $this->{$qbKey});
1,050✔
3670
        }
3671

3672
        return '';
1,170✔
3673
    }
3674

3675
    /**
3676
     * @used-by compileWhereHaving()
3677
     *
3678
     * @param array<string, mixed>|RawSql|string $condition
3679
     */
3680
    private function compileWhereHavingCondition(array|RawSql|string $condition): RawSql|string
3681
    {
3682
        // Is this condition already compiled?
3683
        if (is_string($condition) || $condition instanceof RawSql) {
1,050✔
3684
            return $condition;
34✔
3685
        }
3686

3687
        if ($condition['condition'] instanceof RawSql) {
1,050✔
3688
            return $condition['condition'];
4✔
3689
        }
3690

3691
        if (($condition['columnComparison'] ?? false) === true) {
1,048✔
3692
            return $this->compileColumnComparison($condition);
22✔
3693
        }
3694

3695
        if (($condition['betweenComparison'] ?? false) === true) {
1,035✔
3696
            return $this->compileBetweenComparison($condition);
14✔
3697
        }
3698

3699
        if ($condition['escape'] === false) {
1,027✔
3700
            return $condition['condition'];
121✔
3701
        }
3702

3703
        return $this->compileEscapedCondition($condition['condition']);
1,014✔
3704
    }
3705

3706
    /**
3707
     * @used-by compileWhereHavingCondition()
3708
     */
3709
    private function compileEscapedCondition(string $condition): string
3710
    {
3711
        // Split multiple conditions
3712
        $conditions = preg_split(
1,014✔
3713
            '/((?:^|\s+)AND\s+|(?:^|\s+)OR\s+)/i',
1,014✔
3714
            $condition,
1,014✔
3715
            -1,
1,014✔
3716
            PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY,
1,014✔
3717
        );
1,014✔
3718

3719
        foreach ($conditions as &$condition) {
1,014✔
3720
            $op = $this->getOperator($condition);
1,014✔
3721
            if (
3722
                $op === false
1,014✔
3723
                || preg_match(
1,014✔
3724
                    '/^(\(?)(.*)(' . preg_quote($op, '/') . ')\s*(.*(?<!\)))?(\)?)$/i',
1,014✔
3725
                    $condition,
1,014✔
3726
                    $matches,
1,014✔
3727
                ) !== 1
1,014✔
3728
            ) {
3729
                continue;
888✔
3730
            }
3731

3732
            // $matches = [
3733
            //  0 => '(test <= foo)',   /* the whole thing */
3734
            //  1 => '(',               /* optional */
3735
            //  2 => 'test',            /* the field name */
3736
            //  3 => ' <= ',            /* $op */
3737
            //  4 => 'foo',             /* optional, if $op is e.g. 'IS NULL' */
3738
            //  5 => ')'                /* optional */
3739
            // ];
3740

3741
            if ($matches[4] !== '') {
1,014✔
3742
                $protectIdentifiers = false;
974✔
3743
                if (str_contains($matches[4], '.')) {
974✔
3744
                    $protectIdentifiers = true;
87✔
3745
                }
3746

3747
                if (! str_contains($matches[4], ':')) {
974✔
3748
                    $matches[4] = $this->db->protectIdentifiers(trim($matches[4]), false, $protectIdentifiers);
14✔
3749
                }
3750

3751
                $matches[4] = ' ' . $matches[4];
974✔
3752
            }
3753

3754
            $condition = $matches[1] . $this->db->protectIdentifiers(trim($matches[2]))
1,014✔
3755
                . ' ' . trim($matches[3]) . $matches[4] . $matches[5];
1,014✔
3756
        }
3757

3758
        return implode('', $conditions);
1,014✔
3759
    }
3760

3761
    /**
3762
     * @used-by compileWhereHavingCondition()
3763
     *
3764
     * @param array{columnComparison: true, condition: string, escape: bool, first: string, operator: string, second: string} $condition
3765
     */
3766
    private function compileColumnComparison(array $condition): string
3767
    {
3768
        if ($condition['escape']) {
22✔
3769
            $condition['first']  = $this->db->protectIdentifiers($condition['first'], false, true);
20✔
3770
            $condition['second'] = $this->db->protectIdentifiers($condition['second'], false, true);
20✔
3771
        }
3772

3773
        return $condition['condition'] . $condition['first'] . ' ' . $condition['operator'] . ' ' . $condition['second'];
22✔
3774
    }
3775

3776
    /**
3777
     * @used-by compileWhereHavingCondition()
3778
     *
3779
     * @param array{betweenComparison: true, condition: string, escape: bool, key: string, lowerBind: string, not: string, upperBind: string} $condition
3780
     */
3781
    private function compileBetweenComparison(array $condition): string
3782
    {
3783
        if ($condition['escape']) {
14✔
3784
            $condition['key'] = $this->db->protectIdentifiers($condition['key'], false, true);
12✔
3785
        }
3786

3787
        return $condition['condition'] . $condition['key'] . $condition['not'] . ' BETWEEN :' . $condition['lowerBind'] . ': AND :' . $condition['upperBind'] . ':';
14✔
3788
    }
3789

3790
    /**
3791
     * Escapes identifiers in GROUP BY statements at execution time.
3792
     *
3793
     * Required so that aliases are tracked properly, regardless of whether
3794
     * groupBy() is called prior to from(), join() and prefixTable is added
3795
     * only if needed.
3796
     */
3797
    protected function compileGroupBy(): string
3798
    {
3799
        if (! empty($this->QBGroupBy)) {
1,172✔
3800
            foreach ($this->QBGroupBy as &$groupBy) {
65✔
3801
                // Is it already compiled?
3802
                if (is_string($groupBy)) {
65✔
3803
                    continue;
3✔
3804
                }
3805

3806
                $groupBy = ($groupBy['escape'] === false || $this->isLiteral($groupBy['field']))
65✔
UNCOV
3807
                    ? $groupBy['field']
×
3808
                    : $this->db->protectIdentifiers($groupBy['field']);
65✔
3809
            }
3810

3811
            return "\nGROUP BY " . implode(', ', $this->QBGroupBy);
65✔
3812
        }
3813

3814
        return '';
1,137✔
3815
    }
3816

3817
    /**
3818
     * Escapes identifiers in ORDER BY statements at execution time.
3819
     *
3820
     * Required so that aliases are tracked properly, regardless of whether
3821
     * orderBy() is called prior to from(), join() and prefixTable is added
3822
     * only if needed.
3823
     */
3824
    protected function compileOrderBy(): string
3825
    {
3826
        if (is_array($this->QBOrderBy) && $this->QBOrderBy !== []) {
1,184✔
3827
            foreach ($this->QBOrderBy as &$orderBy) {
874✔
3828
                if (is_string($orderBy)) {
874✔
3829
                    continue;
1✔
3830
                }
3831
                if ($orderBy['escape'] !== false && ! $this->isLiteral($orderBy['field'])) {
874✔
3832
                    $orderBy['field'] = $this->db->protectIdentifiers($orderBy['field']);
872✔
3833
                }
3834

3835
                $orderBy = $orderBy['field'] . $orderBy['direction'];
874✔
3836
            }
3837

3838
            return "\nORDER BY " . implode(', ', $this->QBOrderBy);
874✔
3839
        }
3840

3841
        return '';
1,159✔
3842
    }
3843

3844
    protected function unionInjection(string $sql): string
3845
    {
3846
        if ($this->QBUnion === []) {
1,161✔
3847
            return $sql;
1,161✔
3848
        }
3849

3850
        return 'SELECT * FROM (' . $sql . ') '
7✔
3851
            . ($this->db->protectIdentifiers ? $this->db->escapeIdentifiers('uwrp0') : 'uwrp0')
7✔
3852
            . implode("\n", $this->QBUnion);
7✔
3853
    }
3854

3855
    /**
3856
     * Takes an object as input and converts the class variables to array key/vals
3857
     *
3858
     * @param array|object $object
3859
     *
3860
     * @return array
3861
     */
3862
    protected function objectToArray($object)
3863
    {
3864
        if (! is_object($object)) {
896✔
3865
            return $object;
893✔
3866
        }
3867

3868
        if ($object instanceof RawSql) {
8✔
3869
            throw new InvalidArgumentException('RawSql "' . $object . '" cannot be used here.');
1✔
3870
        }
3871

3872
        $array = [];
7✔
3873

3874
        foreach (get_object_vars($object) as $key => $val) {
7✔
3875
            if ((! is_object($val) || $val instanceof RawSql) && ! is_array($val)) {
7✔
3876
                $array[$key] = $val;
7✔
3877
            }
3878
        }
3879

3880
        return $array;
7✔
3881
    }
3882

3883
    /**
3884
     * Takes an object as input and converts the class variables to array key/vals
3885
     *
3886
     * @param array|object $object
3887
     *
3888
     * @return array
3889
     */
3890
    protected function batchObjectToArray($object)
3891
    {
3892
        if (! is_object($object)) {
68✔
3893
            return $object;
68✔
3894
        }
3895

3896
        $array  = [];
×
3897
        $out    = get_object_vars($object);
×
UNCOV
3898
        $fields = array_keys($out);
×
3899

UNCOV
3900
        foreach ($fields as $val) {
×
3901
            $i = 0;
×
3902

UNCOV
3903
            foreach ($out[$val] as $data) {
×
UNCOV
3904
                $array[$i++][$val] = $data;
×
3905
            }
3906
        }
3907

UNCOV
3908
        return $array;
×
3909
    }
3910

3911
    /**
3912
     * Determines if a string represents a literal value or a field name
3913
     */
3914
    protected function isLiteral(string $str): bool
3915
    {
3916
        $str = trim($str);
911✔
3917

3918
        if ($str === ''
911✔
3919
            || ctype_digit($str)
911✔
3920
            || (string) (float) $str === $str
911✔
3921
            || in_array(strtoupper($str), ['TRUE', 'FALSE'], true)
911✔
3922
        ) {
UNCOV
3923
            return true;
×
3924
        }
3925

3926
        if ($this->isLiteralStr === []) {
911✔
3927
            $this->isLiteralStr = $this->db->escapeChar !== '"' ? ['"', "'"] : ["'"];
911✔
3928
        }
3929

3930
        return in_array($str[0], $this->isLiteralStr, true);
911✔
3931
    }
3932

3933
    /**
3934
     * Publicly-visible method to reset the QB values.
3935
     *
3936
     * @return $this
3937
     */
3938
    public function resetQuery()
3939
    {
3940
        $this->resetSelect();
1✔
3941
        $this->resetWrite();
1✔
3942

3943
        return $this;
1✔
3944
    }
3945

3946
    /**
3947
     * Resets the query builder values.  Called by the get() function
3948
     *
3949
     * @param array $qbResetItems An array of fields to reset
3950
     *
3951
     * @return void
3952
     */
3953
    protected function resetRun(array $qbResetItems)
3954
    {
3955
        foreach ($qbResetItems as $item => $defaultValue) {
1,166✔
3956
            $this->{$item} = $defaultValue;
1,166✔
3957
        }
3958
    }
3959

3960
    /**
3961
     * Resets the query builder values.  Called by the get() function
3962
     *
3963
     * @return void
3964
     */
3965
    protected function resetSelect()
3966
    {
3967
        $this->resetRun([
1,139✔
3968
            'QBSelect'              => [],
1,139✔
3969
            'QBJoin'                => [],
1,139✔
3970
            'QBWhere'               => [],
1,139✔
3971
            'QBGroupBy'             => [],
1,139✔
3972
            'QBHaving'              => [],
1,139✔
3973
            'QBOrderBy'             => [],
1,139✔
3974
            'QBNoEscape'            => [],
1,139✔
3975
            'QBDistinct'            => false,
1,139✔
3976
            'QBLimit'               => false,
1,139✔
3977
            'QBOffset'              => false,
1,139✔
3978
            'QBLockForUpdate'       => false,
1,139✔
3979
            'QBSelectUsesAggregate' => false,
1,139✔
3980
            'QBUnion'               => [],
1,139✔
3981
        ]);
1,139✔
3982

3983
        if ($this->db instanceof BaseConnection) {
1,139✔
3984
            $this->db->setAliasedTables([]);
1,139✔
3985
        }
3986

3987
        // Reset QBFrom part
3988
        if (! empty($this->QBFrom)) {
1,139✔
3989
            $this->from(array_shift($this->QBFrom), true);
1,139✔
3990
        }
3991
    }
3992

3993
    /**
3994
     * Resets the query builder "write" values.
3995
     *
3996
     * Called by the insert() update() insertBatch() updateBatch() and delete() functions
3997
     *
3998
     * @return void
3999
     */
4000
    protected function resetWrite()
4001
    {
4002
        $this->resetRun([
903✔
4003
            'QBSet'     => [],
903✔
4004
            'QBJoin'    => [],
903✔
4005
            'QBWhere'   => [],
903✔
4006
            'QBOrderBy' => [],
903✔
4007
            'QBKeys'    => [],
903✔
4008
            'QBLimit'   => false,
903✔
4009
            'QBIgnore'  => false,
903✔
4010
            'QBOptions' => [],
903✔
4011
        ]);
903✔
4012
    }
4013

4014
    /**
4015
     * Tests whether the string has an SQL operator
4016
     */
4017
    protected function hasOperator(string $str): bool
4018
    {
4019
        return preg_match(
162✔
4020
            '/(<|>|!|=|\sIS NULL|\sIS NOT NULL|\sEXISTS|\sBETWEEN|\sLIKE|\sIN\s*\(|\s)/i',
162✔
4021
            trim($str),
162✔
4022
        ) === 1;
162✔
4023
    }
4024

4025
    /**
4026
     * Returns the SQL string operator
4027
     *
4028
     * @return array|false|string
4029
     */
4030
    protected function getOperator(string $str, bool $list = false)
4031
    {
4032
        if ($this->pregOperators === []) {
1,024✔
4033
            $_les = $this->db->likeEscapeStr !== ''
1,024✔
4034
                ? '\s+' . preg_quote(trim(sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar)), '/')
1,024✔
UNCOV
4035
                : '';
×
4036
            $this->pregOperators = [
1,024✔
4037
                '\s*(?:<|>|!)?=\s*', // =, <=, >=, !=
1,024✔
4038
                '\s*<>?\s*',         // <, <>
1,024✔
4039
                '\s*>\s*',           // >
1,024✔
4040
                '\s+IS NULL',             // IS NULL
1,024✔
4041
                '\s+IS NOT NULL',         // IS NOT NULL
1,024✔
4042
                '\s+EXISTS\s*\(.*\)',     // EXISTS (sql)
1,024✔
4043
                '\s+NOT EXISTS\s*\(.*\)', // NOT EXISTS(sql)
1,024✔
4044
                '\s+BETWEEN\s+',          // BETWEEN value AND value
1,024✔
4045
                '\s+IN\s*\(.*\)',         // IN (list)
1,024✔
4046
                '\s+NOT IN\s*\(.*\)',     // NOT IN (list)
1,024✔
4047
                '\s+LIKE\s+\S.*(' . $_les . ')?',     // LIKE 'expr'[ ESCAPE '%s']
1,024✔
4048
                '\s+NOT LIKE\s+\S.*(' . $_les . ')?', // NOT LIKE 'expr'[ ESCAPE '%s']
1,024✔
4049
            ];
1,024✔
4050
        }
4051

4052
        return preg_match_all(
1,024✔
4053
            '/' . implode('|', $this->pregOperators) . '/i',
1,024✔
4054
            $str,
1,024✔
4055
            $match,
1,024✔
4056
        ) >= 1 ? ($list ? $match[0] : $match[0][0]) : false;
1,024✔
4057
    }
4058

4059
    /**
4060
     * Returns the SQL string operator from where key
4061
     *
4062
     * @return false|list<string>
4063
     */
4064
    private function getOperatorFromWhereKey(string $whereKey)
4065
    {
4066
        $whereKey = trim($whereKey);
977✔
4067

4068
        $pregOperators = [
977✔
4069
            '\s*(?:<|>|!)?=',         // =, <=, >=, !=
977✔
4070
            '\s*<>?',                 // <, <>
977✔
4071
            '\s*>',                   // >
977✔
4072
            '\s+IS NULL',             // IS NULL
977✔
4073
            '\s+IS NOT NULL',         // IS NOT NULL
977✔
4074
            '\s+EXISTS\s*\(.*\)',     // EXISTS (sql)
977✔
4075
            '\s+NOT EXISTS\s*\(.*\)', // NOT EXISTS (sql)
977✔
4076
            '\s+BETWEEN\s+',          // BETWEEN value AND value
977✔
4077
            '\s+IN\s*\(.*\)',         // IN (list)
977✔
4078
            '\s+NOT IN\s*\(.*\)',     // NOT IN (list)
977✔
4079
            '\s+LIKE',                // LIKE
977✔
4080
            '\s+NOT LIKE',            // NOT LIKE
977✔
4081
        ];
977✔
4082

4083
        return preg_match_all(
977✔
4084
            '/' . implode('|', $pregOperators) . '/i',
977✔
4085
            $whereKey,
977✔
4086
            $match,
977✔
4087
        ) >= 1 ? $match[0] : false;
977✔
4088
    }
4089

4090
    /**
4091
     * Stores a bind value after ensuring that it's unique.
4092
     * While it might be nicer to have named keys for our binds array
4093
     * with PHP 7+ we get a huge memory/performance gain with indexed
4094
     * arrays instead, so lets take advantage of that here.
4095
     *
4096
     * @param mixed $value
4097
     */
4098
    protected function setBind(string $key, $value = null, bool $escape = true): string
4099
    {
4100
        if (! array_key_exists($key, $this->binds)) {
1,031✔
4101
            $this->binds[$key] = [
1,031✔
4102
                $value,
1,031✔
4103
                $escape,
1,031✔
4104
            ];
1,031✔
4105

4106
            return $key;
1,031✔
4107
        }
4108

4109
        if (! array_key_exists($key, $this->bindsKeyCount)) {
59✔
4110
            $this->bindsKeyCount[$key] = 1;
59✔
4111
        }
4112

4113
        $count = $this->bindsKeyCount[$key]++;
59✔
4114

4115
        $this->binds[$key . '.' . $count] = [
59✔
4116
            $value,
59✔
4117
            $escape,
59✔
4118
        ];
59✔
4119

4120
        return $key . '.' . $count;
59✔
4121
    }
4122

4123
    /**
4124
     * @param mixed $value
4125
     */
4126
    protected function isSubquery($value): bool
4127
    {
4128
        return $value instanceof BaseBuilder || $value instanceof Closure;
992✔
4129
    }
4130

4131
    /**
4132
     * @param BaseBuilder|Closure(BaseBuilder): BaseBuilder $builder
4133
     * @param bool                                          $wrapped Wrap the subquery in brackets
4134
     * @param string                                        $alias   Subquery alias
4135
     */
4136
    protected function buildSubquery($builder, bool $wrapped = false, string $alias = ''): string
4137
    {
4138
        if ($builder instanceof Closure) {
34✔
4139
            $builder($builder = $this->db->newQuery());
18✔
4140
        }
4141

4142
        if ($builder === $this) {
34✔
4143
            throw new DatabaseException('The subquery cannot be the same object as the main query object.');
2✔
4144
        }
4145

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

4148
        if ($wrapped) {
32✔
4149
            $subquery = '(' . $subquery . ')';
32✔
4150
            $alias    = trim($alias);
32✔
4151

4152
            if ($alias !== '') {
32✔
4153
                $subquery .= ' ' . ($this->db->protectIdentifiers ? $this->db->escapeIdentifiers($alias) : $alias);
16✔
4154
            }
4155
        }
4156

4157
        return $subquery;
32✔
4158
    }
4159
}
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