Passed
Pull Request — master (#56)
by Def
01:34
created

QueryBuilder::update()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
eloc 1
c 0
b 0
f 0
nc 1
nop 4
dl 0
loc 3
ccs 0
cts 0
cp 0
crap 2
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Db\Mssql;
6
7
use JsonException;
8
use Yiisoft\Db\Constraint\Constraint;
9
use Yiisoft\Db\Exception\Exception;
10
use Yiisoft\Db\Exception\InvalidArgumentException;
11
use Yiisoft\Db\Exception\InvalidConfigException;
12
use Yiisoft\Db\Exception\NotSupportedException;
13
use Yiisoft\Db\Expression\Expression;
14
use Yiisoft\Db\Expression\ExpressionInterface;
15
use Yiisoft\Db\Mssql\Condition\InConditionBuilder;
16
use Yiisoft\Db\Mssql\Condition\LikeConditionBuilder;
17
use Yiisoft\Db\Query\Conditions\InCondition;
18
use Yiisoft\Db\Query\Conditions\LikeCondition;
19
use Yiisoft\Db\Query\Query;
20
use Yiisoft\Db\Query\QueryBuilder as AbstractQueryBuilder;
21
use Yiisoft\Db\Schema\ColumnSchemaBuilder;
22
23
use Yiisoft\Strings\NumericHelper;
24
use function array_diff;
25
use function array_keys;
26
use function implode;
27
use function in_array;
28
use function is_string;
29
use function ltrim;
30
use function preg_match;
31
use function preg_replace;
32
use function reset;
33
use function strrpos;
34
use function version_compare;
35
36
/**
37
 * QueryBuilder is the query builder for MS SQL Server databases (version 2008 and above).
38
 */
39
final class QueryBuilder extends AbstractQueryBuilder
40
{
41
    /**
42
     * @var array mapping from abstract column types (keys) to physical column types (values).
43
     */
44
    protected array $typeMap = [
45
        Schema::TYPE_PK => 'int IDENTITY PRIMARY KEY',
46
        Schema::TYPE_UPK => 'int IDENTITY PRIMARY KEY',
47
        Schema::TYPE_BIGPK => 'bigint IDENTITY PRIMARY KEY',
48
        Schema::TYPE_UBIGPK => 'bigint IDENTITY PRIMARY KEY',
49
        Schema::TYPE_CHAR => 'nchar(1)',
50
        Schema::TYPE_STRING => 'nvarchar(255)',
51
        Schema::TYPE_TEXT => 'nvarchar(max)',
52
        Schema::TYPE_TINYINT => 'tinyint',
53
        Schema::TYPE_SMALLINT => 'smallint',
54
        Schema::TYPE_INTEGER => 'int',
55
        Schema::TYPE_BIGINT => 'bigint',
56
        Schema::TYPE_FLOAT => 'float',
57
        Schema::TYPE_DOUBLE => 'float',
58
        Schema::TYPE_DECIMAL => 'decimal(18,0)',
59
        Schema::TYPE_DATETIME => 'datetime',
60
        Schema::TYPE_TIMESTAMP => 'datetime',
61
        Schema::TYPE_TIME => 'time',
62
        Schema::TYPE_DATE => 'date',
63
        Schema::TYPE_BINARY => 'varbinary(max)',
64
        Schema::TYPE_BOOLEAN => 'bit',
65
        Schema::TYPE_MONEY => 'decimal(19,4)',
66
    ];
67 249
68
    protected function defaultExpressionBuilders(): array
69 249
    {
70 249
        return array_merge(parent::defaultExpressionBuilders(), [
71
            InCondition::class => InConditionBuilder::class,
72
            LikeCondition::class => LikeConditionBuilder::class,
73
        ]);
74
    }
75
76
    /**
77
     * Builds the ORDER BY and LIMIT/OFFSET clauses and appends them to the given SQL.
78
     *
79
     * @param string $sql the existing SQL (without ORDER BY/LIMIT/OFFSET).
80
     * @param array $orderBy the order by columns. See {@see Query::orderBy} for more details
81
     * on how to specify this
82
     * parameter.
83
     * @param int|object|null $limit the limit number. See {@see Query::limit} for more details.
84
     * @param int|object|null $offset the offset number. See {@see Query::offset} for more
85
     * details.
86
     * @param array $params the binding parameters to be populated.
87
     *
88
     * @throws Exception|InvalidArgumentException
89
     *
90
     * @return string the SQL completed with ORDER BY/LIMIT/OFFSET (if any).
91 171
     */
92
    public function buildOrderByAndLimit(string $sql, array $orderBy, $limit, $offset, array &$params = []): string
93 171
    {
94 162
        if (!$this->hasOffset($offset) && !$this->hasLimit($limit)) {
95
            $orderBy = $this->buildOrderBy($orderBy, $params);
96 162
97
            return $orderBy === '' ? $sql : $sql . $this->separator . $orderBy;
98
        }
99 13
100
        if (version_compare($this->getDb()->getSchema()->getServerVersion(), '11', '<')) {
101
            return $this->oldBuildOrderByAndLimit($sql, $orderBy, $limit, $offset, $params);
102
        }
103 13
104
        return $this->newBuildOrderByAndLimit($sql, $orderBy, $limit, $offset, $params);
105
    }
106
107
    /**
108
     * Builds the ORDER BY/LIMIT/OFFSET clauses for SQL SERVER 2012 or newer.
109
     *
110
     * @param string $sql the existing SQL (without ORDER BY/LIMIT/OFFSET).
111
     * @param array $orderBy the order by columns. See {@see Query::orderBy} for more details on how to specify this
112
     * parameter.
113
     * @param int|Query|null $limit the limit number. See {@see Query::limit} for more details.
114
     * @param int|Query|null $offset the offset number. See {@see Query::offset} for more details.
115
     * @param array $params the binding parameters to be populated.
116
     *
117
     * @throws Exception|InvalidArgumentException
118
     *
119
     * @return string the SQL completed with ORDER BY/LIMIT/OFFSET (if any).
120 13
     */
121
    protected function newBuildOrderByAndLimit(
122
        string $sql,
123
        array $orderBy,
124
        $limit,
125
        $offset,
126
        array &$params = []
127 13
    ): string {
128
        $orderBy = $this->buildOrderBy($orderBy, $params);
129 13
130
        if ($orderBy === '') {
131 10
            /** ORDER BY clause is required when FETCH and OFFSET are in the SQL */
132
            $orderBy = 'ORDER BY (SELECT NULL)';
133
        }
134 13
135
        $sql .= $this->separator . $orderBy;
136
137
        /**
138
         * {@see http://technet.microsoft.com/en-us/library/gg699618.aspx}
139 13
         */
140 13
        $offset = $this->hasOffset($offset) ? $offset : '0';
141
        $sql .= $this->separator . "OFFSET $offset ROWS";
142 13
143 12
        if ($this->hasLimit($limit)) {
144
            $sql .= $this->separator . "FETCH NEXT $limit ROWS ONLY";
145
        }
146 13
147
        return $sql;
148
    }
149
150
    /**
151
     * Builds the ORDER BY/LIMIT/OFFSET clauses for SQL SERVER 2005 to 2008.
152
     *
153
     * @param string $sql the existing SQL (without ORDER BY/LIMIT/OFFSET).
154
     * @param array $orderBy the order by columns. See {@see Query::orderBy} for more details on how to specify this
155
     * parameter.
156
     * @param int|Query|null $limit the limit number. See {@see Query::limit} for more details.
157
     * @param int|Query|null $offset the offset number. See {@see Query::offset} for more details.
158
     * @param array $params the binding parameters to be populated.
159
     *
160
     * @throws Exception|InvalidArgumentException
161
     *
162
     * @return string the SQL completed with ORDER BY/LIMIT/OFFSET (if any).
163
     */
164
    protected function oldBuildOrderByAndLimit(
165
        string $sql,
166
        array $orderBy,
167
        $limit,
168
        $offset,
169
        array &$params = []
170
    ): string {
171
        $orderBy = $this->buildOrderBy($orderBy, $params);
172
173
        if ($orderBy === '') {
174
            /** ROW_NUMBER() requires an ORDER BY clause */
175
            $orderBy = 'ORDER BY (SELECT NULL)';
176
        }
177
178
        $sql = preg_replace(
179
            '/^([\s(])*SELECT(\s+DISTINCT)?(?!\s*TOP\s*\()/i',
180
            "\\1SELECT\\2 rowNum = ROW_NUMBER() over ($orderBy),",
181
            $sql
182
        );
183
184
        if ($this->hasLimit($limit)) {
185
            $sql = "SELECT TOP $limit * FROM ($sql) sub";
186
        } else {
187
            $sql = "SELECT * FROM ($sql) sub";
188
        }
189
190
        if ($this->hasOffset($offset)) {
191
            $sql .= $this->separator . "WHERE rowNum > $offset";
192
        }
193
194
        return $sql;
195
    }
196
197
    /**
198
     * Builds a SQL statement for renaming a DB table.
199
     *
200
     * @param string $oldName the table to be renamed. The name will be properly quoted by the method.
201
     * @param string $newName the new table name. The name will be properly quoted by the method.
202
     *
203
     * @return string the SQL statement for renaming a DB table.
204 2
     */
205
    public function renameTable(string $oldName, string $newName): string
206
    {
207 2
        return 'sp_rename ' .
208
            $this->getDb()->quoteTableName($oldName) . ', ' . $this->getDb()->quoteTableName($newName);
209
    }
210
211
    /**
212
     * Builds a SQL statement for renaming a column.
213
     *
214
     * @param string $table the table whose column is to be renamed. The name will be properly quoted by the method.
215
     * @param string $oldName the old name of the column. The name will be properly quoted by the method.
216
     * @param string $newName the new name of the column. The name will be properly quoted by the method.
217
     *
218
     * @return string the SQL statement for renaming a DB column.
219
     */
220
    public function renameColumn(string $table, string $oldName, string $newName): string
221
    {
222
        $table = $this->getDb()->quoteTableName($table);
223
        $oldName = $this->getDb()->quoteColumnName($oldName);
224
        $newName = $this->getDb()->quoteColumnName($newName);
225
226
        return "sp_rename '{$table}.{$oldName}', {$newName}, 'COLUMN'";
227
    }
228
229
    /**
230
     * Builds a SQL statement for changing the definition of a column.
231
     *
232
     * @param string $table the table whose column is to be changed. The table name will be properly quoted by the
233
     * method.
234
     * @param string $column the name of the column to be changed. The name will be properly quoted by the method.
235
     * @param string $type the new column type. The [[getColumnType]] method will be invoked to convert abstract column
236
     * type (if any) into the physical one. Anything that is not recognized as abstract type will be kept in the
237
     * generated SQL.
238
     *
239
     * For example, 'string' will be turned into 'varchar(255)', while 'string not null' will become
240
     * 'varchar(255) not null'.
241
     *
242
     * @return string the SQL statement for changing the definition of a column.
243 1
     */
244
    public function alterColumn(string $table, string $column, string $type): string
245 1
    {
246
        $type = $this->getColumnType($type);
247 1
248 1
        return 'ALTER TABLE ' . $this->getDb()->quoteTableName($table) . ' ALTER COLUMN '
249 1
            . $this->getDb()->quoteColumnName($column) . ' '
250
            . $this->getColumnType($type);
251
    }
252
253
    /**
254
     * Creates a SQL command for adding a default value constraint to an existing table.
255
     *
256
     * @param string $name the name of the default value constraint. The name will be properly quoted by the method.
257
     * @param string $table the table that the default value constraint will be added to. The name will be properly
258
     * quoted by the method.
259
     * @param string $column the name of the column to that the constraint will be added on. The name will be properly
260
     * quoted by the method.
261
     * @param mixed $value default value.
262
     *
263
     * @throws Exception
264 1
     *
265
     * @return string the SQL statement for adding a default value constraint to an existing table.
266 1
     */
267 1
    public function addDefaultValue(string $name, string $table, string $column, $value): string
268 1
    {
269
        return 'ALTER TABLE ' . $this->getDb()->quoteTableName($table) . ' ADD CONSTRAINT '
270
            . $this->getDb()->quoteColumnName($name) . ' DEFAULT ' . $this->getDb()->quoteValue($value) . ' FOR '
271
            . $this->getDb()->quoteColumnName($column);
272
    }
273
274
    /**
275
     * Creates a SQL command for dropping a default value constraint.
276
     *
277
     * @param string $name the name of the default value constraint to be dropped. The name will be properly quoted by
278
     * the method.
279
     * @param string $table the table whose default value constraint is to be dropped. The name will be properly quoted
280
     * by the method.
281 1
     *
282
     * @return string the SQL statement for dropping a default value constraint.
283
     */
284 1
    public function dropDefaultValue(string $name, string $table): string
285
    {
286
        return 'ALTER TABLE ' .
287
            $this->getDb()->quoteTableName($table) . ' DROP CONSTRAINT ' . $this->getDb()->quoteColumnName($name);
288
    }
289
290
    /**
291
     * Creates a SQL statement for resetting the sequence value of a table's primary key.
292
     *
293
     * The sequence will be reset such that the primary key of the next new row inserted will have the specified value
294
     * or 1.
295
     *
296
     * @param string $tableName the name of the table whose primary key sequence will be reset.
297
     * @param mixed $value the value for the primary key of the next new row inserted. If this is not set, the next new
298
     * row's primary key will have a value 1.
299
     *
300
     * @throws Exception|InvalidArgumentException if the table does not exist or there is no sequence associated with
301
     * the table.
302
     *
303
     * @return string the SQL statement for resetting sequence.
304
     */
305
    public function resetSequence(string $tableName, $value = null): string
306
    {
307
        $table = $this->getDb()->getTableSchema($tableName);
308
309
        if ($table !== null && $table->getSequenceName() !== null) {
310
            $tableName = $this->getDb()->quoteTableName($tableName);
311
312
            if ($value === null) {
313
                $pk = $table->getPrimaryKey();
314
                $key = $this->getDb()->quoteColumnName(reset($pk));
315
                $value = "(SELECT COALESCE(MAX({$key}),0) FROM {$tableName})+1";
316
            } else {
317
                $value = (int)$value;
318
            }
319
320
            return "DBCC CHECKIDENT ('{$tableName}', RESEED, {$value})";
321
        }
322
323
        throw new InvalidArgumentException("There is not sequence associated with table '$tableName'.");
324
    }
325
326
    /**
327
     * Builds a SQL statement for enabling or disabling integrity check.
328
     *
329
     * @param string $schema the schema of the tables.
330
     * @param string $table the table name.
331
     * @param bool $check whether to turn on or off the integrity check.
332
     *
333
     * @throws NotSupportedException
334
     *
335
     * @return string the SQL statement for checking integrity.
336
     */
337
    public function checkIntegrity(string $schema = '', string $table = '', bool $check = true): string
338
    {
339
        /** @psalm-var Connection $db */
340
        $db = $this->getDb();
341
342
        $enable = $check ? 'CHECK' : 'NOCHECK';
343
        $schema = $schema ?: $db->getSchema()->getDefaultSchema();
344
        $tableNames = $db->getTableSchema($table)
345
            ? [$table] : $db->getSchema()->getTableNames($schema);
0 ignored issues
show
Bug introduced by
It seems like $schema can also be of type null; however, parameter $schema of Yiisoft\Db\Schema\Schema::getTableNames() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

345
            ? [$table] : $db->getSchema()->getTableNames(/** @scrutinizer ignore-type */ $schema);
Loading history...
346
        $viewNames = $db->getSchema()->getViewNames($schema);
347
        $tableNames = array_diff($tableNames, $viewNames);
348
        $command = '';
349
350
        foreach ($tableNames as $tableName) {
351
            $tableName = $db->quoteTableName("{$schema}.{$tableName}");
352
            $command .= "ALTER TABLE $tableName $enable CONSTRAINT ALL; ";
353
        }
354
355
        return $command;
356
    }
357
358
    /**
359
     * Builds a SQL command for adding or updating a comment to a table or a column. The command built will check if a
360
     * comment already exists. If so, it will be updated, otherwise, it will be added.
361
     *
362
     * @param string $comment the text of the comment to be added. The comment will be properly quoted by the method.
363
     * @param string $table the table to be commented or whose column is to be commented. The table name will be
364
     * properly quoted by the method.
365
     * @param string|null $column optional. The name of the column to be commented. If empty, the command will add the
366
     * comment to the table instead. The column name will be properly quoted by the method.
367 4
     *
368
     * @throws Exception|InvalidArgumentException if the table does not exist.
369 4
     *
370
     * @return string the SQL statement for adding a comment.
371 4
     */
372
    protected function buildAddCommentSql(string $comment, string $table, ?string $column = null): string
373
    {
374
        $tableSchema = $this->getDb()->getSchema()->getTableSchema($table);
375 4
376 4
        if ($tableSchema === null) {
377 4
            throw new InvalidArgumentException("Table not found: $table");
378 4
        }
379
380 4
        $schemaName = $tableSchema->getSchemaName() ? "N'" . $tableSchema->getSchemaName() . "'" : 'SCHEMA_NAME()';
381
        $tableName = 'N' . $this->getDb()->quoteValue($tableSchema->getName());
382 4
        $columnName = $column ? 'N' . $this->getDb()->quoteValue($column) : null;
383 4
        $comment = 'N' . $this->getDb()->quoteValue($comment);
384 4
385 4
        $functionParams = "
386
            @name = N'MS_description',
387 4
            @value = $comment,
388
            @level0type = N'SCHEMA', @level0name = $schemaName,
389
            @level1type = N'TABLE', @level1name = $tableName"
390
            . ($column ? ", @level2type = N'COLUMN', @level2name = $columnName" : '') . ';';
391
392 4
        return "
393 4
            IF NOT EXISTS (
394 4
                    SELECT 1
395
                    FROM fn_listextendedproperty (
396
                        N'MS_description',
397 4
                        'SCHEMA', $schemaName,
398
                        'TABLE', $tableName,
399 4
                        " . ($column ? "'COLUMN', $columnName " : ' DEFAULT, DEFAULT ') . "
400
                    )
401
            )
402
                EXEC sys.sp_addextendedproperty $functionParams
403
            ELSE
404
                EXEC sys.sp_updateextendedproperty $functionParams
405
        ";
406
    }
407
408
    /**
409
     * Builds a SQL command for adding comment to column.
410
     *
411
     * @param string $table the table whose column is to be commented. The table name will be properly quoted by the
412
     * method.
413
     * @param string $column the name of the column to be commented. The column name will be properly quoted by the
414
     * method.
415
     * @param string $comment the text of the comment to be added. The comment will be properly quoted by the method.
416 4
     *
417
     * @throws Exception
418 4
     *
419
     * @return string the SQL statement for adding comment on column.
420
     */
421
    public function addCommentOnColumn(string $table, string $column, string $comment): string
422
    {
423
        return $this->buildAddCommentSql($comment, $table, $column);
424
    }
425
426
    /**
427
     * Builds a SQL command for adding comment to table.
428
     *
429
     * @param string $table the table whose column is to be commented. The table name will be properly quoted by the
430
     * method.
431
     * @param string $comment the text of the comment to be added. The comment will be properly quoted by the method.
432 4
     *
433
     * @throws Exception
434 4
     *
435
     * @return string the SQL statement for adding comment on table.
436
     */
437
    public function addCommentOnTable(string $table, string $comment): string
438
    {
439
        return $this->buildAddCommentSql($comment, $table);
440
    }
441
442
    /**
443
     * Builds a SQL command for removing a comment from a table or a column. The command built will check if a comment
444
     * already exists before trying to perform the removal.
445
     *
446
     * @param string $table the table that will have the comment removed or whose column will have the comment removed.
447
     * The table name will be properly quoted by the method.
448
     * @param string|null $column optional. The name of the column whose comment will be removed. If empty, the command
449
     * will remove the comment from the table instead. The column name will be properly quoted by the method.
450 2
     *
451
     * @throws Exception|InvalidArgumentException if the table does not exist.
452 2
     *
453
     * @return string the SQL statement for removing the comment.
454 2
     */
455
    protected function buildRemoveCommentSql(string $table, ?string $column = null): string
456
    {
457
        $tableSchema = $this->getDb()->getSchema()->getTableSchema($table);
458 2
459 2
        if ($tableSchema === null) {
460 2
            throw new InvalidArgumentException("Table not found: $table");
461
        }
462 2
463
        $schemaName = $tableSchema->getSchemaName() ? "N'" . $tableSchema->getSchemaName() . "'" : 'SCHEMA_NAME()';
464
        $tableName = 'N' . $this->getDb()->quoteValue($tableSchema->getName());
465
        $columnName = $column ? 'N' . $this->getDb()->quoteValue($column) : null;
466
467 2
        return "
468 2
            IF EXISTS (
469 2
                    SELECT 1
470
                    FROM fn_listextendedproperty (
471
                        N'MS_description',
472
                        'SCHEMA', $schemaName,
473
                        'TABLE', $tableName,
474 2
                        " . ($column ? "'COLUMN', $columnName " : ' DEFAULT, DEFAULT ') . "
475 2
                    )
476 2
            )
477
                EXEC sys.sp_dropextendedproperty
478
                    @name = N'MS_description',
479
                    @level0type = N'SCHEMA', @level0name = $schemaName,
480
                    @level1type = N'TABLE', @level1name = $tableName"
481
                    . ($column ? ", @level2type = N'COLUMN', @level2name = $columnName" : '') . ';';
482
    }
483
484
    /**
485
     * Builds a SQL command for adding comment to column.
486
     *
487
     * @param string $table the table whose column is to be commented. The table name will be properly quoted by the
488
     * method.
489
     * @param string $column the name of the column to be commented. The column name will be properly quoted by the
490
     * method.
491 2
     *
492
     * @throws Exception
493 2
     *
494
     * @return string the SQL statement for adding comment on column.
495
     */
496
    public function dropCommentFromColumn(string $table, string $column): string
497
    {
498
        return $this->buildRemoveCommentSql($table, $column);
499
    }
500
501
    /**
502
     * Builds a SQL command for adding comment to table.
503
     *
504
     * @param string $table the table whose column is to be commented. The table name will be properly quoted by the
505
     * method.
506 2
     *
507
     * @throws Exception
508 2
     *
509
     * @return string the SQL statement for adding comment on column.
510
     */
511
    public function dropCommentFromTable(string $table): string
512
    {
513
        return $this->buildRemoveCommentSql($table);
514
    }
515
516
    /**
517
     * Returns an array of column names given model name.
518
     *
519
     * @param string|null $modelClass name of the model class.
520
     *
521
     * @return array|null array of column names
522
     */
523
    protected function getAllColumnNames(string $modelClass = null): ?array
524
    {
525
        if (!$modelClass) {
526
            return null;
527
        }
528
529
        $schema = $modelClass::getTableSchema();
530
531
        return array_keys($schema->columns);
532
    }
533
534
    /**
535
     * Creates a SELECT EXISTS() SQL statement.
536 1
     *
537
     * @param string $rawSql the subquery in a raw form to select from.
538 1
     *
539
     * @return string the SELECT EXISTS() SQL statement.
540
     */
541
    public function selectExists(string $rawSql): string
542
    {
543
        return 'SELECT CASE WHEN EXISTS(' . $rawSql . ') THEN 1 ELSE 0 END';
544
    }
545
546
    /**
547
     * Creates an INSERT SQL statement.
548
     *
549
     * For example,
550 27
     *
551
     * ```php
552 27
     * $sql = $queryBuilder->insert('user', [
553
     *     'name' => 'Sam',
554 27
     *     'age' => 30,
555 27
     * ], $params);
556 27
     * ```
557
     *
558
     * The method will properly escape the table and column names.
559
     *
560
     * @param string $table the table that new rows will be inserted into.
561 23
     * @param array|Query $columns the column data (name => value) to be inserted into the table or instance of
562 23
     * {@see Query} to perform INSERT INTO ... SELECT SQL statement. Passing of {@see Query}.
563 23
     * @param array $params the binding parameters that will be generated by this method. They should be bound to the
564
     * DB command later.
565
     *
566
     * @throws Exception|InvalidArgumentException|InvalidConfigException|JsonException|NotSupportedException
567
     *
568
     * @return string the INSERT SQL.
569
     */
570
    public function insert(string $table, $columns, array &$params = []): string
571
    {
572
        $version2005orLater = version_compare($this->getDb()->getSchema()->getServerVersion(), '9', '>=');
573 27
574
        [$names, $placeholders, $values, $params] = $this->prepareInsertValues($table, $columns, $params);
575
576
        $sql = 'INSERT INTO ' . $this->getDb()->quoteTableName($table)
577
            . (!empty($names) ? ' (' . implode(', ', $names) . ')' : '')
578
            . ($version2005orLater ? ' OUTPUT INSERTED.* INTO @temporary_inserted' : '')
579
            . (!empty($placeholders) ? ' VALUES (' . implode(', ', $placeholders) . ')' : $values);
580
581
        if ($version2005orLater) {
582
            $schema = $this->getDb()->getTableSchema($table);
583
584
            $cols = [];
585
            foreach ($schema->getColumns() as $column) {
586
                $cols[] = $this->getDb()->quoteColumnName($column->getName()) . ' '
587
                    . $column->getDbType()
588
                    . (in_array(
589
                        $column->getDbType(),
590
                        ['char', 'varchar', 'nchar', 'nvarchar', 'binary', 'varbinary']
591
                    ) ? '(MAX)' : '')
592
                    . ' ' . ($column->isAllowNull() ? 'NULL' : '');
593
            }
594
595
            $sql = 'SET NOCOUNT ON;DECLARE @temporary_inserted TABLE (' . implode(', ', $cols) . ');'
596
                . $sql . ';SELECT * FROM @temporary_inserted';
597
        }
598
599
        return $sql;
600 26
    }
601
602 26
    /**
603
     * Creates an SQL statement to insert rows into a database table if they do not already exist (matching unique
604 26
     * constraints), or update them if they do.
605
     *
606 26
     * For example,
607
     *
608 23
     * ```php
609 23
     * $sql = $queryBuilder->upsert('pages', [
610 23
     *     'name' => 'Front page',
611 23
     *     'url' => 'http://example.com/', // url is unique
612
     *     'visits' => 0,
613 23
     * ], [
614 23
     *     'visits' => new \Yiisoft\Db\Expression('visits + 1'),
615
     * ], $params);
616 23
     * ```
617 23
     *
618 23
     * The method will properly escape the table and column names.
619 23
     *
620
     * @param string $table the table that new rows will be inserted into/updated in.
621 23
     * @param array|Query $insertColumns the column data (name => value) to be inserted into the table or instance of
622
     * {@see Query} to perform `INSERT INTO ... SELECT` SQL statement.
623 23
     * @param array|bool $updateColumns the column data (name => value) to be updated if they already exist.
624 23
     * If `true` is passed, the column data will be updated to match the insert column data.
625
     * If `false` is passed, no update will be performed if the column data already exists.
626
     * @param array $params the binding parameters that will be generated by this method.
627 23
     * They should be bound to the DB command later.
628 23
     *
629
     * @throws Exception|InvalidConfigException|JsonException|NotSupportedException if this is not supported by the
630
     * underlying DBMS.
631 23
     *
632
     * @return string the resulting SQL.
633
     *
634
     * {@see https://docs.microsoft.com/en-us/sql/t-sql/statements/merge-transact-sql}
635
     * {@see http://weblogs.sqlteam.com/dang/archive/2009/01/31/UPSERT-Race-Condition-With-MERGE.aspx}
636
     */
637
    public function upsert(string $table, $insertColumns, $updateColumns, array &$params): string
638
    {
639
        /** @var Constraint[] $constraints */
640
        $constraints = [];
641
642
        [$uniqueNames, $insertNames, $updateNames] = $this->prepareUpsertColumns(
643
            $table,
644
            $insertColumns,
645
            $updateColumns,
646
            $constraints
647
        );
648
649
        if (empty($uniqueNames)) {
650
            return $this->insert($table, $insertColumns, $params);
651
        }
652
653
        $onCondition = ['or'];
654
        $quotedTableName = $this->getDb()->quoteTableName($table);
655
656
        foreach ($constraints as $constraint) {
657
            $constraintCondition = ['and'];
658
659
            foreach ($constraint->getColumnNames() as $name) {
660
                $quotedName = $this->getDb()->quoteColumnName($name);
661
                $constraintCondition[] = "$quotedTableName.$quotedName=[EXCLUDED].$quotedName";
662
            }
663
664
            $onCondition[] = $constraintCondition;
665
        }
666
667
        $on = $this->buildCondition($onCondition, $params);
668
669 18
        [, $placeholders, $values, $params] = $this->prepareInsertValues($table, $insertColumns, $params);
670
671
        $mergeSql = 'MERGE ' . $this->getDb()->quoteTableName($table) . ' WITH (HOLDLOCK) '
672 18
            . 'USING (' . (!empty($placeholders)
673
            ? 'VALUES (' . implode(', ', $placeholders) . ')'
674 18
            : ltrim($values, ' ')) . ') AS [EXCLUDED] (' . implode(', ', $insertNames) . ') ' . "ON ($on)";
675 18
676
        $insertValues = [];
677
678
        foreach ($insertNames as $name) {
679
            $quotedName = $this->getDb()->quoteColumnName($name);
680
681 18
            if (strrpos($quotedName, '.') === false) {
682 3
                $quotedName = '[EXCLUDED].' . $quotedName;
683
            }
684
685 15
            $insertValues[] = $quotedName;
686 15
        }
687
688 15
        $insertSql = 'INSERT (' . implode(', ', $insertNames) . ')' . ' VALUES (' . implode(', ', $insertValues) . ')';
689 15
690
        if ($updateColumns === false) {
691 15
            return "$mergeSql WHEN NOT MATCHED THEN $insertSql;";
692 15
        }
693 15
694
        if ($updateColumns === true) {
695
            $updateColumns = [];
696 15
697
            foreach ($updateNames as $name) {
698
                $quotedName = $this->getDb()->quoteColumnName($name);
699 15
                if (strrpos($quotedName, '.') === false) {
700
                    $quotedName = '[EXCLUDED].' . $quotedName;
701 15
                }
702
703 15
                $updateColumns[$name] = new Expression($quotedName);
704 15
            }
705 7
        }
706 15
707
        [$updates, $params] = $this->prepareUpdateSets($table, $updateColumns, $params);
708 15
709
        $updateSql = 'UPDATE SET ' . implode(', ', $updates);
710 15
711 15
        return "$mergeSql WHEN MATCHED THEN $updateSql WHEN NOT MATCHED THEN $insertSql;";
712
    }
713 15
714 15
    /**
715
     * Converts an abstract column type into a physical column type.
716
     *
717 15
     * The conversion is done using the type map specified in {@see typeMap}.
718
     * The following abstract column types are supported (using MySQL as an example to explain the corresponding
719
     * physical types):
720 15
     *
721
     * - `pk`: an auto-incremental primary key type, will be converted into "int(11) NOT NULL AUTO_INCREMENT PRIMARY
722 15
     *    KEY".
723 5
     * - `bigpk`: an auto-incremental primary key type, will be converted into "bigint(20) NOT NULL AUTO_INCREMENT
724
     *    PRIMARY KEY".
725
     * - `upk`: an unsigned auto-incremental primary key type, will be converted into "int(10) UNSIGNED NOT NULL
726 10
     *    AUTO_INCREMENT PRIMARY KEY".
727 4
     * - `char`: char type, will be converted into "char(1)".
728
     * - `string`: string type, will be converted into "varchar(255)".
729 4
     * - `text`: a long string type, will be converted into "text".
730 4
     * - `smallint`: a small integer type, will be converted into "smallint(6)".
731 4
     * - `integer`: integer type, will be converted into "int(11)".
732 4
     * - `bigint`: a big integer type, will be converted into "bigint(20)".
733
     * - `boolean`: boolean type, will be converted into "tinyint(1)".
734
     * - `float``: float number type, will be converted into "float".
735 4
     * - `decimal`: decimal number type, will be converted into "decimal".
736
     * - `datetime`: datetime type, will be converted into "datetime".
737
     * - `timestamp`: timestamp type, will be converted into "timestamp".
738
     * - `time`: time type, will be converted into "time".
739 10
     * - `date`: date type, will be converted into "date".
740
     * - `money`: money type, will be converted into "decimal(19,4)".
741 10
     * - `binary`: binary data type, will be converted into "blob".
742
     *
743 10
     * If the abstract type contains two or more parts separated by spaces (e.g. "string NOT NULL"), then only the first
744
     * part will be converted, and the rest of the parts will be appended to the converted result.
745
     *
746
     * For example, 'string NOT NULL' is converted to 'varchar(255) NOT NULL'.
747
     *
748
     * For some of the abstract types you can also specify a length or precision constraint by appending it in round
749
     * brackets directly to the type.
750
     *
751
     * For example `string(32)` will be converted into "varchar(32)" on a MySQL database. If the underlying DBMS does
752
     * not support these kind of constraints for a type it will be ignored.
753
     *
754
     * If a type cannot be found in {@see typeMap}, it will be returned without any change.
755
     *
756
     * @param ColumnSchemaBuilder|string $type abstract column type
757
     *
758
     * @return string physical column type.
759
     */
760
    public function getColumnType($type): string
761
    {
762
        $columnType = parent::getColumnType($type);
763
764
        /** remove unsupported keywords*/
765
        $columnType = preg_replace("/\s*comment '.*'/i", '', $columnType);
766
        return preg_replace('/ first$/i', '', $columnType);
767
    }
768
769 2
    /**
770
     * Extracts table alias if there is one or returns false
771 2
     *
772
     * @param $table
773
     *
774
     * @return array|bool
775
     * @psalm-return array<array-key, string>|bool
776
     */
777
    protected function extractAlias($table)
778
    {
779
        if (preg_match('/^\[.*]$/', $table)) {
780
            return false;
781
        }
782
783
        return parent::extractAlias($table);
784
    }
785
}
786