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

codeigniter4 / CodeIgniter4 / 25908013250

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

Pull #10159

github

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

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

446 existing lines in 24 files now uncovered.

24114 of 27260 relevant lines covered (88.46%)

219.07 hits per line

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

91.78
/system/Database/SQLSRV/Builder.php
1
<?php
2

3
declare(strict_types=1);
4

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

14
namespace CodeIgniter\Database\SQLSRV;
15

16
use CodeIgniter\Database\BaseBuilder;
17
use CodeIgniter\Database\BaseResult;
18
use CodeIgniter\Database\Exceptions\DatabaseException;
19
use CodeIgniter\Database\Exceptions\DataException;
20
use CodeIgniter\Database\Query;
21
use CodeIgniter\Database\RawSql;
22
use CodeIgniter\Database\ResultInterface;
23
use CodeIgniter\Exceptions\InvalidArgumentException;
24
use Config\Feature;
25
use TypeError;
26

27
/**
28
 * Builder for SQLSRV
29
 *
30
 * @todo auto check for TextCastToInt
31
 * @todo auto check for InsertIndexValue
32
 * @todo replace: delete index entries before insert
33
 */
34
class Builder extends BaseBuilder
35
{
36
    private const LOCK_FOR_UPDATE_HINT = ' WITH (UPDLOCK, ROWLOCK)';
37

38
    /**
39
     * ORDER BY random keyword
40
     *
41
     * @var array
42
     */
43
    protected $randomKeyword = [
44
        'NEWID()',
45
        'RAND(%d)',
46
    ];
47

48
    /**
49
     * Quoted identifier flag
50
     *
51
     * Whether to use SQL-92 standard quoted identifier
52
     * (double quotes) or brackets for identifier escaping.
53
     *
54
     * @var bool
55
     */
56
    protected $_quoted_identifier = true;
57

58
    /**
59
     * Handle increment/decrement on text
60
     *
61
     * @var bool
62
     */
63
    public $castTextToInt = true;
64

65
    /**
66
     * Handle IDENTITY_INSERT property/
67
     *
68
     * @var bool
69
     */
70
    public $keyPermission = false;
71

72
    /**
73
     * Groups tables in FROM clauses if needed, so there is no confusion
74
     * about operator precedence.
75
     */
76
    protected function _fromTables(): string
77
    {
78
        $from = [];
843✔
79

80
        foreach ($this->QBFrom as $value) {
843✔
81
            if (str_starts_with($value, '(SELECT')) {
843✔
82
                $from[] = $value;
3✔
83

84
                continue;
3✔
85
            }
86

87
            $from[] = $this->getFullName($value) . ($this->QBLockForUpdate ? self::LOCK_FOR_UPDATE_HINT : '');
843✔
88
        }
89

90
        return implode(', ', $from);
843✔
91
    }
92

93
    /**
94
     * Generates a platform-specific truncate string from the supplied data
95
     *
96
     * If the database does not support the truncate() command,
97
     * then this method maps to 'DELETE FROM table'
98
     */
99
    protected function _truncate(string $table): string
100
    {
101
        return 'TRUNCATE TABLE ' . $this->getFullName($table);
727✔
102
    }
103

104
    /**
105
     * Generates the JOIN portion of the query
106
     *
107
     * @param RawSql|string $cond
108
     *
109
     * @return $this
110
     */
111
    public function join(string $table, $cond, string $type = '', ?bool $escape = null)
112
    {
113
        if ($type !== '') {
8✔
114
            $type = strtoupper(trim($type));
4✔
115

116
            if (! in_array($type, $this->joinTypes, true)) {
4✔
UNCOV
117
                $type = '';
×
118
            } else {
119
                $type .= ' ';
4✔
120
            }
121
        }
122

123
        // Extract any aliases that might exist. We use this information
124
        // in the protectIdentifiers to know whether to add a table prefix
125
        $this->trackAliases($table);
8✔
126

127
        if (! is_bool($escape)) {
8✔
128
            $escape = $this->db->protectIdentifiers;
8✔
129
        }
130

131
        if (! $this->hasOperator($cond)) {
8✔
132
            $cond = ' USING (' . ($escape ? $this->db->escapeIdentifiers($cond) : $cond) . ')';
×
133
        } elseif ($escape === false) {
8✔
134
            $cond = ' ON ' . $cond;
×
135
        } else {
136
            // Split multiple conditions
137
            if (preg_match_all('/\sAND\s|\sOR\s/i', $cond, $joints, PREG_OFFSET_CAPTURE) >= 1) {
8✔
UNCOV
138
                $conditions = [];
×
UNCOV
139
                $joints     = $joints[0];
×
UNCOV
140
                array_unshift($joints, ['', 0]);
×
141

UNCOV
142
                for ($i = count($joints) - 1, $pos = strlen($cond); $i >= 0; $i--) {
×
UNCOV
143
                    $joints[$i][1] += strlen($joints[$i][0]); // offset
×
UNCOV
144
                    $conditions[$i] = substr($cond, $joints[$i][1], $pos - $joints[$i][1]);
×
UNCOV
145
                    $pos            = $joints[$i][1] - strlen($joints[$i][0]);
×
UNCOV
146
                    $joints[$i]     = $joints[$i][0];
×
147
                }
148

UNCOV
149
                ksort($conditions);
×
150
            } else {
151
                $conditions = [$cond];
8✔
152
                $joints     = [''];
8✔
153
            }
154

155
            $cond = ' ON ';
8✔
156

157
            foreach ($conditions as $i => $condition) {
8✔
158
                $operator = $this->getOperator($condition);
8✔
159

160
                // Workaround for BETWEEN
161
                if ($operator === false) {
8✔
UNCOV
162
                    $cond .= $joints[$i] . $condition;
×
163

UNCOV
164
                    continue;
×
165
                }
166

167
                $cond .= $joints[$i];
8✔
168
                $cond .= preg_match('/(\(*)?([\[\]\w\.\'-]+)' . preg_quote($operator, '/') . '(.*)/i', $condition, $match) ? $match[1] . $this->db->protectIdentifiers($match[2]) . $operator . $this->db->protectIdentifiers($match[3]) : $condition;
8✔
169
            }
170
        }
171

172
        // Do we want to escape the table name?
173
        if ($escape === true) {
8✔
174
            $table = $this->db->protectIdentifiers($table, true, null, false);
8✔
175
        }
176

177
        // Assemble the JOIN statement
178
        $this->QBJoin[] = $type . 'JOIN ' . $this->getFullName($table) . $cond;
8✔
179

180
        return $this;
8✔
181
    }
182

183
    /**
184
     * Generates a platform-specific insert string from the supplied data
185
     *
186
     * @todo implement check for this instead static $insertKeyPermission
187
     */
188
    protected function _insert(string $table, array $keys, array $unescapedKeys): string
189
    {
190
        $fullTableName = $this->getFullName($table);
797✔
191

192
        // insert statement
193
        $statement = 'INSERT INTO ' . $fullTableName . ' (' . implode(',', $keys) . ') VALUES (' . implode(', ', $unescapedKeys) . ')';
797✔
194

195
        return $this->keyPermission ? $this->addIdentity($fullTableName, $statement) : $statement;
797✔
196
    }
197

198
    /**
199
     * Insert batch statement
200
     *
201
     * Generates a platform-specific insert string from the supplied data.
202
     */
203
    protected function _insertBatch(string $table, array $keys, array $values): string
204
    {
205
        $sql = $this->QBOptions['sql'] ?? '';
21✔
206

207
        // if this is the first iteration of batch then we need to build skeleton sql
208
        if ($sql === '') {
21✔
209
            $sql = 'INSERT ' . $this->compileIgnore('insert') . 'INTO ' . $this->getFullName($table)
21✔
210
                . ' (' . implode(', ', $keys) . ")\n{:_table_:}";
21✔
211

212
            $this->QBOptions['sql'] = $sql;
21✔
213
        }
214

215
        if (isset($this->QBOptions['setQueryAsData'])) {
21✔
216
            $data = $this->QBOptions['setQueryAsData'];
2✔
217
        } else {
218
            $data = 'VALUES ' . implode(', ', $this->formatValues($values));
21✔
219
        }
220

221
        return str_replace('{:_table_:}', $data, $sql);
21✔
222
    }
223

224
    /**
225
     * Generates a platform-specific update string from the supplied data
226
     */
227
    protected function _update(string $table, array $values): string
228
    {
229
        $valstr = [];
95✔
230

231
        foreach ($values as $key => $val) {
95✔
232
            $valstr[] = $key . ' = ' . $val;
95✔
233
        }
234

235
        $fullTableName = $this->getFullName($table);
95✔
236

237
        $statement = sprintf('UPDATE %s%s SET ', empty($this->QBLimit) ? '' : 'TOP(' . $this->QBLimit . ') ', $fullTableName);
95✔
238

239
        $statement .= implode(', ', $valstr)
95✔
240
            . $this->compileWhereHaving('QBWhere')
95✔
241
            . $this->compileOrderBy();
95✔
242

243
        return $this->keyPermission ? $this->addIdentity($fullTableName, $statement) : $statement;
95✔
244
    }
245

246
    /**
247
     * Increments multiple numeric columns by the specified value(s).
248
     *
249
     * @param array<string, int>|list<string> $columns A list of columns or array of column => value pairs to increment.
250
     * @param int                             $value   The value to increment by if $columns is a list of column names.
251
     */
252
    public function incrementMany(array $columns, int $value = 1): bool
253
    {
254
        if ($columns === []) {
12✔
255
            throw new InvalidArgumentException('Argument #1 ($columns) cannot be empty.');
1✔
256
        }
257

258
        if (array_is_list($columns)) {
11✔
259
            $columns = array_fill_keys($columns, $value);
8✔
260
        }
261

262
        $fields = [];
11✔
263

264
        foreach ($columns as $col => $val) {
11✔
265
            if (! is_int($val)) {
11✔
266
                throw new TypeError(sprintf(
1✔
267
                    'Argument #1 ($columns) must contain only int values, %s given for "%s".',
1✔
268
                    get_debug_type($val),
1✔
269
                    $col,
1✔
270
                ));
1✔
271
            }
272

273
            $col = $this->db->protectIdentifiers($col);
11✔
274
            if ($this->castTextToInt) {
11✔
275
                $fields[$col] = "CONVERT(VARCHAR(MAX),CONVERT(INT,CONVERT(VARCHAR(MAX), {$col})) + {$val})";
10✔
276
            } else {
277
                $fields[$col] = "{$col} + {$val}";
1✔
278
            }
279
        }
280

281
        $sql = $this->_update($this->QBFrom[0], $fields);
10✔
282

283
        if (! $this->testMode) {
10✔
284
            $this->resetWrite();
10✔
285

286
            return $this->db->query($sql, $this->binds, false);
10✔
287
        }
288

UNCOV
289
        return true;
×
290
    }
291

292
    /**
293
     * Decrements multiple numeric columns by the specified value(s).
294
     *
295
     * @param array<string, int>|list<string> $columns A list of columns or array of column => value pairs to decrement.
296
     * @param int                             $value   The value to decrement by if $columns is a list of column names.
297
     */
298
    public function decrementMany(array $columns, int $value = 1): bool
299
    {
300
        if ($columns === []) {
12✔
301
            throw new InvalidArgumentException('Argument #1 ($columns) cannot be empty.');
1✔
302
        }
303

304
        if (array_is_list($columns)) {
11✔
305
            $columns = array_fill_keys($columns, $value);
8✔
306
        }
307

308
        $fields = [];
11✔
309

310
        foreach ($columns as $col => $val) {
11✔
311
            if (! is_int($val)) {
11✔
312
                throw new TypeError(sprintf(
1✔
313
                    'Argument #1 ($columns) must contain only int values, %s given for "%s".',
1✔
314
                    get_debug_type($val),
1✔
315
                    $col,
1✔
316
                ));
1✔
317
            }
318

319
            $col = $this->db->protectIdentifiers($col);
11✔
320
            if ($this->castTextToInt) {
11✔
321
                $fields[$col] = "CONVERT(VARCHAR(MAX),CONVERT(INT,CONVERT(VARCHAR(MAX), {$col})) - {$val})";
10✔
322
            } else {
323
                $fields[$col] = "{$col} - {$val}";
1✔
324
            }
325
        }
326

327
        $sql = $this->_update($this->QBFrom[0], $fields);
10✔
328

329
        if (! $this->testMode) {
10✔
330
            $this->resetWrite();
10✔
331

332
            return $this->db->query($sql, $this->binds, false);
10✔
333
        }
334

UNCOV
335
        return true;
×
336
    }
337

338
    /**
339
     * Get full name of the table
340
     */
341
    private function getFullName(string $table): string
342
    {
343
        $alias = '';
843✔
344

345
        if (str_contains($table, ' ')) {
843✔
346
            $alias = explode(' ', $table);
4✔
347
            $table = array_shift($alias);
4✔
348
            $alias = ' ' . implode(' ', $alias);
4✔
349
        }
350

351
        if ($this->db->escapeChar === '"') {
843✔
352
            if (str_contains($table, '.') && ! str_starts_with($table, '.') && ! str_ends_with($table, '.')) {
843✔
353
                $dbInfo   = explode('.', $table);
2✔
354
                $database = $this->db->getDatabase();
2✔
355
                $table    = $dbInfo[0];
2✔
356

357
                if (count($dbInfo) === 3) {
2✔
358
                    $database  = str_replace('"', '', $dbInfo[0]);
1✔
359
                    $schema    = str_replace('"', '', $dbInfo[1]);
1✔
360
                    $tableName = str_replace('"', '', $dbInfo[2]);
1✔
361
                } else {
362
                    $schema    = str_replace('"', '', $dbInfo[0]);
1✔
363
                    $tableName = str_replace('"', '', $dbInfo[1]);
1✔
364
                }
365

366
                return '"' . $database . '"."' . $schema . '"."' . str_replace('"', '', $tableName) . '"' . $alias;
2✔
367
            }
368

369
            return '"' . $this->db->getDatabase() . '"."' . $this->db->schema . '"."' . str_replace('"', '', $table) . '"' . $alias;
841✔
370
        }
371

372
        return '[' . $this->db->getDatabase() . '].[' . $this->db->schema . '].[' . str_replace('"', '', $table) . ']' . str_replace('"', '', $alias);
×
373
    }
374

375
    /**
376
     * Add permission statements for index value inserts
377
     */
378
    private function addIdentity(string $fullTable, string $insert): string
379
    {
380
        return 'SET IDENTITY_INSERT ' . $fullTable . " ON\n" . $insert . "\nSET IDENTITY_INSERT " . $fullTable . ' OFF';
5✔
381
    }
382

383
    /**
384
     * Local implementation of limit
385
     */
386
    protected function _limit(string $sql, bool $offsetIgnore = false): string
387
    {
388
        // SQL Server cannot handle `LIMIT 0`.
389
        // DatabaseException:
390
        //   [Microsoft][ODBC Driver 17 for SQL Server][SQL Server]The number of
391
        //   rows provided for a FETCH clause must be greater then zero.
392
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
101✔
393
        if (! $limitZeroAsAll && $this->QBLimit === 0) {
101✔
394
            return "SELECT * \nFROM " . $this->_fromTables() . ' WHERE 1=0 ';
1✔
395
        }
396

397
        if (empty($this->QBOrderBy)) {
100✔
398
            $sql .= ' ORDER BY (SELECT NULL) ';
83✔
399
        }
400

401
        if ($offsetIgnore) {
100✔
UNCOV
402
            $sql .= ' OFFSET 0 ';
×
403
        } else {
404
            $sql .= is_int($this->QBOffset) ? ' OFFSET ' . $this->QBOffset : ' OFFSET 0 ';
100✔
405
        }
406

407
        return $sql . ' ROWS FETCH NEXT ' . $this->QBLimit . ' ROWS ONLY ';
100✔
408
    }
409

410
    /**
411
     * Compiles a replace into string and runs the query
412
     *
413
     * @return BaseResult|false|Query|string
414
     *
415
     * @throws DatabaseException
416
     */
417
    public function replace(?array $set = null)
418
    {
419
        if ($set !== null) {
5✔
420
            $this->set($set);
5✔
421
        }
422

423
        if ($this->QBSet === []) {
5✔
UNCOV
424
            if ($this->db->DBDebug) {
×
UNCOV
425
                throw new DatabaseException('You must use the "set" method to update an entry.');
×
426
            }
427

428
            return false; // @codeCoverageIgnore
429
        }
430

431
        $table = $this->QBFrom[0];
5✔
432

433
        $sql = $this->_replace($table, array_keys($this->QBSet), array_values($this->QBSet));
5✔
434

435
        $this->resetWrite();
5✔
436

437
        if ($this->testMode) {
5✔
438
            return $sql;
1✔
439
        }
440

441
        $this->db->simpleQuery('SET IDENTITY_INSERT ' . $this->getFullName($table) . ' ON');
4✔
442

443
        $result = $this->db->query($sql, $this->binds, false);
4✔
444
        $this->db->simpleQuery('SET IDENTITY_INSERT ' . $this->getFullName($table) . ' OFF');
4✔
445

446
        return $result;
4✔
447
    }
448

449
    /**
450
     * Generates a platform-specific replace string from the supplied data
451
     * on match delete and insert
452
     */
453
    protected function _replace(string $table, array $keys, array $values): string
454
    {
455
        // check whether the existing keys are part of the primary key.
456
        // if so then use them for the "ON" part and exclude them from the $values and $keys
457
        $pKeys     = $this->db->getIndexData($table);
5✔
458
        $keyFields = [];
5✔
459

460
        foreach ($pKeys as $key) {
5✔
461
            if ($key->type === 'PRIMARY') {
5✔
462
                $keyFields = array_merge($keyFields, $key->fields);
5✔
463
            }
464

465
            if ($key->type === 'UNIQUE') {
5✔
466
                $keyFields = array_merge($keyFields, $key->fields);
1✔
467
            }
468
        }
469

470
        // Get the unique field names
471
        $escKeyFields = array_map(fn (string $field): string => $this->db->protectIdentifiers($field), array_values(array_unique($keyFields)));
5✔
472

473
        // Get the binds
474
        $binds = $this->binds;
5✔
475
        array_walk($binds, static function (&$item): void {
5✔
476
            $item = $item[0];
5✔
477
        });
5✔
478

479
        // Get the common field and values from the keys data and index fields
480
        $common = array_intersect($keys, $escKeyFields);
5✔
481
        $bingo  = [];
5✔
482

483
        foreach ($common as $v) {
5✔
484
            $k = array_search($v, $keys, true);
5✔
485

486
            $bingo[$keys[$k]] = $binds[trim($values[$k], ':')];
5✔
487
        }
488

489
        // Querying existing data
490
        $builder = $this->db->table($table);
5✔
491

492
        foreach ($bingo as $k => $v) {
5✔
493
            $builder->where($k, $v);
5✔
494
        }
495

496
        $q = $builder->get()->getResult();
5✔
497

498
        // Delete entries if we find them
499
        if ($q !== []) {
5✔
500
            $delete = $this->db->table($table);
4✔
501

502
            foreach ($bingo as $k => $v) {
4✔
503
                $delete->where($k, $v);
4✔
504
            }
505

506
            $delete->delete();
4✔
507
        }
508

509
        return sprintf('INSERT INTO %s (%s) VALUES (%s);', $this->getFullName($table), implode(',', $keys), implode(',', $values));
5✔
510
    }
511

512
    /**
513
     * SELECT [MAX|MIN|AVG|SUM|COUNT]()
514
     *
515
     * Handle float return value
516
     *
517
     * @return $this
518
     */
519
    protected function maxMinAvgSum(string $select = '', string $alias = '', string $type = 'MAX')
520
    {
521
        // int functions can be handled by parent
522
        if ($type !== 'AVG') {
771✔
523
            return parent::maxMinAvgSum($select, $alias, $type);
771✔
524
        }
525

526
        if ($select === '') {
2✔
UNCOV
527
            throw DataException::forEmptyInputGiven('Select');
×
528
        }
529

530
        if (str_contains($select, ',')) {
2✔
UNCOV
531
            throw DataException::forInvalidArgument('Column name not separated by comma');
×
532
        }
533

534
        if ($alias === '') {
2✔
535
            $alias = $this->createAliasFromTable(trim($select));
1✔
536
        }
537

538
        $sql = $type . '( CAST( ' . $this->db->protectIdentifiers(trim($select)) . ' AS FLOAT ) ) AS ' . $this->db->escapeIdentifiers(trim($alias));
2✔
539

540
        $this->QBSelect[]   = $sql;
2✔
541
        $this->QBNoEscape[] = null;
2✔
542

543
        return $this;
2✔
544
    }
545

546
    /**
547
     * "Count All" query
548
     *
549
     * Generates a platform-specific query string that counts all records in
550
     * the particular table
551
     *
552
     * @param bool $reset Are we want to clear query builder values?
553
     *
554
     * @return int|string when $test = true
555
     */
556
    public function countAll(bool $reset = true)
557
    {
558
        $table = $this->QBFrom[0];
5✔
559

560
        $sql = $this->countString . $this->db->escapeIdentifiers('numrows') . ' FROM ' . $this->getFullName($table);
5✔
561

562
        if ($this->testMode) {
5✔
UNCOV
563
            return $sql;
×
564
        }
565

566
        $query = $this->db->query($sql, null, false);
5✔
567
        if (empty($query->getResult())) {
5✔
UNCOV
568
            return 0;
×
569
        }
570

571
        $query = $query->getRow();
5✔
572

573
        if ($reset) {
5✔
574
            $this->resetSelect();
5✔
575
        }
576

577
        return (int) $query->numrows;
5✔
578
    }
579

580
    /**
581
     * Delete statement
582
     */
583
    protected function _delete(string $table): string
584
    {
585
        return 'DELETE' . (empty($this->QBLimit) ? '' : ' TOP (' . $this->QBLimit . ') ') . ' FROM ' . $this->getFullName($table) . $this->compileWhereHaving('QBWhere');
761✔
586
    }
587

588
    /**
589
     * Compiles a delete string and runs the query
590
     *
591
     * @param mixed $where
592
     *
593
     * @return bool|string
594
     *
595
     * @throws DatabaseException
596
     */
597
    public function delete($where = '', ?int $limit = null, bool $resetData = true)
598
    {
599
        $table = $this->db->protectIdentifiers($this->QBFrom[0], true, null, false);
759✔
600

601
        if ($where !== '') {
759✔
602
            $this->where($where);
4✔
603
        }
604

605
        if ($this->QBWhere === []) {
759✔
606
            if ($this->db->DBDebug) {
2✔
607
                throw new DatabaseException('Deletes are not allowed unless they contain a "where" or "like" clause.');
2✔
608
            }
609

610
            return false; // @codeCoverageIgnore
611
        }
612

613
        if ($limit !== null && $limit !== 0) {
759✔
614
            $this->QBLimit = $limit;
1✔
615
        }
616

617
        $sql = $this->_delete($table);
759✔
618

619
        if ($resetData) {
759✔
620
            $this->resetWrite();
759✔
621
        }
622

623
        return $this->testMode ? $sql : $this->db->query($sql, $this->binds, false);
759✔
624
    }
625

626
    /**
627
     * Compile the SELECT statement
628
     *
629
     * Generates a query string based on which functions were used.
630
     *
631
     * @param bool $selectOverride
632
     */
633
    protected function compileSelect($selectOverride = false): string
634
    {
635
        // Write the "select" portion of the query
636
        if ($selectOverride !== false) {
844✔
637
            $sql = $selectOverride;
222✔
638
        } else {
639
            $sql = $this->QBDistinct ? 'SELECT DISTINCT ' : 'SELECT ';
844✔
640

641
            // SQL Server can't work with select * if group by is specified
642
            if (empty($this->QBSelect) && $this->QBGroupBy !== [] && is_array($this->QBGroupBy)) {
844✔
UNCOV
643
                foreach ($this->QBGroupBy as $field) {
×
UNCOV
644
                    $this->QBSelect[] = is_array($field) ? $field['field'] : $field;
×
645
                }
646
            }
647

648
            if (empty($this->QBSelect)) {
844✔
649
                $sql .= '*';
825✔
650
            } else {
651
                // Cycle through the "select" portion of the query and prep each column name.
652
                // The reason we protect identifiers here rather than in the select() function
653
                // is because until the user calls the from() function we don't know if there are aliases
654
                foreach ($this->QBSelect as $key => $val) {
793✔
655
                    $noEscape             = $this->QBNoEscape[$key] ?? null;
793✔
656
                    $this->QBSelect[$key] = $this->db->protectIdentifiers($val, false, $noEscape);
793✔
657
                }
658

659
                $sql .= implode(', ', $this->QBSelect);
793✔
660
            }
661
        }
662

663
        // Write the "FROM" portion of the query
664
        if ($this->QBFrom !== []) {
844✔
665
            $sql .= "\nFROM " . $this->_fromTables();
843✔
666
        }
667

668
        // Write the "JOIN" portion of the query
669
        if (! empty($this->QBJoin)) {
844✔
670
            $sql .= "\n" . implode("\n", $this->QBJoin);
8✔
671
        }
672

673
        $sql .= $this->compileWhereHaving('QBWhere')
844✔
674
            . $this->compileGroupBy()
844✔
675
            . $this->compileWhereHaving('QBHaving')
844✔
676
            . $this->compileOrderBy(); // ORDER BY
844✔
677

678
        // LIMIT
679
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
844✔
680
        if ($limitZeroAsAll) {
844✔
681
            if ($this->QBLimit) {
844✔
682
                $sql = $this->_limit($sql . "\n");
100✔
683
            }
684
        } elseif ($this->QBLimit !== false || $this->QBOffset) {
1✔
685
            $sql = $this->_limit($sql . "\n");
1✔
686
        }
687

688
        $sql .= $this->compileLockForUpdate();
844✔
689

690
        return $this->unionInjection($sql);
843✔
691
    }
692

693
    /**
694
     * Compile the SELECT lock clause.
695
     */
696
    protected function compileLockForUpdate(): string
697
    {
698
        if (! $this->QBLockForUpdate) {
844✔
699
            return '';
839✔
700
        }
701

702
        if ($this->QBFrom === []) {
8✔
703
            throw new DatabaseException('SQLSRV does not support lockForUpdate() without a FROM table.');
1✔
704
        }
705

706
        if ($this->QBUnion !== []) {
7✔
707
            throw new DatabaseException('Query Builder does not support lockForUpdate() with union() or unionAll().');
1✔
708
        }
709

710
        foreach ($this->QBFrom as $value) {
6✔
711
            if (str_starts_with($value, '(SELECT')) {
6✔
712
                throw new DatabaseException('SQLSRV does not support lockForUpdate() on subqueries.');
1✔
713
            }
714
        }
715

716
        return '';
5✔
717
    }
718

719
    /**
720
     * Compiles the select statement based on the other functions called
721
     * and runs the query
722
     *
723
     * @return ResultInterface
724
     */
725
    public function get(?int $limit = null, int $offset = 0, bool $reset = true)
726
    {
727
        $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true; // @phpstan-ignore nullCoalesce.property
829✔
728
        if ($limitZeroAsAll && $limit === 0) {
829✔
UNCOV
729
            $limit = null;
×
730
        }
731

732
        if ($limit !== null) {
829✔
733
            $this->limit($limit, $offset);
7✔
734
        }
735

736
        $result = $this->testMode ? $this->getCompiledSelect($reset) : $this->db->query($this->compileSelect(), $this->binds, false);
829✔
737

738
        if ($reset) {
829✔
739
            $this->resetSelect();
829✔
740

741
            // Clear our binds so we don't eat up memory
742
            $this->binds = [];
829✔
743
        }
744

745
        return $result;
829✔
746
    }
747

748
    /**
749
     * Generates a platform-specific upsertBatch string from the supplied data
750
     *
751
     * @throws DatabaseException
752
     */
753
    protected function _upsertBatch(string $table, array $keys, array $values): string
754
    {
755
        $sql = $this->QBOptions['sql'] ?? '';
19✔
756

757
        // if this is the first iteration of batch then we need to build skeleton sql
758
        if ($sql === '') {
19✔
759
            $fullTableName = $this->getFullName($table);
19✔
760

761
            $constraints = $this->QBOptions['constraints'] ?? [];
19✔
762

763
            $tableIdentity = $this->QBOptions['tableIdentity'] ?? '';
19✔
764
            $sql           = "SELECT name from syscolumns where id = Object_ID('" . $table . "') and colstat = 1";
19✔
765
            if (($query = $this->db->query($sql)) === false) {
19✔
UNCOV
766
                throw new DatabaseException('Failed to get table identity');
×
767
            }
768
            $query = $query->getResultObject();
19✔
769

770
            foreach ($query as $row) {
19✔
771
                $tableIdentity = '"' . $row->name . '"';
18✔
772
            }
773
            $this->QBOptions['tableIdentity'] = $tableIdentity;
19✔
774

775
            $identityInFields = in_array($tableIdentity, $keys, true);
19✔
776

777
            $fieldNames = array_map(static fn ($columnName): string => trim($columnName, '"'), $keys);
19✔
778

779
            if (empty($constraints)) {
19✔
780
                $tableIndexes = $this->db->getIndexData($table);
12✔
781

782
                $uniqueIndexes = array_filter($tableIndexes, static function ($index) use ($fieldNames): bool {
12✔
783
                    $hasAllFields = count(array_intersect($index->fields, $fieldNames)) === count($index->fields);
12✔
784

785
                    return $index->type === 'PRIMARY' && $hasAllFields;
12✔
786
                });
12✔
787

788
                // if no primary found then look for unique - since indexes have no order
789
                if ($uniqueIndexes === []) {
12✔
790
                    $uniqueIndexes = array_filter($tableIndexes, static function ($index) use ($fieldNames): bool {
10✔
791
                        $hasAllFields = count(array_intersect($index->fields, $fieldNames)) === count($index->fields);
10✔
792

793
                        return $index->type === 'UNIQUE' && $hasAllFields;
10✔
794
                    });
10✔
795
                }
796

797
                // only take first index
798
                foreach ($uniqueIndexes as $index) {
12✔
799
                    $constraints = $index->fields;
11✔
800
                    break;
11✔
801
                }
802

803
                $constraints = $this->onConstraint($constraints)->QBOptions['constraints'] ?? [];
12✔
804
            }
805

806
            if (empty($constraints)) {
19✔
807
                if ($this->db->DBDebug) {
1✔
808
                    throw new DatabaseException('No constraint found for upsert.');
1✔
809
                }
810

811
                return ''; // @codeCoverageIgnore
812
            }
813

814
            $alias = $this->QBOptions['alias'] ?? '"_upsert"';
18✔
815

816
            $updateFields = $this->QBOptions['updateFields'] ?? $this->updateFields($keys, false, $constraints)->QBOptions['updateFields'] ?? [];
18✔
817

818
            $sql = 'MERGE INTO ' . $fullTableName . "\nUSING (\n";
18✔
819

820
            $sql .= '{:_table_:}';
18✔
821

822
            $sql .= ") {$alias} (";
18✔
823

824
            $sql .= implode(', ', $keys);
18✔
825

826
            $sql .= ')';
18✔
827

828
            $sql .= "\nON (";
18✔
829

830
            $sql .= implode(
18✔
831
                ' AND ',
18✔
832
                array_map(
18✔
833
                    static fn ($key, $value) => (
18✔
834
                        ($value instanceof RawSql && is_string($key))
18✔
835
                        ?
18✔
UNCOV
836
                        $fullTableName . '.' . $key . ' = ' . $value
×
837
                        :
18✔
838
                        (
18✔
839
                            $value instanceof RawSql
18✔
840
                            ?
18✔
841
                            $value
1✔
842
                            :
18✔
843
                            $fullTableName . '.' . $value . ' = ' . $alias . '.' . $value
18✔
844
                        )
18✔
845
                    ),
18✔
846
                    array_keys($constraints),
18✔
847
                    $constraints,
18✔
848
                ),
18✔
849
            ) . ")\n";
18✔
850

851
            $sql .= "WHEN MATCHED THEN UPDATE SET\n";
18✔
852

853
            $sql .= implode(
18✔
854
                ",\n",
18✔
855
                array_map(
18✔
856
                    static fn ($key, $value): string => $key . ($value instanceof RawSql ?
18✔
857
                        ' = ' . $value :
2✔
858
                    " = {$alias}.{$value}"),
18✔
859
                    array_keys($updateFields),
18✔
860
                    $updateFields,
18✔
861
                ),
18✔
862
            );
18✔
863

864
            $sql .= "\nWHEN NOT MATCHED THEN INSERT (" . implode(', ', $keys) . ")\nVALUES ";
18✔
865

866
            $sql .= (
18✔
867
                '(' . implode(
18✔
868
                    ', ',
18✔
869
                    array_map(
18✔
870
                        static fn ($columnName): string => $columnName === $tableIdentity
18✔
871
                    ? "CASE WHEN {$alias}.{$columnName} IS NULL THEN (SELECT "
5✔
872
                    . 'isnull(IDENT_CURRENT(\'' . $fullTableName . '\')+IDENT_INCR(\''
5✔
873
                    . $fullTableName . "'),1)) ELSE {$alias}.{$columnName} END"
5✔
874
                    : "{$alias}.{$columnName}",
18✔
875
                        $keys,
18✔
876
                    ),
18✔
877
                ) . ');'
18✔
878
            );
18✔
879

880
            $sql = $identityInFields ? $this->addIdentity($fullTableName, $sql) : $sql;
18✔
881

882
            $this->QBOptions['sql'] = $sql;
18✔
883
        }
884

885
        if (isset($this->QBOptions['setQueryAsData'])) {
18✔
886
            $data = $this->QBOptions['setQueryAsData'];
1✔
887
        } else {
888
            $data = 'VALUES ' . implode(', ', $this->formatValues($values)) . "\n";
17✔
889
        }
890

891
        return str_replace('{:_table_:}', $data, $sql);
18✔
892
    }
893

894
    /**
895
     * Gets column names from a select query
896
     */
897
    protected function fieldsFromQuery(string $sql): array
898
    {
899
        return $this->db->query('SELECT TOP 1 * FROM (' . $sql . ') _u_')->getFieldNames();
4✔
900
    }
901
}
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