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

codeigniter4 / CodeIgniter4 / 7293561159

21 Dec 2023 09:55PM UTC coverage: 85.237% (+0.004%) from 85.233%
7293561159

push

github

web-flow
Merge pull request #8355 from paulbalandan/replace

Add `replace` to composer.json

18597 of 21818 relevant lines covered (85.24%)

199.84 hits per line

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

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

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

12
namespace CodeIgniter\Database;
13

14
use Closure;
15
use CodeIgniter\Database\Exceptions\DatabaseException;
16
use CodeIgniter\Database\Exceptions\DataException;
17
use CodeIgniter\Traits\ConditionalTrait;
18
use InvalidArgumentException;
19

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

31
    /**
32
     * Reset DELETE data flag
33
     *
34
     * @var bool
35
     */
36
    protected $resetDeleteData = false;
37

38
    /**
39
     * QB SELECT data
40
     *
41
     * @var array
42
     */
43
    protected $QBSelect = [];
44

45
    /**
46
     * QB DISTINCT flag
47
     *
48
     * @var bool
49
     */
50
    protected $QBDistinct = false;
51

52
    /**
53
     * QB FROM data
54
     *
55
     * @var array
56
     */
57
    protected $QBFrom = [];
58

59
    /**
60
     * QB JOIN data
61
     *
62
     * @var array
63
     */
64
    protected $QBJoin = [];
65

66
    /**
67
     * QB WHERE data
68
     *
69
     * @var array
70
     */
71
    protected $QBWhere = [];
72

73
    /**
74
     * QB GROUP BY data
75
     *
76
     * @var array
77
     */
78
    public $QBGroupBy = [];
79

80
    /**
81
     * QB HAVING data
82
     *
83
     * @var array
84
     */
85
    protected $QBHaving = [];
86

87
    /**
88
     * QB keys
89
     * list of column names.
90
     *
91
     * @var list<string>
92
     */
93
    protected $QBKeys = [];
94

95
    /**
96
     * QB LIMIT data
97
     *
98
     * @var bool|int
99
     */
100
    protected $QBLimit = false;
101

102
    /**
103
     * QB OFFSET data
104
     *
105
     * @var bool|int
106
     */
107
    protected $QBOffset = false;
108

109
    /**
110
     * QB ORDER BY data
111
     *
112
     * @var array|string|null
113
     */
114
    public $QBOrderBy = [];
115

116
    /**
117
     * QB UNION data
118
     *
119
     * @var array<string>
120
     */
121
    protected array $QBUnion = [];
122

123
    /**
124
     * QB NO ESCAPE data
125
     *
126
     * @var array
127
     */
128
    public $QBNoEscape = [];
129

130
    /**
131
     * QB data sets
132
     *
133
     * @var array<string, string>|list<list<int|string>>
134
     */
135
    protected $QBSet = [];
136

137
    /**
138
     * QB WHERE group started flag
139
     *
140
     * @var bool
141
     */
142
    protected $QBWhereGroupStarted = false;
143

144
    /**
145
     * QB WHERE group count
146
     *
147
     * @var int
148
     */
149
    protected $QBWhereGroupCount = 0;
150

151
    /**
152
     * Ignore data that cause certain
153
     * exceptions, for example in case of
154
     * duplicate keys.
155
     *
156
     * @var bool
157
     */
158
    protected $QBIgnore = false;
159

160
    /**
161
     * QB Options data
162
     * Holds additional options and data used to render SQL
163
     * and is reset by resetWrite()
164
     *
165
     * @var array{
166
     *   updateFieldsAdditional?: array,
167
     *   tableIdentity?: string,
168
     *   updateFields?: array,
169
     *   constraints?: array,
170
     *   setQueryAsData?: string,
171
     *   sql?: string,
172
     *   alias?: string
173
     * }
174
     */
175
    protected $QBOptions;
176

177
    /**
178
     * A reference to the database connection.
179
     *
180
     * @var BaseConnection
181
     */
182
    protected $db;
183

184
    /**
185
     * Name of the primary table for this instance.
186
     * Tracked separately because $QBFrom gets escaped
187
     * and prefixed.
188
     *
189
     * When $tableName to the constructor has multiple tables,
190
     * the value is empty string.
191
     *
192
     * @var string
193
     */
194
    protected $tableName;
195

196
    /**
197
     * ORDER BY random keyword
198
     *
199
     * @var array
200
     */
201
    protected $randomKeyword = [
202
        'RAND()',
203
        'RAND(%d)',
204
    ];
205

206
    /**
207
     * COUNT string
208
     *
209
     * @used-by CI_DB_driver::count_all()
210
     * @used-by BaseBuilder::count_all_results()
211
     *
212
     * @var string
213
     */
214
    protected $countString = 'SELECT COUNT(*) AS ';
215

216
    /**
217
     * Collects the named parameters and
218
     * their values for later binding
219
     * in the Query object.
220
     *
221
     * @var array
222
     */
223
    protected $binds = [];
224

225
    /**
226
     * Collects the key count for named parameters
227
     * in the Query object.
228
     *
229
     * @var array
230
     */
231
    protected $bindsKeyCount = [];
232

233
    /**
234
     * Some databases, like SQLite, do not by default
235
     * allow limiting of delete clauses.
236
     *
237
     * @var bool
238
     */
239
    protected $canLimitDeletes = true;
240

241
    /**
242
     * Some databases do not by default
243
     * allow limit update queries with WHERE.
244
     *
245
     * @var bool
246
     */
247
    protected $canLimitWhereUpdates = true;
248

249
    /**
250
     * Specifies which sql statements
251
     * support the ignore option.
252
     *
253
     * @var array
254
     */
255
    protected $supportedIgnoreStatements = [];
256

257
    /**
258
     * Builder testing mode status.
259
     *
260
     * @var bool
261
     */
262
    protected $testMode = false;
263

264
    /**
265
     * Tables relation types
266
     *
267
     * @var array
268
     */
269
    protected $joinTypes = [
270
        'LEFT',
271
        'RIGHT',
272
        'OUTER',
273
        'INNER',
274
        'LEFT OUTER',
275
        'RIGHT OUTER',
276
    ];
277

278
    /**
279
     * Strings that determine if a string represents a literal value or a field name
280
     *
281
     * @var string[]
282
     */
283
    protected $isLiteralStr = [];
284

285
    /**
286
     * RegExp used to get operators
287
     *
288
     * @var string[]
289
     */
290
    protected $pregOperators = [];
291

292
    /**
293
     * Constructor
294
     *
295
     * @param array|string $tableName tablename or tablenames with or without aliases
296
     *
297
     * Examples of $tableName: `mytable`, `jobs j`, `jobs j, users u`, `['jobs j','users u']`
298
     *
299
     * @throws DatabaseException
300
     */
301
    public function __construct($tableName, ConnectionInterface $db, ?array $options = null)
302
    {
303
        if (empty($tableName)) {
818✔
304
            throw new DatabaseException('A table must be specified when creating a new Query Builder.');
×
305
        }
306

307
        /**
308
         * @var BaseConnection $db
309
         */
310
        $this->db = $db;
818✔
311

312
        // If it contains `,`, it has multiple tables
313
        if (is_string($tableName) && strpos($tableName, ',') === false) {
818✔
314
            $this->tableName = $tableName;  // @TODO remove alias if exists
816✔
315
        } else {
316
            $this->tableName = '';
16✔
317
        }
318

319
        $this->from($tableName);
818✔
320

321
        if ($options !== null && $options !== []) {
818✔
322
            foreach ($options as $key => $value) {
×
323
                if (property_exists($this, $key)) {
×
324
                    $this->{$key} = $value;
×
325
                }
326
            }
327
        }
328
    }
329

330
    /**
331
     * Returns the current database connection
332
     *
333
     * @return BaseConnection|ConnectionInterface
334
     */
335
    public function db(): ConnectionInterface
336
    {
337
        return $this->db;
1✔
338
    }
339

340
    /**
341
     * Sets a test mode status.
342
     *
343
     * @return $this
344
     */
345
    public function testMode(bool $mode = true)
346
    {
347
        $this->testMode = $mode;
76✔
348

349
        return $this;
76✔
350
    }
351

352
    /**
353
     * Gets the name of the primary table.
354
     */
355
    public function getTable(): string
356
    {
357
        return $this->tableName;
5✔
358
    }
359

360
    /**
361
     * Returns an array of bind values and their
362
     * named parameters for binding in the Query object later.
363
     */
364
    public function getBinds(): array
365
    {
366
        return $this->binds;
44✔
367
    }
368

369
    /**
370
     * Ignore
371
     *
372
     * Set ignore Flag for next insert,
373
     * update or delete query.
374
     *
375
     * @return $this
376
     */
377
    public function ignore(bool $ignore = true)
378
    {
379
        $this->QBIgnore = $ignore;
1✔
380

381
        return $this;
1✔
382
    }
383

384
    /**
385
     * Generates the SELECT portion of the query
386
     *
387
     * @param array|RawSql|string $select
388
     *
389
     * @return $this
390
     */
391
    public function select($select = '*', ?bool $escape = null)
392
    {
393
        // If the escape value was not set, we will base it on the global setting
394
        if (! is_bool($escape)) {
639✔
395
            $escape = $this->db->protectIdentifiers;
636✔
396
        }
397

398
        if ($select instanceof RawSql) {
639✔
399
            $this->QBSelect[] = $select;
1✔
400

401
            return $this;
1✔
402
        }
403

404
        if (is_string($select)) {
638✔
405
            $select = $escape === false ? [$select] : explode(',', $select);
637✔
406
        }
407

408
        foreach ($select as $val) {
638✔
409
            $val = trim($val);
638✔
410

411
            if ($val !== '') {
638✔
412
                $this->QBSelect[] = $val;
638✔
413

414
                /*
415
                 * When doing 'SELECT NULL as field_alias FROM table'
416
                 * null gets taken as a field, and therefore escaped
417
                 * with backticks.
418
                 * This prevents NULL being escaped
419
                 * @see https://github.com/codeigniter4/CodeIgniter4/issues/1169
420
                 */
421
                if (mb_stripos(trim($val), 'NULL') === 0) {
638✔
422
                    $escape = false;
×
423
                }
424

425
                $this->QBNoEscape[] = $escape;
638✔
426
            }
427
        }
428

429
        return $this;
638✔
430
    }
431

432
    /**
433
     * Generates a SELECT MAX(field) portion of a query
434
     *
435
     * @return $this
436
     */
437
    public function selectMax(string $select = '', string $alias = '')
438
    {
439
        return $this->maxMinAvgSum($select, $alias);
577✔
440
    }
441

442
    /**
443
     * Generates a SELECT MIN(field) portion of a query
444
     *
445
     * @return $this
446
     */
447
    public function selectMin(string $select = '', string $alias = '')
448
    {
449
        return $this->maxMinAvgSum($select, $alias, 'MIN');
4✔
450
    }
451

452
    /**
453
     * Generates a SELECT AVG(field) portion of a query
454
     *
455
     * @return $this
456
     */
457
    public function selectAvg(string $select = '', string $alias = '')
458
    {
459
        return $this->maxMinAvgSum($select, $alias, 'AVG');
4✔
460
    }
461

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

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

482
    /**
483
     * Adds a subquery to the selection
484
     */
485
    public function selectSubquery(BaseBuilder $subquery, string $as): self
486
    {
487
        $this->QBSelect[] = $this->buildSubquery($subquery, true, $as);
2✔
488

489
        return $this;
2✔
490
    }
491

492
    /**
493
     * SELECT [MAX|MIN|AVG|SUM|COUNT]()
494
     *
495
     * @used-by selectMax()
496
     * @used-by selectMin()
497
     * @used-by selectAvg()
498
     * @used-by selectSum()
499
     *
500
     * @return $this
501
     *
502
     * @throws DatabaseException
503
     * @throws DataException
504
     */
505
    protected function maxMinAvgSum(string $select = '', string $alias = '', string $type = 'MAX')
506
    {
507
        if ($select === '') {
587✔
508
            throw DataException::forEmptyInputGiven('Select');
1✔
509
        }
510

511
        if (strpos($select, ',') !== false) {
586✔
512
            throw DataException::forInvalidArgument('column name not separated by comma');
1✔
513
        }
514

515
        $type = strtoupper($type);
585✔
516

517
        if (! in_array($type, ['MAX', 'MIN', 'AVG', 'SUM', 'COUNT'], true)) {
585✔
518
            throw new DatabaseException('Invalid function type: ' . $type);
×
519
        }
520

521
        if ($alias === '') {
585✔
522
            $alias = $this->createAliasFromTable(trim($select));
580✔
523
        }
524

525
        $sql = $type . '(' . $this->db->protectIdentifiers(trim($select)) . ') AS ' . $this->db->escapeIdentifiers(trim($alias));
585✔
526

527
        $this->QBSelect[]   = $sql;
585✔
528
        $this->QBNoEscape[] = null;
585✔
529

530
        return $this;
585✔
531
    }
532

533
    /**
534
     * Determines the alias name based on the table
535
     */
536
    protected function createAliasFromTable(string $item): string
537
    {
538
        if (strpos($item, '.') !== false) {
580✔
539
            $item = explode('.', $item);
1✔
540

541
            return end($item);
1✔
542
        }
543

544
        return $item;
579✔
545
    }
546

547
    /**
548
     * Sets a flag which tells the query string compiler to add DISTINCT
549
     *
550
     * @return $this
551
     */
552
    public function distinct(bool $val = true)
553
    {
554
        $this->QBDistinct = $val;
583✔
555

556
        return $this;
583✔
557
    }
558

559
    /**
560
     * Generates the FROM portion of the query
561
     *
562
     * @param array|string $from
563
     *
564
     * @return $this
565
     */
566
    public function from($from, bool $overwrite = false): self
567
    {
568
        if ($overwrite === true) {
818✔
569
            $this->QBFrom = [];
756✔
570
            $this->db->setAliasedTables([]);
756✔
571
        }
572

573
        foreach ((array) $from as $table) {
818✔
574
            if (strpos($table, ',') !== false) {
818✔
575
                $this->from(explode(',', $table));
17✔
576
            } else {
577
                $table = trim($table);
818✔
578

579
                if ($table === '') {
818✔
580
                    continue;
14✔
581
                }
582

583
                $this->trackAliases($table);
818✔
584
                $this->QBFrom[] = $this->db->protectIdentifiers($table, true, null, false);
818✔
585
            }
586
        }
587

588
        return $this;
818✔
589
    }
590

591
    /**
592
     * @param BaseBuilder $from  Expected subquery
593
     * @param string      $alias Subquery alias
594
     *
595
     * @return $this
596
     */
597
    public function fromSubquery(BaseBuilder $from, string $alias): self
598
    {
599
        $table = $this->buildSubquery($from, true, $alias);
5✔
600

601
        $this->db->addTableAlias($alias);
4✔
602
        $this->QBFrom[] = $table;
4✔
603

604
        return $this;
4✔
605
    }
606

607
    /**
608
     * Generates the JOIN portion of the query
609
     *
610
     * @param RawSql|string $cond
611
     *
612
     * @return $this
613
     */
