Skip to content

Commit 33bbea6

Browse files
committed
bug #3847 [Turbo] Fix broadcasting an entity whose identifier is made of associations (Kocal)
This PR was merged into the 3.x branch. Discussion ---------- [Turbo] Fix broadcasting an entity whose identifier is made of associations | Q | A | -------------- | --- | Bug fix? | yes | New feature? | no | Deprecations? | no | Documentation? | no | Issues | Fix #1856 | License | MIT Entities whose primary key is made up of associations (the usual join-entity shape) couldn't be broadcast at all, as reported in #1856. `ClassMetadata::getIdentifierValues()` returns the related entities themselves instead of their identifiers, and every consumer then runs `implode('-', $id)` on them, which throws `Object of class App\Entity\ClassA could not be converted to string`. `IdAccessor::getIdentifierValues()` now reads the identifier and replaces any field mapped as an association with the identifier of the entity it points to. It checks the mapping of the field, not the class of the value, so it still works when the association is a lazy proxy, and an identifier held in a value object such as a UUID is left alone since it's a plain column. Both producers now go through this accessor: `IdAccessor::getEntityId()`, and `BroadcastListener`, which used to read the identifier values itself and never reached the accessor at all: that's the exact path in the reported stack trace. The listener still gets its `EntityManager` from the flush event it's already handed, so nothing new is injected into it. This is covered by tests on the create path and the update path; the latter reloads the entity first, which under Doctrine ORM 2 turns the identifier associations into real proxies. Commits ------- 3f2a40e [Turbo] Fix broadcasting an entity whose identifier is made of associations
2 parents b543277 + 3f2a40e commit 33bbea6

10 files changed

Lines changed: 317 additions & 4 deletions

File tree

src/Turbo/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# CHANGELOG
22

3+
## 3.5.0
4+
5+
- Fix broadcasting an entity whose identifier is made of associations
6+
37
## 3.2.0
48

59
- Prevent installation alongside `symfony/mercure` 0.7.0 and 0.7.1, which are incompatible

src/Turbo/src/Broadcaster/IdAccessor.php

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace Symfony\UX\Turbo\Broadcaster;
1313

1414
use Doctrine\Persistence\ManagerRegistry;
15+
use Doctrine\Persistence\ObjectManager;
1516
use Symfony\Component\PropertyAccess\PropertyAccess;
1617
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
1718

@@ -27,14 +28,14 @@ public function __construct(
2728
}
2829

2930
/**
30-
* @return string[]|null
31+
* @return array<array-key, mixed>|null
3132
*/
3233
public function getEntityId(object $entity): ?array
3334
{
3435
$entityClass = $entity::class;
3536

3637
if ($this->doctrine && $em = $this->doctrine->getManagerForClass($entityClass)) {
37-
return $em->getClassMetadata($entityClass)->getIdentifierValues($entity);
38+
return self::getIdentifierValues($em, $entity);
3839
}
3940

4041
if ($this->propertyAccessor) {
@@ -43,4 +44,27 @@ public function getEntityId(object $entity): ?array
4344

4445
return null;
4546
}
47+
48+
/**
49+
* Same as ClassMetadata::getIdentifierValues(), except that an identifier which is itself
50+
* an association is replaced by the identifier of the entity it points to: Doctrine hands
51+
* back that related entity, which callers cannot turn into an identifier string.
52+
*
53+
* @internal
54+
*
55+
* @return array<string, mixed>
56+
*/
57+
public static function getIdentifierValues(ObjectManager $em, object $entity): array
58+
{
59+
$metadata = $em->getClassMetadata($entity::class);
60+
$id = $metadata->getIdentifierValues($entity);
61+
62+
foreach ($id as $field => $value) {
63+
if ($metadata->hasAssociation($field)) {
64+
$id[$field] = implode('-', self::getIdentifierValues($em, $value));
65+
}
66+
}
67+
68+
return $id;
69+
}
4670
}

src/Turbo/src/Doctrine/BroadcastListener.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use Symfony\Contracts\Service\ResetInterface;
2020
use Symfony\UX\Turbo\Attribute\Broadcast;
2121
use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface;
22+
use Symfony\UX\Turbo\Broadcaster\IdAccessor;
2223

