Passed
Pull Request — master (#14)
by Wilmer
14:09
created

MssqlQueryBuilder   F

Complexity

Total Complexity 78

Size/Duplication

Total Lines 565
Duplicated Lines 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
eloc 216
c 3
b 0
f 0
dl 0
loc 565
rs 2.16
wmc 78

25 Methods

Rating   Name   Duplication   Size   Complexity  
A buildOrderByAndLimit() 0 13 5
A defaultExpressionBuilders() 0 5 1
B insert() 0 30 8
A addDefaultValue() 0 5 1
A dropCommentFromColumn() 0 3 1
B upsert() 0 71 11
A update() 0 3 1
A addCommentOnTable() 0 3 1
A dropCommentFromTable() 0 3 1
A newBuildOrderByAndLimit() 0 22 4
A selectExists() 0 3 1
A buildRemoveCommentSql() 0 27 6
A buildAddCommentSql() 0 33 6
A addCommentOnColumn() 0 3 1
A dropDefaultValue() 0 4 1
A checkIntegrity() 0 16 5
B normalizeTableRowData() 0 23 7
A getColumnType() 0 9 1
A renameColumn() 0 7 1
A extractAlias() 0 7 2
A renameTable() 0 4 1
A oldBuildOrderByAndLimit() 0 26 4
A getAllColumnNames() 0 10 2
A alterColumn() 0 8 1
A resetSequence() 0 20 5

How to fix   Complexity   

Complex Class

Complex classes like MssqlQueryBuilder often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use MssqlQueryBuilder, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Db\Mssql\Query;
6
7
use Yiisoft\Db\Exception\InvalidArgumentException;
8
use Yiisoft\Db\Constraint\Constraint;
9
use Yiisoft\Db\Expression\Expression;
10
use Yiisoft\Db\Query\QueryBuilder;
11
use Yiisoft\Db\Mssql\Schema\MssqlSchema;
12
13
/**
14
 * MssqlQueryBuilder is the query builder for MS SQL Server databases (version 2008 and above).
15
 */
16
final class MssqlQueryBuilder extends QueryBuilder
17
{
18
    /**
19
     * @var array mapping from abstract column types (keys) to physical column types (values).
20
     */
21
    protected array $typeMap = [
22
        MssqlSchema::TYPE_PK => 'int IDENTITY PRIMARY KEY',
23
        MssqlSchema::TYPE_UPK => 'int IDENTITY PRIMARY KEY',
24
        MssqlSchema::TYPE_BIGPK => 'bigint IDENTITY PRIMARY KEY',
25
        MssqlSchema::TYPE_UBIGPK => 'bigint IDENTITY PRIMARY KEY',
26
        MssqlSchema::TYPE_CHAR => 'nchar(1)',
27
        MssqlSchema::TYPE_STRING => 'nvarchar(255)',
28
        MssqlSchema::TYPE_TEXT => 'nvarchar(max)',
29
        MssqlSchema::TYPE_TINYINT => 'tinyint',
30
        MssqlSchema::TYPE_SMALLINT => 'smallint',
31
        MssqlSchema::TYPE_INTEGER => 'int',
32
        MssqlSchema::TYPE_BIGINT => 'bigint',
33
        MssqlSchema::TYPE_FLOAT => 'float',
34
        MssqlSchema::TYPE_DOUBLE => 'float',
35
        MssqlSchema::TYPE_DECIMAL => 'decimal(18,0)',
36
        MssqlSchema::TYPE_DATETIME => 'datetime',
37
        MssqlSchema::TYPE_TIMESTAMP => 'datetime',
38
        MssqlSchema::TYPE_TIME => 'time',
39
        MssqlSchema::TYPE_DATE => 'date',
40
        MssqlSchema::TYPE_BINARY => 'varbinary(max)',
41
        MssqlSchema::TYPE_BOOLEAN => 'bit',
42
        MssqlSchema::TYPE_MONEY => 'decimal(19,4)',
43
    ];
44
45
    protected function defaultExpressionBuilders(): array
46
    {
47
        return array_merge(parent::defaultExpressionBuilders(), [
48
            Yiisoft\Db\Query\Conditions\InCondition::class => Yiisoft\Db\Mssql\Condition\InConditionBuilder::class,
0 ignored issues
show
Bug introduced by
The type Yiisoft\Db\Mssql\Query\Y...\Conditions\InCondition was not found. Did you mean Yiisoft\Db\Query\Conditions\InCondition? If so, make sure to prefix the type with \.
Loading history...
Bug introduced by
The type Yiisoft\Db\Mssql\Query\Y...tion\InConditionBuilder was not found. Did you mean Yiisoft\Db\Mssql\Condition\InConditionBuilder? If so, make sure to prefix the type with \.
Loading history...
49
            Yiisoft\Db\Query\Conditions\LikeCondition::class => Yiisoft\Db\Mssql\Condition\LikeConditionBuilder::class,
0 ignored issues
show
Bug introduced by
The type Yiisoft\Db\Mssql\Query\Y...onditions\LikeCondition was not found. Did you mean Yiisoft\Db\Query\Conditions\LikeCondition? If so, make sure to prefix the type with \.
Loading history...
Bug introduced by
The type Yiisoft\Db\Mssql\Query\Y...on\LikeConditionBuilder was not found. Did you mean Yiisoft\Db\Mssql\Condition\LikeConditionBuilder? If so, make sure to prefix the type with \.
Loading history...
50
        ]);
51
    }
52
53
    public function buildOrderByAndLimit(string $sql, array $orderBy, $limit, $offset, array &$params = []): string
54
    {
55
        if (!$this->hasOffset($offset) && !$this->hasLimit($limit)) {
56
            $orderBy = $this->buildOrderBy($orderBy, $params);
57
58
            return $orderBy === '' ? $sql : $sql . $this->separator . $orderBy;
59
        }
60
61
        if (version_compare($this->getDb()->getSchema()->getServerVersion(), '11', '<')) {
62
            return $this->oldBuildOrderByAndLimit($sql, $orderBy, $limit, $offset, $params);
63
        }
64
65
        return $this->newBuildOrderByAndLimit($sql, $orderBy, $limit, $offset, $params);
66
    }
67
68
    /**
69
     * Builds the ORDER BY/LIMIT/OFFSET clauses for SQL SERVER 2012 or newer.
70
     *
71
     * @param string $sql the existing SQL (without ORDER BY/LIMIT/OFFSET)
72
     * @param array $orderBy the order by columns. See {@see Yiisoft\Db\Query::orderBy} for more details on how to
73
     * specify this parameter.
74
     * @param mixed $limit the limit number. See {@see Yiisoft\Db\Query::limit} for more details.
75
     * @param mixed $offset the offset number. See {@see Yiisoft\Db\Query::offset} for more details.
76
     * @param array $params the binding parameters to be populated.
77
     *
78
     * @return string the SQL completed with ORDER BY/LIMIT/OFFSET (if any)
79
     */
80
    protected function newBuildOrderByAndLimit(string $sql, array $orderBy, $limit, $offset, array &$params = []): string
81
    {
82
        $orderBy = $this->buildOrderBy($orderBy, $params);
83
84
        if ($orderBy === '') {
85
            /* ORDER BY clause is required when FETCH and OFFSET are in the SQL */
86
            $orderBy = 'ORDER BY (SELECT NULL)';
87
        }
88
89
        $sql .= $this->separator . $orderBy;
90
91
        /**
92
         * {@see http://technet.microsoft.com/en-us/library/gg699618.aspx}
93
         */
94
        $offset = $this->hasOffset($offset) ? $offset : '0';
95
        $sql .= $this->separator . "OFFSET $offset ROWS";
96
97
        if ($this->hasLimit($limit)) {
98
            $sql .= $this->separator . "FETCH NEXT $limit ROWS ONLY";
99
        }
100
101
        return $sql;
102
    }
103
104
    /**
105
     * Builds the ORDER BY/LIMIT/OFFSET clauses for SQL SERVER 2005 to 2008.
106
     *
107
     * @param string $sql the existing SQL (without ORDER BY/LIMIT/OFFSET).
108
     * @param array $orderBy the order by columns. See {@see Yiisoft\Db\Query::orderBy} for more details on how to
109
     * specify this parameter.
110
     * @param mixed $limit the limit number. See {@see Yiisoft\Db\Query::limit} for more details.
111
     * @param mixed $offset the offset number. See {@see Yiisoft\Db\Query::offset} for more details.
112
     * @param array $params the binding parameters to be populated.
113
     *
114
     * @return string the SQL completed with ORDER BY/LIMIT/OFFSET (if any).
115
     */
116
    protected function oldBuildOrderByAndLimit(string $sql, array $orderBy, $limit, $offset, array &$params = []): string
117
    {
118
        $orderBy = $this->buildOrderBy($orderBy, $params);
119
120
        if ($orderBy === '') {
121
            /* ROW_NUMBER() requires an ORDER BY clause */
122
            $orderBy = 'ORDER BY (SELECT NULL)';
123
        }
124
125
        $sql = preg_replace(
126
            '/^([\s(])*SELECT(\s+DISTINCT)?(?!\s*TOP\s*\()/i',
127
            "\\1SELECT\\2 rowNum = ROW_NUMBER() over ($orderBy),",
128
            $sql
129
        );
130
131
        if ($this->hasLimit($limit)) {
132
            $sql = "SELECT TOP $limit * FROM ($sql) sub";
133
        } else {
134
            $sql = "SELECT * FROM ($sql) sub";
135
        }
136
137
        if ($this->hasOffset($offset)) {
138
            $sql .= $this->separator . "WHERE rowNum > $offset";
139
        }
140
141
        return $sql;
142
    }
143
144
    /**
145
     * Builds a SQL statement for renaming a DB table.
146
     *
147
     * @param string $oldName the table to be renamed. The name will be properly quoted by the method.
148
     * @param string $newName the new table name. The name will be properly quoted by the method.
149
     *
150
     * @return string the SQL statement for renaming a DB table.
151
     */
152
    public function renameTable(string $oldName, string $newName): string
153
    {
154
        return 'sp_rename ' .
155
            $this->getDb()->quoteTableName($oldName) . ', ' . $this->getDb()->quoteTableName($newName);
156
    }
157
158
    /**
159
     * Builds a SQL statement for renaming a column.
160
     *
161
     * @param string $table the table whose column is to be renamed. The name will be properly quoted by the method.
162
     * @param string $oldName the old name of the column. The name will be properly quoted by the method.
163
     * @param string $newName the new name of the column. The name will be properly quoted by the method.
164
     *
165
     * @return string the SQL statement for renaming a DB column.
166
     */
167
    public function renameColumn(string $table, string $oldName, string $newName): string
168
    {
169
        $table = $this->getDb()->quoteTableName($table);
170
        $oldName = $this->getDb()->quoteColumnName($oldName);
171
        $newName = $this->getDb()->quoteColumnName($newName);
172
173
        return "sp_rename '{$table}.{$oldName}', {$newName}, 'COLUMN'";
174
    }
175
176
    /**
177
     * Builds a SQL statement for changing the definition of a column.
178
     *
179
     * @param string $table the table whose column is to be changed. The table name will be properly quoted by the
180
     * method.
181
     * @param string $column the name of the column to be changed. The name will be properly quoted by the method.
182
     * @param string $type the new column type. The [[getColumnType]] method will be invoked to convert abstract column
183
     * type (if any) into the physical one. Anything that is not recognized as abstract type will be kept in the
184
     * generated SQL.
185
     *
186
     * For example, 'string' will be turned into 'varchar(255)', while 'string not null' will become
187
     * 'varchar(255) not null'.
188
     *
189
     * @return string the SQL statement for changing the definition of a column.
190
     */
191
    public function alterColumn(string $table, string $column, string $type): string
192
    {
193
        $type = $this->getColumnType($type);
194
        $sql = 'ALTER TABLE ' . $this->getDb()->quoteTableName($table) . ' ALTER COLUMN '
195
            . $this->getDb()->quoteColumnName($column) . ' '
196
            . $this->getColumnType($type);
197
198
        return $sql;
199
    }
200
201
    public function addDefaultValue(string $name, string $table, string $column, $value): string
202
    {
203
        return 'ALTER TABLE ' . $this->getDb()->quoteTableName($table) . ' ADD CONSTRAINT '
204
            . $this->getDb()->quoteColumnName($name) . ' DEFAULT ' . $this->getDb()->quoteValue($value) . ' FOR '
205
            . $this->getDb()->quoteColumnName($column);
206
    }
207
208
    public function dropDefaultValue(string $name, string $table): string
209
    {
210
        return 'ALTER TABLE ' .
211
            $this->getDb()->quoteTableName($table) . ' DROP CONSTRAINT ' . $this->db->quoteColumnName($name);
0 ignored issues
show
Bug introduced by
The method quoteColumnName() does not exist on null. ( Ignorable by Annotation )

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

211
            $this->getDb()->quoteTableName($table) . ' DROP CONSTRAINT ' . $this->db->/** @scrutinizer ignore-call */ quoteColumnName($name);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
212
    }
213
214
    /**
215
     * Creates a SQL statement for resetting the sequence value of a table's primary key.
216
     *
217
     * The sequence will be reset such that the primary key of the next new row inserted will have the specified value
218
     * or 1.
219
     *
220
     * @param string $tableName the name of the table whose primary key sequence will be reset.
221
     * @param mixed $value the value for the primary key of the next new row inserted. If this is not set, the next new
222
     * row's primary key will have a value 1.
223
     *
224
     * @return string the SQL statement for resetting sequence.
225
     *
226
     * @throws InvalidArgumentException if the table does not exist or there is no sequence associated with the table.
227
     */
228
    public function resetSequence(string $tableName, $value = null): string
229
    {
230
        $table = $this->getDb()->getTableSchema($tableName);
231
232
        if ($table !== null && $table->getSequenceName() !== null) {
233
            $tableName = $this->db->quoteTableName($tableName);
234
235
            if ($value === null) {
236
                $key = $this->getDb()->quoteColumnName(reset($table->getPrimaryKey()));
237
                $value = "(SELECT COALESCE(MAX({$key}),0) FROM {$tableName})+1";
238
            } else {
239
                $value = (int) $value;
240
            }
241
242
            return "DBCC CHECKIDENT ('{$tableName}', RESEED, {$value})";
243
        } elseif ($table === null) {
244
            throw new InvalidArgumentException("Table not found: $tableName");
245
        }
246
247
        throw new InvalidArgumentException("There is not sequence associated with table '$tableName'.");
248
    }
249
250
    /**
251
     * Builds a SQL statement for enabling or disabling integrity check.
252
     *
253
     * @param bool $check whether to turn on or off the integrity check.
254
     * @param string $schema the schema of the tables.
255
     * @param string $table the table name.
256
     *
257
     * @return string the SQL statement for checking integrity.
258
     */
259
    public function checkIntegrity(string $schema = '', string $table = '', bool $check = true): string
260
    {
261
        $enable = $check ? 'CHECK' : 'NOCHECK';
262
        $schema = $schema ?: $this->getDb()->getSchema()->defaultSchema;
0 ignored issues
show
Bug introduced by
The property defaultSchema is declared protected in Yiisoft\Db\Schema\Schema and cannot be accessed from this context.
Loading history...
263
        $tableNames = $this->getDb()->getTableSchema($table)
264
            ? [$table] : $this->getDb()->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

264
            ? [$table] : $this->getDb()->getSchema()->getTableNames(/** @scrutinizer ignore-type */ $schema);
Loading history...
265
        $viewNames = $this->getDb()->getSchema()->getViewNames($schema);
266
        $tableNames = array_diff($tableNames, $viewNames);
267
        $command = '';
268
269
        foreach ($tableNames as $tableName) {
270
            $tableName = $this->getDb()->quoteTableName("{$schema}.{$tableName}");
271
            $command .= "ALTER TABLE $tableName $enable CONSTRAINT ALL; ";
272
        }
273
274
        return $command;
275
    }
276
277
    /**
278
     * Builds a SQL command for adding or updating a comment to a table or a column. The command built will check if a
279
     * comment already exists. If so, it will be updated, otherwise, it will be added.
280
     *
281
     * @param string $comment the text of the comment to be added. The comment will be properly quoted by the method.
282
     * @param string $table the table to be commented or whose column is to be commented. The table name will be
283
     * properly quoted by the method.
284
     * @param string $column optional. The name of the column to be commented. If empty, the command will add the
285
     * comment to the table instead. The column name will be properly quoted by the method.
286
     *
287
     * @return string the SQL statement for adding a comment.
288
     *
289
     * @throws InvalidArgumentException if the table does not exist.
290
     */
291
    protected function buildAddCommentSql(string $comment, string $table, string $column = null): string
292
    {
293
        $tableSchema = $this->getDb()->getSchema()->getTableSchema($table);
294
295
        if ($tableSchema === null) {
296
            throw new InvalidArgumentException("Table not found: $table");
297
        }
298
299
        $schemaName = $tableSchema->getSchemaName() ? "N'" . $tableSchema->getSchemaName() . "'" : 'SCHEMA_NAME()';
300
        $tableName = "N" . $this->getDb()->quoteValue($tableSchema->getName());
301
        $columnName = $column ? "N" . $this->getDb()->quoteValue($column) : null;
302
        $comment = "N" . $this->getDb()->quoteValue($comment);
303
304
        $functionParams = "
305
            @name = N'MS_description',
306
            @value = $comment,
307
            @level0type = N'SCHEMA', @level0name = $schemaName,
308
            @level1type = N'TABLE', @level1name = $tableName"
309
            . ($column ? ", @level2type = N'COLUMN', @level2name = $columnName" : '') . ';';
310
311
        return "
312
            IF NOT EXISTS (
313
                    SELECT 1
314
                    FROM fn_listextendedproperty (
315
                        N'MS_description',
316
                        'SCHEMA', $schemaName,
317
                        'TABLE', $tableName,
318
                        " . ($column ? "'COLUMN', $columnName " : ' DEFAULT, DEFAULT ') . "
319
                    )
320
            )
321
                EXEC sys.sp_addextendedproperty $functionParams
322
            ELSE
323
                EXEC sys.sp_updateextendedproperty $functionParams
324
        ";
325
    }
326
327
    public function addCommentOnColumn(string $table, string $column, string $comment): string
328
    {
329
        return $this->buildAddCommentSql($comment, $table, $column);
330
    }
331
332
    public function addCommentOnTable(string $table, string $comment): string
333
    {
334
        return $this->buildAddCommentSql($comment, $table);
335
    }
336
337
    /**
338
     * Builds a SQL command for removing a comment from a table or a column. The command built will check if a comment
339
     * already exists before trying to perform the removal.
340
     *
341
     * @param string $table the table that will have the comment removed or whose column will have the comment removed.
342
     * The table name will be properly quoted by the method.
343
     * @param string|null $column optional. The name of the column whose comment will be removed. If empty, the command
344
     * will remove the comment from the table instead. The column name will be properly quoted by the method.
345
     *
346
     * @return string the SQL statement for removing the comment.
347
     *
348
     * @throws InvalidArgumentException if the table does not exist.
349
     */
350
    protected function buildRemoveCommentSql(string $table, ?string $column = null): string
351
    {
352
        $tableSchema = $this->getDb()->getSchema()->getTableSchema($table);
353
354
        if ($tableSchema === null) {
355
            throw new InvalidArgumentException("Table not found: $table");
356
        }
357
358
        $schemaName = $tableSchema->getSchemaName() ? "N'" . $tableSchema->getSchemaName() . "'" : 'SCHEMA_NAME()';
359
        $tableName = "N" . $this->db->quoteValue($tableSchema->getName());
360
        $columnName = $column ? "N" . $this->getDb()->quoteValue($column) : null;
361
362
        return "
363
            IF EXISTS (
364
                    SELECT 1
365
                    FROM fn_listextendedproperty (
366
                        N'MS_description',
367
                        'SCHEMA', $schemaName,
368
                        'TABLE', $tableName,
369
                        " . ($column ? "'COLUMN', $columnName " : ' DEFAULT, DEFAULT ') . "
370
                    )
371
            )
372
                EXEC sys.sp_dropextendedproperty
373
                    @name = N'MS_description',
374
                    @level0type = N'SCHEMA', @level0name = $schemaName,
375
                    @level1type = N'TABLE', @level1name = $tableName"
376
                    . ($column ? ", @level2type = N'COLUMN', @level2name = $columnName" : '') . ';';
377
    }
378
379
    public function dropCommentFromColumn(string $table, string $column): string
380
    {
381
        return $this->buildRemoveCommentSql($table, $column);
382
    }
383
384
    public function dropCommentFromTable(string $table): string
385
    {
386
        return $this->buildRemoveCommentSql($table);
387
    }
388
389
    /**
390
     * Returns an array of column names given model name.
391
     *
392
     * @param string $modelClass name of the model class
393
     * @return array|null array of column names
394
     */
395
    protected function getAllColumnNames($modelClass = null)
396
    {
397
        if (!$modelClass) {
398
            return null;
399
        }
400
401
        /* @var $modelClass \Yiisoft\Db\ActiveRecord */
402
        $schema = $modelClass::getTableSchema();
403
404
        return array_keys($schema->columns);
405
    }
406
407
    public function selectExists(string $rawSql): string
408
    {
409
        return 'SELECT CASE WHEN EXISTS(' . $rawSql . ') THEN 1 ELSE 0 END';
410
    }
411
412
    /**
413
     * Normalizes data to be saved into the table, performing extra preparations and type converting, if necessary.
414
     *
415
     * @param string $table the table that data will be saved into.
416
     * @param array $columns the column data (name => value) to be saved into the table.
417
     *
418
     * @return array normalized columns
419
     */
420
    private function normalizeTableRowData($table, $columns, &$params)
421
    {
422
        $tableSchema = $this->db->getSchema()->getTableSchema($table);
423
424
        if ($tableSchema !== null) {
425
            $columnSchemas = $tableSchema->getColumns();
426
            foreach ($columns as $name => $value) {
427
                /**
428
                 * {@see https://github.com/yiisoft/yii2/issues/12599}
429
                 */
430
                if (
431
                    isset($columnSchemas[$name]) &&
432
                    $columnSchemas[$name]->getDbtype() === MssqlSchema::TYPE_BINARY &&
433
                    $columnSchemas[$name]->getDbType() === 'varbinary' && is_string($value)
434
                ) {
435
                    $exParams = [];
436
                    $phName = $this->bindParam($value, $exParams);
437
                    $columns[$name] = new Expression("CONVERT(VARBINARY, $phName)", $exParams);
438
                }
439
            }
440
        }
441
442
        return $columns;
443
    }
444
    /**
445
     * Added OUTPUT construction for getting inserted data (for SQL Server 2005 or later) OUTPUT clause - The OUTPUT
446
     * clause is new to SQL Server 2005 and has the ability to access the INSERTED and DELETED tables as is the case
447
     * with a trigger.
448
     */
449
    public function insert(string $table, $columns, array &$params = []): string
450
    {
451
        $columns = $this->normalizeTableRowData($table, $columns, $params);
452
453
        $version2005orLater = version_compare($this->getDb()->getSchema()->getServerVersion(), '9', '>=');
454
455
        [$names, $placeholders, $values, $params] = $this->prepareInsertValues($table, $columns, $params);
456
457
        $sql = 'INSERT INTO ' . $this->getDb()->quoteTableName($table)
458
            . (!empty($names) ? ' (' . implode(', ', $names) . ')' : '')
459
            . ($version2005orLater ? ' OUTPUT INSERTED.* INTO @temporary_inserted' : '')
460
            . (!empty($placeholders) ? ' VALUES (' . implode(', ', $placeholders) . ')' : $values);
461
462
        if ($version2005orLater) {
463
            $schema = $this->getDb()->getTableSchema($table);
464
            $cols = [];
465
            foreach ($schema->getColumns() as $column) {
466
                $cols[] = $this->getDb()->quoteColumnName($column->getName()) . ' '
467
                    . $column->getDbType()
468
                    . (in_array(
469
                        $column->getDbType(),
470
                        ['char', 'varchar', 'nchar', 'nvarchar', 'binary', 'varbinary']
471
                    ) ? "(MAX)" : "")
472
                    . ' ' . ($column->isAllowNull() ? "NULL" : "");
473
            }
474
            $sql = "SET NOCOUNT ON;DECLARE @temporary_inserted TABLE (" . implode(", ", $cols) . ");"
475
                . $sql . ";SELECT * FROM @temporary_inserted";
476
        }
477
478
        return $sql;
479
    }
480
481
    /**
482
     * {@see https://docs.microsoft.com/en-us/sql/t-sql/statements/merge-transact-sql}
483
     * {@see http://weblogs.sqlteam.com/dang/archive/2009/01/31/UPSERT-Race-Condition-With-MERGE.aspx}
484
     */
485
    public function upsert(string $table, $insertColumns, $updateColumns, array &$params): string
486
    {
487
        /** @var Constraint[] $constraints */
488
        $constraints = [];
489
490
        [$uniqueNames, $insertNames, $updateNames] = $this->prepareUpsertColumns(
491
            $table,
492
            $insertColumns,
493
            $updateColumns,
494
            $constraints
495
        );
496
497
        if (empty($uniqueNames)) {
498
            return $this->insert($table, $insertColumns, $params);
499
        }
500
501
        $onCondition = ['or'];
502
        $quotedTableName = $this->getDb()->quoteTableName($table);
503
504
        foreach ($constraints as $constraint) {
505
            $constraintCondition = ['and'];
506
507
            foreach ($constraint->getColumnNames() as $name) {
508
                $quotedName = $this->getDb()->quoteColumnName($name);
509
                $constraintCondition[] = "$quotedTableName.$quotedName=[EXCLUDED].$quotedName";
510
            }
511
512
            $onCondition[] = $constraintCondition;
513
        }
514
515
        $on = $this->buildCondition($onCondition, $params);
516
        [, $placeholders, $values, $params] = $this->prepareInsertValues($table, $insertColumns, $params);
517
        $mergeSql = 'MERGE ' . $this->getDb()->quoteTableName($table) . ' WITH (HOLDLOCK) '
518
            . 'USING (' . (!empty($placeholders)
519
            ? 'VALUES (' . implode(', ', $placeholders) . ')'
520
            : ltrim($values, ' ')) . ') AS [EXCLUDED] (' . implode(', ', $insertNames) . ') ' . "ON ($on)";
521
522
        $insertValues = [];
523
        foreach ($insertNames as $name) {
524
            $quotedName = $this->db->quoteColumnName($name);
525
526
            if (strrpos($quotedName, '.') === false) {
527
                $quotedName = '[EXCLUDED].' . $quotedName;
528
            }
529
530
            $insertValues[] = $quotedName;
531
        }
532
533
        $insertSql = 'INSERT (' . implode(', ', $insertNames) . ')' . ' VALUES (' . implode(', ', $insertValues) . ')';
534
535
        if ($updateColumns === false) {
536
            return "$mergeSql WHEN NOT MATCHED THEN $insertSql;";
537
        }
538
539
        if ($updateColumns === true) {
540
            $updateColumns = [];
541
542
            foreach ($updateNames as $name) {
543
                $quotedName = $this->getDb()->quoteColumnName($name);
544
                if (strrpos($quotedName, '.') === false) {
545
                    $quotedName = '[EXCLUDED].' . $quotedName;
546
                }
547
548
                $updateColumns[$name] = new Expression($quotedName);
549
            }
550
        }
551
552
        [$updates, $params] = $this->prepareUpdateSets($table, $updateColumns, $params);
553
        $updateSql = 'UPDATE SET ' . implode(', ', $updates);
554
555
        return "$mergeSql WHEN MATCHED THEN $updateSql WHEN NOT MATCHED THEN $insertSql;";
556
    }
557
558
    public function update(string $table, array $columns, $condition, array &$params = []): string
559
    {
560
        return parent::update($table, $this->normalizeTableRowData($table, $columns, $params), $condition, $params);
561
    }
562
563
    public function getColumnType($type): string
564
    {
565
        $columnType = parent::getColumnType($type);
566
567
        /* remove unsupported keywords*/
568
        $columnType = preg_replace("/\s*comment '.*'/i", '', $columnType);
569
        $columnType = preg_replace('/ first$/i', '', $columnType);
570
571
        return $columnType;
572
    }
573
574
    protected function extractAlias($table)
575
    {
576
        if (preg_match('/^\[.*\]$/', $table)) {
577
            return false;
578
        }
579
580
        return parent::extractAlias($table);
581
    }
582
}
583