From db4e5b4c33f4f6c31d89ff18127eaca94c1fa067 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 2 Mar 2026 19:30:49 +0400 Subject: [PATCH] Fix indexing object keys in BulkLoader --- src/Relation/BulkLoader.php | 2 +- .../Common/Integration/Case430/CaseTest.php | 246 ++++++++++++++++++ .../Integration/Case430/Entity/Comment.php | 22 ++ .../Integration/Case430/Entity/Post.php | 25 ++ .../Integration/Case430/Entity/User.php | 24 ++ .../Case430/Typecast/UuidTypecast.php | 55 ++++ .../Common/Integration/Case430/schema.php | 184 +++++++++++++ .../MySQL/Integration/Case430/CaseTest.php | 17 ++ .../Postgres/Integration/Case430/CaseTest.php | 17 ++ .../Integration/Case430/CaseTest.php | 17 ++ .../SQLite/Integration/Case430/CaseTest.php | 17 ++ 11 files changed, 625 insertions(+), 1 deletion(-) create mode 100644 tests/ORM/Functional/Driver/Common/Integration/Case430/CaseTest.php create mode 100644 tests/ORM/Functional/Driver/Common/Integration/Case430/Entity/Comment.php create mode 100644 tests/ORM/Functional/Driver/Common/Integration/Case430/Entity/Post.php create mode 100644 tests/ORM/Functional/Driver/Common/Integration/Case430/Entity/User.php create mode 100644 tests/ORM/Functional/Driver/Common/Integration/Case430/Typecast/UuidTypecast.php create mode 100644 tests/ORM/Functional/Driver/Common/Integration/Case430/schema.php create mode 100644 tests/ORM/Functional/Driver/MySQL/Integration/Case430/CaseTest.php create mode 100644 tests/ORM/Functional/Driver/Postgres/Integration/Case430/CaseTest.php create mode 100644 tests/ORM/Functional/Driver/SQLServer/Integration/Case430/CaseTest.php create mode 100644 tests/ORM/Functional/Driver/SQLite/Integration/Case430/CaseTest.php diff --git a/src/Relation/BulkLoader.php b/src/Relation/BulkLoader.php index abf60eec3..fc2e8efa0 100644 --- a/src/Relation/BulkLoader.php +++ b/src/Relation/BulkLoader.php @@ -83,7 +83,7 @@ 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 = $n->getData(); + $data = $mapper->uncast($n->getData()); $this->indexEntity($n, $pk, $data, $entity); $node->push($data); unset($data); diff --git a/tests/ORM/Functional/Driver/Common/Integration/Case430/CaseTest.php b/tests/ORM/Functional/Driver/Common/Integration/Case430/CaseTest.php new file mode 100644 index 000000000..920e68b74 --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Case430/CaseTest.php @@ -0,0 +1,246 @@ +orm, Entity\User::class)) + ->orderBy('login') + ->fetchAll(); + + $this->assertCount(3, $users); + + // Bulk load posts for all users — single query + $this->captureReadQueries(); + (new BulkLoader($this->orm)) + ->collect(...$users) + ->load('posts') + ->run(); + $this->assertNumReads(1); + + // Verify posts are loaded without additional queries + $this->captureReadQueries(); + $postCounts = []; + foreach ($users as $user) { + $postCounts[$user->uuid->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 testBulkLoadBelongsTo(): void + { + $posts = (new Select($this->orm, Entity\Post::class)) + ->orderBy('title') + ->fetchAll(); + + $this->assertCount(4, $posts); + + // Bulk load user for all posts — single query + $this->captureReadQueries(); + (new BulkLoader($this->orm)) + ->collect(...$posts) + ->load('user') + ->run(); + $this->assertNumReads(1); + + // Verify users are loaded without additional queries + $this->captureReadQueries(); + foreach ($posts as $post) { + $this->assertInstanceOf(Entity\User::class, $post->user); + $this->assertInstanceOf(UuidInterface::class, $post->user->uuid); + } + $this->assertNumReads(0); + + // Verify correct user assignment + $postsByUuid = []; + foreach ($posts as $post) { + $postsByUuid[$post->uuid->toString()] = $post; + } + $this->assertSame(self::USER_1, $postsByUuid[self::POST_1]->user->uuid->toString()); + $this->assertSame(self::USER_2, $postsByUuid[self::POST_2]->user->uuid->toString()); + $this->assertSame(self::USER_2, $postsByUuid[self::POST_3]->user->uuid->toString()); + $this->assertSame(self::USER_3, $postsByUuid[self::POST_4]->user->uuid->toString()); + } + + public function testBulkLoadMultipleRelations(): void + { + $posts = (new Select($this->orm, Entity\Post::class)) + ->fetchAll(); + + $this->assertCount(4, $posts); + + // Load both user and comments in one BulkLoader call + $this->captureReadQueries(); + (new BulkLoader($this->orm)) + ->collect(...$posts) + ->load('user') + ->load('comments') + ->run(); + $this->assertNumReads(3); // 1 for users + 1 for comments + 1 for eager-loaded comment.user + + // Verify all relations loaded without queries + $this->captureReadQueries(); + foreach ($posts as $post) { + $this->assertInstanceOf(Entity\User::class, $post->user); + $this->assertIsArray($post->comments); + } + $this->assertNumReads(0); + } + + public function testBulkLoadForSingleEntity(): 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); + } + + public function testBulkLoadBelongsToOnComments(): void + { + // Comment has eager-loaded user and promise-loaded post + $comments = (new Select($this->orm, Entity\Comment::class)) + ->fetchAll(); + + $this->assertCount(3, $comments); + + // Bulk load the lazy post relation + $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(UuidInterface::class, $comment->post->uuid); + } + $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, [ + 'uuid' => 'string,primary', + 'login' => 'string', + 'password_hash' => 'string', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]); + + $this->makeTable('post', [ + 'uuid' => 'string,primary', + 'user_uuid' => '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_uuid', 'user', 'uuid', 'NO ACTION', 'NO ACTION'); + + $this->makeTable('comment', [ + 'uuid' => 'string,primary', + 'public' => 'bool', + 'content' => 'string', + 'user_uuid' => 'string', + 'post_uuid' => 'string', + 'published_at' => 'datetime,nullable', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'deleted_at' => 'datetime,nullable', + ]); + $this->makeFK('comment', 'user_uuid', 'user', 'uuid', 'NO ACTION', 'NO ACTION'); + $this->makeFK('comment', 'post_uuid', 'post', 'uuid', 'NO ACTION', 'NO ACTION'); + } + + private function fillData(): void + { + $now = new \DateTimeImmutable(); + + $this->getDatabase()->table('user')->insertMultiple( + ['uuid', '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( + ['uuid', 'user_uuid', '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( + ['uuid', 'user_uuid', 'post_uuid', '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/Case430/Entity/Comment.php b/tests/ORM/Functional/Driver/Common/Integration/Case430/Entity/Comment.php new file mode 100644 index 000000000..8bae0309e --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Case430/Entity/Comment.php @@ -0,0 +1,22 @@ + */ + public iterable $comments = []; +} diff --git a/tests/ORM/Functional/Driver/Common/Integration/Case430/Entity/User.php b/tests/ORM/Functional/Driver/Common/Integration/Case430/Entity/User.php new file mode 100644 index 000000000..8a064727d --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Case430/Entity/User.php @@ -0,0 +1,24 @@ + */ + public iterable $posts = []; + + /** @var iterable */ + public iterable $comments = []; +} diff --git a/tests/ORM/Functional/Driver/Common/Integration/Case430/Typecast/UuidTypecast.php b/tests/ORM/Functional/Driver/Common/Integration/Case430/Typecast/UuidTypecast.php new file mode 100644 index 000000000..bc337e472 --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Case430/Typecast/UuidTypecast.php @@ -0,0 +1,55 @@ + $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/Case430/schema.php b/tests/ORM/Functional/Driver/Common/Integration/Case430/schema.php new file mode 100644 index 000000000..d10c298cc --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Integration/Case430/schema.php @@ -0,0 +1,184 @@ + [ + Schema::ENTITY => User::class, + Schema::MAPPER => Mapper::class, + Schema::SOURCE => Source::class, + Schema::DATABASE => 'default', + Schema::TABLE => 'user', + Schema::PRIMARY_KEY => ['uuid'], + Schema::FIND_BY_KEYS => ['uuid'], + Schema::COLUMNS => [ + 'uuid' => 'uuid', + '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 => ['uuid'], + Relation::OUTER_KEY => 'user_uuid', + ], + ], + '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 => ['uuid'], + Relation::OUTER_KEY => 'user_uuid', + ], + ], + ], + Schema::SCOPE => null, + Schema::TYPECAST => [ + 'uuid' => 'uuid', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ], + Schema::SCHEMA => [], + Schema::TYPECAST_HANDLER => [UuidTypecast::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 => ['uuid'], + Schema::FIND_BY_KEYS => ['uuid'], + Schema::COLUMNS => [ + 'uuid' => 'uuid', + '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_uuid' => 'user_uuid', + ], + 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_uuid', + Relation::OUTER_KEY => ['uuid'], + ], + ], + '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 => ['uuid'], + Relation::OUTER_KEY => 'post_uuid', + ], + ], + ], + Schema::TYPECAST => [ + 'uuid' => 'uuid', + 'public' => 'bool', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'published_at' => 'datetime', + 'deleted_at' => 'datetime', + 'user_uuid' => 'uuid', + ], + Schema::SCHEMA => [], + Schema::TYPECAST_HANDLER => [UuidTypecast::class, Typecast::class], + ], + 'comment' => [ + Schema::ENTITY => Comment::class, + Schema::SOURCE => Source::class, + Schema::DATABASE => 'default', + Schema::TABLE => 'comment', + Schema::PRIMARY_KEY => ['uuid'], + Schema::FIND_BY_KEYS => ['uuid'], + Schema::COLUMNS => [ + 'uuid' => 'uuid', + 'public' => 'public', + 'content' => 'content', + 'created_at' => 'created_at', + 'updated_at' => 'updated_at', + 'published_at' => 'published_at', + 'deleted_at' => 'deleted_at', + 'user_uuid' => 'user_uuid', + 'post_uuid' => 'post_uuid', + ], + 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_uuid', + Relation::OUTER_KEY => ['uuid'], + ], + ], + '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_uuid', + Relation::OUTER_KEY => ['uuid'], + ], + ], + ], + Schema::TYPECAST => [ + 'uuid' => 'uuid', + 'public' => 'bool', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'published_at' => 'datetime', + 'deleted_at' => 'datetime', + 'user_uuid' => 'uuid', + 'post_uuid' => 'uuid', + ], + Schema::SCHEMA => [], + Schema::TYPECAST_HANDLER => [UuidTypecast::class, Typecast::class], + ], +]; diff --git a/tests/ORM/Functional/Driver/MySQL/Integration/Case430/CaseTest.php b/tests/ORM/Functional/Driver/MySQL/Integration/Case430/CaseTest.php new file mode 100644 index 000000000..2e08a0253 --- /dev/null +++ b/tests/ORM/Functional/Driver/MySQL/Integration/Case430/CaseTest.php @@ -0,0 +1,17 @@ +