Skip to content

Commit

Permalink
Merge pull request symfony#2 from nicolas-grekas/turbo-options
Browse files Browse the repository at this point in the history
Add $options to BroadcastListener::broadcast()
  • Loading branch information
dunglas authored Mar 26, 2021
2 parents 8353ff3 + 2400139 commit f3d1711
Show file tree
Hide file tree
Showing 10 changed files with 102 additions and 57 deletions.
5 changes: 4 additions & 1 deletion src/Turbo/Broadcaster/BroadcasterInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,8 @@
*/
interface BroadcasterInterface
{
public function broadcast(object $entity, string $action): void;
/**
* @param array{id?: string|string[], transports?: string[], topics?: string[], template?: string} $options
*/
public function broadcast(object $entity, string $action, array $options): void;
}
7 changes: 5 additions & 2 deletions src/Turbo/Broadcaster/ImuxBroadcaster.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,13 @@ public function __construct(iterable $broadcasters)
$this->broadcasters = $broadcasters;
}

public function broadcast(object $entity, string $action): void
/**
* {@inheritdoc}
*/
public function broadcast(object $entity, string $action, array $options): void
{
foreach ($this->broadcasters as $broadcaster) {
$broadcaster->broadcast($entity, $action);
$broadcaster->broadcast($entity, $action, $options);
}
}
}
4 changes: 1 addition & 3 deletions src/Turbo/DependencyInjection/TurboExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
use Symfony\Component\Mercure\Hub;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface;
use Symfony\UX\Turbo\Doctrine\BroadcastListener;
use Symfony\UX\Turbo\Mercure\Broadcaster;
Expand Down Expand Up @@ -113,7 +112,6 @@ private function registerMercureTransports(array $config, ContainerBuilder $cont
$missingDeps = array_filter([
'symfony/mercure-bundle' => !class_exists(MercureBundle::class),
'symfony/twig-pack' => !class_exists(TwigBundle::class),
'symfony/property-access' => !interface_exists(PropertyAccessorInterface::class),
]);

if ($missingDeps) {
Expand Down Expand Up @@ -150,7 +148,7 @@ private function registerMercureTransport(ContainerBuilder $container, array $co
$broadcaster = $container->setDefinition("turbo.mercure.{$name}.broadcaster", new ChildDefinition(Broadcaster::class));
$broadcaster->replaceArgument(0, $name);
$broadcaster->replaceArgument(2, new Reference($hubId));
$broadcaster->replaceArgument(4, $config['broadcast']['entity_template_prefixes']);
$broadcaster->replaceArgument(3, $config['broadcast']['entity_template_prefixes']);
$broadcaster->addTag('turbo.broadcaster');
}
}
47 changes: 32 additions & 15 deletions src/Turbo/Doctrine/BroadcastListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
namespace Symfony\UX\Turbo\Doctrine;

use Doctrine\Common\EventArgs;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Event\PostFlushEventArgs;
use Symfony\Contracts\Service\ResetInterface;
use Symfony\UX\Turbo\Attribute\Broadcast;
use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface;
Expand All @@ -34,15 +36,15 @@ final class BroadcastListener implements ResetInterface
private $broadcastedClasses;

/**
* @var \SplObjectStorage<object, object>
* @var \SplObjectStorage<object, array>
*/
private $createdEntities;
/**
* @var \SplObjectStorage<object, object>
* @var \SplObjectStorage<object, array>
*/
private $updatedEntities;
/**
* @var \SplObjectStorage<object, object>
* @var \SplObjectStorage<object, array>
*/
private $removedEntities;

Expand All @@ -66,36 +68,45 @@ public function onFlush(EventArgs $eventArgs): void
return;
}

$uow = $eventArgs->getEntityManager()->getUnitOfWork();
$em = $eventArgs->getEntityManager();
$uow = $em->getUnitOfWork();
foreach ($uow->getScheduledEntityInsertions() as $entity) {
$this->storeEntitiesToPublish($entity, 'createdEntities');
$this->storeEntitiesToPublish($em, $entity, 'createdEntities');
}

