Skip to content

Commit b64e00e

Browse files
Add insertOrUpdate() for upsert operations (#973)
Implements upsert functionality that inserts new rows and updates existing rows on duplicate key conflicts. Database support: - MySQL: ON DUPLICATE KEY UPDATE - PostgreSQL: ON CONFLICT (...) DO UPDATE SET - SQLite: ON CONFLICT (...) DO UPDATE SET Usage: $table->insertOrUpdate($data, $updateColumns, $conflictColumns); $this->insertOrUpdate($tableName, $data, $updateColumns, $conflictColumns); Refs #950 * Remove redundant comment --------- Co-authored-by: Mark Story <mark@mark-story.com>
1 parent 4fad77c commit b64e00e

14 files changed

+516
-44
lines changed

src/BaseSeed.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,15 @@ public function insertOrSkip(string $tableName, array $data): void
191191
$table->insertOrSkip($data)->save();
192192
}
193193

194+
/**
195+
* {@inheritDoc}
196+
*/
197+
public function insertOrUpdate(string $tableName, array $data, array $updateColumns, array $conflictColumns): void
198+
{
199+
$table = new Table($tableName, [], $this->getAdapter());
200+
$table->insertOrUpdate($data, $updateColumns, $conflictColumns)->save();
201+
}
202+
194203
/**
195204
* {@inheritDoc}
196205
*/

src/Db/Adapter/AbstractAdapter.php

