Skip to content

feat: Add InsertQuery::onConflict() method#249

Open
roxblnfk wants to merge 1 commit into
2.xfrom
feature/upsert-onconflict
Open

feat: Add InsertQuery::onConflict() method#249
roxblnfk wants to merge 1 commit into
2.xfrom
feature/upsert-onconflict

Conversation

@roxblnfk
Copy link
Copy Markdown
Member

@roxblnfk roxblnfk commented May 13, 2026

🔍 What was changed

Adds first-class upsert support as a state on InsertQuery, not a new query class.

  • InsertQuery::onConflict(OnConflict|string|array) — new setter. Presence of state flips getType() from INSERT_QUERY to UPSERT_QUERY.
  • Cycle\Database\Query\OnConflict — immutable value object describing conflict-resolution policy: target columns, doUpdate(null|list|map) (supports column expressions via FragmentInterface), doNothing().
  • Driver-specific subclasses (mirroring the CursorOptions pattern):
    • Postgres\PostgresOnConflict::onConstraint($name)ON CONFLICT ON CONSTRAINT.
    • MySQL\MySQLOnConflict::withRowAlias($alias) — customize the INSERT ... AS <alias> row alias.
    • SQLServer\SQLServerOnConflict — placeholder for future MERGE-specific options.
    • Each provides static from(OnConflict) that accepts base or self and rejects mismatched subclasses.
  • Compiler dispatch via new CompilerInterface::UPSERT_QUERY constant:
    • Base Compiler::upsertQuery() → SQLite (INSERT ... ON CONFLICT ... DO UPDATE|NOTHING).
    • PostgresCompiler →z same + RETURNING + constraint target.
    • MySQLCompilerINSERT ... AS <alias> ON DUPLICATE KEY UPDATE (requires MySQL 8.0.19+).
    • SQLServerCompilerMERGE ... WITH (HOLDLOCK) WHEN MATCHED THEN UPDATE WHEN NOT MATCHED THEN INSERT [OUTPUT].
  • CompilerCache::hashUpsertQuery() — caches single-row upserts, parallel to single-row insert caching. Hash delegates to polymorphic OnConflict::getCacheKey() so driver-specific fields are correctly disambiguated.
// Portable
$db->insert('users')->values($row)
    ->onConflict('email')                            // shorthand: DO UPDATE on all columns
    ->run();

$db->insert('users')->values($row)
    ->onConflict(OnConflict::target('email')->doUpdate(['name']))
    ->run();

$db->insert('counters')->values(['key' => 'x', 'n' => 1])
    ->onConflict(OnConflict::target('key')->doUpdate([
        'n' => new Expression('counters.n + EXCLUDED.n'),
    ]))->run();

$db->insert('logs')->values($row)
    ->onConflict(OnConflict::target('request_id')->doNothing())
    ->run();

// Driver-specific
$db->insert('users')->values($row)
    ->onConflict(PostgresOnConflict::onConstraint('users_email_unique')->doUpdate(['name']))
    ->run();

Note

Closes the same gap as #231 with a different architecture. No new query class, no widening of DatabaseInterface/BuilderInterface/Table/QueryBuilder — the only new public surface is InsertQuery::onConflict() and the OnConflict DTO hierarchy. See review.md and fr.md on the branch for the design discussion.

🤔 Why?

  • Upsert is, in SQL terms, an extension clause on INSERT for 3 of 4 drivers — modelling it as a separate query class duplicated into/columns/values machinery and required a parallel Database::upsert(), Table::upsertOne(), BuilderInterface::upsertQuery() surface.
  • Driver-specific behaviour (Postgres ON CONSTRAINT, MySQL row alias, SQLServer MERGE quirks) doesn't belong on a single base DTO — handling it with runtime CompilerExceptions pollutes the abstraction. Driver-specific subclasses move the check to type level and match the existing CursorOptions pattern in the codebase.
  • MySQLCompiler uses modern INSERT ... AS new_row ON DUPLICATE KEY UPDATE col = new_row.col rather than the deprecated VALUES(col) form.

📝 Checklist

  • Closes 💡 Add support for MySQL: ON DUPLICATE KEY UPDATE, PostgreSQL: ON CONFLICT #50 (and supersedes the architecture proposed in feat: Added support for upserting data #231)
  • How was this tested:
    • Unit tests added (OnConflictTest, PostgresOnConflictTest, MySQLOnConflictTest — 27 new tests covering DTO semantics, immutability, from() narrowing/rejection, cache-key stability)
    • Functional compile-time tests added per driver (Common, SQLite, MySQL, Postgres, SQLServer — exercising shorthand, explicit doUpdate columns/expressions, doNothing, multi-row upsert, constraint target, custom row alias, cross-driver subclass rejection)
    • Functional runtime tests against live MySQL/Postgres/SQLServer servers — left for CI; SQLite runtime path is covered

