From ccde6934874162ed0f6885acd916b157788c31e1 Mon Sep 17 00:00:00 2001 From: chapterjason Date: Sun, 19 May 2024 12:16:14 +0200 Subject: [PATCH 01/10] Add test --- src/Turbo/CONTRIBUTING.md | 3 +- src/Turbo/tests/BroadcastTest.php | 15 +++++ src/Turbo/tests/app/Entity/Cart.php | 27 +++++++++ src/Turbo/tests/app/Entity/CartProduct.php | 37 +++++++++++++ src/Turbo/tests/app/Entity/Product.php | 30 ++++++++++ src/Turbo/tests/app/Kernel.php | 55 +++++++++++++++++++ src/Turbo/tests/app/templates/base.html.twig | 1 + .../broadcast/CartProduct.stream.html.twig | 20 +++++++ .../app/templates/cart_products.html.twig | 17 ++++++ 9 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 src/Turbo/tests/app/Entity/Cart.php create mode 100644 src/Turbo/tests/app/Entity/CartProduct.php create mode 100644 src/Turbo/tests/app/Entity/Product.php create mode 100644 src/Turbo/tests/app/templates/broadcast/CartProduct.stream.html.twig create mode 100644 src/Turbo/tests/app/templates/cart_products.html.twig diff --git a/src/Turbo/CONTRIBUTING.md b/src/Turbo/CONTRIBUTING.md index ba533f39cb2..6080d68fa7d 100644 --- a/src/Turbo/CONTRIBUTING.md +++ b/src/Turbo/CONTRIBUTING.md @@ -7,7 +7,7 @@ Start a Mercure Hub: -e MERCURE_PUBLISHER_JWT_KEY='!ChangeMe!' \ -e MERCURE_SUBSCRIBER_JWT_KEY='!ChangeMe!' \ -p 3000:3000 \ - dunglas/mercure caddy run -config /etc/caddy/Caddyfile.dev + dunglas/mercure caddy run --config /etc/caddy/Caddyfile.dev Install the test app: @@ -29,6 +29,7 @@ Start the test app: - `http://localhost:8000/authors`: broadcast - `http://localhost:8000/artists`: broadcast - `http://localhost:8000/songs`: broadcast +- `http://localhost:8000/cart_products`: broadcast ## Run tests diff --git a/src/Turbo/tests/BroadcastTest.php b/src/Turbo/tests/BroadcastTest.php index ea57faf7628..fa2dda59239 100644 --- a/src/Turbo/tests/BroadcastTest.php +++ b/src/Turbo/tests/BroadcastTest.php @@ -104,4 +104,19 @@ public function testBroadcastWithProxy(): void // this part is from the stream template $this->assertSelectorWillContain('#artists', ', updated)'); } + + public function testBroadcastWithCompositePrimaryKey(): void + { + ($client = self::createPantherClient())->request('GET', '/cartProducts'); + + // submit first to create the entities + $client->submitForm('Submit', ['title' => 'product 1']); + $this->assertSelectorWillContain('#cartProducts', 'product 1'); + + // submit again to update the quantity + $client->submitForm('Submit'); + $this->assertSelectorWillContain('#cartProducts', '2x product 1'); + // this part is from the stream template + $this->assertSelectorWillContain('#cartProducts', ', updated)'); + } } diff --git a/src/Turbo/tests/app/Entity/Cart.php b/src/Turbo/tests/app/Entity/Cart.php new file mode 100644 index 00000000000..f3741082d4d --- /dev/null +++ b/src/Turbo/tests/app/Entity/Cart.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Entity; + +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; + +/** + * @author Jason Schilling + */ +#[ORM\Entity] +class Cart +{ + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + #[ORM\Column(type: Types::INTEGER)] + public ?int $id = null; +} diff --git a/src/Turbo/tests/app/Entity/CartProduct.php b/src/Turbo/tests/app/Entity/CartProduct.php new file mode 100644 index 00000000000..dbd8b283630 --- /dev/null +++ b/src/Turbo/tests/app/Entity/CartProduct.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Entity; + +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; +use Symfony\UX\Turbo\Attribute\Broadcast; + +/** + * @author Jason Schilling + */ +#[Broadcast] +#[ORM\Entity] +class CartProduct +{ + #[ORM\Id] + #[ORM\ManyToOne(targetEntity: Cart::class)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + public ?Cart $cart = null; + + #[ORM\Id] + #[ORM\ManyToOne(targetEntity: Product::class)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + public ?Product $product = null; + + #[ORM\Column(type: Types::INTEGER)] + public int $quantity = 1; +} diff --git a/src/Turbo/tests/app/Entity/Product.php b/src/Turbo/tests/app/Entity/Product.php new file mode 100644 index 00000000000..599a2cfa903 --- /dev/null +++ b/src/Turbo/tests/app/Entity/Product.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Entity; + +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; + +/** + * @author Jason Schilling + */ +#[ORM\Entity] +class Product +{ + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + #[ORM\Column(type: Types::INTEGER)] + public ?int $id = null; + + #[ORM\Column] + public string $title = ''; +} diff --git a/src/Turbo/tests/app/Kernel.php b/src/Turbo/tests/app/Kernel.php index 078ba8da579..156e600e511 100644 --- a/src/Turbo/tests/app/Kernel.php +++ b/src/Turbo/tests/app/Kernel.php @@ -13,6 +13,9 @@ use App\Entity\Artist; use App\Entity\Book; +use App\Entity\Cart; +use App\Entity\CartProduct; +use App\Entity\Product; use App\Entity\Song; use Composer\InstalledVersions; use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; @@ -137,6 +140,7 @@ protected function configureRoutes(RoutingConfigurator $routes): void $routes->add('artists', '/artists')->controller('kernel::artists'); $routes->add('artist', '/artists/{id}')->controller('kernel::artist'); $routes->add('artist_from_song', '/artistFromSong')->controller('kernel::artistFromSong'); + $routes->add('cart_products', '/cartProducts')->controller('kernel::cartProducts'); } public function getProjectDir(): string @@ -305,4 +309,55 @@ public function artistFromSong(Request $request, EntityManagerInterface $doctrin 'song' => $song, ])); } + + public function cartProducts(Request $request, EntityManagerInterface $doctrine, Environment $twig): Response + { + $cartProduct = null; + if ($request->isMethod('POST')) { + $cartId = $request->get('cartId'); + $productId = $request->get('productId'); + + if (!$cartId || !$productId) { + $cart = new Cart(); + + $product = new Product(); + + if ($title = $request->get('title')) { + $product->title = $title; + } + + $cartProduct = new CartProduct(); + $cartProduct->cart = $cart; + $cartProduct->product = $product; + $cartProduct->quantity = 1; + + $doctrine->persist($cart); + $doctrine->persist($product); + $doctrine->persist($cartProduct); + $doctrine->flush(); + } else { + $cartProduct = $doctrine->find(CartProduct::class, [$cartId, $productId]); + + if (!$cartProduct) { + throw new NotFoundHttpException(); + } + + ++$cartProduct->quantity; + + if ($remove = $request->get('remove')) { + $doctrine->remove($cartProduct); + $doctrine->remove($cartProduct->product); // for cleanup + $doctrine->remove($cartProduct->cart); // for cleanup + } else { + $doctrine->persist($cartProduct); + } + + $doctrine->flush(); + } + } + + return new Response($twig->render('cart_products.html.twig', [ + 'cartProduct' => $cartProduct, + ])); + } } diff --git a/src/Turbo/tests/app/templates/base.html.twig b/src/Turbo/tests/app/templates/base.html.twig index 0a3c7a37350..96b3a07f3de 100644 --- a/src/Turbo/tests/app/templates/base.html.twig +++ b/src/Turbo/tests/app/templates/base.html.twig @@ -14,6 +14,7 @@
  • Books (broadcast)
  • Songs (broadcast)
  • Artists (broadcast)
  • +
  • CartProducts (broadcast)
  • diff --git a/src/Turbo/tests/app/templates/broadcast/CartProduct.stream.html.twig b/src/Turbo/tests/app/templates/broadcast/CartProduct.stream.html.twig new file mode 100644 index 00000000000..9f285aa3fe0 --- /dev/null +++ b/src/Turbo/tests/app/templates/broadcast/CartProduct.stream.html.twig @@ -0,0 +1,20 @@ + +{% block create %} + + + +{% endblock %} + +{% block update %} + + + +{% endblock %} + +{% block remove %} + +{% endblock %} diff --git a/src/Turbo/tests/app/templates/cart_products.html.twig b/src/Turbo/tests/app/templates/cart_products.html.twig new file mode 100644 index 00000000000..9b7fffeb2d9 --- /dev/null +++ b/src/Turbo/tests/app/templates/cart_products.html.twig @@ -0,0 +1,17 @@ +{% extends 'base.html.twig' %} + +{% block body %} +

    Create Cart, Product and CartProduct, increase quantity on update

    + +
    + + +
    + + + + + +
    +
    +{% endblock %} From 67d29a5115193e89d0b933d65ef77b5c1eded614 Mon Sep 17 00:00:00 2001 From: chapterjason Date: Sun, 19 May 2024 13:01:35 +0200 Subject: [PATCH 02/10] Fix id formatting --- src/Turbo/src/Bridge/Mercure/Broadcaster.php | 4 ++- src/Turbo/src/Broadcaster/IdAccessor.php | 1 + src/Turbo/src/Broadcaster/TwigBroadcaster.php | 3 +- src/Turbo/src/Doctrine/BroadcastListener.php | 29 +++++++++++++++++-- src/Turbo/tests/BroadcastTest.php | 6 ++-- src/Turbo/tests/app/Kernel.php | 2 +- .../app/templates/cart_products.html.twig | 2 +- 7 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/Turbo/src/Bridge/Mercure/Broadcaster.php b/src/Turbo/src/Bridge/Mercure/Broadcaster.php index e727a1276db..a57f22d6af9 100644 --- a/src/Turbo/src/Bridge/Mercure/Broadcaster.php +++ b/src/Turbo/src/Bridge/Mercure/Broadcaster.php @@ -99,7 +99,9 @@ public function broadcast(object $entity, string $action, array $options): void throw new \InvalidArgumentException(sprintf('Cannot broadcast entity of class "%s": the option "topics" is empty and "id" is missing.', $entityClass)); } - $options['topics'] = (array) sprintf(self::TOPIC_PATTERN, rawurlencode($entityClass), rawurlencode(implode('-', (array) $options['id']))); + $id = $options['id_formatted'] ?? implode('-', (array) $options['id']); + + $options['topics'] = (array) sprintf(self::TOPIC_PATTERN, rawurlencode($entityClass), rawurlencode($id)); } $update = new Update( diff --git a/src/Turbo/src/Broadcaster/IdAccessor.php b/src/Turbo/src/Broadcaster/IdAccessor.php index 64606947fdf..d693a9339cb 100644 --- a/src/Turbo/src/Broadcaster/IdAccessor.php +++ b/src/Turbo/src/Broadcaster/IdAccessor.php @@ -34,6 +34,7 @@ public function getEntityId(object $entity): ?array $entityClass = $entity::class; if ($this->doctrine && $em = $this->doctrine->getManagerForClass($entityClass)) { + // @todo: Not sure how to use the same method like in the BroadcastListener without duplicating the code. return $em->getClassMetadata($entityClass)->getIdentifierValues($entity); } diff --git a/src/Turbo/src/Broadcaster/TwigBroadcaster.php b/src/Turbo/src/Broadcaster/TwigBroadcaster.php index 05a428cd53f..aa5af6f6f34 100644 --- a/src/Turbo/src/Broadcaster/TwigBroadcaster.php +++ b/src/Turbo/src/Broadcaster/TwigBroadcaster.php @@ -41,6 +41,7 @@ public function broadcast(object $entity, string $action, array $options): void { if (!isset($options['id']) && null !== $id = $this->idAccessor->getEntityId($entity)) { $options['id'] = $id; + $options['id_formatted'] = $id; } $class = ClassUtil::getEntityClass($entity); @@ -63,7 +64,7 @@ public function broadcast(object $entity, string $action, array $options): void ->renderBlock($action, [ 'entity' => $entity, 'action' => $action, - 'id' => implode('-', (array) ($options['id'] ?? [])), + 'id' => $options['id_formatted'], ] + $options); $this->broadcaster->broadcast($entity, $action, $options); diff --git a/src/Turbo/src/Doctrine/BroadcastListener.php b/src/Turbo/src/Doctrine/BroadcastListener.php index c2c07eb0c9d..c1796f401c0 100644 --- a/src/Turbo/src/Doctrine/BroadcastListener.php +++ b/src/Turbo/src/Doctrine/BroadcastListener.php @@ -94,9 +94,10 @@ public function postFlush(EventArgs $eventArgs): void try { foreach ($this->createdEntities as $entity) { $options = $this->createdEntities[$entity]; - $id = $em->getClassMetadata($entity::class)->getIdentifierValues($entity); + $id = $this->getIdentifierValues($em, $entity); foreach ($options as $option) { $option['id'] = $id; + $options['id_formatted'] = $this->formatId($id); $this->broadcaster->broadcast($entity, Broadcast::ACTION_CREATE, $option); } } @@ -147,13 +148,37 @@ private function storeEntitiesToPublish(EntityManagerInterface $em, object $enti if ($options = $this->broadcastedClasses[$class]) { if ('createdEntities' !== $property) { - $id = $em->getClassMetadata($class)->getIdentifierValues($entity); + $id = $this->getIdentifierValues($em, $entity); foreach ($options as $k => $option) { $options[$k]['id'] = $id; + $options[$k]['id_formatted'] = $this->formatId($id); } } $this->{$property}->attach($entity, $options); } } + + private function getIdentifierValues(EntityManagerInterface $em, object $entity): array + { + $class = ClassUtil::getEntityClass($entity); + + $values = $em->getClassMetadata($class)->getIdentifierValues($entity); + + foreach ($values as $key => $value) { + if (\is_object($value)) { + $values[$key] = $this->getIdentifierValues($em, $value); + } + } + + return $values; + } + + private function formatId(array $id): string + { + $flatten = []; + array_walk_recursive($id, static function ($item) use (&$flatten) { $flatten[] = $item; }); + + return implode('-', $flatten); + } } diff --git a/src/Turbo/tests/BroadcastTest.php b/src/Turbo/tests/BroadcastTest.php index fa2dda59239..035442a6077 100644 --- a/src/Turbo/tests/BroadcastTest.php +++ b/src/Turbo/tests/BroadcastTest.php @@ -111,12 +111,12 @@ public function testBroadcastWithCompositePrimaryKey(): void // submit first to create the entities $client->submitForm('Submit', ['title' => 'product 1']); - $this->assertSelectorWillContain('#cartProducts', 'product 1'); + $this->assertSelectorWillContain('#cart_products', 'product 1'); // submit again to update the quantity $client->submitForm('Submit'); - $this->assertSelectorWillContain('#cartProducts', '2x product 1'); + $this->assertSelectorWillContain('#cart_products', '2x product 1'); // this part is from the stream template - $this->assertSelectorWillContain('#cartProducts', ', updated)'); + $this->assertSelectorWillContain('#cart_products', ', updated)'); } } diff --git a/src/Turbo/tests/app/Kernel.php b/src/Turbo/tests/app/Kernel.php index 156e600e511..d13242f520e 100644 --- a/src/Turbo/tests/app/Kernel.php +++ b/src/Turbo/tests/app/Kernel.php @@ -336,7 +336,7 @@ public function cartProducts(Request $request, EntityManagerInterface $doctrine, $doctrine->persist($cartProduct); $doctrine->flush(); } else { - $cartProduct = $doctrine->find(CartProduct::class, [$cartId, $productId]); + $cartProduct = $doctrine->find(CartProduct::class, ['cart' => $cartId, 'product' => $productId]); if (!$cartProduct) { throw new NotFoundHttpException(); diff --git a/src/Turbo/tests/app/templates/cart_products.html.twig b/src/Turbo/tests/app/templates/cart_products.html.twig index 9b7fffeb2d9..be22c298d5d 100644 --- a/src/Turbo/tests/app/templates/cart_products.html.twig +++ b/src/Turbo/tests/app/templates/cart_products.html.twig @@ -3,7 +3,7 @@ {% block body %}

    Create Cart, Product and CartProduct, increase quantity on update

    -
    +
    From 1a989730c3dfefb2ea2161a4e81f147b28676d31 Mon Sep 17 00:00:00 2001 From: chapterjason Date: Sun, 19 May 2024 13:34:58 +0200 Subject: [PATCH 03/10] Fix composite keys for broadcast --- src/Turbo/config/services.php | 11 +++- src/Turbo/src/Bridge/Mercure/Broadcaster.php | 8 ++- .../src/Broadcaster/DoctrineIdAccessor.php | 55 +++++++++++++++++++ src/Turbo/src/Broadcaster/IdAccessor.php | 12 ++-- src/Turbo/src/Broadcaster/IdFormatter.php | 34 ++++++++++++ src/Turbo/src/Broadcaster/TwigBroadcaster.php | 7 ++- src/Turbo/src/Doctrine/BroadcastListener.php | 35 +++--------- src/Turbo/tests/app/Kernel.php | 1 - 8 files changed, 121 insertions(+), 42 deletions(-) create mode 100644 src/Turbo/src/Broadcaster/DoctrineIdAccessor.php create mode 100644 src/Turbo/src/Broadcaster/IdFormatter.php diff --git a/src/Turbo/config/services.php b/src/Turbo/config/services.php index 0cd1e6a1d5f..b9c947804e9 100644 --- a/src/Turbo/config/services.php +++ b/src/Turbo/config/services.php @@ -12,6 +12,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface; +use Symfony\UX\Turbo\Broadcaster\DoctrineIdAccessor; use Symfony\UX\Turbo\Broadcaster\IdAccessor; use Symfony\UX\Turbo\Broadcaster\ImuxBroadcaster; use Symfony\UX\Turbo\Broadcaster\TwigBroadcaster; @@ -29,10 +30,17 @@ ->alias(BroadcasterInterface::class, 'turbo.broadcaster.imux') + ->set('turbo.id_formatter', IdAccessor::class) + + ->set('turbo.doctrine_id_accessor', DoctrineIdAccessor::class) + ->args([ + service('doctrine')->nullOnInvalid(), + ]) + ->set('turbo.id_accessor', IdAccessor::class) ->args([ service('property_accessor')->nullOnInvalid(), - service('doctrine')->nullOnInvalid(), + service('turbo.doctrine_id_accessor'), ]) ->set('turbo.broadcaster.action_renderer', TwigBroadcaster::class) @@ -52,6 +60,7 @@ ->args([ service('turbo.broadcaster.imux'), service('annotation_reader')->nullOnInvalid(), + service('turbo.doctrine_id_accessor'), ]) ->tag('doctrine.event_listener', ['event' => 'onFlush']) ->tag('doctrine.event_listener', ['event' => 'postFlush']) diff --git a/src/Turbo/src/Bridge/Mercure/Broadcaster.php b/src/Turbo/src/Bridge/Mercure/Broadcaster.php index a57f22d6af9..5d3fa9c99ac 100644 --- a/src/Turbo/src/Bridge/Mercure/Broadcaster.php +++ b/src/Turbo/src/Bridge/Mercure/Broadcaster.php @@ -15,6 +15,8 @@ use Symfony\Component\Mercure\HubInterface; use Symfony\Component\Mercure\Update; use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface; +use Symfony\UX\Turbo\Broadcaster\IdAccessor; +use Symfony\UX\Turbo\Broadcaster\IdFormatter; use Symfony\UX\Turbo\Doctrine\ClassUtil; /** @@ -42,14 +44,16 @@ final class Broadcaster implements BroadcasterInterface private $name; private $hub; + private $idFormatter; /** @var ExpressionLanguage|null */ private $expressionLanguage; - public function __construct(string $name, HubInterface $hub) + public function __construct(string $name, HubInterface $hub, ?IdFormatter $idFormatter = null) { $this->name = $name; $this->hub = $hub; + $this->idFormatter = $idFormatter ?? new IdFormatter(); if (class_exists(ExpressionLanguage::class)) { $this->expressionLanguage = new ExpressionLanguage(); @@ -99,7 +103,7 @@ public function broadcast(object $entity, string $action, array $options): void throw new \InvalidArgumentException(sprintf('Cannot broadcast entity of class "%s": the option "topics" is empty and "id" is missing.', $entityClass)); } - $id = $options['id_formatted'] ?? implode('-', (array) $options['id']); + $id = $this->idFormatter->format($options['id']); $options['topics'] = (array) sprintf(self::TOPIC_PATTERN, rawurlencode($entityClass), rawurlencode($id)); } diff --git a/src/Turbo/src/Broadcaster/DoctrineIdAccessor.php b/src/Turbo/src/Broadcaster/DoctrineIdAccessor.php new file mode 100644 index 00000000000..c1fab40a505 --- /dev/null +++ b/src/Turbo/src/Broadcaster/DoctrineIdAccessor.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Turbo\Broadcaster; + +use Doctrine\Persistence\ManagerRegistry; +use Doctrine\Persistence\ObjectManager; +use Symfony\UX\Turbo\Doctrine\ClassUtil; + +/** + * @author Jason Schilling + */ +class DoctrineIdAccessor +{ + private $doctrine; + + public function __construct(?ManagerRegistry $doctrine = null) + { + $this->doctrine = $doctrine; + } + + public function getEntityId(object $entity): ?array + { + $entityClass = $entity::class; + + if ($this->doctrine && $em = $this->doctrine->getManagerForClass($entityClass)) { + return $this->getIdentifierValues($em,$entity); + } + + return null; + } + + private function getIdentifierValues(ObjectManager $em, object $entity): array + { + $class = ClassUtil::getEntityClass($entity); + + $values = $em->getClassMetadata($class)->getIdentifierValues($entity); + + foreach ($values as $key => $value) { + if (\is_object($value)) { + $values[$key] = $this->getIdentifierValues($em, $value); + } + } + + return $values; + } +} diff --git a/src/Turbo/src/Broadcaster/IdAccessor.php b/src/Turbo/src/Broadcaster/IdAccessor.php index d693a9339cb..2d9d7489c61 100644 --- a/src/Turbo/src/Broadcaster/IdAccessor.php +++ b/src/Turbo/src/Broadcaster/IdAccessor.php @@ -11,19 +11,18 @@ namespace Symfony\UX\Turbo\Broadcaster; -use Doctrine\Persistence\ManagerRegistry; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; class IdAccessor { private $propertyAccessor; - private $doctrine; + private $doctrineIdAccessor; - public function __construct(?PropertyAccessorInterface $propertyAccessor = null, ?ManagerRegistry $doctrine = null) + public function __construct(?PropertyAccessorInterface $propertyAccessor = null, ?DoctrineIdAccessor $doctrineIdAccessor = null) { $this->propertyAccessor = $propertyAccessor ?? (class_exists(PropertyAccess::class) ? PropertyAccess::createPropertyAccessor() : null); - $this->doctrine = $doctrine; + $this->doctrineIdAccessor = $doctrineIdAccessor ?? new DoctrineIdAccessor(); } /** @@ -33,9 +32,8 @@ public function getEntityId(object $entity): ?array { $entityClass = $entity::class; - if ($this->doctrine && $em = $this->doctrine->getManagerForClass($entityClass)) { - // @todo: Not sure how to use the same method like in the BroadcastListener without duplicating the code. - return $em->getClassMetadata($entityClass)->getIdentifierValues($entity); + if (null !== ($id = $this->doctrineIdAccessor->getEntityId($entity))) { + return $id; } if ($this->propertyAccessor) { diff --git a/src/Turbo/src/Broadcaster/IdFormatter.php b/src/Turbo/src/Broadcaster/IdFormatter.php new file mode 100644 index 00000000000..e6b1ce97705 --- /dev/null +++ b/src/Turbo/src/Broadcaster/IdFormatter.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Turbo\Broadcaster; + +/** + * Formats an id array to a string. + * + * In defaults the id array is something like `['id' => 1]` or `['uuid' => '00000000-0000-0000-0000-000000000000']`. + * For a composite key it could be something like `['cart' => ['id' => 1], 'product' => ['id' => 1]]`. + * + * To create a string representation of the id, the values of the array are flattened and concatenated with a dash. + * + * @author Jason Schilling + */ +class IdFormatter +{ + public function format(array $id): string + { + $flatten = []; + + array_walk_recursive($id, static function ($item) use (&$flatten) { $flatten[] = $item; }); + + return implode('-', $flatten); + } +} diff --git a/src/Turbo/src/Broadcaster/TwigBroadcaster.php b/src/Turbo/src/Broadcaster/TwigBroadcaster.php index aa5af6f6f34..2bc27aa6a4c 100644 --- a/src/Turbo/src/Broadcaster/TwigBroadcaster.php +++ b/src/Turbo/src/Broadcaster/TwigBroadcaster.php @@ -25,23 +25,24 @@ final class TwigBroadcaster implements BroadcasterInterface private $twig; private $templatePrefixes; private $idAccessor; + private $idFormatter; /** * @param array $templatePrefixes */ - public function __construct(BroadcasterInterface $broadcaster, Environment $twig, array $templatePrefixes = [], ?IdAccessor $idAccessor = null) + public function __construct(BroadcasterInterface $broadcaster, Environment $twig, array $templatePrefixes = [], ?IdAccessor $idAccessor = null, ?IdFormatter $idFormatter = null) { $this->broadcaster = $broadcaster; $this->twig = $twig; $this->templatePrefixes = $templatePrefixes; $this->idAccessor = $idAccessor ?? new IdAccessor(); + $this->idFormatter = $idFormatter ?? new IdFormatter(); } public function broadcast(object $entity, string $action, array $options): void { if (!isset($options['id']) && null !== $id = $this->idAccessor->getEntityId($entity)) { $options['id'] = $id; - $options['id_formatted'] = $id; } $class = ClassUtil::getEntityClass($entity); @@ -64,7 +65,7 @@ public function broadcast(object $entity, string $action, array $options): void ->renderBlock($action, [ 'entity' => $entity, 'action' => $action, - 'id' => $options['id_formatted'], + 'id' => $this->idFormatter->format($options['id'] ?? []), ] + $options); $this->broadcaster->broadcast($entity, $action, $options); diff --git a/src/Turbo/src/Doctrine/BroadcastListener.php b/src/Turbo/src/Doctrine/BroadcastListener.php index c1796f401c0..1fc7e704b48 100644 --- a/src/Turbo/src/Doctrine/BroadcastListener.php +++ b/src/Turbo/src/Doctrine/BroadcastListener.php @@ -19,6 +19,8 @@ use Symfony\Contracts\Service\ResetInterface; use Symfony\UX\Turbo\Attribute\Broadcast; use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface; +use Symfony\UX\Turbo\Broadcaster\DoctrineIdAccessor; +use Symfony\UX\Turbo\Broadcaster\IdAccessor; /** * Detects changes made from Doctrine entities and broadcasts updates to the broadcasters. @@ -29,6 +31,7 @@ final class BroadcastListener implements ResetInterface { private $broadcaster; private $annotationReader; + private $doctrineIdAccessor; /** * @var array> @@ -48,12 +51,13 @@ final class BroadcastListener implements ResetInterface */ private $removedEntities; - public function __construct(BroadcasterInterface $broadcaster, ?Reader $annotationReader = null) + public function __construct(BroadcasterInterface $broadcaster, ?Reader $annotationReader = null, ?DoctrineIdAccessor $doctrineIdAccessor = null) { $this->reset(); $this->broadcaster = $broadcaster; $this->annotationReader = $annotationReader; + $this->doctrineIdAccessor = $doctrineIdAccessor ?? new DoctrineIdAccessor(); } /** @@ -94,10 +98,9 @@ public function postFlush(EventArgs $eventArgs): void try { foreach ($this->createdEntities as $entity) { $options = $this->createdEntities[$entity]; - $id = $this->getIdentifierValues($em, $entity); + $id = $this->doctrineIdAccessor->getEntityId($entity); foreach ($options as $option) { $option['id'] = $id; - $options['id_formatted'] = $this->formatId($id); $this->broadcaster->broadcast($entity, Broadcast::ACTION_CREATE, $option); } } @@ -148,37 +151,13 @@ private function storeEntitiesToPublish(EntityManagerInterface $em, object $enti if ($options = $this->broadcastedClasses[$class]) { if ('createdEntities' !== $property) { - $id = $this->getIdentifierValues($em, $entity); + $id = $this->doctrineIdAccessor->getEntityId($entity); foreach ($options as $k => $option) { $options[$k]['id'] = $id; - $options[$k]['id_formatted'] = $this->formatId($id); } } $this->{$property}->attach($entity, $options); } } - - private function getIdentifierValues(EntityManagerInterface $em, object $entity): array - { - $class = ClassUtil::getEntityClass($entity); - - $values = $em->getClassMetadata($class)->getIdentifierValues($entity); - - foreach ($values as $key => $value) { - if (\is_object($value)) { - $values[$key] = $this->getIdentifierValues($em, $value); - } - } - - return $values; - } - - private function formatId(array $id): string - { - $flatten = []; - array_walk_recursive($id, static function ($item) use (&$flatten) { $flatten[] = $item; }); - - return implode('-', $flatten); - } } diff --git a/src/Turbo/tests/app/Kernel.php b/src/Turbo/tests/app/Kernel.php index d13242f520e..effc4cf4f4e 100644 --- a/src/Turbo/tests/app/Kernel.php +++ b/src/Turbo/tests/app/Kernel.php @@ -319,7 +319,6 @@ public function cartProducts(Request $request, EntityManagerInterface $doctrine, if (!$cartId || !$productId) { $cart = new Cart(); - $product = new Product(); if ($title = $request->get('title')) { From 2c01cb918b50089381af39316b141bfce3dcf860 Mon Sep 17 00:00:00 2001 From: chapterjason Date: Sun, 19 May 2024 13:42:24 +0200 Subject: [PATCH 04/10] Add missing injection --- src/Turbo/config/services.php | 1 + src/Turbo/src/Bridge/Mercure/Broadcaster.php | 1 - src/Turbo/src/Broadcaster/DoctrineIdAccessor.php | 2 +- src/Turbo/src/Doctrine/BroadcastListener.php | 1 - 4 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Turbo/config/services.php b/src/Turbo/config/services.php index b9c947804e9..b0dd2d00037 100644 --- a/src/Turbo/config/services.php +++ b/src/Turbo/config/services.php @@ -49,6 +49,7 @@ service('twig'), abstract_arg('entity template prefixes'), service('turbo.id_accessor'), + service('turbo.id_formatter'), ]) ->decorate('turbo.broadcaster.imux') diff --git a/src/Turbo/src/Bridge/Mercure/Broadcaster.php b/src/Turbo/src/Bridge/Mercure/Broadcaster.php index 5d3fa9c99ac..5c00cae39c7 100644 --- a/src/Turbo/src/Bridge/Mercure/Broadcaster.php +++ b/src/Turbo/src/Bridge/Mercure/Broadcaster.php @@ -15,7 +15,6 @@ use Symfony\Component\Mercure\HubInterface; use Symfony\Component\Mercure\Update; use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface; -use Symfony\UX\Turbo\Broadcaster\IdAccessor; use Symfony\UX\Turbo\Broadcaster\IdFormatter; use Symfony\UX\Turbo\Doctrine\ClassUtil; diff --git a/src/Turbo/src/Broadcaster/DoctrineIdAccessor.php b/src/Turbo/src/Broadcaster/DoctrineIdAccessor.php index c1fab40a505..347d1e44f04 100644 --- a/src/Turbo/src/Broadcaster/DoctrineIdAccessor.php +++ b/src/Turbo/src/Broadcaster/DoctrineIdAccessor.php @@ -32,7 +32,7 @@ public function getEntityId(object $entity): ?array $entityClass = $entity::class; if ($this->doctrine && $em = $this->doctrine->getManagerForClass($entityClass)) { - return $this->getIdentifierValues($em,$entity); + return $this->getIdentifierValues($em, $entity); } return null; diff --git a/src/Turbo/src/Doctrine/BroadcastListener.php b/src/Turbo/src/Doctrine/BroadcastListener.php index 1fc7e704b48..130200108e1 100644 --- a/src/Turbo/src/Doctrine/BroadcastListener.php +++ b/src/Turbo/src/Doctrine/BroadcastListener.php @@ -20,7 +20,6 @@ use Symfony\UX\Turbo\Attribute\Broadcast; use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface; use Symfony\UX\Turbo\Broadcaster\DoctrineIdAccessor; -use Symfony\UX\Turbo\Broadcaster\IdAccessor; /** * Detects changes made from Doctrine entities and broadcasts updates to the broadcasters. From 59f44bde5613d784c2803af57d7951fc7332194f Mon Sep 17 00:00:00 2001 From: chapterjason Date: Sun, 19 May 2024 13:44:33 +0200 Subject: [PATCH 05/10] Fix fabbot --- src/Turbo/tests/BroadcastTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Turbo/tests/BroadcastTest.php b/src/Turbo/tests/BroadcastTest.php index 035442a6077..53cc43317a0 100644 --- a/src/Turbo/tests/BroadcastTest.php +++ b/src/Turbo/tests/BroadcastTest.php @@ -27,7 +27,7 @@ class BroadcastTest extends PantherTestCase protected function setUp(): void { if (!file_exists(__DIR__.'/app/public/build')) { - throw new \Exception(sprintf('Move into %s and execute Encore before running this test.', realpath(__DIR__.'/app'))); + throw new \Exception(sprintf('Move into "%s" and execute Encore before running this test.', realpath(__DIR__.'/app'))); } parent::setUp(); From 47ba656f3fef5ac675c036b53ef91b7c18fdd998 Mon Sep 17 00:00:00 2001 From: chapterjason Date: Sun, 19 May 2024 14:02:07 +0200 Subject: [PATCH 06/10] Fix PHPStan --- .../src/Bridge/Mercure/TurboStreamListenRenderer.php | 9 +++++++-- src/Turbo/src/Broadcaster/BroadcasterInterface.php | 2 +- src/Turbo/src/Broadcaster/DoctrineIdAccessor.php | 6 ++++++ src/Turbo/src/Broadcaster/IdAccessor.php | 2 +- src/Turbo/src/Broadcaster/IdFormatter.php | 9 ++++++++- src/Turbo/tests/app/Kernel.php | 8 ++++++-- 6 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php b/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php index 97dd22b76e7..90a2ba2a740 100644 --- a/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php +++ b/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php @@ -14,6 +14,7 @@ use Symfony\Component\Mercure\HubInterface; use Symfony\UX\StimulusBundle\Helper\StimulusHelper; use Symfony\UX\Turbo\Broadcaster\IdAccessor; +use Symfony\UX\Turbo\Broadcaster\IdFormatter; use Symfony\UX\Turbo\Twig\TurboStreamListenRendererInterface; use Symfony\WebpackEncoreBundle\Twig\StimulusTwigExtension; use Twig\Environment; @@ -28,14 +29,16 @@ final class TurboStreamListenRenderer implements TurboStreamListenRendererInterf private HubInterface $hub; private StimulusHelper $stimulusHelper; private IdAccessor $idAccessor; + private IdFormatter $idFormatter; /** * @param $stimulus StimulusHelper */ - public function __construct(HubInterface $hub, StimulusHelper|StimulusTwigExtension $stimulus, IdAccessor $idAccessor) + public function __construct(HubInterface $hub, StimulusHelper|StimulusTwigExtension $stimulus, IdAccessor $idAccessor, ?IdFormatter $idFormatter = null) { $this->hub = $hub; $this->idAccessor = $idAccessor; + $this->idFormatter = $idFormatter ?? new IdFormatter(); if ($stimulus instanceof StimulusTwigExtension) { trigger_deprecation('symfony/ux-turbo', '2.9', 'Passing an instance of "%s" as second argument of "%s" is deprecated, pass an instance of "%s" instead.', StimulusTwigExtension::class, __CLASS__, StimulusHelper::class); @@ -55,7 +58,9 @@ public function renderTurboStreamListen(Environment $env, $topic): string throw new \LogicException(sprintf('Cannot listen to entity of class "%s" as the PropertyAccess component is not installed. Try running "composer require symfony/property-access".', $class)); } - $topic = sprintf(Broadcaster::TOPIC_PATTERN, rawurlencode($class), rawurlencode(implode('-', $id))); + $formattedId = $this->idFormatter->format($id); + + $topic = sprintf(Broadcaster::TOPIC_PATTERN, rawurlencode($class), rawurlencode($formattedId)); } elseif (!preg_match('/[^a-zA-Z0-9_\x7f-\xff\\\\]/', $topic) && class_exists($topic)) { // Generate a URI template to subscribe to updates for all objects of this class $topic = sprintf(Broadcaster::TOPIC_PATTERN, rawurlencode($topic), '{id}'); diff --git a/src/Turbo/src/Broadcaster/BroadcasterInterface.php b/src/Turbo/src/Broadcaster/BroadcasterInterface.php index 7bff8a1e20f..fb89534c757 100644 --- a/src/Turbo/src/Broadcaster/BroadcasterInterface.php +++ b/src/Turbo/src/Broadcaster/BroadcasterInterface.php @@ -19,7 +19,7 @@ interface BroadcasterInterface { /** - * @param array{id?: string|string[], transports?: string|string[], topics?: string|string[], template?: string, rendered_action?: string, private?: bool, sse_id?: string, sse_type?: string, sse_retry?: int} $options + * @param array{id?: array|array>, transports?: string|string[], topics?: string|string[], template?: string, rendered_action?: string, private?: bool, sse_id?: string, sse_type?: string, sse_retry?: int} $options */ public function broadcast(object $entity, string $action, array $options): void; } diff --git a/src/Turbo/src/Broadcaster/DoctrineIdAccessor.php b/src/Turbo/src/Broadcaster/DoctrineIdAccessor.php index 347d1e44f04..c3ef16325eb 100644 --- a/src/Turbo/src/Broadcaster/DoctrineIdAccessor.php +++ b/src/Turbo/src/Broadcaster/DoctrineIdAccessor.php @@ -27,6 +27,9 @@ public function __construct(?ManagerRegistry $doctrine = null) $this->doctrine = $doctrine; } + /** + * @return array>|array|null + */ public function getEntityId(object $entity): ?array { $entityClass = $entity::class; @@ -38,6 +41,9 @@ public function getEntityId(object $entity): ?array return null; } + /** + * @return array|array> + */ private function getIdentifierValues(ObjectManager $em, object $entity): array { $class = ClassUtil::getEntityClass($entity); diff --git a/src/Turbo/src/Broadcaster/IdAccessor.php b/src/Turbo/src/Broadcaster/IdAccessor.php index 2d9d7489c61..a4022e38bdc 100644 --- a/src/Turbo/src/Broadcaster/IdAccessor.php +++ b/src/Turbo/src/Broadcaster/IdAccessor.php @@ -26,7 +26,7 @@ public function __construct(?PropertyAccessorInterface $propertyAccessor = null, } /** - * @return string[] + * @return array>|array|null */ public function getEntityId(object $entity): ?array { diff --git a/src/Turbo/src/Broadcaster/IdFormatter.php b/src/Turbo/src/Broadcaster/IdFormatter.php index e6b1ce97705..b39de1ee6c6 100644 --- a/src/Turbo/src/Broadcaster/IdFormatter.php +++ b/src/Turbo/src/Broadcaster/IdFormatter.php @@ -23,8 +23,15 @@ */ class IdFormatter { - public function format(array $id): string + /** + * @param array>|array|string $id + */ + public function format(array|string $id): string { + if (is_string($id)) { + return $id; + } + $flatten = []; array_walk_recursive($id, static function ($item) use (&$flatten) { $flatten[] = $item; }); diff --git a/src/Turbo/tests/app/Kernel.php b/src/Turbo/tests/app/Kernel.php index effc4cf4f4e..5d99852c3e6 100644 --- a/src/Turbo/tests/app/Kernel.php +++ b/src/Turbo/tests/app/Kernel.php @@ -345,8 +345,12 @@ public function cartProducts(Request $request, EntityManagerInterface $doctrine, if ($remove = $request->get('remove')) { $doctrine->remove($cartProduct); - $doctrine->remove($cartProduct->product); // for cleanup - $doctrine->remove($cartProduct->cart); // for cleanup + if ($cartProduct->product) { + $doctrine->remove($cartProduct->product); // for cleanup + } + if ($cartProduct->cart) { + $doctrine->remove($cartProduct->cart); // for cleanup + } } else { $doctrine->persist($cartProduct); } From dfb3300bc75e8dc7e0bcafc4a61cd52e0c0a5ab7 Mon Sep 17 00:00:00 2001 From: chapterjason Date: Sun, 19 May 2024 14:04:57 +0200 Subject: [PATCH 07/10] Fix fabbot --- src/Turbo/src/Broadcaster/IdFormatter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Turbo/src/Broadcaster/IdFormatter.php b/src/Turbo/src/Broadcaster/IdFormatter.php index b39de1ee6c6..04834fc5143 100644 --- a/src/Turbo/src/Broadcaster/IdFormatter.php +++ b/src/Turbo/src/Broadcaster/IdFormatter.php @@ -28,7 +28,7 @@ class IdFormatter */ public function format(array|string $id): string { - if (is_string($id)) { + if (\is_string($id)) { return $id; } From 157de88cd0b60f37f9718a190fac0b9c975bc999 Mon Sep 17 00:00:00 2001 From: chapterjason Date: Sun, 19 May 2024 14:47:25 +0200 Subject: [PATCH 08/10] Fix service --- src/Turbo/config/services.php | 3 ++- src/Turbo/src/Broadcaster/DoctrineIdAccessor.php | 2 +- src/Turbo/src/Broadcaster/IdAccessor.php | 2 -- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Turbo/config/services.php b/src/Turbo/config/services.php index b0dd2d00037..1aab92afa96 100644 --- a/src/Turbo/config/services.php +++ b/src/Turbo/config/services.php @@ -14,6 +14,7 @@ use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface; use Symfony\UX\Turbo\Broadcaster\DoctrineIdAccessor; use Symfony\UX\Turbo\Broadcaster\IdAccessor; +use Symfony\UX\Turbo\Broadcaster\IdFormatter; use Symfony\UX\Turbo\Broadcaster\ImuxBroadcaster; use Symfony\UX\Turbo\Broadcaster\TwigBroadcaster; use Symfony\UX\Turbo\Doctrine\BroadcastListener; @@ -30,7 +31,7 @@ ->alias(BroadcasterInterface::class, 'turbo.broadcaster.imux') - ->set('turbo.id_formatter', IdAccessor::class) + ->set('turbo.id_formatter', IdFormatter::class) ->set('turbo.doctrine_id_accessor', DoctrineIdAccessor::class) ->args([ diff --git a/src/Turbo/src/Broadcaster/DoctrineIdAccessor.php b/src/Turbo/src/Broadcaster/DoctrineIdAccessor.php index c3ef16325eb..443b558e304 100644 --- a/src/Turbo/src/Broadcaster/DoctrineIdAccessor.php +++ b/src/Turbo/src/Broadcaster/DoctrineIdAccessor.php @@ -32,7 +32,7 @@ public function __construct(?ManagerRegistry $doctrine = null) */ public function getEntityId(object $entity): ?array { - $entityClass = $entity::class; + $entityClass = ClassUtil::getEntityClass($entity); if ($this->doctrine && $em = $this->doctrine->getManagerForClass($entityClass)) { return $this->getIdentifierValues($em, $entity); diff --git a/src/Turbo/src/Broadcaster/IdAccessor.php b/src/Turbo/src/Broadcaster/IdAccessor.php index a4022e38bdc..c4aed4507e7 100644 --- a/src/Turbo/src/Broadcaster/IdAccessor.php +++ b/src/Turbo/src/Broadcaster/IdAccessor.php @@ -30,8 +30,6 @@ public function __construct(?PropertyAccessorInterface $propertyAccessor = null, */ public function getEntityId(object $entity): ?array { - $entityClass = $entity::class; - if (null !== ($id = $this->doctrineIdAccessor->getEntityId($entity))) { return $id; } From d5c59258917997a7f6800ce00bb70275c326c901 Mon Sep 17 00:00:00 2001 From: chapterjason Date: Sun, 19 May 2024 17:50:21 +0200 Subject: [PATCH 09/10] Fix real class resolve --- src/Turbo/config/services.php | 10 ++++- src/Turbo/src/Bridge/Mercure/Broadcaster.php | 7 ++- .../Mercure/TurboStreamListenRenderer.php | 7 ++- src/Turbo/src/Broadcaster/IdAccessor.php | 1 + src/Turbo/src/Broadcaster/TwigBroadcaster.php | 10 +++-- src/Turbo/src/Doctrine/BroadcastListener.php | 23 +++++----- .../src/Doctrine/DoctrineClassResolver.php | 44 +++++++++++++++++++ .../DoctrineIdAccessor.php | 13 +++--- src/Turbo/src/TurboBundle.php | 21 ++++++++- src/Turbo/tests/app/Entity/Song.php | 2 +- 10 files changed, 108 insertions(+), 30 deletions(-) create mode 100644 src/Turbo/src/Doctrine/DoctrineClassResolver.php rename src/Turbo/src/{Broadcaster => Doctrine}/DoctrineIdAccessor.php (73%) diff --git a/src/Turbo/config/services.php b/src/Turbo/config/services.php index 1aab92afa96..2c3a95e7e3f 100644 --- a/src/Turbo/config/services.php +++ b/src/Turbo/config/services.php @@ -12,12 +12,13 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface; -use Symfony\UX\Turbo\Broadcaster\DoctrineIdAccessor; use Symfony\UX\Turbo\Broadcaster\IdAccessor; use Symfony\UX\Turbo\Broadcaster\IdFormatter; use Symfony\UX\Turbo\Broadcaster\ImuxBroadcaster; use Symfony\UX\Turbo\Broadcaster\TwigBroadcaster; use Symfony\UX\Turbo\Doctrine\BroadcastListener; +use Symfony\UX\Turbo\Doctrine\DoctrineClassResolver; +use Symfony\UX\Turbo\Doctrine\DoctrineIdAccessor; use Symfony\UX\Turbo\Twig\TwigExtension; /* @@ -31,6 +32,11 @@ ->alias(BroadcasterInterface::class, 'turbo.broadcaster.imux') + ->set('turbo.doctrine_class_resolver', DoctrineClassResolver::class) + ->args([ + service('doctrine')->nullOnInvalid(), + ]) + ->set('turbo.id_formatter', IdFormatter::class) ->set('turbo.doctrine_id_accessor', DoctrineIdAccessor::class) @@ -51,6 +57,7 @@ abstract_arg('entity template prefixes'), service('turbo.id_accessor'), service('turbo.id_formatter'), + service('turbo.doctrine_class_resolver'), ]) ->decorate('turbo.broadcaster.imux') @@ -63,6 +70,7 @@ service('turbo.broadcaster.imux'), service('annotation_reader')->nullOnInvalid(), service('turbo.doctrine_id_accessor'), + service('turbo.doctrine_class_resolver'), ]) ->tag('doctrine.event_listener', ['event' => 'onFlush']) ->tag('doctrine.event_listener', ['event' => 'postFlush']) diff --git a/src/Turbo/src/Bridge/Mercure/Broadcaster.php b/src/Turbo/src/Bridge/Mercure/Broadcaster.php index 5c00cae39c7..7c71c75fd5e 100644 --- a/src/Turbo/src/Bridge/Mercure/Broadcaster.php +++ b/src/Turbo/src/Bridge/Mercure/Broadcaster.php @@ -17,6 +17,7 @@ use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface; use Symfony\UX\Turbo\Broadcaster\IdFormatter; use Symfony\UX\Turbo\Doctrine\ClassUtil; +use Symfony\UX\Turbo\Doctrine\DoctrineClassResolver; /** * Broadcasts updates rendered using Twig with Mercure. @@ -44,15 +45,17 @@ final class Broadcaster implements BroadcasterInterface private $name; private $hub; private $idFormatter; + private $doctrineClassResolver; /** @var ExpressionLanguage|null */ private $expressionLanguage; - public function __construct(string $name, HubInterface $hub, ?IdFormatter $idFormatter = null) + public function __construct(string $name, HubInterface $hub, ?IdFormatter $idFormatter = null, ?DoctrineClassResolver $doctrineClassResolver = null) { $this->name = $name; $this->hub = $hub; $this->idFormatter = $idFormatter ?? new IdFormatter(); + $this->doctrineClassResolver = $doctrineClassResolver ?? new DoctrineClassResolver(); if (class_exists(ExpressionLanguage::class)) { $this->expressionLanguage = new ExpressionLanguage(); @@ -65,7 +68,7 @@ public function broadcast(object $entity, string $action, array $options): void return; } - $entityClass = ClassUtil::getEntityClass($entity); + $entityClass = $this->doctrineClassResolver->resolve($entity); if (!isset($options['rendered_action'])) { throw new \InvalidArgumentException(sprintf('Cannot broadcast entity of class "%s" as option "rendered_action" is missing.', $entityClass)); diff --git a/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php b/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php index 90a2ba2a740..d86e7032434 100644 --- a/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php +++ b/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php @@ -15,6 +15,7 @@ use Symfony\UX\StimulusBundle\Helper\StimulusHelper; use Symfony\UX\Turbo\Broadcaster\IdAccessor; use Symfony\UX\Turbo\Broadcaster\IdFormatter; +use Symfony\UX\Turbo\Doctrine\DoctrineClassResolver; use Symfony\UX\Turbo\Twig\TurboStreamListenRendererInterface; use Symfony\WebpackEncoreBundle\Twig\StimulusTwigExtension; use Twig\Environment; @@ -30,15 +31,17 @@ final class TurboStreamListenRenderer implements TurboStreamListenRendererInterf private StimulusHelper $stimulusHelper; private IdAccessor $idAccessor; private IdFormatter $idFormatter; + private DoctrineClassResolver $doctrineClassResolver; /** * @param $stimulus StimulusHelper */ - public function __construct(HubInterface $hub, StimulusHelper|StimulusTwigExtension $stimulus, IdAccessor $idAccessor, ?IdFormatter $idFormatter = null) + public function __construct(HubInterface $hub, StimulusHelper|StimulusTwigExtension $stimulus, IdAccessor $idAccessor, ?IdFormatter $idFormatter = null, ?DoctrineClassResolver $doctrineClassResolver = null) { $this->hub = $hub; $this->idAccessor = $idAccessor; $this->idFormatter = $idFormatter ?? new IdFormatter(); + $this->doctrineClassResolver = $doctrineClassResolver ?? new DoctrineClassResolver(); if ($stimulus instanceof StimulusTwigExtension) { trigger_deprecation('symfony/ux-turbo', '2.9', 'Passing an instance of "%s" as second argument of "%s" is deprecated, pass an instance of "%s" instead.', StimulusTwigExtension::class, __CLASS__, StimulusHelper::class); @@ -52,7 +55,7 @@ public function __construct(HubInterface $hub, StimulusHelper|StimulusTwigExtens public function renderTurboStreamListen(Environment $env, $topic): string { if (\is_object($topic)) { - $class = $topic::class; + $class = $this->doctrineClassResolver->resolve($topic); if (!$id = $this->idAccessor->getEntityId($topic)) { throw new \LogicException(sprintf('Cannot listen to entity of class "%s" as the PropertyAccess component is not installed. Try running "composer require symfony/property-access".', $class)); diff --git a/src/Turbo/src/Broadcaster/IdAccessor.php b/src/Turbo/src/Broadcaster/IdAccessor.php index c4aed4507e7..27087eff625 100644 --- a/src/Turbo/src/Broadcaster/IdAccessor.php +++ b/src/Turbo/src/Broadcaster/IdAccessor.php @@ -13,6 +13,7 @@ use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\UX\Turbo\Doctrine\DoctrineIdAccessor; class IdAccessor { diff --git a/src/Turbo/src/Broadcaster/TwigBroadcaster.php b/src/Turbo/src/Broadcaster/TwigBroadcaster.php index 2bc27aa6a4c..9cc5e92015a 100644 --- a/src/Turbo/src/Broadcaster/TwigBroadcaster.php +++ b/src/Turbo/src/Broadcaster/TwigBroadcaster.php @@ -12,6 +12,7 @@ namespace Symfony\UX\Turbo\Broadcaster; use Symfony\UX\Turbo\Doctrine\ClassUtil; +use Symfony\UX\Turbo\Doctrine\DoctrineClassResolver; use Twig\Environment; /** @@ -26,17 +27,19 @@ final class TwigBroadcaster implements BroadcasterInterface private $templatePrefixes; private $idAccessor; private $idFormatter; + private $doctrineClassResolver; /** * @param array $templatePrefixes */ - public function __construct(BroadcasterInterface $broadcaster, Environment $twig, array $templatePrefixes = [], ?IdAccessor $idAccessor = null, ?IdFormatter $idFormatter = null) + public function __construct(BroadcasterInterface $broadcaster, Environment $twig, array $templatePrefixes = [], ?IdAccessor $idAccessor = null, ?IdFormatter $idFormatter = null, ?DoctrineClassResolver $doctrineClassResolver = null) { $this->broadcaster = $broadcaster; $this->twig = $twig; $this->templatePrefixes = $templatePrefixes; $this->idAccessor = $idAccessor ?? new IdAccessor(); $this->idFormatter = $idFormatter ?? new IdFormatter(); + $this->doctrineClassResolver = $doctrineClassResolver ?? new DoctrineClassResolver(); } public function broadcast(object $entity, string $action, array $options): void @@ -45,10 +48,9 @@ public function broadcast(object $entity, string $action, array $options): void $options['id'] = $id; } - $class = ClassUtil::getEntityClass($entity); - if (null === $template = $options['template'] ?? null) { - $template = $class; + $template = $this->doctrineClassResolver->resolve($entity); + foreach ($this->templatePrefixes as $namespace => $prefix) { if (str_starts_with($template, $namespace)) { $template = substr_replace($template, $prefix, 0, \strlen($namespace)); diff --git a/src/Turbo/src/Doctrine/BroadcastListener.php b/src/Turbo/src/Doctrine/BroadcastListener.php index 130200108e1..44009237d4f 100644 --- a/src/Turbo/src/Doctrine/BroadcastListener.php +++ b/src/Turbo/src/Doctrine/BroadcastListener.php @@ -19,7 +19,6 @@ use Symfony\Contracts\Service\ResetInterface; use Symfony\UX\Turbo\Attribute\Broadcast; use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface; -use Symfony\UX\Turbo\Broadcaster\DoctrineIdAccessor; /** * Detects changes made from Doctrine entities and broadcasts updates to the broadcasters. @@ -31,6 +30,7 @@ final class BroadcastListener implements ResetInterface private $broadcaster; private $annotationReader; private $doctrineIdAccessor; + private $doctrineClassResolver; /** * @var array> @@ -50,13 +50,14 @@ final class BroadcastListener implements ResetInterface */ private $removedEntities; - public function __construct(BroadcasterInterface $broadcaster, ?Reader $annotationReader = null, ?DoctrineIdAccessor $doctrineIdAccessor = null) + public function __construct(BroadcasterInterface $broadcaster, ?Reader $annotationReader = null, ?DoctrineIdAccessor $doctrineIdAccessor = null, ?DoctrineClassResolver $doctrineClassResolver = null) { $this->reset(); $this->broadcaster = $broadcaster; $this->annotationReader = $annotationReader; $this->doctrineIdAccessor = $doctrineIdAccessor ?? new DoctrineIdAccessor(); + $this->doctrineClassResolver = $doctrineClassResolver ?? new DoctrineClassResolver(); } /** @@ -97,7 +98,7 @@ public function postFlush(EventArgs $eventArgs): void try { foreach ($this->createdEntities as $entity) { $options = $this->createdEntities[$entity]; - $id = $this->doctrineIdAccessor->getEntityId($entity); + $id = $this->doctrineIdAccessor->getEntityId($entity, $em); foreach ($options as $option) { $option['id'] = $id; $this->broadcaster->broadcast($entity, Broadcast::ACTION_CREATE, $option); @@ -129,28 +130,28 @@ public function reset(): void private function storeEntitiesToPublish(EntityManagerInterface $em, object $entity, string $property): void { - $class = ClassUtil::getEntityClass($entity); + $entityClass = $this->doctrineClassResolver->resolve($entity, $em); - if (!isset($this->broadcastedClasses[$class])) { - $this->broadcastedClasses[$class] = []; - $r = new \ReflectionClass($class); + if (!isset($this->broadcastedClasses[$entityClass])) { + $this->broadcastedClasses[$entityClass] = []; + $r = new \ReflectionClass($entityClass); if ($options = $r->getAttributes(Broadcast::class)) { foreach ($options as $option) { - $this->broadcastedClasses[$class][] = $option->newInstance()->options; + $this->broadcastedClasses[$entityClass][] = $option->newInstance()->options; } } elseif ($this->annotationReader && $options = $this->annotationReader->getClassAnnotations($r)) { foreach ($options as $option) { if ($option instanceof Broadcast) { - $this->broadcastedClasses[$class][] = $option->options; + $this->broadcastedClasses[$entityClass][] = $option->options; } } } } - if ($options = $this->broadcastedClasses[$class]) { + if ($options = $this->broadcastedClasses[$entityClass]) { if ('createdEntities' !== $property) { - $id = $this->doctrineIdAccessor->getEntityId($entity); + $id = $this->doctrineIdAccessor->getEntityId($entity, $em); foreach ($options as $k => $option) { $options[$k]['id'] = $id; } diff --git a/src/Turbo/src/Doctrine/DoctrineClassResolver.php b/src/Turbo/src/Doctrine/DoctrineClassResolver.php new file mode 100644 index 00000000000..fb65f642ceb --- /dev/null +++ b/src/Turbo/src/Doctrine/DoctrineClassResolver.php @@ -0,0 +1,44 @@ +doctrine = $doctrine; + } + + /** + * @param object $entity + * @return class-string + */ + public function resolve(object $entity, ?ObjectManager $em = null): string + { + $class = ClassUtil::getEntityClass($entity); + + if (!$this->doctrine) { + return $class; + } + + $em = $em ?? $this->doctrine->getManagerForClass($class); + + if (!$em) { + return $class; + } + + $classMetadata = $em->getClassMetadata($class); + + if ($classMetadata instanceof ClassMetadata) { + return $classMetadata->rootEntityName; + } + + return $class; + } +} diff --git a/src/Turbo/src/Broadcaster/DoctrineIdAccessor.php b/src/Turbo/src/Doctrine/DoctrineIdAccessor.php similarity index 73% rename from src/Turbo/src/Broadcaster/DoctrineIdAccessor.php rename to src/Turbo/src/Doctrine/DoctrineIdAccessor.php index 443b558e304..b1a5edbd03f 100644 --- a/src/Turbo/src/Broadcaster/DoctrineIdAccessor.php +++ b/src/Turbo/src/Doctrine/DoctrineIdAccessor.php @@ -9,11 +9,10 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Turbo\Broadcaster; +namespace Symfony\UX\Turbo\Doctrine; use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ObjectManager; -use Symfony\UX\Turbo\Doctrine\ClassUtil; /** * @author Jason Schilling @@ -30,11 +29,11 @@ public function __construct(?ManagerRegistry $doctrine = null) /** * @return array>|array|null */ - public function getEntityId(object $entity): ?array + public function getEntityId(object $entity, ?ObjectManager $em = null): ?array { - $entityClass = ClassUtil::getEntityClass($entity); + $em = $em ?? $this->doctrine?->getManagerForClass($entity::class); - if ($this->doctrine && $em = $this->doctrine->getManagerForClass($entityClass)) { + if ($em) { return $this->getIdentifierValues($em, $entity); } @@ -46,9 +45,7 @@ public function getEntityId(object $entity): ?array */ private function getIdentifierValues(ObjectManager $em, object $entity): array { - $class = ClassUtil::getEntityClass($entity); - - $values = $em->getClassMetadata($class)->getIdentifierValues($entity); + $values = $em->getClassMetadata($entity::class)->getIdentifierValues($entity); foreach ($values as $key => $value) { if (\is_object($value)) { diff --git a/src/Turbo/src/TurboBundle.php b/src/Turbo/src/TurboBundle.php index 61542a4bab9..503be2419d0 100644 --- a/src/Turbo/src/TurboBundle.php +++ b/src/Turbo/src/TurboBundle.php @@ -14,6 +14,7 @@ use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\Compiler\PassConfig; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Bundle\Bundle; @@ -34,7 +35,7 @@ public function build(ContainerBuilder $container): void { parent::build($container); - $container->addCompilerPass(new class() implements CompilerPassInterface { + $container->addCompilerPass(new class () implements CompilerPassInterface { public function process(ContainerBuilder $container): void { if (!$container->hasDefinition('turbo.broadcaster.imux')) { @@ -45,6 +46,24 @@ public function process(ContainerBuilder $container): void } } }, PassConfig::TYPE_BEFORE_REMOVING); + + $container->addCompilerPass(new class () implements CompilerPassInterface { + public function process(ContainerBuilder $container): void + { + $serviceIds = [ + ...$container->findTaggedServiceIds('turbo.broadcaster'), + ...$container->findTaggedServiceIds('turbo.renderer.stream_listen'), + ]; + + foreach ($serviceIds as $serviceId => $tags) { + $definition = $container->getDefinition($serviceId); + + $definition + ->addArgument(new Reference('turbo.id_formatter')) + ->addArgument(new Reference('turbo.doctrine_class_resolver')); + } + } + }); } public function getPath(): string diff --git a/src/Turbo/tests/app/Entity/Song.php b/src/Turbo/tests/app/Entity/Song.php index cdb5d8a5ef8..6600f2121ea 100644 --- a/src/Turbo/tests/app/Entity/Song.php +++ b/src/Turbo/tests/app/Entity/Song.php @@ -24,7 +24,7 @@ class Song #[ORM\Id] #[ORM\GeneratedValue(strategy: 'AUTO')] #[ORM\Column(type: 'integer')] - public ?string $id = null; + public ?int $id = null; #[ORM\Column] public string $title = ''; From cba61dc715bc84e7807c431a6bd1b4d4a23a3582 Mon Sep 17 00:00:00 2001 From: chapterjason Date: Sun, 19 May 2024 17:56:00 +0200 Subject: [PATCH 10/10] Fix fabbot --- src/Turbo/src/Bridge/Mercure/Broadcaster.php | 1 - src/Turbo/src/Broadcaster/TwigBroadcaster.php | 1 - src/Turbo/src/Doctrine/DoctrineClassResolver.php | 13 ++++++++++++- src/Turbo/src/TurboBundle.php | 4 ++-- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/Turbo/src/Bridge/Mercure/Broadcaster.php b/src/Turbo/src/Bridge/Mercure/Broadcaster.php index 7c71c75fd5e..924a82c51c8 100644 --- a/src/Turbo/src/Bridge/Mercure/Broadcaster.php +++ b/src/Turbo/src/Bridge/Mercure/Broadcaster.php @@ -16,7 +16,6 @@ use Symfony\Component\Mercure\Update; use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface; use Symfony\UX\Turbo\Broadcaster\IdFormatter; -use Symfony\UX\Turbo\Doctrine\ClassUtil; use Symfony\UX\Turbo\Doctrine\DoctrineClassResolver; /** diff --git a/src/Turbo/src/Broadcaster/TwigBroadcaster.php b/src/Turbo/src/Broadcaster/TwigBroadcaster.php index 9cc5e92015a..f79d7428bcf 100644 --- a/src/Turbo/src/Broadcaster/TwigBroadcaster.php +++ b/src/Turbo/src/Broadcaster/TwigBroadcaster.php @@ -11,7 +11,6 @@ namespace Symfony\UX\Turbo\Broadcaster; -use Symfony\UX\Turbo\Doctrine\ClassUtil; use Symfony\UX\Turbo\Doctrine\DoctrineClassResolver; use Twig\Environment; diff --git a/src/Turbo/src/Doctrine/DoctrineClassResolver.php b/src/Turbo/src/Doctrine/DoctrineClassResolver.php index fb65f642ceb..f5869f4b8c4 100644 --- a/src/Turbo/src/Doctrine/DoctrineClassResolver.php +++ b/src/Turbo/src/Doctrine/DoctrineClassResolver.php @@ -1,11 +1,23 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\UX\Turbo\Doctrine; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ObjectManager; +/** + * @author Jason Schilling + */ class DoctrineClassResolver { private $doctrine; @@ -16,7 +28,6 @@ public function __construct(?ManagerRegistry $doctrine = null) } /** - * @param object $entity * @return class-string */ public function resolve(object $entity, ?ObjectManager $em = null): string diff --git a/src/Turbo/src/TurboBundle.php b/src/Turbo/src/TurboBundle.php index 503be2419d0..99cbf74a6ad 100644 --- a/src/Turbo/src/TurboBundle.php +++ b/src/Turbo/src/TurboBundle.php @@ -35,7 +35,7 @@ public function build(ContainerBuilder $container): void { parent::build($container); - $container->addCompilerPass(new class () implements CompilerPassInterface { + $container->addCompilerPass(new class() implements CompilerPassInterface { public function process(ContainerBuilder $container): void { if (!$container->hasDefinition('turbo.broadcaster.imux')) { @@ -47,7 +47,7 @@ public function process(ContainerBuilder $container): void } }, PassConfig::TYPE_BEFORE_REMOVING); - $container->addCompilerPass(new class () implements CompilerPassInterface { + $container->addCompilerPass(new class() implements CompilerPassInterface { public function process(ContainerBuilder $container): void { $serviceIds = [