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
21 changes: 18 additions & 3 deletions src/Driver/Compiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -205,11 +205,11 @@ protected function upsertQuery(QueryParameters $params, Quoter $q, array $tokens
}

$head = \sprintf(
'INSERT INTO %s (%s) VALUES %s ON CONFLICT (%s)',
'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),
$this->conflictTarget($params, $q, $onConflict),
);

if ($onConflict->getAction() === ConflictAction::Nothing) {
Expand All @@ -228,6 +228,18 @@ protected function upsertQuery(QueryParameters $params, Quoter $q, array $tokens
return $head . ' DO UPDATE SET ' . $updates;
}

/**
* Render the conflict target after `ON CONFLICT`: `(col, ...)`, plus β€” on drivers
* that inherit the Postgres inference clause β€” an index predicate
* `(col, ...) WHERE <predicate>`. Driver compilers override to extend it.
*
* @psalm-return non-empty-string
*/
protected function conflictTarget(QueryParameters $params, Quoter $q, OnConflict $onConflict): string
{
return '(' . $this->columns($params, $q, $onConflict->getTarget()) . ')';
}

/**
* @psalm-assert OnConflict $tokens['onConflict']
*/
Expand Down Expand Up @@ -261,7 +273,10 @@ protected function upsertUpdateClause(
string $sourceAlias,
?string $targetAlias = null,
): string {
$source = $this->quoteIdentifier($sourceAlias);
// EXCLUDED is a Postgres/SQLite keyword pseudo-table, not a real alias: it must
// stay unquoted (Postgres rejects the quoted "EXCLUDED" with "missing FROM-clause
// entry"). MySQL/SQLServer pass real aliases (new_row/source) that are quoted.
$source = $sourceAlias === 'EXCLUDED' ? 'EXCLUDED' : $this->quoteIdentifier($sourceAlias);
$targetPrefix = $targetAlias !== null ? $this->quoteIdentifier($targetAlias) . '.' : '';

if ($update === null) {
Expand Down
8 changes: 8 additions & 0 deletions src/Driver/CompilerCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use Cycle\Database\Injection\ParameterInterface;
use Cycle\Database\Injection\SubQuery;
use Cycle\Database\Query\OnConflict;
use Cycle\Database\Query\OnConflictWithPredicate;
use Cycle\Database\Query\QueryInterface;
use Cycle\Database\Query\QueryParameters;
use Cycle\Database\Query\SelectQuery;
Expand Down Expand Up @@ -176,6 +177,13 @@ protected function hashUpsertQuery(QueryParameters $params, array $tokens): stri
return $hash;
}

// The index-inference predicate is rendered between the conflict target and
// DO UPDATE, so its parameters must be pushed before the update parameters.
// Reuse the regular where-hasher to keep that order identical to Compiler::where().
if ($onConflict instanceof OnConflictWithPredicate && ($predicate = $onConflict->getIndexPredicate()) !== []) {
$hash .= '_w' . $this->hashWhere($params, $predicate);
}

// Driver-specific subclasses extend getCacheKey() to append their own fields
// and push any embedded fragment parameters via $params.
return $hash . '_oc' . $onConflict->getCacheKey($params);
Expand Down
17 changes: 9 additions & 8 deletions src/Driver/MySQL/MySQLCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,27 +57,28 @@ protected function upsertQuery(QueryParameters $params, Quoter $q, array $tokens
$values[] = $this->value($params, $q, $value);
}

$rowAlias = $onConflict->getRowAlias();

$head = \sprintf(
'INSERT INTO %s (%s) VALUES %s AS %s',
$base = \sprintf(
'INSERT INTO %s (%s) VALUES %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 on the
// conflict-target column (or the first inserted column as a fallback).
// Using the target column is the conventional idiom and is more predictable
// for schemas where the first inserted column is unrelated to the conflict.
// No row alias is emitted here: without `AS <alias>` the bare `col = col`
// is unambiguous; with the alias in scope MySQL rejects it as ambiguous.
$target = $onConflict->getTarget();
$noopColumn = $target[0] ?? $tokens['columns'][0];
$name = $this->name($params, $q, $noopColumn);
return $head . ' ON DUPLICATE KEY UPDATE ' . \sprintf('%s = %s', $name, $name);
return $base . ' ON DUPLICATE KEY UPDATE ' . \sprintf('%s = %s', $name, $name);
}

// DO UPDATE references the inserted row via `col = <alias>.col`, so the alias is required.
$rowAlias = $onConflict->getRowAlias();
$head = $base . ' AS ' . $this->quoteIdentifier($rowAlias);

$updates = $this->upsertUpdateClause(
$params,
$q,
Expand Down
16 changes: 15 additions & 1 deletion src/Driver/Postgres/PostgresCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,14 @@ protected function compileJsonOrderBy(string $path): FragmentInterface
*/
private function postgresConflictTarget(QueryParameters $params, Quoter $q, PostgresOnConflict $onConflict): string
{
$predicate = $onConflict->getIndexPredicate();

$constraint = $onConflict->getConstraint();
if ($constraint !== null) {
$predicate === [] or throw new CompilerException(
'ON CONFLICT ON CONSTRAINT cannot be combined with an index-inference predicate (targetWhere()).',
);

return 'ON CONSTRAINT ' . $this->quoteIdentifier($constraint);
}

Expand All @@ -138,7 +144,15 @@ private function postgresConflictTarget(QueryParameters $params, Quoter $q, Post
'Upsert query must define a conflict target (columns or constraint).',
);

return \sprintf('(%s)', $this->columns($params, $q, $target));
$result = \sprintf('(%s)', $this->columns($params, $q, $target));

if ($predicate === []) {
return $result;
}

$where = \trim($this->where($params, $q, $predicate));

return $where === '' ? $result : $result . ' WHERE ' . $where;
}

/**
Expand Down
17 changes: 13 additions & 4 deletions src/Driver/Postgres/PostgresOnConflict.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,25 @@

namespace Cycle\Database\Driver\Postgres;

use Cycle\Database\Driver\SQLite\SQLiteOnConflict;
use Cycle\Database\Exception\BuilderException;
use Cycle\Database\Query\ConflictAction;
use Cycle\Database\Query\OnConflict;
use Cycle\Database\Query\OnConflictWithPredicate;
use Cycle\Database\Query\QueryParameters;

/**
* Postgres-specific conflict-resolution policy.
*
* Adds:
* - {@see self::onConstraint()} β€” `ON CONFLICT ON CONSTRAINT <name>` target.
* - {@see OnConflictWithPredicate::targetWhere()} β€” `ON CONFLICT (cols) WHERE <predicate>`
* index-inference for partial unique indexes (shared with {@see SQLiteOnConflict}).
*
* Use {@see self::from()} inside the Postgres compiler to narrow a base
* {@see OnConflict} instance into this type.
*/
final class PostgresOnConflict extends OnConflict
final class PostgresOnConflict extends OnConflictWithPredicate
{
/**
* @param list<non-empty-string> $target
Expand All @@ -30,8 +34,9 @@ protected function __construct(
ConflictAction $action,
null|array $update,
protected ?string $constraint = null,
array $indexPredicate = [],
) {
parent::__construct($target, $action, $update);
parent::__construct($target, $action, $update, $indexPredicate);
}

/**
Expand All @@ -57,19 +62,23 @@ public static function from(OnConflict $options): static
return $options;
}

if ($options::class !== OnConflict::class) {
// Base OnConflict and the feature-compatible SQLite sibling narrow cleanly;
// SQLite never carries a constraint, so nothing is lost. MySQL/SQLServer reject.
if (!$options instanceof OnConflictWithPredicate && $options::class !== OnConflict::class) {
throw new BuilderException(\sprintf(
'Cannot narrow %s to %s. Use the base OnConflict, or %s directly.',
'Cannot narrow %s to %s. Use the base OnConflict, %s, or %s directly.',
$options::class,
self::class,
self::class,
SQLiteOnConflict::class,
));
}

return new self(
target: $options->getTarget(),
action: $options->getAction(),
update: $options->getUpdate(),
indexPredicate: $options instanceof OnConflictWithPredicate ? $options->getIndexPredicate() : [],
);
}

Expand Down
23 changes: 23 additions & 0 deletions src/Driver/SQLite/SQLiteCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use Cycle\Database\Injection\FragmentInterface;
use Cycle\Database\Injection\Parameter;
use Cycle\Database\Injection\ParameterInterface;
use Cycle\Database\Query\OnConflict;
use Cycle\Database\Query\QueryParameters;

class SQLiteCompiler extends Compiler implements CachingCompilerInterface
Expand Down Expand Up @@ -125,4 +126,26 @@ protected function compileJsonOrderBy(string $path): FragmentInterface
{
return new CompileJson($path);
}

/**
* SQLite inherits the Postgres `ON CONFLICT (cols) WHERE <predicate>` inference
* clause, so it supports an index predicate on the conflict target.
*
* @psalm-return non-empty-string
*/
protected function conflictTarget(QueryParameters $params, Quoter $q, OnConflict $onConflict): string
{
$onConflict = SQLiteOnConflict::from($onConflict);

$target = '(' . $this->columns($params, $q, $onConflict->getTarget()) . ')';

$predicate = $onConflict->getIndexPredicate();
if ($predicate === []) {
return $target;
}

$where = \trim($this->where($params, $q, $predicate));

return $where === '' ? $target : $target . ' WHERE ' . $where;
}
}
52 changes: 52 additions & 0 deletions src/Driver/SQLite/SQLiteOnConflict.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);

namespace Cycle\Database\Driver\SQLite;

use Cycle\Database\Driver\Postgres\PostgresOnConflict;
use Cycle\Database\Exception\BuilderException;
use Cycle\Database\Query\OnConflict;
use Cycle\Database\Query\OnConflictWithPredicate;

/**
* SQLite-specific conflict-resolution policy.
*
* SQLite's upsert syntax is modelled on Postgres, so it supports the
* `ON CONFLICT (cols) WHERE <predicate>` index-inference form via
* {@see OnConflictWithPredicate::targetWhere()}. It does NOT support
* `ON CONFLICT ON CONSTRAINT`, so narrowing a {@see PostgresOnConflict} that
* carries a constraint into this type is rejected.
*/
final class SQLiteOnConflict extends OnConflictWithPredicate
{
public static function from(OnConflict $options): static
{
if ($options instanceof self) {
return $options;
}

if ($options instanceof PostgresOnConflict && $options->getConstraint() !== null) {
throw new BuilderException(
'SQLite does not support ON CONFLICT ON CONSTRAINT; use a column target instead.',
);
}

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

return new self(
target: $options->getTarget(),
action: $options->getAction(),
update: $options->getUpdate(),
indexPredicate: $options instanceof OnConflictWithPredicate ? $options->getIndexPredicate() : [],
);
}
}
29 changes: 26 additions & 3 deletions src/Query/OnConflict.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
*
* Base class carries only cross-driver features (target columns, action,
* column-level update spec). Driver-specific extensions live in subclasses:
* - {@see \Cycle\Database\Driver\Postgres\PostgresOnConflict} β€” onConstraint(), where().
* - {@see OnConflictWithPredicate} β€” targetWhere() (index-inference predicate),
* shared base for the two drivers that inherit the Postgres inference clause:
* - {@see \Cycle\Database\Driver\Postgres\PostgresOnConflict} β€” also onConstraint().
* - {@see \Cycle\Database\Driver\SQLite\SQLiteOnConflict}.
* - {@see \Cycle\Database\Driver\MySQL\MySQLOnConflict} β€” withRowAlias().
* - {@see \Cycle\Database\Driver\SQLServer\SQLServerOnConflict} β€” where() (MERGE).
*/
Expand Down Expand Up @@ -60,8 +63,12 @@ public static function target(string|array ...$columns): static
* fields are taken from the input if it is already of this type; otherwise
* default values are used for those fields.
*
* Subclasses MUST reject other driver-specific subclasses (e.g., passing
* PostgresOnConflict to MySQLOnConflict::from() must throw).
* Subclasses MUST reject driver-specific subclasses they cannot represent
* (e.g., passing PostgresOnConflict to MySQLOnConflict::from() must throw).
* Feature-compatible siblings, however, convert without error: PostgresOnConflict
* and SQLiteOnConflict both understand the index-inference predicate and narrow
* into each other (the one exception is a Postgres constraint target, which SQLite
* cannot express).
*/
public static function from(self $options): static
{
Expand All @@ -75,6 +82,22 @@ public static function from(self $options): static
* null β€” overwrite every inserted column from the source row.
* list of strings β€” overwrite only the listed columns from the source row.
* column => value map β€” custom expressions/values per column.
*
* Referencing the inserted ("excluded") row inside a custom expression: use a raw
* {@see \Cycle\Database\Injection\Fragment}, NOT an {@see \Cycle\Database\Injection\Expression}.
* Expression quotes every identifier, and Postgres rejects the quoted "EXCLUDED"
* pseudo-table (`missing FROM-clause entry for table "EXCLUDED"`); a Fragment is
* emitted verbatim. The pseudo-table name is driver-specific β€” EXCLUDED on
* Postgres/SQLite, the row alias (default `new_row`) on MySQL β€” so such expressions
* are inherently non-portable:
*
* // Postgres / SQLite
* ->doUpdate(['hits' => new Fragment('counters.hits + EXCLUDED.hits')])
* // MySQL
* ->doUpdate(['hits' => new Fragment('counters.hits + new_row.hits')])
*
* Use {@see \Cycle\Database\Injection\Expression} only for expressions over real
* table columns (which should be quoted), not for the excluded-row reference.
*/
public function doUpdate(?array $columnsOrMap = null): static
{
Expand Down
34 changes: 34 additions & 0 deletions src/Query/OnConflictWhere.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace Cycle\Database\Query;

use Cycle\Database\Query\Traits\TokenTrait;
use Cycle\Database\Query\Traits\WhereTrait;

/**
* Minimal, driver-independent WHERE builder used to express the index-inference
* predicate of {@see OnConflictWithPredicate::targetWhere()} with the same fluent
* DSL as SelectQuery/UpdateQuery/DeleteQuery: operators, automatic null handling
* (`!= null` β†’ `IS NOT NULL`), AND/OR groups, `BETWEEN`, `IN`, etc.
*
* It reuses {@see WhereTrait} (the public where/andWhere/orWhere API) on top of
* {@see TokenTrait} (the arg-to-token parser) β€” exactly the composition used by the
* query classes β€” and produces a plain token array consumable by the compiler's
* existing where-token renderer. It carries no driver or connection state, so it is
* safe to build inside an immutable {@see OnConflict} value object.
*/
final class OnConflictWhere
{
use TokenTrait;
use WhereTrait;

/**
* Where tokens consumable by {@see \Cycle\Database\Driver\Compiler::where()}.
*/
public function getWhereTokens(): array
{
return $this->whereTokens;
}
}
Loading
Loading