Passed
Push — dev ( 65e869...962df0 )
by Def
08:15 queued 05:32
created

DMLQueryBuilder   A

Complexity

Total Complexity 42

Size/Duplication

Total Lines 366
Duplicated Lines 0 %

Test Coverage

Coverage 94.95%

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 100
c 1
b 0
f 0
dl 0
loc 366
ccs 94
cts 99
cp 0.9495
rs 9.0399
wmc 42

9 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 3 1
A newUpsert() 0 49 6
B normalizeTableRowData() 0 22 7
A upsert() 0 13 2
A resetSequence() 0 27 5
A update() 0 9 2
C batchInsert() 0 73 14
A insert() 0 3 1
A insertEx() 0 20 4

How to fix   Complexity   

Complex Class

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

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

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

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\Driver\PDO\PDOValue;
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\Expression;
16
use Yiisoft\Db\Expression\ExpressionInterface;
17
use Yiisoft\Db\Query\DMLQueryBuilder as AbstractDMLQueryBuilder;
18
use Yiisoft\Db\Query\Query;
19
use Yiisoft\Db\Query\QueryBuilderInterface;
20
use Yiisoft\Db\Query\QueryInterface;
21
use Yiisoft\Db\Schema\Schema;
0 ignored issues
show
Bug introduced by
This use statement conflicts with another class in this namespace, Yiisoft\Db\Pgsql\Schema. Consider defining an alias.

Let?s assume that you have a directory layout like this:

.
|-- OtherDir
|   |-- Bar.php
|   `-- Foo.php
`-- SomeDir
    `-- Foo.php

and let?s assume the following content of Bar.php:

// Bar.php
namespace OtherDir;

use SomeDir\Foo; // This now conflicts the class OtherDir\Foo

If both files OtherDir/Foo.php and SomeDir/Foo.php are loaded in the same runtime, you will see a PHP error such as the following:

PHP Fatal error:  Cannot use SomeDir\Foo as Foo because the name is already in use in OtherDir/Foo.php

However, as OtherDir/Foo.php does not necessarily have to be loaded and the error is only triggered if it is loaded before OtherDir/Bar.php, this problem might go unnoticed for a while. In order to prevent this error from surfacing, you must import the namespace with a different alias:

// Bar.php
namespace OtherDir;

use SomeDir\Foo as SomeDirFoo; // There is no conflict anymore.
Loading history...
22
use Yiisoft\Strings\NumericHelper;
23
24
use function implode;
25
use function is_array;
26
use function is_string;
27
use function reset;
28
29
final class DMLQueryBuilder extends AbstractDMLQueryBuilder
30
{
31 414
    public function __construct(private QueryBuilderInterface $queryBuilder)
32
    {
33 414
        parent::__construct($queryBuilder);
34
    }
35
36 1
    public function insertEx(string $table, QueryInterface|array $columns, array &$params = []): string
37
    {
38 1
        $sql = $this->insert($table, $columns, $params);
39
40 1
        $tableSchema = $this->schema->getTableSchema($table);
41
42 1
        $returnColumns = [];
43 1
        if ($tableSchema !== null) {
44 1
            $returnColumns = $tableSchema->getPrimaryKey();
45
        }
46
47 1
        if (!empty($returnColumns)) {
48 1
            $returning = [];
49 1
            foreach ($returnColumns as $name) {
50 1
                $returning[] = $this->quoter->quoteColumnName($name);
51
            }
52 1
            $sql .= ' RETURNING ' . implode(', ', $returning);
53
        }
54
55 1
        return $sql;
56
    }
57
58
    /**
59
     * @throws Exception|InvalidArgumentException|InvalidConfigException|NotSupportedException
60
     *
61
     * @psalm-suppress MixedArrayOffset
62
     */
63 20
    public function batchInsert(string $table, array $columns, iterable|Generator $rows, array &$params = []): string
64
    {
65 20
        if (empty($rows)) {
66 2
            return '';
67
        }
68
69
        /**
70
         * @psalm-var array<array-key, ColumnSchema> $columnSchemas
71
         */
72 19
        $columnSchemas = [];
73 19
        $schema = $this->schema;
74
75 19
        if (($tableSchema = $schema->getTableSchema($table)) !== null) {
76 19
            $columnSchemas = $tableSchema->getColumns();
77
        }
78
79 19
        $values = [];
80
81
        /**
82
         * @var array $row
83
         */
84 19
        foreach ($rows as $row) {
85 19
            $vs = [];
86
            /**
87
             *  @var int $i
88
             *  @var mixed $value
89
             */
90 19
            foreach ($row as $i => $value) {
91 19
                if (isset($columns[$i], $columnSchemas[$columns[$i]])) {
92
                    /**
93
                     * @var bool|ExpressionInterface|float|int|string|null $value
94
                     */
95 16
                    $value = $columnSchemas[$columns[$i]]->dbTypecast($value);
96
                }
97
98 19
                if (is_string($value)) {
99
                    /** @var mixed */
100 9
                    $value = $this->quoter->quoteValue($value);
101 13
                } elseif (is_float($value)) {
102
                    /** ensure type cast always has . as decimal separator in all locales */
103 1
                    $value = NumericHelper::normalize((string) $value);
104 13
                } elseif ($value === true) {
105 3
                    $value = 'TRUE';
106 13
                } elseif ($value === false) {
107 5
                    $value = 'FALSE';
108 11
                } elseif ($value === null) {
109 4
                    $value = 'NULL';
110 8
                } elseif ($value instanceof ExpressionInterface) {
111 6
                    $value = $this->queryBuilder->buildExpression($value, $params);
112
                }
113
114
                /** @var bool|ExpressionInterface|float|int|string|null $value */
115 19
                $vs[] = $value;
116
            }
117 19
            $values[] = '(' . implode(', ', $vs) . ')';
118
        }
119
120 19
        if (empty($values)) {
121
            return '';
122
        }
123
124
        /** @var string name */
125 19
        foreach ($columns as $i => $name) {
126 18
            $columns[$i] = $this->quoter->quoteColumnName($name);
127
        }
128
129
        /**
130
         * @psalm-var string[] $columns
131
         * @psalm-var string[] $values
132
         */
133
        return 'INSERT INTO '
134 19
            . $this->quoter->quoteTableName($table)
135 19
            . ' (' . implode(', ', $columns) . ') VALUES ' . implode(', ', $values);
136
    }
137
138
    /**
139
     * Creates an INSERT SQL statement.
140
     *
141
     * For example,
142
     *
143
     * ```php