foreach ($uow->getScheduledEntityUpdates() as $entity) {
$this->storeEntitiesToPublish($entity, 'updatedEntities');
$this->storeEntitiesToPublish($em, $entity, 'updatedEntities');
}

foreach ($uow->getScheduledEntityDeletions() as $entity) {
$this->storeEntitiesToPublish($entity, 'removedEntities');
$this->storeEntitiesToPublish($em, $entity, 'removedEntities');
}
}

/**
* Publishes updates for changes collected on flush, and resets the store.
*/
public function postFlush(): void
public function postFlush(EventArgs $eventArgs): void
{
if (!$eventArgs instanceof PostFlushEventArgs) {
return;
}

$em = $eventArgs->getEntityManager();

try {
foreach ($this->createdEntities as $entity) {
$this->broadcaster->broadcast($entity, Broadcast::ACTION_CREATE);
$options = $this->createdEntities[$entity];
$options['id'] = $em->getClassMetadata(\get_class($entity))->getIdentifierValues($entity);
$this->broadcaster->broadcast($entity, Broadcast::ACTION_CREATE, $options);
}

foreach ($this->updatedEntities as $entity) {
$this->broadcaster->broadcast($entity, Broadcast::ACTION_UPDATE);
$this->broadcaster->broadcast($entity, Broadcast::ACTION_UPDATE, $this->updatedEntities[$entity]);
}

foreach ($this->removedEntities as $entity) {
$this->broadcaster->broadcast($entity, Broadcast::ACTION_REMOVE);
$this->broadcaster->broadcast($entity, Broadcast::ACTION_REMOVE, $this->removedEntities[$entity]);
}
} finally {
$this->reset();
Expand All @@ -109,14 +120,20 @@ public function reset(): void
$this->removedEntities = new \SplObjectStorage();
}

private function storeEntitiesToPublish(object $entity, string $property): void
private function storeEntitiesToPublish(EntityManagerInterface $em, object $entity, string $property): void
{
$class = \get_class($entity);

$this->broadcastedClasses[$class] ?? $this->broadcastedClasses[$class] = (new \ReflectionClass($class))->getAttributes(Broadcast::class);

if ($this->broadcastedClasses[$class]) {
$this->{$property}->attach('removedEntities' === $property ? clone $entity : $entity);
if ($attribute = $this->broadcastedClasses[$class][0] ?? false) {
/**
* @var Broadcast $options
*/
$options = $attribute->newInstance();
if ('createdEntities' !== $property) {
$options->options['id'] = $em->getClassMetadata($class)->getIdentifierValues($entity);
}
$this->{$property}->attach($entity, $options->options);
}
}
}
53 changes: 30 additions & 23 deletions src/Turbo/Mercure/Broadcaster.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,14 @@
*
* Supported options are:
*
* * id (string[]) The (potentially composite) identifier of the broadcasted entity
* * transports (string[]) The name of the transports to broadcast to
* * topics (string[]) The topics to use; the default topic is derived from the FQCN of the entity and from its id
* * template (string) The Twig template to render when a new object is created, updated or removed
* * private (bool) Marks Mercure updates as private
* * id (string) ID field of the SSE
* * type (string) type field of the SSE
* * retry (int) retry field of the SSE
* * sse_id (string) ID field of the SSE
* * sse_type (string) type field of the SSE
* * sse_retry (int) retry field of the SSE
*
* @author Kévin Dunglas <kevin@dunglas.fr>
*
Expand All @@ -51,59 +52,55 @@ final class Broadcaster implements BroadcasterInterface

private const OPTIONS = [
// Generic options
'id',
'transports',
// Twig options
'template',
// Mercure options
'topics',
'private',
'id',
'type',
'retry',
'sse_id',
'sse_type',
'sse_retry',
];

/**
* @param array<string, string> $templatePrefixes
*/
public function __construct(string $name, Environment $twig, HubInterface $hub, ?PropertyAccessorInterface $propertyAccessor, array $templatePrefixes = [])
public function __construct(string $name, Environment $twig, HubInterface $hub, array $templatePrefixes = [], PropertyAccessorInterface $propertyAccessor = null)
{
if (80000 > \PHP_VERSION_ID) {
throw new \LogicException('The broadcast feature requires PHP 8.0 or greater, you must either upgrade to PHP 8 or disable it.');
}

$this->name = $name;
$this->twig = $twig;
$this->propertyAccessor = $propertyAccessor ?? PropertyAccess::createPropertyAccessor();
$this->hub = $hub;
$this->templatePrefixes = $templatePrefixes;
$this->propertyAccessor = $propertyAccessor ?? (class_exists(PropertyAccess::class) ? PropertyAccess::createPropertyAccessor() : null);
}

public function broadcast(object $entity, string $action): void
/**
* {@inheritdoc}
*/
public function broadcast(object $entity, string $action, array $options): void
{
if (!$attribute = (new \ReflectionClass($entity))->getAttributes(Broadcast::class)[0] ?? null) {
return;
}

/**
* @var Broadcast $broadcast
*/
$broadcast = $attribute->newInstance();
$options = $this->normalizeOptions($entity, $action, $broadcast->options);
$options = $this->normalizeOptions($entity, $action, $options);

if (isset($options['transports']) && !\in_array($this->name, $options['transports'], true)) {
return;
}

// Will throw if the template or the block doesn't exist
$data = $this->twig->load($options['template'])->renderBlock($action, ['entity' => $entity, 'action' => $action, 'options' => $options]);
$data = $this->twig->load($options['template'])->renderBlock($action, ['entity' => $entity, 'action' => $action] + $options);

$update = new Update(
$options['topics'],
$data,
$options['private'] ?? false,
$options['id'] ?? null,
$options['type'] ?? null,
$options['retry'] ?? null
$options['sse_id'] ?? null,
$options['sse_type'] ?? null,
$options['sse_retry'] ?? null
);

$this->hub->publish($update);
Expand All @@ -126,7 +123,17 @@ private function normalizeOptions(object $entity, string $action, array $options
throw new \InvalidArgumentException(sprintf('Unknown broadcast options "%s" on class "%s". Valid options are: "%s"', implode('", "', $extraKeys), $entityClass, implode('", "', self::OPTIONS)));
}

$options['topics'] = (array) ($options['topics'] ?? sprintf(self::TOPIC_PATTERN, rawurlencode($entityClass), rawurlencode($this->propertyAccessor->getValue($entity, 'id'))));
if (isset($options['id'])) {
$options['id'] = \is_array($options['id']) ? implode('-', $options['id']) : $options['id'];
} elseif (!isset($options['topics'])) {
if (!$this->propertyAccessor) {
throw new \InvalidArgumentException(sprintf('Cannot broadcast entity of class "%s": either option "topics" or "id" is missing, or the PropertyAccess component is not installed. Try running "composer require property-access".', $entityClass));
}

$options['id'] = $this->propertyAccessor->getValue($entity, 'id');
}

$options['topics'] = (array) ($options['topics'] ?? sprintf(self::TOPIC_PATTERN, rawurlencode($entityClass), rawurlencode($options['id'])));
if (isset($options['template'])) {
return $options;
}
Expand Down
19 changes: 16 additions & 3 deletions src/Turbo/Mercure/TurboStreamListenRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

namespace Symfony\UX\Turbo\Mercure;

use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
Expand All @@ -28,18 +29,30 @@ final class TurboStreamListenRenderer implements TurboStreamListenRendererInterf
private $hub;
private $stimulusTwigExtension;
private $propertyAccessor;
private $doctrine;

public function __construct(HubInterface $hub, StimulusTwigExtension $stimulusTwigExtension, ?PropertyAccessorInterface $propertyAccessor)
public function __construct(HubInterface $hub, StimulusTwigExtension $stimulusTwigExtension, PropertyAccessorInterface $propertyAccessor = null, ManagerRegistry $doctrine = null)
{
$this->hub = $hub;
$this->stimulusTwigExtension = $stimulusTwigExtension;
$this->propertyAccessor = $propertyAccessor ?? PropertyAccess::createPropertyAccessor();
$this->propertyAccessor = $propertyAccessor ?? (class_exists(PropertyAccess::class) ? PropertyAccess::createPropertyAccessor() : null);
$this->doctrine = $doctrine;
}

public function renderTurboStreamListen(Environment $env, $topic): string
{
if (\is_object($topic)) {
$topic = sprintf(Broadcaster::TOPIC_PATTERN, rawurlencode(\get_class($topic)), rawurlencode($this->propertyAccessor->getValue($topic, 'id')));
$class = \get_class($topic);

if ($this->doctrine && $em = $this->doctrine->getManagerForClass($class)) {
$id = implode('-', $em->getClassMetadata($class)->getIdentifierValues($topic));
} elseif ($this->propertyAccessor) {
$id = $this->propertyAccessor->getValue($topic, 'id');
} else {
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($id));
} 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}');
Expand Down
9 changes: 5 additions & 4 deletions src/Turbo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,8 @@ are passed to the template as variables: `entity`, `action` and `options`.

### Broadcast Conventions and Configuration

When using the Mercure transport, the entity class **must** have a public property named `id` or a public method named `getId()`.
When using the Mercure transport, entities have to either be managed by Doctrine ORM,
have a public property named `id`, or have a public method named `getId()`.

Symfony UX Turbo will look for a template named after mapping their Fully Qualified Class Names.
For example and by default, if a class marked with the `Broadcast` attribute is named `App\Entity\Foo`,
Expand Down Expand Up @@ -434,9 +435,9 @@ Options are transport-sepcific.
When using Mercure, some extra options are supported:

- `private` (`bool`): marks Mercure updates as private
- `id` (`string`): `id` field of the SSE
- `type` (`string`): `type` field of the SSE
- `retry` (`int`): `retry` field of the SSE
- `sse_id` (`string`): `id` field of the SSE
- `sse_type` (`string`): `type` field of the SSE
- `sse_retry` (`int`): `retry` field of the SSE

Example:

Expand Down
5 changes: 3 additions & 2 deletions src/Turbo/Resources/config/mercure.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,17 @@
abstract_arg('name'),
service('twig'),
abstract_arg('publisher'),
service('property_accessor'),
abstract_arg('entity template prefixes'),
service('property_accessor')->nullOnInvalid(),
])

