diff --git a/src/Relation/BulkLoader.php b/src/Relation/BulkLoader.php index fc2e8efa0..fd5924565 100644 --- a/src/Relation/BulkLoader.php +++ b/src/Relation/BulkLoader.php @@ -126,7 +126,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 throw new \InvalidArgumentException("Invalid value on 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) . ".", + ); \array_key_exists($keyValue, $pool) or $pool[$keyValue] = []; $pool = &$pool[$keyValue]; @@ -148,7 +152,7 @@ private function getEntity(array $pk, array $data): array { $result = $this->index; foreach ($pk as $k) { - $result = $result[$data[$k]] ?? throw new \LogicException('Cannot find indexed entity.'); + $result = $result[(string) $data[$k]] ?? throw new \LogicException('Cannot find indexed entity.'); } return $result; diff --git a/tests/ORM/Fixtures/OneWayUuidTypecast.php b/tests/ORM/Fixtures/OneWayUuidTypecast.php new file mode 100644 index 000000000..a6489d995 --- /dev/null +++ b/tests/ORM/Fixtures/OneWayUuidTypecast.php @@ -0,0 +1,42 @@ + object) but does NOT uncast (object -> string). + * Simulates a real-world scenario where a custom typecast handler only implements CastableInterface. + */ +class OneWayUuidTypecast implements CastableInterface +{ + /** @var non-empty-string[] */ + private array $rules = []; + + public function setRules(array $rules): array + { + foreach ($rules as $key => $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 UuidPrimaryKey((string) $data[$column]); + } + + return $data; + } +} diff --git a/tests/ORM/Unit/Relation/BulkLoaderTest.php b/tests/ORM/Unit/Relation/BulkLoaderTest.php index ece5312a2..22e08b5e5 100644 --- a/tests/ORM/Unit/Relation/BulkLoaderTest.php +++ b/tests/ORM/Unit/Relation/BulkLoaderTest.php @@ -5,13 +5,17 @@ namespace Cycle\ORM\Tests\Unit\Relation; use Cycle\ORM\Factory; +use Cycle\ORM\Heap\Node; use Cycle\ORM\Mapper\Mapper; use Cycle\ORM\ORM; +use Cycle\ORM\Relation; use Cycle\ORM\Relation\BulkLoader; use Cycle\ORM\Relation\RelationLoaderInterface; use Cycle\ORM\Schema; -use Cycle\ORM\Tests\Fixtures\User; +use Cycle\ORM\Tests\Fixtures\OneWayUuidTypecast; use Cycle\ORM\Tests\Fixtures\Profile; +use Cycle\ORM\Tests\Fixtures\User; +use Cycle\ORM\Tests\Fixtures\UuidPrimaryKey; use PHPUnit\Framework\TestCase; class BulkLoaderTest extends TestCase @@ -202,6 +206,74 @@ public function testLoadWithCustomOptions(): void $this->assertSame($loader, $result); } + /** + * BulkLoader should handle Stringable PK values when typecast is one-directional. + * + * Scenario: OneWayUuidTypecast implements only CastableInterface (cast: string→UuidPrimaryKey), + * but NOT UncastableInterface. So mapper->uncast() returns data with UuidPrimaryKey objects. + * BulkLoader::indexEntity() must handle Stringable objects instead of rejecting them via is_scalar(). + */ + public function testRunWithStringablePkAndOneWayTypecast(): void + { + $this->expectNotToPerformAssertions(); + + $schema = new Schema([ + User::class => [ + Schema::ROLE => 'user', + Schema::MAPPER => Mapper::class, + Schema::DATABASE => 'default', + Schema::TABLE => 'user', + Schema::PRIMARY_KEY => 'id', + Schema::COLUMNS => ['id', 'email', 'balance'], + Schema::TYPECAST => ['id' => 'uuid'], + Schema::TYPECAST_HANDLER => OneWayUuidTypecast::class, + Schema::SCHEMA => [], + Schema::RELATIONS => [ + 'profile' => [ + Relation::TYPE => Relation::HAS_ONE, + Relation::TARGET => Profile::class, + Relation::SCHEMA => [ + Relation::CASCADE => true, + Relation::INNER_KEY => 'id', + Relation::OUTER_KEY => 'user_id', + ], + ], + ], + ], + Profile::class => [ + Schema::ROLE => 'profile', + Schema::MAPPER => Mapper::class, + Schema::DATABASE => 'default', + Schema::TABLE => 'profile', + Schema::PRIMARY_KEY => 'id', + Schema::COLUMNS => ['id', 'user_id', 'image'], + Schema::SCHEMA => [], + Schema::RELATIONS => [], + ], + ]); + + $orm = new ORM( + new Factory($this->createMock(\Cycle\Database\DatabaseProviderInterface::class)), + $schema, + ); + + $entity = new User(); + + // Simulate entity loaded from DB: typecast converted string PK to UuidPrimaryKey (Stringable), + // but uncast won't convert it back because OneWayUuidTypecast has no UncastableInterface. + $uuid = new UuidPrimaryKey('550e8400-e29b-41d4-a716-446655440001'); + $node = new Node(Node::MANAGED, [ + 'id' => $uuid, + 'email' => 'test@test.com', + 'balance' => 100, + ], 'user'); + $orm->getHeap()->attach($entity, $node); + + $loader = (new BulkLoader($orm))->collect($entity); + $loader->load('profile'); + $loader->run(); + } + private function createORM(): ORM { $schema = new Schema([