Passed
Push — master ( e6cfe3...1d57d1 )
by Def
17:23 queued 13:56
created

DMLQueryBuilder::upsert()   A

Complexity

Conditions 6
Paths 7

Size

Total Lines 49
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 22
CRAP Score 6

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 6
eloc 19
c 2
b 0
f 0
nc 7
nop 4
dl 0
loc 49
ccs 22
cts 22
cp 1
crap 6
rs 9.0111
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Db\Pgsql;
6
7
use JsonException;
8
use PDO;
9
use Yiisoft\Db\Command\Param;
10
use Yiisoft\Db\Exception\Exception;
11
use Yiisoft\Db\Exception\InvalidArgumentException;
12
use Yiisoft\Db\Exception\InvalidConfigException;
13
use Yiisoft\Db\Exception\NotSupportedException;
14
use Yiisoft\Db\Expression\Expression;
15
use Yiisoft\Db\QueryBuilder\DMLQueryBuilder as AbstractDMLQueryBuilder;
16
use Yiisoft\Db\QueryBuilder\QueryBuilderInterface;
17
use Yiisoft\Db\Query\Query;
18
use Yiisoft\Db\Query\QueryInterface;
19
use Yiisoft\Db\Schema\QuoterInterface;
20
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...
21
use Yiisoft\Db\Schema\SchemaInterface;
22
23
use function implode;
24
use function is_array;
25
use function is_string;
26
use function reset;
27
28
final class DMLQueryBuilder extends AbstractDMLQueryBuilder
29
{
30 546
    public function __construct(
31
        private QueryBuilderInterface $queryBuilder,
32
        private QuoterInterface $quoter,
33
        private SchemaInterface $schema
34
    ) {
35 546
        parent::__construct($queryBuilder, $quoter, $schema);
36
    }
37
38 6
    public function insertEx(string $table, QueryInterface|array $columns, array &$params = []): string
39
    {
40 6
        $sql = $this->insert($table, $columns, $params);
41
42 6
        $tableSchema = $this->schema->getTableSchema($table);
43
44 6
        $returnColumns = [];
45 6
        if ($tableSchema !== null) {
46 6
            $returnColumns = $tableSchema->getPrimaryKey();
47
        }
48
49 6
        if (!empty($returnColumns)) {
50 5
            $returning = [];
51 5
            foreach ($returnColumns as $name) {
52 5
                $returning[] = $this->quoter->quoteColumnName($name);
53
            }
54 5
            $sql .= ' RETURNING ' . implode(', ', $returning);
55
        }
56
57 6
        return $sql;
58
    }
59
60
    /**
61
     * Creates a SQL statement for resetting the sequence value of a table's primary key.
62
     *
63
     * The sequence will be reset such that the primary key of the next new row inserted will have the specified value
64
     * or 1.
65
     *
66
     * @param string $tableName the name of the table whose primary key sequence will be reset.
67
     * @param int|string|null $value the value for the primary key of the next new row inserted. If this is not set, the
68
     * next new row's primary key will have a value 1.
69
     *
70
     * @throws Exception|InvalidArgumentException if the table does not exist or there is no sequence
71
     * associated with the table.
72
     *
73
     * @return string the SQL statement for resetting sequence.
74
     */
75 4
    public function resetSequence(string $tableName, int|string $value = null): string
76
    {
77 4
        $table = $this->schema->getTableSchema($tableName);
78
79 4
        if ($table !== null && ($sequence = $table->getSequenceName()) !== null) {
80
            /**
81
             * {@see http://www.postgresql.org/docs/8.1/static/functions-sequence.html}
82
             */
83 2
            $sequence = $this->quoter->quoteTableName($sequence);
84 2
            $tableName = $this->quoter->quoteTableName($tableName);
85
86 2
            if ($value === null) {
87 2
                $pk = $table->getPrimaryKey();
88 2
                $key = $this->quoter->quoteColumnName(reset($pk));
89 2
                $value = "(SELECT COALESCE(MAX($key),0) FROM $tableName)+1";
90
            }
91
92 2
            return "SELECT SETVAL('$sequence',$value,false)";
93
        }
94
95 2
        if ($table === null) {
96 1
            throw new InvalidArgumentException("Table not found: '$tableName'.");
97
        }
98
99 1
        throw new InvalidArgumentException("There is not sequence associated with table '$tableName'.");
100
    }
101
102
    /**
103
     * Creates an SQL statement to insert rows into a database table if they do not already exist (matching unique
104
     * constraints), or update them if they do.
105
     *
106
     * For example,
107
     *
108
     * ```php
109
     * $sql = $queryBuilder->upsert('pages', [
110
     *     'name' => 'Front page',
111
     *     'url' => 'http://example.com/', // url is unique
112
     *     'visits' => 0,
113
     * ], [
114
     *     'visits' => new \Yiisoft\Db\Expression('visits + 1'),
115
     * ], $params);
116
     * ```
117
     *
118
     * The method will properly escape the table and column names.
119
     *
120
     * @param string $table the table that new rows will be inserted into/updated in.
121
     * @param array|QueryInterface $insertColumns the column data (name => value) to be inserted into the table or
122
     * instance of {@see Query} to perform `INSERT INTO ... SELECT` SQL statement.
123
     * @param array|bool|QueryInterface $updateColumns the column data (name => value) to be updated if they already
124
     * exist.
125
     * If `true` is passed, the column data will be updated to match the insert column data.
126
     * If `false` is passed, no update will be performed if the column data already exists.
127
     * @param array $params the binding parameters that will be generated by this method.
128
     * They should be bound to the DB command later.
129
     *
130
     * @throws Exception|InvalidConfigException|InvalidConfigException|JsonException|NotSupportedException
131
     * if this is not supported by the underlying DBMS.
132
     *
133
     * @return string the resulting SQL.
134
     *
135
     * @link https://www.postgresql.org/docs/9.5/static/sql-insert.html#SQL-ON-CONFLICT
136
     * @link https://stackoverflow.com/questions/1109061/insert-on-duplicate-update-in-postgresql/8702291#8702291
137
     *
138
     * @psalm-suppress UndefinedInterfaceMethod
139
     */
140 34
    public function upsert(
141
        string $table,
142
        QueryInterface|array $insertColumns,
143
        $updateColumns,
144
        array &$params = []
145
    ): string {
146 34
        $insertSql = $this->insert($table, $insertColumns, $params);
147
148
        /** @var array $uniqueNames */
149 34
        [$uniqueNames, , $updateNames] = $this->prepareUpsertColumns(
150 34
            $table,
151 34
            $insertColumns,
152 34
            $updateColumns,
153 34
        );
154
155 34
        if (empty($uniqueNames)) {
156 2
            return $insertSql;
157
        }
158
159 32
        if ($updateNames === []) {
0 ignored issues
show
introduced by
The condition $updateNames === array() is always false.
Loading history...
160
            /** there are no columns to update */
161 2
            $updateColumns = false;
162
        }
163
164 32
        if ($updateColumns === false) {
165 14
            return "$insertSql ON CONFLICT DO NOTHING";
166
        }
167
168 18
        if ($updateColumns === true) {
169 8
            $updateColumns = [];
170
171
            /** @var string $name */
172 8
            foreach ($updateNames as $name) {
0 ignored issues
show
Bug introduced by
The expression $updateNames of type null is not traversable.
Loading history...
173 8
                $updateColumns[$name] = new Expression(
174 8
                    'EXCLUDED.' . $this->quoter->quoteColumnName($name)
175 8
                );
176
            }
177
        }
178
179
        /**
180
         * @var array $updateColumns
181
         *
182
         * @psalm-var string[] $uniqueNames
183
         * @psalm-var string[] $updates
184
         */
185 18
        [$updates, $params] = $this->prepareUpdateSets($table, $updateColumns, $params);
186
187 18
        return $insertSql
188 18
            . ' ON CONFLICT (' . implode(', ', $uniqueNames) . ') DO UPDATE SET ' . implode(', ', $updates);
189
    }
190
}
191