2324
/**
2425
* Detects changes made from Doctrine entities and broadcasts updates to the broadcasters.
@@ -92,7 +93,7 @@ public function postFlush(EventArgs $eventArgs): void
9293
try {
9394
foreach ($this->createdEntities as $entity) {
9495
$options = $this->createdEntities[$entity];
95-
$id = $em->getClassMetadata($entity::class)->getIdentifierValues($entity);
96+
$id = IdAccessor::getIdentifierValues($em, $entity);
9697
foreach ($options as $option) {
9798
$option['id'] = $id;
9899
$this->broadcaster->broadcast($entity, Broadcast::ACTION_CREATE, $option);
@@ -148,7 +149,7 @@ private function storeEntitiesToPublish(EntityManagerInterface $em, object $enti
148149

149150
if ($options = $this->broadcastedClasses[$class]) {
150151
if ($this->createdEntities !== $objectStorage) {
151-
$id = $em->getClassMetadata($class)->getIdentifierValues($entity);
152+
$id = IdAccessor::getIdentifierValues($em, $entity);
152153
foreach ($options as $k => $option) {
153154
$options[$k]['id'] = $id;
154155
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\Turbo\Tests\Broadcaster;
13+
14+
use Doctrine\Persistence\ManagerRegistry;
15+
use PHPUnit\Framework\TestCase;
16+
use Symfony\UX\Turbo\Broadcaster\IdAccessor;
17+
use Symfony\UX\Turbo\Tests\Fixtures\Entity\Membership;
18+
use Symfony\UX\Turbo\Tests\Fixtures\Entity\Player;
19+
use Symfony\UX\Turbo\Tests\Fixtures\Entity\Team;
20+
use Symfony\UX\Turbo\Tests\Fixtures\EntityManagerFactory;
21+
22+
class IdAccessorTest extends TestCase
23+
{
24+
public function testGetEntityIdReturnsTheIdentifierValue(): void
25+
{
26+
$this->assertSame(['id' => 42], $this->createIdAccessor()->getEntityId(new Player(42)));
27+
}
28+
29+
public function testGetEntityIdResolvesAnAssociationToItsOwnIdentifier(): void
30+
{
31+
$membership = new Membership(new Player(1), new Team(2));
32+
33+
$this->assertSame(['player' => '1', 'team' => '2'], $this->createIdAccessor()->getEntityId($membership));
34+
}
35+
36+
private function createIdAccessor(): IdAccessor
37+
{
38+
$doctrine = $this->createStub(ManagerRegistry::class);
39+
$doctrine->method('getManagerForClass')->willReturn(EntityManagerFactory::create());
40+
41+
return new IdAccessor(doctrine: $doctrine);
42+
}
43+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\Turbo\Tests\Doctrine;
13+
14+
use Doctrine\ORM\EntityManager;
15+
use Doctrine\ORM\Events;
16+
use PHPUnit\Framework\TestCase;
17+
use Symfony\UX\Turbo\Doctrine\BroadcastListener;
18+
use Symfony\UX\Turbo\Tests\Fixtures\CollectingBroadcaster;
19+
use Symfony\UX\Turbo\Tests\Fixtures\Entity\Membership;
20+
use Symfony\UX\Turbo\Tests\Fixtures\Entity\Player;
21+
use Symfony\UX\Turbo\Tests\Fixtures\Entity\Team;
22+
use Symfony\UX\Turbo\Tests\Fixtures\EntityManagerFactory;
23+
24+
class BroadcastListenerTest extends TestCase
25+
{
26+
public function testBroadcastACreatedEntityWhoseIdentifierIsMadeOfAssociations(): void
27+
{
28+
$entityManager = EntityManagerFactory::create();
29+
$broadcaster = $this->listenTo($entityManager);
30+
31+
$entityManager->persist($player = new Player(1));
32+
$entityManager->persist($team = new Team(2));
33+
$entityManager->persist(new Membership($player, $team));
34+
$entityManager->flush();
35+
36+
$this->assertSame([['create', ['id' => ['player' => '1', 'team' => '2']]]], $broadcaster->broadcasts);
37+
}
38+
39+
public function testBroadcastAnUpdatedEntityWhoseIdentifierIsMadeOfAssociations(): void
40+
{
41+
$entityManager = EntityManagerFactory::create();
42+
43+
$entityManager->persist($player = new Player(1));
44+
$entityManager->persist($team = new Team(2));
45+
$entityManager->persist(new Membership($player, $team));
46+
$entityManager->flush();
47+
$entityManager->clear();
48+
49+
// Reloading makes the identifier associations lazy, a state the create path never reaches.
50+
$membership = $entityManager->find(Membership::class, ['player' => 1, 'team' => 2]);
51+
$this->assertNotNull($membership);
52+
53+
$broadcaster = $this->listenTo($entityManager);
54+
55+
$membership->role = 'captain';
56+
$entityManager->flush();
57+
58+
$this->assertSame([['update', ['id' => ['player' => '1', 'team' => '2']]]], $broadcaster->broadcasts);
59+
}
60+
61+
private function listenTo(EntityManager $entityManager): CollectingBroadcaster
62+
{
63+
$broadcaster = new CollectingBroadcaster();
64+
65+
$entityManager->getEventManager()->addEventListener(
66+
[Events::onFlush, Events::postFlush],
67+
new BroadcastListener($broadcaster),
68+
);
69+
70+
return $broadcaster;
71+
}
72+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\Turbo\Tests\Fixtures;
13+
14+
use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface;
15+
16+
/**
17+
* @internal
18+
*/
19+
final class CollectingBroadcaster implements BroadcasterInterface
20+
{
21+
/** @var list<array{string, array<string, mixed>}> */
22+
public array $broadcasts = [];
23+
24+
public function broadcast(object $entity, string $action, array $options): void
25+
{
26+
$this->broadcasts[] = [$action, $options];
27+
}
28+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\Turbo\Tests\Fixtures\Entity;
13+
14+
use Doctrine\ORM\Mapping as ORM;
15+
use Symfony\UX\Turbo\Attribute\Broadcast;
16+
17+
/**
18+
* An entity whose primary key is composed of two associations, so Doctrine
19+
* stores the related entities themselves in the identifier properties.
20+
*
21+
* @internal
22+
*/
23+
#[ORM\Entity]
24+
#[Broadcast]
25+
class Membership
26+
{
27+
public function __construct(
28+
#[ORM\Id]
29+
#[ORM\ManyToOne(targetEntity: Player::class)]
30+
public Player $player,
31+
#[ORM\Id]
32+
#[ORM\ManyToOne(targetEntity: Team::class)]
33+
public Team $team,
34+
#[ORM\Column]
35+
public string $role = 'member',
36+
) {
37+
}
38+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\Turbo\Tests\Fixtures\Entity;
13+
14+
use Doctrine\ORM\Mapping as ORM;
15+
16+
/**
17+
* @internal
18+
*/
19+
#[ORM\Entity]
20+
class Player
21+
{
22+
public function __construct(
23+
#[ORM\Id]
24+
#[ORM\Column]
25+
public int $id,
26+
) {
27+
}
28+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\Turbo\Tests\Fixtures\Entity;
13+
14+
use Doctrine\ORM\Mapping as ORM;
15+
16+
/**
17+
* @internal
18+
*/
19+
#[ORM\Entity]
20+
class Team
21+
{
22+
public function __construct(
23+
#[ORM\Id]
24+
#[ORM\Column]
25+
public int $id,
26+
) {
27+
}
28+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\Turbo\Tests\Fixtures;
13+
14+
use Doctrine\DBAL\DriverManager;
15+
use Doctrine\ORM\EntityManager;
16+
use Doctrine\ORM\ORMSetup;
17+
use Doctrine\ORM\Tools\SchemaTool;
18+
19+
/**
20+
* Creates an in-memory sqlite EntityManager holding every entity under Fixtures/Entity.
21+
*
22+
* @internal
23+
*/
24+
final class EntityManagerFactory
25+
{
26+
public static function create(): EntityManager
27+
{
28+
$config = ORMSetup::createAttributeMetadataConfiguration(
29+
paths: [__DIR__.'/Entity'],
30+
isDevMode: true,
31+
);
32+
33+
// @phpstan-ignore function.alreadyNarrowedType (ORM 2 has no native lazy objects, and needs none)
34+
if (method_exists($config, 'enableNativeLazyObjects')) {
35+
$config->enableNativeLazyObjects(true);
36+
}
37+
38+
$entityManager = new EntityManager(
39+
DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true], $config),
40+
$config,
41+
);
42+
43+
new SchemaTool($entityManager)->createSchema($entityManager->getMetadataFactory()->getAllMetadata());
44+
45+
return $entityManager;
46+
}
47+
}

0 commit comments

Comments
 (0)