614
    public function join(string $table, $cond, string $type = '', ?bool $escape = null)
615
    {
616
        if ($type !== '') {
14✔
617
            $type = strtoupper(trim($type));
7✔
618

619
            if (! in_array($type, $this->joinTypes, true)) {
7✔
620
                $type = '';
×
621
            } else {
622
                $type .= ' ';
7✔
623
            }
624
        }
625

626
        // Extract any aliases that might exist. We use this information
627
        // in the protectIdentifiers to know whether to add a table prefix
628
        $this->trackAliases($table);
14✔
629

630
        if (! is_bool($escape)) {
14✔
631
            $escape = $this->db->protectIdentifiers;
14✔
632
        }
633

634
        // Do we want to escape the table name?
635
        if ($escape === true) {
14✔
636
            $table = $this->db->protectIdentifiers($table, true, null, false);
14✔
637
        }
638

639
        if ($cond instanceof RawSql) {
14✔
640
            $this->QBJoin[] = $type . 'JOIN ' . $table . ' ON ' . $cond;
1✔
641

642
            return $this;
1✔
643
        }
644

645
        if (! $this->hasOperator($cond)) {
13✔
646
            $cond = ' USING (' . ($escape ? $this->db->escapeIdentifiers($cond) : $cond) . ')';
×
647
        } elseif ($escape === false) {
13✔
648
            $cond = ' ON ' . $cond;
×
649
        } else {
650
            // Split multiple conditions
651
            if (preg_match_all('/\sAND\s|\sOR\s/i', $cond, $joints, PREG_OFFSET_CAPTURE)) {
13✔
652
                $conditions = [];
1✔
653
                $joints     = $joints[0];
1✔
654
                array_unshift($joints, ['', 0]);
1✔
655

656
                for ($i = count($joints) - 1, $pos = strlen($cond); $i >= 0; $i--) {
1✔
657
                    $joints[$i][1] += strlen($joints[$i][0]); // offset
1✔
658
                    $conditions[$i] = substr($cond, $joints[$i][1], $pos - $joints[$i][1]);
1✔
659
                    $pos            = $joints[$i][1] - strlen($joints[$i][0]);
1✔
660
                    $joints[$i]     = $joints[$i][0];
1✔
661
                }
662
                ksort($conditions);
1✔
663
            } else {
664
                $conditions = [$cond];
12✔
665
                $joints     = [''];
12✔
666
            }
667

668
            $cond = ' ON ';
13✔
669

670
            foreach ($conditions as $i => $condition) {
13✔
671
                $operator = $this->getOperator($condition);
13✔
672

673
                $cond .= $joints[$i];
13✔
674
                $cond .= preg_match('/(\(*)?([\[\]\w\.\'-]+)' . preg_quote($operator, '/') . '(.*)/i', $condition, $match) ? $match[1] . $this->db->protectIdentifiers($match[2]) . $operator . $this->db->protectIdentifiers($match[3]) : $condition;
13✔
675
            }
676
        }
677

678
        // Assemble the JOIN statement
679
        $this->QBJoin[] = $type . 'JOIN ' . $table . $cond;
13✔
680

681
        return $this;
13✔
682
    }
683

684
    /**
685
     * Generates the WHERE portion of the query.
686
     * Separates multiple calls with 'AND'.
687
     *
688
     * @param array|RawSql|string $key
689
     * @param mixed               $value
690
     *
691
     * @return $this
692
     */
693
    public function where($key, $value = null, ?bool $escape = null)
694
    {
695
        return $this->whereHaving('QBWhere', $key, $value, 'AND ', $escape);
659✔
696
    }
697

698
    /**
699
     * OR WHERE
700
     *
701
     * Generates the WHERE portion of the query.
702
     * Separates multiple calls with 'OR'.
703
     *
704
     * @param array|RawSql|string $key
705
     * @param mixed               $value
706
     *
707
     * @return $this
708
     */
709
    public function orWhere($key, $value = null, ?bool $escape = null)
710
    {
711
        return $this->whereHaving('QBWhere', $key, $value, 'OR ', $escape);
5✔
712
    }
713

714
    /**
715
     * @used-by where()
716
     * @used-by orWhere()
717
     * @used-by having()
718
     * @used-by orHaving()
719
     *
720
     * @param array|RawSql|string $key
721
     * @param mixed               $value
722
     *
723
     * @return $this
724
     */
725
    protected function whereHaving(string $qbKey, $key, $value = null, string $type = 'AND ', ?bool $escape = null)
726
    {
727
        $rawSqlOnly = false;
665✔
728

729
        if ($key instanceof RawSql) {
665✔
730
            if ($value === null) {
4✔
731
                $keyValue   = [(string) $key => $key];
1✔
732
                $rawSqlOnly = true;
1✔
733
            } else {
734
                $keyValue = [(string) $key => $value];
4✔
735
            }
736
        } elseif (! is_array($key)) {
661✔
737
            $keyValue = [$key => $value];
651✔
738
        } else {
739
            $keyValue = $key;
159✔
740
        }
741

742
        // If the escape value was not set will base it on the global setting
743
        if (! is_bool($escape)) {
665✔
744
            $escape = $this->db->protectIdentifiers;
661✔
745
        }
746

747
        foreach ($keyValue as $k => $v) {
665✔
748
            $prefix = empty($this->{$qbKey}) ? $this->groupGetType('') : $this->groupGetType($type);
665✔
749

750
            if ($rawSqlOnly === true) {
665✔
751
                $k  = '';
1✔
752
                $op = '';
1✔
753
            } elseif ($v !== null) {
664✔
754
                $op = $this->getOperatorFromWhereKey($k);
657✔
755

756
                if (! empty($op)) {
657✔
757
                    $k = trim($k);
49✔
758

759
                    end($op);
49✔
760
                    $op = trim(current($op));
49✔
761

762
                    // Does the key end with operator?
763
                    if (substr($k, -strlen($op)) === $op) {
49✔
764
                        $k  = rtrim(substr($k, 0, -strlen($op)));
49✔
765
                        $op = " {$op}";
49✔
766
                    } else {
767
                        $op = '';
49✔
768
                    }
769
                } else {
770
                    $op = ' =';
645✔
771
                }
772

773
                if ($this->isSubquery($v)) {
657✔
774
                    $v = $this->buildSubquery($v, true);
1✔
775
                } else {
776
                    $bind = $this->setBind($k, $v, $escape);
657✔
777
                    $v    = " :{$bind}:";
657✔
778
                }
779
            } elseif (! $this->hasOperator($k) && $qbKey !== 'QBHaving') {
83✔
780
                // value appears not to have been set, assign the test to IS NULL
781
                $op = ' IS NULL';
50✔
782
            } elseif (
783
                // The key ends with !=, =, <>, IS, IS NOT
784
                preg_match(
39✔
785
                    '/\s*(!?=|<>|IS(?:\s+NOT)?)\s*$/i',
39✔
786
                    $k,
39✔
787
                    $match,
39✔
788
                    PREG_OFFSET_CAPTURE
39✔
789
                )
39✔
790
            ) {
791
                $k  = substr($k, 0, $match[0][1]);
1✔
792
                $op = $match[1][0] === '=' ? ' IS NULL' : ' IS NOT NULL';
1✔
793
            } else {
794
                $op = '';
38✔
795
            }
796

797
            if ($v instanceof RawSql) {
665✔
798
                $this->{$qbKey}[] = [
1✔
799
                    'condition' => $v->with($prefix . $k . $op . $v),
1✔
800
                    'escape'    => $escape,
1✔
801
                ];
1✔
802
            } else {
803
                $this->{$qbKey}[] = [
664✔
804
                    'condition' => $prefix . $k . $op . $v,
664✔
805
                    'escape'    => $escape,
664✔
806
                ];
664✔
807
            }
808
        }
809

810
        return $this;
665✔
811
    }
812

813
    /**
814
     * Generates a WHERE field IN('item', 'item') SQL query,
815
     * joined with 'AND' if appropriate.
816
     *
817
     * @param array|BaseBuilder|Closure|string $values The values searched on, or anonymous function with subquery
818
     *
819
     * @return $this
820
     */
821
    public function whereIn(?string $key = null, $values = null, ?bool $escape = null)
822
    {
823
        return $this->_whereIn($key, $values, false, 'AND ', $escape);
53✔
824
    }
825

826
    /**
827
     * Generates a WHERE field IN('item', 'item') SQL query,
828
     * joined with 'OR' if appropriate.
829
     *
830
     * @param array|BaseBuilder|Closure|string $values The values searched on, or anonymous function with subquery
831
     *
832
     * @return $this
833
     */
834
    public function orWhereIn(?string $key = null, $values = null, ?bool $escape = null)
835
    {
836
        return $this->_whereIn($key, $values, false, 'OR ', $escape);
3✔
837
    }
838

839
    /**
840
     * Generates a WHERE field NOT IN('item', 'item') SQL query,
841
     * joined with 'AND' if appropriate.
842
     *
843
     * @param array|BaseBuilder|Closure|string $values The values searched on, or anonymous function with subquery
844
     *
845
     * @return $this
846
     */
847
    public function whereNotIn(?string $key = null, $values = null, ?bool $escape = null)
848
    {
849
        return $this->_whereIn($key, $values, true, 'AND ', $escape);
3✔
850
    }
851

852
    /**
853
     * Generates a WHERE field NOT IN('item', 'item') SQL query,
854
     * joined with 'OR' if appropriate.
855
     *
856
     * @param array|BaseBuilder|Closure|string $values The values searched on, or anonymous function with subquery
857
     *
858
     * @return $this
859
     */
860
    public function orWhereNotIn(?string $key = null, $values = null, ?bool $escape = null)
861
    {
862
        return $this->_whereIn($key, $values, true, 'OR ', $escape);
2✔
863
    }
864

865
    /**
866
     * Generates a HAVING field IN('item', 'item') SQL query,
867
     * joined with 'AND' if appropriate.
868
     *
869
     * @param array|BaseBuilder|Closure|string $values The values searched on, or anonymous function with subquery
870
     *
871
     * @return $this
872
     */
873
    public function havingIn(?string $key = null, $values = null, ?bool $escape = null)
874
    {
875
        return $this->_whereIn($key, $values, false, 'AND ', $escape, 'QBHaving');
6✔
876
    }
877

878
    /**
879
     * Generates a HAVING field IN('item', 'item') SQL query,
880
     * joined with 'OR' if appropriate.
881
     *
882
     * @param array|BaseBuilder|Closure|string $values The values searched on, or anonymous function with subquery
883
     *
884
     * @return $this
885
     */
886
    public function orHavingIn(?string $key = null, $values = null, ?bool $escape = null)
887
    {
888
        return $this->_whereIn($key, $values, false, 'OR ', $escape, 'QBHaving');
3✔
889
    }
890

891
    /**
892
     * Generates a HAVING field NOT IN('item', 'item') SQL query,
893
     * joined with 'AND' if appropriate.
894
     *
895
     * @param array|BaseBuilder|Closure|string $values The values searched on, or anonymous function with subquery
896
     *
897
     * @return $this
898
     */
899
    public function havingNotIn(?string $key = null, $values = null, ?bool $escape = null)
900
    {
901
        return $this->_whereIn($key, $values, true, 'AND ', $escape, 'QBHaving');
5✔
902
    }
903

904
    /**
905
     * Generates a HAVING field NOT IN('item', 'item') SQL query,
906
     * joined with 'OR' if appropriate.
907
     *
908
     * @param array|BaseBuilder|Closure|string $values The values searched on, or anonymous function with subquery
909
     *
910
     * @return $this
911
     */
912
    public function orHavingNotIn(?string $key = null, $values = null, ?bool $escape = null)
913
    {
914
        return $this->_whereIn($key, $values, true, 'OR ', $escape, 'QBHaving');
3✔
915
    }
916

917
    /**
918
     * @used-by WhereIn()
919
     * @used-by orWhereIn()
920
     * @used-by whereNotIn()
921
     * @used-by orWhereNotIn()
922
     *
923
     * @param non-empty-string|null          $key
924
     * @param array|BaseBuilder|Closure|null $values The values searched on, or anonymous function with subquery
925
     *
926
     * @return $this
927
     *
928
     * @throws InvalidArgumentException
929
     */
930
    protected function _whereIn(?string $key = null, $values = null, bool $not = false, string $type = 'AND ', ?bool $escape = null, string $clause = 'QBWhere')
931
    {
932
        if ($key === null || $key === '') {
73✔
933
            throw new InvalidArgumentException(sprintf('%s() expects $key to be a non-empty string', debug_backtrace(0, 2)[1]['function']));
2✔
934
        }
935

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

940
        if (! is_bool($escape)) {
68✔
941
            $escape = $this->db->protectIdentifiers;
68✔
942
        }
943

944
        $ok = $key;
68✔
945

946
        if ($escape === true) {
68✔
947
            $key = $this->db->protectIdentifiers($key);
68✔
948
        }
949

950
        $not = ($not) ? ' NOT' : '';
68✔
951

952
        if ($this->isSubquery($values)) {
68✔
953
            $whereIn = $this->buildSubquery($values, true);
8✔
954
            $escape  = false;
8✔
955
        } else {
956
            $whereIn = array_values($values);
60✔
957
        }
958

959
        $ok = $this->setBind($ok, $whereIn, $escape);
68✔
960

961
        $prefix = empty($this->{$clause}) ? $this->groupGetType('') : $this->groupGetType($type);
68✔
962

963
        $whereIn = [
68✔
964
            'condition' => "{$prefix}{$key}{$not} IN :{$ok}:",
68✔
965
            'escape'    => false,
68✔
966
        ];
68✔
967

968
        $this->{$clause}[] = $whereIn;
68✔
969

970
        return $this;
68✔
971
    }
972

973
    /**
974
     * Generates a %LIKE% portion of the query.
975
     * Separates multiple calls with 'AND'.
976
     *
977
     * @param array|RawSql|string $field
978
     *
979
     * @return $this
980
     */
