Skip to content
Open
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
143 changes: 143 additions & 0 deletions src/Driver/Compiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
use Cycle\Database\Injection\FragmentInterface;
use Cycle\Database\Injection\Parameter;
use Cycle\Database\Injection\ParameterInterface;
use Cycle\Database\Query\ConflictAction;
use Cycle\Database\Query\OnConflict;
use Cycle\Database\Query\QueryParameters;

abstract class Compiler implements CompilerInterface
Expand Down Expand Up @@ -109,6 +111,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 +174,121 @@ protected function insertQuery(QueryParameters $params, Quoter $q, array $tokens
);
}

/**
* Compile UPSERT (INSERT ... ON CONFLICT ...) for Postgres/SQLite-compatible dialects.
*
* @param array{
* table: non-empty-string,
* columns: list<non-empty-string>,
* values: list<mixed>,
* onConflict: OnConflict,
* } $tokens
*
* @return non-empty-string
*/
protected function upsertQuery(QueryParameters $params, Quoter $q, array $tokens): string
{
$onConflict = $this->requireOnConflict($tokens);

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

$target = $onConflict->getTarget();
if ($target === []) {
throw new CompilerException('Upsert query must define a conflict target.');
}

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

$head = \sprintf(
'INSERT INTO %s (%s) VALUES %s ON CONFLICT (%s)',
$this->name($params, $q, $tokens['table'], true),
$this->columns($params, $q, $tokens['columns']),
\implode(', ', $values),
$this->columns($params, $q, $target),
);

if ($onConflict->getAction() === ConflictAction::Nothing) {
return $head . ' DO NOTHING';
}

$updates = $this->upsertUpdateClause(
$params,
$q,
$tokens['columns'],
$target,
$onConflict->getUpdate(),
'EXCLUDED',
);

return $head . ' DO UPDATE SET ' . $updates;
}

/**
* @psalm-assert OnConflict $tokens['onConflict']
*/
protected function requireOnConflict(array $tokens): OnConflict
{
$onConflict = $tokens['onConflict'] ?? null;
$onConflict instanceof OnConflict or throw new CompilerException(
'Upsert query requires onConflict state to be configured.',
);

return $onConflict;
}

/**
* Build the column-assignment list for a DO UPDATE / ON DUPLICATE KEY UPDATE clause.
*
* @param list<string> $insertedColumns Columns from the INSERT column list.
* @param list<string> $target Conflict target columns (excluded from auto-update list).
* @param list<string>|array<string, mixed>|null $update Update spec (null = all, list = subset, map = expressions).
* @param string $sourceAlias Pseudo-table name for source row (EXCLUDED, new_row, source).
* @param string|null $targetAlias If non-null, qualifies each LHS column with `<targetAlias>.col` (used by MERGE).
*
* @psalm-return non-empty-string
*/
protected function upsertUpdateClause(
QueryParameters $params,
Quoter $q,
array $insertedColumns,
array $target,
null|array $update,
string $sourceAlias,
?string $targetAlias = null,
): string {
$source = $this->quoteIdentifier($sourceAlias);
$targetPrefix = $targetAlias !== null ? $this->quoteIdentifier($targetAlias) . '.' : '';

if ($update === null) {
$columns = \array_values(\array_diff($insertedColumns, $target));
$columns === [] and $columns = $insertedColumns;

return $this->upsertAssignmentsFromSource($params, $q, $columns, $source, $targetPrefix);
}

if (\array_is_list($update)) {
/** @var list<string> $update */
return $this->upsertAssignmentsFromSource($params, $q, $update, $source, $targetPrefix);
}

$parts = [];
foreach ($update as $column => $value) {
$parts[] = \sprintf(
'%s%s = %s',
$targetPrefix,
$this->name($params, $q, $column),
$this->value($params, $q, $value),
);
}

return \implode(', ', $parts);
}

/**
* @psalm-return non-empty-string
*/
Expand Down Expand Up @@ -611,6 +731,29 @@ protected function compileJsonOrderBy(string $path): string|FragmentInterface
return $path;
}

/**
* @param list<string> $columns
*
* @psalm-return non-empty-string
*/
private function upsertAssignmentsFromSource(
QueryParameters $params,
Quoter $q,
array $columns,
string $quotedSourceAlias,
string $targetPrefix,
): string {
$parts = \array_map(
function (string $column) use ($params, $q, $quotedSourceAlias, $targetPrefix) {
$name = $this->name($params, $q, $column);
return \sprintf('%s%s = %s.%s', $targetPrefix, $name, $quotedSourceAlias, $name);
},
$columns,
);

return \implode(', ', $parts);
}

private function arrayToInOperator(QueryParameters $params, Quoter $q, array $values, bool $in): string
{
$operator = $in ? 'IN' : 'NOT IN';
Expand Down
35 changes: 35 additions & 0 deletions src/Driver/CompilerCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use Cycle\Database\Injection\Parameter;
use Cycle\Database\Injection\ParameterInterface;
use Cycle\Database\Injection\SubQuery;
use Cycle\Database\Query\OnConflict;
use Cycle\Database\Query\QueryInterface;
use Cycle\Database\Query\QueryParameters;
use Cycle\Database\Query\SelectQuery;
Expand Down Expand Up @@ -77,6 +78,23 @@ public function compile(QueryParameters $params, string $prefix, FragmentInterfa
}
}