Lines changed: 65 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -633,9 +633,14 @@ public function fetchAll(string $sql): array
633633
/**
634634
* @inheritDoc
635635
*/
636-
public function insert(TableMetadata $table, array $row, ?InsertMode $mode = null): void
637-
{
638-
$sql = $this->generateInsertSql($table, $row, $mode);
636+
public function insert(
637+
TableMetadata $table,
638+
array $row,
639+
?InsertMode $mode = null,
640+
?array $updateColumns = null,
641+
?array $conflictColumns = null,
642+
): void {
643+
$sql = $this->generateInsertSql($table, $row, $mode, $updateColumns, $conflictColumns);
639644

640645
if ($this->isDryRunEnabled()) {
641646
$this->io->out($sql);
@@ -660,10 +665,17 @@ public function insert(TableMetadata $table, array $row, ?InsertMode $mode = nul
660665
* @param \Migrations\Db\Table\TableMetadata $table The table to insert into
661666
* @param array $row The row to insert
662667
* @param \Migrations\Db\InsertMode|null $mode Insert mode
668+
* @param array<string>|null $updateColumns Columns to update on upsert conflict
669+
* @param array<string>|null $conflictColumns Columns that define uniqueness for upsert (unused in MySQL)
663670
* @return string
664671
*/
665-
protected function generateInsertSql(TableMetadata $table, array $row, ?InsertMode $mode = null): string
666-
{
672+
protected function generateInsertSql(
673+
TableMetadata $table,
674+
array $row,
675+
?InsertMode $mode = null,
676+
?array $updateColumns = null,
677+
?array $conflictColumns = null,
678+
): string {
667679
$sql = sprintf(
668680
'%s INTO %s ',
669681
$this->getInsertPrefix($mode),
@@ -678,8 +690,10 @@ protected function generateInsertSql(TableMetadata $table, array $row, ?InsertMo
678690
}
679691
}
680692

693+
$upsertClause = $this->getUpsertClause($mode, $updateColumns, $conflictColumns);
694+
681695
if ($this->isDryRunEnabled()) {
682-
$sql .= ' VALUES (' . implode(', ', array_map($this->quoteValue(...), $row)) . ');';
696+
$sql .= ' VALUES (' . implode(', ', array_map($this->quoteValue(...), $row)) . ')' . $upsertClause . ';';
683697

684698
return $sql;
685699
} else {
@@ -691,7 +705,7 @@ protected function generateInsertSql(TableMetadata $table, array $row, ?InsertMo
691705
}
692706
$values[] = $placeholder;
693707
}
694-
$sql .= ' VALUES (' . implode(',', $values) . ')';
708+
$sql .= ' VALUES (' . implode(',', $values) . ')' . $upsertClause;
695709

696710
return $sql;
697711
}
@@ -712,6 +726,29 @@ protected function getInsertPrefix(?InsertMode $mode = null): string
712726
return 'INSERT';
713727
}
714728

729+
/**
730+
* Get the upsert clause for MySQL (ON DUPLICATE KEY UPDATE).
731+
*
732+
* @param \Migrations\Db\InsertMode|null $mode Insert mode
733+
* @param array<string>|null $updateColumns Columns to update on conflict
734+
* @param array<string>|null $conflictColumns Columns that define uniqueness (unused in MySQL)
735+
* @return string
736+
*/
737+
protected function getUpsertClause(?InsertMode $mode, ?array $updateColumns, ?array $conflictColumns = null): string
738+
{
739+
if ($mode !== InsertMode::UPSERT || $updateColumns === null) {
740+
return '';
741+
}
742+
743+
$updates = [];
744+
foreach ($updateColumns as $column) {
745+
$quotedColumn = $this->quoteColumnName($column);
746+
$updates[] = $quotedColumn . ' = VALUES(' . $quotedColumn . ')';
747+
}
748+
749+
return ' ON DUPLICATE KEY UPDATE ' . implode(', ', $updates);
750+
}
751+
715752
/**
716753
* Quotes a database value.
717754
*
@@ -759,9 +796,14 @@ protected function quoteString(string $value): string
759796
/**
760797
* @inheritDoc
761798
*/
762-
public function bulkinsert(TableMetadata $table, array $rows, ?InsertMode $mode = null): void
763-
{
764-
$sql = $this->generateBulkInsertSql($table, $rows, $mode);
799+
public function bulkinsert(
800+
TableMetadata $table,
801+
array $rows,
802+
?InsertMode $mode = null,
803+
?array $updateColumns = null,
804+
?array $conflictColumns = null,
805+
): void {
806+
$sql = $this->generateBulkInsertSql($table, $rows, $mode, $updateColumns, $conflictColumns);
765807

766808
if ($this->isDryRunEnabled()) {
767809
$this->io->out($sql);
@@ -796,10 +838,17 @@ public function bulkinsert(TableMetadata $table, array $rows, ?InsertMode $mode
796838
* @param \Migrations\Db\Table\TableMetadata $table The table to insert into
797839
* @param array $rows The rows to insert
798840
* @param \Migrations\Db\InsertMode|null $mode Insert mode
841+
* @param array<string>|null $updateColumns Columns to update on upsert conflict
842+
* @param array<string>|null $conflictColumns Columns that define uniqueness for upsert (unused in MySQL)
799843
* @return string
800844
*/
801-
protected function generateBulkInsertSql(TableMetadata $table, array $rows, ?InsertMode $mode = null): string
802-
{
845+
protected function generateBulkInsertSql(
846+
TableMetadata $table,
847+
array $rows,
848+
?InsertMode $mode = null,
849+
?array $updateColumns = null,
850+
?array $conflictColumns = null,
851+
): string {
803852
$sql = sprintf(
804853
'%s INTO %s ',
805854
$this->getInsertPrefix($mode),
@@ -810,11 +859,13 @@ protected function generateBulkInsertSql(TableMetadata $table, array $rows, ?Ins
810859

811860
$sql .= '(' . implode(', ', array_map($this->quoteColumnName(...), $keys)) . ') VALUES ';
812861

862+
$upsertClause = $this->getUpsertClause($mode, $updateColumns, $conflictColumns);
863+
813864
if ($this->isDryRunEnabled()) {
814865
$values = array_map(function ($row) {
815866
return '(' . implode(', ', array_map($this->quoteValue(...), $row)) . ')';
816867
}, $rows);
817-
$sql .= implode(', ', $values) . ';';
868+
$sql .= implode(', ', $values) . $upsertClause . ';';
818869

819870
return $sql;
820871
} else {
@@ -831,7 +882,7 @@ protected function generateBulkInsertSql(TableMetadata $table, array $rows, ?Ins
831882
$query = '(' . implode(', ', $values) . ')';
832883
$queries[] = $query;
833884
}
834-
$sql .= implode(',', $queries);
885+
$sql .= implode(',', $queries) . $upsertClause;
835886

836887
return $sql;
837888
}

src/Db/Adapter/AdapterInterface.php

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -480,19 +480,35 @@ public function fetchAll(string $sql): array;
480480
* @param \Migrations\Db\Table\TableMetadata $table Table where to insert data
481481
* @param array $row Row
482482
* @param \Migrations\Db\InsertMode|null $mode Insert mode
483+
* @param array<string>|null $updateColumns Columns to update on upsert conflict
484+
* @param array<string>|null $conflictColumns Columns that define uniqueness for upsert
483485
* @return void
484486
*/
485-
public function insert(TableMetadata $table, array $row, ?InsertMode $mode = null): void;
487+
public function insert(
488+
TableMetadata $table,
489+
array $row,
490+
?InsertMode $mode = null,
491+
?array $updateColumns = null,
492+
?array $conflictColumns = null,
493+
): void;
486494

487495
/**
488496
* Inserts data into a table in a bulk.
489497
*
490498
* @param \Migrations\Db\Table\TableMetadata $table Table where to insert data
491499
* @param array $rows Rows
492500
* @param \Migrations\Db\InsertMode|null $mode Insert mode
501+
* @param array<string>|null $updateColumns Columns to update on upsert conflict
502+
* @param array<string>|null $conflictColumns Columns that define uniqueness for upsert
493503
* @return void
494504
*/
495-
public function bulkinsert(TableMetadata $table, array $rows, ?InsertMode $mode = null): void;
505+
public function bulkinsert(
506+
TableMetadata $table,
507+
array $rows,
508+
?InsertMode $mode = null,
509+
?array $updateColumns = null,
510+
?array $conflictColumns = null,
511+
): void;
496512

497513
/**
498514
* Quotes a table name for use in a query.

src/Db/Adapter/AdapterWrapper.php

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -138,17 +138,27 @@ public function query(string $sql, array $params = []): mixed
138138
/**
139139
* @inheritDoc
140140
*/
141-
public function insert(TableMetadata $table, array $row, ?InsertMode $mode = null): void
142-
{
143-
$this->getAdapter()->insert($table, $row, $mode);
141+
public function insert(
142+
TableMetadata $table,
143+
array $row,
144+
?InsertMode $mode = null,
145+
?array $updateColumns = null,
146+
?array $conflictColumns = null,
147+
): void {
148+
$this->getAdapter()->insert($table, $row, $mode, $updateColumns, $conflictColumns);
144149
}
145150

146151
/**
147152
* @inheritDoc
148153
*/
149-
public function bulkinsert(TableMetadata $table, array $rows, ?InsertMode $mode = null): void
150-
{
151-
$this->getAdapter()->bulkinsert($table, $rows, $mode);
154+
public function bulkinsert(
155+
TableMetadata $table,
156+
array $rows,
157+
?InsertMode $mode = null,
158+
?array $updateColumns = null,
159+
?array $conflictColumns = null,
160+
): void {
161+
$this->getAdapter()->bulkinsert($table, $rows, $mode, $updateColumns, $conflictColumns);
152162
}
153163

154164
/**

src/Db/Adapter/PostgresAdapter.php

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1173,8 +1173,13 @@ public function setSearchPath(): void
11731173
/**
11741174
* @inheritDoc
11751175
*/
1176-
public function insert(TableMetadata $table, array $row, ?InsertMode $mode = null): void
1177-
{
1176+
public function insert(
1177+
TableMetadata $table,
1178+
array $row,
1179+
?InsertMode $mode = null,
1180+
?array $updateColumns = null,
1181+
?array $conflictColumns = null,
1182+
): void {
11781183
$sql = sprintf(
11791184
'INSERT INTO %s ',
11801185
$this->quoteTableName($table->getName()),
@@ -1193,7 +1198,7 @@ public function insert(TableMetadata $table, array $row, ?InsertMode $mode = nul
11931198
$override = self::OVERRIDE_SYSTEM_VALUE . ' ';
11941199
}
11951200

1196-
$conflictClause = $this->getConflictClause($mode);
1201+
$conflictClause = $this->getConflictClause($mode, $updateColumns, $conflictColumns);
11971202

11981203
if ($this->isDryRunEnabled()) {
11991204
$sql .= ' ' . $override . 'VALUES (' . implode(', ', array_map($this->quoteValue(...), $row)) . ')' . $conflictClause . ';';
@@ -1219,8 +1224,13 @@ public function insert(TableMetadata $table, array $row, ?InsertMode $mode = nul
12191224
/**
12201225
* @inheritDoc
12211226
*/
1222-
public function bulkinsert(TableMetadata $table, array $rows, ?InsertMode $mode = null): void
1223-
{
1227+
public function bulkinsert(
1228+
TableMetadata $table,
1229+
array $rows,
1230+
?InsertMode $mode = null,
1231+
?array $updateColumns = null,
1232+
?array $conflictColumns = null,
1233+
): void {
12241234
$sql = sprintf(
12251235
'INSERT INTO %s ',
12261236
$this->quoteTableName($table->getName()),
@@ -1236,7 +1246,7 @@ public function bulkinsert(TableMetadata $table, array $rows, ?InsertMode $mode
12361246

12371247
$sql .= '(' . implode(', ', array_map($this->quoteColumnName(...), $keys)) . ') ' . $override . 'VALUES ';
12381248

1239-
$conflictClause = $this->getConflictClause($mode);
1249+
$conflictClause = $this->getConflictClause($mode, $updateColumns, $conflictColumns);
12401250

12411251
if ($this->isDryRunEnabled()) {
12421252
$values = array_map(function ($row) {
@@ -1279,14 +1289,30 @@ public function bulkinsert(TableMetadata $table, array $rows, ?InsertMode $mode
12791289
* Get the ON CONFLICT clause based on insert mode.
12801290
*
12811291
* @param \Migrations\Db\InsertMode|null $mode Insert mode
1292+
* @param array<string>|null $updateColumns Columns to update on upsert conflict
1293+
* @param array<string>|null $conflictColumns Columns that define uniqueness for upsert
12821294
* @return string
12831295
*/
1284-
protected function getConflictClause(?InsertMode $mode = null): string
1285-
{
1296+
protected function getConflictClause(
1297+
?InsertMode $mode = null,
1298+
?array $updateColumns = null,
1299+
?array $conflictColumns = null,
1300+
): string {
12861301
if ($mode === InsertMode::IGNORE) {
12871302
return ' ON CONFLICT DO NOTHING';
12881303
}
12891304

1305+
if ($mode === InsertMode::UPSERT && $updateColumns !== null && $conflictColumns !== null) {
1306+
$quotedConflictColumns = array_map($this->quoteColumnName(...), $conflictColumns);
1307+
$updates = [];
1308+
foreach ($updateColumns as $column) {
1309+
$quotedColumn = $this->quoteColumnName($column);
1310+
$updates[] = $quotedColumn . ' = EXCLUDED.' . $quotedColumn;
1311+
}
1312+
1313+
return ' ON CONFLICT (' . implode(', ', $quotedConflictColumns) . ') DO UPDATE SET ' . implode(', ', $updates);
1314+
}
1315+
12901316
return '';
12911317
}
12921318

src/Db/Adapter/SqliteAdapter.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1709,4 +1709,28 @@ protected function getInsertPrefix(?InsertMode $mode = null): string
17091709

17101710
return 'INSERT';
17111711
}
1712+
1713+
/**
1714+
* Get the upsert clause for SQLite (ON CONFLICT ... DO UPDATE SET).
1715+
*
1716+
* @param \Migrations\Db\InsertMode|null $mode Insert mode
1717+
* @param array<string>|null $updateColumns Columns to update on conflict
1718+
* @param array<string>|null $conflictColumns Columns that define uniqueness for upsert
1719+
* @return string
1720+
*/
1721+
protected function getUpsertClause(?InsertMode $mode, ?array $updateColumns, ?array $conflictColumns = null): string
1722+
{
1723+
if ($mode !== InsertMode::UPSERT || $updateColumns === null || $conflictColumns === null) {
1724+
return '';
1725+
}
1726+
1727+
$quotedConflictColumns = array_map($this->quoteColumnName(...), $conflictColumns);
1728+
$updates = [];
1729+
foreach ($updateColumns as $column) {
1730+
$quotedColumn = $this->quoteColumnName($column);
1731+
$updates[] = $quotedColumn . ' = excluded.' . $quotedColumn;
1732+
}
1733+
1734+
return ' ON CONFLICT (' . implode(', ', $quotedConflictColumns) . ') DO UPDATE SET ' . implode(', ', $updates);
1735+
}
17121736
}

src/Db/Adapter/SqlserverAdapter.php

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1011,9 +1011,14 @@ public function migrated(MigrationInterface $migration, string $direction, strin
10111011
/**
10121012
* @inheritDoc
10131013
*/
1014-
public function insert(TableMetadata $table, array $row, ?InsertMode $mode = null): void
1015-
{
1016-
$sql = $this->generateInsertSql($table, $row, $mode);
1014+
public function insert(
1015+
TableMetadata $table,
1016+
array $row,
1017+
?InsertMode $mode = null,
1018+
?array $updateColumns = null,
1019+
?array $conflictColumns = null,
1020+
): void {
1021+
$sql = $this->generateInsertSql($table, $row, $mode, $updateColumns, $conflictColumns);
10171022

10181023
$sql = $this->updateSQLForIdentityInsert($table->getName(), $sql);
10191024

@@ -1037,9 +1042,14 @@ public function insert(TableMetadata $table, array $row, ?InsertMode $mode = nul
10371042
/**
10381043
* @inheritDoc
10391044
*/
1040-
public function bulkinsert(TableMetadata $table, array $rows, ?InsertMode $mode = null): void
1041-
{
1042-
$sql = $this->generateBulkInsertSql($table, $rows, $mode);
1045+
public function bulkinsert(
1046+
TableMetadata $table,
1047+
array $rows,
1048+
?InsertMode $mode = null,
1049+
?array $updateColumns = null,
1050+
?array $conflictColumns = null,
1051+
): void {
1052+
$sql = $this->generateBulkInsertSql($table, $rows, $mode, $updateColumns, $conflictColumns);
10431053

10441054
$sql = $this->updateSQLForIdentityInsert($table->getName(), $sql);
10451055

0 commit comments

Comments
 (0)