From d957fb5cedaeef3f328e32c374eb8cad0428286a Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 19 Jun 2023 21:29:55 +0400 Subject: [PATCH 1/2] Add test case with bad BelongsTo hydration --- .../Common/Integration/Case416/CaseTest.php | 104 +++++++++++ .../Integration/Case416/Entity/Account.php | 32 ++++ .../Integration/Case416/Entity/Identity.php | 38 +++++ .../Integration/Case416/Entity/Profile.php | 32 ++++ .../Integration/Case416/Entity/UserName.php | 19 +++ .../Case416/Typecast/UuidTypecast.php | 59 +++++++ .../Common/Integration/Case416/schema.php | 161 ++++++++++++++++++ .../MySQL/Integration/Case416/CaseTest.php | 17 ++ .../Postgres/Integration/Case416/CaseTest.php | 17 ++ .../Integration/Case416/CaseTest.php | 17 ++ .../SQLite/Integration/Case416/CaseTest.php | 17 ++ 11 files changed, 513 insertions(+) create mode 100644 tests/ORM/Functional/Driver/Common/Integration/Case416/CaseTest.php create mode 100644 tests/ORM/Functional/Driver/Common/Integration/Case416/Entity/Account.php create mode 100644 tests/ORM/Functional/Driver/Common/Integration/Case416/Entity/Identity.php create mode 100644 tests/ORM/Functional/Driver/Common/Integration/Case416/Entity/Profile.php create mode 100644 tests/ORM/Functional/Driver/Common/Integration/Case416/Entity/UserName.php create mode 100644 tests/ORM/Functional/Driver/Common/Integration/Case416/Typecast/UuidTypecast.php create mode 100644 tests/ORM/Functional/Driver/Common/Integration/Case416/schema.php create mode 100644 tests/ORM/Functional/Driver/MySQL/Integration/Case416/CaseTest.php create mode 100644 tests/ORM/Functional/Driver/Postgres/Integration/Case416/CaseTest.php create mode 100644 tests/ORM/Functional/Driver/SQLServer/Integration/Case416/CaseTest.php create mode 100644 tests/ORM/Functional/Driver/SQLite/Integration/Case416/CaseTest.php diff --git a/tests/ORM/Functional/Driver/Common/Integration/Case416/CaseTest.php b/tests/ORM/Functional/Driver/Common/Integration/Case416/CaseTest.php new file mode 100644 index 00000000..9f1877c6 --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Case416/CaseTest.php @@ -0,0 +1,104 @@ +makeTables(); + $this->fillData(); + + $this->loadSchema(__DIR__ . '/schema.php'); + } + + public function testSelect(): void + { + $uuid = Uuid::uuid7(); + $identity = new Entity\Identity($uuid); + $profile = new Entity\Profile($uuid); + $account = new Entity\Account($uuid, 'test@mail.com', \md5('password')); + $identity->profile = $profile; + $identity->account = $account; + $profile->identity = $identity; + $account->identity = $identity; + + $this->save($identity); + + // Note: heap cleaning fixes this issue + // $this->orm->getHeap()->clean(); + + // Get entity + (new Select($this->orm, Entity\Account::class)) + ->load('identity.profile') + ->wherePK((string)$uuid) + ->fetchOne(); + + // There is no any exception like this: + // [Cycle\ORM\Exception\MapperException] + // Can't hydrate an entity because property and value types are incompatible. + // + // [TypeError] + // Cannot assign Cycle\ORM\Reference\Reference to property + // Cycle\ORM\Tests\Functional\Driver\Common\Integration\Case416\Entity\Profile::$identity of type + // Cycle\ORM\Tests\Functional\Driver\Common\Integration\Case416\Entity\Identity + $this->assertTrue(true); + } + + private function makeTables(): void + { + $this->makeTable(Entity\Identity::ROLE, [ + 'uuid' => 'string,primary', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'deleted_at' => 'datetime,nullable', + ]); + + $this->makeTable(Entity\Account::ROLE, [ + 'uuid' => 'string,primary', + 'email' => 'datetime', + 'password_hash' => 'datetime', + 'updated_at' => 'datetime,nullable', + ]); + $this->makeFK( + Entity\Account::ROLE, + 'uuid', + Entity\Identity::ROLE, + 'uuid', + 'NO ACTION', + 'NO ACTION', + ); + + $this->makeTable(Entity\Profile::ROLE, [ + 'uuid' => 'string,primary', + 'updated_at' => 'datetime', + 'first_name' => 'string', + 'last_name' => 'string', + ]); + $this->makeFK( + Entity\Profile::ROLE, + 'uuid', + Entity\Identity::ROLE, + 'uuid', + 'NO ACTION', + 'NO ACTION', + ); + } + + private function fillData(): void + { + } +} diff --git a/tests/ORM/Functional/Driver/Common/Integration/Case416/Entity/Account.php b/tests/ORM/Functional/Driver/Common/Integration/Case416/Entity/Account.php new file mode 100644 index 00000000..6a4b8496 --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Case416/Entity/Account.php @@ -0,0 +1,32 @@ +updatedAt = new \DateTimeImmutable(); + } +} diff --git a/tests/ORM/Functional/Driver/Common/Integration/Case416/Entity/Identity.php b/tests/ORM/Functional/Driver/Common/Integration/Case416/Entity/Identity.php new file mode 100644 index 00000000..36c831ff --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Case416/Entity/Identity.php @@ -0,0 +1,38 @@ +createdAt = $now; + $this->updatedAt = $now; + } +} diff --git a/tests/ORM/Functional/Driver/Common/Integration/Case416/Entity/Profile.php b/tests/ORM/Functional/Driver/Common/Integration/Case416/Entity/Profile.php new file mode 100644 index 00000000..b758b6ac --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Case416/Entity/Profile.php @@ -0,0 +1,32 @@ +name = new UserName(); + $this->updatedAt = new \DateTimeImmutable(); + } +} diff --git a/tests/ORM/Functional/Driver/Common/Integration/Case416/Entity/UserName.php b/tests/ORM/Functional/Driver/Common/Integration/Case416/Entity/UserName.php new file mode 100644 index 00000000..c9755cde --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Case416/Entity/UserName.php @@ -0,0 +1,19 @@ + $rule) { + if ($rule === 'uuid') { + unset($rules[$key]); + $this->rules[$key] = $rule; + } + } + + return $rules; + } + + public function cast(array $data): array + { + foreach ($this->rules as $column => $rule) { + if (!isset($data[$column])) { + continue; + } + + \assert(\is_string($data[$column])); + $data[$column] = Uuid::fromString($data[$column]); + } + + return $data; + } + + public function uncast(array $data): array + { + foreach ($this->rules as $column => $rule) { + if (!isset($data[$column]) || !$data[$column] instanceof UuidInterface) { + continue; + } + + $data[$column] = $data[$column]->toString(); + } + + return $data; + } +} diff --git a/tests/ORM/Functional/Driver/Common/Integration/Case416/schema.php b/tests/ORM/Functional/Driver/Common/Integration/Case416/schema.php new file mode 100644 index 00000000..49024c51 --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Case416/schema.php @@ -0,0 +1,161 @@ + [ + Schema::ENTITY => Account::class, + Schema::MAPPER => Cycle\ORM\Mapper\Mapper::class, + Schema::SOURCE => Cycle\ORM\Select\Source::class, + Schema::DATABASE => 'default', + Schema::TABLE => 'account', + Schema::PRIMARY_KEY => ['uuid'], + Schema::FIND_BY_KEYS => ['uuid'], + Schema::COLUMNS => [ + 'uuid' => 'uuid', + 'email' => 'email', + 'passwordHash' => 'password_hash', + 'updatedAt' => 'updated_at', + ], + Schema::RELATIONS => [ + 'identity' => [ + Relation::TYPE => Relation::BELONGS_TO, + Relation::TARGET => 'identity', + Relation::LOAD => Relation::LOAD_PROMISE, + Relation::SCHEMA => [ + Relation::CASCADE => true, + Relation::NULLABLE => false, + Relation::INNER_KEY => ['uuid'], + Relation::OUTER_KEY => ['uuid'], + Relation::INVERSION => 'account', + ], + ], + ], + Schema::SCOPE => null, + Schema::TYPECAST => [ + 'uuid' => 'uuid', + 'updatedAt' => 'datetime', + ], + Schema::SCHEMA => [], + Schema::TYPECAST_HANDLER => [UuidTypecast::class, Cycle\ORM\Parser\Typecast::class], + ], + 'identity' => [ + Schema::ENTITY => Identity::class, + Schema::MAPPER => Cycle\ORM\Mapper\Mapper::class, + Schema::SOURCE => Cycle\ORM\Select\Source::class, + Schema::DATABASE => 'default', + Schema::TABLE => 'identity', + Schema::PRIMARY_KEY => ['uuid'], + Schema::FIND_BY_KEYS => ['uuid'], + Schema::COLUMNS => [ + 'uuid' => 'uuid', + 'createdAt' => 'created_at', + 'updatedAt' => 'updated_at', + 'deletedAt' => 'deleted_at', + ], + Schema::RELATIONS => [ + 'profile' => [ + Relation::TYPE => Relation::HAS_ONE, + Relation::TARGET => 'profile', + Relation::LOAD => Relation::LOAD_PROMISE, + Relation::SCHEMA => [ + Relation::CASCADE => true, + Relation::NULLABLE => false, + Relation::INNER_KEY => ['uuid'], + Relation::OUTER_KEY => ['uuid'], + Relation::INVERSION => 'identity', + ], + ], + 'account' => [ + Relation::TYPE => Relation::HAS_ONE, + Relation::TARGET => 'account', + Relation::LOAD => Relation::LOAD_PROMISE, + Relation::SCHEMA => [ + Relation::CASCADE => true, + Relation::NULLABLE => false, + Relation::INNER_KEY => ['uuid'], + Relation::OUTER_KEY => ['uuid'], + Relation::INVERSION => 'identity', + ], + ], + ], + Schema::SCOPE => null, + Schema::TYPECAST => [ + 'uuid' => 'uuid', + 'createdAt' => 'datetime', + 'updatedAt' => 'datetime', + 'deletedAt' => 'datetime', + ], + Schema::SCHEMA => [], + Schema::TYPECAST_HANDLER => [UuidTypecast::class, Cycle\ORM\Parser\Typecast::class], + ], + 'profile' => [ + Schema::ENTITY => Profile::class, + Schema::MAPPER => Cycle\ORM\Mapper\Mapper::class, + Schema::SOURCE => Cycle\ORM\Select\Source::class, + Schema::DATABASE => 'default', + Schema::TABLE => 'profile', + Schema::PRIMARY_KEY => ['uuid'], + Schema::FIND_BY_KEYS => ['uuid'], + Schema::COLUMNS => [ + 'uuid' => 'uuid', + 'updatedAt' => 'updated_at', + ], + Schema::RELATIONS => [ + 'name' => [ + Relation::TYPE => 1, + Relation::TARGET => 'profile:userName:name', + Relation::LOAD => Relation::LOAD_EAGER, + Relation::SCHEMA => [], + ], + 'identity' => [ + Relation::TYPE => Relation::BELONGS_TO, + Relation::TARGET => 'identity', + Relation::LOAD => Relation::LOAD_PROMISE, + Relation::SCHEMA => [ + Relation::CASCADE => true, + Relation::NULLABLE => false, + Relation::INNER_KEY => ['uuid'], + Relation::OUTER_KEY => ['uuid'], + Relation::INVERSION => 'profile', + ], + ], + ], + Schema::SCOPE => null, + Schema::TYPECAST => [ + 'uuid' => 'uuid', + 'updatedAt' => 'datetime', + ], + Schema::SCHEMA => [], + Schema::TYPECAST_HANDLER => [UuidTypecast::class, Cycle\ORM\Parser\Typecast::class], + ], + 'profile:userName:name' => [ + Schema::ENTITY => UserName::class, + Schema::MAPPER => Cycle\ORM\Mapper\Mapper::class, + Schema::SOURCE => Cycle\ORM\Select\Source::class, + Schema::DATABASE => 'default', + Schema::TABLE => 'profile', + Schema::PRIMARY_KEY => ['uuid'], + Schema::FIND_BY_KEYS => ['uuid'], + Schema::COLUMNS => [ + 'firstName' => 'first_name', + 'lastName' => 'last_name', + 'uuid' => 'uuid', + ], + Schema::RELATIONS => [], + Schema::SCOPE => null, + Schema::TYPECAST => [ + 'uuid' => 'uuid', + ], + Schema::SCHEMA => [], + Schema::TYPECAST_HANDLER => [UuidTypecast::class, Cycle\ORM\Parser\Typecast::class], + ], +]; diff --git a/tests/ORM/Functional/Driver/MySQL/Integration/Case416/CaseTest.php b/tests/ORM/Functional/Driver/MySQL/Integration/Case416/CaseTest.php new file mode 100644 index 00000000..7b9a5c66 --- /dev/null +++ b/tests/ORM/Functional/Driver/MySQL/Integration/Case416/CaseTest.php @@ -0,0 +1,17 @@ + Date: Tue, 20 Jun 2023 00:30:54 +0400 Subject: [PATCH 2/2] Fix closure hydrator: public relations are hydrated like private ones now. There are a logic related to ReferenceInterface hydrating --- src/Mapper/Proxy/Hydrator/ClosureHydrator.php | 76 ++++++++++--------- src/Mapper/StdMapper.php | 2 +- .../Common/Integration/Case416/CaseTest.php | 16 ++-- 3 files changed, 47 insertions(+), 47 deletions(-) diff --git a/src/Mapper/Proxy/Hydrator/ClosureHydrator.php b/src/Mapper/Proxy/Hydrator/ClosureHydrator.php index cf24767d..55061f65 100644 --- a/src/Mapper/Proxy/Hydrator/ClosureHydrator.php +++ b/src/Mapper/Proxy/Hydrator/ClosureHydrator.php @@ -83,56 +83,60 @@ private function setEntityProperties(array $properties, object $object, array &$ } /** - * Map private entity relations + * Map private relations of non-proxy entity */ private function setRelationProperties(array $properties, RelationMap $relMap, object $object, array &$data): void { $refl = new \ReflectionClass($object); + $setter = static function (object $object, array $props, array &$data) use ($refl, $relMap): void { + foreach ($props as $property) { + if (!\array_key_exists($property, $data)) { + continue; + } - foreach ($properties as $class => $props) { - if ($class === '') { - continue; - } - - Closure::bind(static function (object $object, array $props, array &$data) use ($refl, $relMap): void { - foreach ($props as $property) { - if (!\array_key_exists($property, $data)) { - continue; - } - - $value = $data[$property]; + $value = $data[$property]; - if ($value instanceof ReferenceInterface) { - $prop = $refl->getProperty($property); + if ($value instanceof ReferenceInterface) { + $prop = $refl->getProperty($property); - if ($prop->hasType()) { - /** @var \ReflectionNamedType[] $types */ - $types = $prop->getType() instanceof \ReflectionUnionType - ? $prop->getType()->getTypes() - : [$prop->getType()]; + if ($prop->hasType()) { + // todo: we can cache this + /** @var \ReflectionNamedType[] $types */ + $types = $prop->getType() instanceof \ReflectionUnionType + ? $prop->getType()->getTypes() + : [$prop->getType()]; - foreach ($types as $type) { - $c = $type->getName(); - if ($c === 'object' || $value instanceof $c) { - $object->{$property} = $value; - unset($data[$property]); + foreach ($types as $type) { + $c = $type->getName(); + if ($c === 'object' || $value instanceof $c) { + $object->{$property} = $value; + unset($data[$property]); - // go to next property - continue 2; - } + // go to next property + continue 2; } + } - $relation = $relMap->getRelations()[$property] ?? null; - if ($relation !== null) { - $value = $relation->collect($relation->resolve($value, true)); - } + $relation = $relMap->getRelations()[$property] ?? null; + if ($relation !== null) { + $value = $relation->collect($relation->resolve($value, true)); } } - - $object->{$property} = $value; - unset($data[$property]); } - }, null, $class)($object, $props, $data); + + $object->{$property} = $value; + unset($data[$property]); + } + }; + + foreach ($properties as $class => $props) { + if ($class === '') { + // Hydrate public properties + $setter($object, $props, $data); + continue; + } + + Closure::bind($setter, null, $class)($object, $props, $data); } } } diff --git a/src/Mapper/StdMapper.php b/src/Mapper/StdMapper.php index 9f985ead..896c683e 100644 --- a/src/Mapper/StdMapper.php +++ b/src/Mapper/StdMapper.php @@ -21,7 +21,7 @@ public function hydrate($entity, array $data): object { $relations = $this->relationMap->getRelations(); foreach ($data as $k => $v) { - if ($v instanceof ReferenceInterface && array_key_exists($k, $relations)) { + if ($v instanceof ReferenceInterface && \array_key_exists($k, $relations)) { $relation = $relations[$k]; $relation->resolve($v, false); diff --git a/tests/ORM/Functional/Driver/Common/Integration/Case416/CaseTest.php b/tests/ORM/Functional/Driver/Common/Integration/Case416/CaseTest.php index 9f1877c6..90e966b8 100644 --- a/tests/ORM/Functional/Driver/Common/Integration/Case416/CaseTest.php +++ b/tests/ORM/Functional/Driver/Common/Integration/Case416/CaseTest.php @@ -20,7 +20,6 @@ public function setUp(): void // Init DB parent::setUp(); $this->makeTables(); - $this->fillData(); $this->loadSchema(__DIR__ . '/schema.php'); } @@ -33,8 +32,6 @@ public function testSelect(): void $account = new Entity\Account($uuid, 'test@mail.com', \md5('password')); $identity->profile = $profile; $identity->account = $account; - $profile->identity = $identity; - $account->identity = $identity; $this->save($identity); @@ -56,6 +53,9 @@ public function testSelect(): void // Cycle\ORM\Tests\Functional\Driver\Common\Integration\Case416\Entity\Profile::$identity of type // Cycle\ORM\Tests\Functional\Driver\Common\Integration\Case416\Entity\Identity $this->assertTrue(true); + + // To avoid `Entity and State are not in sync` exception + $this->orm->getHeap()->clean(); } private function makeTables(): void @@ -69,9 +69,9 @@ private function makeTables(): void $this->makeTable(Entity\Account::ROLE, [ 'uuid' => 'string,primary', - 'email' => 'datetime', - 'password_hash' => 'datetime', - 'updated_at' => 'datetime,nullable', + 'email' => 'string', + 'password_hash' => 'string', + 'updated_at' => 'datetime', ]); $this->makeFK( Entity\Account::ROLE, @@ -97,8 +97,4 @@ private function makeTables(): void 'NO ACTION', ); } - - private function fillData(): void - { - } }