From 884eb845864bffc7c848c850b92745324ddd4b0e Mon Sep 17 00:00:00 2001 From: Tigrov Date: Mon, 26 May 2025 13:27:09 +0700 Subject: [PATCH 1/4] Allow to specify property values when `insert()` or `save()` --- src/AbstractActiveRecord.php | 62 ++++++---- src/ActiveRecord.php | 9 +- src/ActiveRecordInterface.php | 44 +++++-- tests/ActiveRecordTest.php | 172 +++++++++++++++++++++++++- tests/Stubs/ActiveRecord/Customer.php | 4 +- 5 files changed, 248 insertions(+), 43 deletions(-) diff --git a/src/AbstractActiveRecord.php b/src/AbstractActiveRecord.php index 6a388122e..117dd627d 100644 --- a/src/AbstractActiveRecord.php +++ b/src/AbstractActiveRecord.php @@ -23,6 +23,7 @@ use function array_flip; use function array_intersect; use function array_intersect_key; +use function array_is_list; use function array_key_exists; use function array_keys; use function array_merge; @@ -67,8 +68,8 @@ abstract protected function propertyValuesInternal(): array; /** * Inserts Active Record values into DB without considering transaction. * - * @param array|null $propertyNames List of property names that need to be saved. Defaults to `null`, meaning all - * changed property values will be saved. Only changed values will be saved. + * @param array|null $properties List of property names or name-values pairs that need to be saved. + * Defaults to `null`, meaning all changed property values will be saved. * * @throws Exception * @throws InvalidArgumentException @@ -77,7 +78,7 @@ abstract protected function propertyValuesInternal(): array; * * @return bool Whether the record inserted successfully. */ - abstract protected function insertInternal(array|null $propertyNames = null): bool; + abstract protected function insertInternal(array|null $properties = null): bool; /** * Sets the value of the named property. @@ -347,9 +348,9 @@ public function hasOne(string|ActiveRecordInterface|Closure $class, array $link) return $this->createRelationQuery($class, $link, false); } - public function insert(array|null $propertyNames = null): bool + public function insert(array|null $properties = null): bool { - return $this->insertInternal($propertyNames); + return $this->insertInternal($properties); } /** @@ -619,13 +620,13 @@ protected function retrieveRelation(string $name): ActiveRecordInterface|array|n return $this->related[$name] = $query->relatedRecords(); } - public function save(array|null $propertyNames = null): bool + public function save(array|null $properties = null): bool { if ($this->isNewRecord()) { - return $this->insert($propertyNames); + return $this->insert($properties); } - $this->update($propertyNames); + $this->update($properties); return true; } @@ -1093,6 +1094,34 @@ protected function deleteInternal(): int return $result; } + /** + * Returns the property values that have been modified. + * + * @param array|null $properties List of property names or name-values pairs that need to be saved. + * Defaults to `null`, meaning all changed property values will be saved. + * + * @return array The changed property values (name-value pairs). + */ + protected function newPropertyValues(array|null $properties = null): array + { + if (empty($properties) || array_is_list($properties)) { + return $this->newValues($properties); + } + + $names = []; + + foreach ($properties as $name => $value) { + if (is_int($name)) { + $names[] = $value; + } else { + $this->set($name, $value); + $names[] = $name; + } + } + + return $this->newValues($names); + } + /** * Repopulates this active record with the latest data from a newly fetched instance. * @@ -1136,22 +1165,7 @@ protected function updateInternal(array|null $properties = null): int throw new InvalidCallException('The record is new and cannot be updated.'); } - if ($properties === null) { - $names = $this->propertyNames(); - } else { - $names = []; - - foreach ($properties as $name => $value) { - if (is_int($name)) { - $names[] = $value; - } else { - $this->set($name, $value); - $names[] = $name; - } - } - } - - $values = $this->newValues($names); + $values = $this->newPropertyValues($properties); if (empty($values)) { return 0; diff --git a/src/ActiveRecord.php b/src/ActiveRecord.php index 7c54d14d1..87346b9e3 100644 --- a/src/ActiveRecord.php +++ b/src/ActiveRecord.php @@ -6,6 +6,7 @@ use Yiisoft\Db\Constant\ColumnType; use Yiisoft\Db\Exception\Exception; +use Yiisoft\Db\Exception\InvalidCallException; use Yiisoft\Db\Exception\InvalidConfigException; use Yiisoft\Db\Schema\TableSchemaInterface; @@ -153,9 +154,13 @@ protected function propertyValuesInternal(): array return get_object_vars($this); } - protected function insertInternal(array|null $propertyNames = null): bool + protected function insertInternal(array|null $properties = null): bool { - $values = $this->newValues($propertyNames); + if (!$this->isNewRecord()) { + throw new InvalidCallException('The record is not new and cannot be inserted.'); + } + + $values = $this->newPropertyValues($properties); $primaryKeys = $this->db()->createCommand()->insertWithReturningPks($this->tableName(), $values); if ($primaryKeys === false) { diff --git a/src/ActiveRecordInterface.php b/src/ActiveRecordInterface.php index db0044d1b..259b2d7d9 100644 --- a/src/ActiveRecordInterface.php +++ b/src/ActiveRecordInterface.php @@ -9,6 +9,7 @@ use Yiisoft\Db\Constant\ColumnType; use Yiisoft\Db\Exception\Exception; use Yiisoft\Db\Exception\InvalidArgumentException; +use Yiisoft\Db\Exception\InvalidCallException; use Yiisoft\Db\Exception\InvalidConfigException; interface ActiveRecordInterface @@ -195,6 +196,8 @@ public function hasProperty(string $name): bool; /** * Inserts a row into the associated database table using the property values of this record. + * You may specify the properties to be inserted as list of name or name-value pairs. + * If name-value pair specified, the corresponding property values will be modified. * * Only the {@see newValues() changed property values} will be inserted into a database. * @@ -210,15 +213,22 @@ public function hasProperty(string $name): bool; * $customer->insert(); * ``` * - * @param array|null $propertyNames List of property names that need to be saved. Defaults to `null`, meaning all - * changed property values will be saved. + * To insert a customer record with specific properties: + * + * ```php + * $customer->insert(['name' => $name, 'email' => $email]); + * ``` + * + * @param array|null $properties List of property names or name-values pairs that need to be saved. + * Defaults to `null`, meaning all changed property values will be saved. * + * @throws InvalidCallException If the record {@see isNewRecord() is not new}. * @throws InvalidConfigException * @throws Throwable In case insert failed. * * @return bool Whether the record is inserted successfully. */ - public function insert(array|null $propertyNames = null): bool; + public function insert(array|null $properties = null): bool; /** * Checks if any property returned by {@see propertyNames()} method has changed. @@ -358,9 +368,13 @@ public function relationQuery(string $name): ActiveQueryInterface; public function resetRelation(string $name): void; /** - * Saves the current record. + * Saves the changes to this active record into the associated database table. + * You may specify the properties to be updated as list of name or name-value pairs. + * If name-value pair specified, the corresponding property values will be modified. * - * This method will call {@see insert()} when {@see isNewRecord()|isNewRecord} is true, or {@see update()} when + * Only the {@see newValues() changed property values} will be saved into a database. + * + * This method will call {@see insert()} when {@see isNewRecord()} is true, or {@see update()} when * {@see isNewRecord()|isNewRecord} is false. * * For example, to save a customer record: @@ -372,12 +386,18 @@ public function resetRelation(string $name): void; * $customer->save(); * ``` * - * @param array|null $propertyNames List of property names that need to be saved. Defaults to `null`, - * meaning all changed property values will be saved. + * To save a customer record with specific properties: + * + * ```php + * $customer->save(['name' => $name, 'email' => $email]); + * ``` + * + * @param array|null $properties List of property names or name-values pairs that need to be saved. + * Defaults to `null`, meaning all changed property values will be saved. * * @return bool Whether the saving succeeded (that's no validation errors occurred). */ - public function save(array|null $propertyNames = null): bool; + public function save(array|null $properties = null): bool; /** * Sets the named property value. @@ -400,7 +420,7 @@ public function set(string $propertyName, mixed $value): void; * For example, to update a customer record: * * ```php - * $customer = new Customer(); + * $customer = (new ActiveQuery(Customer::class))->findByPk(1); * $customer->name = $name; * $customer->email = $email; * $customer->update(); @@ -409,9 +429,8 @@ public function set(string $propertyName, mixed $value): void; * To update a customer record with specific properties: * * ```php - * $customer = new Customer(); - * $customer->update(['name' => $name, 'email' => $email]); - * ``` + * $customer->update(['name' => $name, 'email' => $email]); + * ``` * * Note that it's possible the update doesn't affect any row in the table. * In this case, this method will return 0. @@ -428,6 +447,7 @@ public function set(string $propertyName, mixed $value): void; * @param array|null $properties List of property names or name-values pairs that need to be saved. * Defaults to `null`, meaning all changed property values will be saved. * + * @throws InvalidCallException If the record {@see isNewRecord() is new}. * @throws OptimisticLockException If the instance implements {@see OptimisticLockInterface} and the data being * updated is outdated. * @throws Throwable In case update failed. diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index 1440dd377..bd4ebf5d5 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -236,6 +236,118 @@ public function testPopulateRecordCallWhenQueryingOnParentClass(): void $this->assertEquals('meow', $animals->getDoes()); } + public function testSave(): void + { + $this->reloadFixtureAfterTest(); + + // insert + $customer = new Customer(); + + $customer->setEmail('user4@example.com'); + $customer->setName('user4'); + $customer->setAddress('address4'); + $customer->setStatus(1); + + $this->assertNull($customer->getId()); + $this->assertTrue($customer->isNewRecord()); + + $this->assertTrue($customer->save()); + $this->assertFalse($customer->isNewRecord()); + $this->assertSame(4, $customer->getId()); + + $customer->refresh(); + $this->assertSame(4, $customer->getId()); + $this->assertSame('user4@example.com', $customer->getEmail()); + $this->assertSame('user4', $customer->getName()); + $this->assertSame('address4', $customer->getAddress()); + $this->assertSame(1, $customer->getStatus()); + + // insert with property names + $customer = new Customer(); + + $customer->setEmail('user5@example.com'); + $customer->setName('user5'); + $customer->setAddress('address5'); + $customer->setStatus(1); + + $this->assertNull($customer->getId()); + $this->assertTrue($customer->isNewRecord()); + + $this->assertTrue($customer->save(['email', 'name', 'address'])); + $this->assertFalse($customer->isNewRecord()); + $this->assertSame(5, $customer->getId()); + + $customer->refresh(); + $this->assertSame(5, $customer->getId()); + $this->assertSame('user5@example.com', $customer->getEmail()); + $this->assertSame('user5', $customer->getName()); + $this->assertSame('address5', $customer->getAddress()); + $this->assertSame(0, $customer->getStatus()); + + // insert with property values + $customer = new Customer(); + $customer->setStatus(1); + + $this->assertTrue($customer->save([ + 'email' => 'user6@example.com', + 'name' => 'user6', + 'address' => 'address6', + ])); + $this->assertFalse($customer->isNewRecord()); + $this->assertSame(6, $customer->getId()); + + $customer->refresh(); + $this->assertSame(6, $customer->getId()); + $this->assertSame('user6@example.com', $customer->getEmail()); + $this->assertSame('user6', $customer->getName()); + $this->assertSame('address6', $customer->getAddress()); + $this->assertSame(0, $customer->getStatus()); + + // update + $customer->setEmail('customer6@example.com'); + $customer->setName('customer6'); + + $this->assertTrue($customer->save()); + + $this->assertFalse($customer->isNewRecord()); + $this->assertSame(6, $customer->getId()); + + $customer->refresh(); + $this->assertSame(6, $customer->getId()); + $this->assertSame('customer6@example.com', $customer->getEmail()); + $this->assertSame('customer6', $customer->getName()); + $this->assertSame('address6', $customer->getAddress()); + $this->assertSame(0, $customer->getStatus()); + + // update with property names + $customer->setEmail('name6@example.com'); + $customer->setName('name6'); + $customer->setStatus(1); + + $this->assertTrue($customer->save(['email', 'name'])); + + $customer->refresh(); + $this->assertSame(6, $customer->getId()); + $this->assertSame('name6@example.com', $customer->getEmail()); + $this->assertSame('name6', $customer->getName()); + $this->assertSame('address6', $customer->getAddress()); + $this->assertSame(0, $customer->getStatus()); + + // update with property values + $customer->setStatus(1); + $this->assertTrue($customer->save([ + 'email' => 'client6@example.com', + 'name' => 'client6', + ])); + + $customer->refresh(); + $this->assertSame(6, $customer->getId()); + $this->assertSame('client6@example.com', $customer->getEmail()); + $this->assertSame('client6', $customer->getName()); + $this->assertSame('address6', $customer->getAddress()); + $this->assertSame(0, $customer->getStatus()); + } + public function testSaveEmpty(): void { $this->reloadFixtureAfterTest(); @@ -420,14 +532,68 @@ public function testInsert(): void $customer->setEmail('user4@example.com'); $customer->setName('user4'); $customer->setAddress('address4'); + $customer->setStatus(1); - $this->assertNull($customer->get('id')); $this->assertTrue($customer->isNewRecord()); + $this->assertNull($customer->getId()); - $customer->save(); + $this->assertTrue($customer->insert()); + $this->assertFalse($customer->isNewRecord()); + $this->assertSame(4, $customer->getId()); + + $customer->refresh(); + $this->assertSame(4, $customer->getId()); + $this->assertSame('user4@example.com', $customer->getEmail()); + $this->assertSame('user4', $customer->getName()); + $this->assertSame('address4', $customer->getAddress()); + $this->assertSame(1, $customer->getStatus()); - $this->assertNotNull($customer->getId()); + // with property names + $customer = new Customer(); + + $customer->setEmail('user5@example.com'); + $customer->setName('user5'); + $customer->setAddress('address5'); + $customer->setStatus(1); + + $this->assertTrue($customer->isNewRecord()); + $this->assertNull($customer->getId()); + + $this->assertTrue($customer->insert(['email', 'name', 'address'])); + $this->assertFalse($customer->isNewRecord()); + $this->assertSame(5, $customer->getId()); + + $customer->refresh(); + $this->assertSame(5, $customer->getId()); + $this->assertSame('user5@example.com', $customer->getEmail()); + $this->assertSame('user5', $customer->getName()); + $this->assertSame('address5', $customer->getAddress()); + $this->assertSame(0, $customer->getStatus()); + + // with property values + $customer = new Customer(); + $customer->setStatus(1); + + $this->assertTrue($customer->insert([ + 'email' => 'user6@example.com', + 'name' => 'user6', + 'address' => 'address6', + ])); $this->assertFalse($customer->isNewRecord()); + $this->assertSame(6, $customer->getId()); + + $customer->refresh(); + $this->assertSame(6, $customer->getId()); + $this->assertSame('user6@example.com', $customer->getEmail()); + $this->assertSame('user6', $customer->getName()); + $this->assertSame('address6', $customer->getAddress()); + $this->assertSame(0, $customer->getStatus()); + + // insert not new record + $this->expectException(InvalidCallException::class); + $this->expectExceptionMessage('The record is not new and cannot be inserted.'); + + $customer->insert(); } /** diff --git a/tests/Stubs/ActiveRecord/Customer.php b/tests/Stubs/ActiveRecord/Customer.php index 5fa91f8c5..b96c4ea8a 100644 --- a/tests/Stubs/ActiveRecord/Customer.php +++ b/tests/Stubs/ActiveRecord/Customer.php @@ -61,9 +61,9 @@ public function relationQuery(string $name): ActiveQueryInterface }; } - public function getId(): int + public function getId(): int|null { - return $this->id; + return $this->id ?? null; } public function getEmail(): string From 3992bd6a4e2fa4078bfc5a186aa932ad9a5d80f0 Mon Sep 17 00:00:00 2001 From: Tigrov Date: Mon, 26 May 2025 13:52:25 +0700 Subject: [PATCH 2/4] Update doc --- src/AbstractActiveRecord.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/AbstractActiveRecord.php b/src/AbstractActiveRecord.php index 117dd627d..8b172751b 100644 --- a/src/AbstractActiveRecord.php +++ b/src/AbstractActiveRecord.php @@ -1096,9 +1096,11 @@ protected function deleteInternal(): int /** * Returns the property values that have been modified. + * You may specify the properties to be returned as list of name or name-value pairs. + * If name-value pair specified, the corresponding property values will be modified. * - * @param array|null $properties List of property names or name-values pairs that need to be saved. - * Defaults to `null`, meaning all changed property values will be saved. + * @param array|null $properties List of property names or name-values pairs that need to be returned. + * Defaults to `null`, meaning all changed property values will be returned. * * @return array The changed property values (name-value pairs). */ From 49b75fe6c05c7602266f7dd87765164d7f7d09df Mon Sep 17 00:00:00 2001 From: Tigrov Date: Mon, 26 May 2025 13:54:15 +0700 Subject: [PATCH 3/4] Update doc --- src/AbstractActiveRecord.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/AbstractActiveRecord.php b/src/AbstractActiveRecord.php index 8b172751b..181e2f05a 100644 --- a/src/AbstractActiveRecord.php +++ b/src/AbstractActiveRecord.php @@ -1099,6 +1099,8 @@ protected function deleteInternal(): int * You may specify the properties to be returned as list of name or name-value pairs. * If name-value pair specified, the corresponding property values will be modified. * + * Only the {@see newValues() changed property values} will be returned. + * * @param array|null $properties List of property names or name-values pairs that need to be returned. * Defaults to `null`, meaning all changed property values will be returned. * From a442d3e27c31410b4d00dcf8b5128c4e3dc9944a Mon Sep 17 00:00:00 2001 From: Tigrov Date: Mon, 26 May 2025 14:00:40 +0700 Subject: [PATCH 4/4] Improve test coverage --- tests/ActiveRecordTest.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index bd4ebf5d5..f5779a62c 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -286,12 +286,13 @@ public function testSave(): void // insert with property values $customer = new Customer(); + $customer->setAddress('address6'); $customer->setStatus(1); $this->assertTrue($customer->save([ 'email' => 'user6@example.com', 'name' => 'user6', - 'address' => 'address6', + 'address', ])); $this->assertFalse($customer->isNewRecord()); $this->assertSame(6, $customer->getId()); @@ -334,17 +335,19 @@ public function testSave(): void $this->assertSame(0, $customer->getStatus()); // update with property values + $customer->setAddress('street6'); $customer->setStatus(1); $this->assertTrue($customer->save([ 'email' => 'client6@example.com', 'name' => 'client6', + 'address', ])); $customer->refresh(); $this->assertSame(6, $customer->getId()); $this->assertSame('client6@example.com', $customer->getEmail()); $this->assertSame('client6', $customer->getName()); - $this->assertSame('address6', $customer->getAddress()); + $this->assertSame('street6', $customer->getAddress()); $this->assertSame(0, $customer->getStatus()); } @@ -572,12 +575,13 @@ public function testInsert(): void // with property values $customer = new Customer(); + $customer->setAddress('address6'); $customer->setStatus(1); $this->assertTrue($customer->insert([ 'email' => 'user6@example.com', 'name' => 'user6', - 'address' => 'address6', + 'address', ])); $this->assertFalse($customer->isNewRecord()); $this->assertSame(6, $customer->getId());