144
     * $sql = $queryBuilder->insert('user', [
145
     *     'name' => 'Sam',
146
     *     'age' => 30,
147
     * ], $params);
148
     * ```
149
     *
150
     * The method will properly escape the table and column names.
151
     *
152
     * @param string $table the table that new rows will be inserted into.
153
     * @param array|QueryInterface $columns the column data (name => value) to be inserted into the table or instance of
154
     * {@see Query} to perform INSERT INTO ... SELECT SQL statement. Passing of {@see Query}.
155
     * @param array $params the binding parameters that will be generated by this method. They should be bound to the
156
     * DB command later.
157
     *
158
     * @return string the INSERT SQL
159
     *
160
     * @psalm-suppress UndefinedInterfaceMethod
161
     * @psalm-suppress MixedArgument
162
     */
163 49
    public function insert(string $table, QueryInterface|array $columns, array &$params = []): string
164
    {
165 49
        return parent::insert($table, $this->normalizeTableRowData($table, $columns), $params);
166
    }
167
168
    /**
169
     * {@see upsert()} implementation for PostgresSQL 9.5 or higher.
170
     *
171
     * @param string $table
172
     * @param array|QueryInterface $insertColumns
173
     * @param array|bool|QueryInterface $updateColumns
174
     * @param array $params
175
     *
176
     * @throws Exception|InvalidArgumentException|InvalidConfigException|JsonException|NotSupportedException
177
     *
178
     * @return string
179
     */
180 18
    public function newUpsert(
181
        string $table,
182
        QueryInterface|array $insertColumns,
183
        bool|array|QueryInterface $updateColumns,
184
        array &$params = []
185
    ): string {
186 18
        $insertSql = $this->insert($table, $insertColumns, $params);
187
188
        /** @var array $uniqueNames */
189 18
        [$uniqueNames, , $updateNames] = $this->prepareUpsertColumns(
190
            $table,
191
            $insertColumns,
192
            $updateColumns,
193
        );
194
195 18
        if (empty($uniqueNames)) {
196 3
            return $insertSql;
197
        }
198
199 15
        if ($updateNames === []) {
0 ignored issues
show
introduced by
The condition $updateNames === array() is always false.
Loading history...
200
            /** there are no columns to update */
201
            $updateColumns = false;
202
        }
203
204 15
        if ($updateColumns === false) {
0 ignored issues
show
introduced by
The condition $updateColumns === false is always false.
Loading history...
205 5
            return "$insertSql ON CONFLICT DO NOTHING";
206
        }
207
208 10
        if ($updateColumns === true) {
0 ignored issues
show
introduced by
The condition $updateColumns === true is always false.
Loading history...
209 4
            $updateColumns = [];
210
211
            /** @var string $name */
212 4
            foreach ($updateNames as $name) {
213 4
                $updateColumns[$name] = new Expression(
214 4
                    'EXCLUDED.' . $this->quoter->quoteColumnName($name)
215
                );
216
            }
217
        }
218
219
        /**
220
         * @var array $updateColumns
221
         *
222
         * @psalm-var string[] $uniqueNames
223
         * @psalm-var string[] $updates
224
         */
225 10
        [$updates, $params] = $this->prepareUpdateSets($table, $updateColumns, $params);
226
227
        return $insertSql
228 10
            . ' ON CONFLICT (' . implode(', ', $uniqueNames) . ') DO UPDATE SET ' . implode(', ', $updates);
229
    }
230
231
    /**
232
     * Creates a SQL statement for resetting the sequence value of a table's primary key.
233
     *
234
     * The sequence will be reset such that the primary key of the next new row inserted will have the specified value
235
     * or 1.
236
     *
237
     * @param string $tableName the name of the table whose primary key sequence will be reset.
238
     * @param mixed $value the value for the primary key of the next new row inserted. If this is not set, the next new
239
     * row's primary key will have a value 1.
240
     *
241
     * @throws Exception|InvalidArgumentException if the table does not exist or there is no sequence
242
     * associated with the table.
243
     *
244
     * @return string the SQL statement for resetting sequence.
245
     */
246 2
    public function resetSequence(string $tableName, $value = null): string
247
    {
248 2
        $table = $this->schema->getTableSchema($tableName);
249
250 2
        if ($table !== null && ($sequence = $table->getSequenceName()) !== null) {
251
            /**
252
             * {@see http://www.postgresql.org/docs/8.1/static/functions-sequence.html}
253
             */
254 2
            $sequence = $this->quoter->quoteTableName($sequence);
255 2
            $tableName = $this->quoter->quoteTableName($tableName);
256
257 2
            if ($value === null) {
258 2
                $pk = $table->getPrimaryKey();
259 2
                $key = $this->quoter->quoteColumnName(reset($pk));
260 2
                $value = "(SELECT COALESCE(MAX($key),0) FROM $tableName)+1";
261
            } else {
262 2
                $value = (int) $value;
263
            }
264
265 2
            return "SELECT SETVAL('$sequence',$value,false)";
266
        }
267
268
        if ($table === null) {
269
            throw new InvalidArgumentException("Table not found: $tableName");
270
        }
271
272
        throw new InvalidArgumentException("There is not sequence associated with table '$tableName'.");
273
    }
274
275
    /**
276
     * Creates an UPDATE SQL statement.
277
     *
278
     * For example,
279
     *
280
     * ```php
