Skip to content

Commit dee5b42

Browse files
authored
Improve insertOrUpdate() API consistency across database adapters (#992)
* 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 * Fix use statement ordering in PostgresAdapterTest * 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. * 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.
1 parent 886c68d commit dee5b42

File tree

8 files changed

+186
-9
lines changed

8 files changed

+186
-9
lines changed

docs/en/seeding.rst

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,89 @@ within your seed class and then use the ``insert()`` method to insert data:
450450
You must call the ``saveData()`` method to commit your data to the table.
451451
Migrations will buffer data until you do so.
452452

453+
Insert Modes
454+
============
455+
456+
In addition to the standard ``insert()`` method, Migrations provides specialized
457+
insert methods for handling conflicts with existing data.
458+
459+
Insert or Skip
460+
--------------
461+
462+
The ``insertOrSkip()`` method inserts rows but silently skips any that would
463+
violate a unique constraint:
464+
465+
.. code-block:: php
466+
467+
<?php
468+
469+
use Migrations\BaseSeed;
470+
471+
class CurrencySeed extends BaseSeed
472+
{
473+
public function run(): void
474+
{
475+
$data = [
476+
['code' => 'USD', 'name' => 'US Dollar'],
477+
['code' => 'EUR', 'name' => 'Euro'],
478+
];
479+
480+
$this->table('currencies')
481+
->insertOrSkip($data)
482+
->saveData();
483+
}
484+
}
485+
486+
Insert or Update (Upsert)
487+
-------------------------
488+
489+
The ``insertOrUpdate()`` method performs an "upsert" operation - inserting new
490+
rows and updating existing rows that conflict on unique columns:
491+
492+
.. code-block:: php
493+
494+
<?php
495+
496+
use Migrations\BaseSeed;
497+
498+
class ExchangeRateSeed extends BaseSeed
499+
{
500+
public function run(): void
501+
{
502+
$data = [
503+
['code' => 'USD', 'rate' => 1.0000],
504+
['code' => 'EUR', 'rate' => 0.9234],
505+
];
506+
507+
$this->table('exchange_rates')
508+
->insertOrUpdate($data, ['rate'], ['code'])
509+
->saveData();
510+
}
511+
}
512+
513+
The method takes three arguments:
514+
515+
- ``$data``: The rows to insert (same format as ``insert()``)
516+
- ``$updateColumns``: Which columns to update when a conflict occurs
517+
- ``$conflictColumns``: Which columns define uniqueness (must have a unique index)
518+
519+
.. warning::
520+
521+
Database-specific behavior differences:
522+
523+
**MySQL**: Uses ``ON DUPLICATE KEY UPDATE``. The ``$conflictColumns`` parameter
524+
is ignored because MySQL automatically applies the update to *all* unique
525+
constraint violations on the table. Passing ``$conflictColumns`` will trigger
526+
a warning. If your table has multiple unique constraints, be aware that a
527+
conflict on *any* of them will trigger the update.
528+
529+
**PostgreSQL/SQLite**: Uses ``ON CONFLICT (...) DO UPDATE SET``. The
530+
``$conflictColumns`` parameter is required and specifies exactly which unique
531+
constraint should trigger the update. A ``RuntimeException`` will be thrown
532+
if this parameter is empty.
533+
534+
**SQL Server**: Not currently supported. Use separate insert/update logic.
535+
453536
Truncating Tables
454537
=================
455538

src/Db/Adapter/AbstractAdapter.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,10 @@ protected function getInsertPrefix(?InsertMode $mode = null): string
730730
/**
731731
* Get the upsert clause for MySQL (ON DUPLICATE KEY UPDATE).
732732
*
733+
* MySQL's ON DUPLICATE KEY UPDATE applies to all unique key constraints on the table,
734+
* so the $conflictColumns parameter is not used. If you pass conflictColumns when using
735+
* MySQL, a warning will be triggered.
736+
*
733737
* @param \Migrations\Db\InsertMode|null $mode Insert mode
734738
* @param array<string>|null $updateColumns Columns to update on conflict
735739
* @param array<string>|null $conflictColumns Columns that define uniqueness (unused in MySQL)
@@ -741,6 +745,14 @@ protected function getUpsertClause(?InsertMode $mode, ?array $updateColumns, ?ar
741745
return '';
742746
}
743747

748+
if ($conflictColumns !== null) {
749+
trigger_error(
750+
'The $conflictColumns parameter is ignored by MySQL. ' .
751+
'MySQL\'s ON DUPLICATE KEY UPDATE applies to all unique constraints on the table.',
752+
E_USER_WARNING,
753+
);
754+
}
755+
744756
$updates = [];
745757
foreach ($updateColumns as $column) {
746758
$quotedColumn = $this->quoteColumnName($column);

src/Db/Adapter/PostgresAdapter.php

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1288,10 +1288,15 @@ public function bulkinsert(
12881288
/**
12891289
* Get the ON CONFLICT clause based on insert mode.
12901290
*
1291+
* PostgreSQL requires explicit conflict columns to determine which unique constraint
1292+
* should trigger the update. Unlike MySQL's ON DUPLICATE KEY UPDATE which applies
1293+
* to all unique constraints, PostgreSQL's ON CONFLICT clause must specify the columns.
1294+
*
12911295
* @param \Migrations\Db\InsertMode|null $mode Insert mode
12921296
* @param array<string>|null $updateColumns Columns to update on upsert conflict
1293-
* @param array<string>|null $conflictColumns Columns that define uniqueness for upsert
1297+
* @param array<string>|null $conflictColumns Columns that define uniqueness for upsert (required for PostgreSQL)
12941298
* @return string
1299+
* @throws \RuntimeException When using UPSERT mode without conflictColumns
12951300
*/
12961301
protected function getConflictClause(
12971302
?InsertMode $mode = null,
@@ -1302,7 +1307,13 @@ protected function getConflictClause(
13021307
return ' ON CONFLICT DO NOTHING';
13031308
}
13041309

1305-
if ($mode === InsertMode::UPSERT && $updateColumns !== null && $conflictColumns !== null) {
1310+
if ($mode === InsertMode::UPSERT) {
1311+
if ($conflictColumns === null || $conflictColumns === []) {
1312+
throw new RuntimeException(
1313+
'PostgreSQL requires the $conflictColumns parameter for insertOrUpdate(). ' .
1314+
'Specify the columns that have a unique constraint to determine conflict resolution.',
1315+
);
1316+
}
13061317
$quotedConflictColumns = array_map($this->quoteColumnName(...), $conflictColumns);
13071318
$updates = [];
13081319
foreach ($updateColumns as $column) {

src/Db/Adapter/SqliteAdapter.php

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1713,17 +1713,29 @@ protected function getInsertPrefix(?InsertMode $mode = null): string
17131713
/**
17141714
* Get the upsert clause for SQLite (ON CONFLICT ... DO UPDATE SET).
17151715
*
1716+
* SQLite requires explicit conflict columns to determine which unique constraint
1717+
* should trigger the update. Unlike MySQL's ON DUPLICATE KEY UPDATE which applies
1718+
* to all unique constraints, SQLite's ON CONFLICT clause must specify the columns.
1719+
*
17161720
* @param \Migrations\Db\InsertMode|null $mode Insert mode
17171721
* @param array<string>|null $updateColumns Columns to update on conflict
1718-
* @param array<string>|null $conflictColumns Columns that define uniqueness for upsert
1722+
* @param array<string>|null $conflictColumns Columns that define uniqueness for upsert (required for SQLite)
17191723
* @return string
1724+
* @throws \RuntimeException When using UPSERT mode without conflictColumns
17201725
*/
17211726
protected function getUpsertClause(?InsertMode $mode, ?array $updateColumns, ?array $conflictColumns = null): string
17221727
{
1723-
if ($mode !== InsertMode::UPSERT || $updateColumns === null || $conflictColumns === null) {
1728+
if ($mode !== InsertMode::UPSERT || $updateColumns === null) {
17241729
return '';
17251730
}
17261731

1732+
if ($conflictColumns === null || $conflictColumns === []) {
1733+
throw new RuntimeException(
1734+
'SQLite requires the $conflictColumns parameter for insertOrUpdate(). ' .
1735+
'Specify the columns that have a unique constraint to determine conflict resolution.',
1736+
);
1737+
}
1738+
17271739
$quotedConflictColumns = array_map($this->quoteColumnName(...), $conflictColumns);
17281740
$updates = [];
17291741
foreach ($updateColumns as $column) {

src/Db/Table.php

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -806,8 +806,21 @@ public function insertOrSkip(array $data)
806806
* This method performs an "upsert" operation - inserting new rows and updating
807807
* existing rows that conflict on the specified unique columns.
808808
*
809-
* Example:
809+
* ### Database-specific behavior:
810+
*
811+
* - **MySQL**: Uses `ON DUPLICATE KEY UPDATE`. The `$conflictColumns` parameter is
812+
* ignored because MySQL automatically applies the update to all unique constraint
813+
* violations. Passing `$conflictColumns` will trigger a warning.
814+
*
815+
* - **PostgreSQL/SQLite**: Uses `ON CONFLICT (...) DO UPDATE SET`. The `$conflictColumns`
816+
* parameter is required and must specify the columns that have a unique constraint.
817+
* A RuntimeException will be thrown if this parameter is empty.
818+
*
819+
* - **SQL Server**: Not currently supported. Use separate insert/update logic.
820+
*
821+
* ### Example:
810822
* ```php
823+
* // Works on all supported databases
811824
* $table->insertOrUpdate([
812825
* ['code' => 'USD', 'rate' => 1.0000],
813826
* ['code' => 'EUR', 'rate' => 0.9234],
@@ -816,8 +829,10 @@ public function insertOrSkip(array $data)
816829
*
817830
* @param array $data array of data in the same format as insert()
818831
* @param array<string> $updateColumns Columns to update when a conflict occurs
819-
* @param array<string> $conflictColumns Columns that define uniqueness (must have unique index)
832+
* @param array<string> $conflictColumns Columns that define uniqueness. Required for PostgreSQL/SQLite,
833+
* ignored by MySQL (triggers warning if provided).
820834
* @return $this
835+
* @throws \RuntimeException When using PostgreSQL or SQLite without specifying conflictColumns
821836
*/
822837
public function insertOrUpdate(array $data, array $updateColumns, array $conflictColumns)
823838
{

src/SeedInterface.php

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,14 +161,25 @@ public function insertOrSkip(string $tableName, array $data): void;
161161
* This method performs an "upsert" operation - inserting new rows and updating
162162
* existing rows that conflict on the specified unique columns.
163163
*
164-
* Uses ON DUPLICATE KEY UPDATE (MySQL), or ON CONFLICT ... DO UPDATE SET
165-
* (PostgreSQL/SQLite).
164+
* ### Database-specific behavior:
165+
*
166+
* - **MySQL**: Uses `ON DUPLICATE KEY UPDATE`. The `$conflictColumns` parameter is
167+
* ignored because MySQL automatically applies the update to all unique constraint
168+
* violations. Passing `$conflictColumns` will trigger a warning.
169+
*
170+
* - **PostgreSQL/SQLite**: Uses `ON CONFLICT (...) DO UPDATE SET`. The `$conflictColumns`
171+
* parameter is required and must specify the columns that have a unique constraint.
172+
* A RuntimeException will be thrown if this parameter is empty.
173+
*
174+
* - **SQL Server**: Not currently supported. Use separate insert/update logic.
166175
*
167176
* @param string $tableName Table name
168177
* @param array $data Data
169178
* @param array<string> $updateColumns Columns to update when a conflict occurs
170-
* @param array<string> $conflictColumns Columns that define uniqueness (must have unique index)
179+
* @param array<string> $conflictColumns Columns that define uniqueness. Required for PostgreSQL/SQLite,
180+
* ignored by MySQL (triggers warning if provided).
171181
* @return void
182+
* @throws \RuntimeException When using PostgreSQL or SQLite without specifying conflictColumns
172183
*/
173184
public function insertOrUpdate(string $tableName, array $data, array $updateColumns, array $conflictColumns): void;
174185

tests/TestCase/Db/Adapter/PostgresAdapterTest.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
use PHPUnit\Framework\Attributes\DataProvider;
2424
use PHPUnit\Framework\Attributes\Depends;
2525
use PHPUnit\Framework\TestCase;
26+
use RuntimeException;
2627

2728
class PostgresAdapterTest extends TestCase
2829
{
@@ -2975,6 +2976,22 @@ public function testInsertOrUpdateModeResetsAfterSave()
29752976
])->save();
29762977
}
29772978

2979+
public function testInsertOrUpdateRequiresConflictColumns()
2980+
{
2981+
$table = new Table('currencies', [], $this->adapter);
2982+
$table->addColumn('code', 'string', ['limit' => 3])
2983+
->addColumn('rate', 'decimal', ['precision' => 10, 'scale' => 4])
2984+
->addIndex('code', ['unique' => true])
2985+
->create();
2986+
2987+
// PostgreSQL requires conflictColumns for insertOrUpdate
2988+
$this->expectException(RuntimeException::class);
2989+
$this->expectExceptionMessage('PostgreSQL requires the $conflictColumns parameter');
2990+
$table->insertOrUpdate([
2991+
['code' => 'USD', 'rate' => 1.0000],
2992+
], ['rate'], [])->save();
2993+
}
2994+
29782995
public function testAddSinglePartitionToExistingTable()
29792996
{
29802997
// Create a partitioned table with room to add more partitions

tests/TestCase/Db/Adapter/SqliteAdapterTest.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3377,4 +3377,20 @@ public function testInsertOrUpdateModeResetsAfterSave()
33773377
['code' => 'ITEM1', 'name' => 'Different Name'],
33783378
])->save();
33793379
}
3380+
3381+
public function testInsertOrUpdateRequiresConflictColumns()
3382+
{
3383+
$table = new Table('currencies', [], $this->adapter);
3384+
$table->addColumn('code', 'string', ['limit' => 3])
3385+
->addColumn('rate', 'decimal', ['precision' => 10, 'scale' => 4])
3386+
->addIndex('code', ['unique' => true])
3387+
->create();
3388+
3389+
// SQLite requires conflictColumns for insertOrUpdate
3390+
$this->expectException(RuntimeException::class);
3391+
$this->expectExceptionMessage('SQLite requires the $conflictColumns parameter');
3392+
$table->insertOrUpdate([
3393+
['code' => 'USD', 'rate' => 1.0000],
3394+
], ['rate'], [])->save();
3395+
}
33803396
}

0 commit comments

Comments
 (0)