Skip to content

Commit 6d4a831

Browse files
soyukaclaude
andcommitted
fix(symfony): publish mercure updates for all resources of an entity
| Q | A | ------------- | --- | Branch? | fix/multiple-mercure | Tickets | ∅ | License | MIT | Doc PR | ∅ * SplObjectStorage keyed by entity only allowed one update per entity; replaced with array to support multiple entries per resource * storeObjectToPublish iterates all resources in the metadata collection and stores a separate entry for each one that has mercure enabled * publishUpdate uses the specific operation for normalization context and IRI resolution Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a2efb39 commit 6d4a831

File tree

3 files changed

+219
-81
lines changed

3 files changed

+219
-81
lines changed

src/Symfony/Doctrine/EventListener/PublishMercureUpdatesListener.php

Lines changed: 77 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616
use ApiPlatform\Doctrine\Common\Messenger\DispatchTrait;
1717
use ApiPlatform\GraphQl\Subscription\MercureSubscriptionIriGeneratorInterface as GraphQlMercureSubscriptionIriGeneratorInterface;
1818
use ApiPlatform\GraphQl\Subscription\SubscriptionManagerInterface as GraphQlSubscriptionManagerInterface;
19+
use ApiPlatform\Metadata\CollectionOperationInterface;
1920
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
20-
use ApiPlatform\Metadata\Exception\OperationNotFoundException;
2121
use ApiPlatform\Metadata\Exception\RuntimeException;
2222
use ApiPlatform\Metadata\HttpOperation;
2323
use ApiPlatform\Metadata\IriConverterInterface;
@@ -58,9 +58,12 @@ final class PublishMercureUpdatesListener
5858
'enable_async_update' => true,
5959
];
6060
private readonly ?ExpressionLanguage $expressionLanguage;
61-
private \SplObjectStorage $createdObjects;
62-
private \SplObjectStorage $updatedObjects;
63-
private \SplObjectStorage $deletedObjects;
61+
/** @var list<array{object: object, options: array, operation: ?Operation}> */
62+
private array $createdObjects;
63+
/** @var list<array{object: object, options: array, operation: ?Operation}> */
64+
private array $updatedObjects;
65+
/** @var list<array{object: object, options: array, operation: ?Operation}> */
66+
private array $deletedObjects;
6467

6568
/**
6669
* @param array<string, string[]|string> $formats
@@ -127,40 +130,30 @@ public function onFlush(EventArgs $eventArgs): void
127130
public function postFlush(): void
128131
{
129132
try {
130-
$creatingObjects = clone $this->createdObjects;
131-
foreach ($creatingObjects as $object) {
132-
if ($this->createdObjects->offsetExists($object)) {
133-
$this->createdObjects->offsetUnset($object);
134-
}
135-
$this->publishUpdate($object, $creatingObjects[$object], 'create');
133+
foreach ($this->createdObjects as $entry) {
134+
$this->publishUpdate($entry['object'], $entry['options'], 'create', $entry['operation']);
136135
}
136+
$this->createdObjects = [];
137137

138-
$updatingObjects = clone $this->updatedObjects;
139-
foreach ($updatingObjects as $object) {
140-
if ($this->updatedObjects->offsetExists($object)) {
141-
$this->updatedObjects->offsetUnset($object);
142-
}
143-
$this->publishUpdate($object, $updatingObjects[$object], 'update');
138+
foreach ($this->updatedObjects as $entry) {
139+
$this->publishUpdate($entry['object'], $entry['options'], 'update', $entry['operation']);
144140
}
141+
$this->updatedObjects = [];
145142

146-
$deletingObjects = clone $this->deletedObjects;
147-
foreach ($deletingObjects as $object) {
148-
$options = $this->deletedObjects[$object];
149-
if ($this->deletedObjects->offsetExists($object)) {
150-
$this->deletedObjects->offsetUnset($object);
151-
}
152-
$this->publishUpdate($object, $deletingObjects[$object], 'delete');
143+
foreach ($this->deletedObjects as $entry) {
144+
$this->publishUpdate($entry['object'], $entry['options'], 'delete', $entry['operation']);
153145
}
146+
$this->deletedObjects = [];
154147
} finally {
155148
$this->reset();
156149
}
157150
}
158151

159152
private function reset(): void
160153
{
161-
$this->createdObjects = new \SplObjectStorage();
162-
$this->updatedObjects = new \SplObjectStorage();
163-
$this->deletedObjects = new \SplObjectStorage();
154+
$this->createdObjects = [];
155+
$this->updatedObjects = [];
156+
$this->deletedObjects = [];
164157
}
165158

166159
private function storeObjectToPublish(object $object, string $property): void
@@ -169,63 +162,79 @@ private function storeObjectToPublish(object $object, string $property): void
169162
return;
170163
}
171164

172-
$operation = $this->resourceMetadataFactory->create($resourceClass)->getOperation();
173-
try {
174-
$options = $operation->getMercure() ?? false;
175-
} catch (OperationNotFoundException) {
176-
return;
177-
}
165+
$resourceMetadataCollection = $this->resourceMetadataFactory->create($resourceClass);
178166

179-
if (\is_string($options)) {
180-
if (null === $this->expressionLanguage) {
181-
throw new RuntimeException('The Expression Language component is not installed. Try running "composer require symfony/expression-language".');
167+
foreach ($resourceMetadataCollection as $resourceMetadata) {
168+
/** @var ?HttpOperation $operation */
169+
$operation = null;
170+
foreach ($resourceMetadata->getOperations() ?? [] as $op) {
171+
if (!$op instanceof CollectionOperationInterface) {
172+
$operation = $op;
173+
break;
174+
}
182175
}
183176