if ($fragment->getType() === self::UPSERT_QUERY) {
$tokens = $fragment->getTokens();

if (\count($tokens['values']) === 1) {
$queryHash = $prefix . $this->hashUpsertQuery($params, $tokens);
if (isset($this->cache[$queryHash])) {
return $this->cache[$queryHash];
}

return $this->cache[$queryHash] = $this->compiler->compile(
new QueryParameters(),
$prefix,
$fragment,
);
}
}

return $this->compiler->compile(
$params,
$prefix,
Expand Down Expand Up @@ -146,6 +164,23 @@ protected function hashInsertQuery(QueryParameters $params, array $tokens): stri
return $hash;
}

/**
* @psalm-return non-empty-string
*/
protected function hashUpsertQuery(QueryParameters $params, array $tokens): string
{
$hash = 'u_' . $this->hashInsertQuery($params, $tokens);

$onConflict = $tokens['onConflict'] ?? null;
if (!$onConflict instanceof OnConflict) {
return $hash;
}

// Driver-specific subclasses extend getCacheKey() to append their own fields
// and push any embedded fragment parameters via $params.
return $hash . '_oc' . $onConflict->getCacheKey($params);
}

/**
* @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
49 changes: 49 additions & 0 deletions src/Driver/MySQL/MySQLCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@
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\ConflictAction;
use Cycle\Database\Query\QueryParameters;

/**
Expand All @@ -36,6 +38,53 @@ protected function insertQuery(QueryParameters $params, Quoter $q, array $tokens
return parent::insertQuery($params, $q, $tokens);
}

/**
* Compile UPSERT as `INSERT ... AS <alias> ON DUPLICATE KEY UPDATE col = <alias>.col`.
* Requires MySQL 8.0.19+ (row-alias syntax). The alias defaults to
* {@see MySQLOnConflict::DEFAULT_ROW_ALIAS}; customize via
* {@see MySQLOnConflict::withRowAlias()} if it collides with a real column name.
*/
protected function upsertQuery(QueryParameters $params, Quoter $q, array $tokens): string
{
$onConflict = MySQLOnConflict::from($this->requireOnConflict($tokens));

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

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

$rowAlias = $onConflict->getRowAlias();

$head = \sprintf(
'INSERT INTO %s (%s) VALUES %s AS %s',
$this->name($params, $q, $tokens['table'], true),
$this->columns($params, $q, $tokens['columns']),
\implode(', ', $values),
$this->quoteIdentifier($rowAlias),
);

if ($onConflict->getAction() === ConflictAction::Nothing) {
// MySQL has no DO NOTHING β€” emulate with a no-op self-assignment.
$first = $this->name($params, $q, $tokens['columns'][0]);
return $head . ' ON DUPLICATE KEY UPDATE ' . \sprintf('%s = %s', $first, $first);
}

$updates = $this->upsertUpdateClause(
$params,
$q,
$tokens['columns'],
$onConflict->getTarget(),
$onConflict->getUpdate(),
$rowAlias,
);

return $head . ' ON DUPLICATE KEY UPDATE ' . $updates;
}

/**
*
*
Expand Down
92 changes: 92 additions & 0 deletions src/Driver/MySQL/MySQLOnConflict.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

declare(strict_types=1);

namespace Cycle\Database\Driver\MySQL;

use Cycle\Database\Exception\BuilderException;
use Cycle\Database\Query\ConflictAction;
use Cycle\Database\Query\OnConflict;
use Cycle\Database\Query\QueryParameters;

/**
* MySQL-specific conflict-resolution policy.
*
* Adds:
* - {@see self::withRowAlias()} β€” customize the row alias used in
* `INSERT ... AS <alias> ON DUPLICATE KEY UPDATE col = <alias>.col`.
* Default alias is {@see self::DEFAULT_ROW_ALIAS}. Customize only when it
* collides with a real column name your update expressions reference.
*
* Note: MySQL's `ON DUPLICATE KEY UPDATE` fires on ANY matching unique index,
* not on a specific target. The {@see self::target()} columns are accepted but
* ignored by the compiler at SQL generation time (kept for portability with
* Postgres/SQLite).
*/
final class MySQLOnConflict extends OnConflict
{
public const DEFAULT_ROW_ALIAS = 'new_row';

/**
* @param list<non-empty-string> $target
* @param list<non-empty-string>|array<non-empty-string, mixed>|null $update
* @param non-empty-string $rowAlias
*/
protected function __construct(
array $target,
ConflictAction $action,
null|array $update,
protected string $rowAlias = self::DEFAULT_ROW_ALIAS,
) {
parent::__construct($target, $action, $update);
}

public static function from(OnConflict $options): static
{
if ($options instanceof self) {
return $options;
}

if ($options::class !== OnConflict::class) {
throw new BuilderException(\sprintf(
'Cannot narrow %s to %s. Use the base OnConflict, or %s directly.',
$options::class,
self::class,
self::class,
));
}

return new self(
target: $options->getTarget(),
action: $options->getAction(),
update: $options->getUpdate(),
);
}

/**
* Set the row alias used in `INSERT ... AS <alias> ON DUPLICATE KEY UPDATE`.
*
* @param non-empty-string $alias Must be a valid MySQL identifier.
*/
public function withRowAlias(string $alias): self
{
$alias === '' and throw new BuilderException('Row alias must not be empty.');

$clone = clone $this;
$clone->rowAlias = $alias;
return $clone;
}

/**
* @return non-empty-string
*/
public function getRowAlias(): string
{
return $this->rowAlias;
}

public function getCacheKey(QueryParameters $params): string
{
return parent::getCacheKey($params) . 'RA' . $this->rowAlias;
}
}
Loading
Loading