981
    public function like($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
982
    {
983
        return $this->_like($field, $match, 'AND ', $side, '', $escape, $insensitiveSearch);
20✔
984
    }
985

986
    /**
987
     * Generates a NOT LIKE portion of the query.
988
     * Separates multiple calls with 'AND'.
989
     *
990
     * @param array|RawSql|string $field
991
     *
992
     * @return $this
993
     */
994
    public function notLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
995
    {
996
        return $this->_like($field, $match, 'AND ', $side, 'NOT', $escape, $insensitiveSearch);
2✔
997
    }
998

999
    /**
1000
     * Generates a %LIKE% portion of the query.
1001
     * Separates multiple calls with 'OR'.
1002
     *
1003
     * @param array|RawSql|string $field
1004
     *
1005
     * @return $this
1006
     */
1007
    public function orLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1008
    {
1009
        return $this->_like($field, $match, 'OR ', $side, '', $escape, $insensitiveSearch);
2✔
1010
    }
1011

1012
    /**
1013
     * Generates a NOT LIKE portion of the query.
1014
     * Separates multiple calls with 'OR'.
1015
     *
1016
     * @param array|RawSql|string $field
1017
     *
1018
     * @return $this
1019
     */
1020
    public function orNotLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1021
    {
1022
        return $this->_like($field, $match, 'OR ', $side, 'NOT', $escape, $insensitiveSearch);
2✔
1023
    }
1024

1025
    /**
1026
     * Generates a %LIKE% portion of the query.
1027
     * Separates multiple calls with 'AND'.
1028
     *
1029
     * @param array|RawSql|string $field
1030
     *
1031
     * @return $this
1032
     */
1033
    public function havingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1034
    {
1035
        return $this->_like($field, $match, 'AND ', $side, '', $escape, $insensitiveSearch, 'QBHaving');
15✔
1036
    }
1037

1038
    /**
1039
     * Generates a NOT LIKE portion of the query.
1040
     * Separates multiple calls with 'AND'.
1041
     *
1042
     * @param array|RawSql|string $field
1043
     *
1044
     * @return $this
1045
     */
1046
    public function notHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1047
    {
1048
        return $this->_like($field, $match, 'AND ', $side, 'NOT', $escape, $insensitiveSearch, 'QBHaving');
4✔
1049
    }
1050

1051
    /**
1052
     * Generates a %LIKE% portion of the query.
1053
     * Separates multiple calls with 'OR'.
1054
     *
1055
     * @param array|RawSql|string $field
1056
     *
1057
     * @return $this
1058
     */
1059
    public function orHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1060
    {
1061
        return $this->_like($field, $match, 'OR ', $side, '', $escape, $insensitiveSearch, 'QBHaving');
4✔
1062
    }
1063

1064
    /**
1065
     * Generates a NOT LIKE portion of the query.
1066
     * Separates multiple calls with 'OR'.
1067
     *
1068
     * @param array|RawSql|string $field
1069
     *
1070
     * @return $this
1071
     */
1072
    public function orNotHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
1073
    {
1074
        return $this->_like($field, $match, 'OR ', $side, 'NOT', $escape, $insensitiveSearch, 'QBHaving');
4✔
1075
    }
1076

1077
    /**
1078
     * @used-by like()
1079
     * @used-by orLike()
1080
     * @used-by notLike()
1081
     * @used-by orNotLike()
1082
     * @used-by havingLike()
1083
     * @used-by orHavingLike()
1084
     * @used-by notHavingLike()
1085
     * @used-by orNotHavingLike()
1086
     *
1087
     * @param array|RawSql|string $field
1088
     *
1089
     * @return $this
1090
     */
1091
    protected function _like($field, string $match = '', string $type = 'AND ', string $side = 'both', string $not = '', ?bool $escape = null, bool $insensitiveSearch = false, string $clause = 'QBWhere')
1092
    {
1093
        $escape = is_bool($escape) ? $escape : $this->db->protectIdentifiers;
42✔
1094
        $side   = strtolower($side);
42✔
1095

1096
        if ($field instanceof RawSql) {
42✔
1097
            $k                 = (string) $field;
3✔
1098
            $v                 = $match;
3✔
1099
            $insensitiveSearch = false;
3✔
1100

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

1103
            if ($side === 'none') {
3✔
1104
                $bind = $this->setBind($field->getBindingKey(), $v, $escape);
×
1105
            } elseif ($side === 'before') {
3✔
1106
                $bind = $this->setBind($field->getBindingKey(), "%{$v}", $escape);
×
1107
            } elseif ($side === 'after') {
3✔
1108
                $bind = $this->setBind($field->getBindingKey(), "{$v}%", $escape);
×
1109
            } else {
1110
                $bind = $this->setBind($field->getBindingKey(), "%{$v}%", $escape);
3✔
1111
            }
1112

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

1115
            // some platforms require an escape sequence definition for LIKE wildcards
1116
            if ($escape === true && $this->db->likeEscapeStr !== '') {
3✔
1117
                $likeStatement .= sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar);
3✔
1118
            }
1119

1120
            $this->{$clause}[] = [
3✔
1121
                'condition' => $field->with($likeStatement),
3✔
1122
                'escape'    => $escape,
3✔
1123
            ];
3✔
1124

1125
            return $this;
3✔
1126
        }
1127

1128
        $keyValue = ! is_array($field) ? [$field => $match] : $field;
39✔
1129

1130
        foreach ($keyValue as $k => $v) {
39✔
1131
            if ($insensitiveSearch === true) {
39✔
1132
                $v = strtolower($v);
2✔
1133
            }
1134

1135
            $prefix = empty($this->{$clause}) ? $this->groupGetType('') : $this->groupGetType($type);
39✔
1136

1137
            if ($side === 'none') {
39✔
1138
                $bind = $this->setBind($k, $v, $escape);
1✔
1139
            } elseif ($side === 'before') {
38✔
1140
                $bind = $this->setBind($k, "%{$v}", $escape);
9✔
1141
            } elseif ($side === 'after') {
29✔
1142
                $bind = $this->setBind($k, "{$v}%", $escape);
5✔
1143
            } else {
1144
                $bind = $this->setBind($k, "%{$v}%", $escape);
24✔
1145
            }
1146

1147
            $likeStatement = $this->_like_statement($prefix, $k, $not, $bind, $insensitiveSearch);
39✔
1148

1149
            // some platforms require an escape sequence definition for LIKE wildcards
1150
            if ($escape === true && $this->db->likeEscapeStr !== '') {
39✔
1151
                $likeStatement .= sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar);
39✔
1152
            }
1153

1154
            $this->{$clause}[] = [
39✔
1155
                'condition' => $likeStatement,
39✔
1156
                'escape'    => $escape,
39✔
1157
            ];
39✔
1158
        }
1159

1160
        return $this;
39✔
1161
    }
1162

1163
    /**
1164
     * Platform independent LIKE statement builder.
1165
     */
1166
    protected function _like_statement(?string $prefix, string $column, ?string $not, string $bind, bool $insensitiveSearch = false): string
1167
    {
1168
        if ($insensitiveSearch === true) {
42✔
1169
            return "{$prefix} LOWER(" . $this->db->escapeIdentifiers($column) . ") {$not} LIKE :{$bind}:";
2✔
1170
        }
1171

1172
        return "{$prefix} {$column} {$not} LIKE :{$bind}:";
40✔
1173
    }
1174

1175
    /**
1176
     * Add UNION statement
1177
     *
1178
     * @param BaseBuilder|Closure $union
1179
     *
1180
     * @return $this
1181
     */
1182
    public function union($union)
1183
    {
1184
        return $this->addUnionStatement($union);
4✔
1185
    }
1186

1187
    /**
1188
     * Add UNION ALL statement
1189
     *
1190
     * @param BaseBuilder|Closure $union
1191
     *
1192
     * @return $this
1193
     */
1194
    public function unionAll($union)
1195
    {
1196
        return $this->addUnionStatement($union, true);
2✔
1197
    }
1198

1199
    /**
1200
     * @used-by union()
1201
     * @used-by unionAll()
1202
     *
1203
     * @param BaseBuilder|Closure $union
1204
     *
1205
     * @return $this
1206
     */
1207
    protected function addUnionStatement($union, bool $all = false)
1208
    {
1209
        $this->QBUnion[] = "\nUNION "
6✔
1210
            . ($all ? 'ALL ' : '')
6✔
1211
            . 'SELECT * FROM '
6✔
1212
            . $this->buildSubquery($union, true, 'uwrp' . (count($this->QBUnion) + 1));
6✔
1213

1214
        return $this;
6✔
1215
    }
1216

1217
    /**
1218
     * Starts a query group.
1219
     *
1220
     * @return $this
1221
     */
1222
    public function groupStart()
1223
    {
1224
        return $this->groupStartPrepare();
2✔
1225
    }
1226

1227
    /**
1228
     * Starts a query group, but ORs the group
1229
     *
1230
     * @return $this
1231
     */
1232
    public function orGroupStart()
1233
    {
1234
        return $this->groupStartPrepare('', 'OR ');
2✔
1235
    }
1236

1237
    /**
1238
     * Starts a query group, but NOTs the group
1239
     *
1240
     * @return $this
1241
     */
1242
    public function notGroupStart()
1243
    {
1244
        return $this->groupStartPrepare('NOT ');
2✔
1245
    }
1246

1247
    /**
1248
     * Starts a query group, but OR NOTs the group
1249
     *
1250
     * @return $this
1251
     */
1252
    public function orNotGroupStart()
1253
    {
1254
        return $this->groupStartPrepare('NOT ', 'OR ');
2✔
1255
    }
1256

1257
    /**
1258
     * Ends a query group
1259
     *
1260
     * @return $this
1261
     */
1262
    public function groupEnd()
1263
    {
1264
        return $this->groupEndPrepare();
8✔
1265
    }
1266

1267
    /**
1268
     * Starts a query group for HAVING clause.
1269
     *
1270
     * @return $this
1271
     */
1272
    public function havingGroupStart()
1273
    {
1274
        return $this->groupStartPrepare('', 'AND ', 'QBHaving');
2✔
1275
    }
1276

1277
    /**
1278
     * Starts a query group for HAVING clause, but ORs the group.
1279
     *
1280
     * @return $this
1281
     */
1282
    public function orHavingGroupStart()
1283
    {
1284
        return $this->groupStartPrepare('', 'OR ', 'QBHaving');
2✔
1285
    }
1286

1287
    /**
1288
     * Starts a query group for HAVING clause, but NOTs the group.
1289
     *
1290
     * @return $this
1291
     */
1292
    public function notHavingGroupStart()
1293
    {
1294
        return $this->groupStartPrepare('NOT ', 'AND ', 'QBHaving');
2✔
1295
    }
1296

1297
    /**
1298
     * Starts a query group for HAVING clause, but OR NOTs the group.
1299
     *
1300
     * @return $this
1301
     */
1302
    public function orNotHavingGroupStart()
1303
    {
1304
        return $this->groupStartPrepare('NOT ', 'OR ', 'QBHaving');
2✔
1305
    }
1306

1307
    /**
1308
     * Ends a query group for HAVING clause.
1309
     *
1310
     * @return $this
1311
     */
1312
    public function havingGroupEnd()
1313
    {
1314
        return $this->groupEndPrepare('QBHaving');
8✔
1315
    }
1316

1317
    /**
1318
     * Prepate a query group start.
1319
     *
1320
     * @return $this
1321
     */
1322
    protected function groupStartPrepare(string $not = '', string $type = 'AND ', string $clause = 'QBWhere')
1323
    {
1324
        $type = $this->groupGetType($type);
16✔
1325

1326
        $this->QBWhereGroupStarted = true;
16✔
1327
        $prefix                    = empty($this->{$clause}) ? '' : $type;
16✔
1328
        $where                     = [
16✔
1329
            'condition' => $prefix . $not . str_repeat(' ', ++$this->QBWhereGroupCount) . ' (',
16✔
1330
            'escape'    => false,
16✔
1331
        ];
16✔
1332

1333
        $this->{$clause}[] = $where;
16✔
1334

1335
        return $this;
16✔
1336
    }
1337

1338
    /**
1339
     * Prepate a query group end.
1340
     *
1341
     * @return $this
1342
     */
1343
    protected function groupEndPrepare(string $clause = 'QBWhere')
1344
    {
1345
        $this->QBWhereGroupStarted = false;
16✔
1346
        $where                     = [
16✔
1347
            'condition' => str_repeat(' ', $this->QBWhereGroupCount--) . ')',
16✔
1348
            'escape'    => false,
16✔
1349
        ];
16✔
1350

1351
        $this->{$clause}[] = $where;
16✔
1352

1353
        return $this;
16✔
1354
    }
1355

1356
    /**
1357
     * @used-by groupStart()
1358
     * @used-by _like()
1359
     * @used-by whereHaving()
1360
     * @used-by _whereIn()
1361
     * @used-by havingGroupStart()
1362
     */
1363
    protected function groupGetType(string $type): string
1364
    {
1365
        if ($this->QBWhereGroupStarted) {
694✔
1366
            $type                      = '';
16✔
1367
            $this->QBWhereGroupStarted = false;
16✔
1368
        }
1369

1370
        return $type;
694✔
1371
    }
1372

1373
    /**
1374
     * @param array|string $by
1375
     *
1376
     * @return $this
1377
     */
1378
    public function groupBy($by, ?bool $escape = null)
1379
    {
1380
        if (! is_bool($escape)) {
56✔
1381
            $escape = $this->db->protectIdentifiers;
56✔
1382
        }
1383

1384
        if (is_string($by)) {
56✔
1385
            $by = ($escape === true) ? explode(',', $by) : [$by];
56✔
1386
        }
1387

1388
        foreach ($by as $val) {
56✔
1389
            $val = trim($val);
56✔
1390

1391
            if ($val !== '') {
56✔
1392
                $val = [
56✔
1393
                    'field'  => $val,
56✔
1394
                    'escape' => $escape,
56✔
1395
                ];
56✔
1396

1397
                $this->QBGroupBy[] = $val;
56✔
1398
            }
1399
        }
1400

1401
        return $this;
56✔
1402
    }
1403

1404
    /**
1405
     * Separates multiple calls with 'AND'.
1406
     *
1407
     * @param array|RawSql|string $key
1408
     * @param mixed               $value
1409
     *
1410
     * @return $this
1411
     */
1412
    public function having($key, $value = null, ?bool $escape = null)
1413
    {
1414
        return $this->whereHaving('QBHaving', $key, $value, 'AND ', $escape);
16✔
1415
    }
1416

1417
    /**
1418
     * Separates multiple calls with 'OR'.
1419
     *
1420
     * @param array|RawSql|string $key
1421
     * @param mixed               $value
1422
     *
1423
     * @return $this
1424
     */
1425
    public function orHaving($key, $value = null, ?bool $escape = null)
1426
    {
1427
        return $this->whereHaving('QBHaving', $key, $value, 'OR ', $escape);
2✔
1428
    }
1429

1430
    /**
1431
     * @param string $direction ASC, DESC or RANDOM
1432
     *
1433
     * @return $this
1434
     */
1435
    public function orderBy(string $orderBy, string $direction = '', ?bool $escape = null)
