From 3add8b1a1593f45f036a5e758bbb29dc6b70f65e Mon Sep 17 00:00:00 2001 From: mscherer Date: Sun, 11 Jan 2026 07:02:46 +0100 Subject: [PATCH 1/4] Improve insertOrUpdate() API consistency across database adapters - Add deprecation warning for MySQL when conflictColumns is passed (ignored) - Add RuntimeException for PostgreSQL/SQLite when conflictColumns is missing - Document database-specific behavior in Table.php and SeedInterface.php - Add tests for PostgreSQL and SQLite conflict column validation --- src/Db/Adapter/AbstractAdapter.php | 12 ++++++++++++ src/Db/Adapter/PostgresAdapter.php | 15 +++++++++++++-- src/Db/Adapter/SqliteAdapter.php | 16 ++++++++++++++-- src/Db/Table.php | 19 +++++++++++++++++-- src/SeedInterface.php | 17 ++++++++++++++--- .../Db/Adapter/PostgresAdapterTest.php | 17 +++++++++++++++++ .../TestCase/Db/Adapter/SqliteAdapterTest.php | 16 ++++++++++++++++ 7 files changed, 103 insertions(+), 9 deletions(-) diff --git a/src/Db/Adapter/AbstractAdapter.php b/src/Db/Adapter/AbstractAdapter.php index 4235ea00..6f959c75 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 deprecation 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) { + deprecationWarning( + '5.1.0', + 'The $conflictColumns parameter is ignored by MySQL. ' . + 'MySQL\'s ON DUPLICATE KEY UPDATE applies to all unique constraints on the table.', + ); + } + $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..e45746fc 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 deprecation 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 deprecation 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..28b7db4f 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 deprecation 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 deprecation 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..8fb9f4b7 100644 --- a/tests/TestCase/Db/Adapter/PostgresAdapterTest.php +++ b/tests/TestCase/Db/Adapter/PostgresAdapterTest.php @@ -10,6 +10,7 @@ use Cake\Datasource\ConnectionManager; use InvalidArgumentException; use Migrations\Db\Adapter\AdapterInterface; +use RuntimeException; use Migrations\Db\Adapter\PostgresAdapter; use Migrations\Db\Literal; use Migrations\Db\Table; @@ -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(); + } } From ff574ef5ed693985d9bbb1ce1ce91dca5f47c687 Mon Sep 17 00:00:00 2001 From: mscherer Date: Sun, 11 Jan 2026 07:07:33 +0100 Subject: [PATCH 2/4] Fix use statement ordering in PostgresAdapterTest --- tests/TestCase/Db/Adapter/PostgresAdapterTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/TestCase/Db/Adapter/PostgresAdapterTest.php b/tests/TestCase/Db/Adapter/PostgresAdapterTest.php index 8fb9f4b7..268831cc 100644 --- a/tests/TestCase/Db/Adapter/PostgresAdapterTest.php +++ b/tests/TestCase/Db/Adapter/PostgresAdapterTest.php @@ -10,7 +10,6 @@ use Cake\Datasource\ConnectionManager; use InvalidArgumentException; use Migrations\Db\Adapter\AdapterInterface; -use RuntimeException; use Migrations\Db\Adapter\PostgresAdapter; use Migrations\Db\Literal; use Migrations\Db\Table; @@ -24,6 +23,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Depends; use PHPUnit\Framework\TestCase; +use RuntimeException; class PostgresAdapterTest extends TestCase { From 1eedc9799cb99da707bb42658a553bfdd5d8d694 Mon Sep 17 00:00:00 2001 From: mscherer Date: Mon, 12 Jan 2026 03:17:02 +0100 Subject: [PATCH 3/4] Change MySQL conflictColumns deprecationWarning to trigger_error Since insertOrUpdate is a new feature, using deprecationWarning() doesn't make semantic sense. Switch to trigger_error() with E_USER_WARNING to alert developers that the parameter is ignored on MySQL without implying the feature will be removed. --- src/Db/Adapter/AbstractAdapter.php | 6 +++--- src/Db/Table.php | 4 ++-- src/SeedInterface.php | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Db/Adapter/AbstractAdapter.php b/src/Db/Adapter/AbstractAdapter.php index 6f959c75..3bb4a81b 100644 --- a/src/Db/Adapter/AbstractAdapter.php +++ b/src/Db/Adapter/AbstractAdapter.php @@ -732,7 +732,7 @@ protected function getInsertPrefix(?InsertMode $mode = null): string * * 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 deprecation warning will be triggered. + * MySQL, a warning will be triggered. * * @param \Migrations\Db\InsertMode|null $mode Insert mode * @param array|null $updateColumns Columns to update on conflict @@ -746,10 +746,10 @@ protected function getUpsertClause(?InsertMode $mode, ?array $updateColumns, ?ar } if ($conflictColumns !== null) { - deprecationWarning( - '5.1.0', + 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, ); } diff --git a/src/Db/Table.php b/src/Db/Table.php index e45746fc..3998627d 100644 --- a/src/Db/Table.php +++ b/src/Db/Table.php @@ -810,7 +810,7 @@ public function insertOrSkip(array $data) * * - **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 deprecation warning. + * 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. @@ -830,7 +830,7 @@ 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. Required for PostgreSQL/SQLite, - * ignored by MySQL (triggers deprecation warning if provided). + * ignored by MySQL (triggers warning if provided). * @return $this * @throws \RuntimeException When using PostgreSQL or SQLite without specifying conflictColumns */ diff --git a/src/SeedInterface.php b/src/SeedInterface.php index 28b7db4f..3d21972a 100644 --- a/src/SeedInterface.php +++ b/src/SeedInterface.php @@ -165,7 +165,7 @@ public function insertOrSkip(string $tableName, array $data): void; * * - **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 deprecation warning. + * 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. @@ -177,7 +177,7 @@ public function insertOrSkip(string $tableName, array $data): void; * @param array $data Data * @param array $updateColumns Columns to update when a conflict occurs * @param array $conflictColumns Columns that define uniqueness. Required for PostgreSQL/SQLite, - * ignored by MySQL (triggers deprecation warning if provided). + * ignored by MySQL (triggers warning if provided). * @return void * @throws \RuntimeException When using PostgreSQL or SQLite without specifying conflictColumns */ From ca4a1d4bae465c91e8b34a52e0f19b1aad37fb56 Mon Sep 17 00:00:00 2001 From: mscherer Date: Mon, 12 Jan 2026 03:20:29 +0100 Subject: [PATCH 4/4] Add documentation for insertOrSkip and insertOrUpdate methods Documents the insert modes with database-specific behavior caveats, particularly the MySQL vs PostgreSQL/SQLite differences for upsert. --- docs/en/seeding.rst | 83 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) 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 =================