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

codeigniter4 / CodeIgniter4 / 25908013250

15 May 2026 08:24AM UTC coverage: 88.459% (+0.2%) from 88.299%
25908013250

Pull #10159

github

web-flow
Merge 5bd38b5ce into 170b89a6e
Pull Request #10159: feat: Add support for callable TTLs in cache handlers

6 of 10 new or added lines in 3 files covered. (60.0%)

446 existing lines in 24 files now uncovered.

24114 of 27260 relevant lines covered (88.46%)

219.07 hits per line

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

94.5
/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,183✔
UNCOV
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,183✔
326

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

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

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

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

369
        return $this;
82✔
370
    }
371

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

380
    /**
381
     * Returns an array of bind values and their
382
     * named parameters for binding in the Query object later.
383
     */
384
    public function getBinds(): array
385
    {
386
        return $this->binds;
59✔
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)) {
922✔
416
            $escape = $this->db->protectIdentifiers;
911✔
417
        }
418

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

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

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

432
                continue;
5✔
433
            }
434

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

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

450
                    continue;
2✔
451
                }
452

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

457
        return $this;
922✔
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);
818✔
468
    }
469

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

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

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

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

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

517
        return $this;
2✔
518
    }
519

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

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

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

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

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

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

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

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

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

573
        return $item;
822✔
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;
826✔
584

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

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

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

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

617
        return $this;
1,183✔
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✔
UNCOV
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✔
UNCOV
675
            $cond = ' USING (' . ($escape ? $this->db->escapeIdentifiers($cond) : $cond) . ')';
×
676
        } elseif ($escape === false) {
14✔
UNCOV
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);
942✔
733
    }
734

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

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

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

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

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

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

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

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

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

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

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

858
        $prefix = $this->{$qbKey} === [] ? $this->groupGetType('') : $this->groupGetType($type);
22✔
859

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

869
        return $this;
22✔
870
    }
871

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

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

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

890
    /**
891
     * @used-by whereExists()
892
     * @used-by orWhereExists()
893
     * @used-by whereNotExists()
894
     * @used-by orWhereNotExists()
895
     *
896
     * @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder) $subquery
897
     *
898
     * @return $this
899
     *
900
     * @throws InvalidArgumentException
901
     */
902
    protected function whereExistsSubquery($subquery, bool $not = false, string $type = 'AND '): static
903
    {
904
        if (! $this->isSubquery($subquery)) {
12✔
905
            throw new InvalidArgumentException(sprintf('%s() expects $subquery to be of type BaseBuilder or closure', debug_backtrace(0, 2)[1]['function']));
4✔
906
        }
907

908
        $prefix   = $this->QBWhere === [] ? $this->groupGetType('') : $this->groupGetType($type);
8✔
909
        $operator = $not ? 'NOT EXISTS' : 'EXISTS';
8✔
910

911
        $this->QBWhere[] = [
8✔
912
            'condition' => "{$prefix}{$operator} {$this->buildSubquery($subquery, true)}",
8✔
913
            'escape'    => false,
8✔
914
        ];
8✔
915

916
        return $this;
7✔
917
    }
918

919
    /**
920
     * @used-by where()
921
     * @used-by orWhere()
922
     * @used-by having()
923
     * @used-by orHaving()
924
     *
925
     * @param array|RawSql|string $key
926
     * @param mixed               $value
927
     *
928
     * @return $this
929
     */
930
    protected function whereHaving(string $qbKey, $key, $value = null, string $type = 'AND ', ?bool $escape = null)
931
    {
932
        $rawSqlOnly = false;
950✔
933

934
        if ($key instanceof RawSql) {
950✔
935
            if ($value === null) {
4✔
936
                $keyValue   = [(string) $key => $key];
1✔
937
                $rawSqlOnly = true;
1✔
938
            } else {
939
                $keyValue = [(string) $key => $value];
3✔
940
            }
941
        } elseif (! is_array($key)) {
946✔
942
            $keyValue = [$key => $value];
936✔
943
        } else {
944
            $keyValue = $key;
241✔
945
        }
946

947
        // If the escape value was not set will base it on the global setting
948
        if (! is_bool($escape)) {
950✔
949
            $escape = $this->db->protectIdentifiers;
944✔
950
        }
951

952
        foreach ($keyValue as $k => $v) {
950✔
953
            $prefix = empty($this->{$qbKey}) ? $this->groupGetType('') : $this->groupGetType($type);
950✔
954

955
            if ($rawSqlOnly) {
950✔
956
                $k  = '';
1✔
957
                $op = '';
1✔
958
            } elseif ($v !== null) {
949✔
959
                $op = $this->getOperatorFromWhereKey($k);
929✔
960

961
                if (! empty($op)) {
929✔
962
                    $k = trim($k);
53✔
963

964
                    end($op);
53✔
965
                    $op = trim(current($op));
53✔
966

967
                    // Does the key end with operator?
968
                    if (str_ends_with($k, $op)) {
53✔
969
                        $k  = rtrim(substr($k, 0, -strlen($op)));
53✔
970
                        $op = " {$op}";
53✔
971
                    } else {
UNCOV
972
                        $op = '';
×
973
                    }
974
                } else {
975
                    $op = ' =';
913✔
976
                }
977

978
                if ($this->isSubquery($v)) {
929✔
979
                    $v = $this->buildSubquery($v, true);
1✔
980
                } else {
981
                    $bind = $this->setBind($k, $v, $escape);
929✔
982
                    $v    = " :{$bind}:";
929✔
983
                }
984
            } elseif (! $this->hasOperator($k) && $qbKey !== 'QBHaving') {
147✔
985
                // value appears not to have been set, assign the test to IS NULL
986
                $op = ' IS NULL';
95✔
987
            } elseif (
988
                // The key ends with !=, =, <>, IS, IS NOT
989
                preg_match(
61✔
990
                    '/\s*(!?=|<>|IS(?:\s+NOT)?)\s*$/i',
61✔
991
                    $k,
61✔
992
                    $match,
61✔
993
                    PREG_OFFSET_CAPTURE,
61✔
994
                )
61✔
995
            ) {
996
                $k  = substr($k, 0, $match[0][1]);
1✔
997
                $op = $match[1][0] === '=' ? ' IS NULL' : ' IS NOT NULL';
1✔
998
            } else {
999
                $op = '';
60✔
1000
            }
1001

1002
            if ($v instanceof RawSql) {
950✔
1003
                $this->{$qbKey}[] = [
1✔
1004
                    'condition' => $v->with($prefix . $k . $op . $v),
1✔
1005
                    'escape'    => $escape,
1✔
1006
                ];
1✔
1007
            } else {
1008
                $this->{$qbKey}[] = [
949✔
1009
                    'condition' => $prefix . $k . $op . $v,
949✔
1010
                    'escape'    => $escape,
949✔
1011
                ];
949✔
1012
            }
1013
        }
1014

1015
        return $this;
950✔
1016
    }
1017

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

1031
    /**
1032
     * Generates a WHERE field IN('item', 'item') SQL query,
1033
     * joined with 'OR' if appropriate.
1034
     *
1035
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1036
     *
1037
     * @return $this
1038
     */
1039
    public function orWhereIn(?string $key = null, $values = null, ?bool $escape = null)
1040
    {
1041
        return $this->_whereIn($key, $values, false, 'OR ', $escape);
3✔
1042
    }
1043

1044
    /**
1045
     * Generates a WHERE field NOT IN('item', 'item') SQL query,
1046
     * joined with 'AND' if appropriate.
1047
     *
1048
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1049
     *
1050
     * @return $this
1051
     */
1052
    public function whereNotIn(?string $key = null, $values = null, ?bool $escape = null)
1053
    {
1054
        return $this->_whereIn($key, $values, true, 'AND ', $escape);
3✔
1055
    }
1056

1057
    /**
1058
     * Generates a WHERE field NOT IN('item', 'item') SQL query,
1059
     * joined with 'OR' if appropriate.
1060
     *
1061
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1062
     *
1063
     * @return $this
1064
     */
1065
    public function orWhereNotIn(?string $key = null, $values = null, ?bool $escape = null)
1066
    {
1067
        return $this->_whereIn($key, $values, true, 'OR ', $escape);
2✔
1068
    }
1069

1070
    /**
1071
     * Generates a HAVING field IN('item', 'item') SQL query,
1072
     * joined with 'AND' if appropriate.
1073
     *
1074
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1075
     *
1076
     * @return $this
1077
     */
1078
    public function havingIn(?string $key = null, $values = null, ?bool $escape = null)
1079
    {
1080
        return $this->_whereIn($key, $values, false, 'AND ', $escape, 'QBHaving');
6✔
1081
    }
1082

1083
    /**
1084
     * Generates a HAVING field IN('item', 'item') SQL query,
1085
     * joined with 'OR' if appropriate.
1086
     *
1087
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1088
     *
1089
     * @return $this
1090
     */
1091
    public function orHavingIn(?string $key = null, $values = null, ?bool $escape = null)
1092
    {
1093
        return $this->_whereIn($key, $values, false, 'OR ', $escape, 'QBHaving');
3✔
1094
    }
1095

1096
    /**
1097
     * Generates a HAVING field NOT IN('item', 'item') SQL query,
1098
     * joined with 'AND' if appropriate.
1099
     *
1100
     * @param array|BaseBuilder|(Closure(BaseBuilder):BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1101
     *
1102
     * @return $this
1103
     */
1104
    public function havingNotIn(?string $key = null, $values = null, ?bool $escape = null)
1105
    {
1106
        return $this->_whereIn($key, $values, true, 'AND ', $escape, 'QBHaving');
5✔
1107
    }
1108

1109
    /**
1110
     * Generates a HAVING field NOT IN('item', 'item') SQL query,
1111
     * joined with 'OR' if appropriate.
1112
     *
1113
     * @param array|BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|null $values The values searched on, or anonymous function with subquery
1114
     *
1115
     * @return $this
1116
     */
1117
    public function orHavingNotIn(?string $key = null, $values = null, ?bool $escape = null)
1118
    {
1119
        return $this->_whereIn($key, $values, true, 'OR ', $escape, 'QBHaving');
3✔
1120
    }
1121

1122
    /**
1123
     * @used-by WhereIn()
1124
     * @used-by orWhereIn()
1125
     * @used-by whereNotIn()
1126
     * @used-by orWhereNotIn()
1127
     *
1128
     * @param non-empty-string|null                                            $key
1129
     * @param BaseBuilder|(Closure(BaseBuilder): BaseBuilder)|list<mixed>|null $values The values searched on, or anonymous function with subquery
1130
     *
1131
     * @return $this
1132
     *
1133
     * @throws InvalidArgumentException
1134
     */
1135
    protected function _whereIn(?string $key = null, $values = null, bool $not = false, string $type = 'AND ', ?bool $escape = null, string $clause = 'QBWhere')
1136
    {
1137
        if ($key === null || $key === '') {
86✔
1138
            throw new InvalidArgumentException(sprintf('%s() expects $key to be a non-empty string', debug_backtrace(0, 2)[1]['function']));
2✔
1139
        }
1140

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

1145
        if (! is_bool($escape)) {
81✔
1146
            $escape = $this->db->protectIdentifiers;
81✔
1147
        }
1148

1149
        $ok = $key;
81✔
1150

1151
        if ($escape === true) {
81✔
1152
            $key = $this->db->protectIdentifiers($key);
81✔
1153
        }
1154

1155
        $not = ($not) ? ' NOT' : '';
81✔
1156

1157
        if ($this->isSubquery($values)) {
81✔
1158
            $whereIn = $this->buildSubquery($values, true);
8✔
1159
            $escape  = false;
8✔
1160
        } else {
1161
            $whereIn = array_values($values);
73✔
1162
        }
1163

1164
        $ok = $this->setBind($ok, $whereIn, $escape);
81✔
1165

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

1168
        $whereIn = [
81✔
1169
            'condition' => "{$prefix}{$key}{$not} IN :{$ok}:",
81✔
1170
            'escape'    => false,
81✔
1171
        ];
81✔
1172

1173
        $this->{$clause}[] = $whereIn;
81✔
1174

1175
        return $this;
81✔
1176
    }
1177

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

1191
    /**
1192
     * Generates a NOT LIKE portion of the query.
1193
     * Separates multiple calls with 'AND'.
1194
     *
1195
     * @param array|RawSql|string $field
1196
     *
1197
     * @return $this
1198
     */
1199
    public function notLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1200
    {
1201
        return $this->_like($field, $match, 'AND ', $side, 'NOT', $escape, $insensitiveSearch);
2✔
1202
    }
1203

1204
    /**
1205
     * Generates a %LIKE% portion of the query.
1206
     * Separates multiple calls with 'OR'.
1207
     *
1208
     * @param array|RawSql|string $field
1209
     *
1210
     * @return $this
1211
     */
1212
    public function orLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1213
    {
1214
        return $this->_like($field, $match, 'OR ', $side, '', $escape, $insensitiveSearch);
2✔
1215
    }
1216

1217
    /**
1218
     * Generates a NOT LIKE portion of the query.
1219
     * Separates multiple calls with 'OR'.
1220
     *
1221
     * @param array|RawSql|string $field
1222
     *
1223
     * @return $this
1224
     */
1225
    public function orNotLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1226
    {
1227
        return $this->_like($field, $match, 'OR ', $side, 'NOT', $escape, $insensitiveSearch);
2✔
1228
    }