1436
    {
1437
        $qbOrderBy = [];
600✔
1438
        if ($orderBy === '') {
600✔
1439
            return $this;
×
1440
        }
1441

1442
        $direction = strtoupper(trim($direction));
600✔
1443

1444
        if ($direction === 'RANDOM') {
600✔
1445
            $direction = '';
3✔
1446
            $orderBy   = ctype_digit($orderBy) ? sprintf($this->randomKeyword[1], $orderBy) : $this->randomKeyword[0];
3✔
1447
            $escape    = false;
3✔
1448
        } elseif ($direction !== '') {
598✔
1449
            $direction = in_array($direction, ['ASC', 'DESC'], true) ? ' ' . $direction : '';
598✔
1450
        }
1451

1452
        if (! is_bool($escape)) {
600✔
1453
            $escape = $this->db->protectIdentifiers;
598✔
1454
        }
1455

1456
        if ($escape === false) {
600✔
1457
            $qbOrderBy[] = [
3✔
1458
                'field'     => $orderBy,
3✔
1459
                'direction' => $direction,
3✔
1460
                'escape'    => false,
3✔
1461
            ];
3✔
1462
        } else {
1463
            $qbOrderBy = [];
598✔
1464

1465
            foreach (explode(',', $orderBy) as $field) {
598✔
1466
                $qbOrderBy[] = ($direction === '' && preg_match('/\s+(ASC|DESC)$/i', rtrim($field), $match, PREG_OFFSET_CAPTURE))
598✔
1467
                    ? [
×
1468
                        'field'     => ltrim(substr($field, 0, $match[0][1])),
×
1469
                        'direction' => ' ' . $match[1][0],
×
1470
                        'escape'    => true,
×
1471
                    ]
×
1472
                    : [
598✔
1473
                        'field'     => trim($field),
598✔
1474
                        'direction' => $direction,
598✔
1475
                        'escape'    => true,
598✔
1476
                    ];
598✔
1477
            }
1478
        }
1479

1480
        $this->QBOrderBy = array_merge($this->QBOrderBy, $qbOrderBy);
600✔
1481

1482
        return $this;
600✔
1483
    }
1484

1485
    /**
1486
     * @return $this
1487
     */
1488
    public function limit(?int $value = null, ?int $offset = 0)
1489
    {
1490
        if ($value !== null) {
84✔
1491
            $this->QBLimit = $value;
84✔
1492
        }
1493

1494
        if ($offset !== null && $offset !== 0) {
84✔
1495
            $this->QBOffset = $offset;
9✔
1496
        }
1497

1498
        return $this;
84✔
1499
    }
1500

1501
    /**
1502
     * Sets the OFFSET value
1503
     *
1504
     * @return $this
1505
     */
1506
    public function offset(int $offset)
1507
    {
1508
        if ($offset !== 0) {
1✔
1509
            $this->QBOffset = $offset;
1✔
1510
        }
1511

1512
        return $this;
1✔
1513
    }
1514

1515
    /**
1516
     * Generates a platform-specific LIMIT clause.
1517
     */
1518
    protected function _limit(string $sql, bool $offsetIgnore = false): string
1519
    {
1520
        return $sql . ' LIMIT ' . ($offsetIgnore === false && $this->QBOffset ? $this->QBOffset . ', ' : '') . $this->QBLimit;
78✔
1521
    }
1522

1523
    /**
1524
     * Allows key/value pairs to be set for insert(), update() or replace().
1525
     *
1526
     * @param array|object|string $key    Field name, or an array of field/value pairs
1527
     * @param mixed               $value  Field value, if $key is a single field
1528
     * @param bool|null           $escape Whether to escape values
1529
     *
1530
     * @return $this
1531
     */
1532
    public function set($key, $value = '', ?bool $escape = null)
1533
    {
1534
        $key = $this->objectToArray($key);
596✔
1535

1536
        if (! is_array($key)) {
596✔
1537
            $key = [$key => $value];
79✔
1538
        }
1539

1540
        $escape = is_bool($escape) ? $escape : $this->db->protectIdentifiers;
596✔
1541

1542
        foreach ($key as $k => $v) {
596✔
1543
            if ($escape) {
596✔
1544
                $bind = $this->setBind($k, $v, $escape);
595✔
1545

1546
                $this->QBSet[$this->db->protectIdentifiers($k, false)] = ":{$bind}:";
595✔
1547
            } else {
1548
                $this->QBSet[$this->db->protectIdentifiers($k, false)] = $v;
10✔
1549
            }
1550
        }
1551

1552
        return $this;
596✔
1553
    }
1554

1555
    /**
1556
     * Returns the previously set() data, alternatively resetting it if needed.
1557
     */
1558
    public function getSetData(bool $clean = false): array
1559
    {
1560
        $data = $this->QBSet;
×
1561

1562
        if ($clean) {
×
1563
            $this->QBSet = [];
×
1564
        }
1565

1566
        return $data;
×
1567
    }
1568

1569
    /**
1570
     * Compiles a SELECT query string and returns the sql.
1571
     */
1572
    public function getCompiledSelect(bool $reset = true): string
1573
    {
1574
        $select = $this->compileSelect();
165✔
1575

1576
        if ($reset === true) {
165✔
1577
            $this->resetSelect();
163✔
1578
        }
1579

1580
        return $this->compileFinalQuery($select);
165✔
1581
    }
1582

1583
    /**
1584
     * Returns a finalized, compiled query string with the bindings
1585
     * inserted and prefixes swapped out.
1586
     */
1587
    protected function compileFinalQuery(string $sql): string
1588
    {
1589
        $query = new Query($this->db);
190✔
1590
        $query->setQuery($sql, $this->binds, false);
190✔
1591

1592
        if (! empty($this->db->swapPre) && ! empty($this->db->DBPrefix)) {
190✔
1593
            $query->swapPrefix($this->db->DBPrefix, $this->db->swapPre);
×
1594
        }
1595

1596
        return $query->getQuery();
190✔
1597
    }
1598

1599
    /**
1600
     * Compiles the select statement based on the other functions called
1601
     * and runs the query
1602
     *
1603
     * @return false|ResultInterface
1604
     */
1605
    public function get(?int $limit = null, int $offset = 0, bool $reset = true)
1606
    {
1607
        if ($limit !== null) {
603✔
1608
            $this->limit($limit, $offset);
3✔
1609
        }
1610

1611
        $result = $this->testMode
603✔
1612
            ? $this->getCompiledSelect($reset)
1✔
1613
            : $this->db->query($this->compileSelect(), $this->binds, false);
602✔
1614

1615
        if ($reset === true) {
603✔
1616
            $this->resetSelect();
603✔
1617

1618
            // Clear our binds so we don't eat up memory
1619
            $this->binds = [];
603✔
1620
        }
1621

1622
        return $result;
603✔
1623
    }
1624

1625
    /**
1626
     * Generates a platform-specific query string that counts all records in
1627
     * the particular table
1628
     *
1629
     * @return int|string
1630
     */
1631
    public function countAll(bool $reset = true)
1632
    {
1633
        $table = $this->QBFrom[0];
5✔
1634

1635
        $sql = $this->countString . $this->db->escapeIdentifiers('numrows') . ' FROM ' .
5✔
1636
            $this->db->protectIdentifiers($table, true, null, false);
5✔
1637

1638
        if ($this->testMode) {
5✔
1639
            return $sql;
1✔
1640
        }
1641

1642
        $query = $this->db->query($sql, null, false);
4✔
1643

1644
        if (empty($query->getResult())) {
4✔
1645
            return 0;
×
1646
        }
1647

1648
        $query = $query->getRow();
4✔
1649

1650
        if ($reset === true) {
4✔
1651
            $this->resetSelect();
4✔
1652
        }
1653

1654
        return (int) $query->numrows;
4✔
1655
    }
1656

1657
    /**
1658
     * Generates a platform-specific query string that counts all records
1659
     * returned by an Query Builder query.
1660
     *
1661
     * @return int|string
1662
     */
1663
    public function countAllResults(bool $reset = true)
1664
    {
1665
        // ORDER BY usage is often problematic here (most notably
1666
        // on Microsoft SQL Server) and ultimately unnecessary
1667
        // for selecting COUNT(*) ...
1668
        $orderBy = [];
152✔
1669

1670
        if (! empty($this->QBOrderBy)) {
152✔
1671
            $orderBy = $this->QBOrderBy;
×
1672

1673
            $this->QBOrderBy = null;
×
1674
        }
1675

1676
        // We cannot use a LIMIT when getting the single row COUNT(*) result
1677
        $limit = $this->QBLimit;
152✔
1678

1679
        $this->QBLimit = false;
152✔
1680

1681
        if ($this->QBDistinct === true || ! empty($this->QBGroupBy)) {
152✔
1682
            // We need to backup the original SELECT in case DBPrefix is used
1683
            $select = $this->QBSelect;
4✔
1684
            $sql    = $this->countString . $this->db->protectIdentifiers('numrows') . "\nFROM (\n" . $this->compileSelect() . "\n) CI_count_all_results";
4✔
1685

1686
            // Restore SELECT part
1687
            $this->QBSelect = $select;
4✔
1688
            unset($select);
4✔
1689
        } else {
1690
            $sql = $this->compileSelect($this->countString . $this->db->protectIdentifiers('numrows'));
148✔
1691
        }
1692

1693
        if ($this->testMode) {
152✔
1694
            return $sql;
9✔
1695
        }
1696

1697
        $result = $this->db->query($sql, $this->binds, false);
147✔
1698

1699
        if ($reset === true) {
147✔
1700
            $this->resetSelect();
138✔
1701
        } elseif (! isset($this->QBOrderBy)) {
15✔
1702
            $this->QBOrderBy = $orderBy;
×
1703
        }
1704

1705
        // Restore the LIMIT setting
1706
        $this->QBLimit = $limit;
147✔
1707

1708
        $row = ! $result instanceof ResultInterface ? null : $result->getRow();
147✔
1709

1710
        if (empty($row)) {
147✔
1711
            return 0;
×
1712
        }
1713

1714
        return (int) $row->numrows;
147✔
1715
    }
1716

1717
    /**
1718
     * Compiles the set conditions and returns the sql statement
1719
     *
1720
     * @return array
1721
     */
1722
    public function getCompiledQBWhere()
1723
    {
1724
        return $this->QBWhere;
53✔
1725
    }
1726

1727
    /**
1728
     * Allows the where clause, limit and offset to be added directly
1729
     *
1730
     * @param array|string $where
1731
     *
1732
     * @return ResultInterface
1733
     */
1734
    public function getWhere($where = null, ?int $limit = null, ?int $offset = 0, bool $reset = true)
1735
    {
1736
        if ($where !== null) {
17✔
1737
            $this->where($where);
16✔
1738
        }
1739

1740
        if ($limit !== null && $limit !== 0) {
17✔
1741
            $this->limit($limit, $offset);
3✔
1742
        }
1743

1744
        $result = $this->testMode
17✔
1745
            ? $this->getCompiledSelect($reset)
4✔
1746
            : $this->db->query($this->compileSelect(), $this->binds, false);
13✔
1747

1748
        if ($reset === true) {
17✔
1749
            $this->resetSelect();
17✔
1750

1751
            // Clear our binds so we don't eat up memory
1752
            $this->binds = [];
17✔
1753
        }
1754

1755
        return $result;
17✔
1756
    }
1757

1758
    /**
1759
     * Compiles batch insert/update/upsert strings and runs the queries
1760
     *
1761
     * @return false|int|string[] Number of rows inserted or FALSE on failure, SQL array when testMode
1762
     *
1763
     * @throws DatabaseException
1764
     */
1765
    protected function batchExecute(string $renderMethod, int $batchSize = 100)
1766
    {
1767
        if (empty($this->QBSet)) {
65✔
1768
            if ($this->db->DBDebug) {
5✔
1769
                throw new DatabaseException(trim($renderMethod, '_') . '() has no data.');
5✔
1770
            }
1771

1772
            return false; // @codeCoverageIgnore
1773
        }
1774

1775
        $table = $this->db->protectIdentifiers($this->QBFrom[0], true, null, false);
60✔
1776

1777
        $affectedRows = 0;
60✔
1778
        $savedSQL     = [];
60✔
1779
        $cnt          = count($this->QBSet);
60✔
1780

1781
        // batch size 0 for unlimited
1782
        if ($batchSize === 0) {
60✔
1783
            $batchSize = $cnt;
×
1784
        }
1785

1786
        for ($i = 0, $total = $cnt; $i < $total; $i += $batchSize) {
60✔
1787
            $QBSet = array_slice($this->QBSet, $i, $batchSize);
60✔
1788

1789
            $sql = $this->{$renderMethod}($table, $this->QBKeys, $QBSet);
60✔
1790

1791
            if ($sql === '') {
57✔
1792
                return false; // @codeCoverageIgnore
1793
            }
1794

1795
            if ($this->testMode) {
57✔
1796
                $savedSQL[] = $sql;
3✔
1797
            } else {
1798
                $this->db->query($sql, null, false);
54✔
1799
                $affectedRows += $this->db->affectedRows();
53✔
1800
            }
1801
        }
1802

1803
        if (! $this->testMode) {
56✔
1804
            $this->resetWrite();
53✔
1805
        }
1806

1807
        return $this->testMode ? $savedSQL : $affectedRows;
56✔
1808
    }
1809

1810
    /**
1811
     * Allows a row or multiple rows to be set for batch inserts/upserts/updates
1812
     *
1813
     * @param array|object $set
1814
     * @param string       $alias alias for sql table
1815
     *
1816
     * @return $this|null
1817
     */
1818
    public function setData($set, ?bool $escape = null, string $alias = '')
1819
    {
1820
        if (empty($set)) {
60✔
1821
            if ($this->db->DBDebug) {
×
1822
                throw new DatabaseException('setData() has no data.');
×
1823
            }
1824

1825
            return null; // @codeCoverageIgnore
1826
        }
1827

1828
        $this->setAlias($alias);
60✔
1829

1830
        // this allows to set just one row at a time
1831
        if (is_object($set) || (! is_array(current($set)) && ! is_object(current($set)))) {
60✔
1832
            $set = [$set];
11✔
1833
        }
1834

1835
        $set = $this->batchObjectToArray($set);
60✔
1836

1837
        $escape = is_bool($escape) ? $escape : $this->db->protectIdentifiers;
60✔
1838

1839
        $keys = array_keys($this->objectToArray(current($set)));
60✔
1840
        sort($keys);
60✔
1841

1842
        foreach ($set as $row) {
60✔
1843
            $row = $this->objectToArray($row);
60✔
1844
            if (array_diff($keys, array_keys($row)) !== [] || array_diff(array_keys($row), $keys) !== []) {
60✔
1845
                // batchExecute() function returns an error on an empty array
1846
                $this->QBSet[] = [];
×
1847

1848
                return null;
×
1849
            }
1850

1851
            ksort($row); // puts $row in the same order as our keys
60✔
1852

1853
            $clean = [];
60✔
1854

1855
            foreach ($row as $rowValue) {
60✔
1856
                $clean[] = $escape ? $this->db->escape($rowValue) : $rowValue;
60✔
1857
            }
1858

1859
            $row = $clean;
60✔
1860

1861
            $this->QBSet[] = $row;
60✔
1862
        }
1863

1864
        foreach ($keys as $k) {
60✔
1865
            $k = $this->db->protectIdentifiers($k, false);
60✔
1866

1867
            if (! in_array($k, $this->QBKeys, true)) {
60✔
1868
                $this->QBKeys[] = $k;
60✔
1869
            }
1870
        }
1871

1872
        return $this;
60✔
1873
    }
1874

1875
    /**
1876
     * Compiles an upsert query and returns the sql
1877
     *
1878
     * @return string
1879
     *
1880
     * @throws DatabaseException
1881
     */
1882
    public function getCompiledUpsert()
1883
    {
1884
        [$currentTestMode, $this->testMode] = [$this->testMode, true];
3✔
1885

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

1888
        $this->testMode = $currentTestMode;
3✔
1889

1890
        return $this->compileFinalQuery($sql);
3✔
1891
    }
