From 46a02914b1565468f964d108c7a71476fb3818c3 Mon Sep 17 00:00:00 2001 From: psihius Date: Thu, 9 Jan 2025 00:10:47 +0200 Subject: [PATCH 1/7] feat(graphql): added support for graphql subscriptions to work for all mutation types --- .../SubscriptionIdentifierGenerator.php | 14 ++ .../Subscription/SubscriptionManager.php | 145 ++++++++++++++++-- .../SubscriptionManagerInterface.php | 2 +- .../PublishMercureUpdatesListener.php | 5 +- 4 files changed, 146 insertions(+), 20 deletions(-) diff --git a/src/GraphQl/Subscription/SubscriptionIdentifierGenerator.php b/src/GraphQl/Subscription/SubscriptionIdentifierGenerator.php index 44afd26aa95..592f90aceba 100644 --- a/src/GraphQl/Subscription/SubscriptionIdentifierGenerator.php +++ b/src/GraphQl/Subscription/SubscriptionIdentifierGenerator.php @@ -23,7 +23,21 @@ final class SubscriptionIdentifierGenerator implements SubscriptionIdentifierGen public function generateSubscriptionIdentifier(array $fields): string { unset($fields['mercureUrl'], $fields['clientSubscriptionId']); + $fields = $this->removeTypename($fields); return hash('sha256', print_r($fields, true)); } + + private function removeTypename(array $data): array + { + foreach ($data as $key => $value) { + if ($key === '__typename') { + unset($data[$key]); + } elseif (is_array($value)) { + $data[$key] = $this->removeTypename($value); + } + } + + return $data; + } } diff --git a/src/GraphQl/Subscription/SubscriptionManager.php b/src/GraphQl/Subscription/SubscriptionManager.php index 8e2532aa33e..5592f111fde 100644 --- a/src/GraphQl/Subscription/SubscriptionManager.php +++ b/src/GraphQl/Subscription/SubscriptionManager.php @@ -42,14 +42,24 @@ public function __construct(private readonly CacheItemPoolInterface $subscriptio public function retrieveSubscriptionId(array $context, ?array $result, ?Operation $operation = null): ?string { + /** @var ResolveInfo $info */ $info = $context['info']; $fields = $info->getFieldSelection(\PHP_INT_MAX); $this->arrayRecursiveSort($fields, 'ksort'); $iri = $operation ? $this->getIdentifierFromOperation($operation, $context['args'] ?? []) : $this->getIdentifierFromContext($context); - if (null === $iri) { + if (empty($iri)) { return null; } + $options = $operation->getMercure() ?? false; + $private = $options['private'] ?? false; + $privateFields = $options['private_fields'] ?? []; + $previousObject = $context['graphql_context']['previous_object'] ?? null; + if ($private && $privateFields && $previousObject) { + foreach ($options['private_fields'] as $privateField) { + $fields['__private_field_'.$privateField] = $this->getResourceId($privateField, $previousObject); + } + } $subscriptionsCacheItem = $this->subscriptionsCache->getItem($this->encodeIriToCacheKey($iri)); $subscriptions = []; if ($subscriptionsCacheItem->isHit()) { @@ -63,26 +73,129 @@ public function retrieveSubscriptionId(array $context, ?array $result, ?Operatio $subscriptionId = $this->subscriptionIdentifierGenerator->generateSubscriptionIdentifier($fields); unset($result['clientSubscriptionId']); + if ($private && $privateFields && $previousObject) { + foreach ($options['private_fields'] as $privateField) { + unset($result['__private_field_'.$privateField]); + } + } $subscriptions[] = [$subscriptionId, $fields, $result]; $subscriptionsCacheItem->set($subscriptions); $this->subscriptionsCache->save($subscriptionsCacheItem); + $this->updateSubscriptionCollectionCacheData( + $iri, + $fields, + $subscriptions, + ); + return $subscriptionId; } - public function getPushPayloads(object $object): array + public function getPushPayloads(object $object, string $type): array + { + if ('delete' === $type) { + $payloads = $this->getDeletePushPayloads($object); + } else { + $payloads = $this->getCreatedOrUpdatedPayloads($object); + } + + return $payloads; + } + + /** + * @return array + */ + private function getSubscriptionsFromIri(string $iri): array + { + $subscriptionsCacheItem = $this->subscriptionsCache->getItem($this->encodeIriToCacheKey($iri)); + + if ($subscriptionsCacheItem->isHit()) { + return $subscriptionsCacheItem->get(); + } + + return []; + } + + private function removeItemFromSubscriptionCache(string $iri): void + { + $cacheKey = $this->encodeIriToCacheKey($iri); + if ($this->subscriptionsCache->hasItem($cacheKey)) { + $this->subscriptionsCache->deleteItem($cacheKey); + } + } + + private function encodeIriToCacheKey(string $iri): string + { + return str_replace('/', '_', $iri); + } + + private function updateSubscriptionCollectionCacheData( + ?string $iri, + array $fields, + array $subscriptions, + ): void + { + $subscriptionCollectionCacheItem = $this->subscriptionsCache->getItem( + $this->encodeIriToCacheKey($this->getCollectionIri($iri)), + ); + if ($subscriptionCollectionCacheItem->isHit()) { + $collectionSubscriptions = $subscriptionCollectionCacheItem->get(); + foreach ($collectionSubscriptions as [$subscriptionId, $subscriptionFields, $subscriptionResult]) { + if ($subscriptionFields === $fields) { + return; + } + } + } + $subscriptionCollectionCacheItem->set($subscriptions); + $this->subscriptionsCache->save($subscriptionCollectionCacheItem); + } + + private function getResourceId(mixed $privateField, object $previousObject): string + { + $id = $previousObject->{'get' . ucfirst($privateField)}()->getId(); + if ($id instanceof \Stringable) { + return (string)$id; + } + return $id; + } + + private function getCollectionIri(string $iri): string + { + return substr($iri, 0, strrpos($iri, '/')); + } + + private function getCreatedOrUpdatedPayloads(object $object): array { $iri = $this->iriConverter->getIriFromResource($object); $subscriptions = $this->getSubscriptionsFromIri($iri); + if ($subscriptions === []) { + // Get subscriptions from collection Iri + $subscriptions = $this->getSubscriptionsFromIri($this->getCollectionIri($iri)); + } $resourceClass = $this->getObjectClass($object); $resourceMetadata = $this->resourceMetadataCollectionFactory->create($resourceClass); $shortName = $resourceMetadata->getOperation()->getShortName(); + $mercure = $resourceMetadata->getOperation()->getMercure() ?? false; + $private = $mercure['private'] ?? false; + $privateFieldsConfig = $mercure['private_fields'] ?? []; + $privateFieldData = []; + if ($private && $privateFieldsConfig) { + foreach ($privateFieldsConfig as $privateField) { + $privateFieldData['__private_field_'.$privateField] = $this->getResourceId($privateField, $object); + } + } + $payloads = []; foreach ($subscriptions as [$subscriptionId, $subscriptionFields, $subscriptionResult]) { + if ($privateFieldData) { + $fieldDiff = array_intersect_assoc($subscriptionFields, $privateFieldData); + if ($fieldDiff !== $privateFieldData) { + continue; + } + } $resolverContext = ['fields' => $subscriptionFields, 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true]; - /** @var Operation */ $operation = (new Subscription())->withName('update_subscription')->withShortName($shortName); $data = $this->normalizeProcessor->process($object, $operation, [], $resolverContext); @@ -92,26 +205,24 @@ public function getPushPayloads(object $object): array $payloads[] = [$subscriptionId, $data]; } } - return $payloads; } - /** - * @return array - */ - private function getSubscriptionsFromIri(string $iri): array + private function getDeletePushPayloads(object $object): array { - $subscriptionsCacheItem = $this->subscriptionsCache->getItem($this->encodeIriToCacheKey($iri)); - - if ($subscriptionsCacheItem->isHit()) { - return $subscriptionsCacheItem->get(); + $iri = $object->id; + $subscriptions = $this->getSubscriptionsFromIri($iri); + if ($subscriptions === []) { + // Get subscriptions from collection Iri + $subscriptions = $this->getSubscriptionsFromIri($this->getCollectionIri($iri)); } - return []; + $payloads = []; + foreach ($subscriptions as [$subscriptionId, $subscriptionFields, $subscriptionResult]) { + $payloads[] = [$subscriptionId, ['type' => 'delete', 'payload' => $object]]; + } + $this->removeItemFromSubscriptionCache($iri); + return $payloads; } - private function encodeIriToCacheKey(string $iri): string - { - return str_replace('/', '_', $iri); - } } diff --git a/src/GraphQl/Subscription/SubscriptionManagerInterface.php b/src/GraphQl/Subscription/SubscriptionManagerInterface.php index 4064f068010..91c89049481 100644 --- a/src/GraphQl/Subscription/SubscriptionManagerInterface.php +++ b/src/GraphQl/Subscription/SubscriptionManagerInterface.php @@ -22,5 +22,5 @@ interface SubscriptionManagerInterface { public function retrieveSubscriptionId(array $context, ?array $result): ?string; - public function getPushPayloads(object $object): array; + public function getPushPayloads(object $object, string $type): array; } diff --git a/src/Symfony/Doctrine/EventListener/PublishMercureUpdatesListener.php b/src/Symfony/Doctrine/EventListener/PublishMercureUpdatesListener.php index f0f9a213a6d..0a3ca6f9fa6 100644 --- a/src/Symfony/Doctrine/EventListener/PublishMercureUpdatesListener.php +++ b/src/Symfony/Doctrine/EventListener/PublishMercureUpdatesListener.php @@ -50,6 +50,7 @@ final class PublishMercureUpdatesListener 'topics' => true, 'data' => true, 'private' => true, + 'private_fields' => true, 'id' => true, 'type' => true, 'retry' => true, @@ -293,11 +294,11 @@ private function evaluateTopics(array &$options, object $object): void */ private function getGraphQlSubscriptionUpdates(object $object, array $options, string $type): array { - if ('update' !== $type || !$this->graphQlSubscriptionManager || !$this->graphQlMercureSubscriptionIriGenerator) { + if (!$this->graphQlSubscriptionManager || !$this->graphQlMercureSubscriptionIriGenerator) { return []; } - $payloads = $this->graphQlSubscriptionManager->getPushPayloads($object); + $payloads = $this->graphQlSubscriptionManager->getPushPayloads($object, $type); $updates = []; foreach ($payloads as [$subscriptionId, $data]) { From c9cefd82e53a40a02e14dbe43d0b963fc695b77e Mon Sep 17 00:00:00 2001 From: psihius Date: Fri, 17 Jan 2025 13:43:10 +0200 Subject: [PATCH 2/7] feat(graphql): adjusted some of the formatting and method placement to have smaller diff --- src/GraphQl/Subscription/SubscriptionManager.php | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/GraphQl/Subscription/SubscriptionManager.php b/src/GraphQl/Subscription/SubscriptionManager.php index 5592f111fde..e8d77977f58 100644 --- a/src/GraphQl/Subscription/SubscriptionManager.php +++ b/src/GraphQl/Subscription/SubscriptionManager.php @@ -42,7 +42,6 @@ public function __construct(private readonly CacheItemPoolInterface $subscriptio public function retrieveSubscriptionId(array $context, ?array $result, ?Operation $operation = null): ?string { - /** @var ResolveInfo $info */ $info = $context['info']; $fields = $info->getFieldSelection(\PHP_INT_MAX); @@ -124,11 +123,6 @@ private function removeItemFromSubscriptionCache(string $iri): void } } - private function encodeIriToCacheKey(string $iri): string - { - return str_replace('/', '_', $iri); - } - private function updateSubscriptionCollectionCacheData( ?string $iri, array $fields, @@ -225,4 +219,8 @@ private function getDeletePushPayloads(object $object): array return $payloads; } + private function encodeIriToCacheKey(string $iri): string + { + return str_replace('/', '_', $iri); + } } From e764f2fbf19ba26813bcc2c43ed4c01db902f3ae Mon Sep 17 00:00:00 2001 From: psihius Date: Thu, 24 Apr 2025 14:52:07 +0300 Subject: [PATCH 3/7] feat(graphql): reworked how collection subscriptions work, added opt in mechanism --- src/GraphQl/Serializer/ItemNormalizer.php | 42 ++++++ .../State/Processor/SubscriptionProcessor.php | 4 + .../Subscription/SubscriptionManager.php | 133 +++++++++++------- src/GraphQl/Type/TypeBuilder.php | 1 + src/Metadata/GraphQl/Subscription.php | 114 ++++++++------- 5 files changed, 188 insertions(+), 106 deletions(-) diff --git a/src/GraphQl/Serializer/ItemNormalizer.php b/src/GraphQl/Serializer/ItemNormalizer.php index 06ea9df299a..7b33da81307 100644 --- a/src/GraphQl/Serializer/ItemNormalizer.php +++ b/src/GraphQl/Serializer/ItemNormalizer.php @@ -16,6 +16,7 @@ use ApiPlatform\GraphQl\State\Provider\NoopProvider; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; use ApiPlatform\Metadata\IdentifiersExtractorInterface; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; @@ -26,6 +27,7 @@ use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\Serializer\CacheKeyTrait; use ApiPlatform\Serializer\ItemNormalizer as BaseItemNormalizer; +use Doctrine\Common\Collections\Collection; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; @@ -106,6 +108,11 @@ public function normalize(mixed $object, ?string $format = null, array $context $data[self::ITEM_IDENTIFIERS_KEY] = $this->identifiersExtractor->getIdentifiersFromItem($object, $context['operation'] ?? null); } + if ($context['graphql_operation_name'] === 'mercure_subscription' && is_object($object) && isset($data['id']) && !isset($data['_id'])) { + $data['_id'] = $data['id']; + $data['id'] = $this->iriConverter->getIriFromResource($object); + } + return $data; } @@ -120,10 +127,45 @@ protected function normalizeCollectionOfRelations(ApiProperty $propertyMetadata, return [...$attributeValue]; } + // Handle relationships for mercure subscriptions + if ($operation instanceof QueryCollection && $context['graphql_operation_name'] === 'mercure_subscription' && $attributeValue instanceof Collection && !$attributeValue->isEmpty()) { + $relationContext = $context; + // Grab collection attributes + $relationContext['attributes'] = $context['attributes']['collection']; + // Iterate over the collection and normalize each item + $data['collection'] = $attributeValue + ->map(fn($item) => $this->normalize($item, $format, $relationContext)) + // Convert the collection to an array + ->toArray(); + // Handle pagination if it's enabled in the query + $data = $this->addPagination($attributeValue, $data, $context); + return $data; + } + // to-many are handled directly by the GraphQL resolver return []; } + private function addPagination(Collection $collection, array $data, array $context): array + { + if ($context['attributes']['paginationInfo'] ?? false) { + $data['paginationInfo'] = []; + if (array_key_exists('hasNextPage', $context['attributes']['paginationInfo'])) { + $data['paginationInfo']['hasNextPage'] = $collection->count() > ($context['pagination']['itemsPerPage'] ?? 10); + } + if (array_key_exists('itemsPerPage', $context['attributes']['paginationInfo'])) { + $data['paginationInfo']['itemsPerPage'] = $context['pagination']['itemsPerPage'] ?? 10; + } + if (array_key_exists('lastPage', $context['attributes']['paginationInfo'])) { + $data['paginationInfo']['lastPage'] = (int) ceil($collection->count() / ($context['pagination']['itemsPerPage'] ?? 10)); + } + if (array_key_exists('totalCount', $context['attributes']['paginationInfo'])) { + $data['paginationInfo']['totalCount'] = $collection->count(); + } + } + return $data; + } + /** * {@inheritdoc} */ diff --git a/src/GraphQl/State/Processor/SubscriptionProcessor.php b/src/GraphQl/State/Processor/SubscriptionProcessor.php index d4389499221..1c6ae28cac2 100644 --- a/src/GraphQl/State/Processor/SubscriptionProcessor.php +++ b/src/GraphQl/State/Processor/SubscriptionProcessor.php @@ -17,6 +17,7 @@ use ApiPlatform\GraphQl\Subscription\OperationAwareSubscriptionManagerInterface; use ApiPlatform\GraphQl\Subscription\SubscriptionManagerInterface; use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation; +use ApiPlatform\Metadata\GraphQl\Subscription; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; @@ -49,6 +50,9 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $hub = \is_array($mercure) ? ($mercure['hub'] ?? null) : null; $data['mercureUrl'] = $this->mercureSubscriptionIriGenerator->generateMercureUrl($subscriptionId, $hub); + if ($operation instanceof Subscription) { + $data['isCollection'] = $operation->isCollection(); + } } return $data; diff --git a/src/GraphQl/Subscription/SubscriptionManager.php b/src/GraphQl/Subscription/SubscriptionManager.php index e8d77977f58..9bcfd46e7b3 100644 --- a/src/GraphQl/Subscription/SubscriptionManager.php +++ b/src/GraphQl/Subscription/SubscriptionManager.php @@ -50,6 +50,7 @@ public function retrieveSubscriptionId(array $context, ?array $result, ?Operatio if (empty($iri)) { return null; } + $options = $operation->getMercure() ?? false; $private = $options['private'] ?? false; $privateFields = $options['private_fields'] ?? []; @@ -59,33 +60,21 @@ public function retrieveSubscriptionId(array $context, ?array $result, ?Operatio $fields['__private_field_'.$privateField] = $this->getResourceId($privateField, $previousObject); } } - $subscriptionsCacheItem = $this->subscriptionsCache->getItem($this->encodeIriToCacheKey($iri)); - $subscriptions = []; - if ($subscriptionsCacheItem->isHit()) { - $subscriptions = $subscriptionsCacheItem->get(); - foreach ($subscriptions as [$subscriptionId, $subscriptionFields, $subscriptionResult]) { - if ($subscriptionFields === $fields) { - return $subscriptionId; - } - } - } - - $subscriptionId = $this->subscriptionIdentifierGenerator->generateSubscriptionIdentifier($fields); - unset($result['clientSubscriptionId']); - if ($private && $privateFields && $previousObject) { - foreach ($options['private_fields'] as $privateField) { - unset($result['__private_field_'.$privateField]); - } + if ($operation->isCollection()) { + $subscriptionId = $this->updateSubscriptionCollectionCacheData( + $iri, + $fields, + ); + } else { + $subscriptionId = $this->updateSubscriptionItemCacheData( + $iri, + $fields, + $result, + $private, + $privateFields, + $previousObject + ); } - $subscriptions[] = [$subscriptionId, $fields, $result]; - $subscriptionsCacheItem->set($subscriptions); - $this->subscriptionsCache->save($subscriptionsCacheItem); - - $this->updateSubscriptionCollectionCacheData( - $iri, - $fields, - $subscriptions, - ); return $subscriptionId; } @@ -123,25 +112,9 @@ private function removeItemFromSubscriptionCache(string $iri): void } } - private function updateSubscriptionCollectionCacheData( - ?string $iri, - array $fields, - array $subscriptions, - ): void + private function encodeIriToCacheKey(string $iri): string { - $subscriptionCollectionCacheItem = $this->subscriptionsCache->getItem( - $this->encodeIriToCacheKey($this->getCollectionIri($iri)), - ); - if ($subscriptionCollectionCacheItem->isHit()) { - $collectionSubscriptions = $subscriptionCollectionCacheItem->get(); - foreach ($collectionSubscriptions as [$subscriptionId, $subscriptionFields, $subscriptionResult]) { - if ($subscriptionFields === $fields) { - return; - } - } - } - $subscriptionCollectionCacheItem->set($subscriptions); - $this->subscriptionsCache->save($subscriptionCollectionCacheItem); + return str_replace('/', '_', $iri); } private function getResourceId(mixed $privateField, object $previousObject): string @@ -161,11 +134,11 @@ private function getCollectionIri(string $iri): string private function getCreatedOrUpdatedPayloads(object $object): array { $iri = $this->iriConverter->getIriFromResource($object); - $subscriptions = $this->getSubscriptionsFromIri($iri); - if ($subscriptions === []) { - // Get subscriptions from collection Iri - $subscriptions = $this->getSubscriptionsFromIri($this->getCollectionIri($iri)); - } + // Add collection subscriptions + $subscriptions = array_merge( + $this->getSubscriptionsFromIri($this->getCollectionIri($iri)), + $this->getSubscriptionsFromIri($iri) + ); $resourceClass = $this->getObjectClass($object); $resourceMetadata = $this->resourceMetadataCollectionFactory->create($resourceClass); @@ -190,7 +163,7 @@ private function getCreatedOrUpdatedPayloads(object $object): array } } $resolverContext = ['fields' => $subscriptionFields, 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true]; - $operation = (new Subscription())->withName('update_subscription')->withShortName($shortName); + $operation = (new Subscription())->withName('mercure_subscription')->withShortName($shortName); $data = $this->normalizeProcessor->process($object, $operation, [], $resolverContext); unset($data['clientSubscriptionId']); @@ -219,8 +192,64 @@ private function getDeletePushPayloads(object $object): array return $payloads; } - private function encodeIriToCacheKey(string $iri): string + private function updateSubscriptionItemCacheData( + string $iri, + array $fields, + ?array $result, + bool $private, + array $privateFields, + ?object $previousObject + ): string { - return str_replace('/', '_', $iri); + $subscriptionsCacheItem = $this->subscriptionsCache->getItem($this->encodeIriToCacheKey($iri)); + $subscriptions = []; + if ($subscriptionsCacheItem->isHit()) { + /* + * @var array, array}> + */ + $subscriptions = $subscriptionsCacheItem->get(); + foreach ($subscriptions as [$subscriptionId, $subscriptionFields, $subscriptionResult]) { + if ($subscriptionFields === $fields) { + return $subscriptionId; + } + } + } + + $subscriptionId = $this->subscriptionIdentifierGenerator->generateSubscriptionIdentifier($fields); + unset($result['clientSubscriptionId']); + if ($private && $privateFields && $previousObject) { + foreach ($privateFields as $privateField) { + unset($result['__private_field_' . $privateField]); + } + } + $subscriptions[] = [$subscriptionId, $fields, $result]; + $subscriptionsCacheItem->set($subscriptions); + $this->subscriptionsCache->save($subscriptionsCacheItem); + return $subscriptionId; + } + + + + private function updateSubscriptionCollectionCacheData( + string $iri, + array $fields, + ): string + { + $subscriptionCollectionCacheItem = $this->subscriptionsCache->getItem( + $this->encodeIriToCacheKey($this->getCollectionIri($iri)), + ); + if ($subscriptionCollectionCacheItem->isHit()) { + $collectionSubscriptions = $subscriptionCollectionCacheItem->get(); + foreach ($collectionSubscriptions as [$subscriptionId, $subscriptionFields]) { + if ($subscriptionFields === $fields) { + return $subscriptionId; + } + } + } + $subscriptionId = $this->subscriptionIdentifierGenerator->generateSubscriptionIdentifier($fields + ['__collection' => true]); + $subscriptions[] = [$subscriptionId, $fields, []]; + $subscriptionCollectionCacheItem->set($subscriptions); + $this->subscriptionsCache->save($subscriptionCollectionCacheItem); + return $subscriptionId; } } diff --git a/src/GraphQl/Type/TypeBuilder.php b/src/GraphQl/Type/TypeBuilder.php index 28680f54eee..0c74502c63b 100644 --- a/src/GraphQl/Type/TypeBuilder.php +++ b/src/GraphQl/Type/TypeBuilder.php @@ -355,6 +355,7 @@ private function getResourceObjectTypeConfiguration(string $shortName, ResourceM if ($operation instanceof Subscription) { $fields['clientSubscriptionId'] = GraphQLType::string(); + $fields['isCollection'] = GraphQLType::boolean(); if ($operation->getMercure()) { $fields['mercureUrl'] = GraphQLType::string(); } diff --git a/src/Metadata/GraphQl/Subscription.php b/src/Metadata/GraphQl/Subscription.php index 59a66fab0c7..31637c62266 100644 --- a/src/Metadata/GraphQl/Subscription.php +++ b/src/Metadata/GraphQl/Subscription.php @@ -76,63 +76,69 @@ public function __construct( mixed $rules = null, ?string $policy = null, array $extraProperties = [], + protected bool $collection = false, ) { parent::__construct( - resolver: $resolver, - args: $args, - extraArgs: $extraArgs, - links: $links, - securityAfterResolver: $securityAfterResolver, - securityMessageAfterResolver: $securityMessageAfterResolver, - shortName: $shortName, - class: $class, - paginationEnabled: $paginationEnabled, - paginationType: $paginationType, - paginationItemsPerPage: $paginationItemsPerPage, - paginationMaximumItemsPerPage: $paginationMaximumItemsPerPage, - paginationPartial: $paginationPartial, - paginationClientEnabled: $paginationClientEnabled, - paginationClientItemsPerPage: $paginationClientItemsPerPage, - paginationClientPartial: $paginationClientPartial, - paginationFetchJoinCollection: $paginationFetchJoinCollection, - paginationUseOutputWalkers: $paginationUseOutputWalkers, - order: $order, - description: $description, - normalizationContext: $normalizationContext, - denormalizationContext: $denormalizationContext, - collectDenormalizationErrors: $collectDenormalizationErrors, - security: $security, - securityMessage: $securityMessage, - securityPostDenormalize: $securityPostDenormalize, - securityPostDenormalizeMessage: $securityPostDenormalizeMessage, - securityPostValidation: $securityPostValidation, - securityPostValidationMessage: $securityPostValidationMessage, - deprecationReason: $deprecationReason, - filters: $filters, - validationContext: $validationContext, - input: $input, - output: $output, - mercure: $mercure, - messenger: $messenger, - elasticsearch: $elasticsearch, - urlGenerationStrategy: $urlGenerationStrategy, - read: $read, - deserialize: $deserialize, - validate: $validate, - write: $write, - serialize: $serialize, - fetchPartial: $fetchPartial, - forceEager: $forceEager, - priority: $priority, - name: $name ?: 'update_subscription', - provider: $provider, - processor: $processor, - stateOptions: $stateOptions, - parameters: $parameters, + resolver : $resolver, + args : $args, + extraArgs : $extraArgs, + links : $links, + securityAfterResolver : $securityAfterResolver, + securityMessageAfterResolver : $securityMessageAfterResolver, + shortName : $shortName, + class : $class, + paginationEnabled : $paginationEnabled, + paginationType : $paginationType, + paginationItemsPerPage : $paginationItemsPerPage, + paginationMaximumItemsPerPage : $paginationMaximumItemsPerPage, + paginationPartial : $paginationPartial, + paginationClientEnabled : $paginationClientEnabled, + paginationClientItemsPerPage : $paginationClientItemsPerPage, + paginationClientPartial : $paginationClientPartial, + paginationFetchJoinCollection : $paginationFetchJoinCollection, + paginationUseOutputWalkers : $paginationUseOutputWalkers, + order : $order, + description : $description, + normalizationContext : $normalizationContext, + denormalizationContext : $denormalizationContext, + collectDenormalizationErrors : $collectDenormalizationErrors, + security : $security, + securityMessage : $securityMessage, + securityPostDenormalize : $securityPostDenormalize, + securityPostDenormalizeMessage : $securityPostDenormalizeMessage, + securityPostValidation : $securityPostValidation, + securityPostValidationMessage : $securityPostValidationMessage, + deprecationReason : $deprecationReason, + filters : $filters, + validationContext : $validationContext, + input : $input, + output : $output, + mercure : $mercure, + messenger : $messenger, + elasticsearch : $elasticsearch, + urlGenerationStrategy : $urlGenerationStrategy, + read : $read, + deserialize : $deserialize, + validate : $validate, + write : $write, + serialize : $serialize, + fetchPartial : $fetchPartial, + forceEager : $forceEager, + priority : $priority, + name : $name ?: 'update_subscription', + provider : $provider, + processor : $processor, + stateOptions : $stateOptions, + parameters : $parameters, queryParameterValidationEnabled: $queryParameterValidationEnabled, - policy: $policy, - rules: $rules, - extraProperties: $extraProperties, + rules : $rules, + policy : $policy, + extraProperties : $extraProperties, ); } + + public function isCollection(): bool + { + return $this->collection; + } } From e57e10524e9d970170ff5468414f2fb667ec3021 Mon Sep 17 00:00:00 2001 From: psihius Date: Thu, 24 Apr 2025 20:27:10 +0300 Subject: [PATCH 4/7] feat(graphql): fixing mercure subscription failing tests --- src/GraphQl/Serializer/ItemNormalizer.php | 2 +- src/GraphQl/Subscription/SubscriptionManager.php | 4 ++-- .../Tests/Subscription/SubscriptionManagerTest.php | 13 ++++++++----- .../PublishMercureUpdatesListenerTest.php | 2 +- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/GraphQl/Serializer/ItemNormalizer.php b/src/GraphQl/Serializer/ItemNormalizer.php index 7b33da81307..20071da66f5 100644 --- a/src/GraphQl/Serializer/ItemNormalizer.php +++ b/src/GraphQl/Serializer/ItemNormalizer.php @@ -108,7 +108,7 @@ public function normalize(mixed $object, ?string $format = null, array $context $data[self::ITEM_IDENTIFIERS_KEY] = $this->identifiersExtractor->getIdentifiersFromItem($object, $context['operation'] ?? null); } - if ($context['graphql_operation_name'] === 'mercure_subscription' && is_object($object) && isset($data['id']) && !isset($data['_id'])) { + if (isset($context['graphql_operation_name']) && $context['graphql_operation_name'] === 'mercure_subscription' && is_object($object) && isset($data['id']) && !isset($data['_id'])) { $data['_id'] = $data['id']; $data['id'] = $this->iriConverter->getIriFromResource($object); } diff --git a/src/GraphQl/Subscription/SubscriptionManager.php b/src/GraphQl/Subscription/SubscriptionManager.php index 9bcfd46e7b3..f628c9db9ea 100644 --- a/src/GraphQl/Subscription/SubscriptionManager.php +++ b/src/GraphQl/Subscription/SubscriptionManager.php @@ -51,7 +51,7 @@ public function retrieveSubscriptionId(array $context, ?array $result, ?Operatio return null; } - $options = $operation->getMercure() ?? false; + $options = $operation ? ($operation->getMercure() ?? false) : false; $private = $options['private'] ?? false; $privateFields = $options['private_fields'] ?? []; $previousObject = $context['graphql_context']['previous_object'] ?? null; @@ -60,7 +60,7 @@ public function retrieveSubscriptionId(array $context, ?array $result, ?Operatio $fields['__private_field_'.$privateField] = $this->getResourceId($privateField, $previousObject); } } - if ($operation->isCollection()) { + if ($operation instanceof Subscription && $operation->isCollection()) { $subscriptionId = $this->updateSubscriptionCollectionCacheData( $iri, $fields, diff --git a/src/GraphQl/Tests/Subscription/SubscriptionManagerTest.php b/src/GraphQl/Tests/Subscription/SubscriptionManagerTest.php index 7afeaeaef03..a32ff83890d 100644 --- a/src/GraphQl/Tests/Subscription/SubscriptionManagerTest.php +++ b/src/GraphQl/Tests/Subscription/SubscriptionManagerTest.php @@ -183,9 +183,11 @@ public function testGetPushPayloadsNoHit(): void $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); $cacheItemProphecy->isHit()->willReturn(false); + $cacheItemProphecy->isHit()->willReturn(false); $this->subscriptionsCacheProphecy->getItem('_dummies_2')->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_dummies')->willReturn($cacheItemProphecy->reveal()); - $this->assertEquals([], $this->subscriptionManager->getPushPayloads($object)); + $this->assertEquals([], $this->subscriptionManager->getPushPayloads($object, 'update')); } public function testGetPushPayloadsHit(): void @@ -199,16 +201,17 @@ public function testGetPushPayloadsHit(): void $this->iriConverterProphecy->getIriFromResource($object)->willReturn('/dummies/2'); $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); - $cacheItemProphecy->isHit()->willReturn(true); + $cacheItemProphecy->isHit()->willReturn(true)->shouldBeCalledTimes(2); $cacheItemProphecy->get()->willReturn([ ['subscriptionIdFoo', ['fieldsFoo'], ['resultFoo']], ['subscriptionIdBar', ['fieldsBar'], ['resultBar']], ]); $this->subscriptionsCacheProphecy->getItem('_dummies_2')->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_dummies')->willReturn($cacheItemProphecy->reveal()); $this->normalizeProcessor->process( $object, - (new Subscription())->withName('update_subscription')->withShortName('Dummy'), + (new Subscription())->withName('mercure_subscription')->withShortName('Dummy'), [], ['fields' => ['fieldsFoo'], 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true] )->willReturn( @@ -217,13 +220,13 @@ public function testGetPushPayloadsHit(): void $this->normalizeProcessor->process( $object, - (new Subscription())->withName('update_subscription')->withShortName('Dummy'), + (new Subscription())->withName('mercure_subscription')->withShortName('Dummy'), [], ['fields' => ['fieldsBar'], 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true] )->willReturn( ['resultBar', 'clientSubscriptionId' => 'client-subscription-id'] ); - $this->assertEquals([['subscriptionIdFoo', ['newResultFoo']]], $this->subscriptionManager->getPushPayloads($object)); + $this->assertEquals([['subscriptionIdFoo', ['newResultFoo']]], $this->subscriptionManager->getPushPayloads($object, 'update')); } } diff --git a/src/Symfony/Tests/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php b/src/Symfony/Tests/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php index 6780f53864e..f264b243c5b 100644 --- a/src/Symfony/Tests/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php +++ b/src/Symfony/Tests/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php @@ -327,7 +327,7 @@ public function testPublishGraphQlUpdates(): void $graphQlSubscriptionManagerProphecy = $this->prophesize(GraphQlSubscriptionManagerInterface::class); $graphQlSubscriptionId = 'subscription-id'; $graphQlSubscriptionData = ['data']; - $graphQlSubscriptionManagerProphecy->getPushPayloads($toUpdate)->willReturn([[$graphQlSubscriptionId, $graphQlSubscriptionData]]); + $graphQlSubscriptionManagerProphecy->getPushPayloads($toUpdate, 'update')->willReturn([[$graphQlSubscriptionId, $graphQlSubscriptionData]]); $graphQlMercureSubscriptionIriGenerator = $this->prophesize(GraphQlMercureSubscriptionIriGeneratorInterface::class); $topicIri = 'subscription-topic-iri'; $graphQlMercureSubscriptionIriGenerator->generateTopicIri($graphQlSubscriptionId)->willReturn($topicIri); From 9e61e92472e0d9799a84eec62badd2910f49bd29 Mon Sep 17 00:00:00 2001 From: psihius Date: Fri, 25 Apr 2025 15:30:41 +0300 Subject: [PATCH 5/7] feat(graphql): fixed code style and a bug in collection cache handling --- src/GraphQl/Serializer/ItemNormalizer.php | 21 ++++---- .../SubscriptionIdentifierGenerator.php | 4 +- .../Subscription/SubscriptionManager.php | 49 ++++++++++--------- .../Subscription/SubscriptionManagerTest.php | 12 +++-- 4 files changed, 47 insertions(+), 39 deletions(-) diff --git a/src/GraphQl/Serializer/ItemNormalizer.php b/src/GraphQl/Serializer/ItemNormalizer.php index 20071da66f5..923ccd2dfe8 100644 --- a/src/GraphQl/Serializer/ItemNormalizer.php +++ b/src/GraphQl/Serializer/ItemNormalizer.php @@ -108,7 +108,7 @@ public function normalize(mixed $object, ?string $format = null, array $context $data[self::ITEM_IDENTIFIERS_KEY] = $this->identifiersExtractor->getIdentifiersFromItem($object, $context['operation'] ?? null); } - if (isset($context['graphql_operation_name']) && $context['graphql_operation_name'] === 'mercure_subscription' && is_object($object) && isset($data['id']) && !isset($data['_id'])) { + if (isset($context['graphql_operation_name']) && 'mercure_subscription' === $context['graphql_operation_name'] && \is_object($object) && isset($data['id']) && !isset($data['_id'])) { $data['_id'] = $data['id']; $data['id'] = $this->iriConverter->getIriFromResource($object); } @@ -128,18 +128,18 @@ protected function normalizeCollectionOfRelations(ApiProperty $propertyMetadata, } // Handle relationships for mercure subscriptions - if ($operation instanceof QueryCollection && $context['graphql_operation_name'] === 'mercure_subscription' && $attributeValue instanceof Collection && !$attributeValue->isEmpty()) { + if ($operation instanceof QueryCollection && 'mercure_subscription' === $context['graphql_operation_name'] && $attributeValue instanceof Collection && !$attributeValue->isEmpty()) { $relationContext = $context; // Grab collection attributes $relationContext['attributes'] = $context['attributes']['collection']; // Iterate over the collection and normalize each item - $data['collection'] = $attributeValue - ->map(fn($item) => $this->normalize($item, $format, $relationContext)) + $data['collection'] = $attributeValue + ->map(fn ($item) => $this->normalize($item, $format, $relationContext)) // Convert the collection to an array ->toArray(); + // Handle pagination if it's enabled in the query - $data = $this->addPagination($attributeValue, $data, $context); - return $data; + return $this->addPagination($attributeValue, $data, $context); } // to-many are handled directly by the GraphQL resolver @@ -150,19 +150,20 @@ private function addPagination(Collection $collection, array $data, array $conte { if ($context['attributes']['paginationInfo'] ?? false) { $data['paginationInfo'] = []; - if (array_key_exists('hasNextPage', $context['attributes']['paginationInfo'])) { + if (\array_key_exists('hasNextPage', $context['attributes']['paginationInfo'])) { $data['paginationInfo']['hasNextPage'] = $collection->count() > ($context['pagination']['itemsPerPage'] ?? 10); } - if (array_key_exists('itemsPerPage', $context['attributes']['paginationInfo'])) { + if (\array_key_exists('itemsPerPage', $context['attributes']['paginationInfo'])) { $data['paginationInfo']['itemsPerPage'] = $context['pagination']['itemsPerPage'] ?? 10; } - if (array_key_exists('lastPage', $context['attributes']['paginationInfo'])) { + if (\array_key_exists('lastPage', $context['attributes']['paginationInfo'])) { $data['paginationInfo']['lastPage'] = (int) ceil($collection->count() / ($context['pagination']['itemsPerPage'] ?? 10)); } - if (array_key_exists('totalCount', $context['attributes']['paginationInfo'])) { + if (\array_key_exists('totalCount', $context['attributes']['paginationInfo'])) { $data['paginationInfo']['totalCount'] = $collection->count(); } } + return $data; } diff --git a/src/GraphQl/Subscription/SubscriptionIdentifierGenerator.php b/src/GraphQl/Subscription/SubscriptionIdentifierGenerator.php index 592f90aceba..76c5ee248ba 100644 --- a/src/GraphQl/Subscription/SubscriptionIdentifierGenerator.php +++ b/src/GraphQl/Subscription/SubscriptionIdentifierGenerator.php @@ -31,9 +31,9 @@ public function generateSubscriptionIdentifier(array $fields): string private function removeTypename(array $data): array { foreach ($data as $key => $value) { - if ($key === '__typename') { + if ('__typename' === $key) { unset($data[$key]); - } elseif (is_array($value)) { + } elseif (\is_array($value)) { $data[$key] = $this->removeTypename($value); } } diff --git a/src/GraphQl/Subscription/SubscriptionManager.php b/src/GraphQl/Subscription/SubscriptionManager.php index f628c9db9ea..99975407954 100644 --- a/src/GraphQl/Subscription/SubscriptionManager.php +++ b/src/GraphQl/Subscription/SubscriptionManager.php @@ -82,7 +82,7 @@ public function retrieveSubscriptionId(array $context, ?array $result, ?Operatio public function getPushPayloads(object $object, string $type): array { if ('delete' === $type) { - $payloads = $this->getDeletePushPayloads($object); + $payloads = $this->getDeletePushPayloads($object); } else { $payloads = $this->getCreatedOrUpdatedPayloads($object); } @@ -119,10 +119,11 @@ private function encodeIriToCacheKey(string $iri): string private function getResourceId(mixed $privateField, object $previousObject): string { - $id = $previousObject->{'get' . ucfirst($privateField)}()->getId(); + $id = $previousObject->{'get'.ucfirst($privateField)}()->getId(); if ($id instanceof \Stringable) { - return (string)$id; + return (string) $id; } + return $id; } @@ -172,35 +173,35 @@ private function getCreatedOrUpdatedPayloads(object $object): array $payloads[] = [$subscriptionId, $data]; } } + return $payloads; } private function getDeletePushPayloads(object $object): array { $iri = $object->id; - $subscriptions = $this->getSubscriptionsFromIri($iri); - if ($subscriptions === []) { - // Get subscriptions from collection Iri - $subscriptions = $this->getSubscriptionsFromIri($this->getCollectionIri($iri)); - } + $subscriptions = array_merge( + $this->getSubscriptionsFromIri($iri), + $this->getSubscriptionsFromIri($this->getCollectionIri($iri)) + ); $payloads = []; foreach ($subscriptions as [$subscriptionId, $subscriptionFields, $subscriptionResult]) { $payloads[] = [$subscriptionId, ['type' => 'delete', 'payload' => $object]]; } $this->removeItemFromSubscriptionCache($iri); + return $payloads; } private function updateSubscriptionItemCacheData( - string $iri, - array $fields, - ?array $result, - bool $private, - array $privateFields, - ?object $previousObject - ): string - { + string $iri, + array $fields, + ?array $result, + bool $private, + array $privateFields, + ?object $previousObject, + ): string { $subscriptionsCacheItem = $this->subscriptionsCache->getItem($this->encodeIriToCacheKey($iri)); $subscriptions = []; if ($subscriptionsCacheItem->isHit()) { @@ -219,25 +220,24 @@ private function updateSubscriptionItemCacheData( unset($result['clientSubscriptionId']); if ($private && $privateFields && $previousObject) { foreach ($privateFields as $privateField) { - unset($result['__private_field_' . $privateField]); + unset($result['__private_field_'.$privateField]); } } $subscriptions[] = [$subscriptionId, $fields, $result]; $subscriptionsCacheItem->set($subscriptions); $this->subscriptionsCache->save($subscriptionsCacheItem); + return $subscriptionId; } - - private function updateSubscriptionCollectionCacheData( string $iri, - array $fields, - ): string - { + array $fields, + ): string { $subscriptionCollectionCacheItem = $this->subscriptionsCache->getItem( $this->encodeIriToCacheKey($this->getCollectionIri($iri)), ); + $collectionSubscriptions = []; if ($subscriptionCollectionCacheItem->isHit()) { $collectionSubscriptions = $subscriptionCollectionCacheItem->get(); foreach ($collectionSubscriptions as [$subscriptionId, $subscriptionFields]) { @@ -247,9 +247,10 @@ private function updateSubscriptionCollectionCacheData( } } $subscriptionId = $this->subscriptionIdentifierGenerator->generateSubscriptionIdentifier($fields + ['__collection' => true]); - $subscriptions[] = [$subscriptionId, $fields, []]; - $subscriptionCollectionCacheItem->set($subscriptions); + $collectionSubscriptions[] = [$subscriptionId, $fields]; + $subscriptionCollectionCacheItem->set($collectionSubscriptions); $this->subscriptionsCache->save($subscriptionCollectionCacheItem); + return $subscriptionId; } } diff --git a/src/GraphQl/Tests/Subscription/SubscriptionManagerTest.php b/src/GraphQl/Tests/Subscription/SubscriptionManagerTest.php index a32ff83890d..d895c1c1024 100644 --- a/src/GraphQl/Tests/Subscription/SubscriptionManagerTest.php +++ b/src/GraphQl/Tests/Subscription/SubscriptionManagerTest.php @@ -201,13 +201,19 @@ public function testGetPushPayloadsHit(): void $this->iriConverterProphecy->getIriFromResource($object)->willReturn('/dummies/2'); $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); - $cacheItemProphecy->isHit()->willReturn(true)->shouldBeCalledTimes(2); + $cacheItemProphecy->isHit()->willReturn(true); $cacheItemProphecy->get()->willReturn([ ['subscriptionIdFoo', ['fieldsFoo'], ['resultFoo']], ['subscriptionIdBar', ['fieldsBar'], ['resultBar']], ]); + $cacheItemProphecyCollection = $this->prophesize(CacheItemInterface::class); + $cacheItemProphecyCollection->isHit()->willReturn(true); + $cacheItemProphecyCollection->get()->willReturn([ + ['subscriptionIdFoo', ['fieldsFoo'], []], + ['subscriptionIdBar', ['fieldsBar'], []], + ]); $this->subscriptionsCacheProphecy->getItem('_dummies_2')->willReturn($cacheItemProphecy->reveal()); - $this->subscriptionsCacheProphecy->getItem('_dummies')->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_dummies')->willReturn($cacheItemProphecyCollection->reveal()); $this->normalizeProcessor->process( $object, @@ -227,6 +233,6 @@ public function testGetPushPayloadsHit(): void ['resultBar', 'clientSubscriptionId' => 'client-subscription-id'] ); - $this->assertEquals([['subscriptionIdFoo', ['newResultFoo']]], $this->subscriptionManager->getPushPayloads($object, 'update')); + $this->assertEquals([['subscriptionIdFoo', ['newResultFoo']], ['subscriptionIdFoo', ['newResultFoo']]], $this->subscriptionManager->getPushPayloads($object, 'update')); } } From a42fe1adbd52e0bd260aff5e3152035742c9f1cf Mon Sep 17 00:00:00 2001 From: psihius Date: Tue, 29 Apr 2025 12:39:03 +0300 Subject: [PATCH 6/7] feat(graphql): Fixed not putting all 3 items into the subscription cache --- src/GraphQl/Subscription/SubscriptionManager.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/GraphQl/Subscription/SubscriptionManager.php b/src/GraphQl/Subscription/SubscriptionManager.php index 99975407954..417971a301c 100644 --- a/src/GraphQl/Subscription/SubscriptionManager.php +++ b/src/GraphQl/Subscription/SubscriptionManager.php @@ -240,14 +240,14 @@ private function updateSubscriptionCollectionCacheData( $collectionSubscriptions = []; if ($subscriptionCollectionCacheItem->isHit()) { $collectionSubscriptions = $subscriptionCollectionCacheItem->get(); - foreach ($collectionSubscriptions as [$subscriptionId, $subscriptionFields]) { + foreach ($collectionSubscriptions as [$subscriptionId, $subscriptionFields, $result]) { if ($subscriptionFields === $fields) { return $subscriptionId; } } } $subscriptionId = $this->subscriptionIdentifierGenerator->generateSubscriptionIdentifier($fields + ['__collection' => true]); - $collectionSubscriptions[] = [$subscriptionId, $fields]; + $collectionSubscriptions[] = [$subscriptionId, $fields, []]; $subscriptionCollectionCacheItem->set($collectionSubscriptions); $this->subscriptionsCache->save($subscriptionCollectionCacheItem); From 1789149e5bc6efb138f55573cfc243909261369f Mon Sep 17 00:00:00 2001 From: psihius Date: Wed, 30 Apr 2025 15:45:57 +0300 Subject: [PATCH 7/7] feat(graphql): Collection cache keys are now segmented by private field data if those are set. --- .../Subscription/SubscriptionManager.php | 66 +++++++++++++------ .../PublishMercureUpdatesListener.php | 23 +++++++ 2 files changed, 70 insertions(+), 19 deletions(-) diff --git a/src/GraphQl/Subscription/SubscriptionManager.php b/src/GraphQl/Subscription/SubscriptionManager.php index 417971a301c..7f09eba0973 100644 --- a/src/GraphQl/Subscription/SubscriptionManager.php +++ b/src/GraphQl/Subscription/SubscriptionManager.php @@ -42,28 +42,33 @@ public function __construct(private readonly CacheItemPoolInterface $subscriptio public function retrieveSubscriptionId(array $context, ?array $result, ?Operation $operation = null): ?string { - /** @var ResolveInfo $info */ - $info = $context['info']; - $fields = $info->getFieldSelection(\PHP_INT_MAX); - $this->arrayRecursiveSort($fields, 'ksort'); $iri = $operation ? $this->getIdentifierFromOperation($operation, $context['args'] ?? []) : $this->getIdentifierFromContext($context); if (empty($iri)) { return null; } + /** @var ResolveInfo $info */ + $info = $context['info']; + $fields = $info->getFieldSelection(\PHP_INT_MAX); + $this->arrayRecursiveSort($fields, 'ksort'); + $options = $operation ? ($operation->getMercure() ?? false) : false; $private = $options['private'] ?? false; $privateFields = $options['private_fields'] ?? []; $previousObject = $context['graphql_context']['previous_object'] ?? null; + $privateFieldData = []; if ($private && $privateFields && $previousObject) { foreach ($options['private_fields'] as $privateField) { - $fields['__private_field_'.$privateField] = $this->getResourceId($privateField, $previousObject); + $fieldData = $this->getResourceId($privateField, $previousObject); + $fields['__private_field_'.$privateField] = $fieldData; + $privateFieldData[] = $fieldData; } } if ($operation instanceof Subscription && $operation->isCollection()) { $subscriptionId = $this->updateSubscriptionCollectionCacheData( $iri, $fields, + $privateFieldData ); } else { $subscriptionId = $this->updateSubscriptionItemCacheData( @@ -93,9 +98,15 @@ public function getPushPayloads(object $object, string $type): array /** * @return array */ - private function getSubscriptionsFromIri(string $iri): array + private function getSubscriptionsFromIri(string $iri, array $fields = []): array { - $subscriptionsCacheItem = $this->subscriptionsCache->getItem($this->encodeIriToCacheKey($iri)); + $subscriptionsCacheItem = $this->subscriptionsCache->getItem( + $this->generatePrivateCacheKeyPart( + $this->encodeIriToCacheKey($iri), + $fields + ) + + ); if ($subscriptionsCacheItem->isHit()) { return $subscriptionsCacheItem->get(); @@ -134,13 +145,6 @@ private function getCollectionIri(string $iri): string private function getCreatedOrUpdatedPayloads(object $object): array { - $iri = $this->iriConverter->getIriFromResource($object); - // Add collection subscriptions - $subscriptions = array_merge( - $this->getSubscriptionsFromIri($this->getCollectionIri($iri)), - $this->getSubscriptionsFromIri($iri) - ); - $resourceClass = $this->getObjectClass($object); $resourceMetadata = $this->resourceMetadataCollectionFactory->create($resourceClass); $shortName = $resourceMetadata->getOperation()->getShortName(); @@ -155,6 +159,13 @@ private function getCreatedOrUpdatedPayloads(object $object): array } } + $iri = $this->iriConverter->getIriFromResource($object); + // Add collection subscriptions + $subscriptions = array_merge( + $this->getSubscriptionsFromIri($this->getCollectionIri($iri), $privateFieldData), + $this->getSubscriptionsFromIri($iri) + ); + $payloads = []; foreach ($subscriptions as [$subscriptionId, $subscriptionFields, $subscriptionResult]) { if ($privateFieldData) { @@ -182,12 +193,13 @@ private function getDeletePushPayloads(object $object): array $iri = $object->id; $subscriptions = array_merge( $this->getSubscriptionsFromIri($iri), - $this->getSubscriptionsFromIri($this->getCollectionIri($iri)) + $this->getSubscriptionsFromIri($this->getCollectionIri($iri), $object->private), ); $payloads = []; + $payload = ['type' => 'delete', 'payload' => ['id' => $object->id, 'iri' => $object->iri, 'type' => $object->type]]; foreach ($subscriptions as [$subscriptionId, $subscriptionFields, $subscriptionResult]) { - $payloads[] = [$subscriptionId, ['type' => 'delete', 'payload' => $object]]; + $payloads[] = [$subscriptionId, $payload]; } $this->removeItemFromSubscriptionCache($iri); @@ -216,12 +228,14 @@ private function updateSubscriptionItemCacheData( } } - $subscriptionId = $this->subscriptionIdentifierGenerator->generateSubscriptionIdentifier($fields); unset($result['clientSubscriptionId']); if ($private && $privateFields && $previousObject) { + $subscriptionId = $this->subscriptionIdentifierGenerator->generateSubscriptionIdentifier($fields); foreach ($privateFields as $privateField) { unset($result['__private_field_'.$privateField]); } + } else { + $subscriptionId = $this->subscriptionIdentifierGenerator->generateSubscriptionIdentifier($fields); } $subscriptions[] = [$subscriptionId, $fields, $result]; $subscriptionsCacheItem->set($subscriptions); @@ -232,10 +246,15 @@ private function updateSubscriptionItemCacheData( private function updateSubscriptionCollectionCacheData( string $iri, - array $fields, + array $fields, + array $privateFieldData, ): string { + $subscriptionCollectionCacheItem = $this->subscriptionsCache->getItem( - $this->encodeIriToCacheKey($this->getCollectionIri($iri)), + $this->generatePrivateCacheKeyPart( + $this->encodeIriToCacheKey($this->getCollectionIri($iri)), + $privateFieldData + ), ); $collectionSubscriptions = []; if ($subscriptionCollectionCacheItem->isHit()) { @@ -253,4 +272,13 @@ private function updateSubscriptionCollectionCacheData( return $subscriptionId; } + + private function generatePrivateCacheKeyPart(string $iriKey, array $fields = []): string + { + if (empty($fields)) { + return $iriKey; + } + return $iriKey.'_'.implode('_', $fields); + } + } diff --git a/src/Symfony/Doctrine/EventListener/PublishMercureUpdatesListener.php b/src/Symfony/Doctrine/EventListener/PublishMercureUpdatesListener.php index 0a3ca6f9fa6..bc797e9a7c1 100644 --- a/src/Symfony/Doctrine/EventListener/PublishMercureUpdatesListener.php +++ b/src/Symfony/Doctrine/EventListener/PublishMercureUpdatesListener.php @@ -218,10 +218,23 @@ private function storeObjectToPublish(object $object, string $property): void // We need to evaluate it here, because in publishUpdate() the resource would be already deleted $this->evaluateTopics($options, $object); + $privateData = []; + $mercureOptions = $operation ? ($operation->getMercure() ?? false) : false; + $private = $mercureOptions['private'] ?? false; + $privateFields = $mercureOptions['private_fields'] ?? []; + if ($private && $privateFields) { + foreach ($privateFields as $privateField) { + if (property_exists($object, $privateField)) { + $privateData[$privateField] = $this->getResourceId($privateField, $object); + } + } + } + $this->deletedObjects[(object) [ 'id' => $this->iriConverter->getIriFromResource($object), 'iri' => $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL), 'type' => 1 === \count($types) ? $types[0] : $types, + 'private' => $privateData, ]] = $options; return; @@ -319,4 +332,14 @@ private function buildUpdate(string|array $iri, string $data, array $options): U { return new Update($iri, $data, $options['private'] ?? false, $options['id'] ?? null, $options['type'] ?? null, $options['retry'] ?? null); } + + private function getResourceId(string $privateField, object $object): string + { + $id = $object->{'get'.ucfirst($privateField)}()->getId(); + if ($id instanceof \Stringable) { + return (string) $id; + } + + return $id; + } }