1229

1230
    /**
1231
     * Generates a %LIKE% portion of the query.
1232
     * Separates multiple calls with 'AND'.
1233
     *
1234
     * @param array|RawSql|string $field
1235
     *
1236
     * @return $this
1237
     */
1238
    public function havingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1239
    {
1240
        return $this->_like($field, $match, 'AND ', $side, '', $escape, $insensitiveSearch, 'QBHaving');
15✔
1241
    }
1242

1243
    /**
1244
     * Generates a NOT LIKE portion of the query.
1245
     * Separates multiple calls with 'AND'.
1246
     *
1247
     * @param array|RawSql|string $field
1248
     *
1249
     * @return $this
1250
     */
1251
    public function notHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1252
    {
1253
        return $this->_like($field, $match, 'AND ', $side, 'NOT', $escape, $insensitiveSearch, 'QBHaving');
4✔
1254
    }
1255

1256
    /**
1257
     * Generates a %LIKE% portion of the query.
1258
     * Separates multiple calls with 'OR'.
1259
     *
1260
     * @param array|RawSql|string $field
1261
     *
1262
     * @return $this
1263
     */
1264
    public function orHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1265
    {
1266
        return $this->_like($field, $match, 'OR ', $side, '', $escape, $insensitiveSearch, 'QBHaving');
4✔
1267
    }
1268

1269
    /**
1270
     * Generates a NOT LIKE portion of the query.
1271
     * Separates multiple calls with 'OR'.
1272
     *
1273
     * @param array|RawSql|string $field
1274
     *
1275
     * @return $this
1276
     */
1277
    public function orNotHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1278
    {
1279
        return $this->_like($field, $match, 'OR ', $side, 'NOT', $escape, $insensitiveSearch, 'QBHaving');
4✔
1280
    }
1281

1282
    /**
1283
     * @used-by like()
1284
     * @used-by orLike()
1285
     * @used-by notLike()
1286
     * @used-by orNotLike()
1287
     * @used-by havingLike()
1288
     * @used-by orHavingLike()
1289
     * @used-by notHavingLike()
1290
     * @used-by orNotHavingLike()
1291
     *
1292
     * @param array<string, string>|RawSql|string $field
1293
     *
1294
     * @return $this
1295
     */
1296
    protected function _like($field, string $match = '', string $type = 'AND ', string $side = 'both', string $not = '', ?bool $escape = null, bool $insensitiveSearch = false, string $clause = 'QBWhere')
1297
    {
1298
        $escape = is_bool($escape) ? $escape : $this->db->protectIdentifiers;
47✔
1299
        $side   = strtolower($side);
47✔
1300

1301
        if ($field instanceof RawSql) {
47✔
1302
            $k                 = (string) $field;
3✔
1303
            $v                 = $match;
3✔
1304
            $insensitiveSearch = false;
3✔
1305

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

1308
            if ($side === 'none') {
3✔
UNCOV
1309
                $bind = $this->setBind($field->getBindingKey(), $v, $escape);
×
1310
            } elseif ($side === 'before') {
3✔
UNCOV
1311
                $bind = $this->setBind($field->getBindingKey(), "%{$v}", $escape);
×
1312
            } elseif ($side === 'after') {
3✔
UNCOV
1313
                $bind = $this->setBind($field->getBindingKey(), "{$v}%", $escape);
×
1314
            } else {
1315
                $bind = $this->setBind($field->getBindingKey(), "%{$v}%", $escape);
3✔
1316
            }
1317

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

1320
            // some platforms require an escape sequence definition for LIKE wildcards
1321
            if ($escape === true && $this->db->likeEscapeStr !== '') {
3✔
1322
                $likeStatement .= sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar);
3✔
1323
            }
1324

1325
            $this->{$clause}[] = [
3✔
1326
                'condition' => $field->with($likeStatement),
3✔
1327
                'escape'    => $escape,
3✔
1328
            ];
3✔
1329

1330
            return $this;
3✔
1331
        }
1332

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

1335
        foreach ($keyValue as $k => $v) {
44✔
1336
            if ($insensitiveSearch) {
44✔
1337
                $v = mb_strtolower($v, 'UTF-8');
7✔
1338
            }
1339

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

1342
            if ($side === 'none') {
44✔
1343
                $bind = $this->setBind($k, $v, $escape);
1✔
1344
            } elseif ($side === 'before') {
43✔
1345
                $bind = $this->setBind($k, "%{$v}", $escape);
9✔
1346
            } elseif ($side === 'after') {
34✔
1347
                $bind = $this->setBind($k, "{$v}%", $escape);
5✔
1348
            } else {
1349
                $bind = $this->setBind($k, "%{$v}%", $escape);
29✔
1350
            }
1351

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

1354
            // some platforms require an escape sequence definition for LIKE wildcards
1355
            if ($escape === true && $this->db->likeEscapeStr !== '') {
44✔
1356
                $likeStatement .= sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar);
44✔
1357
            }
1358

1359
            $this->{$clause}[] = [
44✔
1360
                'condition' => $likeStatement,
44✔
1361
                'escape'    => $escape,
44✔
1362
            ];
44✔
1363
        }
1364

1365
        return $this;
44✔
1366
    }
1367

1368
    /**
1369
     * Platform independent LIKE statement builder.
1370
     */
1371
    protected function _like_statement(?string $prefix, string $column, ?string $not, string $bind, bool $insensitiveSearch = false): string
1372
    {
1373
        if ($insensitiveSearch) {
47✔
1374
            return "{$prefix} LOWER(" . $this->db->escapeIdentifiers($column) . ") {$not} LIKE :{$bind}:";
7✔
1375
        }
1376

1377
        return "{$prefix} {$column} {$not} LIKE :{$bind}:";
40✔
1378
    }
1379

1380
    /**
1381
     * Add UNION statement
1382
     *
1383
     * @param BaseBuilder|Closure(BaseBuilder): BaseBuilder $union
1384
     *
1385
     * @return $this
1386
     */
1387
    public function union($union)
1388
    {
1389
        return $this->addUnionStatement($union);
6✔
1390
    }
1391

1392
    /**
1393
     * Add UNION ALL statement
1394
     *
1395
     * @param BaseBuilder|Closure(BaseBuilder): BaseBuilder $union
1396
     *
1397
     * @return $this
1398
     */
1399
    public function unionAll($union)
1400
    {
1401
        return $this->addUnionStatement($union, true);
2✔
1402
    }
1403

1404
    /**
1405
     * @used-by union()
1406
     * @used-by unionAll()
1407
     *
1408
     * @param BaseBuilder|Closure(BaseBuilder): BaseBuilder $union
1409
     *
1410
     * @return $this
1411
     */
1412
    protected function addUnionStatement($union, bool $all = false)
1413
    {
1414
        $this->QBUnion[] = "\nUNION "
8✔
1415
            . ($all ? 'ALL ' : '')
8✔
1416
            . 'SELECT * FROM '
8✔
1417
            . $this->buildSubquery($union, true, 'uwrp' . (count($this->QBUnion) + 1));
8✔
1418

1419
        return $this;
8✔
1420
    }
1421

1422
    /**
1423
     * Starts a query group.
1424
     *
1425
     * @return $this
1426
     */
1427
    public function groupStart()
1428
    {
1429
        return $this->groupStartPrepare();
4✔
1430
    }
1431

1432
    /**
1433
     * Starts a query group, but ORs the group
1434
     *
1435
     * @return $this
1436
     */
1437
    public function orGroupStart()
1438
    {
1439
        return $this->groupStartPrepare('', 'OR ');
2✔
1440
    }
1441

1442
    /**
1443
     * Starts a query group, but NOTs the group
1444
     *
1445
     * @return $this
1446
     */
1447
    public function notGroupStart()
1448
    {
1449
        return $this->groupStartPrepare('NOT ');
2✔
1450
    }
1451

1452
    /**
1453
     * Starts a query group, but OR NOTs the group
1454
     *
1455
     * @return $this
1456
     */
1457
    public function orNotGroupStart()
1458
    {
1459
        return $this->groupStartPrepare('NOT ', 'OR ');
2✔
1460
    }
1461

1462
    /**
1463
     * Ends a query group
1464
     *
1465
     * @return $this
1466
     */
1467
    public function groupEnd()
1468
    {
1469
        return $this->groupEndPrepare();
10✔
1470
    }
1471

1472
    /**
1473
     * Starts a query group for HAVING clause.
1474
     *
1475
     * @return $this
1476
     */
1477
    public function havingGroupStart()
1478
    {
1479
        return $this->groupStartPrepare('', 'AND ', 'QBHaving');
2✔
1480
    }
1481

1482
    /**
1483
     * Starts a query group for HAVING clause, but ORs the group.
1484
     *
1485
     * @return $this
1486
     */
1487
    public function orHavingGroupStart()
1488
    {
1489
        return $this->groupStartPrepare('', 'OR ', 'QBHaving');
2✔
1490
    }
1491

1492
    /**
1493
     * Starts a query group for HAVING clause, but NOTs the group.
1494
     *
1495
     * @return $this
1496
     */
1497
    public function notHavingGroupStart()
1498
    {
1499
        return $this->groupStartPrepare('NOT ', 'AND ', 'QBHaving');
2✔
1500
    }
1501

1502
    /**
1503
     * Starts a query group for HAVING clause, but OR NOTs the group.
1504
     *
1505
     * @return $this
1506
     */
1507
    public function orNotHavingGroupStart()
1508
    {
1509
        return $this->groupStartPrepare('NOT ', 'OR ', 'QBHaving');
2✔
1510
    }
1511

1512
    /**
1513
     * Ends a query group for HAVING clause.
1514
     *
1515
     * @return $this
1516
     */
1517
    public function havingGroupEnd()
1518
    {
1519
        return $this->groupEndPrepare('QBHaving');
8✔
1520
    }
1521

1522
    /**
1523
     * Prepate a query group start.
1524
     *
1525
     * @return $this
1526
     */
1527
    protected function groupStartPrepare(string $not = '', string $type = 'AND ', string $clause = 'QBWhere')
1528
    {
1529
        $type = $this->groupGetType($type);
18✔
1530

1531
        $this->QBWhereGroupStarted = true;
18✔
1532
        $prefix                    = empty($this->{$clause}) ? '' : $type;
18✔
1533
        $where                     = [
18✔
1534
            'condition' => $prefix . $not . str_repeat(' ', ++$this->QBWhereGroupCount) . ' (',
18✔
1535
            'escape'    => false,
18✔
1536
        ];
18✔
1537

1538
        $this->{$clause}[] = $where;
18✔
1539

1540
        return $this;
18✔
1541
    }
1542

1543
    /**
1544
     * Prepate a query group end.
1545
     *
1546
     * @return $this
1547
     */
1548
    protected function groupEndPrepare(string $clause = 'QBWhere')
1549
    {
1550
        $this->QBWhereGroupStarted = false;
18✔
1551
        $where                     = [
18✔
1552
            'condition' => str_repeat(' ', $this->QBWhereGroupCount--) . ')',
18✔
1553
            'escape'    => false,
18✔
1554
        ];
18✔
1555

1556
        $this->{$clause}[] = $where;
18✔
1557

1558
        return $this;
18✔
1559
    }
1560

1561
    /**
1562
     * @used-by groupStart()
1563
     * @used-by _like()
1564
     * @used-by whereHaving()
1565
     * @used-by _whereIn()
1566
     * @used-by whereColumnHaving()
1567
     * @used-by havingGroupStart()
1568
     */
1569
    protected function groupGetType(string $type): string
1570
    {
1571
        if ($this->QBWhereGroupStarted) {
994✔
1572
            $type                      = '';
18✔
1573
            $this->QBWhereGroupStarted = false;
18✔
1574
        }
1575

1576
        return $type;
994✔
1577
    }
1578

1579
    /**
1580
     * @param array|string $by
1581
     *
1582
     * @return $this
1583
     */
1584
    public function groupBy($by, ?bool $escape = null)
1585
    {
1586
        if (! is_bool($escape)) {
58✔
1587
            $escape = $this->db->protectIdentifiers;
58✔
1588
        }
1589

1590
        if (is_string($by)) {
58✔
1591
            $by = ($escape === true) ? explode(',', $by) : [$by];
58✔
1592
        }
1593

1594
        foreach ($by as $val) {
58✔
1595
            $val = trim($val);
58✔
1596

1597
            if ($val !== '') {
58✔
1598
                $val = [
58✔
1599
                    'field'  => $val,
58✔
1600
                    'escape' => $escape,
58✔
1601
                ];
58✔
1602

1603
                $this->QBGroupBy[] = $val;
58✔
1604
            }
1605
        }
1606

1607
        return $this;
58✔
1608
    }
1609

1610
    /**
1611
     * Separates multiple calls with 'AND'.
1612
     *
1613
     * @param array|RawSql|string $key
1614
     * @param mixed               $value
1615
     *
1616
     * @return $this
1617
     */
1618
    public function having($key, $value = null, ?bool $escape = null)
1619
    {
1620
        return $this->whereHaving('QBHaving', $key, $value, 'AND ', $escape);
18✔
1621
    }
1622

1623
    /**
1624
     * Separates multiple calls with 'OR'.
1625
     *
1626
     * @param array|RawSql|string $key
1627
     * @param mixed               $value
1628
     *
1629
     * @return $this
1630
     */
1631
    public function orHaving($key, $value = null, ?bool $escape = null)