1892

1893
    /**
1894
     * Converts call to batchUpsert
1895
     *
1896
     * @param array|object|null $set
1897
     *
1898
     * @return false|int|string[] Number of affected rows or FALSE on failure, SQL array when testMode
1899
     *
1900
     * @throws DatabaseException
1901
     */
1902
    public function upsert($set = null, ?bool $escape = null)
1903
    {
1904
        // if set() has been used merge QBSet with binds and then setData()
1905
        if ($set === null && ! is_array(current($this->QBSet))) {
10✔
1906
            $set = [];
2✔
1907

1908
            foreach ($this->QBSet as $field => $value) {
2✔
1909
                $k = trim($field, $this->db->escapeChar);
2✔
1910
                // use binds if available else use QBSet value but with RawSql to avoid escape
1911
                $set[$k] = isset($this->binds[$k]) ? $this->binds[$k][0] : new RawSql($value);
2✔
1912
            }
1913

1914
            $this->binds = [];
2✔
1915

1916
            $this->resetRun([
2✔
1917
                'QBSet'  => [],
2✔
1918
                'QBKeys' => [],
2✔
1919
            ]);
2✔
1920

1921
            $this->setData($set, true); // unescaped items are RawSql now
2✔
1922
        } elseif ($set !== null) {
8✔
1923
            $this->setData($set, $escape);
7✔
1924
        } // else setData() has already been used and we need to do nothing
1925

1926
        return $this->batchExecute('_upsertBatch');
10✔
1927
    }
1928

1929
    /**
1930
     * Compiles batch upsert strings and runs the queries
1931
     *
1932
     * @param array|object|null $set a dataset
1933
     *
1934
     * @return false|int|string[] Number of affected rows or FALSE on failure, SQL array when testMode
1935
     *
1936
     * @throws DatabaseException
1937
     */
1938
    public function upsertBatch($set = null, ?bool $escape = null, int $batchSize = 100)
1939
    {
1940
        if (isset($this->QBOptions['setQueryAsData'])) {
10✔
1941
            $sql = $this->_upsertBatch($this->QBFrom[0], $this->QBKeys, []);
1✔
1942

1943
            if ($sql === '') {
1✔
1944
                return false; // @codeCoverageIgnore
1945
            }
1946

1947
            if ($this->testMode === false) {
1✔
1948
                $this->db->query($sql, null, false);
1✔
1949
            }
1950

1951
            $this->resetWrite();
1✔
1952

1953
            return $this->testMode ? $sql : $this->db->affectedRows();
1✔
1954
        }
1955

1956
        if ($set !== null) {
9✔
1957
            $this->setData($set, $escape);
7✔
1958
        }
1959

1960
        return $this->batchExecute('_upsertBatch', $batchSize);
9✔
1961
    }
1962

1963
    /**
1964
     * Generates a platform-specific upsertBatch string from the supplied data
1965
     *
1966
     * @used-by batchExecute
1967
     *
1968
     * @param string                 $table  Protected table name
1969
     * @param list<string>           $keys   QBKeys
1970
     * @param list<list<int|string>> $values QBSet
1971
     */
1972
    protected function _upsertBatch(string $table, array $keys, array $values): string
1973
    {
1974
        $sql = $this->QBOptions['sql'] ?? '';
17✔
1975

1976
        // if this is the first iteration of batch then we need to build skeleton sql
1977
        if ($sql === '') {
17✔
1978
            $updateFields = $this->QBOptions['updateFields'] ?? $this->updateFields($keys)->QBOptions['updateFields'] ?? [];
17✔
1979

1980
            $sql = 'INSERT INTO ' . $table . ' (' . implode(', ', $keys) . ")\n{:_table_:}ON DUPLICATE KEY UPDATE\n" . implode(
17✔
1981
                ",\n",
17✔
1982
                array_map(
17✔
1983
                    static fn ($key, $value) => $table . '.' . $key . ($value instanceof RawSql ?
17✔
1984
                        ' = ' . $value :
2✔
1985
                        ' = VALUES(' . $value . ')'),
17✔
1986
                    array_keys($updateFields),
17✔
1987
                    $updateFields
17✔
1988
                )
17✔
1989
            );
17✔
1990

1991
            $this->QBOptions['sql'] = $sql;
17✔
1992
        }
1993

1994
        if (isset($this->QBOptions['setQueryAsData'])) {
17✔
1995
            $data = $this->QBOptions['setQueryAsData'] . "\n";
1✔
1996
        } else {
1997
            $data = 'VALUES ' . implode(', ', $this->formatValues($values)) . "\n";
16✔
1998
        }
1999

2000
        return str_replace('{:_table_:}', $data, $sql);
17✔
2001
    }
2002

2003
    /**
2004
     * Set table alias for dataset pseudo table.
2005
     */
2006
    private function setAlias(string $alias): BaseBuilder
2007
    {
2008
        if ($alias !== '') {
60✔
2009
            $this->db->addTableAlias($alias);
6✔
2010
            $this->QBOptions['alias'] = $this->db->protectIdentifiers($alias);
6✔
2011
        }
2012

2013
        return $this;
60✔
2014
    }
2015

2016
    /**
2017
     * Sets update fields for upsert, update
2018
     *
2019
     * @param RawSql[]|string|string[] $set
2020
     * @param bool                     $addToDefault adds update fields to the default ones
2021
     * @param array|null               $ignore       ignores items in set
2022
     *
2023
     * @return $this
2024
     */
2025
    public function updateFields($set, bool $addToDefault = false, ?array $ignore = null)
2026
    {
2027
        if (! empty($set)) {
33✔
2028
            if (! is_array($set)) {
33✔
2029
                $set = explode(',', $set);
5✔
2030
            }
2031

2032
            foreach ($set as $key => $value) {
33✔
2033
                if (! ($value instanceof RawSql)) {
33✔
2034
                    $value = $this->db->protectIdentifiers($value);
33✔
2035
                }
2036

2037
                if (is_numeric($key)) {
33✔
2038
                    $key = $value;
33✔
2039
                }
2040

2041
                if ($ignore === null || ! in_array($key, $ignore, true)) {
33✔
2042
                    if ($addToDefault) {
33✔
2043
                        $this->QBOptions['updateFieldsAdditional'][$this->db->protectIdentifiers($key)] = $value;
3✔
2044
                    } else {
2045
                        $this->QBOptions['updateFields'][$this->db->protectIdentifiers($key)] = $value;
33✔
2046
                    }
2047
                }
2048
            }
2049

2050
            if ($addToDefault === false && isset($this->QBOptions['updateFieldsAdditional'], $this->QBOptions['updateFields'])) {
33✔
2051
                $this->QBOptions['updateFields'] = array_merge($this->QBOptions['updateFields'], $this->QBOptions['updateFieldsAdditional']);
3✔
2052

2053
                unset($this->QBOptions['updateFieldsAdditional']);
3✔
2054
            }
2055
        }
2056

2057
        return $this;
33✔
2058
    }
2059

2060
    /**
2061
     * Sets constraints for batch upsert, update
2062
     *
2063
     * @param array|RawSql|string $set a string of columns, key value pairs, or RawSql
2064
     *
2065
     * @return $this
2066
     */
2067
    public function onConstraint($set)
2068
    {
2069
        if (! empty($set)) {
41✔
2070
            if (is_string($set)) {
38✔
2071
                $set = explode(',', $set);
23✔
2072

2073
                $set = array_map(static fn ($key) => trim($key), $set);
23✔
2074
            }
2075

2076
            if ($set instanceof RawSql) {
38✔
2077
                $set = [$set];
2✔
2078
            }
2079

2080
            foreach ($set as $key => $value) {
38✔
2081
                if (! ($value instanceof RawSql)) {
38✔
2082
                    $value = $this->db->protectIdentifiers($value);
35✔
2083
                }
2084

2085
                if (is_string($key)) {
38✔
2086
                    $key = $this->db->protectIdentifiers($key);
2✔
2087
                }
2088

2089
                $this->QBOptions['constraints'][$key] = $value;
38✔
2090
            }
2091
        }
2092

2093
        return $this;
41✔
2094
    }
2095

2096
    /**
2097
     * Sets data source as a query for insertBatch()/updateBatch()/upsertBatch()/deleteBatch()
2098
     *
2099
     * @param BaseBuilder|RawSql $query
2100
     * @param array|string|null  $columns an array or comma delimited string of columns
2101
     */
2102
    public function setQueryAsData($query, ?string $alias = null, $columns = null): BaseBuilder
2103
    {
2104
        if (is_string($query)) {
5✔
2105
            throw new InvalidArgumentException('$query parameter must be BaseBuilder or RawSql class.');
×
2106
        }
2107

2108
        if ($query instanceof BaseBuilder) {
5✔
2109
            $query = $query->getCompiledSelect();
4✔
2110
        } elseif ($query instanceof RawSql) {
1✔
2111
            $query = $query->__toString();
1✔
2112
        }
2113

2114
        if (is_string($query)) {
5✔
2115
            if ($columns !== null && is_string($columns)) {
5✔
2116
                $columns = explode(',', $columns);
1✔
2117
                $columns = array_map(static fn ($key) => trim($key), $columns);
1✔
2118
            }
2119

2120
            $columns = (array) $columns;
5✔
2121

2122
            if ($columns === []) {
5✔
2123
                $columns = $this->fieldsFromQuery($query);
4✔
2124
            }
2125

2126
            if ($alias !== null) {
5✔
2127
                $this->setAlias($alias);
1✔
2128
            }
2129

2130
            foreach ($columns as $key => $value) {
5✔
2131
                $columns[$key] = $this->db->escapeChar . $value . $this->db->escapeChar;
5✔
2132
            }
2133

2134
            $this->QBOptions['setQueryAsData'] = $query;
5✔
2135
            $this->QBKeys                      = $columns;
5✔
2136
            $this->QBSet                       = [];
5✔
2137
        }
2138

2139
        return $this;
5✔
2140
    }
2141

2142
    /**
2143
     * Gets column names from a select query
2144
     */
2145
    protected function fieldsFromQuery(string $sql): array
2146
    {
2147
        return $this->db->query('SELECT * FROM (' . $sql . ') _u_ LIMIT 1')->getFieldNames();
4✔
2148
    }
2149

2150
    /**
2151
     * Converts value array of array to array of strings
2152
     */
2153
    protected function formatValues(array $values): array
2154
    {
2155
        return array_map(static fn ($index) => '(' . implode(',', $index) . ')', $values);
41✔
2156
    }
2157

2158
    /**
2159
     * Compiles batch insert strings and runs the queries
2160
     *
2161
     * @param array|object|null $set a dataset
2162
     *
2163
     * @return false|int|string[] Number of rows inserted or FALSE on failure, SQL array when testMode
2164
     */
2165
    public function insertBatch($set = null, ?bool $escape = null, int $batchSize = 100)
2166
    {
2167
        if (isset($this->QBOptions['setQueryAsData'])) {
27✔
2168
            $sql = $this->_insertBatch($this->QBFrom[0], $this->QBKeys, []);
2✔
2169

2170
            if ($sql === '') {
2✔
2171
                return false; // @codeCoverageIgnore
2172
            }
2173

2174
            if ($this->testMode === false) {
2✔
2175
                $this->db->query($sql, null, false);
2✔
2176
            }
2177

2178
            $this->resetWrite();
2✔
2179

2180
            return $this->testMode ? $sql : $this->db->affectedRows();
2✔
2181
        }
2182

2183
        if ($set !== null && $set !== []) {
27✔
2184
            $this->setData($set, $escape);
24✔
2185
        }
2186

2187
        return $this->batchExecute('_insertBatch', $batchSize);
27✔
2188
    }
2189

2190
    /**
2191
     * Generates a platform-specific insert string from the supplied data.
2192
     *
2193
     * @used-by batchExecute
2194
     *
2195
     * @param string                 $table  Protected table name
2196
     * @param list<string>           $keys   QBKeys
2197
     * @param list<list<int|string>> $values QBSet
2198
     */
2199
    protected function _insertBatch(string $table, array $keys, array $values): string
2200
    {
2201
        $sql = $this->QBOptions['sql'] ?? '';
25✔
2202

2203
        // if this is the first iteration of batch then we need to build skeleton sql
2204
        if ($sql === '') {
25✔
2205
            $sql = 'INSERT ' . $this->compileIgnore('insert') . 'INTO ' . $table
25✔
2206
                . ' (' . implode(', ', $keys) . ")\n{:_table_:}";
25✔
2207

2208
            $this->QBOptions['sql'] = $sql;
25✔
2209
        }
2210

2211
        if (isset($this->QBOptions['setQueryAsData'])) {
25✔
2212
            $data = $this->QBOptions['setQueryAsData'];
2✔
2213
        } else {
2214
            $data = 'VALUES ' . implode(', ', $this->formatValues($values));
25✔
2215
        }
2216

2217
        return str_replace('{:_table_:}', $data, $sql);
25✔
2218
    }
2219

2220
    /**
2221
     * Allows key/value pairs to be set for batch inserts
2222
     *
2223
     * @param mixed $key
2224
     *
2225
     * @return $this|null
2226
     *
2227
     * @deprecated
2228
     */
2229
    public function setInsertBatch($key, string $value = '', ?bool $escape = null)
2230
    {
2231
        if (! is_array($key)) {
2232
            $key = [[$key => $value]];
2233
        }
2234

2235
        return $this->setData($key, $escape);
2236
    }
2237

2238
    /**
2239
     * Compiles an insert query and returns the sql
2240
     *
2241
     * @return bool|string
2242
     *
2243
     * @throws DatabaseException
2244
     */
2245
    public function getCompiledInsert(bool $reset = true)
2246
    {
2247
        if ($this->validateInsert() === false) {
6✔
2248
            return false;
×
2249
        }
2250

2251
        $sql = $this->_insert(
6✔
2252
            $this->db->protectIdentifiers(
6✔
2253
                $this->removeAlias($this->QBFrom[0]),
6✔
2254
                true,
6✔
2255
                null,
6✔
2256
                false
6✔
2257
            ),
6✔
2258
            array_keys($this->QBSet),
6✔
2259
            array_values($this->QBSet)
6✔
2260
        );
6✔
2261

2262
        if ($reset === true) {
6✔
2263
            $this->resetWrite();
6✔
2264
        }
2265

2266
        return $this->compileFinalQuery($sql);
6✔
2267
    }
2268

2269
    /**
2270
     * Compiles an insert string and runs the query
2271
     *
2272
     * @param array|object|null $set
2273
     *
2274
     * @return BaseResult|bool|Query
2275
     *
2276
     * @throws DatabaseException
2277
     */
2278
    public function insert($set = null, ?bool $escape = null)