281
     * $params = [];
282
     * $sql = $queryBuilder->update('user', ['status' => 1], 'age > 30', $params);
283
     * ```
284
     *
285
     * The method will properly escape the table and column names.
286
     *
287
     * @param string $table the table to be updated.
288
     * @param array $columns the column data (name => value) to be updated.
289
     * @param array|string $condition the condition that will be put in the WHERE part. Please refer to
290
     * {@see Query::where()} on how to specify condition.
291
     * @param array $params the binding parameters that will be modified by this method so that they can be bound to the
292
     * DB command later.
293
     *
294
     * @return string the UPDATE SQL.
295
     *
296
     * @psalm-suppress UndefinedInterfaceMethod
297
     */
298 4
    public function update(string $table, array $columns, array|string $condition, array &$params = []): string
299
    {
300 4
        $normalizeTableRowData = $this->normalizeTableRowData($table, $columns);
301
302 4
        return parent::update(
303
            $table,
304 4
            is_array($normalizeTableRowData) ? $normalizeTableRowData : [],
0 ignored issues
show
introduced by
The condition is_array($normalizeTableRowData) is always true.
Loading history...
305
            $condition,
306
            $params,
307
        );
308
    }
309
310
    /**
311
     * Creates an SQL statement to insert rows into a database table if they do not already exist (matching unique
312
     * constraints), or update them if they do.
313
     *
314
     * For example,
315
     *
316
     * ```php