->set(TurboStreamListenRenderer::class)
->abstract()
->args([
abstract_arg('hub'),
service('webpack_encore.twig_stimulus_extension'),
service('property_accessor'),
service('property_accessor')->nullOnInvalid(),
service('doctrine')->nullOnInvalid(),
])
;
};
2 changes: 2 additions & 0 deletions src/Turbo/Tests/app/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
use Doctrine\Bundle\DoctrineBundle\Mapping\MappingDriver;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\DebugBundle\DebugBundle;
use Symfony\Bundle\FrameworkBundle\Controller\TemplateController;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
Expand Down Expand Up @@ -55,6 +56,7 @@ public function registerBundles(): iterable
yield new TurboBundle();
yield new WebpackEncoreBundle();
yield new WebProfilerBundle();
yield new DebugBundle();
}

protected function configureContainer(ContainerConfigurator $container): void
Expand Down
8 changes: 4 additions & 4 deletions src/Turbo/Tests/app/templates/broadcast/Book.stream.html.twig
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
{% block create %}
<turbo-stream action="append" target="books">
<template>
<div id="{{ 'book_' ~ entity.id }}">{{ entity.title }} (#{{ entity.id }})</div>
<div id="{{ 'book_' ~ id }}">{{ entity.title }} (#{{ id }})</div>
</template>
</turbo-stream>
{% endblock %}

{% block update %}
<turbo-stream action="update" target="book_{{ entity.id }}">
<turbo-stream action="update" target="book_{{ id }}">
<template>
{{ entity.title }} (#{{ entity.id }}, updated)
{{ entity.title }} (#{{ id }}, updated)
</template>
</turbo-stream>
{% endblock %}

{% block remove %}
<turbo-stream action="remove" target="book_{{ entity.id }}"></turbo-stream>
<turbo-stream action="remove" target="book_{{ id }}"></turbo-stream>
{% endblock %}

0 comments on commit f3d1711

Please sign in to comment.