2279
    {
2280
        if ($set !== null) {
583✔
2281
            $this->set($set, '', $escape);
574✔
2282
        }
2283

2284
        if ($this->validateInsert() === false) {
583✔
2285
            return false;
×
2286
        }
2287

2288
        $sql = $this->_insert(
582✔
2289
            $this->db->protectIdentifiers(
582✔
2290
                $this->removeAlias($this->QBFrom[0]),
582✔
2291
                true,
582✔
2292
                $escape,
582✔
2293
                false
582✔
2294
            ),
582✔
2295
            array_keys($this->QBSet),
582✔
2296
            array_values($this->QBSet)
582✔
2297
        );
582✔
2298

2299
        if (! $this->testMode) {
582✔
2300
            $this->resetWrite();
578✔
2301

2302
            $result = $this->db->query($sql, $this->binds, false);
578✔
2303

2304
            // Clear our binds so we don't eat up memory
2305
            $this->binds = [];
578✔
2306

2307
            return $result;
578✔
2308
        }
2309

2310
        return false;
5✔
2311
    }
2312

2313
    /**
2314
     * @internal This is a temporary solution.
2315
     *
2316
     * @see https://github.com/codeigniter4/CodeIgniter4/pull/5376
2317
     *
2318
     * @TODO Fix a root cause, and this method should be removed.
2319
     */
2320
    protected function removeAlias(string $from): string
2321
    {
2322
        if (strpos($from, ' ') !== false) {
587✔
2323
            // if the alias is written with the AS keyword, remove it
2324
            $from = preg_replace('/\s+AS\s+/i', ' ', $from);
2✔
2325

2326
            $parts = explode(' ', $from);
2✔
2327
            $from  = $parts[0];
2✔
2328
        }
2329

2330
        return $from;
587✔
2331
    }
2332

2333
    /**
2334
     * This method is used by both insert() and getCompiledInsert() to
2335
     * validate that the there data is actually being set and that table
2336
     * has been chosen to be inserted into.
2337
     *
2338
     * @throws DatabaseException
2339
     */
2340
    protected function validateInsert(): bool
2341
    {
2342
        if (empty($this->QBSet)) {
583✔
2343
            if ($this->db->DBDebug) {
1✔
2344
                throw new DatabaseException('You must use the "set" method to insert an entry.');
1✔
2345
            }
2346

2347
            return false; // @codeCoverageIgnore
2348
        }
2349

2350
        return true;
582✔
2351
    }
2352

2353
    /**
2354
     * Generates a platform-specific insert string from the supplied data
2355
     *
2356
     * @param string $table Protected table name
2357
     */
2358
    protected function _insert(string $table, array $keys, array $unescapedKeys): string
2359
    {
2360
        return 'INSERT ' . $this->compileIgnore('insert') . 'INTO ' . $table . ' (' . implode(', ', $keys) . ') VALUES (' . implode(', ', $unescapedKeys) . ')';
576✔
2361
    }
2362

2363
    /**
2364
     * Compiles a replace into string and runs the query
2365
     *
2366
     * @return BaseResult|false|Query|string
2367
     *
2368
     * @throws DatabaseException
2369
     */
2370
    public function replace(?array $set = null)
2371
    {
2372
        if ($set !== null) {
8✔
2373
            $this->set($set);
7✔
2374
        }
2375

2376
        if (empty($this->QBSet)) {
8✔
2377
            if ($this->db->DBDebug) {
1✔
2378
                throw new DatabaseException('You must use the "set" method to update an entry.');
1✔
2379
            }
2380

2381
            return false; // @codeCoverageIgnore
2382
        }
2383

2384
        $table = $this->QBFrom[0];
7✔
2385

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

2388
        $this->resetWrite();
7✔
2389

2390
        return $this->testMode ? $sql : $this->db->query($sql, $this->binds, false);
7✔
2391
    }
2392

2393
    /**
2394
     * Generates a platform-specific replace string from the supplied data
2395
     *
2396
     * @param string $table Protected table name
2397
     */
2398
    protected function _replace(string $table, array $keys, array $values): string
2399
    {
2400
        return 'REPLACE INTO ' . $table . ' (' . implode(', ', $keys) . ') VALUES (' . implode(', ', $values) . ')';
7✔
2401
    }
2402

2403
    /**
2404
     * Groups tables in FROM clauses if needed, so there is no confusion
2405
     * about operator precedence.
2406
     *
2407
     * Note: This is only used (and overridden) by MySQL and SQLSRV.
2408
     */
2409
    protected function _fromTables(): string
2410
    {
2411
        return implode(', ', $this->QBFrom);
738✔
2412
    }
2413

2414
    /**
2415
     * Compiles an update query and returns the sql
2416
     *
2417
     * @return bool|string
2418
     */
2419
    public function getCompiledUpdate(bool $reset = true)
2420
    {
2421
        if ($this->validateUpdate() === false) {
13✔
2422
            return false;
×
2423
        }
2424

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

2427
        if ($reset === true) {
13✔
2428
            $this->resetWrite();
13✔
2429
        }
2430

2431
        return $this->compileFinalQuery($sql);
13✔
2432
    }
2433

2434
    /**
2435
     * Compiles an update string and runs the query.
2436
     *
2437
     * @param array|object|null        $set
2438
     * @param array|RawSql|string|null $where
2439
     *
2440
     * @throws DatabaseException
2441
     */
2442
    public function update($set = null, $where = null, ?int $limit = null): bool
2443
    {
2444
        if ($set !== null) {
83✔
2445
            $this->set($set);
47✔
2446
        }
2447

2448
        if ($this->validateUpdate() === false) {
83✔
2449
            return false;
×
2450
        }
2451

2452
        if ($where !== null) {
82✔
2453
            $this->where($where);
8✔
2454
        }
2455

2456
        if ($limit !== null && $limit !== 0) {
82✔
2457
            if (! $this->canLimitWhereUpdates) {
3✔
2458
                throw new DatabaseException('This driver does not allow LIMITs on UPDATE queries using WHERE.');
2✔
2459
            }
2460

2461
            $this->limit($limit);
3✔
2462
        }
2463

2464
        $sql = $this->_update($this->QBFrom[0], $this->QBSet);
82✔
2465

2466
        if (! $this->testMode) {
82✔
2467
            $this->resetWrite();
69✔
2468

2469
            $result = $this->db->query($sql, $this->binds, false);
69✔
2470

2471
            if ($result !== false) {
69✔
2472
                // Clear our binds so we don't eat up memory
2473
                $this->binds = [];
66✔
2474

2475
                return true;
66✔
2476
            }
2477

2478
            return false;
3✔
2479
        }
2480

2481
        return true;
13✔
2482
    }
2483

2484
    /**
2485
     * Generates a platform-specific update string from the supplied data
2486
     *
2487
     * @param string $table Protected table name
2488
     */
2489
    protected function _update(string $table, array $values): string
2490
    {
2491
        $valStr = [];
89✔
2492

2493
        foreach ($values as $key => $val) {
89✔
2494
            $valStr[] = $key . ' = ' . $val;
89✔
2495
        }
2496

2497
        return 'UPDATE ' . $this->compileIgnore('update') . $table . ' SET ' . implode(', ', $valStr)
89✔
2498
            . $this->compileWhereHaving('QBWhere')
89✔
2499
            . $this->compileOrderBy()
89✔
2500
            . ($this->QBLimit ? $this->_limit(' ', true) : '');
89✔
2501
    }
2502

2503
    /**
2504
     * This method is used by both update() and getCompiledUpdate() to
2505
     * validate that data is actually being set and that a table has been
2506
     * chosen to be updated.
2507
     *
2508
     * @throws DatabaseException
2509
     */
2510
    protected function validateUpdate(): bool
2511
    {
2512
        if (empty($this->QBSet)) {
84✔
2513
            if ($this->db->DBDebug) {
1✔
2514
                throw new DatabaseException('You must use the "set" method to update an entry.');
1✔
2515
            }
2516

2517
            return false; // @codeCoverageIgnore
2518
        }
2519

2520
        return true;
83✔
2521
    }
2522

2523
    /**
2524
     * Sets data and calls batchExecute to run queries
2525
     *
2526
     * @param array|object|null        $set         a dataset
2527
     * @param array|RawSql|string|null $constraints
2528
     *
2529
     * @return false|int|string[] Number of rows affected or FALSE on failure, SQL array when testMode
2530
     */
2531
    public function updateBatch($set = null, $constraints = null, int $batchSize = 100)
2532
    {
2533
        $this->onConstraint($constraints);
20✔
2534

2535
        if (isset($this->QBOptions['setQueryAsData'])) {
20✔
2536
            $sql = $this->_updateBatch($this->QBFrom[0], $this->QBKeys, []);
1✔
2537

2538
            if ($sql === '') {
1✔
2539
                return false; // @codeCoverageIgnore
2540
            }
2541

2542
            if ($this->testMode === false) {
1✔
2543
                $this->db->query($sql, null, false);
1✔
2544
            }
2545

2546
            $this->resetWrite();
1✔
2547

2548
            return $this->testMode ? $sql : $this->db->affectedRows();
1✔
2549
        }
2550

2551
        if ($set !== null && $set !== []) {
19✔
2552
            $this->setData($set, true);
12✔
2553
        }
2554

2555
        return $this->batchExecute('_updateBatch', $batchSize);
19✔
2556
    }
2557

2558
    /**
2559
     * Generates a platform-specific batch update string from the supplied data
2560
     *
2561
     * @used-by batchExecute
2562
     *
2563
     * @param string                 $table  Protected table name
2564
     * @param list<string>           $keys   QBKeys
2565
     * @param list<list<int|string>> $values QBSet
2566
     */
2567
    protected function _updateBatch(string $table, array $keys, array $values): string
2568
    {
2569
        $sql = $this->QBOptions['sql'] ?? '';
15✔
2570

2571
        // if this is the first iteration of batch then we need to build skeleton sql
2572
        if ($sql === '') {
15✔
2573
            $constraints = $this->QBOptions['constraints'] ?? [];
15✔
2574

2575
            if ($constraints === []) {
15✔
2576
                if ($this->db->DBDebug) {
2✔
2577
                    throw new DatabaseException('You must specify a constraint to match on for batch updates.'); // @codeCoverageIgnore
2578
                }
2579

2580
                return ''; // @codeCoverageIgnore
2581
            }
2582

2583
            $updateFields = $this->QBOptions['updateFields'] ??
13✔
2584
                $this->updateFields($keys, false, $constraints)->QBOptions['updateFields'] ??
13✔
2585
                [];
10✔
2586

2587
            $alias = $this->QBOptions['alias'] ?? '_u';
13✔
2588

2589
            $sql = 'UPDATE ' . $this->compileIgnore('update') . $table . "\n";
13✔
2590

2591
            $sql .= "SET\n";
13✔
2592

2593
            $sql .= implode(
13✔
2594
                ",\n",
13✔
2595
                array_map(
13✔
2596
                    static fn ($key, $value) => $key . ($value instanceof RawSql ?
13✔
2597
                        ' = ' . $value :
2✔
2598
                        ' = ' . $alias . '.' . $value),
13✔
2599
                    array_keys($updateFields),
13✔
2600
                    $updateFields
13✔
2601
                )
13✔
2602
            ) . "\n";
13✔
2603

2604
            $sql .= "FROM (\n{:_table_:}";
13✔
2605

2606
            $sql .= ') ' . $alias . "\n";
13✔
2607

2608
            $sql .= 'WHERE ' . implode(
13✔
2609
                ' AND ',
13✔
2610
                array_map(
13✔
2611
                    static fn ($key, $value) => (
13✔
2612
                        ($value instanceof RawSql && is_string($key))
13✔
2613
                        ?
13✔
2614
                        $table . '.' . $key . ' = ' . $value
1✔
2615
                        :
13✔
2616
                        (
13✔
2617
                            $value instanceof RawSql
12✔
2618
                            ?
12✔
2619
                            $value
3✔
2620
                            :
12✔
2621
                            $table . '.' . $value . ' = ' . $alias . '.' . $value
13✔
2622
                        )
13✔
2623
                    ),
13✔
2624
                    array_keys($constraints),
13✔
2625
                    $constraints
13✔
2626
                )
13✔
2627
            );
13✔
2628

2629
            $this->QBOptions['sql'] = $sql;
13✔
2630
        }
2631

2632
        if (isset($this->QBOptions['setQueryAsData'])) {
13✔
2633
            $data = $this->QBOptions['setQueryAsData'];
1✔
2634
        } else {
2635
            $data = implode(
12✔
2636
                " UNION ALL\n",
12✔
2637
                array_map(
12✔
2638
                    static fn ($value) => 'SELECT ' . implode(', ', array_map(
12✔
2639
                        static fn ($key, $index) => $index . ' ' . $key,
12✔
2640
                        $keys,
12✔
2641
                        $value
12✔
2642
                    )),
12✔
2643
                    $values
12✔
2644
                )
12✔
2645
            ) . "\n";
12✔
2646
        }
2647

2648
        return str_replace('{:_table_:}', $data, $sql);
13✔
2649
    }
2650

2651
    /**
2652
     * Allows key/value pairs to be set for batch updating
2653
     *
2654
     * @param array|object $key
2655
     *
2656
     * @return $this
2657
     *
2658
     * @throws DatabaseException
2659
     *
2660
     * @deprecated
2661
     */
2662
    public function setUpdateBatch($key, string $index = '', ?bool $escape = null)
2663
    {
2664
        if ($index !== '') {
2665
            $this->onConstraint($index);
2666
        }
2667

2668
        $this->setData($key, $escape);
2669

2670
        return $this;
2671
    }
2672

2673
    /**
2674
     * Compiles a delete string and runs "DELETE FROM table"
2675
     *
2676
     * @return bool|string TRUE on success, FALSE on failure, string on testMode
2677
     */
2678
    public function emptyTable()
2679
    {
2680
        $table = $this->QBFrom[0];
4✔
2681

2682
        $sql = $this->_delete($table);
4✔
2683

2684
        if ($this->testMode) {
4✔
2685
            return $sql;
1✔
2686
        }
2687

2688
        $this->resetWrite();
3✔
2689

2690
        return $this->db->query($sql, null, false);
3✔
2691
    }
2692

2693
    /**
2694
     * Compiles a truncate string and runs the query
2695
     * If the database does not support the truncate() command
2696
     * This function maps to "DELETE FROM table"
2697
     *
2698
     * @return bool|string TRUE on success, FALSE on failure, string on testMode
2699
     */
2700
    public function truncate()
2701
    {
2702
        $table = $this->QBFrom[0];
517✔
2703

2704
        $sql = $this->_truncate($table);
517✔
2705

2706
        if ($this->testMode) {
517✔
2707
            return $sql;
2✔
2708
        }
2709

2710
        $this->resetWrite();
516✔
2711

2712
        return $this->db->query($sql, null, false);
516✔
2713
    }
2714

2715
    /**
2716
     * Generates a platform-specific truncate string from the supplied data
2717
     *
2718
     * If the database does not support the truncate() command,
2719
     * then this method maps to 'DELETE FROM table'
2720
     *
2721
     * @param string $table Protected table name
2722
     */
2723
    protected function _truncate(string $table): string
2724
    {
2725
        return 'TRUNCATE ' . $table;
506✔
2726
    }
2727

2728
    /**
2729
     * Compiles a delete query string and returns the sql
2730
     */
2731
    public function getCompiledDelete(bool $reset = true): string
