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

🔍 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 → 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).

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

1 participant