diff --git a/docs/en/seeding.rst b/docs/en/seeding.rst index 94f9459e..80d46f69 100644 --- a/docs/en/seeding.rst +++ b/docs/en/seeding.rst @@ -450,6 +450,89 @@ within your seed class and then use the ``insert()`` method to insert data: You must call the ``saveData()`` method to commit your data to the table. Migrations will buffer data until you do so. +Insert Modes +============ + +In addition to the standard ``insert()`` method, Migrations provides specialized +insert methods for handling conflicts with existing data. + +Insert or Skip +-------------- + +The ``insertOrSkip()`` method inserts rows but silently skips any that would +violate a unique constraint: + +.. code-block:: php + + 'USD', 'name' => 'US Dollar'], + ['code' => 'EUR', 'name' => 'Euro'], + ]; + + $this->table('currencies') + ->insertOrSkip($data) + ->saveData(); + } + } + +Insert or Update (Upsert) +------------------------- + +The ``insertOrUpdate()`` method performs an "upsert" operation - inserting new +rows and updating existing rows that conflict on unique columns: + +.. code-block:: php + + 'USD', 'rate' => 1.0000], + ['code' => 'EUR', 'rate' => 0.9234], + ]; + + $this->table('exchange_rates') + ->insertOrUpdate($data, ['rate'], ['code']) + ->saveData(); + } + } + +The method takes three arguments: + +- ``$data``: The rows to insert (same format as ``insert()``) +- ``$updateColumns``: Which columns to update when a conflict occurs +- ``$conflictColumns``: Which columns define uniqueness (must have a unique index) + +.. warning:: + + Database-specific behavior differences: + + **MySQL**: Uses ``ON DUPLICATE KEY UPDATE``. The ``$conflictColumns`` parameter + is ignored because MySQL automatically applies the update to *all* unique + constraint violations on the table. Passing ``$conflictColumns`` will trigger + a warning. If your table has multiple unique constraints, be aware that a + conflict on *any* of them will trigger the update. + + **PostgreSQL/SQLite**: Uses ``ON CONFLICT (...) DO UPDATE SET``. The + ``$conflictColumns`` parameter is required and specifies exactly which unique + constraint should trigger the update. A ``RuntimeException`` will be thrown + if this parameter is empty. + + **SQL Server**: Not currently supported. Use separate insert/update logic. + Truncating Tables ================= diff --git a/src/Db/Adapter/AbstractAdapter.php b/src/Db/Adapter/AbstractAdapter.php index 4235ea00..3bb4a81b 100644 --- a/src/Db/Adapter/AbstractAdapter.php +++ b/src/Db/Adapter/AbstractAdapter.php @@ -730,6 +730,10 @@ protected function getInsertPrefix(?InsertMode $mode = null): string /** * Get the upsert clause for MySQL (ON DUPLICATE KEY UPDATE). * + * MySQL's ON DUPLICATE KEY UPDATE applies to all unique key constraints on the table, + * so the $conflictColumns parameter is not used. If you pass conflictColumns when using + * MySQL, a warning will be triggered. + * * @param \Migrations\Db\InsertMode|null $mode Insert mode * @param array|null $updateColumns Columns to update on conflict * @param array|null $conflictColumns Columns that define uniqueness (unused in MySQL) @@ -741,6 +745,14 @@ protected function getUpsertClause(?InsertMode $mode, ?array $updateColumns, ?ar return ''; } + if ($conflictColumns !== null) { + trigger_error( + 'The $conflictColumns parameter is ignored by MySQL. ' . + 'MySQL\'s ON DUPLICATE KEY UPDATE applies to all unique constraints on the table.', + E_USER_WARNING, + ); + } + $updates = []; foreach ($updateColumns as $column) { $quotedColumn = $this->quoteColumnName($column); diff --git a/src/Db/Adapter/PostgresAdapter.php b/src/Db/Adapter/PostgresAdapter.php index b1d411c2..8720b9c5 100644 --- a/src/Db/Adapter/PostgresAdapter.php +++ b/src/Db/Adapter/PostgresAdapter.php @@ -1288,10 +1288,15 @@ public function bulkinsert( /** * Get the ON CONFLICT clause based on insert mode. * + * PostgreSQL requires explicit conflict columns to determine which unique constraint + * should trigger the update. Unlike MySQL's ON DUPLICATE KEY UPDATE which applies + * to all unique constraints, PostgreSQL's ON CONFLICT clause must specify the columns. + * * @param \Migrations\Db\InsertMode|null $mode Insert mode * @param array|null $updateColumns Columns to update on upsert conflict - * @param array|null $conflictColumns Columns that define uniqueness for upsert + * @param array|null $conflictColumns Columns that define uniqueness for upsert (required for PostgreSQL) * @return string + * @throws \RuntimeException When using UPSERT mode without conflictColumns */ protected function getConflictClause( ?InsertMode $mode = null, @@ -1302,7 +1307,13 @@ protected function getConflictClause( return ' ON CONFLICT DO NOTHING'; } - if ($mode === InsertMode::UPSERT && $updateColumns !== null && $conflictColumns !== null) { + if ($mode === InsertMode::UPSERT) { + if ($conflictColumns === null || $conflictColumns === []) { + throw new RuntimeException( + 'PostgreSQL requires the $conflictColumns parameter for insertOrUpdate(). ' . + 'Specify the columns that have a unique constraint to determine conflict resolution.', + ); + } $quotedConflictColumns = array_map($this->quoteColumnName(...), $conflictColumns); $updates = []; foreach ($updateColumns as $column) { diff --git a/src/Db/Adapter/SqliteAdapter.php b/src/Db/Adapter/SqliteAdapter.php index ae2ff599..9145e0cb 100644 --- a/src/Db/Adapter/SqliteAdapter.php +++ b/src/Db/Adapter/SqliteAdapter.php @@ -1713,17 +1713,29 @@ protected function getInsertPrefix(?InsertMode $mode = null): string /** * Get the upsert clause for SQLite (ON CONFLICT ... DO UPDATE SET). * + * SQLite requires explicit conflict columns to determine which unique constraint + * should trigger the update. Unlike MySQL's ON DUPLICATE KEY UPDATE which applies + * to all unique constraints, SQLite's ON CONFLICT clause must specify the columns. + * * @param \Migrations\Db\InsertMode|null $mode Insert mode * @param array|null $updateColumns Columns to update on conflict - * @param array|null $conflictColumns Columns that define uniqueness for upsert + * @param array|null $conflictColumns Columns that define uniqueness for upsert (required for SQLite) * @return string + * @throws \RuntimeException When using UPSERT mode without conflictColumns */ protected function getUpsertClause(?InsertMode $mode, ?array $updateColumns, ?array $conflictColumns = null): string { - if ($mode !== InsertMode::UPSERT || $updateColumns === null || $conflictColumns === null) { + if ($mode !== InsertMode::UPSERT || $updateColumns === null) { return ''; } + if ($conflictColumns === null || $conflictColumns === []) { + throw new RuntimeException( + 'SQLite requires the $conflictColumns parameter for insertOrUpdate(). ' . + 'Specify the columns that have a unique constraint to determine conflict resolution.', + ); + } + $quotedConflictColumns = array_map($this->quoteColumnName(...), $conflictColumns); $updates = []; foreach ($updateColumns as $column) { diff --git a/src/Db/Table.php b/src/Db/Table.php index d54658aa..3998627d 100644 --- a/src/Db/Table.php +++ b/src/Db/Table.php @@ -806,8 +806,21 @@ public function insertOrSkip(array $data) * This method performs an "upsert" operation - inserting new rows and updating * existing rows that conflict on the specified unique columns. * - * Example: + * ### Database-specific behavior: + * + * - **MySQL**: Uses `ON DUPLICATE KEY UPDATE`. The `$conflictColumns` parameter is + * ignored because MySQL automatically applies the update to all unique constraint + * violations. Passing `$conflictColumns` will trigger a warning. + * + * - **PostgreSQL/SQLite**: Uses `ON CONFLICT (...) DO UPDATE SET`. The `$conflictColumns` + * parameter is required and must specify the columns that have a unique constraint. + * A RuntimeException will be thrown if this parameter is empty. + * + * - **SQL Server**: Not currently supported. Use separate insert/update logic. + * + * ### Example: * ```php + * // Works on all supported databases * $table->insertOrUpdate([ * ['code' => 'USD', 'rate' => 1.0000], * ['code' => 'EUR', 'rate' => 0.9234], @@ -816,8 +829,10 @@ public function insertOrSkip(array $data) * * @param array $data array of data in the same format as insert() * @param array $updateColumns Columns to update when a conflict occurs - * @param array $conflictColumns Columns that define uniqueness (must have unique index) + * @param array $conflictColumns Columns that define uniqueness. Required for PostgreSQL/SQLite, + * ignored by MySQL (triggers warning if provided). * @return $this + * @throws \RuntimeException When using PostgreSQL or SQLite without specifying conflictColumns */ public function insertOrUpdate(array $data, array $updateColumns, array $conflictColumns) { diff --git a/src/SeedInterface.php b/src/SeedInterface.php index f566484f..3d21972a 100644 --- a/src/SeedInterface.php +++ b/src/SeedInterface.php @@ -161,14 +161,25 @@ public function insertOrSkip(string $tableName, array $data): void; * This method performs an "upsert" operation - inserting new rows and updating * existing rows that conflict on the specified unique columns. * - * Uses ON DUPLICATE KEY UPDATE (MySQL), or ON CONFLICT ... DO UPDATE SET - * (PostgreSQL/SQLite). + * ### Database-specific behavior: + * + * - **MySQL**: Uses `ON DUPLICATE KEY UPDATE`. The `$conflictColumns` parameter is + * ignored because MySQL automatically applies the update to all unique constraint + * violations. Passing `$conflictColumns` will trigger a warning. + * + * - **PostgreSQL/SQLite**: Uses `ON CONFLICT (...) DO UPDATE SET`. The `$conflictColumns` + * parameter is required and must specify the columns that have a unique constraint. + * A RuntimeException will be thrown if this parameter is empty. + * + * - **SQL Server**: Not currently supported. Use separate insert/update logic. * * @param string $tableName Table name * @param array $data Data * @param array $updateColumns Columns to update when a conflict occurs - * @param array $conflictColumns Columns that define uniqueness (must have unique index) + * @param array $conflictColumns Columns that define uniqueness. Required for PostgreSQL/SQLite, + * ignored by MySQL (triggers warning if provided). * @return void + * @throws \RuntimeException When using PostgreSQL or SQLite without specifying conflictColumns */ public function insertOrUpdate(string $tableName, array $data, array $updateColumns, array $conflictColumns): void; diff --git a/tests/TestCase/Db/Adapter/PostgresAdapterTest.php b/tests/TestCase/Db/Adapter/PostgresAdapterTest.php index 2bc5988e..268831cc 100644 --- a/tests/TestCase/Db/Adapter/PostgresAdapterTest.php +++ b/tests/TestCase/Db/Adapter/PostgresAdapterTest.php @@ -23,6 +23,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Depends; use PHPUnit\Framework\TestCase; +use RuntimeException; class PostgresAdapterTest extends TestCase { @@ -2985,6 +2986,22 @@ public function testInsertOrUpdateModeResetsAfterSave() ])->save(); } + public function testInsertOrUpdateRequiresConflictColumns() + { + $table = new Table('currencies', [], $this->adapter); + $table->addColumn('code', 'string', ['limit' => 3]) + ->addColumn('rate', 'decimal', ['precision' => 10, 'scale' => 4]) + ->addIndex('code', ['unique' => true]) + ->create(); + + // PostgreSQL requires conflictColumns for insertOrUpdate + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('PostgreSQL requires the $conflictColumns parameter'); + $table->insertOrUpdate([ + ['code' => 'USD', 'rate' => 1.0000], + ], ['rate'], [])->save(); + } + public function testAddSinglePartitionToExistingTable() { // Create a partitioned table with room to add more partitions diff --git a/tests/TestCase/Db/Adapter/SqliteAdapterTest.php b/tests/TestCase/Db/Adapter/SqliteAdapterTest.php index ad008cd8..2fd54a9b 100644 --- a/tests/TestCase/Db/Adapter/SqliteAdapterTest.php +++ b/tests/TestCase/Db/Adapter/SqliteAdapterTest.php @@ -3377,4 +3377,20 @@ public function testInsertOrUpdateModeResetsAfterSave() ['code' => 'ITEM1', 'name' => 'Different Name'], ])->save(); } + + public function testInsertOrUpdateRequiresConflictColumns() + { + $table = new Table('currencies', [], $this->adapter); + $table->addColumn('code', 'string', ['limit' => 3]) + ->addColumn('rate', 'decimal', ['precision' => 10, 'scale' => 4]) + ->addIndex('code', ['unique' => true]) + ->create(); + + // SQLite requires conflictColumns for insertOrUpdate + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('SQLite requires the $conflictColumns parameter'); + $table->insertOrUpdate([ + ['code' => 'USD', 'rate' => 1.0000], + ], ['rate'], [])->save(); + } }