Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/Relation/BulkLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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;
Expand Down
42 changes: 42 additions & 0 deletions tests/ORM/Fixtures/OneWayUuidTypecast.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace Cycle\ORM\Tests\Fixtures;

use Cycle\ORM\Parser\CastableInterface;

/**
* UUID typecast that only casts (string -> 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;
}
}
74 changes: 73 additions & 1 deletion tests/ORM/Unit/Relation/BulkLoaderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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([
Expand Down
Loading