1632
    {
1633
        return $this->whereHaving('QBHaving', $key, $value, 'OR ', $escape);
2✔
1634
    }
1635

1636
    /**
1637
     * @param string $direction ASC, DESC or RANDOM
1638
     *
1639
     * @return $this
1640
     */
1641
    public function orderBy(string $orderBy, string $direction = '', ?bool $escape = null)
1642
    {
1643
        if ($orderBy === '') {
846✔
UNCOV
1644
            return $this;
×
1645
        }
1646

1647
        $qbOrderBy = [];
846✔
1648

1649
        $direction = strtoupper(trim($direction));
846✔
1650

1651
        if ($direction === 'RANDOM') {
846✔
1652
            $direction = '';
3✔
1653
            $orderBy   = ctype_digit($orderBy) ? sprintf($this->randomKeyword[1], $orderBy) : $this->randomKeyword[0];
3✔
1654
            $escape    = false;
3✔
1655
        } elseif ($direction !== '') {
844✔
1656
            $direction = in_array($direction, ['ASC', 'DESC'], true) ? ' ' . $direction : '';
844✔
1657
        }
1658

1659
        if ($escape === null) {
846✔
1660
            $escape = $this->db->protectIdentifiers;
844✔
1661
        }
1662

1663
        if ($escape === false) {
846✔
1664
            $qbOrderBy[] = [
3✔
1665
                'field'     => $orderBy,
3✔
1666
                'direction' => $direction,
3✔
1667
                'escape'    => false,
3✔
1668
            ];
3✔
1669
        } else {
1670
            foreach (explode(',', $orderBy) as $field) {
844✔
1671
                $qbOrderBy[] = ($direction === '' && preg_match('/\s+(ASC|DESC)$/i', rtrim($field), $match, PREG_OFFSET_CAPTURE))
844✔
UNCOV
1672
                    ? [
×
UNCOV
1673
                        'field'     => ltrim(substr($field, 0, $match[0][1])),
×
UNCOV
1674
                        'direction' => ' ' . $match[1][0],
×
UNCOV
1675
                        'escape'    => true,
×
UNCOV
1676
                    ]
×
1677
                    : [
844✔
1678
                        'field'     => trim($field),
844✔
1679
                        'direction' => $direction,
844✔
1680
                        'escape'    => true,
844✔
1681
                    ];
844✔
1682
            }
1683
        }
1684

1685
        $this->QBOrderBy = array_merge($this->QBOrderBy, $qbOrderBy);
846✔
1686

1687
        return $this;
846✔
1688
    }
1689

1690
    /**
1691
     * @return $this
1692
     */
1693
    public function limit(?int $value = null, ?int $offset = 0)
1694
    {
1695
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
126✔
1696
        if ($limitZeroAsAll && $value === 0) {
126✔
1697
            $value = null;
13✔
1698
        }
1699

1700
        if ($value !== null) {
126✔
1701
            $this->QBLimit = $value;
114✔
1702
        }
1703

1704
        if ($offset !== null && $offset !== 0) {
126✔
1705
            $this->QBOffset = $offset;
10✔
1706
        }
1707

1708
        return $this;
126✔
1709
    }
1710

1711
    /**
1712
     * Locks the selected rows for update.
1713
     */
1714
    public function lockForUpdate(): static
1715
    {
1716
        $this->QBLockForUpdate = true;
26✔
1717

1718
        return $this;
26✔
1719
    }
1720

1721
    /**
1722
     * Sets the OFFSET value
1723
     *
1724
     * @return $this
1725
     */
1726
    public function offset(int $offset)
1727
    {
1728
        if ($offset !== 0) {
1✔
1729
            $this->QBOffset = $offset;
1✔
1730
        }
1731

1732
        return $this;
1✔
1733
    }
1734

1735
    /**
1736
     * Generates a platform-specific LIMIT clause.
1737
     */
1738
    protected function _limit(string $sql, bool $offsetIgnore = false): string
1739
    {
1740
        return $sql . ' LIMIT ' . ($offsetIgnore === false && $this->QBOffset ? $this->QBOffset . ', ' : '') . $this->QBLimit;
113✔
1741
    }
1742

1743
    /**
1744
     * Allows key/value pairs to be set for insert(), update() or replace().
1745
     *
1746
     * @param array|object|string $key    Field name, or an array of field/value pairs, or an object
1747
     * @param mixed               $value  Field value, if $key is a single field
1748
     * @param bool|null           $escape Whether to escape values
1749
     *
1750
     * @return $this
1751
     */
1752
    public function set($key, $value = '', ?bool $escape = null)
1753
    {
1754
        $key = $this->objectToArray($key);
860✔
1755

1756
        if (! is_array($key)) {
860✔
1757
            $key = [$key => $value];
122✔
1758
        }
1759

1760
        $escape = is_bool($escape) ? $escape : $this->db->protectIdentifiers;
860✔
1761

1762
        foreach ($key as $k => $v) {
860✔
1763
            if ($escape) {
860✔
1764
                $bind = $this->setBind($k, $v, $escape);
859✔
1765

1766
                $this->QBSet[$this->db->protectIdentifiers($k, false)] = ":{$bind}:";
859✔
1767
            } else {
1768
                $this->QBSet[$this->db->protectIdentifiers($k, false)] = $v;
10✔
1769
            }
1770
        }
1771

1772
        return $this;
860✔
1773
    }
1774

1775
    /**
1776
     * Returns the previously set() data, alternatively resetting it if needed.
1777
     */
1778
    public function getSetData(bool $clean = false): array
1779
    {
UNCOV
1780
        $data = $this->QBSet;
×
1781

UNCOV
1782
        if ($clean) {
×
UNCOV
1783
            $this->QBSet = [];
×
1784
        }
1785

UNCOV
1786
        return $data;
×
1787
    }
1788

1789
    /**
1790
     * Compiles a SELECT query string and returns the sql.
1791
     */
1792
    public function getCompiledSelect(bool $reset = true): string
1793
    {
1794
        $select = $this->compileSelect();
251✔
1795

1796
        if ($reset) {
240✔
1797
            $this->resetSelect();
231✔
1798
        }
1799

1800
        return $this->compileFinalQuery($select);
240✔
1801
    }
1802

1803
    /**
1804
     * Returns a finalized, compiled query string with the bindings
1805
     * inserted and prefixes swapped out.
1806
     */
1807
    protected function compileFinalQuery(string $sql): string
1808
    {
1809
        $query = new Query($this->db);
265✔
1810
        $query->setQuery($sql, $this->binds, false);
265✔
1811

1812
        if (! empty($this->db->swapPre) && ! empty($this->db->DBPrefix)) {
265✔
UNCOV
1813
            $query->swapPrefix($this->db->DBPrefix, $this->db->swapPre);
×
1814
        }
1815

1816
        return $query->getQuery();
265✔
1817
    }
1818

1819
    /**
1820
     * Compiles the select statement based on the other functions called
1821
     * and runs the query
1822
     *
1823
     * @return false|ResultInterface
1824
     */
1825
    public function get(?int $limit = null, int $offset = 0, bool $reset = true)
1826
    {
1827
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
872✔
1828
        if ($limitZeroAsAll && $limit === 0) {
872✔
1829
            $limit = null;
1✔
1830
        }
1831

1832
        if ($limit !== null) {
872✔
1833
            $this->limit($limit, $offset);
8✔
1834
        }
1835

1836
        $result = $this->testMode
872✔
1837
            ? $this->getCompiledSelect($reset)
2✔
1838
            : $this->db->query($this->compileSelect(), $this->binds, false);
870✔
1839

1840
        if ($reset) {
872✔
1841
            $this->resetSelect();
872✔
1842

1843
            // Clear our binds so we don't eat up memory
1844
            $this->binds = [];
872✔
1845
        }
1846

1847
        return $result;
872✔
1848
    }
1849

1850
    /**
1851
     * Generates a platform-specific query string that counts all records in
1852
     * the particular table
1853
     *
1854
     * @return int|string
1855
     */
1856
    public function countAll(bool $reset = true)
1857
    {
1858
        $table = $this->QBFrom[0];
6✔
1859

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

1863
        if ($this->testMode) {
6✔
1864
            return $sql;
1✔
1865
        }
1866

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

1869
        if (empty($query->getResult())) {
5✔
UNCOV
1870
            return 0;
×
1871
        }
1872

1873
        $query = $query->getRow();
5✔
1874

1875
        if ($reset) {
5✔
1876
            $this->resetSelect();
5✔
1877
        }
1878

1879
        return (int) $query->numrows;
5✔
1880
    }
1881

1882
    /**
1883
     * Generates a platform-specific query string that counts all records
1884
     * returned by an Query Builder query.
1885
     *
1886
     * @return int|string
1887
     */
1888
    public function countAllResults(bool $reset = true)
1889
    {
1890
        // ORDER BY usage is often problematic here (most notably
1891
        // on Microsoft SQL Server) and ultimately unnecessary
1892
        // for selecting COUNT(*) ...
1893
        $orderBy = [];
235✔
1894

1895
        if (! empty($this->QBOrderBy)) {
235✔
UNCOV
1896
            $orderBy = $this->QBOrderBy;
×
1897

UNCOV
1898
            $this->QBOrderBy = null;
×
1899
        }
1900

1901
        // We cannot use a LIMIT when getting the single row COUNT(*) result
1902
        $limit         = $this->QBLimit;
235✔
1903
        $lockForUpdate = $this->QBLockForUpdate;
235✔
1904

1905
        $this->QBLimit         = false;
235✔
1906
        $this->QBLockForUpdate = false;
235✔
1907

1908
        try {
1909
            if ($this->QBDistinct === true || ! empty($this->QBGroupBy)) {
235✔
1910
                // We need to backup the original SELECT in case DBPrefix is used
1911
                $select = $this->QBSelect;
4✔
1912
                $sql    = $this->countString . $this->db->protectIdentifiers('numrows') . "\nFROM (\n" . $this->compileSelect() . "\n) CI_count_all_results";
4✔
1913

1914
                // Restore SELECT part
1915
                $this->QBSelect = $select;
4✔
1916
                unset($select);
4✔
1917
            } else {
1918
                $sql = $this->compileSelect($this->countString . $this->db->protectIdentifiers('numrows'));
231✔
1919
            }
1920
        } finally {
1921
            $this->QBLockForUpdate = $lockForUpdate;
235✔
1922
        }
1923

1924
        if ($this->testMode) {
235✔
1925
            return $sql;
11✔
1926
        }
1927

1928
        $result = $this->db->query($sql, $this->binds, false);
228✔
1929

1930
        if ($reset) {
228✔
1931
            $this->resetSelect();
212✔
1932
        } elseif (! isset($this->QBOrderBy)) {
22✔
UNCOV
1933
            $this->QBOrderBy = $orderBy;
×
1934
        }
1935

1936
        // Restore the LIMIT setting
1937
        $this->QBLimit = $limit;
228✔
1938

1939
        $row = $result instanceof ResultInterface ? $result->getRow() : null;
228✔
1940

1941
        if (empty($row)) {
228✔
UNCOV
1942
            return 0;
×
1943
        }
1944

1945
        return (int) $row->numrows;
228✔
1946
    }
1947

1948
    /**
1949
     * Compiles the set conditions and returns the sql statement
1950
     *
1951
     * @return array
1952
     */
1953
    public function getCompiledQBWhere()
1954
    {
1955
        return $this->QBWhere;
63✔
1956
    }
1957

1958
    /**
1959
     * Allows the where clause, limit and offset to be added directly
1960
     *
1961
     * @param array|string $where
1962
     *
1963
     * @return ResultInterface
1964
     */
1965
    public function getWhere($where = null, ?int $limit = null, ?int $offset = 0, bool $reset = true)
1966
    {
1967
        if ($where !== null) {
17✔
1968
            $this->where($where);
16✔
1969
        }
1970

1971
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
17✔
1972
        if ($limitZeroAsAll && $limit === 0) {
17✔
UNCOV
1973
            $limit = null;
×
1974
        }
1975

1976
        if ($limit !== null) {
17✔
1977
            $this->limit($limit, $offset);
3✔
1978
        }
1979

1980
        $result = $this->testMode
17✔
1981
            ? $this->getCompiledSelect($reset)
4✔
1982
            : $this->db->query($this->compileSelect(), $this->binds, false);
13✔
1983

1984
        if ($reset) {
17✔
1985
            $this->resetSelect();
17✔
1986

1987
            // Clear our binds so we don't eat up memory
1988
            $this->binds = [];
17✔
1989
        }
1990

1991
        return $result;
17✔
1992
    }
1993

1994
    /**
1995
     * Compiles batch insert/update/upsert strings and runs the queries
1996
     *
1997
     * @param '_deleteBatch'|'_insertBatch'|'_updateBatch'|'_upsertBatch' $renderMethod
1998
     *
1999
     * @return false|int|list<string> Number of rows inserted or FALSE on failure, SQL array when testMode
2000
     *
2001
     * @throws DatabaseException
2002
     */
2003
    protected function batchExecute(string $renderMethod, int $batchSize = 100)
