Skip to content

Feature/upsert #224

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 5 commits into from
Closed
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
8 changes: 8 additions & 0 deletions src/Database.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use Cycle\Database\Query\InsertQuery;
use Cycle\Database\Query\SelectQuery;
use Cycle\Database\Query\UpdateQuery;
use Cycle\Database\Query\UpsertQuery;

/**
* Database class is high level abstraction at top of Driver. Databases usually linked to real
Expand Down Expand Up @@ -139,6 +140,13 @@ public function insert(?string $table = null): InsertQuery
->insertQuery($this->prefix, $table);
}

public function upsert(?string $table = null): UpsertQuery
{
return $this->getDriver(self::WRITE)
->getQueryBuilder()
->upsertQuery($this->prefix, $table);
}

public function update(?string $table = null, array $values = [], array $where = []): UpdateQuery
{
return $this->getDriver(self::WRITE)
Expand Down
10 changes: 10 additions & 0 deletions src/DatabaseInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use Cycle\Database\Query\InsertQuery;
use Cycle\Database\Query\SelectQuery;
use Cycle\Database\Query\UpdateQuery;
use Cycle\Database\Query\UpsertQuery;

/**
* DatabaseInterface is high level abstraction used to represent single database. You must always
Expand Down Expand Up @@ -104,6 +105,15 @@ public function query(string $query, array $parameters = []): StatementInterface
*/
public function insert(string $table = ''): InsertQuery;

/**
* Get instance of UpsertBuilder associated with current Database.
*
* @param string $table Table where values should be upserted to.
*
* @see self::withoutCache() May be useful to disable query cache for batch inserts.
*/
public function upsert(string $table = ''): UpsertQuery;

/**
* Get instance of UpdateBuilder associated with current Database.
*
Expand Down
40 changes: 40 additions & 0 deletions src/Driver/Compiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ protected function fragment(
case self::INSERT_QUERY:
return $this->insertQuery($params, $q, $tokens);

case self::UPSERT_QUERY:
return $this->upsertQuery($params, $q, $tokens);

case self::SELECT_QUERY:
if ($nestedQuery) {
if ($fragment->getPrefix() !== null) {
Expand Down Expand Up @@ -169,6 +172,43 @@ protected function insertQuery(QueryParameters $params, Quoter $q, array $tokens
);
}

/**
* @psalm-return non-empty-string
*/
protected function upsertQuery(QueryParameters $params, Quoter $q, array $tokens): string
{
if (\count($tokens['conflicts']) === 0) {
throw new CompilerException('Upsert query must define conflicting index column names');
}

if (\count($tokens['columns']) === 0) {
throw new CompilerException('Upsert query must define at least one column');
}

$values = [];

foreach ($tokens['values'] as $value) {
$values[] = $this->value($params, $q, $value);
}

$updates = \array_map(
function (string $column) use ($params, $q) {
$name = $this->name($params, $q, $column);
return \sprintf('%s = EXCLUDED.%s', $name, $name);
},
$tokens['columns'],
);

return \sprintf(
'INSERT INTO %s (%s) VALUES %s ON CONFLICT (%s) DO UPDATE SET %s',
$this->name($params, $q, $tokens['table'], true),
$this->columns($params, $q, $tokens['columns']),
\implode(', ', $values),
$this->columns($params, $q, $tokens['conflicts']),
\implode(', ', $updates),
);
}

/**
* @psalm-return non-empty-string
*/
Expand Down
1 change: 1 addition & 0 deletions src/Driver/CompilerInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ interface CompilerInterface
public const DELETE_QUERY = 7;
public const JSON_EXPRESSION = 8;
public const SUBQUERY = 9;
public const UPSERT_QUERY = 10;
public const TOKEN_AND = '@AND';
public const TOKEN_OR = '@OR';
public const TOKEN_AND_NOT = '@AND NOT';
Expand Down
33 changes: 33 additions & 0 deletions src/Driver/MySQL/MySQLCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Cycle\Database\Driver\Compiler;
use Cycle\Database\Driver\MySQL\Injection\CompileJson;
use Cycle\Database\Driver\Quoter;
use Cycle\Database\Exception\CompilerException;
use Cycle\Database\Injection\FragmentInterface;
use Cycle\Database\Injection\Parameter;
use Cycle\Database\Query\QueryParameters;
Expand All @@ -36,6 +37,38 @@ protected function insertQuery(QueryParameters $params, Quoter $q, array $tokens
return parent::insertQuery($params, $q, $tokens);
}

/**
* @psalm-return non-empty-string
*/
protected function upsertQuery(QueryParameters $params, Quoter $q, array $tokens): string
{
if (\count($tokens['columns']) === 0) {
throw new CompilerException('Upsert query must define at least one column');
}

$values = [];

foreach ($tokens['values'] as $value) {
$values[] = $this->value($params, $q, $value);
}

$updates = \array_map(
function ($column) use ($params, $q) {
$name = $this->name($params, $q, $column);
return \sprintf('%s = VALUES(%s)', $name, $name);
},
$tokens['columns'],
);

return \sprintf(
'INSERT INTO %s (%s) VALUES %s ON DUPLICATE KEY UPDATE %s',
$this->name($params, $q, $tokens['table'], true),
$this->columns($params, $q, $tokens['columns']),
\implode(', ', $values),
\implode(', ', $updates),
);
}