📃 Documentation

  • Database::cursor()-style PHPDoc on InsertQuery::onConflict() and OnConflict describes both shorthand and DTO forms.
  • InsertQuery::run() PHPDoc notes that lastInsertID is unreliable on the update branch for MySQL/SQLite without RETURNING — driver-specific subclasses with ReturningInterface (Postgres, SQLServer) are recommended for code that needs the row identity back.

Known scope limitations (follow-ups)

  • where() clause on DO UPDATE (Postgres) / WHEN MATCHED AND ... (SQLServer) is intentionally deferred — would require integrating WhereTrait into the immutable DTO; the subclass hierarchy is ready for it without polluting the base.
  • MySQL < 8.0.19 fallback to legacy VALUES(col) not implemented (current target: MySQL 8.0.19+, the row-alias syntax cutoff).

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds first-class upsert support by extending InsertQuery with an onConflict() method that flips the query type to a new UPSERT_QUERY constant; a new immutable OnConflict DTO (with driver-specific PostgresOnConflict, MySQLOnConflict, SQLServerOnConflict subclasses) describes target/action/update spec; each driver compiler implements upsertQuery() (SQLite/Postgres ON CONFLICT, MySQL INSERT ... AS new_row ON DUPLICATE KEY UPDATE, SQL Server MERGE ... WITH (HOLDLOCK)), with single-row upsert caching in CompilerCache.

Changes:

  • New OnConflict value object hierarchy and ConflictAction enum, with shared upsertUpdateClause() helper in base Compiler.
  • InsertQuery::onConflict() setter (string / array / DTO) plus updated getType()/getTokens(); Postgres InsertQuery propagates onConflict into its tokens.
  • Per-driver upsertQuery() compilers, CompilerInterface::UPSERT_QUERY = 10, CompilerCache::hashUpsertQuery(), and unit + functional tests across drivers.

Reviewed changes

Copilot reviewed 23 out of 23 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/Query/OnConflict.php New immutable upsert DTO with target/action/update spec and cache-key generation.
src/Query/ConflictAction.php New enum for Update / Nothing actions.
src/Query/InsertQuery.php Adds onConflict() setter, type-flip in getType(), and onConflict token.
src/Query/ActiveQuery.php Tightens fetchIdentifiers return type annotation.
src/Driver/CompilerInterface.php Adds UPSERT_QUERY = 10 constant.
src/Driver/Compiler.php Default SQLite-style upsertQuery() and shared upsertUpdateClause() helper.
src/Driver/CompilerCache.php Single-row upsert caching via hashUpsertQuery().
src/Driver/MySQL/MySQLOnConflict.php MySQL DTO with withRowAlias(); doc claim that target is "ignored" is misleading.
src/Driver/MySQL/MySQLCompiler.php INSERT ... AS <alias> ON DUPLICATE KEY UPDATE compilation; DO NOTHING emulated via self-assignment.
src/Driver/Postgres/PostgresOnConflict.php Postgres DTO adding onConstraint() target.
src/Driver/Postgres/PostgresCompiler.php ON CONFLICT (...) DO UPDATE/NOTHING [RETURNING] compilation; appendReturning() extracted.
src/Driver/Postgres/Query/PostgresInsertQuery.php Propagates onConflict token.
src/Driver/SQLServer/SQLServerOnConflict.php SQL Server DTO placeholder; rejects mismatched subclasses in from().
src/Driver/SQLServer/SQLServerCompiler.php MERGE ... WITH (HOLDLOCK) compilation with optional OUTPUT.
tests/Database/Unit/Query/OnConflictTest.php DTO semantics, immutability, cache-key tests.
tests/Database/Unit/Driver/{Postgres,MySQL}/*OnConflictTest.php Driver-specific DTO / from() narrowing tests.
tests/Database/Unit/Query/Tokens/InsertQueryTest.php Adds onConflict => null to expected tokens.
tests/Database/Functional/Driver/Common/Query/UpsertQueryTest.php Cross-driver behavior expectations.
tests/Database/Functional/Driver/{SQLite,MySQL,Postgres,SQLServer}/Query/UpsertQueryTest.php Per-driver SQL assertions.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +22 to +24
* 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).
Comment thread src/Query/InsertQuery.php
Comment on lines +138 to +139
$columns = \is_array($conflict) ? \array_values($conflict) : [$conflict];
$this->onConflict = OnConflict::target($columns)->doUpdate();
Comment thread src/Query/OnConflict.php
Comment on lines +169 to +182
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;
}
}
}

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]);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

💡 Add support for MySQL: ON DUPLICATE KEY UPDATE, PostgreSQL: ON CONFLICT

2 participants