2004
    {
2005
        if (empty($this->QBSet)) {
73✔
2006
            if ($this->db->DBDebug) {
5✔
2007
                throw new DatabaseException(trim($renderMethod, '_') . '() has no data.');
5✔
2008
            }
2009

2010
            return false; // @codeCoverageIgnore
2011
        }
2012

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

2015
        $affectedRows = 0;
68✔
2016
        $savedSQL     = [];
68✔
2017
        $cnt          = count($this->QBSet);
68✔
2018

2019
        // batch size 0 for unlimited
2020
        if ($batchSize === 0) {
68✔
UNCOV
2021
            $batchSize = $cnt;
×
2022
        }
2023

2024
        for ($i = 0, $total = $cnt; $i < $total; $i += $batchSize) {
68✔
2025
            $QBSet = array_slice($this->QBSet, $i, $batchSize);
68✔
2026

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

2029
            if ($sql === '') {
65✔
2030
                return false; // @codeCoverageIgnore
2031
            }
2032

2033
            if ($this->testMode) {
65✔
2034
                $savedSQL[] = $sql;
3✔
2035
            } else {
2036
                $this->db->query($sql, null, false);
62✔
2037
                $affectedRows += $this->db->affectedRows();
60✔
2038
            }
2039
        }
2040

2041
        if (! $this->testMode) {
63✔
2042
            $this->resetWrite();
60✔
2043
        }
2044

2045
        return $this->testMode ? $savedSQL : $affectedRows;
63✔
2046
    }
2047

2048
    /**
2049
     * Allows a row or multiple rows to be set for batch inserts/upserts/updates
2050
     *
2051
     * @param array|object $set
2052
     * @param string       $alias alias for sql table
2053
     *
2054
     * @return $this|null
2055
     */
2056
    public function setData($set, ?bool $escape = null, string $alias = '')
2057
    {
2058
        if (empty($set)) {
68✔
UNCOV
2059
            if ($this->db->DBDebug) {
×
UNCOV
2060
                throw new DatabaseException('setData() has no data.');
×
2061
            }
2062

2063
            return null; // @codeCoverageIgnore
2064
        }
2065

2066
        $this->setAlias($alias);
68✔
2067

2068
        // this allows to set just one row at a time
2069
        if (is_object($set) || (! is_array(current($set)) && ! is_object(current($set)))) {
68✔
2070
            $set = [$set];
11✔
2071
        }
2072

2073
        $set = $this->batchObjectToArray($set);
68✔
2074

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

2077
        $keys = array_keys($this->objectToArray(current($set)));
68✔
2078
        sort($keys);
68✔
2079

2080
        foreach ($set as $row) {
68✔
2081
            $row = $this->objectToArray($row);
68✔
2082
            if (array_diff($keys, array_keys($row)) !== [] || array_diff(array_keys($row), $keys) !== []) {
68✔
2083
                // batchExecute() function returns an error on an empty array
UNCOV
2084
                $this->QBSet[] = [];
×
2085

UNCOV
2086
                return null;
×
2087
            }
2088

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

2091
            $clean = [];
68✔
2092

2093
            foreach ($row as $rowValue) {
68✔
2094
                $clean[] = $escape ? $this->db->escape($rowValue) : $rowValue;
68✔
2095
            }
2096

2097
            $row = $clean;
68✔
2098

2099
            $this->QBSet[] = $row;
68✔
2100
        }
2101

2102
        foreach ($keys as $k) {
68✔
2103
            $k = $this->db->protectIdentifiers($k, false);
68✔
2104

2105
            if (! in_array($k, $this->QBKeys, true)) {
68✔
2106
                $this->QBKeys[] = $k;
68✔
2107
            }
2108
        }
2109

2110
        return $this;
68✔
2111
    }
2112

2113
    /**
2114
     * Compiles an upsert query and returns the sql
2115
     *
2116
     * @return string
2117
     *
2118
     * @throws DatabaseException
2119
     */
2120
    public function getCompiledUpsert()
2121
    {
2122
        [$currentTestMode, $this->testMode] = [$this->testMode, true];
3✔
2123

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

2126
        $this->testMode = $currentTestMode;
3✔
2127

2128
        return $this->compileFinalQuery($sql);
3✔
2129
    }
2130

2131
    /**
2132
     * Converts call to batchUpsert
2133
     *
2134
     * @param array|object|null $set
2135
     *
2136
     * @return false|int|list<string> Number of affected rows or FALSE on failure, SQL array when testMode
2137
     *
2138
     * @throws DatabaseException
2139
     */
2140
    public function upsert($set = null, ?bool $escape = null)
2141
    {
2142
        // if set() has been used merge QBSet with binds and then setData()
2143
        if ($set === null && ! is_array(current($this->QBSet))) {
10✔
2144
            $set = [];
2✔
2145

2146
            foreach ($this->QBSet as $field => $value) {
2✔
2147
                $k = trim($field, $this->db->escapeChar);
2✔
2148
                // use binds if available else use QBSet value but with RawSql to avoid escape
2149
                $set[$k] = isset($this->binds[$k]) ? $this->binds[$k][0] : new RawSql($value);
2✔
2150
            }
2151

2152
            $this->binds = [];
2✔
2153

2154
            $this->resetRun([
2✔
2155
                'QBSet'  => [],
2✔
2156
                'QBKeys' => [],
2✔
2157
            ]);
2✔
2158

2159
            $this->setData($set, true); // unescaped items are RawSql now
2✔
2160
        } elseif ($set !== null) {
8✔
2161
            $this->setData($set, $escape);
7✔
2162
        } // else setData() has already been used and we need to do nothing
2163

2164
        return $this->batchExecute('_upsertBatch');
10✔
2165
    }
2166

2167
    /**
2168
     * Compiles batch upsert strings and runs the queries
2169
     *
2170
     * @param array|object|null $set a dataset
2171
     *
2172
     * @return false|int|list<string> Number of affected rows or FALSE on failure, SQL array when testMode
2173
     *
2174
     * @throws DatabaseException
2175
     */
2176
    public function upsertBatch($set = null, ?bool $escape = null, int $batchSize = 100)
2177
    {
2178
        if (isset($this->QBOptions['setQueryAsData'])) {
12✔
2179
            $sql = $this->_upsertBatch($this->QBFrom[0], $this->QBKeys, []);
1✔
2180

2181
            if ($sql === '') {
1✔
2182
                return false; // @codeCoverageIgnore
2183
            }
2184

2185
            if ($this->testMode === false) {
1✔
2186
                $this->db->query($sql, null, false);
1✔
2187
            }
2188

2189
            $this->resetWrite();
1✔
2190

2191
            return $this->testMode ? $sql : $this->db->affectedRows();
1✔
2192
        }
2193

2194
        if ($set !== null) {
11✔
2195
            $this->setData($set, $escape);
9✔
2196
        }
2197

2198
        return $this->batchExecute('_upsertBatch', $batchSize);
11✔
2199
    }
2200

2201
    /**
2202
     * Generates a platform-specific upsertBatch string from the supplied data
2203
     *
2204
     * @used-by batchExecute()
2205
     *
2206
     * @param string                 $table  Protected table name
2207
     * @param list<string>           $keys   QBKeys
2208
     * @param list<list<int|string>> $values QBSet
2209
     */
2210
    protected function _upsertBatch(string $table, array $keys, array $values): string
2211
    {
2212
        $sql = $this->QBOptions['sql'] ?? '';
19✔
2213

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

2218
            $sql = 'INSERT INTO ' . $table . ' (' . implode(', ', $keys) . ")\n{:_table_:}ON DUPLICATE KEY UPDATE\n" . implode(
19✔
2219
                ",\n",
19✔
2220
                array_map(
19✔
2221
                    static fn ($key, $value): string => $table . '.' . $key . ($value instanceof RawSql ?
19✔
2222
                        ' = ' . $value :
2✔
2223
                        ' = VALUES(' . $value . ')'),
19✔
2224
                    array_keys($updateFields),
19✔
2225
                    $updateFields,
19✔
2226
                ),
19✔
2227
            );
19✔
2228

2229
            $this->QBOptions['sql'] = $sql;
19✔
2230
        }
2231

2232
        if (isset($this->QBOptions['setQueryAsData'])) {
19✔
2233
            $data = $this->QBOptions['setQueryAsData'] . "\n";
1✔
2234
        } else {
2235
            $data = 'VALUES ' . implode(', ', $this->formatValues($values)) . "\n";
18✔
2236
        }
2237

2238
        return str_replace('{:_table_:}', $data, $sql);
19✔
2239
    }
2240

2241
    /**
2242
     * Set table alias for dataset pseudo table.
2243
     */
2244
    private function setAlias(string $alias): BaseBuilder
2245
    {
2246
        if ($alias !== '') {
68✔
2247
            $this->db->addTableAlias($alias);
7✔
2248
            $this->QBOptions['alias'] = $this->db->protectIdentifiers($alias);
7✔
2249
        }
2250

2251
        return $this;
68✔
2252
    }
2253

2254
    /**
2255
     * Sets update fields for upsert, update
2256
     *
2257
     * @param list<RawSql>|list<string>|string $set
2258
     * @param bool                             $addToDefault adds update fields to the default ones
2259
     * @param array|null                       $ignore       ignores items in set
2260
     *
2261
     * @return $this
2262
     */
2263
    public function updateFields($set, bool $addToDefault = false, ?array $ignore = null)
2264
    {
2265
        if (! empty($set)) {
38✔
2266
            if (! is_array($set)) {
38✔
2267
                $set = explode(',', $set);
5✔
2268
            }
2269

2270
            foreach ($set as $key => $value) {
38✔
2271
                if (! ($value instanceof RawSql)) {
38✔
2272
                    $value = $this->db->protectIdentifiers($value);
38✔
2273
                }
2274

2275
                if (is_numeric($key)) {
38✔
2276
                    $key = $value;
38✔
2277
                }
2278

2279
                if ($ignore === null || ! in_array($key, $ignore, true)) {
38✔
2280
                    if ($addToDefault) {
38✔
2281
                        $this->QBOptions['updateFieldsAdditional'][$this->db->protectIdentifiers($key)] = $value;
3✔
2282
                    } else {
2283
                        $this->QBOptions['updateFields'][$this->db->protectIdentifiers($key)] = $value;
38✔
2284
                    }
2285
                }
2286
            }
2287

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

2291
                unset($this->QBOptions['updateFieldsAdditional']);
3✔
2292
            }
2293
        }
2294

2295
        return $this;
38✔
2296
    }
2297

2298
    /**
2299
     * Sets constraints for batch upsert, update
2300
     *
2301
     * @param array|RawSql|string $set a string of columns, key value pairs, or RawSql
2302
     *
2303
     * @return $this
2304
     */
2305
    public function onConstraint($set)
2306
    {
2307
        if (! empty($set)) {
47✔
2308
            if (is_string($set)) {
44✔
2309
                $set = explode(',', $set);
27✔
2310

2311
                $set = array_map(trim(...), $set);
27✔
2312
            }
2313

2314
            if ($set instanceof RawSql) {
44✔
2315
                $set = [$set];
2✔
2316
            }
2317

2318
            foreach ($set as $key => $value) {
44✔
2319
                if (! ($value instanceof RawSql)) {
44✔
2320
                    $value = $this->db->protectIdentifiers($value);
41✔
2321
                }
2322

2323
                if (is_string($key)) {
44✔
2324
                    $key = $this->db->protectIdentifiers($key);
3✔
2325
                }
2326

2327
                $this->QBOptions['constraints'][$key] = $value;
44✔
2328
            }
2329
        }
2330

2331
        return $this;
47✔
2332
    }
2333

2334
    /**
2335
     * Sets data source as a query for insertBatch()/updateBatch()/upsertBatch()/deleteBatch()
2336
     *
2337
     * @param BaseBuilder|RawSql $query
2338
     * @param array|string|null  $columns an array or comma delimited string of columns
2339
     */
2340
    public function setQueryAsData($query, ?string $alias = null, $columns = null): BaseBuilder
2341
    {
2342
        if (is_string($query)) {
5✔
UNCOV
2343
            throw new InvalidArgumentException('$query parameter must be BaseBuilder or RawSql class.');
×
2344
        }
2345

2346
        if ($query instanceof BaseBuilder) {
5✔
2347
            $query = $query->getCompiledSelect();
4✔
2348
        } elseif ($query instanceof RawSql) {
1✔
2349
            $query = $query->__toString();
1✔
2350
        }
2351

2352
        if (is_string($query)) {
5✔
2353
            if ($columns !== null && is_string($columns)) {
5✔
2354
                $columns = explode(',', $columns);
1✔
2355
                $columns = array_map(trim(...), $columns);
1✔
2356
            }
2357

2358
            $columns = (array) $columns;
5✔
2359

2360
            if ($columns === []) {
5✔
2361
                $columns = $this->fieldsFromQuery($query);
4✔
2362
            }
2363

2364
            if ($alias !== null) {
5✔
2365
                $this->setAlias($alias);
1✔
2366
            }
2367

2368
            foreach ($columns as $key => $value) {
5✔
2369
                $columns[$key] = $this->db->escapeChar . $value . $this->db->escapeChar;
5✔
2370
            }
2371

2372
            $this->QBOptions['setQueryAsData'] = $query;
5✔
2373
            $this->QBKeys                      = $columns;
5✔
2374
            $this->QBSet                       = [];
5✔
2375
        }
2376

2377
        return $this;
5✔
2378
    }
2379

2380
    /**
2381
     * Gets column names from a select query
2382
     */
2383
    protected function fieldsFromQuery(string $sql): array
2384
    {
2385
        return $this->db->query('SELECT * FROM (' . $sql . ') _u_ LIMIT 1')->getFieldNames();
4✔
2386
    }
