Passed
Pull Request — master (#38)
by Wilmer
12:04
created

QueryBuilder::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 2
c 0
b 0
f 0
nc 1
nop 1
dl 0
loc 5
ccs 2
cts 2
cp 1
crap 1
rs 10
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