Passed
Pull Request — master (#38)
by Wilmer
23:17 queued 08:17
created

QueryBuilder::batchInsert()   C

Complexity

Conditions 14
Paths 97

Size

Total Lines 52
Code Lines 33

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 14.0713

Importance

Changes 0
Metric Value
cc 14
eloc 33
c 0
b 0
f 0
nc 97
nop 4
dl 0
loc 52
ccs 13
cts 14
cp 0.9286
crap 14.0713
rs 6.2666

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Db\Pgsql;
6
7
use JsonException;
8
use PDO;
9
use Generator;
10
use Yiisoft\Db\Connection\ConnectionInterface;
11
use Yiisoft\Db\Constraint\Constraint;
12
use Yiisoft\Db\Exception\Exception;
13
use Yiisoft\Db\Exception\InvalidArgumentException;
14
use Yiisoft\Db\Exception\InvalidConfigException;
15
use Yiisoft\Db\Exception\NotSupportedException;
16
use Yiisoft\Db\Expression\ArrayExpression;
17
use Yiisoft\Db\Expression\Expression;
18
use Yiisoft\Db\Expression\ExpressionInterface;
19
use Yiisoft\Db\Expression\JsonExpression;
20
use Yiisoft\Db\Pdo\PdoValue;
21
use Yiisoft\Db\Query\Conditions\LikeCondition;
22
use Yiisoft\Db\Query\Query;
23
use Yiisoft\Db\Query\QueryBuilder as AbstractQueryBuilder;
24
use Yiisoft\Strings\NumericHelper;
25
26
use function array_diff;
27
use function array_merge;
28
use function array_unshift;
29
use function count;
30
use function explode;
31
use function implode;
32
use function is_bool;
33
use function is_float;
34
use function is_string;
35
use function preg_match;
36
use function preg_replace;
37
use function reset;
38
use function strpos;
39
use function strrpos;
40
use function version_compare;
41
42
final class QueryBuilder extends AbstractQueryBuilder
43
{
44
    /**
45
     * Defines a UNIQUE index for {@see createIndex()}.
46
     */
47
    public const INDEX_UNIQUE = 'unique';
48
49
    /**
50
     * Defines a B-tree index for {@see createIndex()}.
51
     */
52
    public const INDEX_B_TREE = 'btree';
53
54
    /**
55
     * Defines a hash index for {@see createIndex()}.
56
     */
57
    public const INDEX_HASH = 'hash';
58
59
    /**
60
     * Defines a GiST index for {@see createIndex()}.
61
     */
62
    public const INDEX_GIST = 'gist';
63
64
    /**
65
     * Defines a GIN index for {@see createIndex()}.
66
     */
67
    public const INDEX_GIN = 'gin';
68
69
    /**
70
     * @var array mapping from abstract column types (keys) to physical column types (values).
71
     */
72
    protected array $typeMap = [
73
        Schema::TYPE_PK => 'serial NOT NULL PRIMARY KEY',
74
        Schema::TYPE_UPK => 'serial NOT NULL PRIMARY KEY',
75
        Schema::TYPE_BIGPK => 'bigserial NOT NULL PRIMARY KEY',
76
        Schema::TYPE_UBIGPK => 'bigserial NOT NULL PRIMARY KEY',
77
        Schema::TYPE_CHAR => 'char(1)',
78
        Schema::TYPE_STRING => 'varchar(255)',
79
        Schema::TYPE_TEXT => 'text',
80
        Schema::TYPE_TINYINT => 'smallint',
81
        Schema::TYPE_SMALLINT => 'smallint',
82
        Schema::TYPE_INTEGER => 'integer',
83
        Schema::TYPE_BIGINT => 'bigint',
84
        Schema::TYPE_FLOAT => 'double precision',
85
        Schema::TYPE_DOUBLE => 'double precision',
86
        Schema::TYPE_DECIMAL => 'numeric(10,0)',
87
        Schema::TYPE_DATETIME => 'timestamp(0)',
88
        Schema::TYPE_TIMESTAMP => 'timestamp(0)',
89
        Schema::TYPE_TIME => 'time(0)',
90
        Schema::TYPE_DATE => 'date',
91
        Schema::TYPE_BINARY => 'bytea',
92
        Schema::TYPE_BOOLEAN => 'boolean',
93
        Schema::TYPE_MONEY => 'numeric(19,4)',
94
        Schema::TYPE_JSON => 'jsonb',
95
    ];
96
97
    /** @psalm-var Connection $db */
98
    private ConnectionInterface $db;
99
100
    public function __construct(ConnectionInterface $db)
101
    {
102 306
        $this->db = $db;
103
104 306
        parent::__construct($db);
105 306
    }
106
107
    /**
108
     * Contains array of default condition classes. Extend this method, if you want to change default condition classes
109
     * for the query builder.
110
     *
111
     * @return array
112
     *
113
     * See {@see conditionClasses} docs for details.
114
     */
115
    protected function defaultConditionClasses(): array
116
    {
117
        return array_merge(parent::defaultConditionClasses(), [
118
            'ILIKE' => LikeCondition::class,
119
            'NOT ILIKE' => LikeCondition::class,
120 306
            'OR ILIKE' => LikeCondition::class,
121
            'OR NOT ILIKE' => LikeCondition::class,
122 306
        ]);
123 306
    }
124
125
    /**
126
     * Contains array of default expression builders. Extend this method and override it, if you want to change default
127
     * expression builders for this query builder.
128
     *
129
     * @return array
130
     *
131
     * See {@see ExpressionBuilder} docs for details.
132
     */
133
    protected function defaultExpressionBuilders(): array
134
    {
135
        return array_merge(parent::defaultExpressionBuilders(), [
136
            ArrayExpression::class => ArrayExpressionBuilder::class,
137
            JsonExpression::class => JsonExpressionBuilder::class,
138
        ]);
139
    }
140
141
    /**
142
     * Builds a SQL statement for creating a new index.
143
     *
144
     * @param string $name the name of the index. The name will be properly quoted by the method.
145
     * @param string $table the table that the new index will be created for. The table name will be properly quoted by
146
     * the method.
147
     * @param string|array $columns the column(s) that should be included in the index. If there are multiple columns,
148
     * separate them with commas or use an array to represent them. Each column name will be properly quoted by the
149
     * method, unless a parenthesis is found in the name.
150
     * @param bool|string $unique whether to make this a UNIQUE index constraint. You can pass `true` or
151 5
     * {@see INDEX_UNIQUE} to create a unique index, `false` to make a non-unique index using the default index type, or
152
     * one of the following constants to specify the index method to use: {@see INDEX_B_TREE}, {@see INDEX_HASH},
153 5
     * {@see INDEX_GIST}, {@see INDEX_GIN}.
154 3
     *
155 3
     * @throws Exception|InvalidArgumentException
156
     *
157 3
     * @return string the SQL statement for creating a new index.
158 3
     *
159
     * {@see http://www.postgresql.org/docs/8.2/static/sql-createindex.html}
160
     */
161 5
    public function createIndex(string $name, string $table, $columns, $unique = false): string
162 5
    {
163 5
        if ($unique === self::INDEX_UNIQUE || $unique === true) {
164 5
            $index = false;
165 5
            $unique = true;
166
        } else {
167
            $index = $unique;
168
            $unique = false;
169
        }
170
171
        return ($unique ? 'CREATE UNIQUE INDEX ' : 'CREATE INDEX ')
172
            . $this->db->quoteTableName($name) . ' ON '
173
            . $this->db->quoteTableName($table)
174
            . ($index !== false ? " USING $index" : '')
175
            . ' (' . $this->buildColumns($columns) . ')';
176
    }
177
178
    /**
179
     * Builds a SQL statement for dropping an index.
180 3
     *
181
     * @param string $name the name of the index to be dropped. The name will be properly quoted by the method.
182 3
     * @param string $table the table whose index is to be dropped. The name will be properly quoted by the method.
183 1
     *
184 1
     * @return string the SQL statement for dropping an index.
185 1
     */
186 1
    public function dropIndex(string $name, string $table): string
187 1
    {
188
        if (strpos($table, '.') !== false && strpos($name, '.') === false) {
189 1
            if (strpos($table, '{{') !== false) {
190
                $table = preg_replace('/{{(.*?)}}/', '\1', $table);
191
                [$schema, $table] = explode('.', $table);
192
                if (strpos($schema, '%') === false) {
193
                    $name = $schema . '.' . $name;
194
                } else {
195
                    $name = '{{' . $schema . '.' . $name . '}}';
196
                }
197 3
            } else {
198
                [$schema] = explode('.', $table);
199
                $name = $schema . '.' . $name;
200
            }
201
        }
202
203
        return 'DROP INDEX ' . $this->db->quoteTableName($name);
204
    }
205
206
    /**
207
     * Builds a SQL statement for renaming a DB table.
208
     *
209
     * @param string $oldName the table to be renamed. The name will be properly quoted by the method.
210
     * @param string $newName the new table name. The name will be properly quoted by the method.
211
     *
212 2
     * @return string the SQL statement for renaming a DB table.
213
     */
214 2
    public function renameTable(string $oldName, string $newName): string
215 2
    {
216
        return 'ALTER TABLE ' . $this->db->quoteTableName($oldName) . ' RENAME TO '
217
            . $this->db->quoteTableName($newName);
218
    }
219
220
    /**
221
     * Creates a SQL statement for resetting the sequence value of a table's primary key.
222
     *
223
     * The sequence will be reset such that the primary key of the next new row inserted will have the specified value
224
     * or 1.
225
     *
226
     * @param string $tableName the name of the table whose primary key sequence will be reset.
227
     * @param mixed $value the value for the primary key of the next new row inserted. If this is not set, the next new
228
     * row's primary key will have a value 1.
229
     *
230
     * @throws Exception|InvalidArgumentException if the table does not exist or there is no sequence associated with
231
     * the table.
232
     *
233
     * @return string the SQL statement for resetting sequence.
234
     */
235 2
    public function resetSequence(string $tableName, $value = null): string
236
    {
237 2
        $table = $this->db->getTableSchema($tableName);
238 2
        if ($table !== null && $table->getSequenceName() !== null) {
239
            /**
240
             * {@see http://www.postgresql.org/docs/8.1/static/functions-sequence.html}
241
             */
242 2
            $sequence = $this->db->quoteTableName($table->getSequenceName());
243 2
            $tableName = $this->db->quoteTableName($tableName);
244 2
            if ($value === null) {
245 2
                $pk = $table->getPrimaryKey();
246 2
                $key = $this->db->quoteColumnName(reset($pk));
247 2
                $value = "(SELECT COALESCE(MAX({$key}),0) FROM {$tableName})+1";
248
            } else {
249 2
                $value = (int) $value;
250
            }
251
252 2
            return "SELECT SETVAL('$sequence',$value,false)";
253
        }
254
255
        if ($table === null) {
256
            throw new InvalidArgumentException("Table not found: $tableName");
257
        }
258
259
        throw new InvalidArgumentException("There is not sequence associated with table '$tableName'.");
260
    }
261
262
    /**
263
     * Builds a SQL statement for enabling or disabling integrity check.
264
     *
265
     * @param string $schema the schema of the tables.
266
     * @param string $table the table name.
267
     * @param bool $check whether to turn on or off the integrity check.
268
     *
269
     * @return string the SQL statement for checking integrity.
270
     */
271
    public function checkIntegrity(string $schema = '', string $table = '', bool $check = true): string
272
    {
273
        $enable = $check ? 'ENABLE' : 'DISABLE';
274
        $schema = $schema ?: $this->db->getSchema()->getDefaultSchema();
275
        $tableNames = $table ? [$table] : $this->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

275
        $tableNames = $table ? [$table] : $this->db->getSchema()->getTableNames(/** @scrutinizer ignore-type */ $schema);
Loading history...
276
        $viewNames = $this->db->getSchema()->getViewNames($schema);
277
        $tableNames = array_diff($tableNames, $viewNames);
278
        $command = '';
279
280
        foreach ($tableNames as $tableName) {
281
            $tableName = $this->db->quoteTableName("{$schema}.{$tableName}");
282
            $command .= "ALTER TABLE $tableName $enable TRIGGER ALL; ";
283
        }
284
285
        /** enable to have ability to alter several tables */
286
        $this->db->getMasterPdo()->setAttribute(PDO::ATTR_EMULATE_PREPARES, true);
287
288
        return $command;
289
    }
290
291
    /**
292
     * Builds a SQL statement for truncating a DB table.
293
     *
294
     * Explicitly restarts identity for PGSQL to be consistent with other databases which all do this by default.
295
     *
296
     * @param string $table the table to be truncated. The name will be properly quoted by the method.
297
     *
298
     * @return string the SQL statement for truncating a DB table.
299
     */
300
    public function truncateTable(string $table): string
301
    {
302
        return 'TRUNCATE TABLE ' . $this->db->quoteTableName($table) . ' RESTART IDENTITY';
303
    }
304
305
    /**
306
     * Builds a SQL statement for changing the definition of a column.
307
     *
308 1
     * @param string $table the table whose column is to be changed. The table name will be properly quoted by the
309
     * method.
310 1
     * @param string $column the name of the column to be changed. The name will be properly quoted by the method.
311
     * @param object|string $type the new column type. The {@see getColumnType()} method will be invoked to convert abstract
312
     * column type (if any) into the physical one. Anything that is not recognized as abstract type will be kept in the
313
     * generated SQL. For example, 'string' will be turned into 'varchar(255)', while 'string not null' will become
314
     * 'varchar(255) not null'. You can also use PostgreSQL-specific syntax such as `SET NOT NULL`.
315
     *
316
     * @return string the SQL statement for changing the definition of a column.
317
     */
318
    public function alterColumn(string $table, string $column, $type): string
319
    {
320
        $columnName = $this->db->quoteColumnName($column);
321
        $tableName = $this->db->quoteTableName($table);
322
323
        /**
324
         * {@see https://github.com/yiisoft/yii2/issues/4492}
325
         * {@see http://www.postgresql.org/docs/9.1/static/sql-altertable.html}
326
         */
327
        if (preg_match('/^(DROP|SET|RESET)\s+/i', (string) $type)) {
328
            return "ALTER TABLE {$tableName} ALTER COLUMN {$columnName} {$type}";
329
        }
330 1
331
        $type = 'TYPE ' . $this->getColumnType($type);
332 1
        $multiAlterStatement = [];
333 1
        $constraintPrefix = preg_replace('/[^a-z0-9_]/i', '', $table . '_' . $column);
334
335
        if (preg_match('/\s+DEFAULT\s+(["\']?\w*["\']?)/i', $type, $matches)) {
336
            $type = preg_replace('/\s+DEFAULT\s+(["\']?\w*["\']?)/i', '', $type);
337
            $multiAlterStatement[] = "ALTER COLUMN {$columnName} SET DEFAULT {$matches[1]}";
338
        } else {
339 1
            /** safe to drop default even if there was none in the first place */
340 1
            $multiAlterStatement[] = "ALTER COLUMN {$columnName} DROP DEFAULT";
341
        }
342
343 1
        $type = preg_replace('/\s+NOT\s+NULL/i', '', $type, -1, $count);
344 1
345 1
        if ($count) {
346
            $multiAlterStatement[] = "ALTER COLUMN {$columnName} SET NOT NULL";
347 1
        } else {
348 1
            /** remove additional null if any */
349 1
            $type = preg_replace('/\s+NULL/i', '', $type);
350
351
            /** safe to drop not null even if there was none in the first place */
352 1
            $multiAlterStatement[] = "ALTER COLUMN {$columnName} DROP NOT NULL";
353
        }
354
355 1
        if (preg_match('/\s+CHECK\s+\((.+)\)/i', $type, $matches)) {
356
            $type = preg_replace('/\s+CHECK\s+\((.+)\)/i', '', $type);
357 1
            $multiAlterStatement[] = "ADD CONSTRAINT {$constraintPrefix}_check CHECK ({$matches[1]})";
358 1
        }
359
360
        $type = preg_replace('/\s+UNIQUE/i', '', $type, -1, $count);
361 1
362
        if ($count) {
363
            $multiAlterStatement[] = "ADD UNIQUE ({$columnName})";
364 1
        }
365
366
        /** add what's left at the beginning */
367 1
        array_unshift($multiAlterStatement, "ALTER COLUMN {$columnName} {$type}");
368 1
369 1
        return 'ALTER TABLE ' . $tableName . ' ' . implode(', ', $multiAlterStatement);
370
    }
371
372 1
    /**
373
     * Creates an INSERT SQL statement.
374 1
     *
375 1
     * For example,.
376
     *
377
     * ```php
378
     * $sql = $queryBuilder->insert('user', [
379 1
     *     'name' => 'Sam',
380
     *     'age' => 30,
381 1
     * ], $params);
382
     * ```
383
     *
384
     * The method will properly escape the table and column names.
385
     *
386
     * @param string $table the table that new rows will be inserted into.
387
     * @param array|Query $columns the column data (name => value) to be inserted into the table or instance of
388
     * {@see Query|Query} to perform INSERT INTO ... SELECT SQL statement. Passing of
389
     * {@see Query|Query}.
390
     * @param array $params the binding parameters that will be generated by this method. They should be bound to the
391
     * DB command later.
392
     *
393
     * @throws Exception
394
     * @throws InvalidArgumentException
395
     * @throws InvalidConfigException
396
     * @throws NotSupportedException
397
     *
398
     * @return string the INSERT SQL
399
     */
400
    public function insert(string $table, $columns, array &$params = []): string
401
    {
402
        return parent::insert($table, $this->normalizeTableRowData($table, $columns), $params);
403
    }
404
405
    /**
406
     * Creates an SQL statement to insert rows into a database table if they do not already exist (matching unique
407
     * constraints), or update them if they do.
408
     *
409
     * For example,
410
     *
411
     * ```php
412 43
     * $sql = $queryBuilder->upsert('pages', [
413
     *     'name' => 'Front page',
414 43
     *     'url' => 'http://example.com/', // url is unique
415
     *     'visits' => 0,
416
     * ], [
417
     *     'visits' => new \Yiisoft\Db\Expression('visits + 1'),
418
     * ], $params);
419
     * ```
420
     *
421
     * The method will properly escape the table and column names.
422
     *
423
     * @param string $table the table that new rows will be inserted into/updated in.
424
     * @param array|Query $insertColumns the column data (name => value) to be inserted into the table or instance of
425
     * {@see Query} to perform `INSERT INTO ... SELECT` SQL statement.
426
     * @param array|bool $updateColumns the column data (name => value) to be updated if they already exist.
427
     * If `true` is passed, the column data will be updated to match the insert column data.
428
     * If `false` is passed, no update will be performed if the column data already exists.
429
     * @param array $params the binding parameters that will be generated by this method.
430
     * They should be bound to the DB command later.
431
     *
432
     * @throws Exception|JsonException|InvalidConfigException|NotSupportedException if this is not supported by the
433
     * underlying DBMS.
434
     *
435
     * @return string the resulting SQL.
436
     *
437
     * {@see https://www.postgresql.org/docs/9.5/static/sql-insert.html#SQL-ON-CONFLICT}
438
     * {@see https://stackoverflow.com/questions/1109061/insert-on-duplicate-update-in-postgresql/8702291#8702291}
439
     */
440
    public function upsert(string $table, $insertColumns, $updateColumns, array &$params = []): string
441
    {
442
        $insertColumns = $this->normalizeTableRowData($table, $insertColumns);
443
444
        if (!is_bool($updateColumns)) {
445
            $updateColumns = $this->normalizeTableRowData($table, $updateColumns);
446
        }
447
        if (version_compare($this->db->getServerVersion(), '9.5', '<')) {
448
            return $this->oldUpsert($table, $insertColumns, $updateColumns, $params);
449
        }
450
451
        return $this->newUpsert($table, $insertColumns, $updateColumns, $params);
452
    }
453 18
454
    /**
455 18
     * {@see upsert()} implementation for PostgreSQL 9.5 or higher.
456
     *
457 18
     * @param string $table
458 7
     * @param array|Query $insertColumns
459
     * @param array|bool $updateColumns
460 18
     * @param array $params
461
     *
462
     * @throws Exception|JsonException|InvalidArgumentException|InvalidConfigException|NotSupportedException
463
     *
464 18
     * @return string
465
     */
466
    private function newUpsert(string $table, $insertColumns, $updateColumns, array &$params = []): string
467
    {
468
        $insertSql = $this->insert($table, $insertColumns, $params);
469
        [$uniqueNames, , $updateNames] = $this->prepareUpsertColumns($table, $insertColumns, $updateColumns);
470
471
        if (empty($uniqueNames)) {
472
            return $insertSql;
473
        }
474
475
        if ($updateNames === []) {
476
            /** there are no columns to update */
477
            $updateColumns = false;
478
        }
479
480
        if ($updateColumns === false) {
481
            return "$insertSql ON CONFLICT DO NOTHING";
482 18
        }
483
484 18
        if ($updateColumns === true) {
485 18
            $updateColumns = [];
486
            foreach ($updateNames as $name) {
487 18
                $updateColumns[$name] = new Expression('EXCLUDED.' . $this->db->quoteColumnName($name));
488 3
            }
489
        }
490
491 15
        [$updates, $params] = $this->prepareUpdateSets($table, $updateColumns, $params);
492
493
        return $insertSql . ' ON CONFLICT (' . implode(', ', $uniqueNames) . ') DO UPDATE SET '
494
            . implode(', ', $updates);
495
    }
496 15
497 5
    /**
498
     * {@see upsert()} implementation for PostgreSQL older than 9.5.
499
     *
500 10
     * @param string $table
501 4
     * @param array|Query $insertColumns
502 4
     * @param array|bool $updateColumns
503 4
     * @param array $params
504
     *
505
     * @throws Exception|InvalidArgumentException|InvalidConfigException|JsonException|NotSupportedException
506
     *
507 10
     * @return string
508
     */
509 10
    private function oldUpsert(string $table, $insertColumns, $updateColumns, array &$params = []): string
510 10
    {
511
        /** @var Constraint[] $constraints */
512
        [$uniqueNames, $insertNames, $updateNames] = $this->prepareUpsertColumns(
513
            $table,
514
            $insertColumns,
515
            $updateColumns,
516
            $constraints
517
        );
518
519
        if (empty($uniqueNames)) {
520
            return $this->insert($table, $insertColumns, $params);
521
        }
522
523
        if ($updateNames === []) {
524
            /** there are no columns to update */
525
            $updateColumns = false;
526
        }
527
528
        /** @var Schema $schema */
529
        $schema = $this->db->getSchema();
530
531
        if (!$insertColumns instanceof Query) {
532
            $tableSchema = $schema->getTableSchema($table);
533
            $columnSchemas = $tableSchema !== null ? $tableSchema->getColumns() : [];
534
            foreach ($insertColumns as $name => $value) {
535
                /**
536
                 * NULLs and numeric values must be type hinted in order to be used in SET assigments NVM, let's cast
537
                 * them all
538
                 */
539
                if (isset($columnSchemas[$name])) {
540
                    $phName = self::PARAM_PREFIX . count($params);
541
                    $params[$phName] = $value;
542
                    $insertColumns[$name] = new Expression("CAST($phName AS {$columnSchemas[$name]->getDbType()})");
543
                }
544
            }
545
        }
546
547
        [, $placeholders, $values, $params] = $this->prepareInsertValues($table, $insertColumns, $params);
548
        $updateCondition = ['or'];
549
        $insertCondition = ['or'];
550
        $quotedTableName = $schema->quoteTableName($table);
551
552
        foreach ($constraints as $constraint) {
553
            $constraintUpdateCondition = ['and'];
554
            $constraintInsertCondition = ['and'];
555
            foreach ($constraint->getColumnNames() as $name) {
556
                $quotedName = $schema->quoteColumnName($name);
557
                $constraintUpdateCondition[] = "$quotedTableName.$quotedName=\"EXCLUDED\".$quotedName";
558
                $constraintInsertCondition[] = "\"upsert\".$quotedName=\"EXCLUDED\".$quotedName";
559
            }
560
            $updateCondition[] = $constraintUpdateCondition;
561
            $insertCondition[] = $constraintInsertCondition;
562
        }
563
564
        $withSql = 'WITH "EXCLUDED" (' . implode(', ', $insertNames) . ') AS ('
565
            . (!empty($placeholders) ? 'VALUES (' . implode(', ', $placeholders) . ')' : ltrim($values, ' ')) . ')';
566
567
        if ($updateColumns === false) {
568
            $selectSubQuery = (new Query($this->db))
569
                ->select(new Expression('1'))
570
                ->from($table)
571
                ->where($updateCondition);
572
            $insertSelectSubQuery = (new Query($this->db))
573
                ->select($insertNames)
574
                ->from('EXCLUDED')
575
                ->where(['not exists', $selectSubQuery]);
576
            $insertSql = $this->insert($table, $insertSelectSubQuery, $params);
577
578
            return "$withSql $insertSql";
579
        }
580
581
        if ($updateColumns === true) {
582
            $updateColumns = [];
583
            foreach ($updateNames as $name) {
584
                $quotedName = $this->db->quoteColumnName($name);
585
                if (strrpos($quotedName, '.') === false) {
586
                    $quotedName = '"EXCLUDED".' . $quotedName;
587
                }
588
                $updateColumns[$name] = new Expression($quotedName);
589
            }
590
        }
591
592
        [$updates, $params] = $this->prepareUpdateSets($table, $updateColumns, $params);
593
594
        $updateSql = 'UPDATE ' . $this->db->quoteTableName($table) . ' SET ' . implode(', ', $updates)
595
            . ' FROM "EXCLUDED" ' . $this->buildWhere($updateCondition, $params)
596
            . ' RETURNING ' . $this->db->quoteTableName($table) . '.*';
597
598
        $selectUpsertSubQuery = (new Query($this->db))
599
            ->select(new Expression('1'))
600
            ->from('upsert')
601
            ->where($insertCondition);
602
603
        $insertSelectSubQuery = (new Query($this->db))
604
            ->select($insertNames)
605
            ->from('EXCLUDED')
606
            ->where(['not exists', $selectUpsertSubQuery]);
607
608
        $insertSql = $this->insert($table, $insertSelectSubQuery, $params);
609
610
        return "$withSql, \"upsert\" AS ($updateSql) $insertSql";
611
    }
612
613
    /**
614
     * Creates an UPDATE SQL statement.
615
     *
616
     * For example,
617
     *
618
     * ```php
619
     * $params = [];
620
     * $sql = $queryBuilder->update('user', ['status' => 1], 'age > 30', $params);
621
     * ```
622
     *
623
     * The method will properly escape the table and column names.
624
     *
625
     * @param string $table the table to be updated.
626
     * @param array $columns the column data (name => value) to be updated.
627
     * @param array|string $condition the condition that will be put in the WHERE part. Please refer to
628
     * {@see Query::where()} on how to specify condition.
629
     * @param array $params the binding parameters that will be modified by this method so that they can be bound to the
630
     * DB command later.
631
     *
632
     * @throws Exception|InvalidArgumentException
633
     *
634
     * @return string the UPDATE SQL.
635
     */
636
    public function update(string $table, array $columns, $condition, array &$params = []): string
637
    {
638
        return parent::update($table, $this->normalizeTableRowData($table, $columns), $condition, $params);
639
    }
640
641
    /**
642
     * Normalizes data to be saved into the table, performing extra preparations and type converting, if necessary.
643
     *
644
     * @param string $table the table that data will be saved into.
645
     * @param array|Query $columns the column data (name => value) to be saved into the table or instance of
646
     * {@see Query} to perform INSERT INTO ... SELECT SQL statement. Passing of
647
     * {@see Query}.
648
     *
649
     * @return array|object normalized columns
650
     */
651
    private function normalizeTableRowData(string $table, $columns)
652
    {
653
        if ($columns instanceof Query) {
654
            return $columns;
655
        }
656
657
        if (($tableSchema = $this->db->getSchema()->getTableSchema($table)) !== null) {
658 4
            $columnSchemas = $tableSchema->getColumns();
659
            foreach ($columns as $name => $value) {
660 4
                if (
661
                    isset($columnSchemas[$name]) &&
662
                    $columnSchemas[$name]->getType() === Schema::TYPE_BINARY &&
663
                    is_string($value)
664
                ) {
665
                    /** explicitly setup PDO param type for binary column */
666
                    $columns[$name] = new PdoValue($value, PDO::PARAM_LOB);
667
                }
668
            }
669
        }
670
671
        return $columns;
672
    }
673
674
    /**
675
     * Generates a batch INSERT SQL statement.
676
     *
677 45
     * For example,
678
     *
679 45
     * ```php
680 14
     * $sql = $queryBuilder->batchInsert('user', ['name', 'age'], [
681
     *     ['Tom', 30],
682
     *     ['Jane', 20],
683 37
     *     ['Linda', 25],
684 37
     * ]);
685 37
     * ```
686
     *
687 37
     * Note that the values in each row must match the corresponding column names.
688 37
     *
689 37
     * The method will properly escape the column names, and quote the values to be inserted.
690
     *
691
     * @param string $table the table that new rows will be inserted into.
692 1
     * @param array $columns the column names.
693
     * @param array|Generator $rows the rows to be batch inserted into the table.
694
     * @param array $params the binding parameters. This parameter exists.
695
     *
696
     * @throws Exception|InvalidArgumentException|InvalidConfigException|NotSupportedException
697 37
     *
698
     * @return string the batch INSERT SQL statement.
699
     */
700
    public function batchInsert(string $table, array $columns, $rows, array &$params = []): string
701
    {
702
        if (empty($rows)) {
703
            return '';
704
        }
705
706
        $schema = $this->db->getSchema();
707
708
        if (($tableSchema = $schema->getTableSchema($table)) !== null) {
709
            $columnSchemas = $tableSchema->getColumns();
710
        } else {
711
            $columnSchemas = [];
712
        }
713
714
        $values = [];
715
        foreach ($rows as $row) {
716
            $vs = [];
717
            foreach ($row as $i => $value) {
718
                if (isset($columns[$i], $columnSchemas[$columns[$i]])) {
719
                    $value = $columnSchemas[$columns[$i]]->dbTypecast($value);
720
                }
721
722
                if (is_string($value)) {
723
                    $value = $schema->quoteValue($value);
724
                } elseif (is_float($value)) {
725
                    /** ensure type cast always has . as decimal separator in all locales */
726
                    $value = NumericHelper::normalize((string) $value);
727
                } elseif ($value === true) {
728
                    $value = 'TRUE';
729 20
                } elseif ($value === false) {
730
                    $value = 'FALSE';
731 20
                } elseif ($value === null) {
732 2
                    $value = 'NULL';
733
                } elseif ($value instanceof ExpressionInterface) {
734
                    $value = $this->buildExpression($value, $params);
735 19
                }
736
737 19
                $vs[] = $value;
738 19
            }
739
            $values[] = '(' . implode(', ', $vs) . ')';
740
        }
741
742
        if (empty($values)) {
743 19
            return '';
744 19
        }
745 18
746 18
        foreach ($columns as $i => $name) {
747 18
            $columns[$i] = $schema->quoteColumnName($name);
748 15
        }
749
750
        return 'INSERT INTO ' . $schema->quoteTableName($table)
751 18
            . ' (' . implode(', ', $columns) . ') VALUES ' . implode(', ', $values);
752 8
    }
753
}
754