2387

2388
    /**
2389
     * Converts value array of array to array of strings
2390
     */
2391
    protected function formatValues(array $values): array
2392
    {
2393
        return array_map(static fn ($index): string => '(' . implode(',', $index) . ')', $values);
45✔
2394
    }
2395

2396
    /**
2397
     * Compiles batch insert strings and runs the queries
2398
     *
2399
     * @param array|object|null $set a dataset
2400
     *
2401
     * @return false|int|list<string> Number of rows inserted or FALSE on no data to perform an insert operation, SQL array when testMode
2402
     */
2403
    public function insertBatch($set = null, ?bool $escape = null, int $batchSize = 100)
2404
    {
2405
        if (isset($this->QBOptions['setQueryAsData'])) {
29✔
2406
            $sql = $this->_insertBatch($this->QBFrom[0], $this->QBKeys, []);
2✔
2407

2408
            if ($sql === '') {
2✔
2409
                return false; // @codeCoverageIgnore
2410
            }
2411

2412
            if ($this->testMode === false) {
2✔
2413
                $this->db->query($sql, null, false);
2✔
2414
            }
2415

2416
            $this->resetWrite();
2✔
2417

2418
            return $this->testMode ? $sql : $this->db->affectedRows();
2✔
2419
        }
2420

2421
        if ($set !== null && $set !== []) {
29✔
2422
            $this->setData($set, $escape);
27✔
2423
        }
2424

2425
        return $this->batchExecute('_insertBatch', $batchSize);
29✔
2426
    }
2427

2428
    /**
2429
     * Generates a platform-specific insert string from the supplied data.
2430
     *
2431
     * @used-by batchExecute()
2432
     *
2433
     * @param string                 $table  Protected table name
2434
     * @param list<string>           $keys   QBKeys
2435
     * @param list<list<int|string>> $values QBSet
2436
     */
2437
    protected function _insertBatch(string $table, array $keys, array $values): string
2438
    {
2439
        $sql = $this->QBOptions['sql'] ?? '';
27✔
2440

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

2446
            $this->QBOptions['sql'] = $sql;
27✔
2447
        }
2448

2449
        if (isset($this->QBOptions['setQueryAsData'])) {
27✔
2450
            $data = $this->QBOptions['setQueryAsData'];
2✔
2451
        } else {
2452
            $data = 'VALUES ' . implode(', ', $this->formatValues($values));
27✔
2453
        }
2454

2455
        return str_replace('{:_table_:}', $data, $sql);
27✔
2456
    }
2457

2458
    /**
2459
     * Compiles an insert query and returns the sql
2460
     *
2461
     * @return bool|string
2462
     *
2463
     * @throws DatabaseException
2464
     */
2465
    public function getCompiledInsert(bool $reset = true)
2466
    {
2467
        if ($this->validateInsert() === false) {
6✔
UNCOV
2468
            return false;
×
2469
        }
2470

2471
        $sql = $this->_insert(
6✔
2472
            $this->db->protectIdentifiers(
6✔
2473
                $this->removeAlias($this->QBFrom[0]),
6✔
2474
                true,
6✔
2475
                null,
6✔
2476
                false,
6✔
2477
            ),
6✔
2478
            array_keys($this->QBSet),
6✔
2479
            array_values($this->QBSet),
6✔
2480
        );
6✔
2481

2482
        if ($reset) {
6✔
2483
            $this->resetWrite();
6✔
2484
        }
2485

2486
        return $this->compileFinalQuery($sql);
6✔
2487
    }
2488

2489
    /**
2490
     * Compiles an insert string and runs the query
2491
     *
2492
     * @param array|object|null $set
2493
     *
2494
     * @return BaseResult|bool|Query
2495
     *
2496
     * @throws DatabaseException
2497
     */
2498
    public function insert($set = null, ?bool $escape = null)
2499
    {
2500
        if ($set !== null) {
847✔
2501
            $this->set($set, '', $escape);
813✔
2502
        }
2503

2504
        if ($this->validateInsert() === false) {
847✔
UNCOV
2505
            return false;
×
2506
        }
2507

2508
        $sql = $this->_insert(
846✔
2509
            $this->db->protectIdentifiers(
846✔
2510
                $this->removeAlias($this->QBFrom[0]),
846✔
2511
                true,
846✔
2512
                $escape,
846✔
2513
                false,
846✔
2514
            ),
846✔
2515
            array_keys($this->QBSet),
846✔
2516
            array_values($this->QBSet),
846✔
2517
        );
846✔
2518

2519
        if (! $this->testMode) {
846✔
2520
            $this->resetWrite();
842✔
2521

2522
            $result = $this->db->query($sql, $this->binds, false);
842✔
2523

2524
            // Clear our binds so we don't eat up memory
2525
            $this->binds = [];
842✔
2526

2527
            return $result;
842✔
2528
        }
2529

2530
        return false;
5✔
2531
    }
2532

2533
    /**
2534
     * @internal This is a temporary solution.
2535
     *
2536
     * @see https://github.com/codeigniter4/CodeIgniter4/pull/5376
2537
     *
2538
     * @TODO Fix a root cause, and this method should be removed.
2539
     */
2540
    protected function removeAlias(string $from): string
2541
    {
2542
        if (str_contains($from, ' ')) {
851✔
2543
            // if the alias is written with the AS keyword, remove it
2544
            $from = preg_replace('/\s+AS\s+/i', ' ', $from);
2✔
2545

2546
            $parts = explode(' ', $from);
2✔
2547
            $from  = $parts[0];
2✔
2548
        }
2549

2550
        return $from;
851✔
2551
    }
2552

2553
    /**
2554
     * This method is used by both insert() and getCompiledInsert() to
2555
     * validate that the there data is actually being set and that table
2556
     * has been chosen to be inserted into.
2557
     *
2558
     * @throws DatabaseException
2559
     */
2560
    protected function validateInsert(): bool
2561
    {
2562
        if (empty($this->QBSet)) {
847✔
2563
            if ($this->db->DBDebug) {
1✔
2564
                throw new DatabaseException('You must use the "set" method to insert an entry.');
1✔
2565
            }
2566

2567
            return false; // @codeCoverageIgnore
2568
        }
2569

2570
        return true;
846✔
2571
    }
2572

2573
    /**
2574
     * Generates a platform-specific insert string from the supplied data
2575
     *
2576
     * @param string           $table         Protected table name
2577
     * @param list<string>     $keys          Keys of QBSet
2578
     * @param list<int|string> $unescapedKeys Values of QBSet
2579
     */
2580
    protected function _insert(string $table, array $keys, array $unescapedKeys): string
2581
    {
2582
        return 'INSERT ' . $this->compileIgnore('insert') . 'INTO ' . $table . ' (' . implode(', ', $keys) . ') VALUES (' . implode(', ', $unescapedKeys) . ')';
837✔
2583
    }
2584

2585
    /**
2586
     * Compiles a replace into string and runs the query
2587
     *
2588
     * @return BaseResult|false|Query|string
2589
     *
2590
     * @throws DatabaseException
2591
     */
2592
    public function replace(?array $set = null)
2593
    {
2594
        if ($set !== null) {
8✔
2595
            $this->set($set);
7✔
2596
        }
2597

2598
        if (empty($this->QBSet)) {
8✔
2599
            if ($this->db->DBDebug) {
1✔
2600
                throw new DatabaseException('You must use the "set" method to update an entry.');
1✔
2601
            }
2602

2603
            return false; // @codeCoverageIgnore
2604
        }
2605

2606
        $table = $this->QBFrom[0];
7✔
2607

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

2610
        $this->resetWrite();
7✔
2611

2612
        return $this->testMode ? $sql : $this->db->query($sql, $this->binds, false);
7✔
2613
    }
2614

2615
    /**
2616
     * Generates a platform-specific replace string from the supplied data
2617
     *
2618
     * @param string           $table  Protected table name
2619
     * @param list<string>     $keys   Keys of QBSet
2620
     * @param list<int|string> $values Values of QBSet
2621
     */
2622
    protected function _replace(string $table, array $keys, array $values): string
2623
    {
2624
        return 'REPLACE INTO ' . $table . ' (' . implode(', ', $keys) . ') VALUES (' . implode(', ', $values) . ')';
7✔
2625
    }
2626

2627
    /**
2628
     * Groups tables in FROM clauses if needed, so there is no confusion
2629
     * about operator precedence.
2630
     *
2631
     * Note: This is only used (and overridden) by MySQL and SQLSRV.
2632
     */
2633
    protected function _fromTables(): string
2634
    {
2635
        return implode(', ', $this->QBFrom);
1,075✔
2636
    }
2637

2638
    /**
2639
     * Compiles an update query and returns the sql
2640
     *
2641
     * @return bool|string
2642
     */
2643
    public function getCompiledUpdate(bool $reset = true)
2644
    {
2645
        if ($this->validateUpdate() === false) {
13✔
UNCOV
2646
            return false;
×
2647
        }
2648

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

2651
        if ($reset) {
13✔
2652
            $this->resetWrite();
13✔
2653
        }
2654

2655
        return $this->compileFinalQuery($sql);
13✔
2656
    }
2657

2658
    /**
2659
     * Compiles an update string and runs the query.
2660
     *
2661
     * @param array|object|null        $set
2662
     * @param array|RawSql|string|null $where
2663
     *
2664
     * @throws DatabaseException
2665
     */
2666
    public function update($set = null, $where = null, ?int $limit = null): bool
2667
    {
2668
        if ($set !== null) {
104✔
2669
            $this->set($set);
50✔
2670
        }
2671

2672
        if ($this->validateUpdate() === false) {
104✔
UNCOV
2673
            return false;
×
2674
        }
2675

2676
        if ($where !== null) {
103✔
2677
            $this->where($where);
7✔
2678
        }
2679

2680
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
103✔
2681
        if ($limitZeroAsAll && $limit === 0) {
103✔
UNCOV
2682
            $limit = null;
×
2683
        }
2684

2685
        if ($limit !== null) {
103✔
2686
            if (! $this->canLimitWhereUpdates) {
3✔
2687
                throw new DatabaseException('This driver does not allow LIMITs on UPDATE queries using WHERE.');
2✔
2688
            }
2689

2690
            $this->limit($limit);
3✔
2691
        }
2692

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

2695
        if (! $this->testMode) {
103✔
2696
            $this->resetWrite();
90✔
2697

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

2700
            if ($result !== false) {
90✔
2701
                // Clear our binds so we don't eat up memory
2702
                $this->binds = [];
87✔
2703

2704
                return true;
87✔
2705
            }
2706

2707
            return false;
3✔
2708
        }
2709

2710
        return true;
13✔
2711
    }
2712

2713
    /**
2714
     * Generates a platform-specific update string from the supplied data
2715
     *
2716
     * @param string                $table  Protected table name
2717
     * @param array<string, string> $values QBSet
2718
     */
2719
    protected function _update(string $table, array $values): string
2720
    {
2721
        $valStr = [];
122✔
2722

2723
        foreach ($values as $key => $val) {
122✔
2724
            $valStr[] = $key . ' = ' . $val;
122✔
2725
        }
2726

2727
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
122✔
2728
        if ($limitZeroAsAll) {
122✔
2729
            return 'UPDATE ' . $this->compileIgnore('update') . $table . ' SET ' . implode(', ', $valStr)
122✔
2730
                . $this->compileWhereHaving('QBWhere')
122✔
2731
                . $this->compileOrderBy()
122✔
2732
                . ($this->QBLimit ? $this->_limit(' ', true) : '');
122✔
2733
        }
2734

UNCOV
2735
        return 'UPDATE ' . $this->compileIgnore('update') . $table . ' SET ' . implode(', ', $valStr)
×
UNCOV
2736
            . $this->compileWhereHaving('QBWhere')
×
UNCOV
2737
            . $this->compileOrderBy()
×
UNCOV
2738
            . ($this->QBLimit !== false ? $this->_limit(' ', true) : '');
×
2739
    }
2740

2741
    /**
2742
     * This method is used by both update() and getCompiledUpdate() to
2743
     * validate that data is actually being set and that a table has been
2744
     * chosen to be updated.
2745
     *
2746
     * @throws DatabaseException
2747
     */
2748
    protected function validateUpdate(): bool
2749
    {
2750
        if (empty($this->QBSet)) {
105✔
2751
            if ($this->db->DBDebug) {
1✔
2752
                throw new DatabaseException('You must use the "set" method to update an entry.');
1✔
2753
            }
2754

2755
            return false; // @codeCoverageIgnore
2756
        }
2757

2758
        return true;
104✔
2759
    }
2760

2761
    /**
2762
     * Sets data and calls batchExecute to run queries
2763
     *
2764
     * @param array|object|null        $set         a dataset
2765
     * @param array|RawSql|string|null $constraints
2766
     *
2767
     * @return false|int|list<string> Number of rows affected or FALSE on failure, SQL array when testMode
2768
     */
2769
    public function updateBatch($set = null, $constraints = null, int $batchSize = 100)
2770
    {
2771
        $this->onConstraint($constraints);
23✔
2772

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

2776
            if ($sql === '') {
1✔
2777
                return false; // @codeCoverageIgnore
2778
            }
2779

2780
            if ($this->testMode === false) {
1✔
2781
                $this->db->query($sql, null, false);
1✔
2782
            }
2783

2784
            $this->resetWrite();
1✔
2785

2786
            return $this->testMode ? $sql : $this->db->affectedRows();
1✔
2787
        }