184-
$options = $this->expressionLanguage->evaluate($options, ['object' => $object]);
185-
}
177+
if (null === $operation) {
178+
continue;
179+
}
186180

187-
if (false === $options) {
188-
return;
189-
}
181+
$options = $operation->getMercure() ?? false;
190182

191-
if (true === $options) {
192-
$options = [];
193-
}
183+
if (\is_string($options)) {
184+
if (null === $this->expressionLanguage) {
185+
throw new RuntimeException('The Expression Language component is not installed. Try running "composer require symfony/expression-language".');
186+
}
194187

195-
if (!\is_array($options)) {
196-
throw new InvalidArgumentException(\sprintf('The value of the "mercure" attribute of the "%s" resource class must be a boolean, an array of options or an expression returning this array, "%s" given.', $resourceClass, \gettype($options)));
197-
}
188+
$options = $this->expressionLanguage->evaluate($options, ['object' => $object]);
189+
}
198190

199-
foreach ($options as $key => $value) {
200-
if (!isset(self::ALLOWED_KEYS[$key])) {
201-
throw new InvalidArgumentException(\sprintf('The option "%s" set in the "mercure" attribute of the "%s" resource does not exist. Existing options: "%s"', $key, $resourceClass, implode('", "', array_keys(self::ALLOWED_KEYS))));
191+
if (false === $options) {
192+
continue;
202193
}
203-
}
204194

205-
$options['enable_async_update'] ??= true;
195+
if (true === $options) {
196+
$options = [];
197+
}
206198

207-
if ('deletedObjects' === $property) {
208-
$types = $operation instanceof HttpOperation ? $operation->getTypes() : null;
209-
if (null === $types) {
210-
$types = [$operation->getShortName()];
199+
if (!\is_array($options)) {
200+
throw new InvalidArgumentException(\sprintf('The value of the "mercure" attribute of the "%s" resource class must be a boolean, an array of options or an expression returning this array, "%s" given.', $resourceClass, \gettype($options)));
211201
}
212202

213-
// We need to evaluate it here, because in publishUpdate() the resource would be already deleted
214-
$this->evaluateTopics($options, $object);
203+
foreach ($options as $key => $value) {
204+
if (!isset(self::ALLOWED_KEYS[$key])) {
205+
throw new InvalidArgumentException(\sprintf('The option "%s" set in the "mercure" attribute of the "%s" resource does not exist. Existing options: "%s"', $key, $resourceClass, implode('", "', array_keys(self::ALLOWED_KEYS))));
206+
}
207+
}
215208

216-
$this->deletedObjects[(object) [
217-
'id' => $this->iriConverter->getIriFromResource($object),
218-
'iri' => $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL),
219-
'type' => 1 === \count($types) ? $types[0] : $types,
220-
]] = $options;
209+
$options['enable_async_update'] ??= true;
221210

222-
return;
223-
}
211+
if ('deletedObjects' === $property) {
212+
$types = $operation->getTypes();
213+
if (null === $types) {
214+
$types = [$operation->getShortName()];
215+
}
216+
217+
// We need to evaluate it here, because in publishUpdate() the resource would be already deleted
218+
$this->evaluateTopics($options, $object);
219+
220+
$this->deletedObjects[] = [
221+
'object' => (object) [
222+
'id' => $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation),
223+
'iri' => $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL, $operation),
224+
'type' => 1 === \count($types) ? $types[0] : $types,
225+
],
226+
'options' => $options,
227+
'operation' => $operation,
228+
];
224229

225-
$this->{$property}[$object] = $options;
230+
continue;
231+
}
232+
233+
$this->{$property}[] = ['object' => $object, 'options' => $options, 'operation' => $operation];
234+
}
226235
}
227236

228-
private function publishUpdate(object $object, array $options, string $type): void
237+
private function publishUpdate(object $object, array $options, string $type, ?Operation $operation = null): void
229238
{
230239
if ($object instanceof \stdClass) {
231240
// By convention, if the object has been deleted, we send only its IRI and its type.
@@ -235,13 +244,12 @@ private function publishUpdate(object $object, array $options, string $type): vo
235244
/** @var non-empty-string $data */
236245
$data = json_encode(['@id' => $object->id] + ($this->includeType ? ['@type' => $object->type] : []), \JSON_THROW_ON_ERROR);
237246
} else {
238-
$resourceClass = $this->getObjectClass($object);
239-
$context = $options['normalization_context'] ?? $this->resourceMetadataFactory->create($resourceClass)->getOperation()->getNormalizationContext() ?? [];
247+
$context = $options['normalization_context'] ?? $operation?->getNormalizationContext() ?? [];
240248

241249
// We need to evaluate it here, because in storeObjectToPublish() the resource would not have been persisted yet
242250
$this->evaluateTopics($options, $object);
243251

244-
$iri = $options['topics'] ?? $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL);
252+
$iri = $options['topics'] ?? $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL, $operation);
245253
$data = $options['data'] ?? $this->serializer->serialize($object, key($this->formats), $context);
246254
}
247255

0 commit comments

Comments
 (0)