From 7bf804a2b54cf4b66d3b0cd7a235c345722e7d21 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 13 May 2026 23:37:31 +0400 Subject: [PATCH] feat: Add `InsertQuery::onConflict()` method --- src/Driver/Compiler.php | 143 ++++++++++++++ src/Driver/CompilerCache.php | 35 ++++ src/Driver/CompilerInterface.php | 1 + src/Driver/MySQL/MySQLCompiler.php | 49 +++++ src/Driver/MySQL/MySQLOnConflict.php | 92 +++++++++ src/Driver/Postgres/PostgresCompiler.php | 90 +++++++-- src/Driver/Postgres/PostgresOnConflict.php | 89 +++++++++ .../Postgres/Query/PostgresInsertQuery.php | 1 + src/Driver/SQLServer/SQLServerCompiler.php | 98 +++++++++ src/Driver/SQLServer/SQLServerOnConflict.php | 41 ++++ src/Query/ActiveQuery.php | 2 + src/Query/ConflictAction.php | 11 ++ src/Query/InsertQuery.php | 54 ++++- src/Query/OnConflict.php | 187 ++++++++++++++++++ .../Driver/Common/Query/UpsertQueryTest.php | 63 ++++++ .../Driver/MySQL/Query/UpsertQueryTest.php | 97 +++++++++ .../Driver/Postgres/Query/UpsertQueryTest.php | 85 ++++++++ .../SQLServer/Query/UpsertQueryTest.php | 96 +++++++++ .../Driver/SQLite/Query/UpsertQueryTest.php | 86 ++++++++ .../Unit/Driver/MySQL/MySQLOnConflictTest.php | 73 +++++++ .../Postgres/PostgresOnConflictTest.php | 83 ++++++++ tests/Database/Unit/Query/OnConflictTest.php | 131 ++++++++++++ .../Unit/Query/Tokens/InsertQueryTest.php | 1 + 23 files changed, 1593 insertions(+), 15 deletions(-) create mode 100644 src/Driver/MySQL/MySQLOnConflict.php create mode 100644 src/Driver/Postgres/PostgresOnConflict.php create mode 100644 src/Driver/SQLServer/SQLServerOnConflict.php create mode 100644 src/Query/ConflictAction.php create mode 100644 src/Query/OnConflict.php create mode 100644 tests/Database/Functional/Driver/Common/Query/UpsertQueryTest.php create mode 100644 tests/Database/Functional/Driver/MySQL/Query/UpsertQueryTest.php create mode 100644 tests/Database/Functional/Driver/Postgres/Query/UpsertQueryTest.php create mode 100644 tests/Database/Functional/Driver/SQLServer/Query/UpsertQueryTest.php create mode 100644 tests/Database/Functional/Driver/SQLite/Query/UpsertQueryTest.php create mode 100644 tests/Database/Unit/Driver/MySQL/MySQLOnConflictTest.php create mode 100644 tests/Database/Unit/Driver/Postgres/PostgresOnConflictTest.php create mode 100644 tests/Database/Unit/Query/OnConflictTest.php diff --git a/src/Driver/Compiler.php b/src/Driver/Compiler.php index b42b837e..d346911e 100644 --- a/src/Driver/Compiler.php +++ b/src/Driver/Compiler.php @@ -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 @@ -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) { @@ -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, + * values: list, + * 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 $insertedColumns Columns from the INSERT column list. + * @param list $target Conflict target columns (excluded from auto-update list). + * @param list|array|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 `.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 $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 */ @@ -611,6 +731,29 @@ protected function compileJsonOrderBy(string $path): string|FragmentInterface return $path; } + /** + * @param list $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'; diff --git a/src/Driver/CompilerCache.php b/src/Driver/CompilerCache.php index 7aecba87..7a610179 100644 --- a/src/Driver/CompilerCache.php +++ b/src/Driver/CompilerCache.php @@ -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; @@ -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, @@ -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 */ diff --git a/src/Driver/CompilerInterface.php b/src/Driver/CompilerInterface.php index 281c124b..fceabd0d 100644 --- a/src/Driver/CompilerInterface.php +++ b/src/Driver/CompilerInterface.php @@ -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'; diff --git a/src/Driver/MySQL/MySQLCompiler.php b/src/Driver/MySQL/MySQLCompiler.php index 4789d9f4..e2a1a688 100644 --- a/src/Driver/MySQL/MySQLCompiler.php +++ b/src/Driver/MySQL/MySQLCompiler.php @@ -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; /** @@ -36,6 +38,53 @@ protected function insertQuery(QueryParameters $params, Quoter $q, array $tokens return parent::insertQuery($params, $q, $tokens); } + /** + * Compile UPSERT as `INSERT ... AS ON DUPLICATE KEY UPDATE col = .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; + } + /** * * diff --git a/src/Driver/MySQL/MySQLOnConflict.php b/src/Driver/MySQL/MySQLOnConflict.php new file mode 100644 index 00000000..522b1b3f --- /dev/null +++ b/src/Driver/MySQL/MySQLOnConflict.php @@ -0,0 +1,92 @@ + ON DUPLICATE KEY UPDATE col = .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 $target + * @param list|array|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 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; + } +} diff --git a/src/Driver/Postgres/PostgresCompiler.php b/src/Driver/Postgres/PostgresCompiler.php index bbebbaac..f8f63052 100644 --- a/src/Driver/Postgres/PostgresCompiler.php +++ b/src/Driver/Postgres/PostgresCompiler.php @@ -15,8 +15,10 @@ use Cycle\Database\Driver\Compiler; use Cycle\Database\Driver\Postgres\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; /** @@ -36,20 +38,47 @@ protected function insertQuery(QueryParameters $params, Quoter $q, array $tokens { $result = parent::insertQuery($params, $q, $tokens); - if (empty($tokens['return'])) { - return $result; + return $this->appendReturning($params, $q, $result, $tokens); + } + + /** + * @psalm-return non-empty-string + */ + protected function upsertQuery(QueryParameters $params, Quoter $q, array $tokens): string + { + $onConflict = PostgresOnConflict::from($this->requireOnConflict($tokens)); + + if ($tokens['columns'] === []) { + throw new CompilerException('Upsert query must define at least one column.'); } - return \sprintf( - '%s RETURNING %s', - $result, - \implode(',', \array_map( - fn(string|FragmentInterface|null $return) => $return instanceof FragmentInterface - ? $this->fragment($params, $q, $return) - : $this->quoteIdentifier($return), - $tokens['return'], - )), + $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->postgresConflictTarget($params, $q, $onConflict), ); + + if ($onConflict->getAction() === ConflictAction::Nothing) { + return $this->appendReturning($params, $q, $head . ' DO NOTHING', $tokens); + } + + $updates = $this->upsertUpdateClause( + $params, + $q, + $tokens['columns'], + $onConflict->getTarget(), + $onConflict->getUpdate(), + 'EXCLUDED', + ); + + return $this->appendReturning($params, $q, $head . ' DO UPDATE SET ' . $updates, $tokens); } protected function distinct(QueryParameters $params, Quoter $q, string|bool|array $distinct): string @@ -93,4 +122,43 @@ protected function compileJsonOrderBy(string $path): FragmentInterface { return new CompileJson($path); } + + /** + * @psalm-return non-empty-string + */ + private function postgresConflictTarget(QueryParameters $params, Quoter $q, PostgresOnConflict $onConflict): string + { + $constraint = $onConflict->getConstraint(); + if ($constraint !== null) { + return 'ON CONSTRAINT ' . $this->quoteIdentifier($constraint); + } + + $target = $onConflict->getTarget(); + $target === [] and throw new CompilerException( + 'Upsert query must define a conflict target (columns or constraint).', + ); + + return \sprintf('(%s)', $this->columns($params, $q, $target)); + } + + /** + * @psalm-return non-empty-string + */ + private function appendReturning(QueryParameters $params, Quoter $q, string $query, array $tokens): string + { + 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'], + )), + ); + } } diff --git a/src/Driver/Postgres/PostgresOnConflict.php b/src/Driver/Postgres/PostgresOnConflict.php new file mode 100644 index 00000000..d8fd68a6 --- /dev/null +++ b/src/Driver/Postgres/PostgresOnConflict.php @@ -0,0 +1,89 @@ +` target. + * + * Use {@see self::from()} inside the Postgres compiler to narrow a base + * {@see OnConflict} instance into this type. + */ +final class PostgresOnConflict extends OnConflict +{ + /** + * @param list $target + * @param non-empty-string|null $constraint + * @param list|array|null $update + */ + protected function __construct( + array $target, + ConflictAction $action, + null|array $update, + protected ?string $constraint = null, + ) { + parent::__construct($target, $action, $update); + } + + /** + * Conflict target by unique-constraint name. Postgres-only feature. + * + * @param non-empty-string $name + */ + public static function onConstraint(string $name): self + { + $name === '' and throw new BuilderException('Constraint name must not be empty.'); + + return new self( + target: [], + action: ConflictAction::Update, + update: null, + constraint: $name, + ); + } + + 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(), + ); + } + + public function getConstraint(): ?string + { + return $this->constraint; + } + + public function getCacheKey(QueryParameters $params): string + { + $key = parent::getCacheKey($params); + if ($this->constraint !== null) { + $key .= 'C' . $this->constraint; + } + return $key; + } +} diff --git a/src/Driver/Postgres/Query/PostgresInsertQuery.php b/src/Driver/Postgres/Query/PostgresInsertQuery.php index d72ac7a6..5d4e06b6 100644 --- a/src/Driver/Postgres/Query/PostgresInsertQuery.php +++ b/src/Driver/Postgres/Query/PostgresInsertQuery.php @@ -95,6 +95,7 @@ public function getTokens(): array 'return' => $this->returningColumns !== [] ? $this->returningColumns : (array) $this->getPrimaryKey(), 'columns' => $this->columns, 'values' => $this->values, + 'onConflict' => $this->onConflict, ]; } diff --git a/src/Driver/SQLServer/SQLServerCompiler.php b/src/Driver/SQLServer/SQLServerCompiler.php index c7819b63..f85d5317 100644 --- a/src/Driver/SQLServer/SQLServerCompiler.php +++ b/src/Driver/SQLServer/SQLServerCompiler.php @@ -14,9 +14,11 @@ use Cycle\Database\Driver\Compiler; use Cycle\Database\Driver\Quoter; use Cycle\Database\Driver\SQLServer\Injection\CompileJson; +use Cycle\Database\Exception\CompilerException; use Cycle\Database\Injection\Fragment; use Cycle\Database\Injection\FragmentInterface; use Cycle\Database\Injection\Parameter; +use Cycle\Database\Query\ConflictAction; use Cycle\Database\Query\QueryParameters; /** @@ -30,6 +32,18 @@ class SQLServerCompiler extends Compiler */ public const ROW_NUMBER = '_ROW_NUMBER_'; + /** + * Aliases used in the generated MERGE statement. + * + * Note: SQL Server's MERGE has documented concurrency caveats even with HOLDLOCK + * (Aaron Bertrand: "Use Caution with SQL Server's MERGE Statement"). For high-throughput + * upsert workloads, a `BEGIN TRAN; UPDATE; IF @@ROWCOUNT = 0 INSERT; COMMIT` pattern + * may be more robust. We use MERGE here for atomicity in a single statement. + */ + private const MERGE_TARGET_ALIAS = 'target'; + + private const MERGE_SOURCE_ALIAS = 'source'; + /** * @psalm-return non-empty-string */ @@ -68,6 +82,90 @@ protected function insertQuery(QueryParameters $params, Quoter $q, array $tokens ); } + /** + * Compile UPSERT as a MERGE statement. + * + * @psalm-return non-empty-string + */ + protected function upsertQuery(QueryParameters $params, Quoter $q, array $tokens): string + { + $onConflict = SQLServerOnConflict::from($this->requireOnConflict($tokens)); + + if ($tokens['columns'] === []) { + throw new CompilerException('Upsert query must define at least one column.'); + } + + $conflictColumns = $onConflict->getTarget(); + if ($conflictColumns === []) { + throw new CompilerException('Upsert query must define a conflict target.'); + } + + $values = []; + foreach ($tokens['values'] as $value) { + $values[] = $this->value($params, $q, $value); + } + + $target = $this->quoteIdentifier(self::MERGE_TARGET_ALIAS); + $source = $this->quoteIdentifier(self::MERGE_SOURCE_ALIAS); + + $matchOn = \implode(' AND ', \array_map( + function (string $column) use ($params, $q, $target, $source) { + $name = $this->name($params, $q, $column); + return \sprintf('%s.%s = %s.%s', $target, $name, $source, $name); + }, + $conflictColumns, + )); + + $insertColumns = $this->columns($params, $q, $tokens['columns']); + $insertValues = \implode(', ', \array_map( + fn(string $column) => $source . '.' . $this->name($params, $q, $column), + $tokens['columns'], + )); + + $merge = \sprintf( + 'MERGE INTO %s WITH (HOLDLOCK) AS %s USING (VALUES %s) AS %s (%s) ON %s', + $this->name($params, $q, $tokens['table'], true), + $target, + \implode(', ', $values), + $source, + $insertColumns, + $matchOn, + ); + + if ($onConflict->getAction() !== ConflictAction::Nothing) { + $updates = $this->upsertUpdateClause( + $params, + $q, + $tokens['columns'], + $conflictColumns, + $onConflict->getUpdate(), + self::MERGE_SOURCE_ALIAS, + self::MERGE_TARGET_ALIAS, + ); + + $merge .= ' WHEN MATCHED THEN UPDATE SET ' . $updates; + } + + $merge .= \sprintf( + ' WHEN NOT MATCHED THEN INSERT (%s) VALUES (%s)', + $insertColumns, + $insertValues, + ); + + if (empty($tokens['return'])) { + return $merge . ';'; + } + + $output = \implode(', ', \array_map( + fn(string|FragmentInterface|null $return) => $return instanceof FragmentInterface + ? $this->fragment($params, $q, $return) + : 'INSERTED.' . $this->quoteIdentifier($return), + $tokens['return'], + )); + + return \sprintf('%s OUTPUT %s;', $merge, $output); + } + /** * {@inheritDoc} * diff --git a/src/Driver/SQLServer/SQLServerOnConflict.php b/src/Driver/SQLServer/SQLServerOnConflict.php new file mode 100644 index 00000000..c5aa24cd --- /dev/null +++ b/src/Driver/SQLServer/SQLServerOnConflict.php @@ -0,0 +1,41 @@ +`) + * and as a symmetric counterpart to {@see \Cycle\Database\Driver\Postgres\PostgresOnConflict} + * and {@see \Cycle\Database\Driver\MySQL\MySQLOnConflict}. + */ +final class SQLServerOnConflict extends OnConflict +{ + 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(), + ); + } +} diff --git a/src/Query/ActiveQuery.php b/src/Query/ActiveQuery.php index 581acdf0..184de4c1 100644 --- a/src/Query/ActiveQuery.php +++ b/src/Query/ActiveQuery.php @@ -99,6 +99,8 @@ public function __debugInfo(): array /** * Helper methods used to correctly fetch and split identifiers provided by function * parameters. Example: fI(['name, email']) => 'name', 'email' + * + * @return list */ protected function fetchIdentifiers(array $identifiers): array { diff --git a/src/Query/ConflictAction.php b/src/Query/ConflictAction.php new file mode 100644 index 00000000..7b77f792 --- /dev/null +++ b/src/Query/ConflictAction.php @@ -0,0 +1,11 @@ + */ protected array $columns = []; + protected array $values = []; + protected ?OnConflict $onConflict = null; public function __construct(?string $table = null) { @@ -110,10 +115,40 @@ public function values(mixed $rowsets): self return $this; } + /** + * Configure conflict resolution. The query becomes an UPSERT. + * + * Accepts either a fully-built {@see OnConflict} value object, or a shorthand: + * - non-empty-string - conflict target is a single column, action is DO UPDATE over every inserted column. + * - array - conflict target is the given list of columns, action is DO UPDATE. + * + * Examples: + * $insert->onConflict('email'); + * $insert->onConflict(['tenant_id', 'email']); + * $insert->onConflict(OnConflict::target('email')->doUpdate(['name'])); + * $insert->onConflict(OnConflict::target('email')->doNothing()); + */ + public function onConflict(OnConflict|string|array $conflict): self + { + if ($conflict instanceof OnConflict) { + $this->onConflict = $conflict; + return $this; + } + + $columns = \is_array($conflict) ? \array_values($conflict) : [$conflict]; + $this->onConflict = OnConflict::target($columns)->doUpdate(); + + return $this; + } + /** * Run the query and return last insert id. * Returns an assoc array of values if multiple columns were specified as returning columns. * + * For upsert queries with `DO NOTHING` resolving to the existing row, drivers without + * RETURNING support may return 0/null instead of the existing row's id — use a driver + * that supports RETURNING for reliable results. + * * @return array|int|non-empty-string|null */ public function run(): mixed @@ -136,15 +171,26 @@ public function run(): mixed public function getType(): int { - return CompilerInterface::INSERT_QUERY; + return $this->onConflict !== null + ? CompilerInterface::UPSERT_QUERY + : CompilerInterface::INSERT_QUERY; } + /** + * @return array{ + * 'table': non-empty-string, + * 'columns': list, + * 'values': array, + * 'onConflict': OnConflict|null + * } + */ public function getTokens(): array { return [ - 'table' => $this->table, - 'columns' => $this->columns, - 'values' => $this->values, + 'table' => $this->table, + 'columns' => $this->columns, + 'values' => $this->values, + 'onConflict' => $this->onConflict, ]; } } diff --git a/src/Query/OnConflict.php b/src/Query/OnConflict.php new file mode 100644 index 00000000..93920c30 --- /dev/null +++ b/src/Query/OnConflict.php @@ -0,0 +1,187 @@ + $target Conflict target columns. + * @param ConflictAction $action Resolution action. + * @param list|array|null $update + * null — overwrite every inserted column from the source row (except target columns). + * list — overwrite only the listed columns from the source row. + * map — column => value (scalar/Parameter/FragmentInterface) for custom expressions. + */ + protected function __construct( + protected array $target, + protected ConflictAction $action, + protected null|array $update, + ) {} + + /** + * Conflict target by column name(s). + * + * Accepts column names as variadic args, a single comma-separated string, or an array. + */ + public static function target(string|array ...$columns): static + { + $resolved = self::flatten($columns); + $resolved === [] and throw new BuilderException( + 'Conflict target must contain at least one column.', + ); + + return new static( + target: $resolved, + action: ConflictAction::Update, + update: null, + ); + } + + /** + * Narrow a base or matching subclass instance to this type. Subclass-specific + * 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). + */ + public static function from(self $options): static + { + return $options; + } + + /** + * Set action to DO UPDATE. + * + * @param list|array|null $columnsOrMap + * 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. + */ + public function doUpdate(?array $columnsOrMap = null): static + { + $clone = clone $this; + $clone->action = ConflictAction::Update; + $clone->update = $columnsOrMap; + return $clone; + } + + /** + * Set action to DO NOTHING. + */ + public function doNothing(): static + { + $clone = clone $this; + $clone->action = ConflictAction::Nothing; + $clone->update = null; + return $clone; + } + + /** + * @return list + */ + public function getTarget(): array + { + return $this->target; + } + + public function getAction(): ConflictAction + { + return $this->action; + } + + /** + * @return list|array|null + */ + public function getUpdate(): ?array + { + return $this->update; + } + + /** + * Stable signature of the conflict-resolution policy for {@see \Cycle\Database\Driver\CompilerCache}. + * + * Subclasses override to append their own fields, and push any embedded fragment + * parameters via the provided {@see QueryParameters} bag. + * + * @psalm-return non-empty-string + */ + public function getCacheKey(QueryParameters $params): string + { + $key = 'T' . \implode('_', $this->target) . 'A' . $this->action->name; + + if ($this->update === null) { + return $key . 'UN'; + } + + if (\array_is_list($this->update)) { + return $key . 'UL' . \implode('_', $this->update); + } + + $key .= 'UM'; + foreach ($this->update as $column => $value) { + $key .= $column . '='; + + if ($value instanceof FragmentInterface) { + foreach ($value->getTokens()['parameters'] as $fragmentParam) { + $params->push($fragmentParam); + } + $key .= $value; + continue; + } + + if (!$value instanceof ParameterInterface) { + $value = new \Cycle\Database\Injection\Parameter($value); + } + + $params->push($value); + $key .= 'P?'; + } + + return $key; + } + + /** + * @param array> $input + * @return list + */ + protected static function flatten(array $input): array + { + $result = []; + foreach ($input as $item) { + if (\is_array($item)) { + foreach ($item as $name) { + $result[] = (string) $name; + } + continue; + } + foreach (\explode(',', $item) as $name) { + $name = \trim($name); + if ($name !== '') { + $result[] = $name; + } + } + } + + /** @var list $result */ + return $result; + } +} diff --git a/tests/Database/Functional/Driver/Common/Query/UpsertQueryTest.php b/tests/Database/Functional/Driver/Common/Query/UpsertQueryTest.php new file mode 100644 index 00000000..9e930d72 --- /dev/null +++ b/tests/Database/Functional/Driver/Common/Query/UpsertQueryTest.php @@ -0,0 +1,63 @@ +database->insert('table')->values(['email' => 'a@b.c']); + $this->assertSame(CompilerInterface::INSERT_QUERY, $q->getType()); + + $q->onConflict('email'); + $this->assertSame(CompilerInterface::UPSERT_QUERY, $q->getType()); + } + + public function testShorthandIsEquivalentToTargetDoUpdate(): void + { + $a = $this->database->insert('t')->values(['email' => 'x', 'name' => 'y']) + ->onConflict('email'); + + $b = $this->database->insert('t')->values(['email' => 'x', 'name' => 'y']) + ->onConflict(OnConflict::target('email')->doUpdate()); + + $this->assertSame($a->sqlStatement(), $b->sqlStatement()); + } + + public function testShorthandAcceptsArrayOfColumns(): void + { + $q = $this->database->insert('t') + ->values(['tenant_id' => 1, 'email' => 'x', 'name' => 'y']) + ->onConflict(['tenant_id', 'email']); + + $this->assertSame(CompilerInterface::UPSERT_QUERY, $q->getType()); + $this->assertInstanceOf(InsertQuery::class, $q); + } + + public function testOnConflictReturnsInsertQuery(): void + { + $q = $this->database->insert('t')->values(['email' => 'x']); + $this->assertSame($q, $q->onConflict('email')); + } + + public function testEmptyValuesRejected(): void + { + $q = $this->database->insert('t') + ->onConflict(OnConflict::target('email')->doUpdate()); + + $this->expectException(CompilerException::class); + (string) $q; + } +} diff --git a/tests/Database/Functional/Driver/MySQL/Query/UpsertQueryTest.php b/tests/Database/Functional/Driver/MySQL/Query/UpsertQueryTest.php new file mode 100644 index 00000000..88aa6886 --- /dev/null +++ b/tests/Database/Functional/Driver/MySQL/Query/UpsertQueryTest.php @@ -0,0 +1,97 @@ +database->insert('users') + ->values(['email' => 'a@b.c', 'name' => 'Alex']) + ->onConflict('email'); + + $this->assertSameQuery( + 'INSERT INTO {users} ({email}, {name}) VALUES (?, ?) AS {new_row} ' + . 'ON DUPLICATE KEY UPDATE {name} = {new_row}.{name}', + $q, + ); + } + + public function testDoUpdateExplicitColumns(): void + { + $q = $this->database->insert('users') + ->values(['email' => 'a@b.c', 'name' => 'Alex', 'visits' => 1]) + ->onConflict(OnConflict::target('email')->doUpdate(['name', 'visits'])); + + $this->assertSameQuery( + 'INSERT INTO {users} ({email}, {name}, {visits}) VALUES (?, ?, ?) AS {new_row} ' + . 'ON DUPLICATE KEY UPDATE {name} = {new_row}.{name}, {visits} = {new_row}.{visits}', + $q, + ); + } + + public function testDoUpdateWithExpression(): void + { + $q = $this->database->insert('counters') + ->values(['key' => 'x', 'n' => 1]) + ->onConflict(OnConflict::target('key')->doUpdate([ + 'n' => new Expression('counters.n + new_row.n'), + ])); + + $this->assertSameQuery( + 'INSERT INTO {counters} ({key}, {n}) VALUES (?, ?) AS {new_row} ' + . 'ON DUPLICATE KEY UPDATE {n} = {counters}.{n} + {new_row}.{n}', + $q, + ); + } + + public function testDoNothingEmulated(): void + { + $q = $this->database->insert('logs') + ->values(['request_id' => 'r1', 'payload' => 'p']) + ->onConflict(OnConflict::target('request_id')->doNothing()); + + $this->assertSameQuery( + 'INSERT INTO {logs} ({request_id}, {payload}) VALUES (?, ?) AS {new_row} ' + . 'ON DUPLICATE KEY UPDATE {request_id} = {request_id}', + $q, + ); + } + + public function testCustomRowAlias(): void + { + $q = $this->database->insert('users') + ->values(['email' => 'a@b.c', 'name' => 'Alex']) + ->onConflict(MySQLOnConflict::target('email')->withRowAlias('updated')->doUpdate(['name'])); + + $this->assertSameQuery( + 'INSERT INTO {users} ({email}, {name}) VALUES (?, ?) AS {updated} ' + . 'ON DUPLICATE KEY UPDATE {name} = {updated}.{name}', + $q, + ); + } + + public function testPostgresOnConflictRejected(): void + { + $q = $this->database->insert('users') + ->values(['email' => 'a@b.c']) + ->onConflict(\Cycle\Database\Driver\Postgres\PostgresOnConflict::target('email')->doUpdate()); + + $this->expectException(BuilderException::class); + (string) $q; + } +} diff --git a/tests/Database/Functional/Driver/Postgres/Query/UpsertQueryTest.php b/tests/Database/Functional/Driver/Postgres/Query/UpsertQueryTest.php new file mode 100644 index 00000000..0f68ef2e --- /dev/null +++ b/tests/Database/Functional/Driver/Postgres/Query/UpsertQueryTest.php @@ -0,0 +1,85 @@ +database->insert('users') + ->values(['email' => 'a@b.c', 'name' => 'Alex']) + ->onConflict('email'); + + $this->assertSameQuery( + 'INSERT INTO {users} ({email}, {name}) VALUES (?, ?) ' + . 'ON CONFLICT ({email}) DO UPDATE SET {name} = {EXCLUDED}.{name}', + $q, + ); + } + + public function testDoUpdateExplicitColumns(): void + { + $q = $this->database->insert('users') + ->values(['email' => 'a@b.c', 'name' => 'Alex', 'visits' => 1]) + ->onConflict(OnConflict::target('email')->doUpdate(['name', 'visits'])); + + $this->assertSameQuery( + 'INSERT INTO {users} ({email}, {name}, {visits}) VALUES (?, ?, ?) ' + . 'ON CONFLICT ({email}) DO UPDATE SET {name} = {EXCLUDED}.{name}, {visits} = {EXCLUDED}.{visits}', + $q, + ); + } + + public function testDoUpdateWithExpression(): void + { + $q = $this->database->insert('counters') + ->values(['key' => 'x', 'n' => 1]) + ->onConflict(OnConflict::target('key')->doUpdate([ + 'n' => new Expression('counters.n + EXCLUDED.n'), + ])); + + $this->assertSameQuery( + 'INSERT INTO {counters} ({key}, {n}) VALUES (?, ?) ' + . 'ON CONFLICT ({key}) DO UPDATE SET {n} = {counters}.{n} + {EXCLUDED}.{n}', + $q, + ); + } + + public function testDoNothing(): void + { + $q = $this->database->insert('logs') + ->values(['request_id' => 'r1', 'payload' => 'p']) + ->onConflict(OnConflict::target('request_id')->doNothing()); + + $this->assertSameQuery( + 'INSERT INTO {logs} ({request_id}, {payload}) VALUES (?, ?) ON CONFLICT ({request_id}) DO NOTHING', + $q, + ); + } + + public function testConstraintTarget(): void + { + $q = $this->database->insert('users') + ->values(['email' => 'a@b.c', 'name' => 'Alex']) + ->onConflict(PostgresOnConflict::onConstraint('users_email_unique')->doUpdate(['name'])); + + $this->assertSameQuery( + 'INSERT INTO {users} ({email}, {name}) VALUES (?, ?) ' + . 'ON CONFLICT ON CONSTRAINT {users_email_unique} DO UPDATE SET {name} = {EXCLUDED}.{name}', + $q, + ); + } +} diff --git a/tests/Database/Functional/Driver/SQLServer/Query/UpsertQueryTest.php b/tests/Database/Functional/Driver/SQLServer/Query/UpsertQueryTest.php new file mode 100644 index 00000000..2b0b0b3c --- /dev/null +++ b/tests/Database/Functional/Driver/SQLServer/Query/UpsertQueryTest.php @@ -0,0 +1,96 @@ +database->insert('users') + ->values(['email' => 'a@b.c', 'name' => 'Alex']) + ->onConflict('email'); + + $this->assertSameQuery( + 'MERGE INTO {users} WITH (HOLDLOCK) AS {target} ' + . 'USING (VALUES (?, ?)) AS {source} ({email}, {name}) ' + . 'ON {target}.{email} = {source}.{email} ' + . 'WHEN MATCHED THEN UPDATE SET {target}.{name} = {source}.{name} ' + . 'WHEN NOT MATCHED THEN INSERT ({email}, {name}) VALUES ({source}.{email}, {source}.{name});', + $q, + ); + } + + public function testDoUpdateExplicitColumns(): void + { + $q = $this->database->insert('users') + ->values(['email' => 'a@b.c', 'name' => 'Alex', 'visits' => 1]) + ->onConflict(OnConflict::target('email')->doUpdate(['name', 'visits'])); + + $this->assertSameQuery( + 'MERGE INTO {users} WITH (HOLDLOCK) AS {target} ' + . 'USING (VALUES (?, ?, ?)) AS {source} ({email}, {name}, {visits}) ' + . 'ON {target}.{email} = {source}.{email} ' + . 'WHEN MATCHED THEN UPDATE SET {target}.{name} = {source}.{name}, {target}.{visits} = {source}.{visits} ' + . 'WHEN NOT MATCHED THEN INSERT ({email}, {name}, {visits}) ' + . 'VALUES ({source}.{email}, {source}.{name}, {source}.{visits});', + $q, + ); + } + + public function testDoUpdateWithExpression(): void + { + $q = $this->database->insert('counters') + ->values(['key' => 'x', 'n' => 1]) + ->onConflict(OnConflict::target('key')->doUpdate([ + 'n' => new Expression('target.n + source.n'), + ])); + + $this->assertSameQuery( + 'MERGE INTO {counters} WITH (HOLDLOCK) AS {target} ' + . 'USING (VALUES (?, ?)) AS {source} ({key}, {n}) ' + . 'ON {target}.{key} = {source}.{key} ' + . 'WHEN MATCHED THEN UPDATE SET {target}.{n} = {target}.{n} + {source}.{n} ' + . 'WHEN NOT MATCHED THEN INSERT ({key}, {n}) VALUES ({source}.{key}, {source}.{n});', + $q, + ); + } + + public function testDoNothing(): void + { + $q = $this->database->insert('logs') + ->values(['request_id' => 'r1', 'payload' => 'p']) + ->onConflict(OnConflict::target('request_id')->doNothing()); + + $this->assertSameQuery( + 'MERGE INTO {logs} WITH (HOLDLOCK) AS {target} ' + . 'USING (VALUES (?, ?)) AS {source} ({request_id}, {payload}) ' + . 'ON {target}.{request_id} = {source}.{request_id} ' + . 'WHEN NOT MATCHED THEN INSERT ({request_id}, {payload}) VALUES ({source}.{request_id}, {source}.{payload});', + $q, + ); + } + + public function testPostgresOnConflictRejected(): void + { + $q = $this->database->insert('users') + ->values(['email' => 'a@b.c']) + ->onConflict(PostgresOnConflict::target('email')->doUpdate()); + + $this->expectException(BuilderException::class); + (string) $q; + } +} diff --git a/tests/Database/Functional/Driver/SQLite/Query/UpsertQueryTest.php b/tests/Database/Functional/Driver/SQLite/Query/UpsertQueryTest.php new file mode 100644 index 00000000..f7f30c3d --- /dev/null +++ b/tests/Database/Functional/Driver/SQLite/Query/UpsertQueryTest.php @@ -0,0 +1,86 @@ +database->insert('users') + ->values(['email' => 'a@b.c', 'name' => 'Alex']) + ->onConflict('email'); + + $this->assertSameQuery( + 'INSERT INTO {users} ({email}, {name}) VALUES (?, ?) ON CONFLICT ({email}) DO UPDATE SET {name} = {EXCLUDED}.{name}', + $q, + ); + } + + public function testDoUpdateExplicitColumns(): void + { + $q = $this->database->insert('users') + ->values(['email' => 'a@b.c', 'name' => 'Alex', 'visits' => 1]) + ->onConflict(OnConflict::target('email')->doUpdate(['name', 'visits'])); + + $this->assertSameQuery( + 'INSERT INTO {users} ({email}, {name}, {visits}) VALUES (?, ?, ?) ' + . 'ON CONFLICT ({email}) DO UPDATE SET {name} = {EXCLUDED}.{name}, {visits} = {EXCLUDED}.{visits}', + $q, + ); + } + + public function testDoUpdateWithExpression(): void + { + $q = $this->database->insert('counters') + ->values(['key' => 'x', 'n' => 1]) + ->onConflict(OnConflict::target('key')->doUpdate([ + 'n' => new Expression('counters.n + EXCLUDED.n'), + ])); + + $this->assertSameQuery( + 'INSERT INTO {counters} ({key}, {n}) VALUES (?, ?) ' + . 'ON CONFLICT ({key}) DO UPDATE SET {n} = {counters}.{n} + {EXCLUDED}.{n}', + $q, + ); + } + + public function testDoNothing(): void + { + $q = $this->database->insert('logs') + ->values(['request_id' => 'r1', 'payload' => 'p']) + ->onConflict(OnConflict::target('request_id')->doNothing()); + + $this->assertSameQuery( + 'INSERT INTO {logs} ({request_id}, {payload}) VALUES (?, ?) ON CONFLICT ({request_id}) DO NOTHING', + $q, + ); + } + + public function testMultiRowUpsert(): void + { + $q = $this->database->insert('users') + ->values([ + ['email' => 'a@b.c', 'name' => 'Alex'], + ['email' => 'b@c.d', 'name' => 'Bob'], + ]) + ->onConflict('email'); + + $this->assertSameQuery( + 'INSERT INTO {users} ({email}, {name}) VALUES (?, ?), (?, ?) ' + . 'ON CONFLICT ({email}) DO UPDATE SET {name} = {EXCLUDED}.{name}', + $q, + ); + } +} diff --git a/tests/Database/Unit/Driver/MySQL/MySQLOnConflictTest.php b/tests/Database/Unit/Driver/MySQL/MySQLOnConflictTest.php new file mode 100644 index 00000000..fc26c71f --- /dev/null +++ b/tests/Database/Unit/Driver/MySQL/MySQLOnConflictTest.php @@ -0,0 +1,73 @@ +assertSame(MySQLOnConflict::DEFAULT_ROW_ALIAS, $c->getRowAlias()); + } + + public function testWithRowAlias(): void + { + $c = MySQLOnConflict::target('email')->withRowAlias('updated'); + + $this->assertSame('updated', $c->getRowAlias()); + } + + public function testWithRowAliasEmptyRejected(): void + { + $this->expectException(BuilderException::class); + MySQLOnConflict::target('email')->withRowAlias(''); + } + + public function testWithRowAliasIsImmutable(): void + { + $base = MySQLOnConflict::target('email'); + $modified = $base->withRowAlias('updated'); + + $this->assertSame(MySQLOnConflict::DEFAULT_ROW_ALIAS, $base->getRowAlias()); + $this->assertSame('updated', $modified->getRowAlias()); + } + + public function testFromBase(): void + { + $base = OnConflict::target('email')->doUpdate(['name']); + $mysql = MySQLOnConflict::from($base); + + $this->assertInstanceOf(MySQLOnConflict::class, $mysql); + $this->assertSame(['email'], $mysql->getTarget()); + $this->assertSame(MySQLOnConflict::DEFAULT_ROW_ALIAS, $mysql->getRowAlias()); + } + + public function testFromOtherDriverSubclassRejected(): void + { + $pg = PostgresOnConflict::target('email'); + + $this->expectException(BuilderException::class); + MySQLOnConflict::from($pg); + } + + public function testCacheKeyIncludesRowAlias(): void + { + $a = MySQLOnConflict::target('email')->doUpdate(['name']); + $b = MySQLOnConflict::target('email')->withRowAlias('custom')->doUpdate(['name']); + + $this->assertNotSame( + $a->getCacheKey(new QueryParameters()), + $b->getCacheKey(new QueryParameters()), + ); + } +} diff --git a/tests/Database/Unit/Driver/Postgres/PostgresOnConflictTest.php b/tests/Database/Unit/Driver/Postgres/PostgresOnConflictTest.php new file mode 100644 index 00000000..07d95445 --- /dev/null +++ b/tests/Database/Unit/Driver/Postgres/PostgresOnConflictTest.php @@ -0,0 +1,83 @@ +assertSame([], $c->getTarget()); + $this->assertSame('users_email_unique', $c->getConstraint()); + $this->assertSame(ConflictAction::Update, $c->getAction()); + } + + public function testOnConstraintEmptyRejected(): void + { + $this->expectException(BuilderException::class); + PostgresOnConflict::onConstraint(''); + } + + public function testTargetReturnsSubclassInstance(): void + { + $c = PostgresOnConflict::target('email'); + + $this->assertInstanceOf(PostgresOnConflict::class, $c); + } + + public function testDoUpdatePreservesConstraint(): void + { + $c = PostgresOnConflict::onConstraint('users_email_unique')->doUpdate(['name']); + + $this->assertSame('users_email_unique', $c->getConstraint()); + $this->assertSame(['name'], $c->getUpdate()); + } + + public function testFromBase(): void + { + $base = OnConflict::target('email')->doUpdate(['name']); + $pg = PostgresOnConflict::from($base); + + $this->assertInstanceOf(PostgresOnConflict::class, $pg); + $this->assertSame(['email'], $pg->getTarget()); + $this->assertSame(['name'], $pg->getUpdate()); + $this->assertNull($pg->getConstraint()); + } + + public function testFromSelfReturnsSameInstance(): void + { + $pg = PostgresOnConflict::onConstraint('foo'); + + $this->assertSame($pg, PostgresOnConflict::from($pg)); + } + + public function testFromOtherDriverSubclassRejected(): void + { + $mysql = MySQLOnConflict::target('email'); + + $this->expectException(BuilderException::class); + PostgresOnConflict::from($mysql); + } + + public function testCacheKeyIncludesConstraint(): void + { + $a = PostgresOnConflict::target('email')->doUpdate(['name']); + $b = PostgresOnConflict::onConstraint('users_email_unique')->doUpdate(['name']); + + $this->assertNotSame( + $a->getCacheKey(new QueryParameters()), + $b->getCacheKey(new QueryParameters()), + ); + } +} diff --git a/tests/Database/Unit/Query/OnConflictTest.php b/tests/Database/Unit/Query/OnConflictTest.php new file mode 100644 index 00000000..430a0a97 --- /dev/null +++ b/tests/Database/Unit/Query/OnConflictTest.php @@ -0,0 +1,131 @@ +assertSame(['email'], $c->getTarget()); + $this->assertSame(ConflictAction::Update, $c->getAction()); + $this->assertNull($c->getUpdate()); + } + + public function testTargetMultipleColumnsVariadic(): void + { + $c = OnConflict::target('tenant_id', 'email'); + + $this->assertSame(['tenant_id', 'email'], $c->getTarget()); + } + + public function testTargetCommaSeparatedString(): void + { + $c = OnConflict::target('tenant_id, email'); + + $this->assertSame(['tenant_id', 'email'], $c->getTarget()); + } + + public function testTargetArray(): void + { + $c = OnConflict::target(['tenant_id', 'email']); + + $this->assertSame(['tenant_id', 'email'], $c->getTarget()); + } + + public function testTargetEmptyRejected(): void + { + $this->expectException(BuilderException::class); + OnConflict::target(); + } + + public function testDoNothing(): void + { + $c = OnConflict::target('email')->doNothing(); + + $this->assertSame(ConflictAction::Nothing, $c->getAction()); + $this->assertNull($c->getUpdate()); + } + + public function testDoUpdateAllColumns(): void + { + $c = OnConflict::target('email')->doUpdate(); + + $this->assertSame(ConflictAction::Update, $c->getAction()); + $this->assertNull($c->getUpdate()); + } + + public function testDoUpdateColumnList(): void + { + $c = OnConflict::target('email')->doUpdate(['name', 'updated_at']); + + $this->assertSame(['name', 'updated_at'], $c->getUpdate()); + } + + public function testDoUpdateColumnMap(): void + { + $expr = new Expression('counters.n + EXCLUDED.n'); + $c = OnConflict::target('key')->doUpdate(['n' => $expr]); + + $this->assertSame(['n' => $expr], $c->getUpdate()); + } + + public function testImmutability(): void + { + $base = OnConflict::target('email'); + $withUpdate = $base->doUpdate(['name']); + $withNothing = $base->doNothing(); + + $this->assertNull($base->getUpdate()); + $this->assertSame(ConflictAction::Update, $base->getAction()); + + $this->assertSame(['name'], $withUpdate->getUpdate()); + $this->assertSame(ConflictAction::Update, $withUpdate->getAction()); + + $this->assertNull($withNothing->getUpdate()); + $this->assertSame(ConflictAction::Nothing, $withNothing->getAction()); + } + + public function testCacheKeyStableForSameShape(): void + { + $a = OnConflict::target('email')->doUpdate(['name']); + $b = OnConflict::target('email')->doUpdate(['name']); + + $this->assertSame( + $a->getCacheKey(new QueryParameters()), + $b->getCacheKey(new QueryParameters()), + ); + } + + public function testCacheKeyDiffersForDifferentAction(): void + { + $update = OnConflict::target('email')->doUpdate(); + $nothing = OnConflict::target('email')->doNothing(); + + $this->assertNotSame( + $update->getCacheKey(new QueryParameters()), + $nothing->getCacheKey(new QueryParameters()), + ); + } + + public function testCacheKeyDiffersForDifferentUpdateColumns(): void + { + $a = OnConflict::target('email')->doUpdate(['name']); + $b = OnConflict::target('email')->doUpdate(['name', 'visits']); + + $this->assertNotSame( + $a->getCacheKey(new QueryParameters()), + $b->getCacheKey(new QueryParameters()), + ); + } +} diff --git a/tests/Database/Unit/Query/Tokens/InsertQueryTest.php b/tests/Database/Unit/Query/Tokens/InsertQueryTest.php index fb8e6966..1a436f24 100644 --- a/tests/Database/Unit/Query/Tokens/InsertQueryTest.php +++ b/tests/Database/Unit/Query/Tokens/InsertQueryTest.php @@ -29,6 +29,7 @@ public function testBuildQuery(): void 'table' => 'table', 'columns' => ['name', 'value'], 'values' => [new Parameter(['Antony', 1])], + 'onConflict' => null, ], $insert->getTokens(), );