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" + ] + } } } 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(); } } diff --git a/tests/ActiveQueryTest.php b/tests/ActiveQueryTest.php index 0d2976746..06679689a 100644 --- a/tests/ActiveQueryTest.php +++ b/tests/ActiveQueryTest.php @@ -8,11 +8,19 @@ use InvalidArgumentException; use LogicException; use PHPUnit\Framework\Attributes\DataProvider; +use ReflectionMethod; 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\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\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; @@ -22,11 +30,14 @@ 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\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; @@ -35,6 +46,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; @@ -63,6 +75,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(); @@ -72,7 +104,279 @@ 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 + { + $query = Customer::query(); + $method = new ReflectionMethod(ActiveQuery::class, 'removeDuplicatedRows'); + + $rows = [ + ['email' => 'missing-id@example.com'], + ['id' => 1, 'email' => 'user1@example.com'], + ]; + + $result = $method->invoke($query, $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 testModelRelationFilterCompositeKeysFillMissingValuesWithNullForActiveRecordModel(): void + { + $model = new CompositePrimaryKeyDossier(); + $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 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 @@ -263,13 +567,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 @@ -294,6 +614,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(); @@ -713,6 +1041,140 @@ 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->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'); + $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 */ @@ -2662,6 +3124,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); @@ -2672,6 +3345,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); @@ -2680,6 +3364,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 @@ -2717,7 +3412,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 41d056a09..b1c648dd6 100644 --- a/tests/ActiveRecordTest.php +++ b/tests/ActiveRecordTest.php @@ -20,19 +20,25 @@ 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\CustomerWithFactory; +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; 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\Profile; use Yiisoft\ActiveRecord\Tests\Stubs\ActiveRecord\Promotion; @@ -645,6 +651,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)); @@ -743,7 +760,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(); } @@ -752,7 +773,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(); } @@ -782,17 +807,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(); } @@ -901,6 +946,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(); @@ -974,6 +1038,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(); @@ -1337,6 +1432,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(); @@ -1429,6 +1581,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(); @@ -1446,11 +1611,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 @@ -1496,6 +1656,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(); @@ -1628,6 +1802,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(); @@ -1764,6 +1954,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(); @@ -1795,6 +2001,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(); @@ -1829,6 +2059,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 f3fbf309b..cd012154a 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\Test\Support\EventDispatcher\SimpleEventDispatcher; abstract class EventsTraitTest extends TestCase @@ -154,6 +170,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( @@ -251,4 +286,185 @@ static function (object $event): void { $this->assertNull($model->id); $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; + }, + ), + ); + 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/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/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->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); + } +} 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'); + } +} 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), + }; + } +}