2788

2789
        if ($set !== null && $set !== []) {
22✔
2790
            $this->setData($set, true);
16✔
2791
        }
2792

2793
        return $this->batchExecute('_updateBatch', $batchSize);
22✔
2794
    }
2795

2796
    /**
2797
     * Generates a platform-specific batch update string from the supplied data
2798
     *
2799
     * @used-by batchExecute()
2800
     *
2801
     * @param string                 $table  Protected table name
2802
     * @param list<string>           $keys   QBKeys
2803
     * @param list<list<int|string>> $values QBSet
2804
     */
2805
    protected function _updateBatch(string $table, array $keys, array $values): string
2806
    {
2807
        $sql = $this->QBOptions['sql'] ?? '';
19✔
2808

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

2813
            if ($constraints === []) {
19✔
2814
                if ($this->db->DBDebug) {
2✔
2815
                    throw new DatabaseException('You must specify a constraint to match on for batch updates.'); // @codeCoverageIgnore
2816
                }
2817

2818
                return ''; // @codeCoverageIgnore
2819
            }
2820

2821
            $updateFields = $this->QBOptions['updateFields'] ??
17✔
2822
                $this->updateFields($keys, false, $constraints)->QBOptions['updateFields'] ??
17✔
2823
                [];
14✔
2824

2825
            $alias = $this->QBOptions['alias'] ?? '_u';
17✔
2826

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

2829
            $sql .= "SET\n";
17✔
2830

2831
            $sql .= implode(
17✔
2832
                ",\n",
17✔
2833
                array_map(
17✔
2834
                    static fn ($key, $value): string => $key . ($value instanceof RawSql ?
17✔
2835
                        ' = ' . $value :
2✔
2836
                        ' = ' . $alias . '.' . $value),
17✔
2837
                    array_keys($updateFields),
17✔
2838
                    $updateFields,
17✔
2839
                ),
17✔
2840
            ) . "\n";
17✔
2841

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

2844
            $sql .= ') ' . $alias . "\n";
17✔
2845

2846
            $sql .= 'WHERE ' . implode(
17✔
2847
                ' AND ',
17✔
2848
                array_map(
17✔
2849
                    static fn ($key, $value) => (
17✔
2850
                        ($value instanceof RawSql && is_string($key))
17✔
2851
                        ?
17✔
2852
                        $table . '.' . $key . ' = ' . $value
1✔
2853
                        :
17✔
2854
                        (
17✔
2855
                            $value instanceof RawSql
16✔
2856
                            ?
16✔
2857
                            $value
3✔
2858
                            :
16✔
2859
                            $table . '.' . $value . ' = ' . $alias . '.' . $value
17✔
2860
                        )
17✔
2861
                    ),
17✔
2862
                    array_keys($constraints),
17✔
2863
                    $constraints,
17✔
2864
                ),
17✔
2865
            );
17✔
2866

2867
            $this->QBOptions['sql'] = $sql;
17✔
2868
        }
2869

2870
        if (isset($this->QBOptions['setQueryAsData'])) {
17✔
2871
            $data = $this->QBOptions['setQueryAsData'];
1✔
2872
        } else {
2873
            $data = implode(
16✔
2874
                " UNION ALL\n",
16✔
2875
                array_map(
16✔
2876
                    static fn ($value): string => 'SELECT ' . implode(', ', array_map(
16✔
2877
                        static fn ($key, $index): string => $index . ' ' . $key,
16✔
2878
                        $keys,
16✔
2879
                        $value,
16✔
2880
                    )),
16✔
2881
                    $values,
16✔
2882
                ),
16✔
2883
            ) . "\n";
16✔
2884
        }
2885

2886
        return str_replace('{:_table_:}', $data, $sql);
17✔
2887
    }
2888

2889
    /**
2890
     * Compiles a delete string and runs "DELETE FROM table"
2891
     *
2892
     * @return bool|string TRUE on success, FALSE on failure, string on testMode
2893
     */
2894
    public function emptyTable()
2895
    {
2896
        $table = $this->QBFrom[0];
4✔
2897

2898
        $sql = $this->_delete($table);
4✔
2899

2900
        if ($this->testMode) {
4✔
2901
            return $sql;
1✔
2902
        }
2903

2904
        $this->resetWrite();
3✔
2905

2906
        return $this->db->query($sql, null, false);
3✔
2907
    }
2908

2909
    /**
2910
     * Compiles a truncate string and runs the query
2911
     * If the database does not support the truncate() command
2912
     * This function maps to "DELETE FROM table"
2913
     *
2914
     * @return bool|string TRUE on success, FALSE on failure, string on testMode
2915
     */
2916
    public function truncate()
2917
    {
2918
        $table = $this->QBFrom[0];
765✔
2919

2920
        $sql = $this->_truncate($table);
765✔
2921

2922
        if ($this->testMode) {
765✔
2923
            return $sql;
2✔
2924
        }
2925

2926
        $this->resetWrite();
764✔
2927

2928
        return $this->db->query($sql, null, false);
764✔
2929
    }
2930

2931
    /**
2932
     * Generates a platform-specific truncate string from the supplied data
2933
     *
2934
     * If the database does not support the truncate() command,
2935
     * then this method maps to 'DELETE FROM table'
2936
     *
2937
     * @param string $table Protected table name
2938
     */
2939
    protected function _truncate(string $table): string
2940
    {
2941
        return 'TRUNCATE ' . $table;
750✔
2942
    }
2943

2944
    /**
2945
     * Compiles a delete query string and returns the sql
2946
     */
2947
    public function getCompiledDelete(bool $reset = true): string
2948
    {
2949
        $sql = $this->testMode()->delete('', null, $reset);
3✔
2950
        $this->testMode(false);
3✔
2951

2952
        return $this->compileFinalQuery($sql);
3✔
2953
    }
2954

2955
    /**
2956
     * Compiles a delete string and runs the query
2957
     *
2958
     * @param array|RawSql|string $where
2959
     *
2960
     * @return bool|string Returns a SQL string if in test mode.
2961
     *
2962
     * @throws DatabaseException
2963
     */
2964
    public function delete($where = '', ?int $limit = null, bool $resetData = true)
2965
    {
2966
        $table = $this->db->protectIdentifiers($this->QBFrom[0], true, null, false);
804✔
2967

2968
        if ($where !== '') {
804✔
2969
            $this->where($where);
4✔
2970
        }
2971

2972
        if (empty($this->QBWhere)) {
804✔
2973
            if ($this->db->DBDebug) {
2✔
2974
                throw new DatabaseException('Deletes are not allowed unless they contain a "where" or "like" clause.');
2✔
2975
            }
2976

2977
            return false; // @codeCoverageIgnore
2978
        }
2979

2980
        $sql = $this->_delete($this->removeAlias($table));
804✔
2981

2982
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
804✔
2983
        if ($limitZeroAsAll && $limit === 0) {
804✔
UNCOV
2984
            $limit = null;
×
2985
        }
2986

2987
        if ($limit !== null) {
804✔
2988
            $this->QBLimit = $limit;
1✔
2989
        }
2990

2991
        if (! empty($this->QBLimit)) {
804✔
2992
            if (! $this->canLimitDeletes) {
2✔
2993
                throw new DatabaseException('SQLite3 does not allow LIMITs on DELETE queries.');
1✔
2994
            }
2995

2996
            $sql = $this->_limit($sql, true);
2✔
2997
        }
2998

2999
        if ($resetData) {
804✔
3000
            $this->resetWrite();
804✔
3001
        }
3002

3003
        return $this->testMode ? $sql : $this->db->query($sql, $this->binds, false);
804✔
3004
    }
3005

3006
    /**
3007
     * Sets data and calls batchExecute to run queries
3008
     *
3009
     * @param array|object|null $set         a dataset
3010
     * @param array|RawSql|null $constraints
3011
     *
3012
     * @return false|int|list<string> Number of rows affected or FALSE on failure, SQL array when testMode
3013
     */
3014
    public function deleteBatch($set = null, $constraints = null, int $batchSize = 100)
3015
    {
3016
        $this->onConstraint($constraints);
3✔
3017

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

3021
            if ($sql === '') {
1✔
3022
                return false; // @codeCoverageIgnore
3023
            }
3024

3025
            if ($this->testMode === false) {
1✔
3026
                $this->db->query($sql, null, false);
1✔
3027
            }
3028

3029
            $this->resetWrite();
1✔
3030

3031
            return $this->testMode ? $sql : $this->db->affectedRows();
1✔
3032
        }
3033

3034
        if ($set !== null && $set !== []) {
2✔
UNCOV
3035
            $this->setData($set, true);
×
3036
        }
3037

3038
        return $this->batchExecute('_deleteBatch', $batchSize);
2✔
3039
    }
3040

3041
    /**
3042
     * Generates a platform-specific batch update string from the supplied data
3043
     *
3044
     * @used-by batchExecute()
3045
     *
3046
     * @param string           $table  Protected table name
3047
     * @param list<string>     $keys   QBKeys
3048
     * @param list<int|string> $values QBSet
3049
     */
3050
    protected function _deleteBatch(string $table, array $keys, array $values): string
3051
    {
3052
        $sql = $this->QBOptions['sql'] ?? '';
3✔
3053

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

3058
            if ($constraints === []) {
3✔
UNCOV
3059
                if ($this->db->DBDebug) {
×
3060
                    throw new DatabaseException('You must specify a constraint to match on for batch deletes.'); // @codeCoverageIgnore
3061
                }
3062

3063
                return ''; // @codeCoverageIgnore
3064
            }
3065

3066
            $alias = $this->QBOptions['alias'] ?? '_u';
3✔
3067

3068
            $sql = 'DELETE ' . $table . ' FROM ' . $table . "\n";
3✔
3069

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

3072
            $sql .= ') ' . $alias . "\n";
3✔
3073

3074
            $sql .= 'ON ' . implode(
3✔
3075
                ' AND ',
3✔
3076
                array_map(
3✔
3077
                    static fn ($key, $value) => (
3✔
3078
                        $value instanceof RawSql ?
3✔
UNCOV
3079
                        $value :
×
3080
                        (
3✔
3081
                            is_string($key) ?
3✔
3082
                            $table . '.' . $key . ' = ' . $alias . '.' . $value :
2✔
3083
                            $table . '.' . $value . ' = ' . $alias . '.' . $value
3✔
3084
                        )
3✔
3085
                    ),
3✔
3086
                    array_keys($constraints),
3✔
3087
                    $constraints,
3✔
3088
                ),
3✔
3089
            );
3✔
3090

3091
            // convert binds in where
3092
            foreach ($this->QBWhere as $key => $where) {
3✔
3093
                foreach ($this->binds as $field => $bind) {
2✔
UNCOV
3094
                    $this->QBWhere[$key]['condition'] = str_replace(':' . $field . ':', $bind[0], $where['condition']);
×
3095
                }
3096
            }
3097

3098
            $sql .= ' ' . $this->compileWhereHaving('QBWhere');
3✔
3099

3100
            $this->QBOptions['sql'] = trim($sql);
3✔
3101
        }
3102

3103
        if (isset($this->QBOptions['setQueryAsData'])) {
3✔
3104
            $data = $this->QBOptions['setQueryAsData'];
1✔
3105
        } else {
3106
            $data = implode(
2✔
3107
                " UNION ALL\n",
2✔
3108
                array_map(
2✔
3109
                    static fn ($value): string => 'SELECT ' . implode(', ', array_map(
2✔
3110
                        static fn ($key, $index): string => $index . ' ' . $key,
2✔
3111
                        $keys,
2✔
3112
                        $value,
2✔
3113
                    )),
2✔
3114
                    $values,
2✔
3115
                ),
2✔
3116
            ) . "\n";
2✔
3117
        }
3118

3119
        return str_replace('{:_table_:}', $data, $sql);
3✔
3120
    }
3121

3122
    /**
3123
     * Increments a numeric column by the specified value.
3124
     *
3125
     * @return bool
3126
     */
3127
    public function increment(string $column, int $value = 1)
3128
    {
3129
        return $this->incrementMany([$column], $value);
6✔
3130
    }
3131

3132
    /**
3133
     * Increments multiple numeric columns by the specified value(s).
3134
     *
3135
     * @param array<string, int>|list<string> $columns A list of columns or array of column => value pairs to increment.
3136
     * @param int                             $value   The value to increment by if $columns is a list of column names.
3137
     */
3138
    public function incrementMany(array $columns, int $value = 1): bool
3139
    {
3140
        if ($columns === []) {
11✔
3141
            throw new InvalidArgumentException('Argument #1 ($columns) cannot be empty.');
1✔
3142
        }
3143

3144
        if (array_is_list($columns)) {
10✔
3145
            $columns = array_fill_keys($columns, $value);
7✔
3146
        }
3147

3148
        $fields = [];
10✔
3149

3150
        foreach ($columns as $col => $val) {
10✔
3151
            if (! is_int($val)) {
10✔
3152
                throw new TypeError(sprintf(
1✔
3153
                    'Argument #1 ($columns) must contain only int values, %s given for "%s".',
1✔
3154
                    get_debug_type($val),
1✔
3155
                    $col,
1✔
3156
                ));
1✔
3157
            }
3158

3159
            $col          = $this->db->protectIdentifiers($col);
10✔
3160
            $fields[$col] = "{$col} + {$val}";
10✔
3161
        }
3162

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

3165
        if (! $this->testMode) {
9✔
3166
            $this->resetWrite();
9✔
3167

3168
            return $this->db->query($sql, $this->binds, false);
9✔
3169
        }
3170

UNCOV
3171
        return true;
×
3172
    }