317
     * $sql = $queryBuilder->upsert('pages', [
318
     *     'name' => 'Front page',
319
     *     'url' => 'http://example.com/', // url is unique
320
     *     'visits' => 0,
321
     * ], [
322
     *     'visits' => new \Yiisoft\Db\Expression('visits + 1'),
323
     * ], $params);
324
     * ```
325
     *
326
     * The method will properly escape the table and column names.
327
     *
328
     * @param string $table the table that new rows will be inserted into/updated in.
329
     * @param array|QueryInterface $insertColumns the column data (name => value) to be inserted into the table or
330
     * instance of {@see Query} to perform `INSERT INTO ... SELECT` SQL statement.
331
     * @param array|bool|QueryInterface $updateColumns the column data (name => value) to be updated if they already
332
     * exist.
333
     * If `true` is passed, the column data will be updated to match the insert column data.
334
     * If `false` is passed, no update will be performed if the column data already exists.
335
     * @param array $params the binding parameters that will be generated by this method.
336
     * They should be bound to the DB command later.
337
     *
338
     * @throws Exception|InvalidConfigException|JsonException|NotSupportedException if this is not supported by the
339
     * underlying DBMS.
340
     *
341
     * @return string the resulting SQL.
342
     *
343
     * @link https://www.postgresql.org/docs/9.5/static/sql-insert.html#SQL-ON-CONFLICT
344
     * @link https://stackoverflow.com/questions/1109061/insert-on-duplicate-update-in-postgresql/8702291#8702291
345
     *
346
     * @psalm-suppress UndefinedInterfaceMethod
347
     */
348 18
    public function upsert(
349
        string $table,
350
        QueryInterface|array $insertColumns,
351
        $updateColumns,
352
        array &$params = []
353
    ): string {
354 18
        $insertColumns = $this->normalizeTableRowData($table, $insertColumns);
355
356 18
        if (!is_bool($updateColumns)) {
357 7
            $updateColumns = $this->normalizeTableRowData($table, $updateColumns);
358
        }
359
360 18
        return $this->newUpsert($table, $insertColumns, $updateColumns, $params);
361
    }
362
363
    /**
364
     * Normalizes data to be saved into the table, performing extra preparations and type converting, if necessary.
365
     *
366
     * @param string $table the table that data will be saved into.
367
     * @param array|QueryInterface $columns the column data (name => value) to be saved into the table or instance of
368
     * {@see QueryInterface} to perform INSERT INTO ... SELECT SQL statement. Passing of
369
     * {@see QueryInterface}.
370
     *
371
     * @return array|QueryInterface normalized columns.
372
     */
373 51
    private function normalizeTableRowData(string $table, QueryInterface|array $columns): QueryInterface|array
374
    {
375 51
        if ($columns instanceof QueryInterface) {
0 ignored issues
show
introduced by
$columns is never a sub-type of Yiisoft\Db\Query\QueryInterface.
Loading history...
376 14
            return $columns;
377
        }
378
379 43
        if (($tableSchema = $this->schema->getTableSchema($table)) !== null) {
380 43
            $columnSchemas = $tableSchema->getColumns();
381
            /** @var mixed $value */
382 43
            foreach ($columns as $name => $value) {
383
                if (
384 43
                    isset($columnSchemas[$name]) &&
385 43
                    $columnSchemas[$name]->getType() === Schema::TYPE_BINARY &&
386 43
                    is_string($value)
387
                ) {
388
                    /** explicitly setup PDO param type for binary column */
389 2
                    $columns[$name] = new PDOValue($value, PDO::PARAM_LOB);
390
                }
391
            }
392
        }
393
394 43
        return $columns;
395
    }
396
}
397