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 new file mode 100644 index 00000000..90e966b8 --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Case416/CaseTest.php @@ -0,0 +1,100 @@ +makeTables(); + + $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; + + $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); + + // To avoid `Entity and State are not in sync` exception + $this->orm->getHeap()->clean(); + } + + 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' => 'string', + 'password_hash' => 'string', + 'updated_at' => 'datetime', + ]); + $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', + ); + } +} 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 @@ +