3173

3174
    /**
3175
     * Decrements a numeric column by the specified value.
3176
     *
3177
     * @return bool
3178
     */
3179
    public function decrement(string $column, int $value = 1)
3180
    {
3181
        return $this->decrementMany([$column], $value);
6✔
3182
    }
3183

3184
    /**
3185
     * Decrements multiple numeric columns by the specified value(s).
3186
     *
3187
     * @param array<string, int>|list<string> $columns A list of columns or array of column => value pairs to decrement.
3188
     * @param int                             $value   The value to decrement by if $columns is a list of column names.
3189
     */
3190
    public function decrementMany(array $columns, int $value = 1): bool
3191
    {
3192
        if ($columns === []) {
11✔
3193
            throw new InvalidArgumentException('Argument #1 ($columns) cannot be empty.');
1✔
3194
        }
3195

3196
        if (array_is_list($columns)) {
10✔
3197
            $columns = array_fill_keys($columns, $value);
7✔
3198
        }
3199

3200
        $fields = [];
10✔
3201

3202
        foreach ($columns as $col => $val) {
10✔
3203
            if (! is_int($val)) {
10✔
3204
                throw new TypeError(sprintf(
1✔
3205
                    'Argument #1 ($columns) must contain only int values, %s given for "%s".',
1✔
3206
                    get_debug_type($val),
1✔
3207
                    $col,
1✔
3208
                ));
1✔
3209
            }
3210

3211
            $col          = $this->db->protectIdentifiers($col);
10✔
3212
            $fields[$col] = "{$col} - {$val}";
10✔
3213
        }
3214

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

3217
        if (! $this->testMode) {
9✔
3218
            $this->resetWrite();
9✔
3219

3220
            return $this->db->query($sql, $this->binds, false);
9✔
3221
        }
3222

UNCOV
3223
        return true;
×
3224
    }
3225

3226
    /**
3227
     * Generates a platform-specific delete string from the supplied data
3228
     *
3229
     * @param string $table Protected table name
3230
     */
3231
    protected function _delete(string $table): string
3232
    {
3233
        return 'DELETE ' . $this->compileIgnore('delete') . 'FROM ' . $table . $this->compileWhereHaving('QBWhere');
807✔
3234
    }
3235

3236
    /**
3237
     * Used to track SQL statements written with aliased tables.
3238
     *
3239
     * @param array|string $table The table to inspect
3240
     *
3241
     * @return string|null
3242
     */
3243
    protected function trackAliases($table)
3244
    {
3245
        if (is_array($table)) {
1,183✔
UNCOV
3246
            foreach ($table as $t) {
×
UNCOV
3247
                $this->trackAliases($t);
×
3248
            }
3249

UNCOV
3250
            return null;
×
3251
        }
3252

3253
        // Does the string contain a comma?  If so, we need to separate
3254
        // the string into discreet statements
3255
        if (str_contains($table, ',')) {
1,183✔
UNCOV
3256
            return $this->trackAliases(explode(',', $table));
×
3257
        }
3258

3259
        // if a table alias is used we can recognize it by a space
3260
        if (str_contains($table, ' ')) {
1,183✔
3261
            // if the alias is written with the AS keyword, remove it
3262
            $table = preg_replace('/\s+AS\s+/i', ' ', $table);
19✔
3263

3264
            // Grab the alias
3265
            $alias = trim(strrchr($table, ' '));
19✔
3266

3267
            // Store the alias, if it doesn't already exist
3268
            $this->db->addTableAlias($alias);
19✔
3269
        }
3270

3271
        return null;
1,183✔
3272
    }
3273

3274
    /**
3275
     * Compile the SELECT statement
3276
     *
3277
     * Generates a query string based on which functions were used.
3278
     * Should not be called directly.
3279
     *
3280
     * @param mixed $selectOverride
3281
     */
3282
    protected function compileSelect($selectOverride = false): string
3283
    {
3284
        if ($selectOverride !== false) {
1,100✔
3285
            $sql = $selectOverride;
228✔
3286
        } else {
3287
            $sql = $this->QBDistinct ? 'SELECT DISTINCT ' : 'SELECT ';
1,097✔
3288

3289
            if (empty($this->QBSelect)) {
1,097✔
3290
                $sql .= '*';
986✔
3291
            } else {
3292
                // Cycle through the "select" portion of the query and prep each column name.
3293
                // The reason we protect identifiers here rather than in the select() function
3294
                // is because until the user calls the from() function we don't know if there are aliases
3295
                foreach ($this->QBSelect as $key => $val) {
942✔
3296
                    if ($val instanceof RawSql) {
942✔
3297
                        $this->QBSelect[$key] = (string) $val;
5✔
3298
                    } else {
3299
                        $protect              = $this->QBNoEscape[$key] ?? null;
940✔
3300
                        $this->QBSelect[$key] = $this->db->protectIdentifiers($val, false, $protect);
940✔
3301
                    }
3302
                }
3303

3304
                $sql .= implode(', ', $this->QBSelect);
942✔
3305
            }
3306
        }
3307

3308
        if (! empty($this->QBFrom)) {
1,100✔
3309
            $sql .= "\nFROM " . $this->_fromTables();
1,100✔
3310
        }
3311

3312
        if (! empty($this->QBJoin)) {
1,100✔
3313
            $sql .= "\n" . implode("\n", $this->QBJoin);
15✔
3314
        }
3315

3316
        $sql .= $this->compileWhereHaving('QBWhere')
1,100✔
3317
            . $this->compileGroupBy()
1,100✔
3318
            . $this->compileWhereHaving('QBHaving')
1,100✔
3319
            . $this->compileOrderBy();
1,100✔
3320

3321
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
1,100✔
3322
        if ($limitZeroAsAll) {
1,100✔
3323
            if ($this->QBLimit) {
1,099✔
3324
                $sql = $this->_limit($sql . "\n");
107✔
3325
            }
3326
        } elseif ($this->QBLimit !== false || $this->QBOffset) {
2✔
3327
            $sql = $this->_limit($sql . "\n");
2✔
3328
        }
3329

3330
        $sql .= $this->compileLockForUpdate();
1,100✔
3331

3332
        return $this->unionInjection($sql);
1,090✔
3333
    }
3334

3335
    /**
3336
     * Compile the SELECT lock clause.
3337
     */
3338
    protected function compileLockForUpdate(): string
3339
    {
3340
        if ($this->QBLockForUpdate && $this->QBUnion !== []) {
219✔
3341
            throw new DatabaseException('Query Builder does not support lockForUpdate() with union() or unionAll().');
1✔
3342
        }
3343

3344
        return $this->QBLockForUpdate ? "\nFOR UPDATE" : '';
219✔
3345
    }
3346

3347
    /**
3348
     * Checks if the ignore option is supported by
3349
     * the Database Driver for the specific statement.
3350
     *
3351
     * @return string
3352
     */
3353
    protected function compileIgnore(string $statement)
3354
    {
3355
        if ($this->QBIgnore && isset($this->supportedIgnoreStatements[$statement])) {
868✔
3356
            return trim($this->supportedIgnoreStatements[$statement]) . ' ';
1✔
3357
        }
3358

3359
        return '';
867✔
3360
    }
3361

3362
    /**
3363
     * Escapes identifiers in WHERE and HAVING statements at execution time.
3364
     *
3365
     * Required so that aliases are tracked properly, regardless of whether
3366
     * where(), orWhere(), having(), orHaving are called prior to from(),
3367
     * join() and prefixTable is added only if needed.
3368
     *
3369
     * @param string $qbKey 'QBWhere' or 'QBHaving'
3370
     *
3371
     * @return string SQL statement
3372
     */
3373
    protected function compileWhereHaving(string $qbKey): string
3374
    {
3375
        if (! empty($this->{$qbKey})) {
1,134✔
3376
            foreach ($this->{$qbKey} as &$qbkey) {
993✔
3377
                // Is this condition already compiled?
3378
                if (is_string($qbkey)) {
993✔
3379
                    continue;
25✔
3380
                }
3381

3382
                if ($qbkey instanceof RawSql) {
993✔
3383
                    continue;
2✔
3384
                }
3385

3386
                if ($qbkey['condition'] instanceof RawSql) {
993✔
3387
                    $qbkey = $qbkey['condition'];
4✔
3388

3389
                    continue;
4✔
3390
                }
3391

3392
                if (($qbkey['columnComparison'] ?? false) === true) {
991✔
3393
                    $qbkey = $this->compileColumnComparison($qbkey);
22✔
3394

3395
                    continue;
22✔
3396
                }
3397

3398
                if ($qbkey['escape'] === false) {
978✔
3399
                    $qbkey = $qbkey['condition'];
117✔
3400

3401
                    continue;
117✔
3402
                }
3403

3404
                // Split multiple conditions
3405
                $conditions = preg_split(
965✔
3406
                    '/((?:^|\s+)AND\s+|(?:^|\s+)OR\s+)/i',
965✔
3407
                    $qbkey['condition'],
965✔
3408
                    -1,
965✔
3409
                    PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY,
965✔
3410
                );
965✔
3411

3412
                foreach ($conditions as &$condition) {
965✔
3413
                    $op = $this->getOperator($condition);
965✔
3414
                    if (
3415
                        $op === false
965✔
3416
                        || preg_match(
965✔
3417
                            '/^(\(?)(.*)(' . preg_quote($op, '/') . ')\s*(.*(?<!\)))?(\)?)$/i',
965✔
3418
                            $condition,
965✔
3419
                            $matches,
965✔
3420
                        ) !== 1
965✔
3421
                    ) {
3422
                        continue;
850✔
3423
                    }
3424

3425
                    // $matches = [
3426
                    //  0 => '(test <= foo)',   /* the whole thing */
3427
                    //  1 => '(',               /* optional */
3428
                    //  2 => 'test',            /* the field name */
3429
                    //  3 => ' <= ',            /* $op */
3430
                    //  4 => 'foo',                    /* optional, if $op is e.g. 'IS NULL' */
3431
                    //  5 => ')'                /* optional */
3432
                    // ];
3433

3434
                    if ($matches[4] !== '') {
965✔
3435
                        $protectIdentifiers = false;
928✔
3436
                        if (str_contains($matches[4], '.')) {
928✔
3437
                            $protectIdentifiers = true;
87✔
3438
                        }
3439

3440
                        if (! str_contains($matches[4], ':')) {
928✔
3441
                            $matches[4] = $this->db->protectIdentifiers(trim($matches[4]), false, $protectIdentifiers);
14✔
3442
                        }
3443

3444
                        $matches[4] = ' ' . $matches[4];
928✔
3445
                    }
3446

3447
                    $condition = $matches[1] . $this->db->protectIdentifiers(trim($matches[2]))
965✔
3448
                        . ' ' . trim($matches[3]) . $matches[4] . $matches[5];
965✔
3449
                }
3450

3451
                $qbkey = implode('', $conditions);
965✔
3452
            }
3453

3454
            return ($qbKey === 'QBHaving' ? "\nHAVING " : "\nWHERE ")
993✔
3455
                . implode("\n", $this->{$qbKey});
993✔
3456
        }
3457

3458
        return '';
1,116✔
3459
    }
3460

3461
    /**
3462
     * @used-by compileWhereHaving()
3463
     *
3464
     * @param array{columnComparison: true, condition: string, escape: bool, first: string, operator: string, second: string} $condition
3465
     */
3466
    private function compileColumnComparison(array $condition): string
3467
    {
3468
        if ($condition['escape']) {
22✔
3469
            $condition['first']  = $this->db->protectIdentifiers($condition['first'], false, true);
20✔
3470
            $condition['second'] = $this->db->protectIdentifiers($condition['second'], false, true);
20✔
3471
        }
3472

3473
        return $condition['condition'] . $condition['first'] . ' ' . $condition['operator'] . ' ' . $condition['second'];
22✔
3474
    }
3475

3476
    /**
3477
     * Escapes identifiers in GROUP BY statements at execution time.
3478
     *
3479
     * Required so that aliases are tracked properly, regardless of whether
3480
     * groupBy() is called prior to from(), join() and prefixTable is added
3481
     * only if needed.
3482
     */
3483
    protected function compileGroupBy(): string
3484
    {
3485
        if (! empty($this->QBGroupBy)) {
1,117✔
3486
            foreach ($this->QBGroupBy as &$groupBy) {
58✔
3487
                // Is it already compiled?
3488
                if (is_string($groupBy)) {
58✔
3489
                    continue;
2✔
3490
                }
3491

3492
                $groupBy = ($groupBy['escape'] === false || $this->isLiteral($groupBy['field']))
58✔
UNCOV
3493
                    ? $groupBy['field']
×
3494
                    : $this->db->protectIdentifiers($groupBy['field']);
58✔
3495
            }
3496

3497
            return "\nGROUP BY " . implode(', ', $this->QBGroupBy);
58✔
3498
        }
3499

3500
        return '';
1,089✔
3501
    }
3502

3503
    /**
3504
     * Escapes identifiers in ORDER BY statements at execution time.
3505
     *
3506
     * Required so that aliases are tracked properly, regardless of whether
3507
     * orderBy() is called prior to from(), join() and prefixTable is added
3508
     * only if needed.
3509
     */