2732
    {
2733
        $sql = $this->testMode()->delete('', null, $reset);
3✔
2734
        $this->testMode(false);
3✔
2735

2736
        return $this->compileFinalQuery($sql);
3✔
2737
    }
2738

2739
    /**
2740
     * Compiles a delete string and runs the query
2741
     *
2742
     * @param array|RawSql|string $where
2743
     *
2744
     * @return bool|string Returns a SQL string if in test mode.
2745
     *
2746
     * @throws DatabaseException
2747
     */
2748
    public function delete($where = '', ?int $limit = null, bool $resetData = true)
2749
    {
2750
        $table = $this->db->protectIdentifiers($this->QBFrom[0], true, null, false);
567✔
2751

2752
        if ($where !== '') {
567✔
2753
            $this->where($where);
4✔
2754
        }
2755

2756
        if (empty($this->QBWhere)) {
567✔
2757
            if ($this->db->DBDebug) {
4✔
2758
                throw new DatabaseException('Deletes are not allowed unless they contain a "where" or "like" clause.');
4✔
2759
            }
2760

2761
            return false; // @codeCoverageIgnore
2762
        }
2763

2764
        $sql = $this->_delete($this->removeAlias($table));
567✔
2765

2766
        if ($limit !== null && $limit !== 0) {
567✔
2767
            $this->QBLimit = $limit;
1✔
2768
        }
2769

2770
        if (! empty($this->QBLimit)) {
567✔
2771
            if (! $this->canLimitDeletes) {
2✔
2772
                throw new DatabaseException('SQLite3 does not allow LIMITs on DELETE queries.');
1✔
2773
            }
2774

2775
            $sql = $this->_limit($sql, true);
2✔
2776
        }
2777

2778
        if ($resetData) {
567✔
2779
            $this->resetWrite();
567✔
2780
        }
2781

2782
        return $this->testMode ? $sql : $this->db->query($sql, $this->binds, false);
567✔
2783
    }
2784

2785
    /**
2786
     * Sets data and calls batchExecute to run queries
2787
     *
2788
     * @param array|object|null $set         a dataset
2789
     * @param array|RawSql|null $constraints
2790
     *
2791
     * @return false|int|string[] Number of rows affected or FALSE on failure, SQL array when testMode
2792
     */
2793
    public function deleteBatch($set = null, $constraints = null, int $batchSize = 100)
2794
    {
2795
        $this->onConstraint($constraints);
2✔
2796

2797
        if (isset($this->QBOptions['setQueryAsData'])) {
2✔
2798
            $sql = $this->_deleteBatch($this->QBFrom[0], $this->QBKeys, []);
1✔
2799

2800
            if ($sql === '') {
1✔
2801
                return false; // @codeCoverageIgnore
2802
            }
2803

2804
            if ($this->testMode === false) {
1✔
2805
                $this->db->query($sql, null, false);
1✔
2806
            }
2807

2808
            $this->resetWrite();
1✔
2809

2810
            return $this->testMode ? $sql : $this->db->affectedRows();
1✔
2811
        }
2812

2813
        if ($set !== null && $set !== []) {
1✔
2814
            $this->setData($set, true);
×
2815
        }
2816

2817
        return $this->batchExecute('_deleteBatch', $batchSize);
1✔
2818
    }
2819

2820
    /**
2821
     * Generates a platform-specific batch update string from the supplied data
2822
     *
2823
     * @used-by batchExecute
2824
     *
2825
     * @param string       $table Protected table name
2826
     * @param list<string> $keys  QBKeys
2827
     * @paramst<string|int>> $values QBSet
2828
     */
2829
    protected function _deleteBatch(string $table, array $keys, array $values): string
2830
    {
2831
        $sql = $this->QBOptions['sql'] ?? '';
2✔
2832

2833
        // if this is the first iteration of batch then we need to build skeleton sql
2834
        if ($sql === '') {
2✔
2835
            $constraints = $this->QBOptions['constraints'] ?? [];
2✔
2836

2837
            if ($constraints === []) {
2✔
2838
                if ($this->db->DBDebug) {
×
2839
                    throw new DatabaseException('You must specify a constraint to match on for batch deletes.'); // @codeCoverageIgnore
2840
                }
2841

2842
                return ''; // @codeCoverageIgnore
2843
            }
2844

2845
            $alias = $this->QBOptions['alias'] ?? '_u';
2✔
2846

2847
            $sql = 'DELETE ' . $table . ' FROM ' . $table . "\n";
2✔
2848

2849
            $sql .= "INNER JOIN (\n{:_table_:}";
2✔
2850

2851
            $sql .= ') ' . $alias . "\n";
2✔
2852

2853
            $sql .= 'ON ' . implode(
2✔
2854
                ' AND ',
2✔
2855
                array_map(
2✔
2856
                    static fn ($key, $value) => (
2✔
2857
                        $value instanceof RawSql ?
2✔
2858
                        $value :
×
2859
                        (
2✔
2860
                            is_string($key) ?
2✔
2861
                            $table . '.' . $key . ' = ' . $alias . '.' . $value :
1✔
2862
                            $table . '.' . $value . ' = ' . $alias . '.' . $value
2✔
2863
                        )
2✔
2864
                    ),
2✔
2865
                    array_keys($constraints),
2✔
2866
                    $constraints
2✔
2867
                )
2✔
2868
            );
2✔
2869

2870
            // convert binds in where
2871
            foreach ($this->QBWhere as $key => $where) {
2✔
2872
                foreach ($this->binds as $field => $bind) {
2✔
2873
                    $this->QBWhere[$key]['condition'] = str_replace(':' . $field . ':', $bind[0], $where['condition']);
×
2874
                }
2875
            }
2876

2877
            $sql .= ' ' . $this->compileWhereHaving('QBWhere');
2✔
2878

2879
            $this->QBOptions['sql'] = trim($sql);
2✔
2880
        }
2881

2882
        if (isset($this->QBOptions['setQueryAsData'])) {
2✔
2883
            $data = $this->QBOptions['setQueryAsData'];
1✔
2884
        } else {
2885
            $data = implode(
1✔
2886
                " UNION ALL\n",
1✔
2887
                array_map(
1✔
2888
                    static fn ($value) => 'SELECT ' . implode(', ', array_map(
1✔
2889
                        static fn ($key, $index) => $index . ' ' . $key,
1✔
2890
                        $keys,
1✔
2891
                        $value
1✔
2892
                    )),
1✔
2893
                    $values
1✔
2894
                )
1✔
2895
            ) . "\n";
1✔
2896
        }
2897

2898
        return str_replace('{:_table_:}', $data, $sql);
2✔
2899
    }
2900

2901
    /**
2902
     * Increments a numeric column by the specified value.
2903
     *
2904
     * @return bool
2905
     */
2906
    public function increment(string $column, int $value = 1)
2907
    {
2908
        $column = $this->db->protectIdentifiers($column);
3✔
2909

2910
        $sql = $this->_update($this->QBFrom[0], [$column => "{$column} + {$value}"]);
3✔
2911

2912
        if (! $this->testMode) {
3✔
2913
            $this->resetWrite();
3✔
2914

2915
            return $this->db->query($sql, $this->binds, false);
3✔
2916
        }
2917

2918
        return true;
×
2919
    }
2920

2921
    /**
2922
     * Decrements a numeric column by the specified value.
2923
     *
2924
     * @return bool
2925
     */
2926
    public function decrement(string $column, int $value = 1)
2927
    {
2928
        $column = $this->db->protectIdentifiers($column);
3✔
2929

2930
        $sql = $this->_update($this->QBFrom[0], [$column => "{$column}-{$value}"]);
3✔
2931

2932
        if (! $this->testMode) {
3✔
2933
            $this->resetWrite();
3✔
2934

2935
            return $this->db->query($sql, $this->binds, false);
3✔
2936
        }
2937

2938
        return true;
×
2939
    }
2940

2941
    /**
2942
     * Generates a platform-specific delete string from the supplied data
2943
     *
2944
     * @param string $table Protected table name
2945
     */
2946
    protected function _delete(string $table): string
2947
    {
2948
        return 'DELETE ' . $this->compileIgnore('delete') . 'FROM ' . $table . $this->compileWhereHaving('QBWhere');
570✔
2949
    }
2950

2951
    /**
2952
     * Used to track SQL statements written with aliased tables.
2953
     *
2954
     * @param array|string $table The table to inspect
2955
     *
2956
     * @return string|void
2957
     */
2958
    protected function trackAliases($table)
2959
    {
2960
        if (is_array($table)) {
818✔
2961
            foreach ($table as $t) {
×
2962
                $this->trackAliases($t);
×
2963
            }
2964

2965
            return;
×
2966
        }
2967

2968
        // Does the string contain a comma?  If so, we need to separate
2969
        // the string into discreet statements
2970
        if (strpos($table, ',') !== false) {
818✔
2971
            return $this->trackAliases(explode(',', $table));
×
2972
        }
2973

2974
        // if a table alias is used we can recognize it by a space
2975
        if (strpos($table, ' ') !== false) {
818✔
2976
            // if the alias is written with the AS keyword, remove it
2977
            $table = preg_replace('/\s+AS\s+/i', ' ', $table);
15✔
2978

2979
            // Grab the alias
2980
            $table = trim(strrchr($table, ' '));
15✔
2981

2982
            // Store the alias, if it doesn't already exist
2983
            $this->db->addTableAlias($table);
15✔
2984
        }
2985
    }
2986

2987
    /**
2988
     * Compile the SELECT statement
2989
     *
2990
     * Generates a query string based on which functions were used.
2991
     * Should not be called directly.
2992
     *
2993
     * @param mixed $selectOverride
2994
     */
2995
    protected function compileSelect($selectOverride = false): string
2996
    {
2997
        if ($selectOverride !== false) {
756✔
2998
            $sql = $selectOverride;
148✔
2999
        } else {
3000
            $sql = (! $this->QBDistinct) ? 'SELECT ' : 'SELECT DISTINCT ';
753✔
3001

3002
            if (empty($this->QBSelect)) {
753✔
3003
                $sql .= '*';
680✔
3004
            } elseif ($this->QBSelect[0] instanceof RawSql) {
658✔
3005
                $sql .= (string) $this->QBSelect[0];
1✔
3006
            } else {
3007
                // Cycle through the "select" portion of the query and prep each column name.
3008
                // The reason we protect identifiers here rather than in the select() function
3009
                // is because until the user calls the from() function we don't know if there are aliases
3010
                foreach ($this->QBSelect as $key => $val) {
657✔
3011
                    $noEscape             = $this->QBNoEscape[$key] ?? null;
657✔
3012
                    $this->QBSelect[$key] = $this->db->protectIdentifiers($val, false, $noEscape);
657✔
3013
                }
3014

3015
                $sql .= implode(', ', $this->QBSelect);
657✔
3016
            }
3017
        }
3018

3019
        if (! empty($this->QBFrom)) {
756✔
3020
            $sql .= "\nFROM " . $this->_fromTables();
756✔
3021
        }
3022

3023
        if (! empty($this->QBJoin)) {
756✔
3024
            $sql .= "\n" . implode("\n", $this->QBJoin);
14✔
3025
        }
3026

3027
        $sql .= $this->compileWhereHaving('QBWhere')
756✔
3028
            . $this->compileGroupBy()
756✔
3029
            . $this->compileWhereHaving('QBHaving')
756✔
3030
            . $this->compileOrderBy();
756✔
3031

3032
        if ($this->QBLimit) {
756✔
3033
            $sql = $this->_limit($sql . "\n");
73✔
3034
        }
3035

3036
        return $this->unionInjection($sql);
756✔
3037
    }
3038

3039
    /**
3040
     * Checks if the ignore option is supported by
3041
     * the Database Driver for the specific statement.
3042
     *
3043
     * @return string
3044
     */
3045
    protected function compileIgnore(string $statement)
3046
    {
3047
        if ($this->QBIgnore && isset($this->supportedIgnoreStatements[$statement])) {
606✔
3048
            return trim($this->supportedIgnoreStatements[$statement]) . ' ';
1✔
3049
        }
3050

3051
        return '';
605✔
3052
    }
3053

3054
    /**
3055
     * Escapes identifiers in WHERE and HAVING statements at execution time.
3056
     *
3057
     * Required so that aliases are tracked properly, regardless of whether
3058
     * where(), orWhere(), having(), orHaving are called prior to from(),
3059
     * join() and prefixTable is added only if needed.
3060
     *
3061
     * @param string $qbKey 'QBWhere' or 'QBHaving'
3062
     *
3063
     * @return string SQL statement
3064
     */
3065
    protected function compileWhereHaving(string $qbKey): string
3066
    {
3067
        if (! empty($this->{$qbKey})) {
778✔
3068
            foreach ($this->{$qbKey} as &$qbkey) {
694✔
3069
                // Is this condition already compiled?
3070
                if (is_string($qbkey)) {
694✔
3071
                    continue;
22✔
3072
                }
3073

3074
                if ($qbkey instanceof RawSql) {
694✔
3075
                    continue;
2✔
3076
                }
3077

3078
                if ($qbkey['condition'] instanceof RawSql) {
694✔
3079
                    $qbkey = $qbkey['condition'];
4✔
3080

3081
                    continue;
4✔
3082
                }
3083

3084
                if ($qbkey['escape'] === false) {
692✔
3085
                    $qbkey = $qbkey['condition'];
94✔
3086

3087
                    continue;
94✔
3088
                }
3089

3090
                // Split multiple conditions
3091
                $conditions = preg_split(
682✔
3092
                    '/((?:^|\s+)AND\s+|(?:^|\s+)OR\s+)/i',
682✔
3093
                    $qbkey['condition'],
682✔
3094
                    -1,
682✔
3095
                    PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY
682✔
3096
                );
682✔
3097

3098
                foreach ($conditions as &$condition) {
682✔
3099
                    if (($op = $this->getOperator($condition)) === false
682✔
3100
                        || ! preg_match('/^(\(?)(.*)(' . preg_quote($op, '/') . ')\s*(.*(?<!\)))?(\)?)$/i', $condition, $matches)
682✔
3101
                    ) {
3102
                        continue;
595✔
3103
                    }
3104
                    // $matches = array(
3105
                    //        0 => '(test <= foo)',        /* the whole thing */
3106
                    //        1 => '(',                /* optional */
3107
                    //        2 => 'test',                /* the field name */
3108
                    //        3 => ' <= ',                /* $op */
3109
                    //        4 => 'foo',                /* optional, if $op is e.g. 'IS NULL' */
3110
                    //        5 => ')'                /* optional */
3111
                    // );
3112

3113
                    if (! empty($matches[4])) {
682✔
3114
                        $protectIdentifiers = false;
658✔
3115
                        if (strpos($matches[4], '.') !== false) {
658✔
3116
                            $protectIdentifiers = true;
63✔
3117
                        }
3118

3119
                        if (strpos($matches[4], ':') === false) {
658✔
3120
                            $matches[4] = $this->db->protectIdentifiers(trim($matches[4]), false, $protectIdentifiers);
14✔
3121
                        }
3122

3123
                        $matches[4] = ' ' . $matches[4];
658✔
3124
                    }
3125

3126
                    $condition = $matches[1] . $this->db->protectIdentifiers(trim($matches[2]))
682✔
3127
                        . ' ' . trim($matches[3]) . $matches[4] . $matches[5];
682✔
3128
                }
3129

3130
                $qbkey = implode('', $conditions);
682✔
3131
            }
3132

3133
            return ($qbKey === 'QBHaving' ? "\nHAVING " : "\nWHERE ")
694✔
3134
                . implode("\n", $this->{$qbKey});
694✔
3135
        }
3136

3137
        return '';
760✔
3138
    }
