Passed
Push — master ( fdf9c0...a3fe4f )
by Alexander
04:30
created

QueryBuilder::buildAddCommentSql()   A

Complexity

Conditions 6
Paths 17

Size

Total Lines 33
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 6.0052

Importance

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

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