Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions docs/en/seeding.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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

<?php

use Migrations\BaseSeed;

class CurrencySeed extends BaseSeed
{
public function run(): void
{
$data = [
['code' => '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

<?php

use Migrations\BaseSeed;

class ExchangeRateSeed extends BaseSeed
{
public function run(): void
{
$data = [
['code' => '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
=================

Expand Down
12 changes: 12 additions & 0 deletions src/Db/Adapter/AbstractAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>|null $updateColumns Columns to update on conflict
* @param array<string>|null $conflictColumns Columns that define uniqueness (unused in MySQL)
Expand All @@ -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);
Expand Down
15 changes: 13 additions & 2 deletions src/Db/Adapter/PostgresAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>|null $updateColumns Columns to update on upsert conflict
* @param array<string>|null $conflictColumns Columns that define uniqueness for upsert
* @param array<string>|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,
Expand All @@ -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) {
Expand Down
16 changes: 14 additions & 2 deletions src/Db/Adapter/SqliteAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>|null $updateColumns Columns to update on conflict
* @param array<string>|null $conflictColumns Columns that define uniqueness for upsert
* @param array<string>|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) {
Expand Down
19 changes: 17 additions & 2 deletions src/Db/Table.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +811 to +819
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should this also be in the docs? There are a lot of caveats to using this method.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added docs for insertOrSkip() and insertOrUpdate() in docs/en/seeding.rst with the database-specific caveats in a warning block.

*
* ### Example:
* ```php
* // Works on all supported databases
* $table->insertOrUpdate([
* ['code' => 'USD', 'rate' => 1.0000],
* ['code' => 'EUR', 'rate' => 0.9234],
Expand All @@ -816,8 +829,10 @@ public function insertOrSkip(array $data)
*
* @param array $data array of data in the same format as insert()
* @param array<string> $updateColumns Columns to update when a conflict occurs
* @param array<string> $conflictColumns Columns that define uniqueness (must have unique index)
* @param array<string> $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)
{
Expand Down
17 changes: 14 additions & 3 deletions src/SeedInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> $updateColumns Columns to update when a conflict occurs
* @param array<string> $conflictColumns Columns that define uniqueness (must have unique index)
* @param array<string> $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;

Expand Down
17 changes: 17 additions & 0 deletions tests/TestCase/Db/Adapter/PostgresAdapterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Depends;
use PHPUnit\Framework\TestCase;
use RuntimeException;

class PostgresAdapterTest extends TestCase
{
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions tests/TestCase/Db/Adapter/SqliteAdapterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}