diff --git a/CHANGELOG.md b/CHANGELOG.md index f1959c69..c13f7b6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # CHANGELOG +v2.3.4 (31.07.2023) +-------------------- +- Fix fields uncasting in the ManyToMany relation by @roxblnfk, thanks @gam6itko (#427, #428) +- Fix resolving of a not loaded parent in the relation RefersTo by @roxblnfk, thanks @msmakouz and snafets (#414) +- Fix belongs to relation when parent is changed using parent id by @roxblnfk, thanks @roquie (#346, #432) + v2.3.3 (21.07.2023) -------------------- - Fix loading for Embedded entities when parent is null by @gam6itko and @roxblnfk (#422, #423) @@ -11,7 +17,7 @@ v2.3.2 (20.06.2023) -------------------- - Fix proxy-mapper hydration mechanism: public relations in a non-proxy-entity are hydrated like private ones. There is a special logic related to `ReferenceInterface` hydrating. By @roxblnfk (#417) -- Add the method `forUpdate` in the `Select` phpdoc. By @msmakouz in (#413) +- Add the method `forUpdate` in the `Select` phpdoc. By @msmakouz (#413) v2.3.1 (01.05.2023) -------------------- diff --git a/src/Heap/Node.php b/src/Heap/Node.php index 46bf5152..16e5c2c8 100644 --- a/src/Heap/Node.php +++ b/src/Heap/Node.php @@ -129,15 +129,11 @@ public function syncState(RelationMap $relMap, State $state): array { $changes = array_udiff_assoc($state->getTransactionData(), $this->data, [self::class, 'compare']); - $relations = $relMap->getRelations(); - foreach ($state->getRelations() as $name => $relation) { - if ($relation instanceof ReferenceInterface - && isset($relations[$name]) - && (isset($this->relations[$name]) xor isset($relation)) - ) { - $changes[$name] = $relation->hasValue() ? $relation->getValue() : $relation; + foreach ($state->getRelations() as $name => $value) { + if ($value instanceof ReferenceInterface) { + $changes[$name] = $value->hasValue() ? $value->getValue() : $value; } - $this->setRelation($name, $relation); + $this->setRelation($name, $value); } // DELETE handled separately diff --git a/src/Mapper/Proxy/EntityProxyTrait.php b/src/Mapper/Proxy/EntityProxyTrait.php index c52a28cd..b975f561 100644 --- a/src/Mapper/Proxy/EntityProxyTrait.php +++ b/src/Mapper/Proxy/EntityProxyTrait.php @@ -39,6 +39,23 @@ public function __get(string $name) throw new RuntimeException(sprintf('Property %s.%s is not initialized.', get_parent_class(static::class), $name)); } + public function __unset(string $name): void + { + if (\array_key_exists($name, $this->__cycle_orm_rel_map->getRelations())) { + $propertyClass = $this->__cycle_orm_relation_props->getPropertyClass($name); + if ($propertyClass === PropertyMap::PUBLIC_CLASS) { + unset($this->$name); + } else { + Closure::bind(static function (object $object, string $property): void { + unset($object->{$property}); + }, null, $propertyClass)($this, $name); + } + } + if (\method_exists(parent::class, '__unset')) { + parent::__unset($name); + } + } + public function __set(string $name, $value): void { if (!array_key_exists($name, $this->__cycle_orm_rel_map->getRelations())) { diff --git a/src/Mapper/Proxy/Hydrator/ClosureHydrator.php b/src/Mapper/Proxy/Hydrator/ClosureHydrator.php index 55061f65..cb0a3cf0 100644 --- a/src/Mapper/Proxy/Hydrator/ClosureHydrator.php +++ b/src/Mapper/Proxy/Hydrator/ClosureHydrator.php @@ -34,7 +34,10 @@ public function hydrate(RelationMap $relMap, array $propertyMaps, object $object foreach ($data as $property => $value) { try { - // Ignore deprecations + if (isset($relMap->getRelations()[$property])) { + unset($object->{$property}); + } + // Use @ to try to ignore deprecations @$object->{$property} = $value; } catch (\Throwable $e) { if ($e::class === \TypeError::class) { @@ -66,7 +69,7 @@ private function setEntityProperties(array $properties, object $object, array &$ } try { - // Ignore deprecations + // Use @ to try to ignore deprecations @$object->{$property} = $data[$property]; unset($data[$property]); } catch (\Throwable $e) { diff --git a/src/Mapper/Proxy/ProxyEntityFactory.php b/src/Mapper/Proxy/ProxyEntityFactory.php index cbc09302..8cc7cac0 100644 --- a/src/Mapper/Proxy/ProxyEntityFactory.php +++ b/src/Mapper/Proxy/ProxyEntityFactory.php @@ -101,7 +101,10 @@ public function extractRelations(RelationMap $relMap, object $entity): array foreach ($relMap->getRelations() as $key => $relation) { if (!array_key_exists($key, $currentData)) { $arrayData ??= $this->entityToArray($entity); - $currentData[$key] = $arrayData[$key]; + if (!array_key_exists($key, $arrayData)) { + continue; + } + $currentData[$key] = $arrayData[$key] ?? null; } } diff --git a/src/Relation/BelongsTo.php b/src/Relation/BelongsTo.php index 733cc796..b1a7c094 100644 --- a/src/Relation/BelongsTo.php +++ b/src/Relation/BelongsTo.php @@ -126,11 +126,13 @@ private function shouldPull(Tuple $tuple, Tuple $rTuple): bool $newData = $rTuple->state->getTransactionData(); $current = $tuple->state->getData(); $noChanges = true; + $toReference = []; foreach ($this->outerKeys as $i => $outerKey) { $innerKey = $this->innerKeys[$i]; if (!array_key_exists($innerKey, $oldData) || $oldData[$innerKey] !== $newData[$outerKey]) { return true; } + $toReference[$outerKey] = $current[$innerKey]; $noChanges = $noChanges && Node::compare($current[$innerKey], $oldData[$innerKey]) === 0; } // If no changes @@ -140,14 +142,27 @@ private function shouldPull(Tuple $tuple, Tuple $rTuple): bool } // Nullable relation and null values if ($this->isNullable()) { + $isNull = true; foreach ($this->innerKeys as $innerKey) { if (!array_key_exists($innerKey, $current) || $current[$innerKey] !== null) { - return false; + $isNull = false; + break; } } - $tuple->state->setRelation($this->getName(), null); - $tuple->state->setRelationStatus($this->getName(), RelationInterface::STATUS_RESOLVED); + if ($isNull) { + $tuple->state->setRelation($this->getName(), null); + $tuple->state->setRelationStatus($this->getName(), RelationInterface::STATUS_RESOLVED); + return false; + } } + $tuple->state->setRelationStatus($this->getName(), RelationInterface::STATUS_RESOLVED); + + $reference = new Reference($this->target, $toReference); + $tuple->state->setRelation( + $this->getName(), + $this->resolve($reference, false) ?? $reference, + ); + return false; } diff --git a/tests/ORM/Functional/Driver/Common/Integration/Case346/CaseTest.php b/tests/ORM/Functional/Driver/Common/Integration/Case346/CaseTest.php new file mode 100644 index 00000000..c6324da3 --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Case346/CaseTest.php @@ -0,0 +1,93 @@ +makeTables(); + $this->fillData(); + + $this->loadSchema(__DIR__ . '/schema.php'); + } + + public function testSelect(): void + { + /** @var Entity\Post $post */ + $post = (new Select($this->orm, Entity\Post::class)) + ->wherePK(1) + ->fetchOne(); + + $this->assertSame(1, $post->user->id); + + $post->user_id = 2; + + $this->save($post); + + $this->assertSame(2, $post->user_id); + $this->assertSame(2, $post->user->id); + $this->orm->getHeap()->clean(); + } + + private function makeTables(): void + { + // Make tables + $this->makeTable(Entity\User::ROLE, [ + 'id' => 'primary', // autoincrement + 'login' => 'string', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]); + + $this->makeTable('post', [ + 'id' => 'primary', + 'user_id' => 'int', + 'slug' => 'string', + 'title' => 'string', + 'public' => 'bool', + 'content' => 'string', + 'published_at' => 'datetime,nullable', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'deleted_at' => 'datetime,nullable', + ]); + $this->makeFK('post', 'user_id', 'user', 'id', 'NO ACTION', 'NO ACTION'); + } + + private function fillData(): void + { + $this->getDatabase()->table('user')->insertMultiple( + ['login'], + [ + ['user-1'], + ['user-2'], + ['user-3'], + ['user-4'], + ], + ); + $this->getDatabase()->table('post')->insertMultiple( + ['user_id', 'slug', 'title', 'public', 'content'], + [ + [1, 'slug-string-1', 'Title 1', true, 'Foo-bar-baz content 1'], + [2, 'slug-string-2', 'Title 2', true, 'Foo-bar-baz content 2'], + [2, 'slug-string-3', 'Title 3', true, 'Foo-bar-baz content 3'], + [3, 'slug-string-4', 'Title 4', true, 'Foo-bar-baz content 4'], + [3, 'slug-string-5', 'Title 5', true, 'Foo-bar-baz content 5'], + [3, 'slug-string-6', 'Title 6', true, 'Foo-bar-baz content 6'], + ], + ); + } +} diff --git a/tests/ORM/Functional/Driver/Common/Integration/Case346/Entity/Post.php b/tests/ORM/Functional/Driver/Common/Integration/Case346/Entity/Post.php new file mode 100644 index 00000000..852069d4 --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Case346/Entity/Post.php @@ -0,0 +1,36 @@ +title = $title; + $this->content = $content; + $this->created_at = new DateTimeImmutable(); + $this->updated_at = new DateTimeImmutable(); + $this->resetSlug(); + } + + public function resetSlug(): void + { + $this->slug = \bin2hex(\random_bytes(32)); + } +} diff --git a/tests/ORM/Functional/Driver/Common/Integration/Case346/Entity/User.php b/tests/ORM/Functional/Driver/Common/Integration/Case346/Entity/User.php new file mode 100644 index 00000000..355e998e --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Case346/Entity/User.php @@ -0,0 +1,26 @@ + */ + public iterable $posts = []; + + public function __construct(string $login, string $password) + { + $this->login = $login; + $this->created_at = new DateTimeImmutable(); + $this->updated_at = new DateTimeImmutable(); + } +} diff --git a/tests/ORM/Functional/Driver/Common/Integration/Case346/schema.php b/tests/ORM/Functional/Driver/Common/Integration/Case346/schema.php new file mode 100644 index 00000000..80aae45f --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Case346/schema.php @@ -0,0 +1,95 @@ + [ + Schema::ENTITY => Post::class, + Schema::SOURCE => Source::class, + Schema::DATABASE => 'default', + Schema::MAPPER => Mapper::class, + Schema::TABLE => 'post', + Schema::PRIMARY_KEY => ['id'], + Schema::FIND_BY_KEYS => ['id'], + Schema::COLUMNS => [ + 'id' => 'id', + 'slug' => 'slug', + 'title' => 'title', + 'public' => 'public', + 'content' => 'content', + 'created_at' => 'created_at', + 'updated_at' => 'updated_at', + 'published_at' => 'published_at', + 'deleted_at' => 'deleted_at', + 'user_id' => 'user_id', + ], + Schema::RELATIONS => [ + 'user' => [ + Relation::TYPE => Relation::BELONGS_TO, + Relation::TARGET => User::ROLE, + Relation::LOAD => Relation::LOAD_PROMISE, + Relation::SCHEMA => [ + Relation::CASCADE => true, + Relation::NULLABLE => true, + Relation::INNER_KEY => 'user_id', + Relation::OUTER_KEY => ['id'], + ], + ], + ], + Schema::TYPECAST => [ + 'id' => 'int', + 'public' => 'bool', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'published_at' => 'datetime', + 'deleted_at' => 'datetime', + 'user_id' => 'int', + ], + Schema::SCHEMA => [], + ], + 'user' => [ + Schema::ENTITY => User::class, + Schema::MAPPER => Mapper::class, + Schema::SOURCE => Source::class, + Schema::DATABASE => 'default', + Schema::TABLE => 'user', + Schema::PRIMARY_KEY => ['id'], + Schema::FIND_BY_KEYS => ['id'], + Schema::COLUMNS => [ + 'id' => 'id', + 'login' => 'login', + 'created_at' => 'created_at', + 'updated_at' => 'updated_at', + ], + Schema::RELATIONS => [ + // 'posts' => [ + // Relation::TYPE => Relation::HAS_MANY, + // Relation::TARGET => 'post', + // Relation::COLLECTION_TYPE => 'array', + // Relation::LOAD => Relation::LOAD_PROMISE, + // Relation::SCHEMA => [ + // Relation::CASCADE => true, + // Relation::NULLABLE => false, + // Relation::WHERE => [], + // Relation::ORDER_BY => [], + // Relation::INNER_KEY => ['id'], + // Relation::OUTER_KEY => 'user_id', + // ], + // ], + ], + Schema::SCOPE => null, + Schema::TYPECAST => [ + 'id' => 'int', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ], + Schema::SCHEMA => [], + ], +]; diff --git a/tests/ORM/Functional/Driver/MySQL/Integration/Case346/CaseTest.php b/tests/ORM/Functional/Driver/MySQL/Integration/Case346/CaseTest.php new file mode 100644 index 00000000..74f9be16 --- /dev/null +++ b/tests/ORM/Functional/Driver/MySQL/Integration/Case346/CaseTest.php @@ -0,0 +1,17 @@ + new Config\SQLiteDriverConfig( queryCache: true, + options:[ + 'logQueryParameters' => true, + ], ), 'mysql' => new Config\MySQLDriverConfig( connection: new Config\MySQL\TcpConnectionConfig(