Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
- New #348: Realize `Schema::loadResultColumn()` method (@Tigrov)
- New #354: Add `FOR` clause to query (@vjik)
- New #355: Use `DateTimeColumn` class for datetime column types (@Tigrov)
- New #356: Implement `DMLQueryBuilder::upsertWithReturningPks()` method (@Tigrov)
- Enh #356: Refactor `Command::insertWithReturningPks()` and `DMLQueryBuilder::upsert()` methods (@Tigrov)

## 1.2.0 March 21, 2024

Expand Down
38 changes: 30 additions & 8 deletions src/Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@
use Yiisoft\Db\Driver\Pdo\AbstractPdoCommand;
use Yiisoft\Db\Exception\Exception;
use Yiisoft\Db\Exception\InvalidArgumentException;
use Yiisoft\Db\Exception\NotSupportedException;
use Yiisoft\Db\Query\QueryInterface;
use Yiisoft\Db\Schema\Column\ColumnInterface;

use function array_pop;
use function count;
use function ltrim;
use function mb_strlen;
use function preg_match_all;
use function strpos;

Expand All @@ -21,27 +25,45 @@
*/
final class Command extends AbstractPdoCommand
{
public function insertWithReturningPks(string $table, array $columns): array|false
public function insertWithReturningPks(string $table, array|QueryInterface $columns): array|false
{
$params = [];
$sql = $this->db->getQueryBuilder()->insert($table, $columns, $params);
$this->setSql($sql)->bindValues($params);

if (!$this->execute()) {
if ($this->execute() === 0) {
return false;
}

$tableSchema = $this->db->getSchema()->getTableSchema($table);
$tablePrimaryKeys = $tableSchema?->getPrimaryKey() ?? [];

if (empty($tablePrimaryKeys)) {
return [];
}

if ($columns instanceof QueryInterface) {
throw new NotSupportedException(__METHOD__ . '() not supported for QueryInterface by SQLite.');
}

$result = [];

/** @var TableSchema $tableSchema */
foreach ($tablePrimaryKeys as $name) {
if ($tableSchema?->getColumn($name)?->isAutoIncrement()) {
$result[$name] = $this->db->getLastInsertId((string) $tableSchema?->getSequenceName());
continue;
/** @var ColumnInterface $column */
$column = $tableSchema->getColumn($name);

if ($column->isAutoIncrement()) {
$value = $this->db->getLastInsertId();
} else {
$value = $columns[$name] ?? $column->getDefaultValue();
}

if ($this->phpTypecasting) {
$value = $column->phpTypecast($value);
}

$result[$name] = $columns[$name] ?? $tableSchema?->getColumn($name)?->getDefaultValue();
$result[$name] = $value;
}

return $result;
Expand Down Expand Up @@ -139,11 +161,11 @@ protected function queryInternal(int $queryMode): mixed
*
* @throws InvalidArgumentException
*
* @return array|bool List of SQL statements or `false` if there's a single statement.
* @return array|false List of SQL statements or `false` if there's a single statement.
*
* @psalm-return false|list<array{0: string, 1: array}>
*/
private function splitStatements(string $sql, array $params): bool|array
private function splitStatements(string $sql, array $params): array|false
{
$semicolonIndex = strpos($sql, ';');

Expand Down
80 changes: 35 additions & 45 deletions src/DMLQueryBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,21 @@

namespace Yiisoft\Db\Sqlite;

use Yiisoft\Db\Constraint\Constraint;
use Yiisoft\Db\Exception\InvalidArgumentException;
use Yiisoft\Db\Exception\NotSupportedException;
use Yiisoft\Db\Expression\Expression;
use Yiisoft\Db\Query\QueryInterface;
use Yiisoft\Db\QueryBuilder\AbstractDMLQueryBuilder;

use function array_map;
use function implode;

/**
* Implements a DML (Data Manipulation Language) SQL statements for SQLite Server.
*/
final class DMLQueryBuilder extends AbstractDMLQueryBuilder
{
public function insertWithReturningPks(string $table, QueryInterface|array $columns, array &$params = []): string
public function insertWithReturningPks(string $table, array|QueryInterface $columns, array &$params = []): string
{
throw new NotSupportedException(__METHOD__ . '() is not supported by SQLite.');
}
Expand Down Expand Up @@ -52,65 +52,55 @@ public function resetSequence(string $table, int|string|null $value = null): str

public function upsert(
string $table,
QueryInterface|array $insertColumns,
bool|array $updateColumns,
array &$params
array|QueryInterface $insertColumns,
array|bool $updateColumns = true,
array &$params = [],
): string {
/** @var Constraint[] $constraints */
$constraints = [];
$insertSql = $this->insert($table, $insertColumns, $params);

[$uniqueNames, $insertNames, $updateNames] = $this->prepareUpsertColumns(
$table,
$insertColumns,
$updateColumns,
$constraints
);
[$uniqueNames, , $updateNames] = $this->prepareUpsertColumns($table, $insertColumns, $updateColumns);

if (empty($uniqueNames)) {
return $this->insert($table, $insertColumns, $params);
}

[, $placeholders, $values, $params] = $this->prepareInsertValues($table, $insertColumns, $params);

$quotedTableName = $this->quoter->quoteTableName($table);

$insertSql = 'INSERT OR IGNORE INTO ' . $quotedTableName
. (!empty($insertNames) ? ' (' . implode(', ', $insertNames) . ')' : '')
. (!empty($placeholders) ? ' VALUES (' . implode(', ', $placeholders) . ')' : ' ' . $values);

if ($updateColumns === false) {
return $insertSql;
}

$updateCondition = ['or'];

foreach ($constraints as $constraint) {
$constraintCondition = ['and'];
/** @psalm-var string[] $columnNames */
$columnNames = $constraint->getColumnNames();
foreach ($columnNames as $name) {
$quotedName = $this->quoter->quoteColumnName($name);
$constraintCondition[] = "$quotedTableName.$quotedName=(SELECT $quotedName FROM `EXCLUDED`)";
}
$updateCondition[] = $constraintCondition;
if ($updateColumns === false || $updateNames === []) {
/** there are no columns to update */
return "$insertSql ON CONFLICT DO NOTHING";
}

if ($updateColumns === true) {
$updateColumns = [];

/** @psalm-var string[] $updateNames */
foreach ($updateNames as $quotedName) {
$updateColumns[$quotedName] = new Expression("(SELECT $quotedName FROM `EXCLUDED`)");
foreach ($updateNames as $name) {
$updateColumns[$name] = new Expression(
'EXCLUDED.' . $this->quoter->quoteColumnName($name)
);
}
}

if ($updateColumns === []) {
return $insertSql;
}
[$updates, $params] = $this->prepareUpdateSets($table, $updateColumns, $params);

return $insertSql
. ' ON CONFLICT (' . implode(', ', $uniqueNames) . ') DO UPDATE SET ' . implode(', ', $updates);
}

public function upsertWithReturningPks(
string $table,
array|QueryInterface $insertColumns,
array|bool $updateColumns = true,
array &$params = [],
): string {
$sql = $this->upsert($table, $insertColumns, $updateColumns, $params);
$returnColumns = $this->schema->getTableSchema($table)?->getPrimaryKey();

$updateSql = 'WITH "EXCLUDED" (' . implode(', ', $insertNames) . ') AS ('
. (!empty($placeholders) ? 'VALUES (' . implode(', ', $placeholders) . ')' : $values)
. ') ' . $this->update($table, $updateColumns, $updateCondition, $params);
if (!empty($returnColumns)) {
$returnColumns = array_map($this->quoter->quoteColumnName(...), $returnColumns);

$sql .= ' RETURNING ' . implode(', ', $returnColumns);
}

return "$updateSql; $insertSql;";
return $sql;
}
}
58 changes: 44 additions & 14 deletions tests/Provider/QueryBuilderProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -115,72 +115,72 @@ public static function upsert(): array
$concreteData = [
'regular values' => [
3 => <<<SQL
WITH "EXCLUDED" (`email`, `address`, `status`, `profile_id`) AS (VALUES (:qp0, :qp1, :qp2, :qp3)) UPDATE `T_upsert` SET `address`=(SELECT `address` FROM `EXCLUDED`), `status`=(SELECT `status` FROM `EXCLUDED`), `profile_id`=(SELECT `profile_id` FROM `EXCLUDED`) WHERE `T_upsert`.`email`=(SELECT `email` FROM `EXCLUDED`); INSERT OR IGNORE INTO `T_upsert` (`email`, `address`, `status`, `profile_id`) VALUES (:qp0, :qp1, :qp2, :qp3);
INSERT INTO `T_upsert` (`email`, `address`, `status`, `profile_id`) VALUES (:qp0, :qp1, :qp2, :qp3) ON CONFLICT (`email`) DO UPDATE SET `address`=EXCLUDED.`address`, `status`=EXCLUDED.`status`, `profile_id`=EXCLUDED.`profile_id`
SQL,
],
'regular values with unique at not the first position' => [
3 => <<<SQL
WITH "EXCLUDED" (`address`, `email`, `status`, `profile_id`) AS (VALUES (:qp0, :qp1, :qp2, :qp3)) UPDATE `T_upsert` SET `address`=(SELECT `address` FROM `EXCLUDED`), `status`=(SELECT `status` FROM `EXCLUDED`), `profile_id`=(SELECT `profile_id` FROM `EXCLUDED`) WHERE `T_upsert`.`email`=(SELECT `email` FROM `EXCLUDED`); INSERT OR IGNORE INTO `T_upsert` (`address`, `email`, `status`, `profile_id`) VALUES (:qp0, :qp1, :qp2, :qp3);
INSERT INTO `T_upsert` (`address`, `email`, `status`, `profile_id`) VALUES (:qp0, :qp1, :qp2, :qp3) ON CONFLICT (`email`) DO UPDATE SET `address`=EXCLUDED.`address`, `status`=EXCLUDED.`status`, `profile_id`=EXCLUDED.`profile_id`
SQL,
],
'regular values with update part' => [
3 => <<<SQL
WITH "EXCLUDED" (`email`, `address`, `status`, `profile_id`) AS (VALUES (:qp0, :qp1, :qp2, :qp3)) UPDATE `T_upsert` SET `address`=:qp4, `status`=:qp5, `orders`=T_upsert.orders + 1 WHERE `T_upsert`.`email`=(SELECT `email` FROM `EXCLUDED`); INSERT OR IGNORE INTO `T_upsert` (`email`, `address`, `status`, `profile_id`) VALUES (:qp0, :qp1, :qp2, :qp3);
INSERT INTO `T_upsert` (`email`, `address`, `status`, `profile_id`) VALUES (:qp0, :qp1, :qp2, :qp3) ON CONFLICT (`email`) DO UPDATE SET `address`=:qp4, `status`=:qp5, `orders`=T_upsert.orders + 1
SQL,
],
'regular values without update part' => [
3 => <<<SQL
INSERT OR IGNORE INTO `T_upsert` (`email`, `address`, `status`, `profile_id`) VALUES (:qp0, :qp1, :qp2, :qp3)
INSERT INTO `T_upsert` (`email`, `address`, `status`, `profile_id`) VALUES (:qp0, :qp1, :qp2, :qp3) ON CONFLICT DO NOTHING
SQL,
],
'query' => [
3 => <<<SQL
WITH "EXCLUDED" (`email`, `status`) AS (SELECT `email`, 2 AS `status` FROM `customer` WHERE `name`=:qp0 LIMIT 1) UPDATE `T_upsert` SET `status`=(SELECT `status` FROM `EXCLUDED`) WHERE `T_upsert`.`email`=(SELECT `email` FROM `EXCLUDED`); INSERT OR IGNORE INTO `T_upsert` (`email`, `status`) SELECT `email`, 2 AS `status` FROM `customer` WHERE `name`=:qp0 LIMIT 1;
INSERT INTO `T_upsert` (`email`, `status`) SELECT `email`, 2 AS `status` FROM `customer` WHERE `name`=:qp0 LIMIT 1 ON CONFLICT (`email`) DO UPDATE SET `status`=EXCLUDED.`status`
SQL,
],
'query with update part' => [
3 => <<<SQL
WITH "EXCLUDED" (`email`, `status`) AS (SELECT `email`, 2 AS `status` FROM `customer` WHERE `name`=:qp0 LIMIT 1) UPDATE `T_upsert` SET `address`=:qp1, `status`=:qp2, `orders`=T_upsert.orders + 1 WHERE `T_upsert`.`email`=(SELECT `email` FROM `EXCLUDED`); INSERT OR IGNORE INTO `T_upsert` (`email`, `status`) SELECT `email`, 2 AS `status` FROM `customer` WHERE `name`=:qp0 LIMIT 1;
INSERT INTO `T_upsert` (`email`, `status`) SELECT `email`, 2 AS `status` FROM `customer` WHERE `name`=:qp0 LIMIT 1 ON CONFLICT (`email`) DO UPDATE SET `address`=:qp1, `status`=:qp2, `orders`=T_upsert.orders + 1
SQL,
],
'query without update part' => [
3 => <<<SQL
INSERT OR IGNORE INTO `T_upsert` (`email`, `status`) SELECT `email`, 2 AS `status` FROM `customer` WHERE `name`=:qp0 LIMIT 1
INSERT INTO `T_upsert` (`email`, `status`) SELECT `email`, 2 AS `status` FROM `customer` WHERE `name`=:qp0 LIMIT 1 ON CONFLICT DO NOTHING
SQL,
],
'values and expressions' => [
3 => <<<SQL
WITH "EXCLUDED" (`email`, `ts`) AS (VALUES (:qp0, CURRENT_TIMESTAMP)) UPDATE {{%T_upsert}} SET `ts`=(SELECT `ts` FROM `EXCLUDED`) WHERE {{%T_upsert}}.`email`=(SELECT `email` FROM `EXCLUDED`); INSERT OR IGNORE INTO {{%T_upsert}} (`email`, `ts`) VALUES (:qp0, CURRENT_TIMESTAMP);
INSERT INTO {{%T_upsert}} (`email`, `ts`) VALUES (:qp0, CURRENT_TIMESTAMP) ON CONFLICT (`email`) DO UPDATE SET `ts`=EXCLUDED.`ts`
SQL,
],
'values and expressions with update part' => [
3 => <<<SQL
WITH "EXCLUDED" (`email`, `ts`) AS (VALUES (:qp0, CURRENT_TIMESTAMP)) UPDATE {{%T_upsert}} SET `orders`=T_upsert.orders + 1 WHERE {{%T_upsert}}.`email`=(SELECT `email` FROM `EXCLUDED`); INSERT OR IGNORE INTO {{%T_upsert}} (`email`, `ts`) VALUES (:qp0, CURRENT_TIMESTAMP);
INSERT INTO {{%T_upsert}} (`email`, `ts`) VALUES (:qp0, CURRENT_TIMESTAMP) ON CONFLICT (`email`) DO UPDATE SET `orders`=T_upsert.orders + 1
SQL,
],
'values and expressions without update part' => [
3 => <<<SQL
INSERT OR IGNORE INTO {{%T_upsert}} (`email`, `ts`) VALUES (:qp0, CURRENT_TIMESTAMP)
INSERT INTO {{%T_upsert}} (`email`, `ts`) VALUES (:qp0, CURRENT_TIMESTAMP) ON CONFLICT DO NOTHING
SQL,
],
'query, values and expressions with update part' => [
3 => <<<SQL
WITH "EXCLUDED" (`email`, [[ts]]) AS (SELECT :phEmail AS `email`, CURRENT_TIMESTAMP AS [[ts]]) UPDATE {{%T_upsert}} SET `ts`=:qp1, `orders`=T_upsert.orders + 1 WHERE {{%T_upsert}}.`email`=(SELECT `email` FROM `EXCLUDED`); INSERT OR IGNORE INTO {{%T_upsert}} (`email`, [[ts]]) SELECT :phEmail AS `email`, CURRENT_TIMESTAMP AS [[ts]];
INSERT INTO {{%T_upsert}} (`email`, [[ts]]) SELECT :phEmail AS `email`, CURRENT_TIMESTAMP AS [[ts]] ON CONFLICT (`email`) DO UPDATE SET `ts`=:qp1, `orders`=T_upsert.orders + 1
SQL,
],
'query, values and expressions without update part' => [
3 => <<<SQL
INSERT OR IGNORE INTO {{%T_upsert}} (`email`, [[ts]]) SELECT :phEmail AS `email`, CURRENT_TIMESTAMP AS [[ts]]
INSERT INTO {{%T_upsert}} (`email`, [[ts]]) SELECT :phEmail AS `email`, CURRENT_TIMESTAMP AS [[ts]] ON CONFLICT DO NOTHING
SQL,
],
'no columns to update' => [
3 => <<<SQL
INSERT OR IGNORE INTO `T_upsert_1` (`a`) VALUES (:qp0)
INSERT INTO `T_upsert_1` (`a`) VALUES (:qp0) ON CONFLICT DO NOTHING
SQL,
],
'no columns to update with unique' => [
3 => <<<SQL
INSERT OR IGNORE INTO {{%T_upsert}} (`email`) VALUES (:qp0)
INSERT INTO {{%T_upsert}} (`email`) VALUES (:qp0) ON CONFLICT DO NOTHING
SQL,
],
'no unique columns in table - simple insert' => [
Expand All @@ -199,6 +199,36 @@ public static function upsert(): array
return $upsert;
}

public static function upsertWithReturningPks(): array
{
$upsert = self::upsert();

foreach ($upsert as &$data) {
$data[3] .= ' RETURNING `id`';
}

$upsert['no columns to update'][3] = 'INSERT INTO `T_upsert_1` (`a`) VALUES (:qp0) ON CONFLICT DO NOTHING RETURNING `a`';

return [
...$upsert,
'composite primary key' => [
'notauto_pk',
['id_1' => 1, 'id_2' => 2.5, 'type' => 'Test'],
true,
'INSERT INTO `notauto_pk` (`id_1`, `id_2`, `type`) VALUES (:qp0, :qp1, :qp2)'
. ' ON CONFLICT (`id_1`, `id_2`) DO UPDATE SET `type`=EXCLUDED.`type` RETURNING `id_1`, `id_2`',
[':qp0' => 1, ':qp1' => 2.5, ':qp2' => 'Test'],
],
'no primary key' => [
'type',
['int_col' => 3, 'char_col' => 'a', 'float_col' => 1.2, 'bool_col' => true],
true,
'INSERT INTO `type` (`int_col`, `char_col`, `float_col`, `bool_col`) VALUES (:qp0, :qp1, :qp2, :qp3)',
[':qp0' => 3, ':qp1' => 'a', ':qp2' => 1.2, ':qp3' => true],
],
];
}

public static function buildColumnDefinition(): array
{
$values = parent::buildColumnDefinition();
Expand Down
21 changes: 8 additions & 13 deletions tests/QueryBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -617,26 +617,21 @@ public function testUpsert(
string $table,
array|QueryInterface $insertColumns,
array|bool $updateColumns,
string $expectedSQL,
string $expectedSql,
array $expectedParams
): void {
$db = $this->getConnection(true);

$actualParams = [];
$actualSQL = $db->getQueryBuilder()->upsert($table, $insertColumns, $updateColumns, $actualParams);

$this->assertSame($expectedSQL, $actualSQL);

$this->assertSame($expectedParams, $actualParams);
parent::testUpsert($table, $insertColumns, $updateColumns, $expectedSql, $expectedParams);
}

#[DataProviderExternal(QueryBuilderProvider::class, 'upsert')]
public function testUpsertExecute(
#[DataProviderExternal(QueryBuilderProvider::class, 'upsertWithReturningPks')]
public function testUpsertWithReturningPks(
string $table,
array|QueryInterface $insertColumns,
array|bool $updateColumns
array|bool $updateColumns,
string $expectedSql,
array $expectedParams
): void {
parent::testUpsertExecute($table, $insertColumns, $updateColumns);
parent::testUpsertWithReturningPks($table, $insertColumns, $updateColumns, $expectedSql, $expectedParams);
}

#[DataProviderExternal(QueryBuilderProvider::class, 'selectScalar')]
Expand Down
2 changes: 1 addition & 1 deletion tests/Support/Fixture/sqlite.sql
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ CREATE TABLE "default_pk" (

CREATE TABLE "notauto_pk" (
id_1 INTEGER,
id_2 INTEGER,
id_2 DECIMAL(5,2),
type VARCHAR(255) NOT NULL,
PRIMARY KEY (id_1, id_2)
);
Expand Down