/**
*
*
Expand Down
2 changes: 2 additions & 0 deletions src/Driver/MySQL/MySQLDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use Cycle\Database\Exception\StatementException;
use Cycle\Database\Query\InsertQuery;
use Cycle\Database\Query\QueryBuilder;
use Cycle\Database\Query\UpsertQuery;

/**
* Talks to mysql databases.
Expand All @@ -38,6 +39,7 @@ public static function create(DriverConfig $config): static
new QueryBuilder(
new MySQLSelectQuery(),
new InsertQuery(),
new UpsertQuery(),
new MySQLUpdateQuery(),
new MySQLDeleteQuery(),
),
Expand Down
23 changes: 23 additions & 0 deletions src/Driver/Postgres/PostgresCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,29 @@ protected function insertQuery(QueryParameters $params, Quoter $q, array $tokens
);
}

/**
* @psalm-return non-empty-string
*/
protected function upsertQuery(QueryParameters $params, Quoter $q, array $tokens): string
{
$query = parent::upsertQuery($params, $q, $tokens);

if (empty($tokens['return'])) {
return $query;
}

return \sprintf(
'%s RETURNING %s',
$query,
\implode(',', \array_map(
fn(string|FragmentInterface|null $return) => $return instanceof FragmentInterface
? $this->fragment($params, $q, $return)
: $this->quoteIdentifier($return),
$tokens['return'],
)),
);
}

protected function distinct(QueryParameters $params, Quoter $q, string|bool|array $distinct): string
{
if ($distinct === false) {
Expand Down
2 changes: 2 additions & 0 deletions src/Driver/Postgres/PostgresDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use Cycle\Database\Driver\Postgres\Query\PostgresInsertQuery;
use Cycle\Database\Driver\Postgres\Query\PostgresSelectQuery;
use Cycle\Database\Driver\Postgres\Query\PostgresUpdateQuery;
use Cycle\Database\Driver\Postgres\Query\PostgresUpsertQuery;
use Cycle\Database\Exception\DriverException;
use Cycle\Database\Exception\StatementException;
use Cycle\Database\Query\QueryBuilder;
Expand Down Expand Up @@ -65,6 +66,7 @@ public static function create(DriverConfig $config): static
new QueryBuilder(
new PostgresSelectQuery(),
new PostgresInsertQuery(),
new PostgresUpsertQuery(),
new PostgresUpdateQuery(),
new PostgresDeleteQuery(),
),
Expand Down
106 changes: 106 additions & 0 deletions src/Driver/Postgres/Query/PostgresUpsertQuery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

/**
* This file is part of Cycle ORM package.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace Cycle\Database\Driver\Postgres\Query;

use Cycle\Database\Driver\DriverInterface;
use Cycle\Database\Driver\Postgres\PostgresDriver;
use Cycle\Database\Exception\BuilderException;
use Cycle\Database\Exception\ReadonlyConnectionException;
use Cycle\Database\Injection\FragmentInterface;
use Cycle\Database\Query\ReturningInterface;
use Cycle\Database\Query\QueryInterface;
use Cycle\Database\Query\QueryParameters;
use Cycle\Database\Query\UpsertQuery;
use Cycle\Database\StatementInterface;

/**
* Postgres driver requires a slightly different way to handle last insert id.
*/
class PostgresUpsertQuery extends UpsertQuery implements ReturningInterface
{
/** @var PostgresDriver|null */
protected ?DriverInterface $driver = null;

/** @deprecated */
protected string|FragmentInterface|null $returning = null;

/** @var list<FragmentInterface|non-empty-string> */
protected array $returningColumns = [];

public function withDriver(DriverInterface $driver, ?string $prefix = null): QueryInterface
{
$driver instanceof PostgresDriver or throw new BuilderException(
'Postgres UpsertQuery can be used only with Postgres driver',
);

return parent::withDriver($driver, $prefix);
}

/**
* Set returning column. If not set, the driver will detect PK automatically.
*/
public function returning(string|FragmentInterface ...$columns): self
{
$columns === [] and throw new BuilderException('RETURNING clause should contain at least 1 column.');

$this->returning = \count($columns) === 1 ? \reset($columns) : null;

$this->returningColumns = \array_values($columns);

return $this;
}

public function run(): mixed
{
$params = new QueryParameters();
$queryString = $this->sqlStatement($params);

$this->driver->isReadonly() and throw ReadonlyConnectionException::onWriteStatementExecution();

$result = $this->driver->query($queryString, $params->getParameters());

try {
if ($this->returningColumns !== []) {
if (\count($this->returningColumns) === 1) {
return $result->fetchColumn();
}

return $result->fetch(StatementInterface::FETCH_ASSOC);
}

// Return PK if no RETURNING clause is set
if ($this->getPrimaryKey() !== null) {
return $result->fetchColumn();
}

return null;
} finally {
$result->close();
}
}

public function getTokens(): array
{
return parent::getTokens() + [
'return' => $this->returningColumns !== [] ? $this->returningColumns : (array) $this->getPrimaryKey(),
];
}

private function getPrimaryKey(): ?string
{
try {
return $this->driver?->getPrimaryKey($this->prefix, $this->table);
} catch (\Throwable) {
return null;
}
}
}
Loading
Loading