From 27f1743599f1e68491a5264bb282961a0fe9027b Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Fri, 13 Mar 2026 13:08:36 +0400 Subject: [PATCH 01/66] Fixed AbstractActiveRecord --- tests/ActiveRecordTest.php | 11 +++++++++++ tests/MagicActiveRecordTest.php | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index fc8e07f6f..3cd9573d8 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -747,6 +747,17 @@ public function testEquals(): void $customerB = new Customer(); $this->assertFalse($customerA->equals($customerB)); + $customerA = Customer::query()->findByPk(1); + $customerB = new Customer(); + $this->assertFalse($customerA->equals($customerB)); + $this->assertFalse($customerB->equals($customerA)); + + $customerA = Customer::query()->findByPk(1); + $customerB = new Customer(); + $customerB->setId(1); + $this->assertFalse($customerA->equals($customerB)); + $this->assertFalse($customerB->equals($customerA)); + $customerA = new Customer(); $customerB = new Item(); $this->assertFalse($customerA->equals($customerB)); diff --git a/tests/MagicActiveRecordTest.php b/tests/MagicActiveRecordTest.php index 604f28ab4..c2302c58a 100644 --- a/tests/MagicActiveRecordTest.php +++ b/tests/MagicActiveRecordTest.php @@ -560,6 +560,17 @@ public function testEquals(): void $customerB = new Customer(); $this->assertFalse($customerA->equals($customerB)); + $customerA = Customer::query()->findByPk(1); + $customerB = new Customer(); + $this->assertFalse($customerA->equals($customerB)); + $this->assertFalse($customerB->equals($customerA)); + + $customerA = Customer::query()->findByPk(1); + $customerB = new Customer(); + $customerB->id = 1; + $this->assertFalse($customerA->equals($customerB)); + $this->assertFalse($customerB->equals($customerA)); + $customerA = new Customer(); $customerB = new Item(); $this->assertFalse($customerA->equals($customerB)); From aeecf634d2389f966b8e75b9c8879953e54ebe52 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Fri, 13 Mar 2026 13:41:04 +0400 Subject: [PATCH 02/66] Fixed AbstractActiveRecord.php:454 --- tests/ActiveRecordTest.php | 122 ++++++++++++++++++++----------------- 1 file changed, 67 insertions(+), 55 deletions(-) diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index 3cd9573d8..a6413f596 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -436,6 +436,18 @@ public function testResetNotSavedRelation(): void $this->assertCount(1, $order->getOrderItems()); } + public function testResetRelation(): void + { + $customer = Customer::query()->findByPk(2); + $customer->getOrders(); + + $this->assertStringContainsString('orders', serialize($customer)); + + $customer->resetRelation('orders'); + + $this->assertStringNotContainsString('orders', serialize($customer)); + } + public function testIssetException(): void { self::markTestSkipped('There are no magic properties in the Cat class'); @@ -656,61 +668,61 @@ public function testBooleanProperty(): void $this->assertCount(2, $customers); } - public function testPropertyAccess(): void - { - self::markTestSkipped('There are no magic properties in the Cat class'); - - $arClass = new Customer(); - - $this->assertTrue($arClass->canSetProperty('name')); - $this->assertTrue($arClass->canGetProperty('name')); - $this->assertFalse($arClass->canSetProperty('unExistingColumn')); - $this->assertFalse(isset($arClass->name)); - - $arClass->name = 'foo'; - $this->assertTrue(isset($arClass->name)); - - unset($arClass->name); - $this->assertNull($arClass->name); - - /** @see https://github.com/yiisoft/yii2-gii/issues/190 */ - $baseModel = new Customer(); - $this->assertFalse($baseModel->hasProperty('unExistingColumn')); - - $customer = new Customer(); - $this->assertInstanceOf(Customer::class, $customer); - $this->assertTrue($customer->canGetProperty('id')); - $this->assertTrue($customer->canSetProperty('id')); - - /** tests that we really can get and set this property */ - $this->assertNull($customer->id); - $customer->id = 10; - $this->assertNotNull($customer->id); - - /** Let's test relations */ - $this->assertTrue($customer->canGetProperty('orderItems')); - $this->assertFalse($customer->canSetProperty('orderItems')); - - /** Newly created model must have empty relation */ - $this->assertSame([], $customer->orderItems); - - /** does it still work after accessing the relation? */ - $this->assertTrue($customer->canGetProperty('orderItems')); - $this->assertFalse($customer->canSetProperty('orderItems')); - - $this->expectException(InvalidCallException::class); - $this->expectExceptionMessage('Setting read-only property: ' . Customer::class . '::orderItems'); - $customer->orderItems = [new Item()]; - - /** related property $customer->orderItems didn't change cause it's read-only */ - $this->assertSame([], $customer->orderItems); - $this->assertFalse($customer->canGetProperty('non_existing_property')); - $this->assertFalse($customer->canSetProperty('non_existing_property')); - - $this->expectException(UnknownPropertyException::class); - $this->expectExceptionMessage('Setting unknown property: ' . Customer::class . '::non_existing_property'); - $customer->non_existing_property = null; - } +// public function testPropertyAccess(): void +// { +// self::markTestSkipped('There are no magic properties in the Cat class'); +// +// $arClass = new Customer(); +// +// $this->assertTrue($arClass->canSetProperty('name')); +// $this->assertTrue($arClass->canGetProperty('name')); +// $this->assertFalse($arClass->canSetProperty('unExistingColumn')); +// $this->assertFalse(isset($arClass->name)); +// +// $arClass->name = 'foo'; +// $this->assertTrue(isset($arClass->name)); +// +// unset($arClass->name); +// $this->assertNull($arClass->name); +// +// /** @see https://github.com/yiisoft/yii2-gii/issues/190 */ +// $baseModel = new Customer(); +// $this->assertFalse($baseModel->hasProperty('unExistingColumn')); +// +// $customer = new Customer(); +// $this->assertInstanceOf(Customer::class, $customer); +// $this->assertTrue($customer->canGetProperty('id')); +// $this->assertTrue($customer->canSetProperty('id')); +// +// /** tests that we really can get and set this property */ +// $this->assertNull($customer->id); +// $customer->id = 10; +// $this->assertNotNull($customer->id); +// +// /** Let's test relations */ +// $this->assertTrue($customer->canGetProperty('orderItems')); +// $this->assertFalse($customer->canSetProperty('orderItems')); +// +// /** Newly created model must have empty relation */ +// $this->assertSame([], $customer->orderItems); +// +// /** does it still work after accessing the relation? */ +// $this->assertTrue($customer->canGetProperty('orderItems')); +// $this->assertFalse($customer->canSetProperty('orderItems')); +// +// $this->expectException(InvalidCallException::class); +// $this->expectExceptionMessage('Setting read-only property: ' . Customer::class . '::orderItems'); +// $customer->orderItems = [new Item()]; +// +// /** related property $customer->orderItems didn't change cause it's read-only */ +// $this->assertSame([], $customer->orderItems); +// $this->assertFalse($customer->canGetProperty('non_existing_property')); +// $this->assertFalse($customer->canSetProperty('non_existing_property')); +// +// $this->expectException(UnknownPropertyException::class); +// $this->expectExceptionMessage('Setting unknown property: ' . Customer::class . '::non_existing_property'); +// $customer->non_existing_property = null; +// } public function testHasProperty(): void { From ec920b32f48c718da8b1a90b87631f02da4ed41d Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Fri, 13 Mar 2026 14:24:07 +0400 Subject: [PATCH 03/66] Add test ensuring new records do not execute updates during save --- tests/MagicActiveRecordTest.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/MagicActiveRecordTest.php b/tests/MagicActiveRecordTest.php index c2302c58a..58108fcc6 100644 --- a/tests/MagicActiveRecordTest.php +++ b/tests/MagicActiveRecordTest.php @@ -10,6 +10,7 @@ use LogicException; use PHPUnit\Framework\Attributes\DataProvider; use Yiisoft\ActiveRecord\ActiveQueryInterface; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\SetValueOnUpdateAr; use Yiisoft\ActiveRecord\Tests\Stubs\MagicActiveRecord\Alpha; use Yiisoft\ActiveRecord\Tests\Stubs\MagicActiveRecord\Animal; use Yiisoft\ActiveRecord\Tests\Stubs\MagicActiveRecord\Cat; @@ -235,6 +236,19 @@ public function testPopulateRecordCallWhenQueryingOnParentClass(): void $this->assertEquals('meow', $animals->getDoes()); } + public function testSaveNewRecordDoesNotExecuteUpdate(): void + { + $this->reloadFixtureAfterTest(); + + $record = new SetValueOnUpdateAr(); + $record->id = 99; + $record->name = 'Test'; + + $record->save(); + + $this->assertSame('Test', $record->name); + } + public function testSaveEmpty(): void { $this->reloadFixtureAfterTest(); From f0eaf4b42ffd6f61f521243bd71174cb25551cf2 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Fri, 13 Mar 2026 15:03:26 +0400 Subject: [PATCH 04/66] Add test for resetting populated relation via property setter --- tests/ActiveRecordTest.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index a6413f596..16b46f518 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -517,6 +517,19 @@ public function testSetPropertyNoExist(): void $cat->set('noExist', 1); } + public function testSetPropertyReset(): void + { + $customer = Customer::query()->findByPk(2); + + $this->assertFalse($customer->isRelationPopulated('profile')); + $this->assertNull($customer->getProfile()); + $this->assertTrue($customer->isRelationPopulated('profile')); + + $customer->setProfileId(null); + + $this->assertFalse($customer->isRelationPopulated('profile')); + } + public function testAssignOldValue(): void { $customer = new Customer(); From 3b89f334a85a6a643ebc30efa1f0239df345740a Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Fri, 13 Mar 2026 15:12:13 +0400 Subject: [PATCH 05/66] Add test for handling models without a primary key --- tests/ActiveRecordTest.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index 16b46f518..636f1e63f 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -28,6 +28,7 @@ use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Dog; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Item; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\NoExist; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\NoPk; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\NullValues; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Order; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\OrderItem; @@ -925,6 +926,18 @@ public function testPrimaryKeyOldValueWithoutPrimaryKey(): void $orderItem->primaryKeyOldValue(); } + public function testPrimaryKeyOldValueWithoutPrimaryKeyContainsTableName(): void + { + $model = new NoPk(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage( + NoPk::class . ' does not have a primary key. You should either define a primary key for no_pk table or override the primaryKey() method.', + ); + + $model->primaryKeyOldValue(); + } + public function testPrimaryKeyOldValuesWithoutPrimaryKey(): void { $orderItem = new OrderItemWithNullFK(); From 39ad6def9d405995d03865cd0d167aac8865ac8a Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Fri, 13 Mar 2026 15:27:38 +0400 Subject: [PATCH 06/66] Add test for linking via relation with a new record --- tests/ActiveRecordTest.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index 636f1e63f..c4b382687 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -1647,6 +1647,20 @@ public function testLinkViaRelationWithNewRecord(): void $customer->link('items2', $item); } + public function testLinkViaRelationWithOneNewRecord(): void + { + $this->reloadFixtureAfterTest(); + + $customer = Customer::query()->findByPk(1); + $item = new Item(); + + $this->expectException(InvalidCallException::class); + $this->expectExceptionMessage( + 'Unable to link models: the models being linked cannot be newly created.', + ); + $customer->link('items2', $item); + } + public function testLinkViaTable(): void { $this->reloadFixtureAfterTest(); From 5de7e0c7028ff3b98ef05b13fb1f90caf169aa8f Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Fri, 13 Mar 2026 15:32:52 +0400 Subject: [PATCH 07/66] Add test for unlinking array-valued property with delete in ActiveRecord --- tests/ActiveRecordTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index c4b382687..c178d483f 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -1945,6 +1945,24 @@ public function testUnlinkWithArrayValuedProperty(): void ); } + public function testUnlinkWithArrayValuedPropertyAndDelete(): void + { + $this->reloadFixtureAfterTest(); + + $promotion = Promotion::query()->findByPk(1); + $items = $promotion->getItemsViaJson(); + $item = $items[0]; + + $promotion->unlink('itemsViaJson', $item, true); + + $this->assertSame([2], $promotion->json_item_ids); + $this->assertNull(Item::query()->findByPk($item->getId())); + $this->assertSame( + '[2]', + self::db()->select('json_item_ids')->from('{{promotion}}')->where(['id' => 1])->scalar(), + ); + } + public function testUnlinkViaTableWithDelete(): void { $this->reloadFixtureAfterTest(); From 4235f60595bd1c93bf68c94632f709ac6101ea32 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Fri, 13 Mar 2026 15:37:16 +0400 Subject: [PATCH 08/66] Add tests for intermediate relation population and reset conditions --- tests/ActiveQueryTest.php | 44 ++++++++++++++++++++++++++++++++++++++ tests/ActiveRecordTest.php | 13 +++++++++++ 2 files changed, 57 insertions(+) diff --git a/tests/ActiveQueryTest.php b/tests/ActiveQueryTest.php index 35244c692..33ad50d3e 100644 --- a/tests/ActiveQueryTest.php +++ b/tests/ActiveQueryTest.php @@ -2723,6 +2723,27 @@ public function testGetAlreadyPopulatedViaRelation(): void $this->assertCount(2, $items); } + public function testGetViaRelationUsesPopulatedIntermediateRelation(): void + { + $order = Order::query()->findByPk(1); + $order->populateRelation('orderItems', []); + + $items = $order->getItemsIndexedQuery()->all(); + + $this->assertSame([], $items); + $this->assertTrue($order->isRelationPopulated('orderItems')); + } + + public function testGetViaRelationPopulatesIntermediateRelation(): void + { + $order = Order::query()->findByPk(1); + $this->assertFalse($order->isRelationPopulated('orderItems')); + + $order->getItemsIndexedQuery()->all(); + + $this->assertTrue($order->isRelationPopulated('orderItems')); + } + public function testGetViaCallableWithHasOne(): void { $order = Order::query()->findByPk(1); @@ -2733,6 +2754,17 @@ public function testGetViaCallableWithHasOne(): void $this->assertSame(1, $profile->getId()); } + public function testGetViaCallableWithHasOneIgnoresPopulatedIntermediateRelation(): void + { + $order = Order::query()->findByPk(1); + $order->populateRelation('customer', null); + + $profile = $order->getCustomerProfileViaCallableQuery()->one(); + + $this->assertInstanceOf(Profile::class, $profile); + $this->assertSame(1, $profile->getId()); + } + public function testGetViaWithHasOne(): void { $order = Order::query()->findByPk(1); @@ -2743,6 +2775,16 @@ public function testGetViaWithHasOne(): void $this->assertSame(1, $profile->getId()); } + public function testGetViaWithHasOneUsesPopulatedIntermediateRelation(): void + { + $order = Order::query()->findByPk(1); + $order->populateRelation('customer', null); + + $profile = $order->getCustomerProfileViaCustomerQuery()->one(); + + $this->assertNull($profile); + } + public function testGetAlreadyPopulatedViaWithHasOne(): void { $order = Order::query()->with('customer')->findByPk(1); @@ -2778,7 +2820,9 @@ public function testCloneQueryWithViaRelationName(): void $this->assertIsArray($queryVia); $this->assertIsArray($clonedQueryVia); + $this->assertSame($queryVia[0], $clonedQueryVia[0]); $this->assertNotSame($queryVia[1], $clonedQueryVia[1]); + $this->assertSame($queryVia[2], $clonedQueryVia[2]); } public function testExceptionOnIndexWithNonExistentNestedProperty(): void diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index c178d483f..e17a20e29 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -531,6 +531,19 @@ public function testSetPropertyReset(): void $this->assertFalse($customer->isRelationPopulated('profile')); } + public function testSetPropertyResetViaTableRelation(): void + { + $order = Order::query()->findByPk(1); + + $this->assertFalse($order->isRelationPopulated('booksViaTable')); + $this->assertCount(2, $order->getBooksViaTable()); + $this->assertTrue($order->isRelationPopulated('booksViaTable')); + + $order->setId(99); + + $this->assertFalse($order->isRelationPopulated('booksViaTable')); + } + public function testAssignOldValue(): void { $customer = new Customer(); From 91babdc1d5a87d525508a34eff7694c2c833305c Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Fri, 13 Mar 2026 15:42:59 +0400 Subject: [PATCH 09/66] Add new test cases for ActiveQuery and EventsTrait functionality --- tests/ActiveQueryTest.php | 37 +++++++++++++++++++++++++++--- tests/EventsTraitTest.php | 47 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/tests/ActiveQueryTest.php b/tests/ActiveQueryTest.php index 33ad50d3e..5d58a5132 100644 --- a/tests/ActiveQueryTest.php +++ b/tests/ActiveQueryTest.php @@ -76,6 +76,20 @@ public function testPopulateEmptyRows(): void $this->assertEquals([], $query->populate([])); } + public function testPopulateKeepsAllModelsFromResultCallback(): void + { + $query = Customer::query()->resultCallback(static fn(array $rows): array => [ + Customer::query()->findByPk(1), + Customer::query()->findByPk(2), + ]); + + $models = $query->populate([['id' => 1], ['id' => 2]]); + + $this->assertCount(2, $models); + $this->assertSame(1, $models[0]->getId()); + $this->assertSame(2, $models[1]->getId()); + } + public function testAll(): void { $query = Customer::query(); @@ -264,13 +278,29 @@ public function testViaWithEmptyPrimaryModel(): void public function testViaTable(): void { $order = new Order(); - + $callableUsed = false; $query = Customer::query(); - $query->primaryModel($order)->viaTable(Profile::class, ['id' => 'item_id']); + $query->primaryModel($order)->viaTable( + Profile::class, + ['id' => 'item_id'], + static function (ActiveQueryInterface $relation) use (&$callableUsed): void { + $callableUsed = true; + $relation->where(['id' => 1]); + }, + ); $this->assertInstanceOf(ActiveQuery::class, $query); - $this->assertInstanceOf(ActiveQuery::class, $query->getVia()); + $this->assertTrue($callableUsed); + + $via = $query->getVia(); + + $this->assertInstanceOf(ActiveQuery::class, $via); + $this->assertInstanceOf(Order::class, $via->getModel()); + $this->assertTrue($via->isMultiple()); + $this->assertTrue($via->isAsArray()); + $this->assertSame(['id' => 'item_id'], $via->getLink()); + $this->assertSame(['id' => 1], $via->getWhere()); } public function testAliasNotSet(): void @@ -2773,6 +2803,7 @@ public function testGetViaWithHasOne(): void $this->assertInstanceOf(Profile::class, $profile); $this->assertSame(1, $profile->getId()); + $this->assertTrue($order->isRelationPopulated('customer')); } public function testGetViaWithHasOneUsesPopulatedIntermediateRelation(): void diff --git a/tests/EventsTraitTest.php b/tests/EventsTraitTest.php index f3fbf309b..683bf3bd4 100644 --- a/tests/EventsTraitTest.php +++ b/tests/EventsTraitTest.php @@ -4,6 +4,11 @@ namespace Yiisoft\ActiveRecord\Tests; +use DateTimeImmutable; +use Yiisoft\ActiveRecord\Event\AfterInsert; +use Yiisoft\ActiveRecord\Event\AfterSave; +use Yiisoft\ActiveRecord\Event\AfterUpdate; +use Yiisoft\ActiveRecord\Event\AfterUpsert; use Yiisoft\ActiveRecord\Event\BeforeCreateQuery; use Yiisoft\ActiveRecord\Event\BeforeInsert; use Yiisoft\ActiveRecord\Event\BeforePopulate; @@ -11,7 +16,10 @@ use Yiisoft\ActiveRecord\Event\BeforeUpdate; use Yiisoft\ActiveRecord\Event\BeforeUpsert; use Yiisoft\ActiveRecord\Event\EventDispatcherProvider; +use Yiisoft\ActiveRecord\Event\Handler\DefaultDateTimeOnInsert; +use Yiisoft\ActiveRecord\Event\Handler\DefaultValue; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CategoryEventsModel; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Order; use Yiisoft\Test\Support\EventDispatcher\SimpleEventDispatcher; abstract class EventsTraitTest extends TestCase @@ -251,4 +259,43 @@ static function (object $event): void { $this->assertNull($model->id); $this->assertSame('Custom Return Upsert', $model->name); } + + public function testEventsKeepModelReference(): void + { + $model = new CategoryEventsModel(); + $properties = ['name']; + $count = 1; + $data = ['id' => 1]; + + $this->assertSame($model, new AfterInsert($model)->model); + $this->assertSame($model, new AfterSave($model)->model); + $this->assertSame($model, new AfterUpdate($model, $count)->model); + $this->assertSame($model, new AfterUpsert($model)->model); + $this->assertSame($model, new BeforePopulate($model, $data)->model); + $this->assertSame($model, new BeforeSave($model, $properties)->model); + } + + public function testAttributeHandlerProviderPropertyNamesArePublic(): void + { + $handler = new DefaultValue('value', 'name', 'status'); + + $this->assertSame(['name', 'status'], $handler->getPropertyNames()); + } + + public function testDefaultDateTimeOnInsertUsesCustomValue(): void + { + $dateTime = new DateTimeImmutable('2024-01-01 12:34:56'); + $handler = new DefaultDateTimeOnInsert($dateTime, 'created_at'); + $eventHandlers = $handler->getEventHandlers(); + $beforeInsert = $eventHandlers[BeforeInsert::class]; + + $order = new Order(); + $order->setCustomerId(1); + $order->setTotal(10.0); + + $event = new BeforeInsert($order); + $beforeInsert($event); + + $this->assertSame($dateTime, $order->getCreatedAt()); + } } From 5d7bc96b129d3c0573362a79152bf2d5f0bca33e Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Fri, 13 Mar 2026 15:47:38 +0400 Subject: [PATCH 10/66] Add unit tests for edge cases and behavior refinements across multiple components --- tests/ActiveQueryTest.php | 20 +++++++++++++ tests/ActiveRecordTest.php | 12 +++++--- tests/ArrayAccessTraitTest.php | 20 +++++++++++++ tests/ArrayableTraitTest.php | 13 ++++++++ tests/EventsTraitTest.php | 53 +++++++++++++++++++++++++++++++++ tests/MagicActiveRecordTest.php | 11 +++++++ 6 files changed, 125 insertions(+), 4 deletions(-) diff --git a/tests/ActiveQueryTest.php b/tests/ActiveQueryTest.php index 5d58a5132..70df1811f 100644 --- a/tests/ActiveQueryTest.php +++ b/tests/ActiveQueryTest.php @@ -64,6 +64,26 @@ public function testOptions(): void $this->assertSame([], $query->getJoinsWith()); } + public function testJoinWithWithoutEagerLoadingCreatesNewInstance(): void + { + $joinWith = new JoinWith(['customer', 'items'], true, 'LEFT JOIN'); + $withoutEagerLoading = $joinWith->withoutEagerLoading(); + + $this->assertNotSame($joinWith, $withoutEagerLoading); + $this->assertSame(['customer', 'items'], $joinWith->getWith()); + $this->assertSame([], $withoutEagerLoading->getWith()); + } + + public function testJoinWithGetWithKeepsFilteredRelations(): void + { + $joinWith = new JoinWith(['customer', 'items', 'books'], ['customer', 'books'], 'LEFT JOIN'); + + $this->assertSame( + [0 => 'customer', 2 => 'books'], + $joinWith->getWith(), + ); + } + public function testPrepare(): void { $query = Customer::query(); diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index e17a20e29..db48fc766 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -1052,12 +1052,14 @@ public function testWithCustomConnection(): void ConnectionProvider::set($db, 'custom'); DbHelper::loadFixture($db); - $customer = new CustomerWithCustomConnection(); + $originalCustomer = new CustomerWithCustomConnection(); - $this->assertSame($this->db(), $customer->db()); + $this->assertSame($this->db(), $originalCustomer->db()); - $customer = $customer->withConnectionName('custom'); + $customer = $originalCustomer->withConnectionName('custom'); + $this->assertNotSame($originalCustomer, $customer); + $this->assertSame($this->db(), $originalCustomer->db()); $this->assertSame($db, $customer->db()); $db->close(); @@ -1068,13 +1070,15 @@ public function testWithCustomConnection(): void public function testWithFactory(): void { $factory = $this->createFactory(); + $originalOrder = $factory->create(OrderWithFactory::class); - $orderQuery = new ActiveQuery($factory->create(OrderWithFactory::class)->withFactory($factory)); + $orderQuery = new ActiveQuery($originalOrder->withFactory($factory)); $order = $orderQuery->with('customerWithFactory')->findByPk(2); $this->assertInstanceOf(OrderWithFactory::class, $order); $this->assertTrue($order->isRelationPopulated('customerWithFactory')); $this->assertInstanceOf(CustomerWithFactory::class, $order->getCustomerWithFactory()); + $this->assertInstanceOf(Customer::class, $originalOrder->createQuery(CustomerWithFactory::class)->getModel()); } public function testWithFactoryInstanceRelation(): void diff --git a/tests/ArrayAccessTraitTest.php b/tests/ArrayAccessTraitTest.php index 49926962d..0591cad64 100644 --- a/tests/ArrayAccessTraitTest.php +++ b/tests/ArrayAccessTraitTest.php @@ -23,6 +23,15 @@ public function testOffsetExists(): void $this->assertFalse(isset($model['not-exists'])); } + public function testOffsetExistsDoesNotTreatPropertyAsRelation(): void + { + $model = new CustomerArrayAccessModel(); + $model->name = 'test'; + + $this->assertTrue(isset($model['name'])); + $this->assertFalse($model->isRelationPopulated('name')); + } + public function testOffsetExistsWithRelation(): void { $model = CustomerArrayAccessModel::query()->with('profile')->findByPk(1); @@ -41,6 +50,15 @@ public function testOffsetGet(): void $this->assertSame('custom value', $model['customProperty']); } + public function testOffsetGetReturnsPropertyValue(): void + { + $model = new CustomerArrayAccessModel(); + $model->name = 'property value'; + + $this->assertSame('property value', $model['name']); + $this->assertFalse($model->isRelationPopulated('name')); + } + public function testOffsetGetWithRelation(): void { $model = CustomerArrayAccessModel::query()->with('profile')->findByPk(1); @@ -112,6 +130,7 @@ public function testOffsetUnsetWithProperty(): void unset($model['name']); $this->assertTrue(!isset($model->name)); + $this->assertNull($model->get('name')); } public function testOffsetUnsetWithObjectProperty(): void @@ -134,5 +153,6 @@ public function testOffsetUnsetWithRelation(): void unset($model['profile']); $this->assertFalse($model->isRelationPopulated('profile')); + $this->assertNull($model->get('profile_id')); } } diff --git a/tests/ArrayableTraitTest.php b/tests/ArrayableTraitTest.php index 9c201cdb2..59d1f918d 100644 --- a/tests/ArrayableTraitTest.php +++ b/tests/ArrayableTraitTest.php @@ -33,6 +33,19 @@ public function testFields(): void ); } + public function testExtraFields(): void + { + $customer = CustomerForArrayable::query()->findByPk(1); + + $this->assertSame( + [ + 'item' => 'item', + 'items' => 'items', + ], + $customer->extraFields(), + ); + } + public function testToArray(): void { $customerQuery = Customer::query(); diff --git a/tests/EventsTraitTest.php b/tests/EventsTraitTest.php index 683bf3bd4..801b6e05f 100644 --- a/tests/EventsTraitTest.php +++ b/tests/EventsTraitTest.php @@ -10,6 +10,7 @@ use Yiisoft\ActiveRecord\Event\AfterUpdate; use Yiisoft\ActiveRecord\Event\AfterUpsert; use Yiisoft\ActiveRecord\Event\BeforeCreateQuery; +use Yiisoft\ActiveRecord\Event\BeforeDelete; use Yiisoft\ActiveRecord\Event\BeforeInsert; use Yiisoft\ActiveRecord\Event\BeforePopulate; use Yiisoft\ActiveRecord\Event\BeforeSave; @@ -162,6 +163,25 @@ static function (object $event): void { $this->assertSame('Custom Return Save', $model->name); } + public function testDeleteWithEventPrevention(): void + { + EventDispatcherProvider::set( + CategoryEventsModel::class, + new SimpleEventDispatcher( + static function (object $event): void { + if ($event instanceof BeforeDelete) { + $event->preventDefault(); + } + }, + ), + ); + + $model = CategoryEventsModel::query()->findByPk(1); + + $this->assertSame(0, $model->delete()); + $this->assertNotNull(CategoryEventsModel::query()->findByPk(1)); + } + public function testUpdateWithEventPrevention(): void { EventDispatcherProvider::set( @@ -260,6 +280,39 @@ static function (object $event): void { $this->assertSame('Custom Return Upsert', $model->name); } + public function testAfterEventsAreDispatched(): void + { + $triggeredEvents = []; + + EventDispatcherProvider::set( + CategoryEventsModel::class, + new SimpleEventDispatcher( + static function (object $event) use (&$triggeredEvents): void { + $triggeredEvents[] = $event::class; + }, + ), + ); + + $model = new CategoryEventsModel(); + $model->name = 'After Insert'; + $model->insert(); + + $model->name = 'After Save'; + $model->save(); + + $model->name = 'After Update'; + $model->update(); + + $model = new CategoryEventsModel(); + $model->name = 'After Upsert'; + $model->upsert(); + + $this->assertContains(AfterInsert::class, $triggeredEvents); + $this->assertContains(AfterSave::class, $triggeredEvents); + $this->assertContains(AfterUpdate::class, $triggeredEvents); + $this->assertContains(AfterUpsert::class, $triggeredEvents); + } + public function testEventsKeepModelReference(): void { $model = new CategoryEventsModel(); diff --git a/tests/MagicActiveRecordTest.php b/tests/MagicActiveRecordTest.php index 58108fcc6..8fe9a0ef1 100644 --- a/tests/MagicActiveRecordTest.php +++ b/tests/MagicActiveRecordTest.php @@ -930,6 +930,17 @@ public function testSettingUnknownProperty(): void $customer->nonExistentProperty = 'value'; } + public function testGettingUnknownProperty(): void + { + $customer = new Customer(); + + $this->expectException(UnknownPropertyException::class); + $this->expectExceptionMessage( + 'Getting unknown property or relation: ' . Customer::class . '::nonExistentProperty', + ); + $customer->nonExistentProperty; + } + public static function dataIsProperty(): array { return [ From 0154804160016afc774f9334250785fc64586f30 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Fri, 13 Mar 2026 15:51:04 +0400 Subject: [PATCH 11/66] Add tests for property handling in `MagicActiveRecordTest` and `RepositoryTraitTest` --- tests/MagicActiveRecordTest.php | 25 +++++++++++++++++++++++++ tests/RepositoryTraitTest.php | 2 ++ 2 files changed, 27 insertions(+) diff --git a/tests/MagicActiveRecordTest.php b/tests/MagicActiveRecordTest.php index 8fe9a0ef1..af198f745 100644 --- a/tests/MagicActiveRecordTest.php +++ b/tests/MagicActiveRecordTest.php @@ -545,6 +545,24 @@ public function testHasProperty(): void $this->assertFalse($customer->hasProperty('notExist')); } + public function testCanGetPropertyWithoutCheckingVars(): void + { + $customer = new Customer(); + + $this->assertTrue($customer->canGetProperty('name', false)); + $this->assertTrue($customer->canGetProperty('orders', false)); + $this->assertFalse($customer->canGetProperty('non_existing_property', false)); + } + + public function testCanSetPropertyWithoutCheckingVars(): void + { + $customer = new Customer(); + + $this->assertTrue($customer->canSetProperty('name', false)); + $this->assertFalse($customer->canSetProperty('orders', false)); + $this->assertFalse($customer->canSetProperty('non_existing_property', false)); + } + public function testHasRelationQuery(): void { $customer = new Customer(); @@ -941,6 +959,13 @@ public function testGettingUnknownProperty(): void $customer->nonExistentProperty; } + public function testIssetWriteOnlyPropertyReturnsFalse(): void + { + $cat = new Cat(); + + $this->assertFalse(isset($cat->nonExistingProperty)); + } + public static function dataIsProperty(): array { return [ diff --git a/tests/RepositoryTraitTest.php b/tests/RepositoryTraitTest.php index 9f2b1e987..ed8e96ad2 100644 --- a/tests/RepositoryTraitTest.php +++ b/tests/RepositoryTraitTest.php @@ -13,6 +13,8 @@ public function testFind(): void { $customerQuery = Customer::query(); + $this->assertEquals($customerQuery, Customer::find()); + $this->assertEquals( $customerQuery->setWhere(['id' => 1]), Customer::find(['id' => 1]), From a2cca915c21762c0807bf08a79660ef38eea6224 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Fri, 13 Mar 2026 15:57:00 +0400 Subject: [PATCH 12/66] Wrap event class instantiations in parentheses to enhance readability in EventsTraitTest. --- tests/EventsTraitTest.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/EventsTraitTest.php b/tests/EventsTraitTest.php index 801b6e05f..1f5d2c6ee 100644 --- a/tests/EventsTraitTest.php +++ b/tests/EventsTraitTest.php @@ -320,12 +320,12 @@ public function testEventsKeepModelReference(): void $count = 1; $data = ['id' => 1]; - $this->assertSame($model, new AfterInsert($model)->model); - $this->assertSame($model, new AfterSave($model)->model); - $this->assertSame($model, new AfterUpdate($model, $count)->model); - $this->assertSame($model, new AfterUpsert($model)->model); - $this->assertSame($model, new BeforePopulate($model, $data)->model); - $this->assertSame($model, new BeforeSave($model, $properties)->model); + $this->assertSame($model, (new AfterInsert($model))->model); + $this->assertSame($model, (new AfterSave($model))->model); + $this->assertSame($model, (new AfterUpdate($model, $count))->model); + $this->assertSame($model, (new AfterUpsert($model))->model); + $this->assertSame($model, (new BeforePopulate($model, $data))->model); + $this->assertSame($model, (new BeforeSave($model, $properties))->model); } public function testAttributeHandlerProviderPropertyNamesArePublic(): void From c4e2b9a3f057f14f044e40c0842faf65f732a07a Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Fri, 13 Mar 2026 16:31:15 +0400 Subject: [PATCH 13/66] Refactor tests to improve consistency and simplify assertions across multiple test files. --- tests/ActiveRecordTest.php | 25 +++++++++---------------- tests/ArrayAccessTraitTest.php | 1 - tests/ArrayableTraitTest.php | 3 ++- tests/EventsTraitTest.php | 5 ++++- 4 files changed, 15 insertions(+), 19 deletions(-) diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index db48fc766..fd1aed922 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -1052,14 +1052,12 @@ public function testWithCustomConnection(): void ConnectionProvider::set($db, 'custom'); DbHelper::loadFixture($db); - $originalCustomer = new CustomerWithCustomConnection(); + $customer = new CustomerWithCustomConnection(); - $this->assertSame($this->db(), $originalCustomer->db()); + $this->assertSame($this->db(), $customer->db()); - $customer = $originalCustomer->withConnectionName('custom'); + $customer = $customer->withConnectionName('custom'); - $this->assertNotSame($originalCustomer, $customer); - $this->assertSame($this->db(), $originalCustomer->db()); $this->assertSame($db, $customer->db()); $db->close(); @@ -1070,15 +1068,13 @@ public function testWithCustomConnection(): void public function testWithFactory(): void { $factory = $this->createFactory(); - $originalOrder = $factory->create(OrderWithFactory::class); - $orderQuery = new ActiveQuery($originalOrder->withFactory($factory)); + $orderQuery = new ActiveQuery($factory->create(OrderWithFactory::class)->withFactory($factory)); $order = $orderQuery->with('customerWithFactory')->findByPk(2); $this->assertInstanceOf(OrderWithFactory::class, $order); $this->assertTrue($order->isRelationPopulated('customerWithFactory')); $this->assertInstanceOf(CustomerWithFactory::class, $order->getCustomerWithFactory()); - $this->assertInstanceOf(Customer::class, $originalOrder->createQuery(CustomerWithFactory::class)->getModel()); } public function testWithFactoryInstanceRelation(): void @@ -1962,20 +1958,17 @@ public function testUnlinkWithArrayValuedProperty(): void ); } - public function testUnlinkWithArrayValuedPropertyAndDelete(): void + public function testUnlinkAllWithArrayValuedPropertyAndDelete(): void { $this->reloadFixtureAfterTest(); $promotion = Promotion::query()->findByPk(1); - $items = $promotion->getItemsViaJson(); - $item = $items[0]; - - $promotion->unlink('itemsViaJson', $item, true); + $promotion->unlinkAll('itemsViaJson', true); - $this->assertSame([2], $promotion->json_item_ids); - $this->assertNull(Item::query()->findByPk($item->getId())); + $this->assertSame([1, 2], $promotion->json_item_ids); + $this->assertCount(0, Item::query()->where(['id' => [1, 2]])->all()); $this->assertSame( - '[2]', + '[1, 2]', self::db()->select('json_item_ids')->from('{{promotion}}')->where(['id' => 1])->scalar(), ); } diff --git a/tests/ArrayAccessTraitTest.php b/tests/ArrayAccessTraitTest.php index 0591cad64..bc56d7d13 100644 --- a/tests/ArrayAccessTraitTest.php +++ b/tests/ArrayAccessTraitTest.php @@ -153,6 +153,5 @@ public function testOffsetUnsetWithRelation(): void unset($model['profile']); $this->assertFalse($model->isRelationPopulated('profile')); - $this->assertNull($model->get('profile_id')); } } diff --git a/tests/ArrayableTraitTest.php b/tests/ArrayableTraitTest.php index 59d1f918d..04c4de61b 100644 --- a/tests/ArrayableTraitTest.php +++ b/tests/ArrayableTraitTest.php @@ -36,11 +36,12 @@ public function testFields(): void public function testExtraFields(): void { $customer = CustomerForArrayable::query()->findByPk(1); + $customer2 = CustomerForArrayable::query()->findByPk(2); + $customer->populateRelation('item', $customer2); $this->assertSame( [ 'item' => 'item', - 'items' => 'items', ], $customer->extraFields(), ); diff --git a/tests/EventsTraitTest.php b/tests/EventsTraitTest.php index 1f5d2c6ee..3fc571875 100644 --- a/tests/EventsTraitTest.php +++ b/tests/EventsTraitTest.php @@ -294,6 +294,7 @@ static function (object $event) use (&$triggeredEvents): void { ); $model = new CategoryEventsModel(); + $model->id = 100; $model->name = 'After Insert'; $model->insert(); @@ -304,6 +305,7 @@ static function (object $event) use (&$triggeredEvents): void { $model->update(); $model = new CategoryEventsModel(); + $model->id = 101; $model->name = 'After Upsert'; $model->upsert(); @@ -345,8 +347,9 @@ public function testDefaultDateTimeOnInsertUsesCustomValue(): void $order = new Order(); $order->setCustomerId(1); $order->setTotal(10.0); + $properties = null; - $event = new BeforeInsert($order); + $event = new BeforeInsert($order, $properties); $beforeInsert($event); $this->assertSame($dateTime, $order->getCreatedAt()); From 1d7e21066c031feb5a635d0c173232b2de2cd41a Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Fri, 13 Mar 2026 16:49:45 +0400 Subject: [PATCH 14/66] Add new tests for improving ActiveRecord and ActiveQuery behavior customization --- tests/ActiveQueryTest.php | 77 +++++++++++- tests/ActiveRecordTest.php | 242 ++++++++++++++++++++++++++++++++++++- 2 files changed, 314 insertions(+), 5 deletions(-) diff --git a/tests/ActiveQueryTest.php b/tests/ActiveQueryTest.php index 70df1811f..92c1cbe74 100644 --- a/tests/ActiveQueryTest.php +++ b/tests/ActiveQueryTest.php @@ -93,7 +93,34 @@ public function testPrepare(): void public function testPopulateEmptyRows(): void { $query = Customer::query(); - $this->assertEquals([], $query->populate([])); + $this->assertSame([], $query->populate([])); + } + + public function testCreateModelsCanBeOverridden(): void + { + $query = new class (Customer::class) extends ActiveQuery { + protected function createModels(array $rows): array + { + return [['overridden' => true, 'rows' => $rows]]; + } + }; + + $this->assertSame( + [['overridden' => true, 'rows' => [['id' => 1]]]], + $query->populate([['id' => 1]]), + ); + } + + public function testGetPrimaryTableNameCanBeOverridden(): void + { + $query = new class (Customer::class) extends ActiveQuery { + protected function getPrimaryTableName(): string + { + return 'profile'; + } + }; + + $this->assertSame(['{{profile}}' => '{{profile}}'], $query->getTablesUsedInFrom()); } public function testPopulateKeepsAllModelsFromResultCallback(): void @@ -345,6 +372,14 @@ public function testAliasYetSet(): void $this->assertEquals(['alias' => 'old'], $query->getFrom()); } + public function testFindByPkWithAliasDoesNotPrefixBaseTableName(): void + { + $customer = Customer::query()->alias('c')->findByPk(1); + + $this->assertInstanceOf(Customer::class, $customer); + $this->assertSame(1, $customer->getId()); + } + public function testGetTableNamesNotFilledFrom(): void { $query = Profile::query(); @@ -764,6 +799,33 @@ public function testJoinWith(): void $this->assertTrue($orders[0]->getItems()[0]->isRelationPopulated('category')); } + public function testJoinWithRejectsMalformedAliasedRelationNameWithPrefix(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(Order::class . ' has no relation named "bad customer".'); + + Order::query()->joinWith(['bad customer c'])->all(); + } + + public function testJoinWithRejectsMalformedAliasedRelationNameWithSuffix(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(Order::class . ' has no relation named "customer as c".'); + + Order::query()->joinWith(['customer as c trailing'])->all(); + } + + public function testFindByPkWithJoinAndJoinWithUsesQualifiedPrimaryKey(): void + { + $order = Order::query() + ->joinWith('customer', false) + ->innerJoin('profile', '{{customer}}.{{profile_id}} = {{profile}}.{{id}}') + ->findByPk(1); + + $this->assertInstanceOf(Order::class, $order); + $this->assertSame(1, $order->getId()); + } + /** * @depends testJoinWith */ @@ -2794,6 +2856,19 @@ public function testGetViaRelationPopulatesIntermediateRelation(): void $this->assertTrue($order->isRelationPopulated('orderItems')); } + public function testGetViaRelationRestoresOriginalWhereCondition(): void + { + $order = Order::query()->findByPk(1); + $query = $order->getItemsIndexedQuery(); + + $this->assertNull($query->getWhere()); + + $items = $query->all(); + + $this->assertCount(2, $items); + $this->assertNull($query->getWhere()); + } + public function testGetViaCallableWithHasOne(): void { $order = Order::query()->findByPk(1); diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index fd1aed922..b0e19ce19 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -26,6 +26,7 @@ use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\DefaultValueAr; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\DefaultValueOnInsertAr; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Dog; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Employee; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Item; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\NoExist; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\NoPk; @@ -504,6 +505,22 @@ public function testSetProperties(): void ); } + public function testPopulatePropertiesIgnoresUnknownFields(): void + { + $customer = new Customer(); + + $customer->populateProperties([ + 'email' => 'samdark@mail.ru', + 'name' => 'samdark', + 'unknown' => 'ignored', + ]); + + $this->assertSame('samdark@mail.ru', $customer->getEmail()); + $this->assertSame('samdark', $customer->getName()); + $this->assertFalse($customer->hasProperty('unknown')); + $this->assertNull($customer->get('unknown')); + } + public function testSetPropertyNoExist(): void { self::markTestSkipped('There are no magic properties in the Cat class'); @@ -895,7 +912,11 @@ public function testPrimaryKeyValueWithoutPrimaryKey(): void $orderItem = new OrderItemWithNullFK(); $this->expectException(LogicException::class); - $this->expectExceptionMessage(OrderItemWithNullFK::class . ' does not have a primary key.'); + $this->expectExceptionMessage( + OrderItemWithNullFK::class + . ' does not have a primary key. You should either define a primary key for order_item_with_null_fk table' + . ' or override the primaryKey() method.', + ); $orderItem->primaryKeyValue(); } @@ -904,7 +925,11 @@ public function testPrimaryKeyValuesWithoutPrimaryKey(): void $orderItem = new OrderItemWithNullFK(); $this->expectException(LogicException::class); - $this->expectExceptionMessage(OrderItemWithNullFK::class . ' does not have a primary key.'); + $this->expectExceptionMessage( + OrderItemWithNullFK::class + . ' does not have a primary key. You should either define a primary key for order_item_with_null_fk table' + . ' or override the primaryKey() method.', + ); $orderItem->primaryKeyValues(); } @@ -934,7 +959,11 @@ public function testPrimaryKeyOldValueWithoutPrimaryKey(): void $orderItem = new OrderItemWithNullFK(); $this->expectException(LogicException::class); - $this->expectExceptionMessage(OrderItemWithNullFK::class . ' does not have a primary key.'); + $this->expectExceptionMessage( + OrderItemWithNullFK::class + . ' does not have a primary key. You should either define a primary key for order_item_with_null_fk table' + . ' or override the primaryKey() method.', + ); $orderItem->primaryKeyOldValue(); } @@ -956,7 +985,11 @@ public function testPrimaryKeyOldValuesWithoutPrimaryKey(): void $orderItem = new OrderItemWithNullFK(); $this->expectException(LogicException::class); - $this->expectExceptionMessage(OrderItemWithNullFK::class . ' does not have a primary key.'); + $this->expectExceptionMessage( + OrderItemWithNullFK::class + . ' does not have a primary key. You should either define a primary key for order_item_with_null_fk table' + . ' or override the primaryKey() method.', + ); $orderItem->primaryKeyOldValues(); } @@ -1501,6 +1534,20 @@ public function testUpsertWithException(): void $customer->upsert(); } + public function testUpsertUpdatesExistingRecordByDefault(): void + { + $this->reloadFixtureAfterTest(); + + $customer = new DefaultValueOnInsertAr(); + $customer->id = 1; + $customer->name = 'updated-via-default-upsert'; + + $customer->upsert(); + + $reloadedCustomer = DefaultValueOnInsertAr::query()->findByPk(1); + $this->assertSame('updated-via-default-upsert', $reloadedCustomer->name); + } + public function testTimestampBehavior(): void { $this->reloadFixtureAfterTest(); @@ -1740,6 +1787,57 @@ public function testLinkExistingRecordToNewWithSharedPrimaryKey(): void $this->assertFalse($profile->isNew()); } + public function testLinkNewRecordToExistingWithCustomCompositeSharedPrimaryKey(): void + { + $this->reloadFixtureAfterTest(); + + $employee = Employee::query()->findByPk([2, 2]); + $this->assertNotNull($employee); + + $dossier = new class () extends \Yiisoft\ActiveRecord\ActiveRecord { + protected ?int $id = null; + protected int $department_id; + protected int $employee_id; + protected string $summary; + + public function tableName(): string + { + return 'dossier'; + } + + public function primaryKey(): array + { + return ['department_id', 'employee_id']; + } + + public function relationQuery(string $name): \Yiisoft\ActiveRecord\ActiveQueryInterface + { + return match ($name) { + 'employee' => $this->hasOne(Employee::class, [ + 'department_id' => 'department_id', + 'id' => 'employee_id', + ]), + default => parent::relationQuery($name), + }; + } + }; + $dossier->set('id', 99); + $dossier->set('summary', 'Linked via shared composite key'); + + $dossier->link('employee', $employee); + + $this->assertSame(2, $dossier->get('department_id')); + $this->assertSame(2, $dossier->get('employee_id')); + $this->assertFalse($dossier->isNew()); + $this->assertTrue( + self::db()->createQuery()->from('{{dossier}}')->where([ + 'department_id' => 2, + 'employee_id' => 2, + 'summary' => 'Linked via shared composite key', + ])->exists(), + ); + } + public function testLinkWithNonPrimaryKeyFields(): void { $this->reloadFixtureAfterTest(); @@ -1806,6 +1904,30 @@ public function testMarkPropertyChanged(): void $this->assertSame($expectedAffectedRows, $affectedRows); } + public function testResetsIntermediateViaRelationWhenLinkPropertyChanges(): void + { + $order = (new class () extends Order { + public function relationQuery(string $name): \Yiisoft\ActiveRecord\ActiveQueryInterface + { + return match ($name) { + 'customerProfileViaCustomer' => $this->getCustomerProfileViaCustomerQuery(), + default => parent::relationQuery($name), + }; + } + })::query()->findByPk(1); + + $profile = $order->getCustomerProfileViaCustomer(); + + $this->assertInstanceOf(Profile::class, $profile); + $this->assertTrue($order->isRelationPopulated('customerProfileViaCustomer')); + $this->assertTrue($order->isRelationPopulated('customer')); + + $order->setCustomerId(2); + + $this->assertFalse($order->isRelationPopulated('customerProfileViaCustomer')); + $this->assertFalse($order->isRelationPopulated('customer')); + } + public function testMarkAsNew(): void { $this->reloadFixtureAfterTest(); @@ -1988,6 +2110,30 @@ public function testUnlinkViaTableWithDelete(): void ); } + public function testUnlinkViaTableWithoutDelete(): void + { + $this->reloadFixtureAfterTest(); + + $order = Order::query()->findByPk(1); + $book = $order->getBooksWithNullFKViaTable()[0]; + $initialNullLinksCount = self::db() + ->createQuery() + ->from('{{order_item_with_null_fk}}') + ->where(['order_id' => null, 'item_id' => null]) + ->count(); + + $order->unlink('booksWithNullFKViaTable', $book, false); + + $this->assertCount(1, $order->getBooksWithNullFKViaTable()); + $this->assertSame( + $initialNullLinksCount + 1, + self::db()->createQuery()->from('{{order_item_with_null_fk}}')->where([ + 'order_id' => null, + 'item_id' => null, + ])->count(), + ); + } + public function testUnlinkHasOneWithoutDelete(): void { $this->reloadFixtureAfterTest(); @@ -2022,6 +2168,94 @@ public function testUpdateCountersThrowsExceptionForNewRecord(): void $orderItem->updateCounters(['quantity' => 1]); } + public function testUpdateCountersUsesZeroForNullCurrentValue(): void + { + $this->reloadFixtureAfterTest(); + + $customer = new Customer(); + $customer->setEmail('counter-null@example.com'); + $customer->setName('Counter Null'); + $customer->setAddress('Counter street'); + $customer->save(); + + $customer->updateCounters(['status' => 1]); + + $this->assertSame(1, $customer->getStatus()); + $this->assertSame(1, Customer::query()->findByPk($customer->getId())->getStatus()); + } + + public function testCreateRelationQueryCanBeOverridden(): void + { + $customer = new class () extends Customer { + public function relationQuery(string $name): \Yiisoft\ActiveRecord\ActiveQueryInterface + { + return match ($name) { + 'profile' => $this->hasOne(Profile::class, ['id' => 'profile_id']), + default => parent::relationQuery($name), + }; + } + + protected function createRelationQuery( + \Yiisoft\ActiveRecord\ActiveRecordInterface|string $modelClass, + array $link, + bool $multiple, + ): \Yiisoft\ActiveRecord\ActiveQueryInterface { + return new class ($modelClass) extends ActiveQuery {}; + } + }; + + $query = $customer->relationQuery('profile'); + + $this->assertNotSame(ActiveQuery::class, $query::class); + } + + public function testDeleteInternalCanBeOverridden(): void + { + $this->reloadFixtureAfterTest(); + + $customer = (new class () extends Customer { + protected function deleteInternal(): int + { + return 77; + } + })::query()->findByPk(1); + + $this->assertSame(77, $customer->delete()); + $this->assertNotNull(Customer::query()->findByPk(1)); + } + + public function testRefreshInternalCanBeOverridden(): void + { + $customer = (new class () extends Customer { + protected function refreshInternal( + array|\Yiisoft\ActiveRecord\ActiveRecordInterface|null $record = null, + ): bool { + $this->setName('refreshed-via-override'); + return true; + } + })::query()->findByPk(1); + + $this->assertTrue($customer->refresh()); + $this->assertSame('refreshed-via-override', $customer->getName()); + } + + public function testUpdateInternalCanBeOverridden(): void + { + $this->reloadFixtureAfterTest(); + + $customer = (new class () extends Customer { + protected function updateInternal(?array $properties = null): int + { + return 91; + } + })::query()->findByPk(1); + + $customer->setName('should-not-hit-db'); + + $this->assertSame(91, $customer->update()); + $this->assertSame('user1', Customer::query()->findByPk(1)->getName()); + } + public function testGetAllWithHasOneAndArrayValue(): void { $promotions = Promotion::query()->with('singleItem')->andWhere(['id' => [1, 2]])->all(); From 9e400ec6a4ab46015b4b1520f47de6cba5685901 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Fri, 13 Mar 2026 17:06:27 +0400 Subject: [PATCH 15/66] Add comprehensive tests for `ArArrayHelper`, `ModelRelationFilter`, and `RelationPopulator` --- tests/ActiveQueryTest.php | 203 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) diff --git a/tests/ActiveQueryTest.php b/tests/ActiveQueryTest.php index 92c1cbe74..67875030a 100644 --- a/tests/ActiveQueryTest.php +++ b/tests/ActiveQueryTest.php @@ -11,6 +11,8 @@ use Yiisoft\ActiveRecord\ActiveQuery; use Yiisoft\ActiveRecord\ActiveQueryInterface; use Yiisoft\ActiveRecord\Internal\ArArrayHelper; +use Yiisoft\ActiveRecord\Internal\ModelRelationFilter; +use Yiisoft\ActiveRecord\Internal\RelationPopulator; use Yiisoft\ActiveRecord\JoinWith; use Yiisoft\ActiveRecord\OptimisticLockException; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\BitValues; @@ -36,6 +38,7 @@ use Yiisoft\Db\Exception\InvalidConfigException; use Yiisoft\Db\Expression\Expression; use Yiisoft\Db\Query\QueryInterface; +use Yiisoft\Db\QueryBuilder\Condition\In; use RuntimeException; use function sort; @@ -96,6 +99,23 @@ public function testPopulateEmptyRows(): void $this->assertSame([], $query->populate([])); } + public function testArArrayHelperGetValueByPathReturnsActiveRecordProperty(): void + { + $customer = Customer::query()->findByPk(1); + + $this->assertSame('user1', ArArrayHelper::getValueByPath($customer, 'name', 'default')); + } + + public function testArArrayHelperIndexCastsFloatKeyToString(): void + { + $indexed = ArArrayHelper::index([ + ['id' => 1.5, 'name' => 'float-key'], + ], 'id'); + + $this->assertArrayHasKey('1.5', $indexed); + $this->assertSame('float-key', $indexed['1.5']['name']); + } + public function testCreateModelsCanBeOverridden(): void { $query = new class (Customer::class) extends ActiveQuery { @@ -123,6 +143,72 @@ protected function getPrimaryTableName(): string $this->assertSame(['{{profile}}' => '{{profile}}'], $query->getTablesUsedInFrom()); } + public function testModelRelationFilterFlattensAndDeduplicatesArrayValues(): void + { + $query = Item::query()->link(['id' => 'item_ids']); + + ModelRelationFilter::apply($query, [ + ['item_ids' => [1, 2]], + ['item_ids' => [2, 3]], + ]); + + $where = $query->getWhere(); + + $this->assertInstanceOf(In::class, $where); + $this->assertSame('id', $where->column); + $this->assertSame([1, 2, 3], array_values($where->values)); + } + + public function testModelRelationFilterSingleColumnEmulatesExecutionWhenValuesMissing(): void + { + $query = Item::query()->link(['id' => 'missing']); + + ModelRelationFilter::apply($query, [[]]); + + $this->assertTrue($query->shouldEmulateExecution()); + $this->assertSame('1=0', $query->getWhere()); + } + + public function testModelRelationFilterCompositeKeysFillMissingValuesWithNull(): void + { + $query = Dossier::query()->link(['department_id' => 'department_id', 'employee_id' => 'employee_id']); + + ModelRelationFilter::apply($query, [ + ['department_id' => 2], + ]); + + $where = $query->getWhere(); + + $this->assertInstanceOf(In::class, $where); + $this->assertSame(['department_id', 'employee_id'], array_values($where->column)); + $this->assertSame([['department_id' => 2, 'employee_id' => null]], array_values($where->values)); + } + + public function testModelRelationFilterCompositeEmulatesExecutionWhenValuesMissing(): void + { + $query = Dossier::query()->link(['department_id' => 'department_id', 'employee_id' => 'employee_id']); + + ModelRelationFilter::apply($query, [[]]); + + $this->assertTrue($query->shouldEmulateExecution()); + $this->assertSame('1=0', $query->getWhere()); + } + + public function testModelRelationFilterThrowsForExpressionFromWithoutAlias(): void + { + $query = Item::query() + ->from([new Expression('(SELECT 1)')]) + ->join('INNER JOIN', 'customer', '1=1') + ->link(['id' => 'item_ids']); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Alias must be set for a table specified by an expression.'); + + ModelRelationFilter::apply($query, [ + ['item_ids' => [1]], + ]); + } + public function testPopulateKeepsAllModelsFromResultCallback(): void { $query = Customer::query()->resultCallback(static fn(array $rows): array => [ @@ -826,6 +912,60 @@ public function testFindByPkWithJoinAndJoinWithUsesQualifiedPrimaryKey(): void $this->assertSame(1, $order->getId()); } + public function testJoinWithViaTableAddsOnlyIntermediateAndChildJoins(): void + { + $query = Order::query()->joinWith('books'); + $query->prepare($this->db()->getQueryBuilder()); + + $joins = $query->getJoins(); + + $this->assertCount(2, $joins); + $this->assertSame('order_item', $joins[0][1]); + $this->assertSame('item', $joins[1][1]); + } + + public function testJoinWithViaRelationAddsOnlyExpectedJoins(): void + { + $query = Customer::query()->joinWith('items2'); + $query->prepare($this->db()->getQueryBuilder()); + + $joins = $query->getJoins(); + + $this->assertCount(3, $joins); + $this->assertSame('order', $joins[0][1]); + $this->assertSame('order_item', $joins[1][1]); + $this->assertSame('item', $joins[2][1]); + } + + public function testJoinWithKeepsDistinctJoinsForDifferentTableSpecifications(): void + { + $query = Order::query()->joinWith(['books', 'books2']); + $query->prepare($this->db()->getQueryBuilder()); + + $joins = $query->getJoins(); + + $this->assertCount(3, $joins); + $this->assertSame('order_item', $joins[0][1]); + $this->assertSame('item', $joins[1][1]); + $this->assertSame(['order_item'], $joins[2][1]); + } + + public function testJoinWithAppliesOrderByWhereAndParamsFromChildRelation(): void + { + $query = Order::query()->joinWith([ + 'customer' => static function (ActiveQueryInterface $relation): void { + $relation->orderBy(['id' => SORT_DESC]); + $relation->andWhere(['id' => 2]); + $relation->addParams([':custom' => 42]); + }, + ]); + $query->prepare($this->db()->getQueryBuilder()); + + $this->assertSame(['id' => SORT_DESC], $query->getOrderBy()); + $this->assertSame([':custom' => 42], $query->getParams()); + $this->assertIsArray($query->getWhere()); + } + /** * @depends testJoinWith */ @@ -2869,6 +3009,69 @@ public function testGetViaRelationRestoresOriginalWhereCondition(): void $this->assertNull($query->getWhere()); } + public function testRelationPopulatorReturnsAllRelatedModelsForMultiplePrimaries(): void + { + $customers = [ + Customer::query()->findByPk(1), + Customer::query()->findByPk(2), + ]; + + $query = $customers[0]->getOrdersQuery()->primaryModel(null); + $orders = RelationPopulator::populate($query, 'orders', $customers); + + $this->assertCount(3, $orders); + $this->assertCount(1, $customers[0]->getOrders()); + $this->assertCount(2, $customers[1]->getOrders()); + } + + public function testRelationPopulatorFiltersRelatedModelsForSpecificPrimaries(): void + { + $customers = [ + Customer::query()->findByPk(1), + Customer::query()->findByPk(3), + ]; + + $query = $customers[0]->getOrdersQuery()->primaryModel(null); + $orders = RelationPopulator::populate($query, 'orders', $customers); + + $this->assertCount(1, $orders); + $this->assertCount(1, $customers[0]->getOrders()); + $this->assertSame([], $customers[1]->getOrders()); + } + + public function testRelationPopulatorRestoresIndexByAfterPopulation(): void + { + $customers = [ + Customer::query()->findByPk(1), + Customer::query()->findByPk(2), + ]; + + $query = $customers[0]->getOrdersIndexedWithInverseOfQuery()->primaryModel(null); + + $this->assertSame('id', $query->getIndexBy()); + + RelationPopulator::populate($query, 'ordersIndexedWithInverseOf', $customers); + + $this->assertSame('id', $query->getIndexBy()); + } + + public function testRelationPopulatorHandlesDeepViaRelation(): void + { + $categories = [ + Category::query()->findByPk(1), + Category::query()->findByPk(2), + ]; + + $query = $categories[0]->getOrdersQuery()->primaryModel(null); + $orders = RelationPopulator::populate($query, 'orders', $categories); + + $this->assertCount(3, $orders); + $this->assertCount(2, $categories[0]->getOrders()); + $this->assertCount(1, $categories[1]->getOrders()); + $this->assertSame([1, 3], ArArrayHelper::getColumn($categories[0]->getOrders(), 'id')); + $this->assertSame([2], ArArrayHelper::getColumn($categories[1]->getOrders(), 'id')); + } + public function testGetViaCallableWithHasOne(): void { $order = Order::query()->findByPk(1); From 5f106e9c58d4df6ab57f704361ac056a7ef7c1b7 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Fri, 13 Mar 2026 17:36:20 +0400 Subject: [PATCH 16/66] Add new test cases for ActiveQuery, ActiveRecord, traits, and event handlers --- tests/ActiveQueryTest.php | 86 ++++++++++++++++++ tests/ActiveRecordTest.php | 48 ++++++++++ tests/EventsTraitTest.php | 155 ++++++++++++++++++++++++++++++++ tests/MagicActiveRecordTest.php | 10 +++ tests/RepositoryTraitTest.php | 8 ++ 5 files changed, 307 insertions(+) diff --git a/tests/ActiveQueryTest.php b/tests/ActiveQueryTest.php index 67875030a..30a915dbe 100644 --- a/tests/ActiveQueryTest.php +++ b/tests/ActiveQueryTest.php @@ -13,6 +13,7 @@ use Yiisoft\ActiveRecord\Internal\ArArrayHelper; use Yiisoft\ActiveRecord\Internal\ModelRelationFilter; use Yiisoft\ActiveRecord\Internal\RelationPopulator; +use Yiisoft\ActiveRecord\Internal\TableNameAndAliasResolver; use Yiisoft\ActiveRecord\JoinWith; use Yiisoft\ActiveRecord\OptimisticLockException; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\BitValues; @@ -116,6 +117,91 @@ public function testArArrayHelperIndexCastsFloatKeyToString(): void $this->assertSame('float-key', $indexed['1.5']['name']); } + public function testPopulateRemovesDuplicateRowsUsingSinglePrimaryKey(): void + { + $rows = [ + [ + 'id' => 1, + 'email' => 'first@example.com', + 'name' => 'First', + 'address' => 'First street', + 'status' => 1, + 'bool_status' => true, + 'profile_id' => 1, + ], + [ + 'id' => '1', + 'email' => 'second@example.com', + 'name' => 'Second', + 'address' => 'Second street', + 'status' => 1, + 'bool_status' => true, + 'profile_id' => 1, + ], + ]; + + $models = Customer::query()->leftJoin('profile', '1=1')->asArray()->populate($rows); + + $this->assertCount(1, $models); + $this->assertSame('second@example.com', $models[0]['email']); + $this->assertSame('Second street', $models[0]['address']); + } + + public function testPopulateRemovesDuplicateRowsUsingCompositePrimaryKey(): void + { + $rows = [ + [ + 'order_id' => 1, + 'item_id' => 2, + 'quantity' => 1, + 'subtotal' => 10.0, + ], + [ + 'order_id' => 1, + 'item_id' => 2, + 'quantity' => 7, + 'subtotal' => 70.0, + ], + ]; + + $models = OrderItem::query()->leftJoin('item', '1=1')->asArray()->populate($rows); + + $this->assertCount(1, $models); + $this->assertSame(7, $models[0]['quantity']); + $this->assertSame(70.0, $models[0]['subtotal']); + } + + public function testPopulateKeepsDistinctRowsForDifferentCompositePrimaryKeys(): void + { + $rows = [ + [ + 'order_id' => 1, + 'item_id' => 1, + 'quantity' => 1, + 'subtotal' => 10.0, + ], + [ + 'order_id' => 1, + 'item_id' => 2, + 'quantity' => 2, + 'subtotal' => 20.0, + ], + ]; + + $models = OrderItem::query()->leftJoin('item', '1=1')->asArray()->populate($rows); + + $this->assertCount(2, $models); + $this->assertSame([1, 2], array_column($models, 'item_id')); + } + + public function testGetTableNameAndAliasDoesNotTrimTrailingGarbage(): void + { + [$tableName, $alias] = TableNameAndAliasResolver::resolve(Customer::query()->from(['customer c!'])); + + $this->assertSame('customer c!', $tableName); + $this->assertSame('customer c!', $alias); + } + public function testCreateModelsCanBeOverridden(): void { $query = new class (Customer::class) extends ActiveQuery { diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index b0e19ce19..76b2cd371 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -1098,6 +1098,25 @@ public function testWithCustomConnection(): void ConnectionProvider::remove('custom'); } + public function testWithCustomConnectionReturnsClone(): void + { + $db = $this->createConnection(); + + ConnectionProvider::set($db, 'custom'); + DbHelper::loadFixture($db); + + $customer = new CustomerWithCustomConnection(); + $customerWithCustomConnection = $customer->withConnectionName('custom'); + + $this->assertNotSame($customer, $customerWithCustomConnection); + $this->assertSame($this->db(), $customer->db()); + $this->assertSame($db, $customerWithCustomConnection->db()); + + $db->close(); + + ConnectionProvider::remove('custom'); + } + public function testWithFactory(): void { $factory = $this->createFactory(); @@ -1171,6 +1190,25 @@ public function testWithFactoryNonInitiated(): void $order->getCustomerWithFactory(); } + public function testWithFactoryReturnsCloneAndKeepsOriginalUnchanged(): void + { + $factory = $this->createFactory(); + $order = new OrderWithFactory(); + $orderWithFactory = $order->withFactory($factory); + + $this->assertNotSame($order, $orderWithFactory); + + $loadedOrder = (new ActiveQuery($orderWithFactory))->findByPk(2); + $this->assertInstanceOf(CustomerWithFactory::class, $loadedOrder->getCustomerWithFactory()); + + $loadedOrderWithoutFactory = (new ActiveQuery($order))->findByPk(2); + + $this->expectException(ArgumentCountError::class); + $this->expectExceptionMessage('Too few arguments to function'); + + $loadedOrderWithoutFactory->getCustomerWithFactory(); + } + public function testSerialization(): void { $profile = new Profile(); @@ -1904,6 +1942,16 @@ public function testMarkPropertyChanged(): void $this->assertSame($expectedAffectedRows, $affectedRows); } + public function testMarkPropertyChangedWithEmptyNameDoesNotModifyOldValues(): void + { + $customer = new Customer(); + $customer->assignOldValues(['' => 'sentinel', 'name' => 'user1']); + + $customer->markPropertyChanged(''); + + $this->assertSame(['' => 'sentinel', 'name' => 'user1'], $customer->oldValues()); + } + public function testResetsIntermediateViaRelationWhenLinkPropertyChanges(): void { $order = (new class () extends Order { diff --git a/tests/EventsTraitTest.php b/tests/EventsTraitTest.php index 3fc571875..06b52c2ab 100644 --- a/tests/EventsTraitTest.php +++ b/tests/EventsTraitTest.php @@ -19,7 +19,13 @@ use Yiisoft\ActiveRecord\Event\EventDispatcherProvider; use Yiisoft\ActiveRecord\Event\Handler\DefaultDateTimeOnInsert; use Yiisoft\ActiveRecord\Event\Handler\DefaultValue; +use Yiisoft\ActiveRecord\Event\Handler\DefaultValueOnInsert; +use Yiisoft\ActiveRecord\Event\Handler\SetDateTimeOnUpdate; +use Yiisoft\ActiveRecord\Event\Handler\SetValueOnUpdate; +use Yiisoft\ActiveRecord\Event\Handler\SoftDelete; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CategoryEventsModel; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Customer; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\DefaultValueOnInsertAr; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Order; use Yiisoft\Test\Support\EventDispatcher\SimpleEventDispatcher; @@ -337,6 +343,155 @@ public function testAttributeHandlerProviderPropertyNamesArePublic(): void $this->assertSame(['name', 'status'], $handler->getPropertyNames()); } + public function testDefaultValueOnInsertBeforeUpsertInitializesInsertPropertiesFromPropertyNames(): void + { + $model = new DefaultValueOnInsertAr(); + $model->id = 7; + + $insertProperties = null; + $updateProperties = true; + $event = new BeforeUpsert($model, $insertProperties, $updateProperties); + + $handler = new DefaultValueOnInsert('Vasya', 'name'); + $eventHandlers = $handler->getEventHandlers(); + $beforeUpsert = $eventHandlers[BeforeUpsert::class]; + $beforeUpsert($event); + + $this->assertSame( + [0 => 'id', 1 => 'name', 'name' => 'Vasya'], + $insertProperties, + ); + } + + public function testDefaultValueOnInsertBeforeUpsertPreservesExistingInsertProperties(): void + { + $model = new DefaultValueOnInsertAr(); + $model->id = 7; + + $insertProperties = ['id']; + $updateProperties = true; + $event = new BeforeUpsert($model, $insertProperties, $updateProperties); + + $handler = new DefaultValueOnInsert('Vasya', 'name'); + $eventHandlers = $handler->getEventHandlers(); + $beforeUpsert = $eventHandlers[BeforeUpsert::class]; + $beforeUpsert($event); + + $this->assertSame( + [0 => 'id', 'name' => 'Vasya'], + $insertProperties, + ); + } + + public function testSetDateTimeOnUpdateUsesCustomValue(): void + { + $dateTime = new DateTimeImmutable('2020-01-02 03:04:05'); + $properties = null; + $event = new BeforeUpdate(new Order(), $properties); + + $handler = new SetDateTimeOnUpdate($dateTime, 'updated_at'); + $eventHandlers = $handler->getEventHandlers(); + $beforeUpdate = $eventHandlers[BeforeUpdate::class]; + $beforeUpdate($event); + + $this->assertSame($dateTime, $event->model->get('updated_at')); + } + + public function testSetValueOnUpdateBeforeUpsertKeepsConfiguredPropertyNamesAndAccumulatedUpdates(): void + { + $model = new Customer(); + $model->setId(1); + + $insertProperties = ['id' => 1]; + $updateProperties = ['status' => 1]; + $event = new BeforeUpsert($model, $insertProperties, $updateProperties); + + $handler = new SetValueOnUpdate('Updated', 'name', 'email'); + $eventHandlers = $handler->getEventHandlers(); + $beforeUpsert = $eventHandlers[BeforeUpsert::class]; + $beforeUpsert($event); + + $this->assertSame(['name', 'email'], $handler->getPropertyNames()); + $this->assertSame( + ['status' => 1, 'name' => 'Updated', 'email' => 'Updated'], + $updateProperties, + ); + } + + public function testSetValueOnUpdateBeforeUpsertUsesInsertPropertiesWithoutPrimaryKeys(): void + { + $model = new Customer(); + $model->setId(1); + $model->setEmail('model@example.com'); + + $insertProperties = ['id' => 1, 'email' => 'insert@example.com']; + $updateProperties = true; + $event = new BeforeUpsert($model, $insertProperties, $updateProperties); + + $handler = new SetValueOnUpdate('Updated', 'name'); + $eventHandlers = $handler->getEventHandlers(); + $beforeUpsert = $eventHandlers[BeforeUpsert::class]; + $beforeUpsert($event); + + $this->assertSame( + ['email' => 'insert@example.com', 'name' => 'Updated'], + $updateProperties, + ); + } + + public function testSetValueOnUpdateBeforeUpsertAddsPropertyWhenUpdatesAreDisabled(): void + { + $model = new Customer(); + $model->setId(1); + + $insertProperties = ['id' => 1]; + $updateProperties = false; + $event = new BeforeUpsert($model, $insertProperties, $updateProperties); + + $handler = new SetValueOnUpdate('Updated', 'name'); + $eventHandlers = $handler->getEventHandlers(); + $beforeUpsert = $eventHandlers[BeforeUpsert::class]; + $beforeUpsert($event); + + $this->assertSame(['name' => 'Updated'], $updateProperties); + } + + public function testSoftDeleteBeforeDeleteUsesCustomValueAndPreventsDefault(): void + { + $this->reloadFixtureAfterTest(); + + $order = Order::query()->findByPk(1); + $dateTime = new DateTimeImmutable('2021-02-03 04:05:06'); + $event = new BeforeDelete($order); + + $handler = new SoftDelete($dateTime, 'deleted_at'); + $eventHandlers = $handler->getEventHandlers(); + $beforeDelete = $eventHandlers[BeforeDelete::class]; + $beforeDelete($event); + + $this->assertTrue($event->isDefaultPrevented()); + $this->assertSame(1, $event->getReturnValue()); + $this->assertSame($dateTime, $order->get('deleted_at')); + } + + public function testSoftDeleteBeforeDeleteSkipsAlreadyDeletedProperty(): void + { + $this->reloadFixtureAfterTest(); + + $order = Order::query()->findByPk(1); + $order->set('deleted_at', new DateTimeImmutable('2020-01-01 00:00:00')); + + $event = new BeforeDelete($order); + $handler = new SoftDelete(new DateTimeImmutable('2022-03-04 05:06:07'), 'deleted_at'); + $eventHandlers = $handler->getEventHandlers(); + $beforeDelete = $eventHandlers[BeforeDelete::class]; + $beforeDelete($event); + + $this->assertTrue($event->isDefaultPrevented()); + $this->assertNull($event->getReturnValue()); + $this->assertNull(self::db()->select('deleted_at')->from('{{order}}')->where(['id' => 1])->scalar()); + } + public function testDefaultDateTimeOnInsertUsesCustomValue(): void { $dateTime = new DateTimeImmutable('2024-01-01 12:34:56'); diff --git a/tests/MagicActiveRecordTest.php b/tests/MagicActiveRecordTest.php index af198f745..8b72ba302 100644 --- a/tests/MagicActiveRecordTest.php +++ b/tests/MagicActiveRecordTest.php @@ -563,6 +563,16 @@ public function testCanSetPropertyWithoutCheckingVars(): void $this->assertFalse($customer->canSetProperty('non_existing_property', false)); } + public function testCanGetAndSetMemberVariableDependOnCheckVars(): void + { + $customer = new Customer(); + + $this->assertTrue($customer->canGetProperty('status2')); + $this->assertFalse($customer->canGetProperty('status2', false)); + $this->assertTrue($customer->canSetProperty('status2')); + $this->assertFalse($customer->canSetProperty('status2', false)); + } + public function testHasRelationQuery(): void { $customer = new Customer(); diff --git a/tests/RepositoryTraitTest.php b/tests/RepositoryTraitTest.php index ed8e96ad2..c1100dffa 100644 --- a/tests/RepositoryTraitTest.php +++ b/tests/RepositoryTraitTest.php @@ -21,6 +21,14 @@ public function testFind(): void ); } + public function testFindWithoutConditionIgnoresParams(): void + { + $query = Customer::find(null, [':id' => 1]); + + $this->assertNull($query->getWhere()); + $this->assertSame([], $query->getParams()); + } + public function testFindOne(): void { $customerQuery = Customer::query(); From 0517a0158960394e32d9d3635d70bfdb89c30ea0 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Fri, 13 Mar 2026 17:56:49 +0400 Subject: [PATCH 17/66] Fix spacing in JSON array assertion in ActiveRecordTest --- tests/ActiveRecordTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index 76b2cd371..38735585a 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -2135,7 +2135,7 @@ public function testUnlinkAllWithArrayValuedPropertyAndDelete(): void $promotion = Promotion::query()->findByPk(1); $promotion->unlinkAll('itemsViaJson', true); - $this->assertSame([1, 2], $promotion->json_item_ids); + $this->assertSame([1,2], $promotion->json_item_ids); $this->assertCount(0, Item::query()->where(['id' => [1, 2]])->all()); $this->assertSame( '[1, 2]', From 91c4b35e7edc99b1742c90147122da5eac956d5c Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Fri, 13 Mar 2026 18:07:38 +0400 Subject: [PATCH 18/66] Fixes inconsistent formatting in `ActiveRecordTest` assertions for JSON handling --- tests/ActiveRecordTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index 38735585a..a93351b3f 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -2135,10 +2135,10 @@ public function testUnlinkAllWithArrayValuedPropertyAndDelete(): void $promotion = Promotion::query()->findByPk(1); $promotion->unlinkAll('itemsViaJson', true); - $this->assertSame([1,2], $promotion->json_item_ids); + $this->assertSame([1, 2], $promotion->json_item_ids); $this->assertCount(0, Item::query()->where(['id' => [1, 2]])->all()); $this->assertSame( - '[1, 2]', + '[1,2]', self::db()->select('json_item_ids')->from('{{promotion}}')->where(['id' => 1])->scalar(), ); } From 8daa965ac7628ad8880cff8f50c5f15d901f1dd9 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Fri, 13 Mar 2026 18:15:52 +0400 Subject: [PATCH 19/66] Fixes inconsistent formatting in `ActiveRecordTest` assertions for JSON handling --- tests/ActiveQueryTest.php | 153 +++++++++++++++++++++++++++++++++ tests/ActiveRecordTest.php | 144 ++++++++++++++++++++++++++++++- tests/ArrayAccessTraitTest.php | 27 ++++++ 3 files changed, 323 insertions(+), 1 deletion(-) diff --git a/tests/ActiveQueryTest.php b/tests/ActiveQueryTest.php index 30a915dbe..e2de930e9 100644 --- a/tests/ActiveQueryTest.php +++ b/tests/ActiveQueryTest.php @@ -25,6 +25,7 @@ use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Employee; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Item; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\NoPk; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\NullValues; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Order; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\OrderItem; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\OrderItemWithNullFK; @@ -107,6 +108,14 @@ public function testArArrayHelperGetValueByPathReturnsActiveRecordProperty(): vo $this->assertSame('user1', ArArrayHelper::getValueByPath($customer, 'name', 'default')); } + public function testArArrayHelperGetValueByPathReturnsColumnWithoutDeclaredProperty(): void + { + $record = new NullValues(); + $record->set('var1', 123); + + $this->assertSame(123, ArArrayHelper::getValueByPath($record, 'var1', 'default')); + } + public function testArArrayHelperIndexCastsFloatKeyToString(): void { $indexed = ArArrayHelper::index([ @@ -202,6 +211,42 @@ public function testGetTableNameAndAliasDoesNotTrimTrailingGarbage(): void $this->assertSame('customer c!', $alias); } + public function testPopulateEmptyRowsDoesNotCallCreateModels(): void + { + $query = new class (Customer::class) extends ActiveQuery { + protected function createModels(array $rows): array + { + throw new RuntimeException('createModels() should not be called for empty rows.'); + } + }; + + $this->assertSame([], $query->populate([])); + } + + public function testRemoveDuplicatedRowsChecksPrimaryKeyPresenceInFirstRow(): void + { + $query = Customer::query(); + $method = new \ReflectionMethod(ActiveQuery::class, 'removeDuplicatedRows'); + $method->setAccessible(true); + + $rows = [ + ['email' => 'missing-id@example.com'], + ['id' => 1, 'email' => 'user1@example.com'], + ]; + + set_error_handler( + static fn(int $severity, string $message): never => throw new \ErrorException($message, 0, $severity), + ); + + try { + $result = $method->invoke($query, $rows); + } finally { + restore_error_handler(); + } + + $this->assertSame($rows, $result); + } + public function testCreateModelsCanBeOverridden(): void { $query = new class (Customer::class) extends ActiveQuery { @@ -245,6 +290,20 @@ public function testModelRelationFilterFlattensAndDeduplicatesArrayValues(): voi $this->assertSame([1, 2, 3], array_values($where->values)); } + public function testModelRelationFilterSeparatesScalarAndNonScalarValues(): void + { + $query = Item::query()->link(['id' => 'item_ids']); + + ModelRelationFilter::apply($query, [ + ['item_ids' => [1, [2], 1]], + ]); + + $where = $query->getWhere(); + + $this->assertInstanceOf(In::class, $where); + $this->assertSame([1, [2]], array_values($where->values)); + } + public function testModelRelationFilterSingleColumnEmulatesExecutionWhenValuesMissing(): void { $query = Item::query()->link(['id' => 'missing']); @@ -270,6 +329,55 @@ public function testModelRelationFilterCompositeKeysFillMissingValuesWithNull(): $this->assertSame([['department_id' => 2, 'employee_id' => null]], array_values($where->values)); } + public function testModelRelationFilterCompositeKeysFillMissingValuesWithNullForActiveRecordModel(): void + { + $model = new class () extends \Yiisoft\ActiveRecord\ActiveRecord { + protected int $department_id; + protected int $employee_id; + + public function tableName(): string + { + return 'dossier'; + } + }; + $model->set('department_id', 2); + + $query = Dossier::query() + ->from(['d' => 'dossier']) + ->join('INNER JOIN', 'employee e', '1=1') + ->link(['department_id' => 'department_id', 'employee_id' => 'employee_id']); + + ModelRelationFilter::apply($query, [$model]); + + $where = $query->getWhere(); + + $this->assertInstanceOf(In::class, $where); + $this->assertSame( + [['d.department_id' => 2, 'd.employee_id' => null]], + array_values($where->values), + ); + } + + public function testModelRelationFilterCompositeArrayModelsUseQualifiedColumnNames(): void + { + $query = Dossier::query() + ->from(['d' => 'dossier']) + ->join('INNER JOIN', 'employee e', '1=1') + ->link(['department_id' => 'department_id', 'employee_id' => 'employee_id']); + + ModelRelationFilter::apply($query, [ + ['department_id' => 2], + ]); + + $where = $query->getWhere(); + + $this->assertInstanceOf(In::class, $where); + $this->assertSame( + [['d.department_id' => 2, 'd.employee_id' => null]], + array_values($where->values), + ); + } + public function testModelRelationFilterCompositeEmulatesExecutionWhenValuesMissing(): void { $query = Dossier::query()->link(['department_id' => 'department_id', 'employee_id' => 'employee_id']); @@ -1052,6 +1160,22 @@ public function testJoinWithAppliesOrderByWhereAndParamsFromChildRelation(): voi $this->assertIsArray($query->getWhere()); } + public function testJoinWithDoesNotPrepareChildRelationWithoutNestedJoins(): void + { + $capturedRelation = null; + + $query = Order::query()->joinWith([ + 'customer' => static function (ActiveQueryInterface $relation) use (&$capturedRelation): void { + $capturedRelation = $relation; + }, + ]); + $query->prepare($this->db()->getQueryBuilder()); + + $this->assertInstanceOf(ActiveQueryInterface::class, $capturedRelation); + $this->assertSame([], $capturedRelation->getJoinsWith()); + $this->assertSame([], $capturedRelation->getFrom()); + } + /** * @depends testJoinWith */ @@ -3158,6 +3282,35 @@ public function testRelationPopulatorHandlesDeepViaRelation(): void $this->assertSame([2], ArArrayHelper::getColumn($categories[1]->getOrders(), 'id')); } + public function testRelationPopulatorGetModelKeysCastsArrayValuesToString(): void + { + $method = new \ReflectionMethod(RelationPopulator::class, 'getModelKeys'); + $method->setAccessible(true); + + $keys = $method->invoke(null, ['department_id' => '2', 'employee_id' => 2], ['department_id', 'employee_id']); + + $this->assertSame([serialize(['2', '2'])], $keys); + } + + public function testRelationPopulatorGetModelKeysCastsRecordValuesToString(): void + { + $method = new \ReflectionMethod(RelationPopulator::class, 'getModelKeys'); + $method->setAccessible(true); + + $employee = Employee::query()->findByPk([2, 2]); + $keys = $method->invoke(null, $employee, ['department_id', 'id']); + + $this->assertSame([serialize(['2', '2'])], $keys); + } + + public function testRelationPopulatorGetModelKeysReturnsEmptyArrayWhenValuesAreMissing(): void + { + $method = new \ReflectionMethod(RelationPopulator::class, 'getModelKeys'); + $method->setAccessible(true); + + $this->assertSame([], $method->invoke(null, [], ['missing'])); + } + public function testGetViaCallableWithHasOne(): void { $order = Order::query()->findByPk(1); diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index a93351b3f..9927777ec 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -1209,6 +1209,18 @@ public function testWithFactoryReturnsCloneAndKeepsOriginalUnchanged(): void $loadedOrderWithoutFactory->getCustomerWithFactory(); } + public function testWithFactoryPropagatesToNestedRelations(): void + { + $factory = $this->createFactory(); + + $order = (new ActiveQuery($factory->create(OrderWithFactory::class)->withFactory($factory)))->findByPk(2); + $customer = $order->getCustomerWithFactory(); + $relatedOrder = $customer->getOrdersWithFactory()[0]; + + $this->assertInstanceOf(OrderWithFactory::class, $relatedOrder); + $this->assertInstanceOf(CustomerWithFactory::class, $relatedOrder->getCustomerWithFactory()); + } + public function testSerialization(): void { $profile = new Profile(); @@ -1586,6 +1598,49 @@ public function testUpsertUpdatesExistingRecordByDefault(): void $this->assertSame('updated-via-default-upsert', $reloadedCustomer->name); } + public function testCustomerUpsertUpdatesExistingRecordByDefault(): void + { + $this->reloadFixtureAfterTest(); + + $customer = new Customer(); + $customer->setEmail('user1@example.com'); + $customer->setAddress('updated-address-via-default-upsert'); + + $customer->upsert(); + + $reloadedCustomer = Customer::query()->findByPk(1); + $this->assertSame('updated-address-via-default-upsert', $reloadedCustomer->getAddress()); + } + + public function testSetValueOnUpdateUpsertKeepsOtherChangedPropertiesWhenUpdatesAreImplicit(): void + { + $this->reloadFixtureAfterTest(); + + $customer = new class () extends \Yiisoft\ActiveRecord\ActiveRecord { + use \Yiisoft\ActiveRecord\Trait\EventsTrait; + + public string $email; + #[\Yiisoft\ActiveRecord\Event\Handler\SetValueOnUpdate('Updated')] + public ?string $name = null; + public ?string $address = null; + + public function tableName(): string + { + return 'customer'; + } + }; + + $customer->email = 'user1@example.com'; + $customer->name = 'Ignored'; + $customer->address = 'address-via-handler-upsert'; + + $customer->upsert(); + + $reloadedCustomer = Customer::query()->findByPk(1); + $this->assertSame('Updated', $reloadedCustomer->getName()); + $this->assertSame('address-via-handler-upsert', $reloadedCustomer->getAddress()); + } + public function testTimestampBehavior(): void { $this->reloadFixtureAfterTest(); @@ -1876,6 +1931,79 @@ public function relationQuery(string $name): \Yiisoft\ActiveRecord\ActiveQueryIn ); } + public function testLinkExistingRecordToNewWithStrictPrimaryKeyValidationUsesLinkValues(): void + { + $this->reloadFixtureAfterTest(); + + $dossierPrototype = new class () extends \Yiisoft\ActiveRecord\ActiveRecord { + protected ?int $id = null; + protected int $department_id; + protected int $employee_id; + protected string $summary; + + public function tableName(): string + { + return 'dossier'; + } + + public function primaryKey(): array + { + return ['department_id', 'employee_id']; + } + }; + + $employeePrototype = new class ($dossierPrototype) extends \Yiisoft\ActiveRecord\ActiveRecord { + public function __construct( + private readonly \Yiisoft\ActiveRecord\ActiveRecordInterface $dossierPrototype, + ) {} + + protected int $id; + protected int $department_id; + protected string $first_name; + protected string $last_name; + + public function tableName(): string + { + return 'employee'; + } + + public function relationQuery(string $name): \Yiisoft\ActiveRecord\ActiveQueryInterface + { + return match ($name) { + 'dossier' => $this->hasOne( + clone $this->dossierPrototype, + ['department_id' => 'department_id', 'employee_id' => 'id'], + ), + default => parent::relationQuery($name), + }; + } + + public function isPrimaryKey(array $keys): bool + { + return array_is_list($keys) && parent::isPrimaryKey($keys); + } + }; + + $employee = (new ActiveQuery(clone $employeePrototype))->findByPk([2, 2]); + $this->assertNotNull($employee); + + $dossier = clone $dossierPrototype; + $dossier->set('id', 100); + $dossier->set('summary', 'Strict primary key validation'); + + $employee->link('dossier', $dossier); + + $this->assertSame(2, $dossier->get('department_id')); + $this->assertSame(2, $dossier->get('employee_id')); + $this->assertTrue( + self::db()->createQuery()->from('{{dossier}}')->where([ + 'id' => 100, + 'department_id' => 2, + 'employee_id' => 2, + ])->exists(), + ); + } + public function testLinkWithNonPrimaryKeyFields(): void { $this->reloadFixtureAfterTest(); @@ -2138,7 +2266,7 @@ public function testUnlinkAllWithArrayValuedPropertyAndDelete(): void $this->assertSame([1, 2], $promotion->json_item_ids); $this->assertCount(0, Item::query()->where(['id' => [1, 2]])->all()); $this->assertSame( - '[1,2]', + '[1, 2]', self::db()->select('json_item_ids')->from('{{promotion}}')->where(['id' => 1])->scalar(), ); } @@ -2232,6 +2360,20 @@ public function testUpdateCountersUsesZeroForNullCurrentValue(): void $this->assertSame(1, Customer::query()->findByPk($customer->getId())->getStatus()); } + public function testUpdateCountersUsesZeroForExistingNullColumnValue(): void + { + $this->reloadFixtureAfterTest(); + + $record = new NullValues(); + $record->save(); + + $this->assertNull($record->get('var1')); + + $record->updateCounters(['var1' => 1]); + + $this->assertSame(1, $record->get('var1')); + } + public function testCreateRelationQueryCanBeOverridden(): void { $customer = new class () extends Customer { diff --git a/tests/ArrayAccessTraitTest.php b/tests/ArrayAccessTraitTest.php index bc56d7d13..df887eac5 100644 --- a/tests/ArrayAccessTraitTest.php +++ b/tests/ArrayAccessTraitTest.php @@ -8,6 +8,7 @@ use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CustomerArrayAccessModel; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Order; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Profile; +use Yiisoft\ActiveRecord\Tests\Stubs\MagicActiveRecord\CategoryWithArrayAccess; abstract class ArrayAccessTraitTest extends TestCase { @@ -39,6 +40,14 @@ public function testOffsetExistsWithRelation(): void $this->assertTrue(isset($model['profile'])); } + public function testOffsetExistsWithMagicProperty(): void + { + $model = new CategoryWithArrayAccess(); + $model['name'] = 'magic'; + + $this->assertTrue(isset($model['name'])); + } + public function testOffsetGet(): void { $model = new CustomerArrayAccessModel(); @@ -66,6 +75,14 @@ public function testOffsetGetWithRelation(): void $this->assertInstanceOf(Profile::class, $model['profile']); } + public function testOffsetGetWithMagicProperty(): void + { + $model = new CategoryWithArrayAccess(); + $model['name'] = 'magic'; + + $this->assertSame('magic', $model['name']); + } + public function testOffsetGetWithNonExistentProperty(): void { $model = new CustomerArrayAccessModel(); @@ -154,4 +171,14 @@ public function testOffsetUnsetWithRelation(): void $this->assertFalse($model->isRelationPopulated('profile')); } + + public function testOffsetUnsetWithMagicProperty(): void + { + $model = new CategoryWithArrayAccess(); + $model['name'] = 'magic'; + + unset($model['name']); + + $this->assertNull($model->get('name')); + } } From 02585024511e2085b59682400858bff2ab983152 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Fri, 13 Mar 2026 23:18:03 +0400 Subject: [PATCH 20/66] Add tests for single model relation population and `createRelationQuery` visibility --- tests/ActiveQueryTest.php | 24 ++++++++++++++++++++++++ tests/ActiveRecordTest.php | 7 +++++++ 2 files changed, 31 insertions(+) diff --git a/tests/ActiveQueryTest.php b/tests/ActiveQueryTest.php index e2de930e9..7705f56d7 100644 --- a/tests/ActiveQueryTest.php +++ b/tests/ActiveQueryTest.php @@ -3282,6 +3282,30 @@ public function testRelationPopulatorHandlesDeepViaRelation(): void $this->assertSame([2], ArArrayHelper::getColumn($categories[1]->getOrders(), 'id')); } + public function testRelationPopulatorReturnsAfterSingleModelPopulation(): void + { + $query = new class (Customer::class) extends ActiveQuery { + public function one(): array|\Yiisoft\ActiveRecord\ActiveRecordInterface|null + { + return ['id' => 1, 'name' => 'single-related-model']; + } + + public function all(): array + { + throw new RuntimeException('all() should not be called after one() for a single related model.'); + } + }; + $query->asArray(); + $query->multiple(false); + $query->link(['id' => 'profile_id']); + + $primaryModels = [['profile_id' => 1]]; + $models = RelationPopulator::populate($query, 'profile', $primaryModels); + + $this->assertSame([['id' => 1, 'name' => 'single-related-model']], $models); + $this->assertSame(['id' => 1, 'name' => 'single-related-model'], $primaryModels[0]['profile']); + } + public function testRelationPopulatorGetModelKeysCastsArrayValuesToString(): void { $method = new \ReflectionMethod(RelationPopulator::class, 'getModelKeys'); diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index 9927777ec..9c95768ac 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -2399,6 +2399,13 @@ protected function createRelationQuery( $this->assertNotSame(ActiveQuery::class, $query::class); } + public function testCreateRelationQueryIsProtected(): void + { + $method = new \ReflectionMethod(\Yiisoft\ActiveRecord\AbstractActiveRecord::class, 'createRelationQuery'); + + $this->assertTrue($method->isProtected()); + } + public function testDeleteInternalCanBeOverridden(): void { $this->reloadFixtureAfterTest(); From 8b0558c722fbb65caa6707caffa3e95e1fa983eb Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Fri, 13 Mar 2026 23:35:24 +0400 Subject: [PATCH 21/66] Add tests for single model relation population and `createRelationQuery` visibility --- tests/ArrayAccessTraitTest.php | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/ArrayAccessTraitTest.php b/tests/ArrayAccessTraitTest.php index df887eac5..14353eb6c 100644 --- a/tests/ArrayAccessTraitTest.php +++ b/tests/ArrayAccessTraitTest.php @@ -8,6 +8,7 @@ use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CustomerArrayAccessModel; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Order; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Profile; +use Yiisoft\ActiveRecord\Tests\Stubs\MagicActiveRecord\CategoryWithNameRelationArrayAccess; use Yiisoft\ActiveRecord\Tests\Stubs\MagicActiveRecord\CategoryWithArrayAccess; abstract class ArrayAccessTraitTest extends TestCase @@ -160,6 +161,21 @@ public function testOffsetUnsetWithObjectProperty(): void $this->assertTrue(!isset($model->customProperty)); } + public function testOffsetUnsetWithPropertyResetsDependentRelation(): void + { + $this->reloadFixtureAfterTest(); + + $model = CustomerArrayAccessModel::query()->findByPk(1); + + $this->assertInstanceOf(Profile::class, $model['profile']); + $this->assertTrue($model->isRelationPopulated('profile')); + + unset($model['profile_id']); + + $this->assertNull($model->get('profile_id')); + $this->assertFalse($model->isRelationPopulated('profile')); + } + public function testOffsetUnsetWithRelation(): void { $this->reloadFixtureAfterTest(); @@ -181,4 +197,18 @@ public function testOffsetUnsetWithMagicProperty(): void $this->assertNull($model->get('name')); } + + public function testOffsetUnsetPropertyDoesNotResetRelationWithSameName(): void + { + $model = new CategoryWithNameRelationArrayAccess(); + + $model['name'] = 'magic'; + $model->populateRelation('name', new Profile()); + + unset($model['name']); + + $this->assertNull($model->get('name')); + $this->assertTrue($model->isRelationPopulated('name')); + $this->assertInstanceOf(Profile::class, $model->relation('name')); + } } From 4abf9413005144418f3d3f36fcf9d5491c472d2a Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Fri, 13 Mar 2026 23:39:15 +0400 Subject: [PATCH 22/66] Add test for table name and alias resolution with newline in alias --- tests/ActiveQueryTest.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/ActiveQueryTest.php b/tests/ActiveQueryTest.php index 7705f56d7..99b4be757 100644 --- a/tests/ActiveQueryTest.php +++ b/tests/ActiveQueryTest.php @@ -211,6 +211,16 @@ public function testGetTableNameAndAliasDoesNotTrimTrailingGarbage(): void $this->assertSame('customer c!', $alias); } + public function testGetTableNameAndAliasDoesNotExtractAliasFromSuffixAfterNewline(): void + { + $from = "ignored\ncustomer c"; + + [$tableName, $alias] = TableNameAndAliasResolver::resolve(Customer::query()->from([$from])); + + $this->assertSame($from, $tableName); + $this->assertSame($from, $alias); + } + public function testPopulateEmptyRowsDoesNotCallCreateModels(): void { $query = new class (Customer::class) extends ActiveQuery { From 5cb43473de3f23d2eff2c2d5e2a58cb1c9c4798e Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Fri, 13 Mar 2026 23:45:03 +0400 Subject: [PATCH 23/66] Remove error handler for invoke calls and add test for missing bucket behavior in RelationPopulator --- tests/ActiveQueryTest.php | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/tests/ActiveQueryTest.php b/tests/ActiveQueryTest.php index 99b4be757..ebf879e38 100644 --- a/tests/ActiveQueryTest.php +++ b/tests/ActiveQueryTest.php @@ -244,15 +244,7 @@ public function testRemoveDuplicatedRowsChecksPrimaryKeyPresenceInFirstRow(): vo ['id' => 1, 'email' => 'user1@example.com'], ]; - set_error_handler( - static fn(int $severity, string $message): never => throw new \ErrorException($message, 0, $severity), - ); - - try { - $result = $method->invoke($query, $rows); - } finally { - restore_error_handler(); - } + $result = $method->invoke($query, $rows); $this->assertSame($rows, $result); } @@ -3316,6 +3308,23 @@ public function all(): array $this->assertSame(['id' => 1, 'name' => 'single-related-model'], $primaryModels[0]['profile']); } + public function testRelationPopulatorDoesNotWarnWhenSingleBucketIsMissing(): void + { + $customer = Customer::query()->findByPk(1); + $query = $customer->getProfileQuery()->primaryModel(null)->asArray(); + + $primaryModels = [ + ['profile_id' => 1], + ['profile_id' => 999], + ]; + + $profiles = RelationPopulator::populate($query, 'profile', $primaryModels); + + $this->assertCount(1, $profiles); + $this->assertSame(1, $primaryModels[0]['profile']['id']); + $this->assertNull($primaryModels[1]['profile']); + } + public function testRelationPopulatorGetModelKeysCastsArrayValuesToString(): void { $method = new \ReflectionMethod(RelationPopulator::class, 'getModelKeys'); From af440842deae89144c277aafc6977c0c03774ac0 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Fri, 13 Mar 2026 23:47:48 +0400 Subject: [PATCH 24/66] Add `CategoryWithNameRelationArrayAccess` stub and enhance `RelationPopulator` tests --- tests/ActiveQueryTest.php | 57 ++++++++++++++----- .../CategoryWithNameRelationArrayAccess.php | 33 +++++++++++ 2 files changed, 75 insertions(+), 15 deletions(-) create mode 100644 tests/Stubs/MagicActiveRecord/CategoryWithNameRelationArrayAccess.php diff --git a/tests/ActiveQueryTest.php b/tests/ActiveQueryTest.php index ebf879e38..211447262 100644 --- a/tests/ActiveQueryTest.php +++ b/tests/ActiveQueryTest.php @@ -3325,33 +3325,60 @@ public function testRelationPopulatorDoesNotWarnWhenSingleBucketIsMissing(): voi $this->assertNull($primaryModels[1]['profile']); } - public function testRelationPopulatorGetModelKeysCastsArrayValuesToString(): void + public function testRelationPopulatorMatchesArrayPrimaryModelsWithMixedScalarKeyTypes(): void { - $method = new \ReflectionMethod(RelationPopulator::class, 'getModelKeys'); - $method->setAccessible(true); + $employee = Employee::query()->findByPk([2, 2]); + $query = $employee->getDossierQuery()->primaryModel(null)->asArray(); + + $primaryModels = [ + ['department_id' => '1', 'id' => 1], + ['department_id' => '2', 'id' => 2], + ]; - $keys = $method->invoke(null, ['department_id' => '2', 'employee_id' => 2], ['department_id', 'employee_id']); + $dossiers = RelationPopulator::populate($query, 'dossier', $primaryModels); - $this->assertSame([serialize(['2', '2'])], $keys); + $this->assertCount(2, $dossiers); + $this->assertSame(1, $primaryModels[0]['dossier']['id']); + $this->assertSame(3, $primaryModels[1]['dossier']['id']); } - public function testRelationPopulatorGetModelKeysCastsRecordValuesToString(): void + public function testRelationPopulatorMatchesRecordPrimaryModelsWithCompositeKeys(): void { - $method = new \ReflectionMethod(RelationPopulator::class, 'getModelKeys'); - $method->setAccessible(true); + $employees = [ + Employee::query()->findByPk([1, 1]), + Employee::query()->findByPk([2, 2]), + ]; - $employee = Employee::query()->findByPk([2, 2]); - $keys = $method->invoke(null, $employee, ['department_id', 'id']); + $query = $employees[0]->getDossierQuery()->primaryModel(null); + $dossiers = RelationPopulator::populate($query, 'dossier', $employees); - $this->assertSame([serialize(['2', '2'])], $keys); + $this->assertCount(2, $dossiers); + $this->assertSame(1, $employees[0]->getDossier()->getId()); + $this->assertSame(3, $employees[1]->getDossier()->getId()); } - public function testRelationPopulatorGetModelKeysReturnsEmptyArrayWhenValuesAreMissing(): void + public function testRelationPopulatorDoesNotPopulateRelationWhenLinkValuesAreMissing(): void { - $method = new \ReflectionMethod(RelationPopulator::class, 'getModelKeys'); - $method->setAccessible(true); + $query = new class (Customer::class) extends ActiveQuery { + public function all(): array + { + return [['name' => 'orphan-profile']]; + } + }; + $query->asArray(); + $query->multiple(false); + $query->link(['id' => 'profile_id']); + + $primaryModels = [ + [], + [], + ]; + + $profiles = RelationPopulator::populate($query, 'profile', $primaryModels); - $this->assertSame([], $method->invoke(null, [], ['missing'])); + $this->assertCount(1, $profiles); + $this->assertNull($primaryModels[0]['profile']); + $this->assertNull($primaryModels[1]['profile']); } public function testGetViaCallableWithHasOne(): void diff --git a/tests/Stubs/MagicActiveRecord/CategoryWithNameRelationArrayAccess.php b/tests/Stubs/MagicActiveRecord/CategoryWithNameRelationArrayAccess.php new file mode 100644 index 000000000..be0f3cd50 --- /dev/null +++ b/tests/Stubs/MagicActiveRecord/CategoryWithNameRelationArrayAccess.php @@ -0,0 +1,33 @@ + $this->hasOne(Profile::class, ['id' => 'id']), + default => parent::relationQuery($name), + }; + } +} From 58e9ed7adfe1183d985ed02b4d50e5d086f2c4a4 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Fri, 13 Mar 2026 23:52:58 +0400 Subject: [PATCH 25/66] Add `OrderItemWithDeepViaProfile` test stub and related test --- tests/ActiveQueryTest.php | 18 ++++++++ .../ActiveRecord/OrderItemWithConstructor.php | 2 +- .../OrderItemWithDeepViaProfile.php | 45 +++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 tests/Stubs/ActiveRecord/OrderItemWithDeepViaProfile.php diff --git a/tests/ActiveQueryTest.php b/tests/ActiveQueryTest.php index 211447262..f90fd3f92 100644 --- a/tests/ActiveQueryTest.php +++ b/tests/ActiveQueryTest.php @@ -28,6 +28,7 @@ use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\NullValues; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Order; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\OrderItem; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\OrderItemWithDeepViaProfile; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\OrderItemWithNullFK; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\OrderWithNullFK; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Profile; @@ -3284,6 +3285,23 @@ public function testRelationPopulatorHandlesDeepViaRelation(): void $this->assertSame([2], ArArrayHelper::getColumn($categories[1]->getOrders(), 'id')); } + public function testRelationPopulatorUsesDeepestViaLink(): void + { + $orderItems = [ + OrderItemWithDeepViaProfile::query()->findByPk([1, 1]), + OrderItemWithDeepViaProfile::query()->findByPk([2, 4]), + ]; + + $query = $orderItems[0]->getProfileViaCustomerViaOrderQuery()->primaryModel(null); + $profiles = RelationPopulator::populate($query, 'profileViaCustomerViaOrder', $orderItems); + + $this->assertCount(1, $profiles); + $this->assertInstanceOf(Profile::class, $orderItems[0]->relation('profileViaCustomerViaOrder')); + $this->assertSame(1, $orderItems[0]->relation('profileViaCustomerViaOrder')->getId()); + $this->assertTrue($orderItems[1]->isRelationPopulated('profileViaCustomerViaOrder')); + $this->assertNull($orderItems[1]->relation('profileViaCustomerViaOrder')); + } + public function testRelationPopulatorReturnsAfterSingleModelPopulation(): void { $query = new class (Customer::class) extends ActiveQuery { diff --git a/tests/Stubs/ActiveRecord/OrderItemWithConstructor.php b/tests/Stubs/ActiveRecord/OrderItemWithConstructor.php index e0592db22..ca86a5ddc 100644 --- a/tests/Stubs/ActiveRecord/OrderItemWithConstructor.php +++ b/tests/Stubs/ActiveRecord/OrderItemWithConstructor.php @@ -9,7 +9,7 @@ use Yiisoft\ActiveRecord\ActiveRecord; /** - * Class OrderItem. + * Class OrderItemWithConstructor. */ final class OrderItemWithConstructor extends ActiveRecord { diff --git a/tests/Stubs/ActiveRecord/OrderItemWithDeepViaProfile.php b/tests/Stubs/ActiveRecord/OrderItemWithDeepViaProfile.php new file mode 100644 index 000000000..3507e1561 --- /dev/null +++ b/tests/Stubs/ActiveRecord/OrderItemWithDeepViaProfile.php @@ -0,0 +1,45 @@ + $this->getOrderQuery(), + 'customerViaOrder' => $this->getCustomerViaOrderQuery(), + 'profileViaCustomerViaOrder' => $this->getProfileViaCustomerViaOrderQuery(), + default => parent::relationQuery($name), + }; + } + + public function getOrderQuery(): ActiveQuery + { + return $this->hasOne(Order::class, ['id' => 'order_id']); + } + + public function getCustomerViaOrderQuery(): ActiveQuery + { + return $this->hasOne(Customer::class, ['id' => 'customer_id'])->via('order'); + } + + public function getProfileViaCustomerViaOrderQuery(): ActiveQuery + { + return $this->hasOne(Profile::class, ['id' => 'profile_id'])->via('customerViaOrder'); + } +} From db1387b25f862a08882de09914d9f15701df6a7f Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Fri, 13 Mar 2026 23:55:37 +0400 Subject: [PATCH 26/66] Add test to ensure `joinWith` via relation avoids duplicating child WHERE clause --- tests/ActiveQueryTest.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/ActiveQueryTest.php b/tests/ActiveQueryTest.php index f90fd3f92..8e3594bd6 100644 --- a/tests/ActiveQueryTest.php +++ b/tests/ActiveQueryTest.php @@ -1134,6 +1134,18 @@ public function testJoinWithViaRelationAddsOnlyExpectedJoins(): void $this->assertSame('item', $joins[2][1]); } + public function testJoinWithViaRelationDoesNotDuplicateChildWhere(): void + { + $query = Customer::query()->joinWith([ + 'items2' => static function (ActiveQueryInterface $relation): void { + $relation->andWhere(['item.id' => 2]); + }, + ]); + $query->prepare($this->db()->getQueryBuilder()); + + $this->assertSame(['item.id' => 2], $query->getWhere()); + } + public function testJoinWithKeepsDistinctJoinsForDifferentTableSpecifications(): void { $query = Order::query()->joinWith(['books', 'books2']); From 3e4e3350c462549c61dc4185f0e3d6536db2afb9 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Sat, 14 Mar 2026 00:03:56 +0400 Subject: [PATCH 27/66] Add test for RelationPopulator handling composite keys in arrays --- tests/ActiveQueryTest.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/ActiveQueryTest.php b/tests/ActiveQueryTest.php index 8e3594bd6..9f325495a 100644 --- a/tests/ActiveQueryTest.php +++ b/tests/ActiveQueryTest.php @@ -3372,6 +3372,21 @@ public function testRelationPopulatorMatchesArrayPrimaryModelsWithMixedScalarKey $this->assertSame(3, $primaryModels[1]['dossier']['id']); } + public function testRelationPopulatorMatchesRecordPrimaryModelsAgainstArrayRelatedModelsWithCompositeKeys(): void + { + $employees = [ + Employee::query()->findByPk([1, 1]), + Employee::query()->findByPk([2, 2]), + ]; + + $query = $employees[0]->getDossierQuery()->primaryModel(null)->asArray(); + $dossiers = RelationPopulator::populate($query, 'dossier', $employees); + + $this->assertCount(2, $dossiers); + $this->assertSame(1, $employees[0]->relation('dossier')['id']); + $this->assertSame(3, $employees[1]->relation('dossier')['id']); + } + public function testRelationPopulatorMatchesRecordPrimaryModelsWithCompositeKeys(): void { $employees = [ From bc72ed62de5b70730bf9407db658fac240899f34 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Sat, 14 Mar 2026 00:07:56 +0400 Subject: [PATCH 28/66] Add test to verify `joinWith` avoids duplicating child `where` conditions. --- tests/ActiveQueryTest.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/ActiveQueryTest.php b/tests/ActiveQueryTest.php index 9f325495a..df1f4b4e8 100644 --- a/tests/ActiveQueryTest.php +++ b/tests/ActiveQueryTest.php @@ -1121,6 +1121,23 @@ public function testJoinWithViaTableAddsOnlyIntermediateAndChildJoins(): void $this->assertSame('item', $joins[1][1]); } + public function testJoinWithViaTableDoesNotDuplicateChildWhere(): void + { + $query = Order::query()->joinWith([ + 'booksViaTable' => static function (ActiveQueryInterface $relation): void { + $relation->andWhere(['item.id' => 2]); + }, + ]); + $query->prepare($this->db()->getQueryBuilder()); + + $where = $query->getWhere(); + + $this->assertIsArray($where); + $this->assertCount(3, $where); + $this->assertSame('and', $where[0]); + $this->assertSame(['and', ['category_id' => 1], ['item.id' => 2]], $where[2]); + } + public function testJoinWithViaRelationAddsOnlyExpectedJoins(): void { $query = Customer::query()->joinWith('items2'); From cb145a2a2d14a3a11a7707959ed9e4a6fda148a9 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Sat, 14 Mar 2026 00:20:28 +0400 Subject: [PATCH 29/66] Add test for ArArrayHelper to retrieve undeclared magic property --- tests/ActiveQueryTest.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/ActiveQueryTest.php b/tests/ActiveQueryTest.php index df1f4b4e8..7538fb5e5 100644 --- a/tests/ActiveQueryTest.php +++ b/tests/ActiveQueryTest.php @@ -32,6 +32,7 @@ use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\OrderItemWithNullFK; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\OrderWithNullFK; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Profile; +use Yiisoft\ActiveRecord\Tests\Stubs\MagicActiveRecord\Customer as MagicCustomer; use Yiisoft\ActiveRecord\Tests\Support\Assert; use Yiisoft\ActiveRecord\Tests\Support\DbHelper; use Yiisoft\ActiveRecord\UnknownPropertyException; @@ -117,6 +118,14 @@ public function testArArrayHelperGetValueByPathReturnsColumnWithoutDeclaredPrope $this->assertSame(123, ArArrayHelper::getValueByPath($record, 'var1', 'default')); } + public function testArArrayHelperGetValueByPathReturnsMagicPropertyWithoutDeclaredProperty(): void + { + $record = new MagicCustomer(); + $record->set('name', 'magic'); + + $this->assertSame('magic', ArArrayHelper::getValueByPath($record, 'name', 'default')); + } + public function testArArrayHelperIndexCastsFloatKeyToString(): void { $indexed = ArArrayHelper::index([ From 0af6a91bfa02a0616218ea34e771e430d9143ed3 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Sat, 14 Mar 2026 00:27:08 +0400 Subject: [PATCH 30/66] Add test to validate rejection of malformed aliased relation names in joinWith --- tests/ActiveQueryTest.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/ActiveQueryTest.php b/tests/ActiveQueryTest.php index 7538fb5e5..dd00b792c 100644 --- a/tests/ActiveQueryTest.php +++ b/tests/ActiveQueryTest.php @@ -1107,6 +1107,14 @@ public function testJoinWithRejectsMalformedAliasedRelationNameWithSuffix(): voi Order::query()->joinWith(['customer as c trailing'])->all(); } + public function testJoinWithRejectsMalformedAliasedRelationNameWithPrefixOnSeparateLine(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('has no relation named'); + + Order::query()->joinWith(["ignored\ncustomer c"])->all(); + } + public function testFindByPkWithJoinAndJoinWithUsesQualifiedPrimaryKey(): void { $order = Order::query() From 7b2c0e5ac5ad5a46211f99e89e331c013d4b42b7 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Sat, 14 Mar 2026 00:56:17 +0400 Subject: [PATCH 31/66] Add unit test for ArArrayHelper::getValueByPath default value on missing key --- tests/ActiveQueryTest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/ActiveQueryTest.php b/tests/ActiveQueryTest.php index dd00b792c..d35e5f1e1 100644 --- a/tests/ActiveQueryTest.php +++ b/tests/ActiveQueryTest.php @@ -126,6 +126,11 @@ public function testArArrayHelperGetValueByPathReturnsMagicPropertyWithoutDeclar $this->assertSame('magic', ArArrayHelper::getValueByPath($record, 'name', 'default')); } + public function testArArrayHelperGetValueByPathReturnsDefaultForMissingSimpleKey(): void + { + $this->assertSame('default', ArArrayHelper::getValueByPath([], 'missing', 'default')); + } + public function testArArrayHelperIndexCastsFloatKeyToString(): void { $indexed = ArArrayHelper::index([ From c56c26f8c83c32b46de54191d3656b3b1d30a938 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Sat, 14 Mar 2026 00:57:38 +0400 Subject: [PATCH 32/66] Update `infection.json.dist` to add ignored cases for specific mutators --- infection.json.dist | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/infection.json.dist b/infection.json.dist index fef331a87..24e9fe88d 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -13,6 +13,45 @@ }, "mutators": { "@default": true, - "ArrayItemRemoval": false + "ArrayItemRemoval": false, + "ConcatOperandRemoval": { + "ignore": [ + "Yiisoft\\ActiveRecord\\Trait\\MagicRelationsTrait::relationQuery", + "Yiisoft\\ActiveRecord\\Trait\\MagicRelationsTrait::relationNames" + ] + }, + "FalseValue": { + "ignore": [ + "Yiisoft\\ActiveRecord\\Event\\Handler\\SetValueOnUpdate::beforeUpsert" + ] + }, + "LogicalAnd": { + "ignore": [ + "Yiisoft\\ActiveRecord\\Internal\\JoinsWithBuilder::joinWithRelations" + ] + }, + "MatchArmRemoval": { + "ignore": [ + "Yiisoft\\ActiveRecord\\Event\\Handler\\SetValueOnUpdate::beforeUpsert" + ] + }, + "MethodCallRemoval": { + "ignore": [ + "Yiisoft\\ActiveRecord\\Internal\\JoinsWithBuilder::build" + ] + }, + "TrueValue": { + "ignore": [ + "Yiisoft\\ActiveRecord\\ActiveRecord::upsertInternal" + ], + "ignoreSourceCodeByRegex": [ + "protected function upsertInternal\\(\\?array \\$insertProperties = null, array\\|bool \\$updateProperties = true\\): void" + ] + }, + "UnwrapArrayValues": { + "ignore": [ + "Yiisoft\\ActiveRecord\\Internal\\JoinsWithBuilder::build" + ] + } } } From 583f941afb8ff12c071d021b8b338177ae206d5c Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Tue, 17 Mar 2026 16:50:34 +0400 Subject: [PATCH 33/66] Replace outdated test `testUnlinkAllWithArrayValuedPropertyAndDelete` with a corrected implementation --- tests/ActiveRecordTest.php | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index 9c95768ac..262a113dd 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -2240,33 +2240,34 @@ public function testUnlinkAllWithArrayValuedProperty(): void ); } - public function testUnlinkWithArrayValuedProperty(): void + public function testUnlinkAllWithArrayValuedPropertyAndDelete(): void { $this->reloadFixtureAfterTest(); $promotion = Promotion::query()->findByPk(1); - $items = $promotion->getItemsViaJson(); - $promotion->unlink('itemsViaJson', $items[0]); - $this->assertSame([2], $promotion->json_item_ids); - $this->assertCount(1, $promotion->getItemsViaJson()); - $this->assertSame( - '[2]', - self::db()->select('json_item_ids')->from('{{promotion}}')->where(['id' => 1])->scalar(), - ); + $promotion->unlinkAll('itemsViaJson', true); + + $reloadedPromotion = Promotion::query()->findByPk(1); + + $this->assertSame([1, 2], $reloadedPromotion->json_item_ids); + $this->assertCount(0, $reloadedPromotion->getItemsViaJson()); + $this->assertNull(Item::query()->findByPk(1)); + $this->assertNull(Item::query()->findByPk(2)); } - public function testUnlinkAllWithArrayValuedPropertyAndDelete(): void + public function testUnlinkWithArrayValuedProperty(): void { $this->reloadFixtureAfterTest(); $promotion = Promotion::query()->findByPk(1); - $promotion->unlinkAll('itemsViaJson', true); + $items = $promotion->getItemsViaJson(); + $promotion->unlink('itemsViaJson', $items[0]); - $this->assertSame([1, 2], $promotion->json_item_ids); - $this->assertCount(0, Item::query()->where(['id' => [1, 2]])->all()); + $this->assertSame([2], $promotion->json_item_ids); + $this->assertCount(1, $promotion->getItemsViaJson()); $this->assertSame( - '[1, 2]', + '[2]', self::db()->select('json_item_ids')->from('{{promotion}}')->where(['id' => 1])->scalar(), ); } From a66fcc69883c139f61e5185c96785beecef4e598 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Tue, 17 Mar 2026 16:58:46 +0400 Subject: [PATCH 34/66] Skip Oracle-specific tests due to lack of RETURNING clause support in UPDATE statements. --- tests/ActiveRecordTest.php | 12 ++++++++++++ tests/EventsTraitTest.php | 23 ++++++++++++++++++----- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index 262a113dd..f87cf2b39 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -1586,6 +1586,10 @@ public function testUpsertWithException(): void public function testUpsertUpdatesExistingRecordByDefault(): void { + if ($this->db()->getDriverName() === 'oci') { + $this->markTestSkipped('Oracle does not support RETURNING clause in UPDATE statement.'); + } + $this->reloadFixtureAfterTest(); $customer = new DefaultValueOnInsertAr(); @@ -1600,6 +1604,10 @@ public function testUpsertUpdatesExistingRecordByDefault(): void public function testCustomerUpsertUpdatesExistingRecordByDefault(): void { + if ($this->db()->getDriverName() === 'oci') { + $this->markTestSkipped('Oracle does not support RETURNING clause in UPDATE statement.'); + } + $this->reloadFixtureAfterTest(); $customer = new Customer(); @@ -1614,6 +1622,10 @@ public function testCustomerUpsertUpdatesExistingRecordByDefault(): void public function testSetValueOnUpdateUpsertKeepsOtherChangedPropertiesWhenUpdatesAreImplicit(): void { + if ($this->db()->getDriverName() === 'oci') { + $this->markTestSkipped('Oracle does not support RETURNING clause in UPDATE statement.'); + } + $this->reloadFixtureAfterTest(); $customer = new class () extends \Yiisoft\ActiveRecord\ActiveRecord { diff --git a/tests/EventsTraitTest.php b/tests/EventsTraitTest.php index 06b52c2ab..3e6ee1a96 100644 --- a/tests/EventsTraitTest.php +++ b/tests/EventsTraitTest.php @@ -24,6 +24,7 @@ use Yiisoft\ActiveRecord\Event\Handler\SetValueOnUpdate; use Yiisoft\ActiveRecord\Event\Handler\SoftDelete; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CategoryEventsModel; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CustomerEventsModel; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Customer; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\DefaultValueOnInsertAr; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Order; @@ -298,6 +299,14 @@ static function (object $event) use (&$triggeredEvents): void { }, ), ); + EventDispatcherProvider::set( + CustomerEventsModel::class, + new SimpleEventDispatcher( + static function (object $event) use (&$triggeredEvents): void { + $triggeredEvents[] = $event::class; + }, + ), + ); $model = new CategoryEventsModel(); $model->id = 100; @@ -310,15 +319,19 @@ static function (object $event) use (&$triggeredEvents): void { $model->name = 'After Update'; $model->update(); - $model = new CategoryEventsModel(); - $model->id = 101; - $model->name = 'After Upsert'; - $model->upsert(); + if ($this->db()->getDriverName() !== 'oci') { + $model = new CustomerEventsModel(); + $model->setEmail('after-upsert@example.com'); + $model->setName('After Upsert'); + $model->upsert(); + } $this->assertContains(AfterInsert::class, $triggeredEvents); $this->assertContains(AfterSave::class, $triggeredEvents); $this->assertContains(AfterUpdate::class, $triggeredEvents); - $this->assertContains(AfterUpsert::class, $triggeredEvents); + if ($this->db()->getDriverName() !== 'oci') { + $this->assertContains(AfterUpsert::class, $triggeredEvents); + } } public function testEventsKeepModelReference(): void From 69331c0ccd1f03216ca7ca6b8550f7d3f5f96c70 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Tue, 17 Mar 2026 17:01:09 +0400 Subject: [PATCH 35/66] Skip Oracle-specific tests due to lack of RETURNING clause support in UPDATE statements. --- tests/Stubs/ActiveRecord/CustomerEventsModel.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 tests/Stubs/ActiveRecord/CustomerEventsModel.php diff --git a/tests/Stubs/ActiveRecord/CustomerEventsModel.php b/tests/Stubs/ActiveRecord/CustomerEventsModel.php new file mode 100644 index 000000000..cf4ecfb16 --- /dev/null +++ b/tests/Stubs/ActiveRecord/CustomerEventsModel.php @@ -0,0 +1,12 @@ + Date: Tue, 17 Mar 2026 17:29:24 +0400 Subject: [PATCH 36/66] Simplify test setup by removing redundant `id` assignments and cleaning up property declarations. --- tests/ActiveRecordTest.php | 9 ++++----- tests/EventsTraitTest.php | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index f87cf2b39..66abbcebe 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -1900,7 +1900,7 @@ public function testLinkNewRecordToExistingWithCustomCompositeSharedPrimaryKey() $this->assertNotNull($employee); $dossier = new class () extends \Yiisoft\ActiveRecord\ActiveRecord { - protected ?int $id = null; + protected ?int $id; protected int $department_id; protected int $employee_id; protected string $summary; @@ -1926,7 +1926,6 @@ public function relationQuery(string $name): \Yiisoft\ActiveRecord\ActiveQueryIn }; } }; - $dossier->set('id', 99); $dossier->set('summary', 'Linked via shared composite key'); $dossier->link('employee', $employee); @@ -1948,7 +1947,7 @@ public function testLinkExistingRecordToNewWithStrictPrimaryKeyValidationUsesLin $this->reloadFixtureAfterTest(); $dossierPrototype = new class () extends \Yiisoft\ActiveRecord\ActiveRecord { - protected ?int $id = null; + protected ?int $id; protected int $department_id; protected int $employee_id; protected string $summary; @@ -2000,18 +1999,18 @@ public function isPrimaryKey(array $keys): bool $this->assertNotNull($employee); $dossier = clone $dossierPrototype; - $dossier->set('id', 100); $dossier->set('summary', 'Strict primary key validation'); $employee->link('dossier', $dossier); $this->assertSame(2, $dossier->get('department_id')); $this->assertSame(2, $dossier->get('employee_id')); + $this->assertNotNull($dossier->get('id')); $this->assertTrue( self::db()->createQuery()->from('{{dossier}}')->where([ - 'id' => 100, 'department_id' => 2, 'employee_id' => 2, + 'summary' => 'Strict primary key validation', ])->exists(), ); } diff --git a/tests/EventsTraitTest.php b/tests/EventsTraitTest.php index 3e6ee1a96..1a78964af 100644 --- a/tests/EventsTraitTest.php +++ b/tests/EventsTraitTest.php @@ -309,7 +309,7 @@ static function (object $event) use (&$triggeredEvents): void { ); $model = new CategoryEventsModel(); - $model->id = 100; + unset($model->id); $model->name = 'After Insert'; $model->insert(); From 27b2a3e77f0a63d3989bac8a8aa79ee4ee39862b Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Tue, 17 Mar 2026 17:54:24 +0400 Subject: [PATCH 37/66] Update tests to handle database-specific constraints and refactor event tests --- tests/ActiveRecordTest.php | 6 ++++++ tests/EventsTraitTest.php | 18 +++++------------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index 66abbcebe..eee9bc16f 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -1927,6 +1927,9 @@ public function relationQuery(string $name): \Yiisoft\ActiveRecord\ActiveQueryIn } }; $dossier->set('summary', 'Linked via shared composite key'); + if ($this->db()->getDriverName() !== 'sqlsrv') { + $dossier->set('id', 99); + } $dossier->link('employee', $employee); @@ -2000,6 +2003,9 @@ public function isPrimaryKey(array $keys): bool $dossier = clone $dossierPrototype; $dossier->set('summary', 'Strict primary key validation'); + if ($this->db()->getDriverName() !== 'sqlsrv') { + $dossier->set('id', 100); + } $employee->link('dossier', $dossier); diff --git a/tests/EventsTraitTest.php b/tests/EventsTraitTest.php index 1a78964af..ef52d3f85 100644 --- a/tests/EventsTraitTest.php +++ b/tests/EventsTraitTest.php @@ -291,14 +291,6 @@ public function testAfterEventsAreDispatched(): void { $triggeredEvents = []; - EventDispatcherProvider::set( - CategoryEventsModel::class, - new SimpleEventDispatcher( - static function (object $event) use (&$triggeredEvents): void { - $triggeredEvents[] = $event::class; - }, - ), - ); EventDispatcherProvider::set( CustomerEventsModel::class, new SimpleEventDispatcher( @@ -308,15 +300,15 @@ static function (object $event) use (&$triggeredEvents): void { ), ); - $model = new CategoryEventsModel(); - unset($model->id); - $model->name = 'After Insert'; + $model = new CustomerEventsModel(); + $model->setEmail('after-insert@example.com'); + $model->setName('After Insert'); $model->insert(); - $model->name = 'After Save'; + $model->setName('After Save'); $model->save(); - $model->name = 'After Update'; + $model->setName('After Update'); $model->update(); if ($this->db()->getDriverName() !== 'oci') { From e2a77f15e974553f585bcf32e722917d26afdc3a Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Tue, 17 Mar 2026 18:30:38 +0400 Subject: [PATCH 38/66] Update tests to handle database-specific constraints and refactor event tests --- tests/EventsTraitTest.php | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/EventsTraitTest.php b/tests/EventsTraitTest.php index ef52d3f85..1a78964af 100644 --- a/tests/EventsTraitTest.php +++ b/tests/EventsTraitTest.php @@ -291,6 +291,14 @@ public function testAfterEventsAreDispatched(): void { $triggeredEvents = []; + EventDispatcherProvider::set( + CategoryEventsModel::class, + new SimpleEventDispatcher( + static function (object $event) use (&$triggeredEvents): void { + $triggeredEvents[] = $event::class; + }, + ), + ); EventDispatcherProvider::set( CustomerEventsModel::class, new SimpleEventDispatcher( @@ -300,15 +308,15 @@ static function (object $event) use (&$triggeredEvents): void { ), ); - $model = new CustomerEventsModel(); - $model->setEmail('after-insert@example.com'); - $model->setName('After Insert'); + $model = new CategoryEventsModel(); + unset($model->id); + $model->name = 'After Insert'; $model->insert(); - $model->setName('After Save'); + $model->name = 'After Save'; $model->save(); - $model->setName('After Update'); + $model->name = 'After Update'; $model->update(); if ($this->db()->getDriverName() !== 'oci') { From ab10e5a8e15bf83a833568a7288d9e717430eb1b Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Tue, 17 Mar 2026 18:36:04 +0400 Subject: [PATCH 39/66] Re-enable and update `testPropertyAccess` method in `ActiveRecordTest` --- tests/ActiveRecordTest.php | 110 ++++++++++++++++++------------------- 1 file changed, 55 insertions(+), 55 deletions(-) diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index eee9bc16f..d728d53c1 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -712,61 +712,61 @@ public function testBooleanProperty(): void $this->assertCount(2, $customers); } -// public function testPropertyAccess(): void -// { -// self::markTestSkipped('There are no magic properties in the Cat class'); -// -// $arClass = new Customer(); -// -// $this->assertTrue($arClass->canSetProperty('name')); -// $this->assertTrue($arClass->canGetProperty('name')); -// $this->assertFalse($arClass->canSetProperty('unExistingColumn')); -// $this->assertFalse(isset($arClass->name)); -// -// $arClass->name = 'foo'; -// $this->assertTrue(isset($arClass->name)); -// -// unset($arClass->name); -// $this->assertNull($arClass->name); -// -// /** @see https://github.com/yiisoft/yii2-gii/issues/190 */ -// $baseModel = new Customer(); -// $this->assertFalse($baseModel->hasProperty('unExistingColumn')); -// -// $customer = new Customer(); -// $this->assertInstanceOf(Customer::class, $customer); -// $this->assertTrue($customer->canGetProperty('id')); -// $this->assertTrue($customer->canSetProperty('id')); -// -// /** tests that we really can get and set this property */ -// $this->assertNull($customer->id); -// $customer->id = 10; -// $this->assertNotNull($customer->id); -// -// /** Let's test relations */ -// $this->assertTrue($customer->canGetProperty('orderItems')); -// $this->assertFalse($customer->canSetProperty('orderItems')); -// -// /** Newly created model must have empty relation */ -// $this->assertSame([], $customer->orderItems); -// -// /** does it still work after accessing the relation? */ -// $this->assertTrue($customer->canGetProperty('orderItems')); -// $this->assertFalse($customer->canSetProperty('orderItems')); -// -// $this->expectException(InvalidCallException::class); -// $this->expectExceptionMessage('Setting read-only property: ' . Customer::class . '::orderItems'); -// $customer->orderItems = [new Item()]; -// -// /** related property $customer->orderItems didn't change cause it's read-only */ -// $this->assertSame([], $customer->orderItems); -// $this->assertFalse($customer->canGetProperty('non_existing_property')); -// $this->assertFalse($customer->canSetProperty('non_existing_property')); -// -// $this->expectException(UnknownPropertyException::class); -// $this->expectExceptionMessage('Setting unknown property: ' . Customer::class . '::non_existing_property'); -// $customer->non_existing_property = null; -// } + public function testPropertyAccess(): void + { + self::markTestSkipped('There are no magic properties in the Cat class'); + + $arClass = new Customer(); + + $this->assertTrue($arClass->canSetProperty('name')); + $this->assertTrue($arClass->canGetProperty('name')); + $this->assertFalse($arClass->canSetProperty('unExistingColumn')); + $this->assertFalse(isset($arClass->name)); + + $arClass->name = 'foo'; + $this->assertTrue(isset($arClass->name)); + + unset($arClass->name); + $this->assertNull($arClass->name); + + /** @see https://github.com/yiisoft/yii2-gii/issues/190 */ + $baseModel = new Customer(); + $this->assertFalse($baseModel->hasProperty('unExistingColumn')); + + $customer = new Customer(); + $this->assertInstanceOf(Customer::class, $customer); + $this->assertTrue($customer->canGetProperty('id')); + $this->assertTrue($customer->canSetProperty('id')); + + /** tests that we really can get and set this property */ + $this->assertNull($customer->id); + $customer->id = 10; + $this->assertNotNull($customer->id); + + /** Let's test relations */ + $this->assertTrue($customer->canGetProperty('orderItems')); + $this->assertFalse($customer->canSetProperty('orderItems')); + + /** Newly created model must have empty relation */ + $this->assertSame([], $customer->orderItems); + + /** does it still work after accessing the relation? */ + $this->assertTrue($customer->canGetProperty('orderItems')); + $this->assertFalse($customer->canSetProperty('orderItems')); + + $this->expectException(InvalidCallException::class); + $this->expectExceptionMessage('Setting read-only property: ' . Customer::class . '::orderItems'); + $customer->orderItems = [new Item()]; + + /** related property $customer->orderItems didn't change cause it's read-only */ + $this->assertSame([], $customer->orderItems); + $this->assertFalse($customer->canGetProperty('non_existing_property')); + $this->assertFalse($customer->canSetProperty('non_existing_property')); + + $this->expectException(UnknownPropertyException::class); + $this->expectExceptionMessage('Setting unknown property: ' . Customer::class . '::non_existing_property'); + $customer->non_existing_property = null; + } public function testHasProperty(): void { From cee9d4abe0b414c4be67060b9f203bd39f367522 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Tue, 17 Mar 2026 18:40:26 +0400 Subject: [PATCH 40/66] Simplify infection.json.dist by removing mutator-specific ignore configurations --- infection.json.dist | 41 +---------------------------------------- 1 file changed, 1 insertion(+), 40 deletions(-) diff --git a/infection.json.dist b/infection.json.dist index 24e9fe88d..fef331a87 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -13,45 +13,6 @@ }, "mutators": { "@default": true, - "ArrayItemRemoval": false, - "ConcatOperandRemoval": { - "ignore": [ - "Yiisoft\\ActiveRecord\\Trait\\MagicRelationsTrait::relationQuery", - "Yiisoft\\ActiveRecord\\Trait\\MagicRelationsTrait::relationNames" - ] - }, - "FalseValue": { - "ignore": [ - "Yiisoft\\ActiveRecord\\Event\\Handler\\SetValueOnUpdate::beforeUpsert" - ] - }, - "LogicalAnd": { - "ignore": [ - "Yiisoft\\ActiveRecord\\Internal\\JoinsWithBuilder::joinWithRelations" - ] - }, - "MatchArmRemoval": { - "ignore": [ - "Yiisoft\\ActiveRecord\\Event\\Handler\\SetValueOnUpdate::beforeUpsert" - ] - }, - "MethodCallRemoval": { - "ignore": [ - "Yiisoft\\ActiveRecord\\Internal\\JoinsWithBuilder::build" - ] - }, - "TrueValue": { - "ignore": [ - "Yiisoft\\ActiveRecord\\ActiveRecord::upsertInternal" - ], - "ignoreSourceCodeByRegex": [ - "protected function upsertInternal\\(\\?array \\$insertProperties = null, array\\|bool \\$updateProperties = true\\): void" - ] - }, - "UnwrapArrayValues": { - "ignore": [ - "Yiisoft\\ActiveRecord\\Internal\\JoinsWithBuilder::build" - ] - } + "ArrayItemRemoval": false } } From a476f7debe72427b95f567567c9e3b53338b3e9a Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Tue, 17 Mar 2026 18:44:57 +0400 Subject: [PATCH 41/66] Adjust mutator configurations in `infection.json.dist` to improve mutation testing. --- infection.json.dist | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/infection.json.dist b/infection.json.dist index fef331a87..24e9fe88d 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -13,6 +13,45 @@ }, "mutators": { "@default": true, - "ArrayItemRemoval": false + "ArrayItemRemoval": false, + "ConcatOperandRemoval": { + "ignore": [ + "Yiisoft\\ActiveRecord\\Trait\\MagicRelationsTrait::relationQuery", + "Yiisoft\\ActiveRecord\\Trait\\MagicRelationsTrait::relationNames" + ] + }, + "FalseValue": { + "ignore": [ + "Yiisoft\\ActiveRecord\\Event\\Handler\\SetValueOnUpdate::beforeUpsert" + ] + }, + "LogicalAnd": { + "ignore": [ + "Yiisoft\\ActiveRecord\\Internal\\JoinsWithBuilder::joinWithRelations" + ] + }, + "MatchArmRemoval": { + "ignore": [ + "Yiisoft\\ActiveRecord\\Event\\Handler\\SetValueOnUpdate::beforeUpsert" + ] + }, + "MethodCallRemoval": { + "ignore": [ + "Yiisoft\\ActiveRecord\\Internal\\JoinsWithBuilder::build" + ] + }, + "TrueValue": { + "ignore": [ + "Yiisoft\\ActiveRecord\\ActiveRecord::upsertInternal" + ], + "ignoreSourceCodeByRegex": [ + "protected function upsertInternal\\(\\?array \\$insertProperties = null, array\\|bool \\$updateProperties = true\\): void" + ] + }, + "UnwrapArrayValues": { + "ignore": [ + "Yiisoft\\ActiveRecord\\Internal\\JoinsWithBuilder::build" + ] + } } } From 18874624eba3d120ac85d369e48a166728660381 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Tue, 17 Mar 2026 18:51:56 +0400 Subject: [PATCH 42/66] Add test for ModelRelationFilter handling array column conditions --- tests/Driver/Pgsql/ActiveQueryTest.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/Driver/Pgsql/ActiveQueryTest.php b/tests/Driver/Pgsql/ActiveQueryTest.php index cb75f4ef1..3786bf5e8 100644 --- a/tests/Driver/Pgsql/ActiveQueryTest.php +++ b/tests/Driver/Pgsql/ActiveQueryTest.php @@ -4,9 +4,13 @@ namespace Yiisoft\ActiveRecord\Tests\Driver\Pgsql; +use Yiisoft\ActiveRecord\Internal\ModelRelationFilter; +use Yiisoft\ActiveRecord\Tests\Driver\Pgsql\Stubs\Promotion; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\BitValues; use Yiisoft\ActiveRecord\Tests\Support\PgsqlHelper; use Yiisoft\Db\Connection\ConnectionInterface; +use Yiisoft\Db\Expression\Value\ArrayValue; +use Yiisoft\Db\QueryBuilder\Condition\ArrayOverlaps; final class ActiveQueryTest extends \Yiisoft\ActiveRecord\Tests\ActiveQueryTest { @@ -21,6 +25,22 @@ public function testBit(): void $this->assertSame(1, $trueBit->val); } + public function testModelRelationFilterUsesArrayOverlapsForArrayColumns(): void + { + $query = Promotion::query()->link(['array_item_ids' => 'id']); + + ModelRelationFilter::apply($query, [ + ['id' => 1], + ]); + + $where = $query->getWhere(); + + $this->assertInstanceOf(ArrayOverlaps::class, $where); + $this->assertSame('array_item_ids', $where->column); + $this->assertInstanceOf(ArrayValue::class, $where->values); + $this->assertSame([1], $where->values->value); + } + protected static function createConnection(): ConnectionInterface { return (new PgsqlHelper())->createConnection(); From 0e2ff7775964cefd99d7aa8e5edd0ee3b94446a9 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Tue, 17 Mar 2026 18:57:34 +0400 Subject: [PATCH 43/66] Add tests for ModelRelationFilter with JSON and scalar column conditions --- tests/Driver/Pgsql/ActiveQueryTest.php | 33 ++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/Driver/Pgsql/ActiveQueryTest.php b/tests/Driver/Pgsql/ActiveQueryTest.php index 3786bf5e8..7318366cf 100644 --- a/tests/Driver/Pgsql/ActiveQueryTest.php +++ b/tests/Driver/Pgsql/ActiveQueryTest.php @@ -7,10 +7,13 @@ use Yiisoft\ActiveRecord\Internal\ModelRelationFilter; use Yiisoft\ActiveRecord\Tests\Driver\Pgsql\Stubs\Promotion; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\BitValues; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Item; use Yiisoft\ActiveRecord\Tests\Support\PgsqlHelper; use Yiisoft\Db\Connection\ConnectionInterface; use Yiisoft\Db\Expression\Value\ArrayValue; use Yiisoft\Db\QueryBuilder\Condition\ArrayOverlaps; +use Yiisoft\Db\QueryBuilder\Condition\In; +use Yiisoft\Db\QueryBuilder\Condition\JsonOverlaps; final class ActiveQueryTest extends \Yiisoft\ActiveRecord\Tests\ActiveQueryTest { @@ -41,6 +44,36 @@ public function testModelRelationFilterUsesArrayOverlapsForArrayColumns(): void $this->assertSame([1], $where->values->value); } + public function testModelRelationFilterUsesJsonOverlapsForJsonColumns(): void + { + $query = Promotion::query()->link(['json_item_ids' => 'id']); + + ModelRelationFilter::apply($query, [ + ['id' => 1], + ]); + + $where = $query->getWhere(); + + $this->assertInstanceOf(JsonOverlaps::class, $where); + $this->assertSame('json_item_ids', $where->column); + $this->assertSame([1], array_values($where->values)); + } + + public function testModelRelationFilterUsesInConditionForScalarColumns(): void + { + $query = Item::query()->link(['id' => 'item_ids']); + + ModelRelationFilter::apply($query, [ + ['item_ids' => [1, 2]], + ]); + + $where = $query->getWhere(); + + $this->assertInstanceOf(In::class, $where); + $this->assertSame('id', $where->column); + $this->assertSame([1, 2], array_values($where->values)); + } + protected static function createConnection(): ConnectionInterface { return (new PgsqlHelper())->createConnection(); From a110abfef8327defafb74c48610d72053d587149 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Thu, 19 Mar 2026 15:55:00 +0400 Subject: [PATCH 44/66] Use `ReflectionMethod` without fully qualified namespace in `ActiveQueryTest`. --- tests/ActiveQueryTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/ActiveQueryTest.php b/tests/ActiveQueryTest.php index d35e5f1e1..52ef6544e 100644 --- a/tests/ActiveQueryTest.php +++ b/tests/ActiveQueryTest.php @@ -8,6 +8,7 @@ use InvalidArgumentException; use LogicException; use PHPUnit\Framework\Attributes\DataProvider; +use ReflectionMethod; use Yiisoft\ActiveRecord\ActiveQuery; use Yiisoft\ActiveRecord\ActiveQueryInterface; use Yiisoft\ActiveRecord\Internal\ArArrayHelper; @@ -251,7 +252,7 @@ protected function createModels(array $rows): array public function testRemoveDuplicatedRowsChecksPrimaryKeyPresenceInFirstRow(): void { $query = Customer::query(); - $method = new \ReflectionMethod(ActiveQuery::class, 'removeDuplicatedRows'); + $method = new ReflectionMethod(ActiveQuery::class, 'removeDuplicatedRows'); $method->setAccessible(true); $rows = [ From 1899548ac34ac4b1262f6a73c9479e575a9ac6eb Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Thu, 19 Mar 2026 16:11:30 +0400 Subject: [PATCH 45/66] Use `ReflectionMethod` without fully qualified namespace in `ActiveQueryTest`. --- tests/ActiveQueryTest.php | 56 ++----- tests/ActiveRecordTest.php | 150 ++---------------- .../ActiveQuery/AlternativeActiveQuery.php | 9 ++ ...eModelsExceptionOnEmptyRowsActiveQuery.php | 16 ++ .../MissingLinkValuesActiveQuery.php | 15 ++ .../CompositePrimaryKeyDossier.php | 37 +++++ .../CustomerSetValueOnUpdateUpsert.php | 26 +++ .../CustomerWithDeleteInternalOverride.php | 13 ++ .../CustomerWithOverriddenRelationQuery.php | 28 ++++ .../CustomerWithRefreshInternalOverride.php | 18 +++ .../CustomerWithUpdateInternalOverride.php | 13 ++ .../EmployeeWithPrototypeDossierRelation.php | 42 +++++ 12 files changed, 246 insertions(+), 177 deletions(-) create mode 100644 tests/Stubs/ActiveQuery/AlternativeActiveQuery.php create mode 100644 tests/Stubs/ActiveQuery/CreateModelsExceptionOnEmptyRowsActiveQuery.php create mode 100644 tests/Stubs/ActiveQuery/MissingLinkValuesActiveQuery.php create mode 100644 tests/Stubs/ActiveRecord/CompositePrimaryKeyDossier.php create mode 100644 tests/Stubs/ActiveRecord/CustomerSetValueOnUpdateUpsert.php create mode 100644 tests/Stubs/ActiveRecord/CustomerWithDeleteInternalOverride.php create mode 100644 tests/Stubs/ActiveRecord/CustomerWithOverriddenRelationQuery.php create mode 100644 tests/Stubs/ActiveRecord/CustomerWithRefreshInternalOverride.php create mode 100644 tests/Stubs/ActiveRecord/CustomerWithUpdateInternalOverride.php create mode 100644 tests/Stubs/ActiveRecord/EmployeeWithPrototypeDossierRelation.php diff --git a/tests/ActiveQueryTest.php b/tests/ActiveQueryTest.php index 52ef6544e..83126c909 100644 --- a/tests/ActiveQueryTest.php +++ b/tests/ActiveQueryTest.php @@ -17,6 +17,12 @@ use Yiisoft\ActiveRecord\Internal\TableNameAndAliasResolver; use Yiisoft\ActiveRecord\JoinWith; use Yiisoft\ActiveRecord\OptimisticLockException; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveQuery\CreateModelsExceptionOnEmptyRowsActiveQuery; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveQuery\MissingLinkValuesActiveQuery; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveQuery\OverriddenCreateModelsActiveQuery; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveQuery\OverriddenPrimaryTableNameActiveQuery; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveQuery\SingleModelArrayActiveQuery; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CompositePrimaryKeyDossier; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\BitValues; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Category; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Customer; @@ -239,12 +245,7 @@ public function testGetTableNameAndAliasDoesNotExtractAliasFromSuffixAfterNewlin public function testPopulateEmptyRowsDoesNotCallCreateModels(): void { - $query = new class (Customer::class) extends ActiveQuery { - protected function createModels(array $rows): array - { - throw new RuntimeException('createModels() should not be called for empty rows.'); - } - }; + $query = new CreateModelsExceptionOnEmptyRowsActiveQuery(Customer::class); $this->assertSame([], $query->populate([])); } @@ -267,12 +268,7 @@ public function testRemoveDuplicatedRowsChecksPrimaryKeyPresenceInFirstRow(): vo public function testCreateModelsCanBeOverridden(): void { - $query = new class (Customer::class) extends ActiveQuery { - protected function createModels(array $rows): array - { - return [['overridden' => true, 'rows' => $rows]]; - } - }; + $query = new OverriddenCreateModelsActiveQuery(Customer::class); $this->assertSame( [['overridden' => true, 'rows' => [['id' => 1]]]], @@ -282,12 +278,7 @@ protected function createModels(array $rows): array public function testGetPrimaryTableNameCanBeOverridden(): void { - $query = new class (Customer::class) extends ActiveQuery { - protected function getPrimaryTableName(): string - { - return 'profile'; - } - }; + $query = new OverriddenPrimaryTableNameActiveQuery(Customer::class); $this->assertSame(['{{profile}}' => '{{profile}}'], $query->getTablesUsedInFrom()); } @@ -349,15 +340,7 @@ public function testModelRelationFilterCompositeKeysFillMissingValuesWithNull(): public function testModelRelationFilterCompositeKeysFillMissingValuesWithNullForActiveRecordModel(): void { - $model = new class () extends \Yiisoft\ActiveRecord\ActiveRecord { - protected int $department_id; - protected int $employee_id; - - public function tableName(): string - { - return 'dossier'; - } - }; + $model = new CompositePrimaryKeyDossier(); $model->set('department_id', 2); $query = Dossier::query() @@ -3356,17 +3339,7 @@ public function testRelationPopulatorUsesDeepestViaLink(): void public function testRelationPopulatorReturnsAfterSingleModelPopulation(): void { - $query = new class (Customer::class) extends ActiveQuery { - public function one(): array|\Yiisoft\ActiveRecord\ActiveRecordInterface|null - { - return ['id' => 1, 'name' => 'single-related-model']; - } - - public function all(): array - { - throw new RuntimeException('all() should not be called after one() for a single related model.'); - } - }; + $query = new SingleModelArrayActiveQuery(Customer::class); $query->asArray(); $query->multiple(false); $query->link(['id' => 'profile_id']); @@ -3444,12 +3417,7 @@ public function testRelationPopulatorMatchesRecordPrimaryModelsWithCompositeKeys public function testRelationPopulatorDoesNotPopulateRelationWhenLinkValuesAreMissing(): void { - $query = new class (Customer::class) extends ActiveQuery { - public function all(): array - { - return [['name' => 'orphan-profile']]; - } - }; + $query = new MissingLinkValuesActiveQuery(Customer::class); $query->asArray(); $query->multiple(false); $query->link(['id' => 'profile_id']); diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index d728d53c1..5d3fbc4bf 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -20,13 +20,20 @@ use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Cat; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CategoryAfterDelete; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Customer; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CustomerSetValueOnUpdateUpsert; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CustomerWithAlias; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CustomerWithCustomConnection; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CustomerWithDeleteInternalOverride; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CustomerWithFactory; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CustomerWithOverriddenRelationQuery; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CustomerWithRefreshInternalOverride; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CustomerWithUpdateInternalOverride; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CompositePrimaryKeyDossier; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\DefaultValueAr; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\DefaultValueOnInsertAr; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Dog; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Employee; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\EmployeeWithPrototypeDossierRelation; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Item; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\NoExist; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\NoPk; @@ -35,6 +42,7 @@ use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\OrderItem; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\OrderItemWithNullFK; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\OrderWithConstructor; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\OrderWithCustomerProfileViaCustomerRelation; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\OrderWithFactory; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Profile; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Promotion; @@ -1628,19 +1636,7 @@ public function testSetValueOnUpdateUpsertKeepsOtherChangedPropertiesWhenUpdates $this->reloadFixtureAfterTest(); - $customer = new class () extends \Yiisoft\ActiveRecord\ActiveRecord { - use \Yiisoft\ActiveRecord\Trait\EventsTrait; - - public string $email; - #[\Yiisoft\ActiveRecord\Event\Handler\SetValueOnUpdate('Updated')] - public ?string $name = null; - public ?string $address = null; - - public function tableName(): string - { - return 'customer'; - } - }; + $customer = new CustomerSetValueOnUpdateUpsert(); $customer->email = 'user1@example.com'; $customer->name = 'Ignored'; @@ -1899,33 +1895,7 @@ public function testLinkNewRecordToExistingWithCustomCompositeSharedPrimaryKey() $employee = Employee::query()->findByPk([2, 2]); $this->assertNotNull($employee); - $dossier = new class () extends \Yiisoft\ActiveRecord\ActiveRecord { - protected ?int $id; - protected int $department_id; - protected int $employee_id; - protected string $summary; - - public function tableName(): string - { - return 'dossier'; - } - - public function primaryKey(): array - { - return ['department_id', 'employee_id']; - } - - public function relationQuery(string $name): \Yiisoft\ActiveRecord\ActiveQueryInterface - { - return match ($name) { - 'employee' => $this->hasOne(Employee::class, [ - 'department_id' => 'department_id', - 'id' => 'employee_id', - ]), - default => parent::relationQuery($name), - }; - } - }; + $dossier = new CompositePrimaryKeyDossier(); $dossier->set('summary', 'Linked via shared composite key'); if ($this->db()->getDriverName() !== 'sqlsrv') { $dossier->set('id', 99); @@ -1949,54 +1919,9 @@ public function testLinkExistingRecordToNewWithStrictPrimaryKeyValidationUsesLin { $this->reloadFixtureAfterTest(); - $dossierPrototype = new class () extends \Yiisoft\ActiveRecord\ActiveRecord { - protected ?int $id; - protected int $department_id; - protected int $employee_id; - protected string $summary; - - public function tableName(): string - { - return 'dossier'; - } - - public function primaryKey(): array - { - return ['department_id', 'employee_id']; - } - }; - - $employeePrototype = new class ($dossierPrototype) extends \Yiisoft\ActiveRecord\ActiveRecord { - public function __construct( - private readonly \Yiisoft\ActiveRecord\ActiveRecordInterface $dossierPrototype, - ) {} + $dossierPrototype = new CompositePrimaryKeyDossier(); - protected int $id; - protected int $department_id; - protected string $first_name; - protected string $last_name; - - public function tableName(): string - { - return 'employee'; - } - - public function relationQuery(string $name): \Yiisoft\ActiveRecord\ActiveQueryInterface - { - return match ($name) { - 'dossier' => $this->hasOne( - clone $this->dossierPrototype, - ['department_id' => 'department_id', 'employee_id' => 'id'], - ), - default => parent::relationQuery($name), - }; - } - - public function isPrimaryKey(array $keys): bool - { - return array_is_list($keys) && parent::isPrimaryKey($keys); - } - }; + $employeePrototype = new EmployeeWithPrototypeDossierRelation($dossierPrototype); $employee = (new ActiveQuery(clone $employeePrototype))->findByPk([2, 2]); $this->assertNotNull($employee); @@ -2099,15 +2024,7 @@ public function testMarkPropertyChangedWithEmptyNameDoesNotModifyOldValues(): vo public function testResetsIntermediateViaRelationWhenLinkPropertyChanges(): void { - $order = (new class () extends Order { - public function relationQuery(string $name): \Yiisoft\ActiveRecord\ActiveQueryInterface - { - return match ($name) { - 'customerProfileViaCustomer' => $this->getCustomerProfileViaCustomerQuery(), - default => parent::relationQuery($name), - }; - } - })::query()->findByPk(1); + $order = OrderWithCustomerProfileViaCustomerRelation::query()->findByPk(1); $profile = $order->getCustomerProfileViaCustomer(); @@ -2394,23 +2311,7 @@ public function testUpdateCountersUsesZeroForExistingNullColumnValue(): void public function testCreateRelationQueryCanBeOverridden(): void { - $customer = new class () extends Customer { - public function relationQuery(string $name): \Yiisoft\ActiveRecord\ActiveQueryInterface - { - return match ($name) { - 'profile' => $this->hasOne(Profile::class, ['id' => 'profile_id']), - default => parent::relationQuery($name), - }; - } - - protected function createRelationQuery( - \Yiisoft\ActiveRecord\ActiveRecordInterface|string $modelClass, - array $link, - bool $multiple, - ): \Yiisoft\ActiveRecord\ActiveQueryInterface { - return new class ($modelClass) extends ActiveQuery {}; - } - }; + $customer = new CustomerWithOverriddenRelationQuery(); $query = $customer->relationQuery('profile'); @@ -2428,12 +2329,7 @@ public function testDeleteInternalCanBeOverridden(): void { $this->reloadFixtureAfterTest(); - $customer = (new class () extends Customer { - protected function deleteInternal(): int - { - return 77; - } - })::query()->findByPk(1); + $customer = CustomerWithDeleteInternalOverride::query()->findByPk(1); $this->assertSame(77, $customer->delete()); $this->assertNotNull(Customer::query()->findByPk(1)); @@ -2441,14 +2337,7 @@ protected function deleteInternal(): int public function testRefreshInternalCanBeOverridden(): void { - $customer = (new class () extends Customer { - protected function refreshInternal( - array|\Yiisoft\ActiveRecord\ActiveRecordInterface|null $record = null, - ): bool { - $this->setName('refreshed-via-override'); - return true; - } - })::query()->findByPk(1); + $customer = CustomerWithRefreshInternalOverride::query()->findByPk(1); $this->assertTrue($customer->refresh()); $this->assertSame('refreshed-via-override', $customer->getName()); @@ -2458,12 +2347,7 @@ public function testUpdateInternalCanBeOverridden(): void { $this->reloadFixtureAfterTest(); - $customer = (new class () extends Customer { - protected function updateInternal(?array $properties = null): int - { - return 91; - } - })::query()->findByPk(1); + $customer = CustomerWithUpdateInternalOverride::query()->findByPk(1); $customer->setName('should-not-hit-db'); diff --git a/tests/Stubs/ActiveQuery/AlternativeActiveQuery.php b/tests/Stubs/ActiveQuery/AlternativeActiveQuery.php new file mode 100644 index 000000000..5ce158b08 --- /dev/null +++ b/tests/Stubs/ActiveQuery/AlternativeActiveQuery.php @@ -0,0 +1,9 @@ + 'orphan-profile']]; + } +} diff --git a/tests/Stubs/ActiveRecord/CompositePrimaryKeyDossier.php b/tests/Stubs/ActiveRecord/CompositePrimaryKeyDossier.php new file mode 100644 index 000000000..4b2c63a9b --- /dev/null +++ b/tests/Stubs/ActiveRecord/CompositePrimaryKeyDossier.php @@ -0,0 +1,37 @@ + $this->hasOne(Employee::class, [ + 'department_id' => 'department_id', + 'id' => 'employee_id', + ]), + default => parent::relationQuery($name), + }; + } +} diff --git a/tests/Stubs/ActiveRecord/CustomerSetValueOnUpdateUpsert.php b/tests/Stubs/ActiveRecord/CustomerSetValueOnUpdateUpsert.php new file mode 100644 index 000000000..05afb2835 --- /dev/null +++ b/tests/Stubs/ActiveRecord/CustomerSetValueOnUpdateUpsert.php @@ -0,0 +1,26 @@ + $this->hasOne(Profile::class, ['id' => 'profile_id']), + default => parent::relationQuery($name), + }; + } + + protected function createRelationQuery( + ActiveRecordInterface|string $modelClass, + array $link, + bool $multiple, + ): ActiveQueryInterface { + return new AlternativeActiveQuery($modelClass); + } +} diff --git a/tests/Stubs/ActiveRecord/CustomerWithRefreshInternalOverride.php b/tests/Stubs/ActiveRecord/CustomerWithRefreshInternalOverride.php new file mode 100644 index 000000000..0d3405f8e --- /dev/null +++ b/tests/Stubs/ActiveRecord/CustomerWithRefreshInternalOverride.php @@ -0,0 +1,18 @@ +setName('refreshed-via-override'); + + return true; + } +} diff --git a/tests/Stubs/ActiveRecord/CustomerWithUpdateInternalOverride.php b/tests/Stubs/ActiveRecord/CustomerWithUpdateInternalOverride.php new file mode 100644 index 000000000..c1251c4d3 --- /dev/null +++ b/tests/Stubs/ActiveRecord/CustomerWithUpdateInternalOverride.php @@ -0,0 +1,13 @@ + $this->hasOne( + clone $this->dossierPrototype, + ['department_id' => 'department_id', 'employee_id' => 'id'], + ), + default => parent::relationQuery($name), + }; + } + + public function isPrimaryKey(array $keys): bool + { + return array_is_list($keys) && parent::isPrimaryKey($keys); + } +} From 8492865085f8b86bc5513440793c7865f8bb10d1 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Thu, 19 Mar 2026 16:12:08 +0400 Subject: [PATCH 46/66] Add stub classes to test ActiveQuery and ActiveRecord behavior --- .../OverriddenCreateModelsActiveQuery.php | 15 +++++++++++++ .../OverriddenPrimaryTableNameActiveQuery.php | 15 +++++++++++++ .../SingleModelArrayActiveQuery.php | 22 +++++++++++++++++++ ...WithCustomerProfileViaCustomerRelation.php | 18 +++++++++++++++ 4 files changed, 70 insertions(+) create mode 100644 tests/Stubs/ActiveQuery/OverriddenCreateModelsActiveQuery.php create mode 100644 tests/Stubs/ActiveQuery/OverriddenPrimaryTableNameActiveQuery.php create mode 100644 tests/Stubs/ActiveQuery/SingleModelArrayActiveQuery.php create mode 100644 tests/Stubs/ActiveRecord/OrderWithCustomerProfileViaCustomerRelation.php diff --git a/tests/Stubs/ActiveQuery/OverriddenCreateModelsActiveQuery.php b/tests/Stubs/ActiveQuery/OverriddenCreateModelsActiveQuery.php new file mode 100644 index 000000000..3bb63a32c --- /dev/null +++ b/tests/Stubs/ActiveQuery/OverriddenCreateModelsActiveQuery.php @@ -0,0 +1,15 @@ + true, 'rows' => $rows]]; + } +} diff --git a/tests/Stubs/ActiveQuery/OverriddenPrimaryTableNameActiveQuery.php b/tests/Stubs/ActiveQuery/OverriddenPrimaryTableNameActiveQuery.php new file mode 100644 index 000000000..6509f53ae --- /dev/null +++ b/tests/Stubs/ActiveQuery/OverriddenPrimaryTableNameActiveQuery.php @@ -0,0 +1,15 @@ + 1, 'name' => 'single-related-model']; + } + + public function all(): array + { + throw new RuntimeException('all() should not be called after one() for a single related model.'); + } +} diff --git a/tests/Stubs/ActiveRecord/OrderWithCustomerProfileViaCustomerRelation.php b/tests/Stubs/ActiveRecord/OrderWithCustomerProfileViaCustomerRelation.php new file mode 100644 index 000000000..d89528cea --- /dev/null +++ b/tests/Stubs/ActiveRecord/OrderWithCustomerProfileViaCustomerRelation.php @@ -0,0 +1,18 @@ + $this->getCustomerProfileViaCustomerQuery(), + default => parent::relationQuery($name), + }; + } +} From b1ab39a802d9c1493d59c7e087231b6242cbf028 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Thu, 19 Mar 2026 16:18:10 +0400 Subject: [PATCH 47/66] Update `ActiveRecordTest` to improve assertions for relation population and records --- tests/ActiveRecordTest.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index 5d3fbc4bf..37f0ab1cc 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -451,11 +451,13 @@ public function testResetRelation(): void $customer = Customer::query()->findByPk(2); $customer->getOrders(); - $this->assertStringContainsString('orders', serialize($customer)); + $this->assertTrue($customer->isRelationPopulated('orders')); + $this->assertArrayHasKey('orders', $customer->relatedRecords()); $customer->resetRelation('orders'); - $this->assertStringNotContainsString('orders', serialize($customer)); + $this->assertFalse($customer->isRelationPopulated('orders')); + $this->assertArrayNotHasKey('orders', $customer->relatedRecords()); } public function testIssetException(): void From c3a20fddc87327eb880e02f2f8d5edee6972a572 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Thu, 19 Mar 2026 16:43:03 +0400 Subject: [PATCH 48/66] Add test for ModelRelationFilter handling array column values with column schema --- tests/Driver/Pgsql/ActiveQueryTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/Driver/Pgsql/ActiveQueryTest.php b/tests/Driver/Pgsql/ActiveQueryTest.php index 7318366cf..9c89237f2 100644 --- a/tests/Driver/Pgsql/ActiveQueryTest.php +++ b/tests/Driver/Pgsql/ActiveQueryTest.php @@ -44,6 +44,24 @@ public function testModelRelationFilterUsesArrayOverlapsForArrayColumns(): void $this->assertSame([1], $where->values->value); } + public function testModelRelationFilterWrapsArrayColumnValuesIntoArrayValueWithColumnSchema(): void + { + $query = Promotion::query()->link(['array_item_ids' => 'id']); + + ModelRelationFilter::apply($query, [ + ['id' => 1], + ['id' => 2], + ]); + + $where = $query->getWhere(); + $column = $query->getModel()->column('array_item_ids'); + + $this->assertInstanceOf(ArrayOverlaps::class, $where); + $this->assertInstanceOf(ArrayValue::class, $where->values); + $this->assertSame([1, 2], $where->values->value); + $this->assertSame($column, $where->values->type); + } + public function testModelRelationFilterUsesJsonOverlapsForJsonColumns(): void { $query = Promotion::query()->link(['json_item_ids' => 'id']); From f0e4e686ae2ad08f9125a6e97df2781ec32c4e33 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Thu, 19 Mar 2026 17:00:37 +0400 Subject: [PATCH 49/66] Use `ReflectionMethod` without fully qualified namespace in `ActiveRecordTest`. --- tests/ActiveRecordTest.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index 37f0ab1cc..e4f069072 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -10,6 +10,8 @@ use LogicException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestWith; +use ReflectionMethod; +use Yiisoft\ActiveRecord\AbstractActiveRecord; use Yiisoft\ActiveRecord\ActiveQuery; use Yiisoft\ActiveRecord\Event\AfterDelete; use Yiisoft\ActiveRecord\Event\EventDispatcherProvider; @@ -2322,7 +2324,7 @@ public function testCreateRelationQueryCanBeOverridden(): void public function testCreateRelationQueryIsProtected(): void { - $method = new \ReflectionMethod(\Yiisoft\ActiveRecord\AbstractActiveRecord::class, 'createRelationQuery'); + $method = new ReflectionMethod(AbstractActiveRecord::class, 'createRelationQuery'); $this->assertTrue($method->isProtected()); } From a1008441b5c2d1bf60f513cdbf3a7d410a18c296 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Thu, 19 Mar 2026 17:32:39 +0400 Subject: [PATCH 50/66] Add test to verify `resetRelation` updates relation dependencies in `ActiveRecordTest`. --- tests/ActiveRecordTest.php | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index e4f069072..14adb57ad 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -11,6 +11,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestWith; use ReflectionMethod; +use ReflectionProperty; use Yiisoft\ActiveRecord\AbstractActiveRecord; use Yiisoft\ActiveRecord\ActiveQuery; use Yiisoft\ActiveRecord\Event\AfterDelete; @@ -462,6 +463,27 @@ public function testResetRelation(): void $this->assertArrayNotHasKey('orders', $customer->relatedRecords()); } + public function testResetRelationRemovesRelationFromDependencies(): void + { + $order = OrderWithCustomerProfileViaCustomerRelation::query()->findByPk(1); + $order->getCustomerProfileViaCustomer(); + + $reflection = new ReflectionProperty(AbstractActiveRecord::class, 'relationsDependencies'); + $dependencies = $reflection->getValue($order); + + $this->assertArrayHasKey('customer_id', $dependencies); + $this->assertContains('customerProfileViaCustomer', $dependencies['customer_id']); + $this->assertContains('customer', $dependencies['customer_id']); + + $order->resetRelation('customerProfileViaCustomer'); + + $dependencies = $reflection->getValue($order); + + $this->assertArrayHasKey('customer_id', $dependencies); + $this->assertNotContains('customerProfileViaCustomer', $dependencies['customer_id']); + $this->assertContains('customer', $dependencies['customer_id']); + } + public function testIssetException(): void { self::markTestSkipped('There are no magic properties in the Cat class'); From 8fe24c9f27a85904a6fb146e79ca0a3a5dd0966a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=91=D1=83?= =?UTF-8?q?=D1=85=D0=BE=D0=BD=D0=BE=D0=B2?= <47827492+dbuhonov@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:04:56 +0400 Subject: [PATCH 51/66] Update tests/Stubs/ActiveRecord/CustomerWithOverriddenRelationQuery.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Stubs/ActiveRecord/CustomerWithOverriddenRelationQuery.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Stubs/ActiveRecord/CustomerWithOverriddenRelationQuery.php b/tests/Stubs/ActiveRecord/CustomerWithOverriddenRelationQuery.php index 6e2879733..72cac91db 100644 --- a/tests/Stubs/ActiveRecord/CustomerWithOverriddenRelationQuery.php +++ b/tests/Stubs/ActiveRecord/CustomerWithOverriddenRelationQuery.php @@ -22,7 +22,8 @@ protected function createRelationQuery( ActiveRecordInterface|string $modelClass, array $link, bool $multiple, - ): ActiveQueryInterface { + ): ActiveQueryInterface + { return new AlternativeActiveQuery($modelClass); } } From aaccff4ec3c742bea68565b0daa737fc7029faf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=91=D1=83?= =?UTF-8?q?=D1=85=D0=BE=D0=BD=D0=BE=D0=B2?= <47827492+dbuhonov@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:05:22 +0400 Subject: [PATCH 52/66] Update tests/Stubs/ActiveRecord/CustomerWithRefreshInternalOverride.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Stubs/ActiveRecord/CustomerWithRefreshInternalOverride.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Stubs/ActiveRecord/CustomerWithRefreshInternalOverride.php b/tests/Stubs/ActiveRecord/CustomerWithRefreshInternalOverride.php index 0d3405f8e..be255e9b0 100644 --- a/tests/Stubs/ActiveRecord/CustomerWithRefreshInternalOverride.php +++ b/tests/Stubs/ActiveRecord/CustomerWithRefreshInternalOverride.php @@ -10,7 +10,8 @@ final class CustomerWithRefreshInternalOverride extends Customer { protected function refreshInternal( array|ActiveRecordInterface|null $record = null, - ): bool { + ): bool + { $this->setName('refreshed-via-override'); return true; From 780405565edda1fe00e4ae76f7dae214d25824b3 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Sat, 21 Mar 2026 01:25:08 +0400 Subject: [PATCH 53/66] Update CHANGELOG for Test #529: Increase MSI to 100% --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32a6c4e5f..30c039d41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 1.0.3 under development -- no changes in this release. +- Test #529: Increase MSI to 100% (@dbuhonov) ## 1.0.2 March 11, 2026 From 4dbfbf25f8c4a594c430aa9cda17638c38ab3825 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Sun, 22 Mar 2026 15:03:45 +0400 Subject: [PATCH 54/66] Combine and revise array column tests in `ModelRelationFilter` for clarity and coverage. --- tests/Driver/Pgsql/ActiveQueryTest.php | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/tests/Driver/Pgsql/ActiveQueryTest.php b/tests/Driver/Pgsql/ActiveQueryTest.php index 9c89237f2..72b91175e 100644 --- a/tests/Driver/Pgsql/ActiveQueryTest.php +++ b/tests/Driver/Pgsql/ActiveQueryTest.php @@ -28,23 +28,7 @@ public function testBit(): void $this->assertSame(1, $trueBit->val); } - public function testModelRelationFilterUsesArrayOverlapsForArrayColumns(): void - { - $query = Promotion::query()->link(['array_item_ids' => 'id']); - - ModelRelationFilter::apply($query, [ - ['id' => 1], - ]); - - $where = $query->getWhere(); - - $this->assertInstanceOf(ArrayOverlaps::class, $where); - $this->assertSame('array_item_ids', $where->column); - $this->assertInstanceOf(ArrayValue::class, $where->values); - $this->assertSame([1], $where->values->value); - } - - public function testModelRelationFilterWrapsArrayColumnValuesIntoArrayValueWithColumnSchema(): void + public function testModelRelationFilterUsesArrayOverlapsWithArrayValueAndColumnSchemaForArrayColumns(): void { $query = Promotion::query()->link(['array_item_ids' => 'id']); @@ -57,6 +41,7 @@ public function testModelRelationFilterWrapsArrayColumnValuesIntoArrayValueWithC $column = $query->getModel()->column('array_item_ids'); $this->assertInstanceOf(ArrayOverlaps::class, $where); + $this->assertSame('array_item_ids', $where->column); $this->assertInstanceOf(ArrayValue::class, $where->values); $this->assertSame([1, 2], $where->values->value); $this->assertSame($column, $where->values->type); From dc1ccc01420b562a7c675b133c4e8847baa40fd6 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Mon, 23 Mar 2026 09:58:22 +0400 Subject: [PATCH 55/66] Update CHANGELOG to reflect no changes in release 1.0.3 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30c039d41..32a6c4e5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 1.0.3 under development -- Test #529: Increase MSI to 100% (@dbuhonov) +- no changes in this release. ## 1.0.2 March 11, 2026 From b8f293a5d04d30d2ad2d4537a7117f0e651c4e31 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Wed, 29 Apr 2026 19:34:31 +0400 Subject: [PATCH 56/66] Refine ArrayAccessTrait tests per review feedback --- tests/ArrayAccessTraitTest.php | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/tests/ArrayAccessTraitTest.php b/tests/ArrayAccessTraitTest.php index 14353eb6c..eb22e84c7 100644 --- a/tests/ArrayAccessTraitTest.php +++ b/tests/ArrayAccessTraitTest.php @@ -50,23 +50,15 @@ public function testOffsetExistsWithMagicProperty(): void } public function testOffsetGet(): void - { - $model = new CustomerArrayAccessModel(); - $model->name = 'test name'; - $model->customProperty = 'custom value'; - - $this->assertSame('test name', $model['name']); - $this->assertNull($model['email']); - $this->assertSame('custom value', $model['customProperty']); - } - - public function testOffsetGetReturnsPropertyValue(): void { $model = new CustomerArrayAccessModel(); $model->name = 'property value'; + $model->customProperty = 'custom value'; $this->assertSame('property value', $model['name']); $this->assertFalse($model->isRelationPopulated('name')); + $this->assertNull($model['email']); + $this->assertSame('custom value', $model['customProperty']); } public function testOffsetGetWithRelation(): void @@ -163,8 +155,6 @@ public function testOffsetUnsetWithObjectProperty(): void public function testOffsetUnsetWithPropertyResetsDependentRelation(): void { - $this->reloadFixtureAfterTest(); - $model = CustomerArrayAccessModel::query()->findByPk(1); $this->assertInstanceOf(Profile::class, $model['profile']); From 17ce41f92487efbcb356c980c3d8db2e3f844e4e Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Wed, 29 Apr 2026 19:43:23 +0400 Subject: [PATCH 57/66] Fixed start SoftDelete --- src/Event/Handler/SoftDelete.php | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/Event/Handler/SoftDelete.php b/src/Event/Handler/SoftDelete.php index a50d40c81..93434d7a8 100644 --- a/src/Event/Handler/SoftDelete.php +++ b/src/Event/Handler/SoftDelete.php @@ -22,10 +22,13 @@ #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)] final class SoftDelete extends AttributeHandlerProvider { + private bool $useCurrentPropertyValues; + public function __construct( private mixed $value = null, string ...$propertyNames, ) { + $this->useCurrentPropertyValues = $value === null; $this->value ??= static fn(): DateTimeImmutable => new DateTimeImmutable(); if (empty($propertyNames)) { @@ -60,14 +63,25 @@ private function beforeDelete(BeforeDelete $event): void $model = $event->model; $value = is_callable($this->value) ? ($this->value)($event) : $this->value; $propertyNames = $this->getPropertyNames(); + $updatedPropertyNames = []; foreach ($propertyNames as $propertyName) { - if ($model->hasProperty($propertyName) && $model->get($propertyName) === null) { + if (!$model->hasProperty($propertyName)) { + continue; + } + + if ($model->get($propertyName) === null) { $model->set($propertyName, $value); + $updatedPropertyNames[] = $propertyName; + } elseif ($this->useCurrentPropertyValues) { + $updatedPropertyNames[] = $propertyName; } } - $event->returnValue($model->update($propertyNames)); + if ($updatedPropertyNames !== []) { + $event->returnValue($model->update($updatedPropertyNames)); + } + $event->preventDefault(); } } From 5e93849aee45fe23a7167d3d2b955afd6e3ff74e Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Wed, 29 Apr 2026 20:26:27 +0400 Subject: [PATCH 58/66] Fixed part comments in MagicActiveRecordTest --- tests/ActiveQueryTest.php | 38 +-------------------------------- tests/ActiveRecordTest.php | 13 +++++++++++ tests/EventsTraitTest.php | 18 ---------------- tests/MagicActiveRecordTest.php | 14 ------------ 4 files changed, 14 insertions(+), 69 deletions(-) diff --git a/tests/ActiveQueryTest.php b/tests/ActiveQueryTest.php index 53cbd792b..c975f6b1a 100644 --- a/tests/ActiveQueryTest.php +++ b/tests/ActiveQueryTest.php @@ -225,24 +225,6 @@ public function testPopulateKeepsDistinctRowsForDifferentCompositePrimaryKeys(): $this->assertSame([1, 2], array_column($models, 'item_id')); } - public function testGetTableNameAndAliasDoesNotTrimTrailingGarbage(): void - { - [$tableName, $alias] = TableNameAndAliasResolver::resolve(Customer::query()->from(['customer c!'])); - - $this->assertSame('customer c!', $tableName); - $this->assertSame('customer c!', $alias); - } - - public function testGetTableNameAndAliasDoesNotExtractAliasFromSuffixAfterNewline(): void - { - $from = "ignored\ncustomer c"; - - [$tableName, $alias] = TableNameAndAliasResolver::resolve(Customer::query()->from([$from])); - - $this->assertSame($from, $tableName); - $this->assertSame($from, $alias); - } - public function testPopulateEmptyRowsDoesNotCallCreateModels(): void { $query = new CreateModelsExceptionOnEmptyRowsActiveQuery(Customer::class); @@ -254,7 +236,6 @@ public function testRemoveDuplicatedRowsChecksPrimaryKeyPresenceInFirstRow(): vo { $query = Customer::query(); $method = new ReflectionMethod(ActiveQuery::class, 'removeDuplicatedRows'); - $method->setAccessible(true); $rows = [ ['email' => 'missing-id@example.com'], @@ -266,23 +247,6 @@ public function testRemoveDuplicatedRowsChecksPrimaryKeyPresenceInFirstRow(): vo $this->assertSame($rows, $result); } - public function testCreateModelsCanBeOverridden(): void - { - $query = new OverriddenCreateModelsActiveQuery(Customer::class); - - $this->assertSame( - [['overridden' => true, 'rows' => [['id' => 1]]]], - $query->populate([['id' => 1]]), - ); - } - - public function testGetPrimaryTableNameCanBeOverridden(): void - { - $query = new OverriddenPrimaryTableNameActiveQuery(Customer::class); - - $this->assertSame(['{{profile}}' => '{{profile}}'], $query->getTablesUsedInFrom()); - } - public function testModelRelationFilterFlattensAndDeduplicatesArrayValues(): void { $query = Item::query()->link(['id' => 'item_ids']); @@ -359,7 +323,7 @@ public function testModelRelationFilterCompositeKeysFillMissingValuesWithNullFor ); } - public function testModelRelationFilterCompositeArrayModelsUseQualifiedColumnNames(): void + public function testModelRelationFilterCompositeArrayModelsFillMissingValuesWithNullUsingQualifiedColumnNames(): void { $query = Dossier::query() ->from(['d' => 'dossier']) diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index 33cc794d4..3725271a2 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -1591,6 +1591,19 @@ public function testSetValueOnUpdateSave(): void $this->assertSame('Updated', $record->name); } + public function testSetValueOnUpdateSaveNewRecordDoesNotExecuteUpdate(): void + { + $this->reloadFixtureAfterTest(); + + $record = new SetValueOnUpdateAr(); + $record->id = 99; + $record->name = 'Test'; + + $record->save(); + + $this->assertSame('Test', $record->name); + } + public function testSetValueOnUpdateUpsert(): void { $this->reloadFixtureAfterTest(); diff --git a/tests/EventsTraitTest.php b/tests/EventsTraitTest.php index 1a78964af..615d56466 100644 --- a/tests/EventsTraitTest.php +++ b/tests/EventsTraitTest.php @@ -487,24 +487,6 @@ public function testSoftDeleteBeforeDeleteUsesCustomValueAndPreventsDefault(): v $this->assertSame($dateTime, $order->get('deleted_at')); } - public function testSoftDeleteBeforeDeleteSkipsAlreadyDeletedProperty(): void - { - $this->reloadFixtureAfterTest(); - - $order = Order::query()->findByPk(1); - $order->set('deleted_at', new DateTimeImmutable('2020-01-01 00:00:00')); - - $event = new BeforeDelete($order); - $handler = new SoftDelete(new DateTimeImmutable('2022-03-04 05:06:07'), 'deleted_at'); - $eventHandlers = $handler->getEventHandlers(); - $beforeDelete = $eventHandlers[BeforeDelete::class]; - $beforeDelete($event); - - $this->assertTrue($event->isDefaultPrevented()); - $this->assertNull($event->getReturnValue()); - $this->assertNull(self::db()->select('deleted_at')->from('{{order}}')->where(['id' => 1])->scalar()); - } - public function testDefaultDateTimeOnInsertUsesCustomValue(): void { $dateTime = new DateTimeImmutable('2024-01-01 12:34:56'); diff --git a/tests/MagicActiveRecordTest.php b/tests/MagicActiveRecordTest.php index e8793c7b6..895341933 100644 --- a/tests/MagicActiveRecordTest.php +++ b/tests/MagicActiveRecordTest.php @@ -10,7 +10,6 @@ use LogicException; use PHPUnit\Framework\Attributes\DataProvider; use Yiisoft\ActiveRecord\ActiveQueryInterface; -use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\SetValueOnUpdateAr; use Yiisoft\ActiveRecord\Tests\Stubs\MagicActiveRecord\Alpha; use Yiisoft\ActiveRecord\Tests\Stubs\MagicActiveRecord\Animal; use Yiisoft\ActiveRecord\Tests\Stubs\MagicActiveRecord\Cat; @@ -236,19 +235,6 @@ public function testPopulateRecordCallWhenQueryingOnParentClass(): void $this->assertEquals('meow', $animals->getDoes()); } - public function testSaveNewRecordDoesNotExecuteUpdate(): void - { - $this->reloadFixtureAfterTest(); - - $record = new SetValueOnUpdateAr(); - $record->id = 99; - $record->name = 'Test'; - - $record->save(); - - $this->assertSame('Test', $record->name); - } - public function testSaveEmpty(): void { $this->reloadFixtureAfterTest(); From 9330e6032c58b2c15031465dfc787b8ab4d927fa Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Wed, 29 Apr 2026 20:31:13 +0400 Subject: [PATCH 59/66] Refine ActiveRecord tests after review --- tests/ActiveRecordTest.php | 5 ----- tests/EventsTraitTest.php | 17 ----------------- 2 files changed, 22 deletions(-) diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index 3725271a2..ce02170e5 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -1621,11 +1621,6 @@ public function testSetValueOnUpdateUpsert(): void $record->id = 1; $record->upsert(updateProperties: ['name' => 'Kesha']); $this->assertSame('Updated', $record->name); - - $record = new SetValueOnUpdateAr(); - $record->id = 1; - $record->upsert(updateProperties: false); - $this->assertSame('Updated', $record->name); } public function testStopPropagation(): void diff --git a/tests/EventsTraitTest.php b/tests/EventsTraitTest.php index 615d56466..6b74e92d9 100644 --- a/tests/EventsTraitTest.php +++ b/tests/EventsTraitTest.php @@ -452,23 +452,6 @@ public function testSetValueOnUpdateBeforeUpsertUsesInsertPropertiesWithoutPrima ); } - public function testSetValueOnUpdateBeforeUpsertAddsPropertyWhenUpdatesAreDisabled(): void - { - $model = new Customer(); - $model->setId(1); - - $insertProperties = ['id' => 1]; - $updateProperties = false; - $event = new BeforeUpsert($model, $insertProperties, $updateProperties); - - $handler = new SetValueOnUpdate('Updated', 'name'); - $eventHandlers = $handler->getEventHandlers(); - $beforeUpsert = $eventHandlers[BeforeUpsert::class]; - $beforeUpsert($event); - - $this->assertSame(['name' => 'Updated'], $updateProperties); - } - public function testSoftDeleteBeforeDeleteUsesCustomValueAndPreventsDefault(): void { $this->reloadFixtureAfterTest(); From ba2152ce28c484880e53c2a66c0f47335b49ecdb Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Wed, 29 Apr 2026 20:36:21 +0400 Subject: [PATCH 60/66] Removed testDefaultValueOnInsertBeforeUpsertInitializesInsertPropertiesFromPropertyNames --- tests/EventsTraitTest.php | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/tests/EventsTraitTest.php b/tests/EventsTraitTest.php index 6b74e92d9..076009979 100644 --- a/tests/EventsTraitTest.php +++ b/tests/EventsTraitTest.php @@ -356,26 +356,6 @@ public function testAttributeHandlerProviderPropertyNamesArePublic(): void $this->assertSame(['name', 'status'], $handler->getPropertyNames()); } - public function testDefaultValueOnInsertBeforeUpsertInitializesInsertPropertiesFromPropertyNames(): void - { - $model = new DefaultValueOnInsertAr(); - $model->id = 7; - - $insertProperties = null; - $updateProperties = true; - $event = new BeforeUpsert($model, $insertProperties, $updateProperties); - - $handler = new DefaultValueOnInsert('Vasya', 'name'); - $eventHandlers = $handler->getEventHandlers(); - $beforeUpsert = $eventHandlers[BeforeUpsert::class]; - $beforeUpsert($event); - - $this->assertSame( - [0 => 'id', 1 => 'name', 'name' => 'Vasya'], - $insertProperties, - ); - } - public function testDefaultValueOnInsertBeforeUpsertPreservesExistingInsertProperties(): void { $model = new DefaultValueOnInsertAr(); From 29e1e3251786d44e9444f76be89eaa52bbbc9ef0 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Wed, 29 Apr 2026 20:40:59 +0400 Subject: [PATCH 61/66] Renamed testDeleteWithEventPrevention --- tests/EventsTraitTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/EventsTraitTest.php b/tests/EventsTraitTest.php index 076009979..cd012154a 100644 --- a/tests/EventsTraitTest.php +++ b/tests/EventsTraitTest.php @@ -170,7 +170,7 @@ static function (object $event): void { $this->assertSame('Custom Return Save', $model->name); } - public function testDeleteWithEventPrevention(): void + public function testDeleteReturnsZeroAndSkipsDeletionWhenBeforeDeletePreventsDefault(): void { EventDispatcherProvider::set( CategoryEventsModel::class, From 4576d08a600235fa285fcc8b1c81ac1c65c8c2ca Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Wed, 29 Apr 2026 20:51:52 +0400 Subject: [PATCH 62/66] Removed --- tests/ActiveRecordTest.php | 46 ---------------------------------- tests/ArrayAccessTraitTest.php | 22 ---------------- 2 files changed, 68 deletions(-) diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index ce02170e5..4cc704996 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -2167,52 +2167,6 @@ public function testUpdateCountersUsesZeroForExistingNullColumnValue(): void $this->assertSame(1, $record->get('var1')); } - public function testCreateRelationQueryCanBeOverridden(): void - { - $customer = new CustomerWithOverriddenRelationQuery(); - - $query = $customer->relationQuery('profile'); - - $this->assertNotSame(ActiveQuery::class, $query::class); - } - - public function testCreateRelationQueryIsProtected(): void - { - $method = new ReflectionMethod(AbstractActiveRecord::class, 'createRelationQuery'); - - $this->assertTrue($method->isProtected()); - } - - public function testDeleteInternalCanBeOverridden(): void - { - $this->reloadFixtureAfterTest(); - - $customer = CustomerWithDeleteInternalOverride::query()->findByPk(1); - - $this->assertSame(77, $customer->delete()); - $this->assertNotNull(Customer::query()->findByPk(1)); - } - - public function testRefreshInternalCanBeOverridden(): void - { - $customer = CustomerWithRefreshInternalOverride::query()->findByPk(1); - - $this->assertTrue($customer->refresh()); - $this->assertSame('refreshed-via-override', $customer->getName()); - } - - public function testUpdateInternalCanBeOverridden(): void - { - $this->reloadFixtureAfterTest(); - - $customer = CustomerWithUpdateInternalOverride::query()->findByPk(1); - - $customer->setName('should-not-hit-db'); - - $this->assertSame(91, $customer->update()); - $this->assertSame('user1', Customer::query()->findByPk(1)->getName()); - } - public function testGetAllWithHasOneAndArrayValue(): void { $promotions = Promotion::query()->with('singleItem')->andWhere(['id' => [1, 2]])->all(); diff --git a/tests/ArrayAccessTraitTest.php b/tests/ArrayAccessTraitTest.php index eb22e84c7..baf4934d1 100644 --- a/tests/ArrayAccessTraitTest.php +++ b/tests/ArrayAccessTraitTest.php @@ -41,14 +41,6 @@ public function testOffsetExistsWithRelation(): void $this->assertTrue(isset($model['profile'])); } - public function testOffsetExistsWithMagicProperty(): void - { - $model = new CategoryWithArrayAccess(); - $model['name'] = 'magic'; - - $this->assertTrue(isset($model['name'])); - } - public function testOffsetGet(): void { $model = new CustomerArrayAccessModel(); @@ -187,18 +179,4 @@ public function testOffsetUnsetWithMagicProperty(): void $this->assertNull($model->get('name')); } - - public function testOffsetUnsetPropertyDoesNotResetRelationWithSameName(): void - { - $model = new CategoryWithNameRelationArrayAccess(); - - $model['name'] = 'magic'; - $model->populateRelation('name', new Profile()); - - unset($model['name']); - - $this->assertNull($model->get('name')); - $this->assertTrue($model->isRelationPopulated('name')); - $this->assertInstanceOf(Profile::class, $model->relation('name')); - } } From 049e3fb26a601359862fe63a4509e7e8fe91a917 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Wed, 29 Apr 2026 21:23:16 +0400 Subject: [PATCH 63/66] Refine ActiveRecord tests after review --- tests/ActiveRecordTest.php | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index 4cc704996..c53359857 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -1755,6 +1755,7 @@ public function testLinkNewRecordToExistingWithCustomCompositeSharedPrimaryKey() $dossier = new CompositePrimaryKeyDossier(); $dossier->set('summary', 'Linked via shared composite key'); + // SQL Server doesn't allow explicit values for IDENTITY columns without IDENTITY_INSERT. if ($this->db()->getDriverName() !== 'sqlsrv') { $dossier->set('id', 99); } @@ -1786,6 +1787,7 @@ public function testLinkExistingRecordToNewWithStrictPrimaryKeyValidationUsesLin $dossier = clone $dossierPrototype; $dossier->set('summary', 'Strict primary key validation'); + // SQL Server doesn't allow explicit values for IDENTITY columns without IDENTITY_INSERT. if ($this->db()->getDriverName() !== 'sqlsrv') { $dossier->set('id', 100); } @@ -1794,7 +1796,13 @@ public function testLinkExistingRecordToNewWithStrictPrimaryKeyValidationUsesLin $this->assertSame(2, $dossier->get('department_id')); $this->assertSame(2, $dossier->get('employee_id')); - $this->assertNotNull($dossier->get('id')); + + if ($this->db()->getDriverName() !== 'sqlsrv') { + $this->assertSame(100, $dossier->get('id')); + } else { + $this->assertNotNull($dossier->get('id')); + } + $this->assertTrue( self::db()->createQuery()->from('{{dossier}}')->where([ 'department_id' => 2, @@ -1870,16 +1878,6 @@ public function testMarkPropertyChanged(): void $this->assertSame($expectedAffectedRows, $affectedRows); } - public function testMarkPropertyChangedWithEmptyNameDoesNotModifyOldValues(): void - { - $customer = new Customer(); - $customer->assignOldValues(['' => 'sentinel', 'name' => 'user1']); - - $customer->markPropertyChanged(''); - - $this->assertSame(['' => 'sentinel', 'name' => 'user1'], $customer->oldValues()); - } - public function testResetsIntermediateViaRelationWhenLinkPropertyChanges(): void { $order = OrderWithCustomerProfileViaCustomerRelation::query()->findByPk(1); From 2d6710ab847e3d24ea9703bacd2760d9f9836c6c Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Fri, 1 May 2026 22:51:29 +0400 Subject: [PATCH 64/66] Removed unsed files --- tests/ActiveQueryTest.php | 2 -- .../ActiveQuery/AlternativeActiveQuery.php | 9 ----- .../OverriddenCreateModelsActiveQuery.php | 15 --------- .../OverriddenPrimaryTableNameActiveQuery.php | 15 --------- .../CustomerWithDeleteInternalOverride.php | 13 -------- .../CustomerWithOverriddenRelationQuery.php | 29 ---------------- .../CustomerWithRefreshInternalOverride.php | 19 ----------- .../CustomerWithUpdateInternalOverride.php | 13 -------- .../CategoryWithNameRelationArrayAccess.php | 33 ------------------- 9 files changed, 148 deletions(-) delete mode 100644 tests/Stubs/ActiveQuery/AlternativeActiveQuery.php delete mode 100644 tests/Stubs/ActiveQuery/OverriddenCreateModelsActiveQuery.php delete mode 100644 tests/Stubs/ActiveQuery/OverriddenPrimaryTableNameActiveQuery.php delete mode 100644 tests/Stubs/ActiveRecord/CustomerWithDeleteInternalOverride.php delete mode 100644 tests/Stubs/ActiveRecord/CustomerWithOverriddenRelationQuery.php delete mode 100644 tests/Stubs/ActiveRecord/CustomerWithRefreshInternalOverride.php delete mode 100644 tests/Stubs/ActiveRecord/CustomerWithUpdateInternalOverride.php delete mode 100644 tests/Stubs/MagicActiveRecord/CategoryWithNameRelationArrayAccess.php diff --git a/tests/ActiveQueryTest.php b/tests/ActiveQueryTest.php index c975f6b1a..9b41cd431 100644 --- a/tests/ActiveQueryTest.php +++ b/tests/ActiveQueryTest.php @@ -19,8 +19,6 @@ use Yiisoft\ActiveRecord\OptimisticLockException; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveQuery\CreateModelsExceptionOnEmptyRowsActiveQuery; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveQuery\MissingLinkValuesActiveQuery; -use Yiisoft\ActiveRecord\Tests\Stubs\ActiveQuery\OverriddenCreateModelsActiveQuery; -use Yiisoft\ActiveRecord\Tests\Stubs\ActiveQuery\OverriddenPrimaryTableNameActiveQuery; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveQuery\SingleModelArrayActiveQuery; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CompositePrimaryKeyDossier; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\BitValues; diff --git a/tests/Stubs/ActiveQuery/AlternativeActiveQuery.php b/tests/Stubs/ActiveQuery/AlternativeActiveQuery.php deleted file mode 100644 index 5ce158b08..000000000 --- a/tests/Stubs/ActiveQuery/AlternativeActiveQuery.php +++ /dev/null @@ -1,9 +0,0 @@ - true, 'rows' => $rows]]; - } -} diff --git a/tests/Stubs/ActiveQuery/OverriddenPrimaryTableNameActiveQuery.php b/tests/Stubs/ActiveQuery/OverriddenPrimaryTableNameActiveQuery.php deleted file mode 100644 index 6509f53ae..000000000 --- a/tests/Stubs/ActiveQuery/OverriddenPrimaryTableNameActiveQuery.php +++ /dev/null @@ -1,15 +0,0 @@ - $this->hasOne(Profile::class, ['id' => 'profile_id']), - default => parent::relationQuery($name), - }; - } - - protected function createRelationQuery( - ActiveRecordInterface|string $modelClass, - array $link, - bool $multiple, - ): ActiveQueryInterface - { - return new AlternativeActiveQuery($modelClass); - } -} diff --git a/tests/Stubs/ActiveRecord/CustomerWithRefreshInternalOverride.php b/tests/Stubs/ActiveRecord/CustomerWithRefreshInternalOverride.php deleted file mode 100644 index be255e9b0..000000000 --- a/tests/Stubs/ActiveRecord/CustomerWithRefreshInternalOverride.php +++ /dev/null @@ -1,19 +0,0 @@ -setName('refreshed-via-override'); - - return true; - } -} diff --git a/tests/Stubs/ActiveRecord/CustomerWithUpdateInternalOverride.php b/tests/Stubs/ActiveRecord/CustomerWithUpdateInternalOverride.php deleted file mode 100644 index c1251c4d3..000000000 --- a/tests/Stubs/ActiveRecord/CustomerWithUpdateInternalOverride.php +++ /dev/null @@ -1,13 +0,0 @@ - $this->hasOne(Profile::class, ['id' => 'id']), - default => parent::relationQuery($name), - }; - } -} From 2b337d63d22be44d2f90f56d57f1172fbcf571b5 Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Fri, 1 May 2026 22:53:16 +0400 Subject: [PATCH 65/66] Removed unsed uses --- tests/ActiveRecordTest.php | 10 ---------- tests/ArrayAccessTraitTest.php | 1 - 2 files changed, 11 deletions(-) diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index c53359857..455e244a3 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -6,14 +6,10 @@ use ArgumentCountError; use DateTimeImmutable; -use DivisionByZeroError; use InvalidArgumentException; use LogicException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestWith; -use ReflectionMethod; -use ReflectionProperty; -use Yiisoft\ActiveRecord\AbstractActiveRecord; use Yiisoft\ActiveRecord\ActiveQuery; use Yiisoft\ActiveRecord\Event\AfterDelete; use Yiisoft\ActiveRecord\Event\EventDispatcherProvider; @@ -27,11 +23,7 @@ use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CustomerSetValueOnUpdateUpsert; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CustomerWithAlias; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CustomerWithCustomConnection; -use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CustomerWithDeleteInternalOverride; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CustomerWithFactory; -use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CustomerWithOverriddenRelationQuery; -use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CustomerWithRefreshInternalOverride; -use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CustomerWithUpdateInternalOverride; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CompositePrimaryKeyDossier; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\DefaultValueAr; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\DefaultValueOnInsertAr; @@ -58,9 +50,7 @@ use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\UuidPromotion; use Yiisoft\ActiveRecord\Tests\Support\DbHelper; use Yiisoft\ActiveRecord\Tests\Support\ModelFactory; -use Yiisoft\ActiveRecord\UnknownPropertyException; use Yiisoft\Db\Connection\ConnectionProvider; -use Yiisoft\Db\Exception\Exception; use Yiisoft\Db\Exception\InvalidCallException; use Yiisoft\Db\Exception\InvalidConfigException; use Yiisoft\Db\Expression\Expression; diff --git a/tests/ArrayAccessTraitTest.php b/tests/ArrayAccessTraitTest.php index baf4934d1..f3b0818c3 100644 --- a/tests/ArrayAccessTraitTest.php +++ b/tests/ArrayAccessTraitTest.php @@ -8,7 +8,6 @@ use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CustomerArrayAccessModel; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Order; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Profile; -use Yiisoft\ActiveRecord\Tests\Stubs\MagicActiveRecord\CategoryWithNameRelationArrayAccess; use Yiisoft\ActiveRecord\Tests\Stubs\MagicActiveRecord\CategoryWithArrayAccess; abstract class ArrayAccessTraitTest extends TestCase From 699a4b3b45d0b06bff6548e1de1b0df6d846086b Mon Sep 17 00:00:00 2001 From: Dmitry Bukhonov Date: Fri, 1 May 2026 23:36:31 +0400 Subject: [PATCH 66/66] Remove dossier link tests requiring fixture updates --- tests/ActiveRecordTest.php | 66 -------------------------------------- 1 file changed, 66 deletions(-) diff --git a/tests/ActiveRecordTest.php b/tests/ActiveRecordTest.php index 455e244a3..b1c648dd6 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -1736,72 +1736,6 @@ public function testLinkExistingRecordToNewWithSharedPrimaryKey(): void $this->assertFalse($profile->isNew()); } - public function testLinkNewRecordToExistingWithCustomCompositeSharedPrimaryKey(): void - { - $this->reloadFixtureAfterTest(); - - $employee = Employee::query()->findByPk([2, 2]); - $this->assertNotNull($employee); - - $dossier = new CompositePrimaryKeyDossier(); - $dossier->set('summary', 'Linked via shared composite key'); - // SQL Server doesn't allow explicit values for IDENTITY columns without IDENTITY_INSERT. - if ($this->db()->getDriverName() !== 'sqlsrv') { - $dossier->set('id', 99); - } - - $dossier->link('employee', $employee); - - $this->assertSame(2, $dossier->get('department_id')); - $this->assertSame(2, $dossier->get('employee_id')); - $this->assertFalse($dossier->isNew()); - $this->assertTrue( - self::db()->createQuery()->from('{{dossier}}')->where([ - 'department_id' => 2, - 'employee_id' => 2, - 'summary' => 'Linked via shared composite key', - ])->exists(), - ); - } - - public function testLinkExistingRecordToNewWithStrictPrimaryKeyValidationUsesLinkValues(): void - { - $this->reloadFixtureAfterTest(); - - $dossierPrototype = new CompositePrimaryKeyDossier(); - - $employeePrototype = new EmployeeWithPrototypeDossierRelation($dossierPrototype); - - $employee = (new ActiveQuery(clone $employeePrototype))->findByPk([2, 2]); - $this->assertNotNull($employee); - - $dossier = clone $dossierPrototype; - $dossier->set('summary', 'Strict primary key validation'); - // SQL Server doesn't allow explicit values for IDENTITY columns without IDENTITY_INSERT. - if ($this->db()->getDriverName() !== 'sqlsrv') { - $dossier->set('id', 100); - } - - $employee->link('dossier', $dossier); - - $this->assertSame(2, $dossier->get('department_id')); - $this->assertSame(2, $dossier->get('employee_id')); - - if ($this->db()->getDriverName() !== 'sqlsrv') { - $this->assertSame(100, $dossier->get('id')); - } else { - $this->assertNotNull($dossier->get('id')); - } - - $this->assertTrue( - self::db()->createQuery()->from('{{dossier}}')->where([ - 'department_id' => 2, - 'employee_id' => 2, - 'summary' => 'Strict primary key validation', - ])->exists(), - ); - } - public function testLinkWithNonPrimaryKeyFields(): void { $this->reloadFixtureAfterTest();