Skip to content

Commit cf24802

Browse files
committed
Realize ActiveRecord::upsert() method
1 parent 4d39f1f commit cf24802

File tree

9 files changed

+274
-20
lines changed

9 files changed

+274
-20
lines changed

src/AbstractActiveRecord.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,16 @@ abstract protected function insertInternal(array|null $properties = null): bool;
8888
*/
8989
abstract protected function populateProperty(string $name, mixed $value): void;
9090

91+
/**
92+
* Internal method to insert or update a record in the database.
93+
*
94+
* @see upsert()
95+
*/
96+
abstract protected function upsertInternal(
97+
array|null $insertProperties = null,
98+
array|bool $updateValues = true,
99+
): bool;
100+
91101
public function delete(): int
92102
{
93103
return $this->deleteInternal();
@@ -799,6 +809,11 @@ public function updateCounters(array $counters): bool
799809
return true;
800810
}
801811

812+
public function upsert(array|null $insertProperties = null, array|bool $updateValues = true): bool
813+
{
814+
return $this->upsertInternal($insertProperties, $updateValues);
815+
}
816+
802817
public function unlink(string $relationName, ActiveRecordInterface $arClass, bool $delete = false): void
803818
{
804819
$viaClass = null;

src/ActiveRecord.php

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
use Yiisoft\Db\Exception\InvalidConfigException;
1111
use Yiisoft\Db\Schema\TableSchemaInterface;
1212

13+
use function array_intersect_key;
14+
use function array_keys;
15+
use function array_merge;
1316
use function get_object_vars;
1417

1518
/**
@@ -134,14 +137,9 @@ public function loadDefaultValues(bool $skipIfSet = true): static
134137
public function populateRecord(array|object $row): void
135138
{
136139
$row = ArArrayHelper::toArray($row);
137-
$columns = $this->tableSchema()->getColumns();
138-
$rowColumns = array_intersect_key($row, $columns);
139-
140-
foreach ($rowColumns as $name => &$value) {
141-
$value = $columns[$name]->phpTypecast($value);
142-
}
140+
$row = $this->phpTypecastValues($row);
143141

144-
parent::populateRecord($rowColumns + $row);
142+
parent::populateRecord($row);
145143
}
146144

147145
public function primaryKey(): array
@@ -163,25 +161,61 @@ protected function insertInternal(array|null $properties = null): bool
163161
$values = $this->newPropertyValues($properties);
164162
$primaryKeys = $this->db()->createCommand()->insertWithReturningPks($this->tableName(), $values);
165163

166-
if ($primaryKeys === false) {
164+
return $this->populateRawValues($primaryKeys, $values);
165+
}
166+
167+
protected function upsertInternal(array|null $insertProperties = null, array|bool $updateValues = true): bool
168+
{
169+
if (!$this->isNewRecord()) {
170+
throw new InvalidCallException('The record is not new and cannot be inserted.');
171+
}
172+
173+
$values = $this->newPropertyValues($insertProperties);
174+
$returnProperties = $insertProperties !== null ? array_merge($this->primaryKey(), array_keys($values)) : null;
175+
176+
$returnedValues = $this->db()->createCommand()
177+
->upsertReturning($this->tableName(), $values, $updateValues, $returnProperties);
178+
179+
return $this->populateRawValues($returnedValues);
180+
}
181+
182+
protected function populateProperty(string $name, mixed $value): void
183+
{
184+
$this->$name = $value;
185+
}
186+
187+
private function populateRawValues(array|false $rawValues, array $oldValues = []): bool
188+
{
189+
if ($rawValues === false) {
167190
return false;
168191
}
169192

170-
$columns = $this->tableSchema()->getColumns();
193+
$values = $this->phpTypecastValues($rawValues);
194+
195+
foreach ($values as $name => $value) {
196+
$this->set($name, $value);
197+
}
171198

172-
foreach ($primaryKeys as $name => $value) {
173-
$id = $columns[$name]->phpTypecast($value);
174-
$this->set($name, $id);
175-
$values[$name] = $id;
199+
if (empty($oldValues)) {
200+
$oldValues = $values;
201+
} else {
202+
$oldValues = array_merge($oldValues, $values);
176203
}
177204

178-
$this->assignOldValues($values);
205+
$this->assignOldValues($oldValues);
179206

180207
return true;
181208
}
182209

183-
protected function populateProperty(string $name, mixed $value): void
210+
private function phpTypecastValues(array $values): array
184211
{
185-
$this->$name = $value;
212+
$columns = $this->tableSchema()->getColumns();
213+
$columnValues = array_intersect_key($values, $columns);
214+
215+
foreach ($columnValues as $name => $value) {
216+
$values[$name] = $columns[$name]->phpTypecast($value);
217+
}
218+
219+
return $values;
186220
}
187221
}

src/ActiveRecordInterface.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,37 @@ public function update(array|null $properties = null): int;
492492
*/
493493
public function updateAll(array $propertyValues, array|string $condition = [], array $params = []): int;
494494

495+
/**
496+
* Insert a row into the associated database table if the record doesn't already exist (matching unique constraints)
497+
* or update the record if it exists, with populating model by the returning record values.
498+
*
499+
* Only the {@see newValues() changed property values} will be inserted or updated.
500+
*
501+
* If the table's primary key is auto incremental and is `null` during execution, it will be populated with the
502+
* actual value after insertion or update.
503+
*
504+
* For example, to upsert a customer record:
505+
*
506+
* ```php
507+
* $customer = new Customer();
508+
* $customer->name = $name;
509+
* $customer->email = $email; // unique property
510+
* $customer->upsert();
511+
* ```
512+
*
513+
* @param array|null $insertProperties List of property names or name-values pairs that need to be inserted.
514+
* Defaults to `null`, meaning all changed property values will be inserted.
515+
* @param array|bool $updateValues The property values (name => value) to update if the record already exists.
516+
* If `true` is passed, the record values will be updated to match the insert property values.
517+
* If `false` is passed, no update will be performed if the record already exist.
518+
*
519+
* @throws InvalidConfigException
520+
* @throws Throwable In case query failed.
521+
*
522+
* @return bool Whether the record is inserted or updated successfully.
523+
*/
524+
public function upsert(array|null $insertProperties = null, array|bool $updateValues = true): bool;
525+
495526
/**
496527
* Destroys the relationship between two records.
497528
*

tests/ActiveRecordTest.php

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use ArgumentCountError;
88
use DivisionByZeroError;
9+
use PHPUnit\Framework\Attributes\DataProvider;
910
use Yiisoft\ActiveRecord\ActiveQuery;
1011
use Yiisoft\ActiveRecord\ArArrayHelper;
1112
use Yiisoft\ActiveRecord\ConnectionProvider;
@@ -1170,4 +1171,159 @@ public function testIsChanged(): void
11701171

11711172
$this->assertTrue((new Customer())->isChanged());
11721173
}
1174+
1175+
public static function dataUpsert(): array
1176+
{
1177+
return [
1178+
'insert values' => [
1179+
'values' => [
1180+
'email' => '[email protected]',
1181+
'name' => 'user4',
1182+
'address' => 'address4',
1183+
],
1184+
'insertProperties' => null,
1185+
'updateValues' => false,
1186+
'expected' => [
1187+
'id' => 4,
1188+
'email' => '[email protected]',
1189+
'name' => 'user4',
1190+
'address' => 'address4',
1191+
],
1192+
],
1193+
'insert part values' => [
1194+
'values' => [
1195+
'email' => '[email protected]',
1196+
'name' => 'user4',
1197+
'address' => 'address4',
1198+
],
1199+
'insertProperties' => [
1200+
'email',
1201+
'name',
1202+
],
1203+
'updateValues' => false,
1204+
'expected' => [
1205+
'id' => 4,
1206+
'email' => '[email protected]',
1207+
'name' => 'user4',
1208+
'address' => 'address4',
1209+
],
1210+
'expectedAfterRefresh' => [
1211+
'id' => 4,
1212+
'email' => '[email protected]',
1213+
'name' => 'user4',
1214+
'address' => null,
1215+
],
1216+
],
1217+
'insert from insertProperties' => [
1218+
'values' => [
1219+
'email' => '[email protected]',
1220+
'address' => 'address4',
1221+
],
1222+
'insertProperties' => [
1223+
'email',
1224+
'name' => 'user4',
1225+
],
1226+
'updateValues' => false,
1227+
'expected' => [
1228+
'id' => 4,
1229+
'email' => '[email protected]',
1230+
'name' => 'user4',
1231+
'address' => 'address4',
1232+
],
1233+
'expectedAfterRefresh' => [
1234+
'id' => 4,
1235+
'email' => '[email protected]',
1236+
'name' => 'user4',
1237+
'address' => null,
1238+
],
1239+
],
1240+
'without changes' => [
1241+
'values' => [
1242+
'email' => '[email protected]',
1243+
'name' => 'new name',
1244+
],
1245+
'insertProperties' => null,
1246+
'updateValues' => false,
1247+
'expected' => [
1248+
'id' => 3,
1249+
'email' => '[email protected]',
1250+
'name' => 'user3',
1251+
'address' => 'address3',
1252+
],
1253+
],
1254+
'update from values' => [
1255+
'values' => [
1256+
'email' => '[email protected]',
1257+
'name' => 'new name',
1258+
'address' => 'new address',
1259+
],
1260+
'insertProperties' => null,
1261+
'updateValues' => true,
1262+
'expected' => [
1263+
'id' => 3,
1264+
'email' => '[email protected]',
1265+
'name' => 'new name',
1266+
'address' => 'new address',
1267+
],
1268+
],
1269+
'update from updateValues' => [
1270+
'values' => [
1271+
'email' => '[email protected]',
1272+
'address' => 'new address',
1273+
],
1274+
'insertProperties' => null,
1275+
'updateValues' => [
1276+
'name' => 'another name',
1277+
'address' => 'another address'
1278+
],
1279+
'expected' => [
1280+
'id' => 3,
1281+
'email' => '[email protected]',
1282+
'name' => 'another name',
1283+
'address' => 'another address',
1284+
],
1285+
],
1286+
];
1287+
}
1288+
1289+
#[DataProvider('dataUpsert')]
1290+
public function testUpsert(
1291+
array $values,
1292+
array|null $insertProperties,
1293+
array|bool $updateValues,
1294+
array $expected,
1295+
array|null $expectedAfterRefresh = null,
1296+
): void {
1297+
$this->reloadFixtureAfterTest();
1298+
1299+
$customer = new Customer();
1300+
1301+
foreach ($values as $property => $value) {
1302+
$customer->set($property, $value);
1303+
}
1304+
1305+
$this->assertTrue($customer->upsert($insertProperties, $updateValues));
1306+
1307+
$this->assertFalse($customer->isNewRecord());
1308+
1309+
foreach ($expected as $property => $value) {
1310+
$this->assertSame($value, $customer->get($property));
1311+
}
1312+
1313+
$customer->refresh();
1314+
1315+
foreach ($expectedAfterRefresh ?? $expected as $property => $value) {
1316+
$this->assertSame($value, $customer->get($property));
1317+
}
1318+
}
1319+
1320+
public function testUpsertWithException(): void
1321+
{
1322+
$customer = Customer::findByPk(1);
1323+
1324+
$this->expectException(InvalidCallException::class);
1325+
$this->expectExceptionMessage('The record is not new and cannot be inserted.');
1326+
1327+
$customer->upsert();
1328+
}
11731329
}

tests/Driver/Oracle/ActiveRecordTest.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44

55
namespace Yiisoft\ActiveRecord\Tests\Driver\Oracle;
66

7+
use PHPUnit\Framework\Attributes\TestWith;
78
use Yiisoft\ActiveRecord\ActiveQuery;
89
use Yiisoft\ActiveRecord\Tests\Driver\Oracle\Stubs\Customer;
910
use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Type;
1011
use Yiisoft\ActiveRecord\Tests\Support\OracleHelper;
1112
use Yiisoft\Db\Connection\ConnectionInterface;
13+
use Yiisoft\Db\Exception\NotSupportedException;
1214
use Yiisoft\Factory\Factory;
1315

1416
final class ActiveRecordTest extends \Yiisoft\ActiveRecord\Tests\ActiveRecordTest
@@ -81,4 +83,20 @@ public function testBooleanProperty(): void
8183
$customers = $customerQuery->where(['bool_status' => '0'])->all();
8284
$this->assertCount(2, $customers);
8385
}
86+
87+
#[TestWith([[], [], [], []])]
88+
public function testUpsert(
89+
array $values,
90+
array|null $insertProperties,
91+
array|bool $updateValues,
92+
array $expected,
93+
array|null $expectedAfterRefresh = null,
94+
): void {
95+
$customer = new Customer();
96+
97+
$this->expectException(NotSupportedException::class);
98+
$this->expectExceptionMessage('Yiisoft\Db\Oracle\DMLQueryBuilder::upsertReturning() is not supported by Oracle.');
99+
100+
$customer->upsert($insertProperties, $updateValues);
101+
}
84102
}

tests/data/mssql.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ CREATE TABLE [dbo].[profile] (
3838

3939
CREATE TABLE [dbo].[customer] (
4040
[id] [int] IDENTITY NOT NULL,
41-
[email] [varchar](128) NOT NULL,
41+
[email] [varchar](128) NOT NULL UNIQUE,
4242
[name] [varchar](128),
4343
[address] [text],
4444
[status] [int] DEFAULT 0,

tests/data/mysql.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ CREATE TABLE `profile` (
5050

5151
CREATE TABLE `customer` (
5252
`id` int(11) NOT NULL AUTO_INCREMENT,
53-
`email` varchar(128) NOT NULL,
53+
`email` varchar(128) NOT NULL UNIQUE,
5454
`name` varchar(128),
5555
`address` text,
5656
`status` int (11) DEFAULT 0,

tests/data/pgsql.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ CREATE TABLE "schema1"."profile" (
6161

6262
CREATE TABLE "customer" (
6363
id serial not null primary key,
64-
email varchar(128) NOT NULL,
64+
email varchar(128) NOT NULL UNIQUE,
6565
name varchar(128),
6666
address text,
6767
status integer DEFAULT 0,

0 commit comments

Comments
 (0)