Passed
Pull Request — master (#70)
by Wilmer
16:10
created

QueryBuilder::oldUpsert()   C

Complexity

Conditions 14
Paths 109

Size

Total Lines 102
Code Lines 65

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 210

Importance

Changes 0
Metric Value
cc 14
eloc 65
c 0
b 0
f 0
nc 109
nop 4
dl 0
loc 102
ccs 0
cts 62
cp 0
crap 210
rs 6.1916

2 Methods

Rating   Name   Duplication   Size   Complexity  
B QueryBuilder::normalizeTableRowData() 0 22 7
A QueryBuilder::update() 0 3 1

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 Generator;
8
use JsonException;
9
use PDO;
10
use Yiisoft\Db\Constraint\Constraint;
11
use Yiisoft\Db\Exception\Exception;
12
use Yiisoft\Db\Exception\InvalidArgumentException;
13
use Yiisoft\Db\Exception\InvalidConfigException;
14
use Yiisoft\Db\Exception\NotSupportedException;
15
use Yiisoft\Db\Expression\ArrayExpression;
16
use Yiisoft\Db\Expression\Expression;
17
use Yiisoft\Db\Expression\ExpressionInterface;
18
use Yiisoft\Db\Expression\JsonExpression;
19
use Yiisoft\Db\Pdo\PdoValue;
20
use Yiisoft\Db\Query\Conditions\LikeCondition;
21
use Yiisoft\Db\Query\Query;
22
use Yiisoft\Db\Query\QueryBuilder as AbstractQueryBuilder;
23
use Yiisoft\Db\Schema\ColumnSchemaBuilder;
24
use Yiisoft\Strings\NumericHelper;
25
26
use function array_diff;
27
use function array_merge;
28
use function array_unshift;
29
use function explode;
30
use function implode;
31
use function is_bool;
32
use function is_float;
33
use function is_string;
34
use function preg_match;
35
use function preg_replace;
36
use function reset;
37
use function strpos;
38
39
final class QueryBuilder extends AbstractQueryBuilder
40
{
41
    /**
42
     * Defines a UNIQUE index for {@see createIndex()}.
43
     */
44
    public const INDEX_UNIQUE = 'unique';
45
46
    /**
47
     * Defines a B-tree index for {@see createIndex()}.
48
     */
49
    public const INDEX_B_TREE = 'btree';
50
51
    /**
52
     * Defines a hash index for {@see createIndex()}.
53
     */
54
    public const INDEX_HASH = 'hash';
55
56
    /**
57
     * Defines a GiST index for {@see createIndex()}.
58
     */
59
    public const INDEX_GIST = 'gist';
60
61
    /**
62
     * Defines a GIN index for {@see createIndex()}.
63
     */
64
    public const INDEX_GIN = 'gin';
65
66
    /**
67
     * @var array mapping from abstract column types (keys) to physical column types (values).
68
     */
69
    protected array $typeMap = [
70
        Schema::TYPE_PK => 'serial NOT NULL PRIMARY KEY',
71
        Schema::TYPE_UPK => 'serial NOT NULL PRIMARY KEY',
72
        Schema::TYPE_BIGPK => 'bigserial NOT NULL PRIMARY KEY',
73
        Schema::TYPE_UBIGPK => 'bigserial NOT NULL PRIMARY KEY',
74
        Schema::TYPE_CHAR => 'char(1)',
75
        Schema::TYPE_STRING => 'varchar(255)',
76
        Schema::TYPE_TEXT => 'text',
77
        Schema::TYPE_TINYINT => 'smallint',
78
        Schema::TYPE_SMALLINT => 'smallint',
79
        Schema::TYPE_INTEGER => 'integer',
80
        Schema::TYPE_BIGINT => 'bigint',
81
        Schema::TYPE_FLOAT => 'double precision',
82
        Schema::TYPE_DOUBLE => 'double precision',
83
        Schema::TYPE_DECIMAL => 'numeric(10,0)',
84
        Schema::TYPE_DATETIME => 'timestamp(0)',
85
        Schema::TYPE_TIMESTAMP => 'timestamp(0)',
86
        Schema::TYPE_TIME => 'time(0)',
87
        Schema::TYPE_DATE => 'date',
88
        Schema::TYPE_BINARY => 'bytea',
89
        Schema::TYPE_BOOLEAN => 'boolean',
90
        Schema::TYPE_MONEY => 'numeric(19,4)',
91
        Schema::TYPE_JSON => 'jsonb',
92
    ];
93
94
    /**
95
     * Contains array of default condition classes. Extend this method, if you want to change default condition classes
96
     * for the query builder.
97
     *
98
     * @return array
99
     *
100
     * See {@see conditionClasses} docs for details.
101
     */
102
    protected function defaultConditionClasses(): array
103
    {
104
        return array_merge(parent::defaultConditionClasses(), [
105 305
            'ILIKE' => LikeCondition::class,
106
            'NOT ILIKE' => LikeCondition::class,
107 305
            'OR ILIKE' => LikeCondition::class,
108 305
            'OR NOT ILIKE' => LikeCondition::class,
109
        ]);
110
    }
111
112
    /**
113
     * Contains array of default expression builders. Extend this method and override it, if you want to change default
114
     * expression builders for this query builder.
115
     *
116
     * @return array
117
     *
118
     * See {@see ExpressionBuilder} docs for details.
119
     */
120
    protected function defaultExpressionBuilders(): array
121
    {
122
        return array_merge(parent::defaultExpressionBuilders(), [
123 305
            ArrayExpression::class => ArrayExpressionBuilder::class,
124
            JsonExpression::class => JsonExpressionBuilder::class,
125 305
        ]);
126 305
    }
127
128
    /**
129
     * Builds a SQL statement for creating a new index.
130
     *
131
     * @param string $name the name of the index. The name will be properly quoted by the method.
132
     * @param string $table the table that the new index will be created for. The table name will be properly quoted by
133
     * the method.
134
     * @param array|string $columns the column(s) that should be included in the index. If there are multiple columns,
135
     * separate them with commas or use an array to represent them. Each column name will be properly quoted by the
136
     * method, unless a parenthesis is found in the name.
137
     * @param bool|string $unique whether to make this a UNIQUE index constraint. You can pass `true` or
138
     * {@see INDEX_UNIQUE} to create a unique index, `false` to make a non-unique index using the default index type, or
139
     * one of the following constants to specify the index method to use: {@see INDEX_B_TREE}, {@see INDEX_HASH},
140
     * {@see INDEX_GIST}, {@see INDEX_GIN}.
141
     *
142
     * @throws Exception|InvalidArgumentException
143
     *
144
     * @return string the SQL statement for creating a new index.
145
     *
146
     * {@see http://www.postgresql.org/docs/8.2/static/sql-createindex.html}
147
     */
148
    public function createIndex(string $name, string $table, $columns, $unique = false): string
149
    {
150
        if ($unique === self::INDEX_UNIQUE || $unique === true) {
151 5
            $index = false;
152
            $unique = true;
153 5
        } else {
154 3
            $index = $unique;
155 3
            $unique = false;
156
        }
157 3
158 3
        return ($unique ? 'CREATE UNIQUE INDEX ' : 'CREATE INDEX ')
159
            . $this->getDb()->quoteTableName($name) . ' ON '
160
            . $this->getDb()->quoteTableName($table)
161 5
            . ($index !== false ? " USING $index" : '')
162 5
            . ' (' . $this->buildColumns($columns) . ')';
163 5
    }
164 5
165 5
    /**
166
     * Builds a SQL statement for dropping an index.
167
     *
168
     * @param string $name the name of the index to be dropped. The name will be properly quoted by the method.
169
     * @param string $table the table whose index is to be dropped. The name will be properly quoted by the method.
170
     *
171
     * @return string the SQL statement for dropping an index.
172
     */
173
    public function dropIndex(string $name, string $table): string
174
    {
175
        if (strpos($table, '.') !== false && strpos($name, '.') === false) {
176 3
            if (strpos($table, '{{') !== false) {
177
                $table = preg_replace('/{{(.*?)}}/', '\1', $table);
178 3
                [$schema, $table] = explode('.', $table);
179 1
                if (strpos($schema, '%') === false) {
180 1
                    $name = $schema . '.' . $name;
181 1
                } else {
182 1
                    $name = '{{' . $schema . '.' . $name . '}}';
183 1
                }
184
            } else {
185 1
                [$schema] = explode('.', $table);
186
                $name = $schema . '.' . $name;
187
            }
188
        }
189
190
        return 'DROP INDEX ' . $this->getDb()->quoteTableName($name);
191
    }
192
193 3
    /**
194
     * Builds a SQL statement for renaming a DB table.
195
     *
196
     * @param string $oldName the table to be renamed. The name will be properly quoted by the method.
197
     * @param string $newName the new table name. The name will be properly quoted by the method.
198
     *
199
     * @return string the SQL statement for renaming a DB table.
200
     */
201
    public function renameTable(string $oldName, string $newName): string
202
    {
203
        return 'ALTER TABLE ' . $this->getDb()->quoteTableName($oldName) . ' RENAME TO '
204 2
            . $this->getDb()->quoteTableName($newName);
205
    }
206 2
207 2
    /**
208
     * Creates a SQL statement for resetting the sequence value of a table's primary key.
209
     *
210
     * The sequence will be reset such that the primary key of the next new row inserted will have the specified value
211
     * or 1.
212
     *
213
     * @param string $tableName the name of the table whose primary key sequence will be reset.
214
     * @param mixed $value the value for the primary key of the next new row inserted. If this is not set, the next new
215
     * row's primary key will have a value 1.
216
     *
217
     * @throws Exception|InvalidArgumentException|JsonException if the table does not exist or there is no sequence
218
     * associated with the table.
219
     *
220
     * @return string the SQL statement for resetting sequence.
221
     *
222
     * @psalm-suppress MixedArgument
223
     */
224
    public function resetSequence(string $tableName, $value = null): string
225 1
    {
226
        $table = $this->getDb()->getTableSchema($tableName);
227 1
228 1
        if ($table !== null && ($sequence = $table->getSequenceName()) !== null) {
229
            /**
230
             * {@see http://www.postgresql.org/docs/8.1/static/functions-sequence.html}
231
             */
232 1
            $sequence = $this->getDb()->quoteTableName($sequence);
233 1
            $tableName = $this->getDb()->quoteTableName($tableName);
234 1
235 1
            if ($value === null) {
236 1
                $pk = $table->getPrimaryKey();
237 1
                $key = $this->getDb()->quoteColumnName(reset($pk));
238
                $value = "(SELECT COALESCE(MAX({$key}),0) FROM {$tableName})+1";
239 1
            } else {
240
                $value = (int) $value;
241
            }
242 1
243
            return "SELECT SETVAL('$sequence',$value,false)";
244
        }
245
246
        if ($table === null) {
247
            throw new InvalidArgumentException("Table not found: $tableName");
248
        }
249
250
        throw new InvalidArgumentException("There is not sequence associated with table '$tableName'.");
251
    }
252
253
    /**
254
     * Builds a SQL statement for enabling or disabling integrity check.
255
     *
256
     * @param string $schema the schema of the tables.
257
     * @param string $table the table name.
258
     * @param bool $check whether to turn on or off the integrity check.
259
     *
260
     * @throws Exception|NotSupportedException
261
     *
262
     * @return string the SQL statement for checking integrity.
263
     */
264
    public function checkIntegrity(string $schema = '', string $table = '', bool $check = true): string
265
    {
266
        /** @psalm-var Connection $db */
267
        $db = $this->getDb();
268
269
        $enable = $check ? 'ENABLE' : 'DISABLE';
270
        $schema = $schema ?: $db->getSchema()->getDefaultSchema();
271
        $tableNames = [];
272
        $viewNames = [];
273
274
        if ($schema !== null) {
275
            $tableNames = $table ? [$table] : $db->getSchema()->getTableNames($schema);
276
            $viewNames = $db->getSchema()->getViewNames($schema);
277
        }
278
279
        $tableNames = array_diff($tableNames, $viewNames);
280
        $command = '';
281
282
        foreach ($tableNames as $tableName) {
283
            $tableName = $db->quoteTableName("{$schema}.{$tableName}");
284
            $command .= "ALTER TABLE $tableName $enable TRIGGER ALL; ";
285
        }
286
287
        /** enable to have ability to alter several tables */
288
        $db->getMasterPdo()->setAttribute(PDO::ATTR_EMULATE_PREPARES, true);
289
290
        return $command;
291
    }
292
293 1
    /**
294
     * Builds a SQL statement for truncating a DB table.
295 1
     *
296
     * Explicitly restarts identity for PGSQL to be consistent with other databases which all do this by default.
297
     *
298
     * @param string $table the table to be truncated. The name will be properly quoted by the method.
299
     *
300
     * @return string the SQL statement for truncating a DB table.
301
     */
302
    public function truncateTable(string $table): string
303
    {
304
        return 'TRUNCATE TABLE ' . $this->getDb()->quoteTableName($table) . ' RESTART IDENTITY';
305
    }
306
307
    /**
308
     * Builds a SQL statement for changing the definition of a column.
309
     *
310
     * @param string $table the table whose column is to be changed. The table name will be properly quoted by the
311 1
     * method.
312
     * @param string $column the name of the column to be changed. The name will be properly quoted by the method.
313 1
     * @param ColumnSchemaBuilder|string $type the new column type. The {@see getColumnType()} method will be invoked to
314 1
     * convert abstract column type (if any) into the physical one. Anything that is not recognized as abstract type
315
     * will be kept in the generated SQL. For example, 'string' will be turned into 'varchar(255)', while
316
     * 'string not null' will become 'varchar(255) not null'. You can also use PostgreSQL-specific syntax such as
317
     * `SET NOT NULL`.
318
     *
319
     * @return string the SQL statement for changing the definition of a column.
320 1
     */
321 1
    public function alterColumn(string $table, string $column, $type): string
322
    {
323
        $columnName = $this->getDb()->quoteColumnName($column);
324 1
        $tableName = $this->getDb()->quoteTableName($table);
325 1
326 1
        if (is_object($type)) {
327
            /** @var string $type */
328 1
            $type = $type->__toString();
329 1
        }
330 1
331
        /**
332
         * {@see https://github.com/yiisoft/yii2/issues/4492}
333 1
         * {@see http://www.postgresql.org/docs/9.1/static/sql-altertable.html}
334
         */
335 1
        if (preg_match('/^(DROP|SET|RESET)\s+/i', $type)) {
336 1
            return "ALTER TABLE {$tableName} ALTER COLUMN {$columnName} {$type}";
337
        }
338
339 1
        $type = 'TYPE ' . $this->getColumnType($type);
340 1
341 1
        $multiAlterStatement = [];
342
        $constraintPrefix = preg_replace('/[^a-z0-9_]/i', '', $table . '_' . $column);
343
344
        if (preg_match('/\s+DEFAULT\s+(["\']?\w*["\']?)/i', $type, $matches)) {
345 1
            $type = preg_replace('/\s+DEFAULT\s+(["\']?\w*["\']?)/i', '', $type);
346 1
            $multiAlterStatement[] = "ALTER COLUMN {$columnName} SET DEFAULT {$matches[1]}";
347 1
        } else {
348
            // safe to drop default even if there was none in the first place
349
            $multiAlterStatement[] = "ALTER COLUMN {$columnName} DROP DEFAULT";
350 1
        }
351
352 1
        $type = preg_replace('/\s+NOT\s+NULL/i', '', $type, -1, $count);
353 1
354
        if ($count) {
355
            $multiAlterStatement[] = "ALTER COLUMN {$columnName} SET NOT NULL";
356
        } else {
357 1
            // remove additional null if any
358
            $type = preg_replace('/\s+NULL/i', '', $type);
359 1
            // safe to drop not null even if there was none in the first place
360
            $multiAlterStatement[] = "ALTER COLUMN {$columnName} DROP NOT NULL";
361
        }
362
363
        if (preg_match('/\s+CHECK\s+\((.+)\)/i', $type, $matches)) {
364
            $type = preg_replace('/\s+CHECK\s+\((.+)\)/i', '', $type);
365
            $multiAlterStatement[] = "ADD CONSTRAINT {$constraintPrefix}_check CHECK ({$matches[1]})";
366
        }
367
368
        $type = preg_replace('/\s+UNIQUE/i', '', $type, -1, $count);
369
        if ($count) {
370
            $multiAlterStatement[] = "ADD UNIQUE ({$columnName})";
371
        }
372
373
        // add what's left at the beginning
374
        array_unshift($multiAlterStatement, "ALTER COLUMN {$columnName} {$type}");
375
376
        return 'ALTER TABLE ' . $tableName . ' ' . implode(', ', $multiAlterStatement);
377
    }
378
379
    /**
380
     * Creates an INSERT SQL statement.
381
     *
382
     * For example,.
383
     *
384
     * ```php
385
     * $sql = $queryBuilder->insert('user', [
386
     *     'name' => 'Sam',
387
     *     'age' => 30,
388
     * ], $params);
389
     * ```
390 43
     *
391
     * The method will properly escape the table and column names.
392 43
     *
393
     * @param string $table the table that new rows will be inserted into.
394
     * @param array|Query $columns the column data (name => value) to be inserted into the table or instance of
395
     * {@see Query|Query} to perform INSERT INTO ... SELECT SQL statement. Passing of
396
     * {@see Query|Query}.
397
     * @param array $params the binding parameters that will be generated by this method. They should be bound to the
398
     * DB command later.
399
     *
400
     * @throws Exception|InvalidArgumentException|InvalidConfigException|JsonException|NotSupportedException
401
     *
402
     * @return string the INSERT SQL
403
     *
404
     * @psalm-suppress MixedArgument
405
     */
406
    public function insert(string $table, $columns, array &$params = []): string
407
    {
408
        return parent::insert($table, $this->normalizeTableRowData($table, $columns), $params);
409
    }
410
411
    /**
412
     * Creates an SQL statement to insert rows into a database table if they do not already exist (matching unique
413
     * constraints), or update them if they do.
414
     *
415
     * For example,
416
     *
417
     * ```php
418
     * $sql = $queryBuilder->upsert('pages', [
419
     *     'name' => 'Front page',
420
     *     'url' => 'http://example.com/', // url is unique
421
     *     'visits' => 0,
422
     * ], [
423
     *     'visits' => new \Yiisoft\Db\Expression('visits + 1'),
424
     * ], $params);
425
     * ```
426
     *
427
     * The method will properly escape the table and column names.
428
     *
429
     * @param string $table the table that new rows will be inserted into/updated in.
430 18
     * @param array|Query $insertColumns the column data (name => value) to be inserted into the table or instance of
431
     * {@see Query} to perform `INSERT INTO ... SELECT` SQL statement.
432 18
     * @param array|bool $updateColumns the column data (name => value) to be updated if they already exist.
433
     * If `true` is passed, the column data will be updated to match the insert column data.
434 18
     * If `false` is passed, no update will be performed if the column data already exists.
435 7
     * @param array $params the binding parameters that will be generated by this method.
436
     * They should be bound to the DB command later.
437 18
     *
438
     * @throws Exception|InvalidConfigException|JsonException|NotSupportedException if this is not supported by the
439
     * underlying DBMS.
440
     *
441 18
     * @return string the resulting SQL.
442
     *
443
     * {@see https://www.postgresql.org/docs/9.5/static/sql-insert.html#SQL-ON-CONFLICT}
444
     * {@see https://stackoverflow.com/questions/1109061/insert-on-duplicate-update-in-postgresql/8702291#8702291}
445
     *
446
     * @psalm-suppress MixedArgument
447
     * @psalm-suppress MixedAssignment
448
     */
449
    public function upsert(string $table, $insertColumns, $updateColumns, array &$params = []): string
450
    {
451
        $insertColumns = $this->normalizeTableRowData($table, $insertColumns);
452
453
        if (!is_bool($updateColumns)) {
454
            $updateColumns = $this->normalizeTableRowData($table, $updateColumns);
455
        }
456 18
457
        return $this->newUpsert($table, $insertColumns, $updateColumns, $params);
458 18
    }
459 18
460
    /**
461 18
     * {@see upsert()} implementation for PostgreSQL 9.5 or higher.
462 3
     *
463
     * @param string $table
464
     * @param array|Query $insertColumns
465 15
     * @param array|bool $updateColumns
466
     * @param array $params
467
     *
468
     * @throws Exception|InvalidArgumentException|InvalidConfigException|JsonException|NotSupportedException
469
     *
470 15
     * @return string
471 5
     */
472
    private function newUpsert(string $table, $insertColumns, $updateColumns, array &$params = []): string
473
    {
474 10
        $insertSql = $this->insert($table, $insertColumns, $params);
475 4
476 4
        /** @var array<array-key, mixed> $uniqueNames */
477 4
        [$uniqueNames, , $updateNames] = $this->prepareUpsertColumns($table, $insertColumns, $updateColumns);
478
479
        if (empty($uniqueNames)) {
480
            return $insertSql;
481 10
        }
482
483 10
        if ($updateNames === []) {
484 10
            /** there are no columns to update */
485
            $updateColumns = false;
486
        }
487
488
        if ($updateColumns === false) {
489
            return "$insertSql ON CONFLICT DO NOTHING";
490
        }
491
492
        if ($updateColumns === true) {
493
            $updateColumns = [];
494
495
            /** @var string $name */
496
            foreach ($updateNames as $name) {
497
                $updateColumns[$name] = new Expression('EXCLUDED.' . $this->getDb()->quoteColumnName($name));
498
            }
499
        }
500
501
        /** @var array<array-key, mixed> $updates */
502
        [$updates, $params] = $this->prepareUpdateSets($table, $updateColumns, $params);
503
504
        return $insertSql . ' ON CONFLICT (' . implode(', ', $uniqueNames) . ') DO UPDATE SET '
505
            . implode(', ', $updates);
506
    }
507
508
    /**
509
     * Creates an UPDATE SQL statement.
510
     *
511
     * For example,
512
     *
513
     * ```php
514
     * $params = [];
515
     * $sql = $queryBuilder->update('user', ['status' => 1], 'age > 30', $params);
516
     * ```
517
     *
518
     * The method will properly escape the table and column names.
519
     *
520
     * @param string $table the table to be updated.
521
     * @param array $columns the column data (name => value) to be updated.
522
     * @param array|string $condition the condition that will be put in the WHERE part. Please refer to
523
     * {@see Query::where()} on how to specify condition.
524
     * @param array $params the binding parameters that will be modified by this method so that they can be bound to the
525
     * DB command later.
526
     *
527
     * @throws Exception|InvalidArgumentException|JsonException
528
     *
529
     * @return string the UPDATE SQL.
530
     * @psalm-suppress MixedArgument
531
     */
532
    public function update(string $table, array $columns, $condition, array &$params = []): string
533
    {
534
        return parent::update($table, $this->normalizeTableRowData($table, $columns), $condition, $params);
535
    }
536
537
    /**
538
     * Normalizes data to be saved into the table, performing extra preparations and type converting, if necessary.
539
     *
540
     * @param string $table the table that data will be saved into.
541
     * @param array|Query $columns the column data (name => value) to be saved into the table or instance of
542
     * {@see Query} to perform INSERT INTO ... SELECT SQL statement. Passing of
543
     * {@see Query}.
544
     *
545
     * @return mixed normalized columns.
546
     */
547
    private function normalizeTableRowData(string $table, $columns)
548
    {
549
        if ($columns instanceof Query) {
550
            return $columns;
551
        }
552
553
        if (($tableSchema = $this->getDb()->getSchema()->getTableSchema($table)) !== null) {
554
            $columnSchemas = $tableSchema->getColumns();
555
            /** @var mixed $value */
556
            foreach ($columns as $name => $value) {
557
                if (
558
                    isset($columnSchemas[$name]) &&
559
                    $columnSchemas[$name]->getType() === Schema::TYPE_BINARY &&
560
                    is_string($value)
561
                ) {
562
                    /** explicitly setup PDO param type for binary column */
563
                    $columns[$name] = new PdoValue($value, PDO::PARAM_LOB);
564
                }
565
            }
566
        }
567
568
        return $columns;
569
    }
570
571
    /**
572
     * Generates a batch INSERT SQL statement.
573
     *
574
     * For example,
575
     *
576
     * ```php
577
     * $sql = $queryBuilder->batchInsert('user', ['name', 'age'], [
578
     *     ['Tom', 30],
579
     *     ['Jane', 20],
580
     *     ['Linda', 25],
581
     * ]);
582
     * ```
583
     *
584
     * Note that the values in each row must match the corresponding column names.
585
     *
586
     * The method will properly escape the column names, and quote the values to be inserted.
587
     *
588
     * @param string $table the table that new rows will be inserted into.
589
     * @param array<array-key, string> $columns the column names.
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<array-key, string> at position 2 could not be parsed: Unknown type name 'array-key' at position 2 in array<array-key, string>.
Loading history...
590
     * @param array|Generator $rows the rows to be batch inserted into the table.
591
     * @param array $params the binding parameters. This parameter exists.
592
     *
593
     * @throws Exception|InvalidArgumentException|InvalidConfigException|NotSupportedException
594
     *
595
     * @return string the batch INSERT SQL statement.
596
     * @psalm-suppress MoreSpecificImplementedParamType
597
     */
598
    public function batchInsert(string $table, array $columns, $rows, array &$params = []): string
599
    {
600
        if (empty($rows)) {
601
            return '';
602
        }
603
604
        /**
605
         * @var array<array-key, object> $columnSchemas
606
         */
607
        $columnSchemas = [];
608
        $schema = $this->getDb()->getSchema();
609
610
        if (($tableSchema = $schema->getTableSchema($table)) !== null) {
611
            $columnSchemas = $tableSchema->getColumns();
612
        }
613
614
        $values = [];
615
616
        /**
617
         * @var array<array-key, mixed> $row
618
         */
619
        foreach ($rows as $row) {
620
            $vs = [];
621
            /**
622
             *  @var int $i
623
             *  @var mixed $value
624
             */
625
            foreach ($row as $i => $value) {
626 4
                if (isset($columns[$i], $columnSchemas[$columns[$i]])) {
627
                    /**
628 4
                     * @var bool|ExpressionInterface|float|int|string|null $value
629
                     * @psalm-suppress MixedMethodCall
630
                     */
631
                    $value = $columnSchemas[$columns[$i]]->dbTypecast($value);
632
                }
633
634
                if (is_string($value)) {
635
                    $value = $schema->quoteValue($value);
636
                } elseif (is_float($value)) {
637
                    /** ensure type cast always has . as decimal separator in all locales */
638
                    $value = NumericHelper::normalize((string) $value);
639
                } elseif ($value === true) {
640
                    $value = 'TRUE';
641 45
                } elseif ($value === false) {
642
                    $value = 'FALSE';
643 45
                } elseif ($value === null) {
644 14
                    $value = 'NULL';
645
                } elseif ($value instanceof ExpressionInterface) {
646
                    $value = $this->buildExpression($value, $params);
647 37
                }
648 37
649 37
                /** @var bool|ExpressionInterface|float|int|string|null $value */
650
                $vs[] = $value;
651 37
            }
652 37
            $values[] = '(' . implode(', ', $vs) . ')';
653 37
        }
654
655
        if (empty($values)) {
656 1
            return '';
657
        }
658
659
        /** @var string name */
660
        foreach ($columns as $i => $name) {
661 37
            $columns[$i] = $schema->quoteColumnName($name);
662
        }
663
664
        return 'INSERT INTO ' . $schema->quoteTableName($table)
665
            . ' (' . implode(', ', $columns) . ') VALUES ' . implode(', ', $values);
666
    }
667
}
668