3139

3140
    /**
3141
     * Escapes identifiers in GROUP BY statements at execution time.
3142
     *
3143
     * Required so that aliases are tracked properly, regardless of whether
3144
     * groupBy() is called prior to from(), join() and prefixTable is added
3145
     * only if needed.
3146
     */
3147
    protected function compileGroupBy(): string
3148
    {
3149
        if (! empty($this->QBGroupBy)) {
761✔
3150
            foreach ($this->QBGroupBy as &$groupBy) {
56✔
3151
                // Is it already compiled?
3152
                if (is_string($groupBy)) {
56✔
3153
                    continue;
2✔
3154
                }
3155

3156
                $groupBy = ($groupBy['escape'] === false || $this->isLiteral($groupBy['field']))
56✔
3157
                    ? $groupBy['field']
×
3158
                    : $this->db->protectIdentifiers($groupBy['field']);
56✔
3159
            }
3160

3161
            return "\nGROUP BY " . implode(', ', $this->QBGroupBy);
56✔
3162
        }
3163

3164
        return '';
735✔
3165
    }
3166

3167
    /**
3168
     * Escapes identifiers in ORDER BY statements at execution time.
3169
     *
3170
     * Required so that aliases are tracked properly, regardless of whether
3171
     * orderBy() is called prior to from(), join() and prefixTable is added
3172
     * only if needed.
3173
     */
3174
    protected function compileOrderBy(): string
3175
    {
3176
        if (is_array($this->QBOrderBy) && $this->QBOrderBy !== []) {
773✔
3177
            foreach ($this->QBOrderBy as &$orderBy) {
600✔
3178
                if ($orderBy['escape'] !== false && ! $this->isLiteral($orderBy['field'])) {
600✔
3179
                    $orderBy['field'] = $this->db->protectIdentifiers($orderBy['field']);
598✔
3180
                }
3181

3182
                $orderBy = $orderBy['field'] . $orderBy['direction'];
600✔
3183
            }
3184

3185
            return $this->QBOrderBy = "\nORDER BY " . implode(', ', $this->QBOrderBy);
600✔
3186
        }
3187

3188
        if (is_string($this->QBOrderBy)) {
748✔
3189
            return $this->QBOrderBy;
×
3190
        }
3191

3192
        return '';
748✔
3193
    }
3194

3195
    protected function unionInjection(string $sql): string
3196
    {
3197
        if ($this->QBUnion === []) {
761✔
3198
            return $sql;
761✔
3199
        }
3200

3201
        return 'SELECT * FROM (' . $sql . ') '
6✔
3202
            . ($this->db->protectIdentifiers ? $this->db->escapeIdentifiers('uwrp0') : 'uwrp0')
6✔
3203
            . implode("\n", $this->QBUnion);
6✔
3204
    }
3205

3206
    /**
3207
     * Takes an object as input and converts the class variables to array key/vals
3208
     *
3209
     * @param array|object $object
3210
     *
3211
     * @return array
3212
     */
3213
    protected function objectToArray($object)
3214
    {
3215
        if (! is_object($object)) {
603✔
3216
            return $object;
600✔
3217
        }
3218

3219
        if ($object instanceof RawSql) {
10✔
3220
            throw new InvalidArgumentException('RawSql "' . $object . '" cannot be used here.');
1✔
3221
        }
3222

3223
        $array = [];
9✔
3224

3225
        foreach (get_object_vars($object) as $key => $val) {
9✔
3226
            if ((! is_object($val) || $val instanceof RawSql) && ! is_array($val)) {
9✔
3227
                $array[$key] = $val;
9✔
3228
            }
3229
        }
3230

3231
        return $array;
9✔
3232
    }
3233

3234
    /**
3235
     * Takes an object as input and converts the class variables to array key/vals
3236
     *
3237
     * @param array|object $object
3238
     *
3239
     * @return array
3240
     */
3241
    protected function batchObjectToArray($object)
3242
    {
3243
        if (! is_object($object)) {
60✔
3244
            return $object;
60✔
3245
        }
3246

3247
        $array  = [];
×
3248
        $out    = get_object_vars($object);
×
3249
        $fields = array_keys($out);
×
3250

3251
        foreach ($fields as $val) {
×
3252
            $i = 0;
×
3253

3254
            foreach ($out[$val] as $data) {
×
3255
                $array[$i++][$val] = $data;
×
3256
            }
3257
        }
3258

3259
        return $array;
×
3260
    }
3261

3262
    /**
3263
     * Determines if a string represents a literal value or a field name
3264
     */
3265
    protected function isLiteral(string $str): bool
3266
    {
3267
        $str = trim($str);
628✔
3268

3269
        if ($str === ''
628✔
3270
            || ctype_digit($str)
628✔
3271
            || (string) (float) $str === $str
628✔
3272
            || in_array(strtoupper($str), ['TRUE', 'FALSE'], true)
628✔
3273
        ) {
3274
            return true;
×
3275
        }
3276

3277
        if ($this->isLiteralStr === []) {
628✔
3278
            $this->isLiteralStr = $this->db->escapeChar !== '"' ? ['"', "'"] : ["'"];
628✔
3279
        }
3280

3281
        return in_array($str[0], $this->isLiteralStr, true);
628✔
3282
    }
3283

3284
    /**
3285
     * Publicly-visible method to reset the QB values.
3286
     *
3287
     * @return $this
3288
     */
3289
    public function resetQuery()
3290
    {
3291
        $this->resetSelect();
1✔
3292
        $this->resetWrite();
1✔
3293

3294
        return $this;
1✔
3295
    }
3296

3297
    /**
3298
     * Resets the query builder values.  Called by the get() function
3299
     *
3300
     * @param array $qbResetItems An array of fields to reset
3301
     */
3302
    protected function resetRun(array $qbResetItems)
3303
    {
3304
        foreach ($qbResetItems as $item => $defaultValue) {
783✔
3305
            $this->{$item} = $defaultValue;
783✔
3306
        }
3307
    }
3308

3309
    /**
3310
     * Resets the query builder values.  Called by the get() function
3311
     */
3312
    protected function resetSelect()
3313
    {
3314
        $this->resetRun([
756✔
3315
            'QBSelect'   => [],
756✔
3316
            'QBJoin'     => [],
756✔
3317
            'QBWhere'    => [],
756✔
3318
            'QBGroupBy'  => [],
756✔
3319
            'QBHaving'   => [],
756✔
3320
            'QBOrderBy'  => [],
756✔
3321
            'QBNoEscape' => [],
756✔
3322
            'QBDistinct' => false,
756✔
3323
            'QBLimit'    => false,
756✔
3324
            'QBOffset'   => false,
756✔
3325
            'QBUnion'    => [],
756✔
3326
        ]);
756✔
3327

3328
        if (! empty($this->db)) {
756✔
3329
            $this->db->setAliasedTables([]);
756✔
3330
        }
3331

3332
        // Reset QBFrom part
3333
        if (! empty($this->QBFrom)) {
756✔
3334
            $this->from(array_shift($this->QBFrom), true);
756✔
3335
        }
3336
    }
3337

3338
    /**
3339
     * Resets the query builder "write" values.
3340
     *
3341
     * Called by the insert() update() insertBatch() updateBatch() and delete() functions
3342
     */
3343
    protected function resetWrite()
3344
    {
3345
        $this->resetRun([
607✔
3346
            'QBSet'     => [],
607✔
3347
            'QBJoin'    => [],
607✔
3348
            'QBWhere'   => [],
607✔
3349
            'QBOrderBy' => [],
607✔
3350
            'QBKeys'    => [],
607✔
3351
            'QBLimit'   => false,
607✔
3352
            'QBIgnore'  => false,
607✔
3353
            'QBOptions' => [],
607✔
3354
        ]);
607✔
3355
    }
3356

3357
    /**
3358
     * Tests whether the string has an SQL operator
3359
     */
3360
    protected function hasOperator(string $str): bool
3361
    {
3362
        return preg_match(
92✔
3363
            '/(<|>|!|=|\sIS NULL|\sIS NOT NULL|\sEXISTS|\sBETWEEN|\sLIKE|\sIN\s*\(|\s)/i',
92✔
3364
            trim($str)
92✔
3365
        ) === 1;
92✔
3366
    }
3367

3368
    /**
3369
     * Returns the SQL string operator
3370
     *
3371
     * @return array|false|string
3372
     */
3373
    protected function getOperator(string $str, bool $list = false)
3374
    {
3375
        if ($this->pregOperators === []) {
690✔
3376
            $_les = $this->db->likeEscapeStr !== ''
690✔
3377
                ? '\s+' . preg_quote(trim(sprintf($this->db->likeEscapeStr, $this->db->likeEscapeChar)), '/')
690✔
3378
                : '';
×
3379
            $this->pregOperators = [
690✔
3380
                '\s*(?:<|>|!)?=\s*', // =, <=, >=, !=
690✔
3381
                '\s*<>?\s*',         // <, <>
690✔
3382
                '\s*>\s*',           // >
690✔
3383
                '\s+IS NULL',             // IS NULL
690✔
3384
                '\s+IS NOT NULL',         // IS NOT NULL
690✔
3385
                '\s+EXISTS\s*\(.*\)',     // EXISTS (sql)
690✔
3386
                '\s+NOT EXISTS\s*\(.*\)', // NOT EXISTS(sql)
690✔
3387
                '\s+BETWEEN\s+',          // BETWEEN value AND value
690✔
3388
                '\s+IN\s*\(.*\)',         // IN (list)
690✔
3389
                '\s+NOT IN\s*\(.*\)',     // NOT IN (list)
690✔
3390
                '\s+LIKE\s+\S.*(' . $_les . ')?',     // LIKE 'expr'[ ESCAPE '%s']
690✔
3391
                '\s+NOT LIKE\s+\S.*(' . $_les . ')?', // NOT LIKE 'expr'[ ESCAPE '%s']
690✔
3392
            ];
690✔
3393
        }
3394

3395
        return preg_match_all(
690✔
3396
            '/' . implode('|', $this->pregOperators) . '/i',
690✔
3397
            $str,
690✔
3398
            $match
690✔
3399
        ) ? ($list ? $match[0] : $match[0][0]) : false;
690✔
3400
    }
3401

3402
    /**
3403
     * Returns the SQL string operator from where key
3404
     *
3405
     * @return false|list<string>
3406
     */
3407
    private function getOperatorFromWhereKey(string $whereKey)
3408
    {
3409
        $whereKey = trim($whereKey);
657✔
3410

3411
        $pregOperators = [
657✔
3412
            '\s*(?:<|>|!)?=',         // =, <=, >=, !=
657✔
3413
            '\s*<>?',                 // <, <>
657✔
3414
            '\s*>',                   // >
657✔
3415
            '\s+IS NULL',             // IS NULL
657✔
3416
            '\s+IS NOT NULL',         // IS NOT NULL
657✔
3417
            '\s+EXISTS\s*\(.*\)',     // EXISTS (sql)
657✔
3418
            '\s+NOT EXISTS\s*\(.*\)', // NOT EXISTS (sql)
657✔
3419
            '\s+BETWEEN\s+',          // BETWEEN value AND value
657✔
3420
            '\s+IN\s*\(.*\)',         // IN (list)
657✔
3421
            '\s+NOT IN\s*\(.*\)',     // NOT IN (list)
657✔
3422
            '\s+LIKE',                // LIKE
657✔
3423
            '\s+NOT LIKE',            // NOT LIKE
657✔
3424
        ];
657✔
3425

3426
        return preg_match_all(
657✔
3427
            '/' . implode('|', $pregOperators) . '/i',
657✔
3428
            $whereKey,
657✔
3429
            $match
657✔
3430
        ) ? $match[0] : false;
657✔
3431
    }
3432

3433
    /**
3434
     * Stores a bind value after ensuring that it's unique.
3435
     * While it might be nicer to have named keys for our binds array
3436
     * with PHP 7+ we get a huge memory/performance gain with indexed
3437
     * arrays instead, so lets take advantage of that here.
3438
     *
3439
     * @param mixed $value
3440
     */
3441
    protected function setBind(string $key, $value = null, bool $escape = true): string
3442
    {
3443
        if (! array_key_exists($key, $this->binds)) {
693✔
3444
            $this->binds[$key] = [
693✔
3445
                $value,
693✔
3446
                $escape,
693✔
3447
            ];
693✔
3448

3449
            return $key;
693✔
3450
        }
3451

3452
        if (! array_key_exists($key, $this->bindsKeyCount)) {
39✔
3453
            $this->bindsKeyCount[$key] = 1;
39✔
3454
        }
3455

3456
        $count = $this->bindsKeyCount[$key]++;
39✔
3457

3458
        $this->binds[$key . '.' . $count] = [
39✔
3459
            $value,
39✔
3460
            $escape,
39✔
3461
        ];
39✔
3462

3463
        return $key . '.' . $count;
39✔
3464
    }
3465

3466
    /**
3467
     * Returns a clone of a Base Builder with reset query builder values.
3468
     *
3469
     * @return $this
3470
     *
3471
     * @deprecated
3472
     */
3473
    protected function cleanClone()
3474
    {
3475
        return (clone $this)->from([], true)->resetQuery();
3476
    }
3477

3478
    /**
3479
     * @param mixed $value
3480
     */
3481
    protected function isSubquery($value): bool
3482
    {
3483
        return $value instanceof BaseBuilder || $value instanceof Closure;
665✔
3484
    }
3485

3486
    /**
3487
     * @param BaseBuilder|Closure $builder
3488
     * @param bool                $wrapped Wrap the subquery in brackets
3489
     * @param string              $alias   Subquery alias
3490
     */
3491
    protected function buildSubquery($builder, bool $wrapped = false, string $alias = ''): string
3492
    {
3493
        if ($builder instanceof Closure) {
21✔
3494
            $builder($builder = $this->db->newQuery());
11✔
3495
        }
3496

3497
        if ($builder === $this) {
21✔
3498
            throw new DatabaseException('The subquery cannot be the same object as the main query object.');
1✔
3499
        }
3500

3501
        $subquery = strtr($builder->getCompiledSelect(false), "\n", ' ');
20✔
3502

3503
        if ($wrapped) {
20✔
3504
            $subquery = '(' . $subquery . ')';
20✔
3505
            $alias    = trim($alias);
20✔
3506

3507
            if ($alias !== '') {
20✔
3508
                $subquery .= ' ' . ($this->db->protectIdentifiers ? $this->db->escapeIdentifiers($alias) : $alias);
11✔
3509
            }
3510
        }
3511

3512
        return $subquery;
20✔
3513
    }
3514
}
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