3510
    protected function compileOrderBy(): string
3511
    {
3512
        if (is_array($this->QBOrderBy) && $this->QBOrderBy !== []) {
1,129✔
3513
            foreach ($this->QBOrderBy as &$orderBy) {
846✔
3514
                if (is_string($orderBy)) {
846✔
3515
                    continue;
1✔
3516
                }
3517
                if ($orderBy['escape'] !== false && ! $this->isLiteral($orderBy['field'])) {
846✔
3518
                    $orderBy['field'] = $this->db->protectIdentifiers($orderBy['field']);
844✔
3519
                }
3520

3521
                $orderBy = $orderBy['field'] . $orderBy['direction'];
846✔
3522
            }
3523

3524
            return "\nORDER BY " . implode(', ', $this->QBOrderBy);
846✔
3525
        }
3526

3527
        return '';
1,100✔
3528
    }
3529

3530
    protected function unionInjection(string $sql): string
3531
    {
3532
        if ($this->QBUnion === []) {
1,106✔
3533
            return $sql;
1,106✔
3534
        }
3535

3536
        return 'SELECT * FROM (' . $sql . ') '
6✔
3537
            . ($this->db->protectIdentifiers ? $this->db->escapeIdentifiers('uwrp0') : 'uwrp0')
6✔
3538
            . implode("\n", $this->QBUnion);
6✔
3539
    }
3540

3541
    /**
3542
     * Takes an object as input and converts the class variables to array key/vals
3543
     *
3544
     * @param array|object $object
3545
     *
3546
     * @return array
3547
     */
3548
    protected function objectToArray($object)
3549
    {
3550
        if (! is_object($object)) {
867✔
3551
            return $object;
864✔
3552
        }
3553

3554
        if ($object instanceof RawSql) {
8✔
3555
            throw new InvalidArgumentException('RawSql "' . $object . '" cannot be used here.');
1✔
3556
        }
3557

3558
        $array = [];
7✔
3559

3560
        foreach (get_object_vars($object) as $key => $val) {
7✔
3561
            if ((! is_object($val) || $val instanceof RawSql) && ! is_array($val)) {
7✔
3562
                $array[$key] = $val;
7✔
3563
            }
3564
        }
3565

3566
        return $array;
7✔
3567
    }
3568

3569
    /**
3570
     * Takes an object as input and converts the class variables to array key/vals
3571
     *
3572
     * @param array|object $object
3573
     *
3574
     * @return array
3575
     */
3576
    protected function batchObjectToArray($object)
3577
    {
3578
        if (! is_object($object)) {
68✔
3579
            return $object;
68✔
3580
        }
3581

UNCOV
3582
        $array  = [];
×
UNCOV
3583
        $out    = get_object_vars($object);
×
UNCOV
3584
        $fields = array_keys($out);
×
3585

UNCOV
3586
        foreach ($fields as $val) {
×
UNCOV
3587
            $i = 0;
×
3588

UNCOV
3589
            foreach ($out[$val] as $data) {
×
UNCOV
3590
                $array[$i++][$val] = $data;
×
3591
            }
3592
        }
3593

UNCOV
3594
        return $array;
×
3595
    }
3596

3597
    /**
3598
     * Determines if a string represents a literal value or a field name
3599
     */
3600
    protected function isLiteral(string $str): bool
3601
    {
3602
        $str = trim($str);
876✔
3603

3604
        if ($str === ''
876✔
3605
            || ctype_digit($str)
876✔
3606
            || (string) (float) $str === $str
876✔
3607
            || in_array(strtoupper($str), ['TRUE', 'FALSE'], true)
876✔
3608
        ) {
UNCOV
3609
            return true;
×
3610
        }
3611

3612
        if ($this->isLiteralStr === []) {
876✔
3613
            $this->isLiteralStr = $this->db->escapeChar !== '"' ? ['"', "'"] : ["'"];
876✔
3614
        }
3615

3616
        return in_array($str[0], $this->isLiteralStr, true);
876✔
3617
    }
3618

3619
    /**
3620
     * Publicly-visible method to reset the QB values.
3621
     *
3622
     * @return $this
3623
     */
3624
    public function resetQuery()
3625
    {
3626
        $this->resetSelect();
1✔
3627
        $this->resetWrite();
1✔
3628

3629
        return $this;
1✔
3630
    }
3631

3632
    /**
3633
     * Resets the query builder values.  Called by the get() function
3634
     *
3635
     * @param array $qbResetItems An array of fields to reset
3636
     *
3637
     * @return void
3638
     */
3639
    protected function resetRun(array $qbResetItems)
3640
    {
3641
        foreach ($qbResetItems as $item => $defaultValue) {
1,121✔
3642
            $this->{$item} = $defaultValue;
1,121✔
3643
        }
3644
    }
3645

3646
    /**
3647
     * Resets the query builder values.  Called by the get() function
3648
     *
3649
     * @return void
3650
     */
3651
    protected function resetSelect()
3652
    {
3653
        $this->resetRun([
1,094✔
3654
            'QBSelect'              => [],
1,094✔
3655
            'QBJoin'                => [],
1,094✔
3656
            'QBWhere'               => [],
1,094✔
3657
            'QBGroupBy'             => [],
1,094✔
3658
            'QBHaving'              => [],
1,094✔
3659
            'QBOrderBy'             => [],
1,094✔
3660
            'QBNoEscape'            => [],
1,094✔
3661
            'QBDistinct'            => false,
1,094✔
3662
            'QBLimit'               => false,
1,094✔
3663
            'QBOffset'              => false,
1,094✔
3664
            'QBLockForUpdate'       => false,
1,094✔
3665
            'QBSelectUsesAggregate' => false,
1,094✔
3666
            'QBUnion'               => [],
1,094✔
3667
        ]);
1,094✔
3668

3669
        if ($this->db instanceof BaseConnection) {
1,094✔
3670
            $this->db->setAliasedTables([]);
1,094✔
3671
        }
3672

3673
        // Reset QBFrom part
3674
        if (! empty($this->QBFrom)) {
1,094✔
3675
            $this->from(array_shift($this->QBFrom), true);
1,094✔
3676
        }
3677
    }
3678

3679
    /**
3680
     * Resets the query builder "write" values.
3681
     *
3682
     * Called by the insert() update() insertBatch() updateBatch() and delete() functions
3683
     *
3684
     * @return void
3685
     */
3686
    protected function resetWrite()
3687
    {
3688
        $this->resetRun([
874✔
3689
            'QBSet'     => [],
874✔
3690
            'QBJoin'    => [],
874✔
3691
            'QBWhere'   => [],
874✔
3692
            'QBOrderBy' => [],
874✔
3693
            'QBKeys'    => [],
874✔
3694
            'QBLimit'   => false,
874✔
3695
            'QBIgnore'  => false,
874✔
3696
            'QBOptions' => [],
874✔
3697
        ]);
874✔
3698
    }
3699

3700
    /**
3701
     * Tests whether the string has an SQL operator
3702
     */
3703
    protected function hasOperator(string $str): bool
3704
    {
3705
        return preg_match(
158✔
3706
            '/(<|>|!|=|\sIS NULL|\sIS NOT NULL|\sEXISTS|\sBETWEEN|\sLIKE|\sIN\s*\(|\s)/i',
158✔
3707
            trim($str),
158✔
3708
        ) === 1;
158✔
3709
    }
3710

3711
    /**
3712
     * Returns the SQL string operator
3713
     *
3714
     * @return array|false|string
3715
     */
3716
    protected function getOperator(string $str, bool $list = false)
3717
    {
3718
        if ($this->pregOperators === []) {
975✔
3719
            $_les = $this->db->likeEscapeStr !== ''
975✔
3720
                ? '\s+' . preg_quote(trim(sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar)), '/')
975✔
UNCOV
3721
                : '';
×
3722
            $this->pregOperators = [
975✔
3723
                '\s*(?:<|>|!)?=\s*', // =, <=, >=, !=
975✔
3724
                '\s*<>?\s*',         // <, <>
975✔
3725
                '\s*>\s*',           // >
975✔
3726
                '\s+IS NULL',             // IS NULL
975✔
3727
                '\s+IS NOT NULL',         // IS NOT NULL
975✔
3728
                '\s+EXISTS\s*\(.*\)',     // EXISTS (sql)
975✔
3729
                '\s+NOT EXISTS\s*\(.*\)', // NOT EXISTS(sql)
975✔
3730
                '\s+BETWEEN\s+',          // BETWEEN value AND value
975✔
3731
                '\s+IN\s*\(.*\)',         // IN (list)
975✔
3732
                '\s+NOT IN\s*\(.*\)',     // NOT IN (list)
975✔
3733
                '\s+LIKE\s+\S.*(' . $_les . ')?',     // LIKE 'expr'[ ESCAPE '%s']
975✔
3734
                '\s+NOT LIKE\s+\S.*(' . $_les . ')?', // NOT LIKE 'expr'[ ESCAPE '%s']
975✔
3735
            ];
975✔
3736
        }
3737

3738
        return preg_match_all(
975✔
3739
            '/' . implode('|', $this->pregOperators) . '/i',
975✔
3740
            $str,
975✔
3741
            $match,
975✔
3742
        ) >= 1 ? ($list ? $match[0] : $match[0][0]) : false;
975✔
3743
    }
3744

3745
    /**
3746
     * Returns the SQL string operator from where key
3747
     *
3748
     * @return false|list<string>
3749
     */
3750
    private function getOperatorFromWhereKey(string $whereKey)
3751
    {
3752
        $whereKey = trim($whereKey);
929✔
3753

3754
        $pregOperators = [
929✔
3755
            '\s*(?:<|>|!)?=',         // =, <=, >=, !=
929✔
3756
            '\s*<>?',                 // <, <>
929✔
3757
            '\s*>',                   // >
929✔
3758
            '\s+IS NULL',             // IS NULL
929✔
3759
            '\s+IS NOT NULL',         // IS NOT NULL
929✔
3760
            '\s+EXISTS\s*\(.*\)',     // EXISTS (sql)
929✔
3761
            '\s+NOT EXISTS\s*\(.*\)', // NOT EXISTS (sql)
929✔
3762
            '\s+BETWEEN\s+',          // BETWEEN value AND value
929✔
3763
            '\s+IN\s*\(.*\)',         // IN (list)
929✔
3764
            '\s+NOT IN\s*\(.*\)',     // NOT IN (list)
929✔
3765
            '\s+LIKE',                // LIKE
929✔
3766
            '\s+NOT LIKE',            // NOT LIKE
929✔
3767
        ];
929✔
3768

3769
        return preg_match_all(
929✔
3770
            '/' . implode('|', $pregOperators) . '/i',
929✔
3771
            $whereKey,
929✔
3772
            $match,
929✔
3773
        ) >= 1 ? $match[0] : false;
929✔
3774
    }
3775

3776
    /**
3777
     * Stores a bind value after ensuring that it's unique.
3778
     * While it might be nicer to have named keys for our binds array
3779
     * with PHP 7+ we get a huge memory/performance gain with indexed
3780
     * arrays instead, so lets take advantage of that here.
3781
     *
3782
     * @param mixed $value
3783
     */
3784
    protected function setBind(string $key, $value = null, bool $escape = true): string
3785
    {
3786
        if (! array_key_exists($key, $this->binds)) {
976✔
3787
            $this->binds[$key] = [
976✔
3788
                $value,
976✔
3789
                $escape,
976✔
3790
            ];
976✔
3791

3792
            return $key;
976✔
3793
        }
3794

3795
        if (! array_key_exists($key, $this->bindsKeyCount)) {
45✔
3796
            $this->bindsKeyCount[$key] = 1;
45✔
3797
        }
3798

3799
        $count = $this->bindsKeyCount[$key]++;
45✔
3800

3801
        $this->binds[$key . '.' . $count] = [
45✔
3802
            $value,
45✔
3803
            $escape,
45✔
3804
        ];
45✔
3805

3806
        return $key . '.' . $count;
45✔
3807
    }
3808

3809
    /**
3810
     * @param mixed $value
3811
     */
3812
    protected function isSubquery($value): bool
3813
    {
3814
        return $value instanceof BaseBuilder || $value instanceof Closure;
944✔
3815
    }
3816

3817
    /**
3818
     * @param BaseBuilder|Closure(BaseBuilder): BaseBuilder $builder
3819
     * @param bool                                          $wrapped Wrap the subquery in brackets
3820
     * @param string                                        $alias   Subquery alias
3821
     */
3822
    protected function buildSubquery($builder, bool $wrapped = false, string $alias = ''): string
3823
    {
3824
        if ($builder instanceof Closure) {
33✔
3825
            $builder($builder = $this->db->newQuery());
18✔
3826
        }
3827

3828
        if ($builder === $this) {
33✔
3829
            throw new DatabaseException('The subquery cannot be the same object as the main query object.');
2✔
3830
        }
3831

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

3834
        if ($wrapped) {
31✔
3835
            $subquery = '(' . $subquery . ')';
31✔
3836
            $alias    = trim($alias);
31✔
3837

3838
            if ($alias !== '') {
31✔
3839
                $subquery .= ' ' . ($this->db->protectIdentifiers ? $this->db->escapeIdentifiers($alias) : $alias);
15✔
3840
            }
3841
        }
3842

3843
        return $subquery;
31✔
3844
    }
3845
}
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