diff --git a/tests/ActiveQueryTest.php b/tests/ActiveQueryTest.php index b4b0dfd44..78fb8e998 100644 --- a/tests/ActiveQueryTest.php +++ b/tests/ActiveQueryTest.php @@ -11,7 +11,13 @@ 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\ActiveQuery\CreateModelsExceptionOnEmptyRowsActiveQuery; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveQuery\MissingLinkValuesActiveQuery; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveQuery\SingleModelArrayActiveQuery; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\BitValues; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Category; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Customer; @@ -21,12 +27,15 @@ 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\OrderItemWithDeepViaProfile; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\OrderItemWithNullFK; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\OrderWithNullFK; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\OrderWithSoftDelete; 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\Db\Command\AbstractCommand; @@ -34,11 +43,14 @@ 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; use function ucfirst; +use const SORT_DESC; + abstract class ActiveQueryTest extends TestCase { public function testOptions(): void @@ -62,6 +74,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(); @@ -71,7 +103,255 @@ public function testPrepare(): void public function testPopulateEmptyRows(): void { $query = Customer::query(); - $this->assertEquals([], $query->populate([])); + $this->assertSame([], $query->populate([])); + } + + public function testArArrayHelperGetValueByPathReturnsActiveRecordProperty(): void + { + $customer = Customer::query()->findByPk(1); + + $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 testArArrayHelperGetValueByPathReturnsMagicPropertyWithoutDeclaredProperty(): void + { + $record = new MagicCustomer(); + $record->set('name', 'magic'); + + $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([ + ['id' => 1.5, 'name' => 'float-key'], + ], 'id'); + + $this->assertArrayHasKey('1.5', $indexed); + $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 testPopulateEmptyRowsDoesNotCallCreateModels(): void + { + $query = new CreateModelsExceptionOnEmptyRowsActiveQuery(Customer::class); + + $this->assertSame([], $query->populate([])); + } + + public function testRemoveDuplicatedRowsChecksPrimaryKeyPresenceInFirstRow(): void + { + $rows = [ + ['email' => 'missing-id@example.com'], + ['id' => 1, 'email' => 'user1@example.com'], + ]; + + $result = Customer::query()->leftJoin('profile', '1=1')->asArray()->populate($rows); + + $this->assertSame($rows, $result); + } + + 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 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']); + + 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 testModelRelationFilterCompositeArrayModelsFillMissingValuesWithNullUsingQualifiedColumnNames(): 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']); + + 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 => [ + 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 @@ -262,13 +542,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 @@ -293,6 +589,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(); @@ -712,6 +1016,137 @@ 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 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() + ->joinWith('customer', false) + ->innerJoin('profile', '{{customer}}.{{profile_id}} = {{profile}}.{{id}}') + ->findByPk(1); + + $this->assertInstanceOf(Order::class, $order); + $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 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->assertSame(['and', ['category_id' => 1], ['item.id' => 2]], $where); + } + + 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 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']); + $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()); + } + + 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 */ @@ -2646,6 +3081,217 @@ 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 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 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 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 SingleModelArrayActiveQuery(Customer::class); + $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 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 testRelationPopulatorMatchesArrayPrimaryModelsWithMixedScalarKeyTypes(): void + { + $employee = Employee::query()->findByPk([2, 2]); + $query = $employee->getDossierQuery()->primaryModel(null)->asArray(); + + $primaryModels = [ + ['department_id' => '1', 'id' => 1], + ['department_id' => '2', 'id' => 2], + ]; + + $dossiers = RelationPopulator::populate($query, 'dossier', $primaryModels); + + $this->assertCount(2, $dossiers); + $this->assertSame(1, $primaryModels[0]['dossier']['id']); + $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 = [ + Employee::query()->findByPk([1, 1]), + Employee::query()->findByPk([2, 2]), + ]; + + $query = $employees[0]->getDossierQuery()->primaryModel(null); + $dossiers = RelationPopulator::populate($query, 'dossier', $employees); + + $this->assertCount(2, $dossiers); + $this->assertSame(1, $employees[0]->getDossier()->getId()); + $this->assertSame(3, $employees[1]->getDossier()->getId()); + } + + public function testRelationPopulatorDoesNotPopulateRelationWhenLinkValuesAreMissing(): void + { + $query = new MissingLinkValuesActiveQuery(Customer::class); + $query->asArray(); + $query->multiple(false); + $query->link(['id' => 'profile_id']); + + $primaryModels = [ + [], + [], + ]; + + $profiles = RelationPopulator::populate($query, 'profile', $primaryModels); + + $this->assertCount(1, $profiles); + $this->assertNull($primaryModels[0]['profile']); + $this->assertNull($primaryModels[1]['profile']); + } + public function testGetViaCallableWithHasOne(): void { $order = Order::query()->findByPk(1); @@ -2656,6 +3302,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); @@ -2664,6 +3321,17 @@ public function testGetViaWithHasOne(): void $this->assertInstanceOf(Profile::class, $profile); $this->assertSame(1, $profile->getId()); + $this->assertTrue($order->isRelationPopulated('customer')); + } + + public function testGetViaWithHasOneUsesPopulatedIntermediateRelation(): void + { + $order = Order::query()->findByPk(1); + $order->populateRelation('customer', null); + + $profile = $order->getCustomerProfileViaCustomerQuery()->one(); + + $this->assertNull($profile); } public function testGetAlreadyPopulatedViaWithHasOne(): void @@ -2701,7 +3369,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 b53f3844a..15f72702f 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -22,6 +22,7 @@ use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CategoryAfterDelete; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Customer; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\CustomerQuery; +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\CustomerWithFactory; @@ -31,11 +32,13 @@ use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Item; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\ItemWithPropertyHooks; 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; 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\OrderWithSoftDelete; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Profile; @@ -651,6 +654,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)); @@ -749,7 +763,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(); } @@ -758,7 +776,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(); } @@ -788,17 +810,37 @@ 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(); } + 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(); $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(); } @@ -907,6 +949,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(); @@ -980,6 +1041,37 @@ 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 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(); @@ -1343,6 +1435,63 @@ public function testUpsertWithException(): void $customer->upsert(); } + 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(); + $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 testCustomerUpsertUpdatesExistingRecordByDefault(): void + { + if ($this->db()->getDriverName() === 'oci') { + $this->markTestSkipped('Oracle does not support RETURNING clause in UPDATE statement.'); + } + + $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 + { + if ($this->db()->getDriverName() === 'oci') { + $this->markTestSkipped('Oracle does not support RETURNING clause in UPDATE statement.'); + } + + $this->reloadFixtureAfterTest(); + + $customer = new CustomerSetValueOnUpdateUpsert(); + + $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(); @@ -1435,6 +1584,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(); @@ -1452,11 +1614,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 @@ -1502,6 +1659,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(); @@ -1634,6 +1805,22 @@ public function testMarkPropertyChanged(): void $this->assertSame($expectedAffectedRows, $affectedRows); } + public function testResetsIntermediateViaRelationWhenLinkPropertyChanges(): void + { + $order = OrderWithCustomerProfileViaCustomerRelation::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(); @@ -1770,6 +1957,22 @@ public function testUnlinkAllWithArrayValuedProperty(): void ); } + public function testUnlinkAllWithArrayValuedPropertyAndDelete(): void + { + $this->reloadFixtureAfterTest(); + + $promotion = Promotion::query()->findByPk(1); + + $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 testUnlinkWithArrayValuedProperty(): void { $this->reloadFixtureAfterTest(); @@ -1801,6 +2004,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(); @@ -1835,6 +2062,36 @@ 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 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 testGetAllWithHasOneAndArrayValue(): void { $promotions = Promotion::query()->with('singleItem')->andWhere(['id' => [1, 2]])->all(); diff --git a/tests/ArrayAccessTraitTest.php b/tests/ArrayAccessTraitTest.php index 49926962d..f3b0818c3 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 { @@ -23,6 +24,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); @@ -33,10 +43,11 @@ public function testOffsetExistsWithRelation(): void public function testOffsetGet(): void { $model = new CustomerArrayAccessModel(); - $model->name = 'test name'; + $model->name = 'property value'; $model->customProperty = 'custom value'; - $this->assertSame('test name', $model['name']); + $this->assertSame('property value', $model['name']); + $this->assertFalse($model->isRelationPopulated('name')); $this->assertNull($model['email']); $this->assertSame('custom value', $model['customProperty']); } @@ -48,6 +59,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(); @@ -112,6 +131,7 @@ public function testOffsetUnsetWithProperty(): void unset($model['name']); $this->assertTrue(!isset($model->name)); + $this->assertNull($model->get('name')); } public function testOffsetUnsetWithObjectProperty(): void @@ -124,6 +144,19 @@ public function testOffsetUnsetWithObjectProperty(): void $this->assertTrue(!isset($model->customProperty)); } + public function testOffsetUnsetWithPropertyResetsDependentRelation(): void + { + $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(); @@ -135,4 +168,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')); + } } diff --git a/tests/ArrayableTraitTest.php b/tests/ArrayableTraitTest.php index 9c201cdb2..04c4de61b 100644 --- a/tests/ArrayableTraitTest.php +++ b/tests/ArrayableTraitTest.php @@ -33,6 +33,20 @@ 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', + ], + $customer->extraFields(), + ); + } + public function testToArray(): void { $customerQuery = Customer::query(); diff --git a/tests/Driver/Pgsql/ActiveQueryTest.php b/tests/Driver/Pgsql/ActiveQueryTest.php index cb75f4ef1..72b91175e 100644 --- a/tests/Driver/Pgsql/ActiveQueryTest.php +++ b/tests/Driver/Pgsql/ActiveQueryTest.php @@ -4,9 +4,16 @@ 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\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 { @@ -21,6 +28,55 @@ public function testBit(): void $this->assertSame(1, $trueBit->val); } + public function testModelRelationFilterUsesArrayOverlapsWithArrayValueAndColumnSchemaForArrayColumns(): 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->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); + } + + 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(); diff --git a/tests/EventsTraitTest.php b/tests/EventsTraitTest.php index af7292878..a7df0eac2 100644 --- a/tests/EventsTraitTest.php +++ b/tests/EventsTraitTest.php @@ -4,14 +4,30 @@ 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\BeforeDelete; use Yiisoft\ActiveRecord\Event\BeforeInsert; use Yiisoft\ActiveRecord\Event\BeforePopulate; use Yiisoft\ActiveRecord\Event\BeforeSave; 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\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\CustomerEventsModel; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Customer; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\DefaultValueOnInsertAr; +use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Order; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\SetValueOnUpdateAr; use Yiisoft\Test\Support\EventDispatcher\SimpleEventDispatcher; @@ -155,6 +171,25 @@ static function (object $event): void { $this->assertSame('Custom Return Save', $model->name); } + public function testDeleteReturnsZeroAndSkipsDeletionWhenBeforeDeletePreventsDefault(): 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( @@ -263,4 +298,185 @@ public function testSetValueOnUpdateOnUpsertWithUpdatePropertiesFalse(): void $this->assertSame('Vasya', $model->name); } + + 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( + static function (object $event) use (&$triggeredEvents): void { + $triggeredEvents[] = $event::class; + }, + ), + ); + + $model = new CategoryEventsModel(); + unset($model->id); + $model->name = 'After Insert'; + $model->insert(); + + $model->name = 'After Save'; + $model->save(); + + $model->name = 'After Update'; + $model->update(); + + 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); + if ($this->db()->getDriverName() !== 'oci') { + $this->assertContains(AfterUpsert::class, $triggeredEvents); + } + } + + 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 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 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 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); + $properties = null; + + $event = new BeforeInsert($order, $properties); + $beforeInsert($event); + + $this->assertSame($dateTime, $order->getCreatedAt()); + } } diff --git a/tests/MagicActiveRecordTest.php b/tests/MagicActiveRecordTest.php index c437d82e9..895341933 100644 --- a/tests/MagicActiveRecordTest.php +++ b/tests/MagicActiveRecordTest.php @@ -534,6 +534,34 @@ 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 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(); @@ -563,6 +591,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)); @@ -908,6 +947,24 @@ 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 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..c1100dffa 100644 --- a/tests/RepositoryTraitTest.php +++ b/tests/RepositoryTraitTest.php @@ -13,12 +13,22 @@ public function testFind(): void { $customerQuery = Customer::query(); + $this->assertEquals($customerQuery, Customer::find()); + $this->assertEquals( $customerQuery->setWhere(['id' => 1]), Customer::find(['id' => 1]), ); } + 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(); diff --git a/tests/Stubs/ActiveQuery/CreateModelsExceptionOnEmptyRowsActiveQuery.php b/tests/Stubs/ActiveQuery/CreateModelsExceptionOnEmptyRowsActiveQuery.php new file mode 100644 index 000000000..342ea404d --- /dev/null +++ b/tests/Stubs/ActiveQuery/CreateModelsExceptionOnEmptyRowsActiveQuery.php @@ -0,0 +1,16 @@ + 'orphan-profile']]; + } +} diff --git a/tests/Stubs/ActiveQuery/SingleModelArrayActiveQuery.php b/tests/Stubs/ActiveQuery/SingleModelArrayActiveQuery.php new file mode 100644 index 000000000..784065c85 --- /dev/null +++ b/tests/Stubs/ActiveQuery/SingleModelArrayActiveQuery.php @@ -0,0 +1,22 @@ + 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/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 @@ + $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'); + } +} 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), + }; + } +}