From 35f5729f5332311f4fe434c76f6a73b2df8425b8 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 6 Mar 2026 09:32:19 +0400 Subject: [PATCH 1/3] All the necessary keys in BulkLoader will be converted consistently --- src/Heap/Node.php | 16 +- src/Relation/BulkLoader.php | 45 ++- .../Common/Integration/Case431/CaseTest.php | 270 ++++++++++++++++++ .../Integration/Case431/Entity/Comment.php | 27 ++ .../Integration/Case431/Entity/Post.php | 32 +++ .../Integration/Case431/Entity/User.php | 35 +++ .../Case431/Typecast/OneWayUuidTypecast.php | 42 +++ .../Typecast/OneWayValueInterfaceTypecast.php | 42 +++ .../Case431/Typecast/StringableUuid.php | 22 ++ .../Case431/Typecast/ValueInterfaceUuid.php | 39 +++ .../Common/Integration/Case431/schema.php | 185 ++++++++++++ .../MySQL/Integration/Case431/CaseTest.php | 17 ++ .../Postgres/Integration/Case431/CaseTest.php | 17 ++ .../Integration/Case431/CaseTest.php | 17 ++ .../SQLite/Integration/Case431/CaseTest.php | 17 ++ 15 files changed, 808 insertions(+), 15 deletions(-) create mode 100644 tests/ORM/Functional/Driver/Common/Integration/Case431/CaseTest.php create mode 100644 tests/ORM/Functional/Driver/Common/Integration/Case431/Entity/Comment.php create mode 100644 tests/ORM/Functional/Driver/Common/Integration/Case431/Entity/Post.php create mode 100644 tests/ORM/Functional/Driver/Common/Integration/Case431/Entity/User.php create mode 100644 tests/ORM/Functional/Driver/Common/Integration/Case431/Typecast/OneWayUuidTypecast.php create mode 100644 tests/ORM/Functional/Driver/Common/Integration/Case431/Typecast/OneWayValueInterfaceTypecast.php create mode 100644 tests/ORM/Functional/Driver/Common/Integration/Case431/Typecast/StringableUuid.php create mode 100644 tests/ORM/Functional/Driver/Common/Integration/Case431/Typecast/ValueInterfaceUuid.php create mode 100644 tests/ORM/Functional/Driver/Common/Integration/Case431/schema.php create mode 100644 tests/ORM/Functional/Driver/MySQL/Integration/Case431/CaseTest.php create mode 100644 tests/ORM/Functional/Driver/Postgres/Integration/Case431/CaseTest.php create mode 100644 tests/ORM/Functional/Driver/SQLServer/Integration/Case431/CaseTest.php create mode 100644 tests/ORM/Functional/Driver/SQLite/Integration/Case431/CaseTest.php diff --git a/src/Heap/Node.php b/src/Heap/Node.php index d397fe567..3e471b2b8 100644 --- a/src/Heap/Node.php +++ b/src/Heap/Node.php @@ -47,13 +47,15 @@ public function __construct( */ public static function convertToSolid(mixed $value): mixed { - if (!\is_object($value)) { - return $value; - } - if ($value instanceof \DateTimeInterface) { - return $value instanceof \DateTimeImmutable ? $value : \DateTimeImmutable::createFromInterface($value); - } - return $value instanceof \Stringable ? $value->__toString() : $value; + return match (true) { + !\is_object($value) => $value, + $value instanceof ValueInterface => $value->rawValue(), + $value instanceof \DateTimeInterface => $value instanceof \DateTimeImmutable + ? $value + : \DateTimeImmutable::createFromInterface($value), + $value instanceof \Stringable => $value->__toString(), + default => $value, + }; } public static function compare(mixed $a, mixed $b): int diff --git a/src/Relation/BulkLoader.php b/src/Relation/BulkLoader.php index fd5924565..55f0dbcb7 100644 --- a/src/Relation/BulkLoader.php +++ b/src/Relation/BulkLoader.php @@ -5,6 +5,7 @@ namespace Cycle\ORM\Relation; use Cycle\ORM\Heap\Node; +use Cycle\ORM\MapperInterface; use Cycle\ORM\ORMInterface; use Cycle\ORM\Reference\ReferenceInterface; use Cycle\ORM\SchemaInterface; @@ -23,6 +24,9 @@ final class BulkLoader implements BulkLoaderInterface, RelationLoaderInterface private UpdateLoader $loader; private array $index = []; + /** @var list Keys matter for relations */ + private array $keys = []; + public function __construct( private ORMInterface $orm, ) {} @@ -65,6 +69,12 @@ public function run(): void {} public function load(string $relation, array $options = []): static { $this->loader->loadRelation($relation, $options, load: true); + + $role = $this->loader->getTarget(); + $relMap = $this->orm->getRelationMap($role); + $r = $relMap->getRelations()[$relation]; + $this->keys = \array_merge($this->keys, $r->getInnerKeys()); + return $this; } @@ -78,12 +88,13 @@ public function run(): void $relations = $relMap->getRelations(); $heap = $this->orm->getHeap(); $factory = $this->orm->getService(EntityFactoryInterface::class); + $keys = \array_unique(\array_merge($this->keys, $pk)); foreach ($this->entities as $entity) { $n = $heap->get($entity) ?? throw new \LogicException("Entity node not found in the heap."); // Use Node data to load relations instead of actual entity data // to avoid inconsistent state in the Heap - $data = $mapper->uncast($n->getData()); + $data = self::normalizeKeys($mapper->uncast($n->getData()), $keys); $this->indexEntity($n, $pk, $data, $entity); $node->push($data); unset($data); @@ -125,12 +136,11 @@ private function indexEntity(Node $node, array $keys, array $data, object $entit { $pool = &$this->index; foreach ($keys as $k) { - $keyValue = $data[$k] ?? throw new \LogicException("Bulk loader cannot get the value for the key `$k`."); - \is_scalar($keyValue) or $keyValue instanceof \Stringable - ? ($keyValue = (string) $keyValue) - : throw new \InvalidArgumentException( - "Invalid value on the key `$k`. Expected scalar, got " . \get_debug_type($keyValue) . ".", - ); + $keyValue = $data[$k]; + \is_scalar($keyValue) or throw new \InvalidArgumentException( + "Invalid value on the primary key `$k`. Expected scalar, got " . \get_debug_type($keyValue) . ".", + ); + $pk[$k] = $keyValue; \array_key_exists($keyValue, $pool) or $pool[$keyValue] = []; $pool = &$pool[$keyValue]; @@ -140,6 +150,25 @@ private function indexEntity(Node $node, array $keys, array $data, object $entit $pool = [$entity, $node]; } + /** + * Normalize data by provided keys. + * Only keys matter for relations loading, so unnecessary data will be filtered out. + * + * @param non-empty-array $keys + * @return non-empty-array + */ + private static function normalizeKeys(array $data, array $keys): array + { + $result = []; + foreach ($keys as $k) { + \array_key_exists($k, $data) or throw new \LogicException( + "Bulk loader cannot get the value for the key `$k`.", + ); + $result[$k] = Node::convertToSolid($data[$k]); + } + return $result; + } + /** * Fetch indexed entity by provided keys and data. * @@ -152,7 +181,7 @@ private function getEntity(array $pk, array $data): array { $result = $this->index; foreach ($pk as $k) { - $result = $result[(string) $data[$k]] ?? throw new \LogicException('Cannot find indexed entity.'); + $result = $result[$data[$k]] ?? throw new \LogicException('Cannot find indexed entity.'); } return $result; diff --git a/tests/ORM/Functional/Driver/Common/Integration/Case431/CaseTest.php b/tests/ORM/Functional/Driver/Common/Integration/Case431/CaseTest.php new file mode 100644 index 000000000..5e6f598ed --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Case431/CaseTest.php @@ -0,0 +1,270 @@ +orm, Entity\User::class)) + ->orderBy('login') + ->fetchAll(); + + $this->assertCount(3, $users); + + $this->captureReadQueries(); + (new BulkLoader($this->orm)) + ->collect(...$users) + ->load('posts') + ->run(); + $this->assertNumReads(1); + + $this->captureReadQueries(); + $postCounts = []; + foreach ($users as $user) { + $this->assertInstanceOf(Typecast\StringableUuid::class, $user->id); + $postCounts[$user->id->toString()] = \count($user->posts); + } + $this->assertNumReads(0); + + $this->assertSame(1, $postCounts[self::USER_1]); + $this->assertSame(2, $postCounts[self::USER_2]); + $this->assertSame(1, $postCounts[self::USER_3]); + } + + public function testStringablePk_SingleEntity(): void + { + $user = (new Select($this->orm, Entity\User::class)) + ->where('login', 'user-2') + ->fetchOne(); + + $this->assertInstanceOf(Entity\User::class, $user); + + $this->captureReadQueries(); + (new BulkLoader($this->orm)) + ->collect($user) + ->load('posts') + ->run(); + $this->assertNumReads(1); + + $this->captureReadQueries(); + $this->assertCount(2, $user->posts); + $this->assertNumReads(0); + } + + // ============================================= + // ValueInterface PK tests (Post entity, ValueInterfaceUuid) + // rawValue() returns DB-matching UUID, __toString() returns "urn:uuid:..." + // If BulkLoader incorrectly uses __toString(), indexing won't match DB data. + // ============================================= + + public function testValueInterfacePk_HasMany(): void + { + $posts = (new Select($this->orm, Entity\Post::class)) + ->fetchAll(); + + $this->assertCount(4, $posts); + + foreach ($posts as $post) { + $this->assertInstanceOf(Typecast\ValueInterfaceUuid::class, $post->id); + } + + $this->captureReadQueries(); + (new BulkLoader($this->orm)) + ->collect(...$posts) + ->load('comments') + ->run(); + $this->assertNumReads(2); // 1 comments + 1 eager comment.user + + $this->captureReadQueries(); + foreach ($posts as $post) { + $this->assertIsArray($post->comments); + } + $this->assertNumReads(0); + } + + public function testValueInterfacePk_BelongsTo(): void + { + $posts = (new Select($this->orm, Entity\Post::class)) + ->orderBy('title') + ->fetchAll(); + + $this->assertCount(4, $posts); + + $this->captureReadQueries(); + (new BulkLoader($this->orm)) + ->collect(...$posts) + ->load('user') + ->run(); + $this->assertNumReads(1); + + $this->captureReadQueries(); + foreach ($posts as $post) { + $this->assertInstanceOf(Entity\User::class, $post->user); + } + $this->assertNumReads(0); + } + + public function testValueInterfacePk_MultipleRelations(): void + { + $posts = (new Select($this->orm, Entity\Post::class)) + ->fetchAll(); + + $this->assertCount(4, $posts); + + $this->captureReadQueries(); + (new BulkLoader($this->orm)) + ->collect(...$posts) + ->load('user') + ->load('comments') + ->run(); + $this->assertNumReads(3); // 1 users + 1 comments + 1 eager comment.user + + $this->captureReadQueries(); + foreach ($posts as $post) { + $this->assertInstanceOf(Entity\User::class, $post->user); + $this->assertIsArray($post->comments); + } + $this->assertNumReads(0); + } + + // ============================================= + // Cross-type: BulkLoad comments, load lazy post (ValueInterfaceUuid FK) + // ============================================= + + public function testStringablePk_BelongsToValueInterfaceEntity(): void + { + $comments = (new Select($this->orm, Entity\Comment::class)) + ->fetchAll(); + + $this->assertCount(3, $comments); + + $this->captureReadQueries(); + (new BulkLoader($this->orm)) + ->collect(...$comments) + ->load('post') + ->run(); + $this->assertNumReads(1); + + $this->captureReadQueries(); + foreach ($comments as $comment) { + $this->assertInstanceOf(Entity\Post::class, $comment->post); + $this->assertInstanceOf(Typecast\ValueInterfaceUuid::class, $comment->post->id); + } + $this->assertNumReads(0); + } + + public function setUp(): void + { + parent::setUp(); + $this->makeTables(); + $this->fillData(); + + $this->loadSchema(__DIR__ . '/schema.php'); + } + + private function makeTables(): void + { + $this->makeTable(Entity\User::ROLE, [ + 'id' => 'string,primary', + 'login' => 'string', + 'password_hash' => 'string', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]); + + $this->makeTable('post', [ + 'id' => 'string,primary', + 'user_id' => 'string', + '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'); + + $this->makeTable('comment', [ + 'id' => 'string,primary', + 'public' => 'bool', + 'content' => 'string', + 'user_id' => 'string', + 'post_id' => 'string', + 'published_at' => 'datetime,nullable', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'deleted_at' => 'datetime,nullable', + ]); + $this->makeFK('comment', 'user_id', 'user', 'id', 'NO ACTION', 'NO ACTION'); + $this->makeFK('comment', 'post_id', 'post', 'id', 'NO ACTION', 'NO ACTION'); + } + + private function fillData(): void + { + $now = new \DateTimeImmutable(); + + $this->getDatabase()->table('user')->insertMultiple( + ['id', 'login', 'password_hash', 'created_at', 'updated_at'], + [ + [self::USER_1, 'user-1', \md5('pass1'), $now, $now], + [self::USER_2, 'user-2', \md5('pass2'), $now, $now], + [self::USER_3, 'user-3', \md5('pass3'), $now, $now], + ], + ); + + $this->getDatabase()->table('post')->insertMultiple( + ['id', 'user_id', 'slug', 'title', 'public', 'content', 'created_at', 'updated_at'], + [ + [self::POST_1, self::USER_1, 'slug-1', 'Post 1', true, 'Content 1', $now, $now], + [self::POST_2, self::USER_2, 'slug-2', 'Post 2', true, 'Content 2', $now, $now], + [self::POST_3, self::USER_2, 'slug-3', 'Post 3', true, 'Content 3', $now, $now], + [self::POST_4, self::USER_3, 'slug-4', 'Post 4', true, 'Content 4', $now, $now], + ], + ); + + $this->getDatabase()->table('comment')->insertMultiple( + ['id', 'user_id', 'post_id', 'public', 'content', 'created_at', 'updated_at'], + [ + [self::COMMENT_1, self::USER_1, self::POST_1, true, 'Comment 1', $now, $now], + [self::COMMENT_2, self::USER_1, self::POST_2, true, 'Comment 2', $now, $now], + [self::COMMENT_3, self::USER_2, self::POST_1, true, 'Comment 3', $now, $now], + ], + ); + } +} diff --git a/tests/ORM/Functional/Driver/Common/Integration/Case431/Entity/Comment.php b/tests/ORM/Functional/Driver/Common/Integration/Case431/Entity/Comment.php new file mode 100644 index 000000000..565d7736b --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Case431/Entity/Comment.php @@ -0,0 +1,27 @@ +content = $content; + $this->created_at = new \DateTimeImmutable(); + $this->updated_at = new \DateTimeImmutable(); + } +} diff --git a/tests/ORM/Functional/Driver/Common/Integration/Case431/Entity/Post.php b/tests/ORM/Functional/Driver/Common/Integration/Case431/Entity/Post.php new file mode 100644 index 000000000..07ddb4096 --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Case431/Entity/Post.php @@ -0,0 +1,32 @@ + */ + public iterable $comments = []; + + public function __construct(string $title = '', string $content = '') + { + $this->title = $title; + $this->content = $content; + $this->created_at = new \DateTimeImmutable(); + $this->updated_at = new \DateTimeImmutable(); + $this->slug = \bin2hex(\random_bytes(32)); + } +} diff --git a/tests/ORM/Functional/Driver/Common/Integration/Case431/Entity/User.php b/tests/ORM/Functional/Driver/Common/Integration/Case431/Entity/User.php new file mode 100644 index 000000000..754c62968 --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Case431/Entity/User.php @@ -0,0 +1,35 @@ + */ + public iterable $posts = []; + + /** @var iterable */ + public iterable $comments = []; + + public function __construct(string $login, string $password) + { + $this->login = $login; + $this->created_at = new \DateTimeImmutable(); + $this->updated_at = new \DateTimeImmutable(); + $this->setPassword($password); + } + + public function setPassword(string $password): void + { + $this->passwordHash = md5($password); + } +} diff --git a/tests/ORM/Functional/Driver/Common/Integration/Case431/Typecast/OneWayUuidTypecast.php b/tests/ORM/Functional/Driver/Common/Integration/Case431/Typecast/OneWayUuidTypecast.php new file mode 100644 index 000000000..d5bc4962b --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Case431/Typecast/OneWayUuidTypecast.php @@ -0,0 +1,42 @@ + $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; + } + + $data[$column] = new StringableUuid((string) $data[$column]); + } + + return $data; + } +} diff --git a/tests/ORM/Functional/Driver/Common/Integration/Case431/Typecast/OneWayValueInterfaceTypecast.php b/tests/ORM/Functional/Driver/Common/Integration/Case431/Typecast/OneWayValueInterfaceTypecast.php new file mode 100644 index 000000000..989a10e0f --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Case431/Typecast/OneWayValueInterfaceTypecast.php @@ -0,0 +1,42 @@ + $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; + } + + $data[$column] = new ValueInterfaceUuid((string) $data[$column]); + } + + return $data; + } +} diff --git a/tests/ORM/Functional/Driver/Common/Integration/Case431/Typecast/StringableUuid.php b/tests/ORM/Functional/Driver/Common/Integration/Case431/Typecast/StringableUuid.php new file mode 100644 index 000000000..db34b4414 --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Case431/Typecast/StringableUuid.php @@ -0,0 +1,22 @@ +value; + } + + public function toString(): string + { + return $this->value; + } +} diff --git a/tests/ORM/Functional/Driver/Common/Integration/Case431/Typecast/ValueInterfaceUuid.php b/tests/ORM/Functional/Driver/Common/Integration/Case431/Typecast/ValueInterfaceUuid.php new file mode 100644 index 000000000..0ec98aead --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Case431/Typecast/ValueInterfaceUuid.php @@ -0,0 +1,39 @@ +value; + } + + public function rawType(): int + { + return \PDO::PARAM_STR; + } + + public function __toString(): string + { + return 'urn:uuid:' . $this->value; + } + + public function toString(): string + { + return $this->value; + } +} diff --git a/tests/ORM/Functional/Driver/Common/Integration/Case431/schema.php b/tests/ORM/Functional/Driver/Common/Integration/Case431/schema.php new file mode 100644 index 000000000..e8a054ccb --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Case431/schema.php @@ -0,0 +1,185 @@ + [ + 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', + 'passwordHash' => 'password_hash', + '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', + ], + ], + 'comments' => [ + Relation::TYPE => Relation::HAS_MANY, + Relation::TARGET => 'comment', + Relation::COLLECTION_TYPE => 'array', + Relation::LOAD => Relation::LOAD_PROMISE, + Relation::SCHEMA => [ + Relation::CASCADE => true, + Relation::NULLABLE => false, + Relation::WHERE => [], + Relation::ORDER_BY => [], + Relation::ORDER_BY => [], + Relation::INNER_KEY => ['id'], + Relation::OUTER_KEY => 'user_id', + ], + ], + ], + Schema::TYPECAST => [ + 'id' => 'uuid', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ], + Schema::SCHEMA => [], + Schema::TYPECAST_HANDLER => [OneWayUuidTypecast::class, Typecast::class], + ], + 'post' => [ + Schema::ENTITY => Post::class, + Schema::MAPPER => Mapper::class, + Schema::SOURCE => Source::class, + Schema::DATABASE => 'default', + 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 => false, + Relation::INNER_KEY => 'user_id', + Relation::OUTER_KEY => ['id'], + ], + ], + 'comments' => [ + Relation::TYPE => Relation::HAS_MANY, + Relation::TARGET => 'comment', + 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 => 'post_id', + ], + ], + ], + Schema::TYPECAST => [ + 'id' => 'uuid', + 'public' => 'bool', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'published_at' => 'datetime', + 'deleted_at' => 'datetime', + 'user_id' => 'uuid', + ], + Schema::SCHEMA => [], + Schema::TYPECAST_HANDLER => [OneWayValueInterfaceTypecast::class, Typecast::class], + ], + 'comment' => [ + Schema::ENTITY => Comment::class, + Schema::SOURCE => Source::class, + Schema::DATABASE => 'default', + Schema::TABLE => 'comment', + Schema::PRIMARY_KEY => ['id'], + Schema::FIND_BY_KEYS => ['id'], + Schema::COLUMNS => [ + 'id' => 'id', + 'public' => 'public', + 'content' => 'content', + 'created_at' => 'created_at', + 'updated_at' => 'updated_at', + 'published_at' => 'published_at', + 'deleted_at' => 'deleted_at', + 'user_id' => 'user_id', + 'post_id' => 'post_id', + ], + Schema::RELATIONS => [ + 'user' => [ + Relation::TYPE => Relation::BELONGS_TO, + Relation::TARGET => User::ROLE, + Relation::LOAD => Relation::LOAD_EAGER, + Relation::SCHEMA => [ + Relation::CASCADE => true, + Relation::NULLABLE => false, + Relation::INNER_KEY => 'user_id', + Relation::OUTER_KEY => ['id'], + ], + ], + 'post' => [ + Relation::TYPE => Relation::BELONGS_TO, + Relation::TARGET => 'post', + Relation::LOAD => Relation::LOAD_PROMISE, + Relation::SCHEMA => [ + Relation::CASCADE => true, + Relation::NULLABLE => false, + Relation::INNER_KEY => 'post_id', + Relation::OUTER_KEY => ['id'], + ], + ], + ], + Schema::TYPECAST => [ + 'id' => 'uuid', + 'public' => 'bool', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'published_at' => 'datetime', + 'deleted_at' => 'datetime', + 'user_id' => 'uuid', + 'post_id' => 'uuid', + ], + Schema::SCHEMA => [], + Schema::TYPECAST_HANDLER => [OneWayUuidTypecast::class, Typecast::class], + ], +]; diff --git a/tests/ORM/Functional/Driver/MySQL/Integration/Case431/CaseTest.php b/tests/ORM/Functional/Driver/MySQL/Integration/Case431/CaseTest.php new file mode 100644 index 000000000..baa0679be --- /dev/null +++ b/tests/ORM/Functional/Driver/MySQL/Integration/Case431/CaseTest.php @@ -0,0 +1,17 @@ + Date: Fri, 6 Mar 2026 05:33:06 +0000 Subject: [PATCH 2/3] style(php-cs-fixer): fix coding standards --- src/Relation/BulkLoader.php | 39 +++++++++---------- .../Case431/Typecast/StringableUuid.php | 4 +- .../Case431/Typecast/ValueInterfaceUuid.php | 8 ++-- 3 files changed, 25 insertions(+), 26 deletions(-) diff --git a/src/Relation/BulkLoader.php b/src/Relation/BulkLoader.php index 55f0dbcb7..35244a30d 100644 --- a/src/Relation/BulkLoader.php +++ b/src/Relation/BulkLoader.php @@ -5,7 +5,6 @@ namespace Cycle\ORM\Relation; use Cycle\ORM\Heap\Node; -use Cycle\ORM\MapperInterface; use Cycle\ORM\ORMInterface; use Cycle\ORM\Reference\ReferenceInterface; use Cycle\ORM\SchemaInterface; @@ -126,6 +125,25 @@ public function run(): void $this->index = []; } + /** + * Normalize data by provided keys. + * Only keys matter for relations loading, so unnecessary data will be filtered out. + * + * @param non-empty-array $keys + * @return non-empty-array + */ + private static function normalizeKeys(array $data, array $keys): array + { + $result = []; + foreach ($keys as $k) { + \array_key_exists($k, $data) or throw new \LogicException( + "Bulk loader cannot get the value for the key `$k`.", + ); + $result[$k] = Node::convertToSolid($data[$k]); + } + return $result; + } + /** * Index entity by provided keys and data. * @@ -150,25 +168,6 @@ private function indexEntity(Node $node, array $keys, array $data, object $entit $pool = [$entity, $node]; } - /** - * Normalize data by provided keys. - * Only keys matter for relations loading, so unnecessary data will be filtered out. - * - * @param non-empty-array $keys - * @return non-empty-array - */ - private static function normalizeKeys(array $data, array $keys): array - { - $result = []; - foreach ($keys as $k) { - \array_key_exists($k, $data) or throw new \LogicException( - "Bulk loader cannot get the value for the key `$k`.", - ); - $result[$k] = Node::convertToSolid($data[$k]); - } - return $result; - } - /** * Fetch indexed entity by provided keys and data. * diff --git a/tests/ORM/Functional/Driver/Common/Integration/Case431/Typecast/StringableUuid.php b/tests/ORM/Functional/Driver/Common/Integration/Case431/Typecast/StringableUuid.php index db34b4414..a688db440 100644 --- a/tests/ORM/Functional/Driver/Common/Integration/Case431/Typecast/StringableUuid.php +++ b/tests/ORM/Functional/Driver/Common/Integration/Case431/Typecast/StringableUuid.php @@ -10,12 +10,12 @@ public function __construct( private readonly string $value, ) {} - public function __toString(): string + public function toString(): string { return $this->value; } - public function toString(): string + public function __toString(): string { return $this->value; } diff --git a/tests/ORM/Functional/Driver/Common/Integration/Case431/Typecast/ValueInterfaceUuid.php b/tests/ORM/Functional/Driver/Common/Integration/Case431/Typecast/ValueInterfaceUuid.php index 0ec98aead..0b2de9b55 100644 --- a/tests/ORM/Functional/Driver/Common/Integration/Case431/Typecast/ValueInterfaceUuid.php +++ b/tests/ORM/Functional/Driver/Common/Integration/Case431/Typecast/ValueInterfaceUuid.php @@ -27,13 +27,13 @@ public function rawType(): int return \PDO::PARAM_STR; } - public function __toString(): string + public function toString(): string { - return 'urn:uuid:' . $this->value; + return $this->value; } - public function toString(): string + public function __toString(): string { - return $this->value; + return 'urn:uuid:' . $this->value; } } From 8f9d9d4705884a2c1886a43e8441827d64ff9329 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 6 Mar 2026 10:46:52 +0400 Subject: [PATCH 3/3] Refactor --- src/Heap/Node.php | 4 ++-- src/Relation/BulkLoader.php | 15 +++++++-------- .../Driver/Common/Schema/Column/UUIDTest.php | 8 ++------ 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/Heap/Node.php b/src/Heap/Node.php index 3e471b2b8..78a5d072a 100644 --- a/src/Heap/Node.php +++ b/src/Heap/Node.php @@ -88,8 +88,8 @@ public static function compare(mixed $a, mixed $b): int } // Object and string/int if ($ta[1] === 'string' || $ta[0] === 'integer') { - $a = $a instanceof \Stringable ? $a->__toString() : (string) $a; - $b = $b instanceof \Stringable ? $b->__toString() : (string) $b; + $a = (string) self::convertToSolid($a); + $b = (string) self::convertToSolid($b); return $a <=> $b; } return -1; diff --git a/src/Relation/BulkLoader.php b/src/Relation/BulkLoader.php index 35244a30d..8705bd37a 100644 --- a/src/Relation/BulkLoader.php +++ b/src/Relation/BulkLoader.php @@ -69,9 +69,11 @@ public function load(string $relation, array $options = []): static { $this->loader->loadRelation($relation, $options, load: true); + // Determine inner keys for the relation $role = $this->loader->getTarget(); $relMap = $this->orm->getRelationMap($role); - $r = $relMap->getRelations()[$relation]; + $parentRel = \explode('.', $relation, 2)[0]; + $r = $relMap->getRelations()[$parentRel]; $this->keys = \array_merge($this->keys, $r->getInnerKeys()); return $this; @@ -93,7 +95,8 @@ public function run(): void $n = $heap->get($entity) ?? throw new \LogicException("Entity node not found in the heap."); // Use Node data to load relations instead of actual entity data // to avoid inconsistent state in the Heap - $data = self::normalizeKeys($mapper->uncast($n->getData()), $keys); + $data = $mapper->uncast($n->getData()); + self::normalizeKeys($data, $keys); $this->indexEntity($n, $pk, $data, $entity); $node->push($data); unset($data); @@ -127,21 +130,17 @@ public function run(): void /** * Normalize data by provided keys. - * Only keys matter for relations loading, so unnecessary data will be filtered out. * * @param non-empty-array $keys - * @return non-empty-array */ - private static function normalizeKeys(array $data, array $keys): array + private static function normalizeKeys(array &$data, array $keys): void { - $result = []; foreach ($keys as $k) { \array_key_exists($k, $data) or throw new \LogicException( "Bulk loader cannot get the value for the key `$k`.", ); - $result[$k] = Node::convertToSolid($data[$k]); + $data[$k] = Node::convertToSolid($data[$k]); } - return $result; } /** diff --git a/tests/ORM/Functional/Driver/Common/Schema/Column/UUIDTest.php b/tests/ORM/Functional/Driver/Common/Schema/Column/UUIDTest.php index 26d783ab1..637148012 100644 --- a/tests/ORM/Functional/Driver/Common/Schema/Column/UUIDTest.php +++ b/tests/ORM/Functional/Driver/Common/Schema/Column/UUIDTest.php @@ -27,15 +27,11 @@ public function testCreate(): void $e->balance = 300; $this->captureWriteQueries(); - $tr = new Transaction($this->orm); - $tr->persist($e); - $tr->run(); + $this->save($e); $this->assertNumWrites(1); $this->captureWriteQueries(); - $tr = new Transaction($this->orm); - $tr->persist($e); - $tr->run(); + $this->save($e); $this->assertNumWrites(0); $this->assertEquals(1, $e->id);