From 4d5f7ef5f2ea9f2caa8372c7aca2c5f7d710fe13 Mon Sep 17 00:00:00 2001 From: psihius Date: Thu, 9 Jan 2025 00:10:47 +0200 Subject: [PATCH 01/11] feat(graphql): added support for graphql subscriptions to work for all mutation types --- .../SubscriptionIdentifierGenerator.php | 14 ++ .../Subscription/SubscriptionManager.php | 144 ++++++++++++++++-- .../SubscriptionManagerInterface.php | 2 +- .../PublishMercureUpdatesListener.php | 5 +- 4 files changed, 146 insertions(+), 19 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 afebe45bfb1..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,24 +73,128 @@ 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]; $operation = (new Subscription())->withName('update_subscription')->withShortName($shortName); $data = $this->normalizeProcessor->process($object, $operation, [], $resolverContext); @@ -91,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 e04003e9e42..604cb1135ec 100644 --- a/src/GraphQl/Subscription/SubscriptionManagerInterface.php +++ b/src/GraphQl/Subscription/SubscriptionManagerInterface.php @@ -25,5 +25,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 7ca43c05dc9..6cda4144b61 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, @@ -297,11 +298,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 161afaf583f24fcc358b951499115f8108ded16f Mon Sep 17 00:00:00 2001 From: psihius Date: Fri, 17 Jan 2025 13:43:10 +0200 Subject: [PATCH 02/11] 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 c9902332c5a12047d4a3f8924893f62069a4e203 Mon Sep 17 00:00:00 2001 From: psihius Date: Thu, 24 Apr 2025 14:52:07 +0300 Subject: [PATCH 03/11] 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 | 107 +++++++------- 5 files changed, 185 insertions(+), 102 deletions(-) diff --git a/src/GraphQl/Serializer/ItemNormalizer.php b/src/GraphQl/Serializer/ItemNormalizer.php index 1d94476c787..aaee9631bc3 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; @@ -109,6 +111,11 @@ public function normalize(mixed $data, ?string $format = null, array $context = $normalizedData[self::ITEM_IDENTIFIERS_KEY] = $this->identifiersExtractor->getIdentifiersFromItem($data, $context['operation'] ?? null); } + if ('mercure_subscription' === ($context['graphql_operation_name'] ?? null) && \is_object($data) && isset($normalizedData['id']) && !isset($normalizedData['_id'])) { + $normalizedData['_id'] = $normalizedData['id']; + $normalizedData['id'] = $this->iriConverter->getIriFromResource($data); + } + return $normalizedData; } @@ -123,10 +130,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 289bb8762a4..81102cdfa6f 100644 --- a/src/GraphQl/Type/TypeBuilder.php +++ b/src/GraphQl/Type/TypeBuilder.php @@ -358,6 +358,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 dbd54aff922..75dc8457100 100644 --- a/src/Metadata/GraphQl/Subscription.php +++ b/src/Metadata/GraphQl/Subscription.php @@ -76,63 +76,70 @@ public function __construct( ?string $policy = null, array $extraProperties = [], ?bool $map = null, + 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, - 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, + 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, map: $map + map : $map, ); } + + public function isCollection(): bool + { + return $this->collection; + } } From 46c1a9b2f170b1d9c46b840b6e853bfb0bb3fe84 Mon Sep 17 00:00:00 2001 From: psihius Date: Thu, 24 Apr 2025 20:27:10 +0300 Subject: [PATCH 04/11] 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 aaee9631bc3..94daaf627c3 100644 --- a/src/GraphQl/Serializer/ItemNormalizer.php +++ b/src/GraphQl/Serializer/ItemNormalizer.php @@ -111,7 +111,7 @@ public function normalize(mixed $data, ?string $format = null, array $context = $normalizedData[self::ITEM_IDENTIFIERS_KEY] = $this->identifiersExtractor->getIdentifiersFromItem($data, $context['operation'] ?? null); } - if ('mercure_subscription' === ($context['graphql_operation_name'] ?? null) && \is_object($data) && isset($normalizedData['id']) && !isset($normalizedData['_id'])) { + if (isset($context['graphql_operation_name']) && 'mercure_subscription' === $context['graphql_operation_name'] && \is_object($data) && isset($normalizedData['id']) && !isset($normalizedData['_id'])) { $normalizedData['_id'] = $normalizedData['id']; $normalizedData['id'] = $this->iriConverter->getIriFromResource($data); } 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 8ce791e1a57..181e023cc08 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 1017971e2235e277e81d70c125244b969928fff6 Mon Sep 17 00:00:00 2001 From: psihius Date: Fri, 25 Apr 2025 15:30:41 +0300 Subject: [PATCH 05/11] feat(graphql): fixed code style and a bug in collection cache handling --- src/GraphQl/Serializer/ItemNormalizer.php | 19 +++---- .../SubscriptionIdentifierGenerator.php | 4 +- .../Subscription/SubscriptionManager.php | 49 ++++++++++--------- .../Subscription/SubscriptionManagerTest.php | 12 +++-- 4 files changed, 46 insertions(+), 38 deletions(-) diff --git a/src/GraphQl/Serializer/ItemNormalizer.php b/src/GraphQl/Serializer/ItemNormalizer.php index 94daaf627c3..01b6ed4597c 100644 --- a/src/GraphQl/Serializer/ItemNormalizer.php +++ b/src/GraphQl/Serializer/ItemNormalizer.php @@ -131,18 +131,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 @@ -153,19 +153,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 55a8d524485c3704829d5c96e04517dbff7a7a53 Mon Sep 17 00:00:00 2001 From: psihius Date: Tue, 29 Apr 2025 12:39:03 +0300 Subject: [PATCH 06/11] 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 5b0fd285d9dbfc37aefc2bd913d56760630fcf34 Mon Sep 17 00:00:00 2001 From: psihius Date: Wed, 30 Apr 2025 15:45:57 +0300 Subject: [PATCH 07/11] 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 6cda4144b61..fee2c7d9521 100644 --- a/src/Symfony/Doctrine/EventListener/PublishMercureUpdatesListener.php +++ b/src/Symfony/Doctrine/EventListener/PublishMercureUpdatesListener.php @@ -218,11 +218,24 @@ 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' => (object) [ 'id' => $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation), 'iri' => $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL, $operation), 'type' => 1 === \count($types) ? $types[0] : $types, + 'private' => $privateData, ], 'options' => $options, 'operation' => $operation, @@ -323,4 +336,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; + } } From def1d9fbe0f876df7271ff51c4c6d5d7a85241ee Mon Sep 17 00:00:00 2001 From: psihius Date: Thu, 26 Jun 2025 14:26:00 +0300 Subject: [PATCH 08/11] feat(graphql): Handle multiple subscriptions per resource and issue updates to them all --- .../State/Processor/SubscriptionProcessor.php | 2 +- .../Subscription/SubscriptionManager.php | 82 ++++++++++++------- .../PublishMercureUpdatesListener.php | 2 +- 3 files changed, 54 insertions(+), 32 deletions(-) diff --git a/src/GraphQl/State/Processor/SubscriptionProcessor.php b/src/GraphQl/State/Processor/SubscriptionProcessor.php index 1c6ae28cac2..6f66482df98 100644 --- a/src/GraphQl/State/Processor/SubscriptionProcessor.php +++ b/src/GraphQl/State/Processor/SubscriptionProcessor.php @@ -33,7 +33,7 @@ public function __construct(private readonly ProcessorInterface $decorated, priv public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) { $data = $this->decorated->process($data, $operation, $uriVariables, $context); - if (!$operation instanceof GraphQlOperation || !($mercure = $operation->getMercure())) { + if (!$operation instanceof Subscription || !($mercure = $operation->getMercure())) { return $data; } diff --git a/src/GraphQl/Subscription/SubscriptionManager.php b/src/GraphQl/Subscription/SubscriptionManager.php index 7f09eba0973..3e6d87ba305 100644 --- a/src/GraphQl/Subscription/SubscriptionManager.php +++ b/src/GraphQl/Subscription/SubscriptionManager.php @@ -131,7 +131,7 @@ private function encodeIriToCacheKey(string $iri): string private function getResourceId(mixed $privateField, object $previousObject): string { $id = $previousObject->{'get'.ucfirst($privateField)}()->getId(); - if ($id instanceof \Stringable) { + if ($id instanceof \Stringable || is_numeric($id)) { return (string) $id; } @@ -149,39 +149,61 @@ private function getCreatedOrUpdatedPayloads(object $object): array $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); - } - } - - $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) { - $fieldDiff = array_intersect_assoc($subscriptionFields, $privateFieldData); - if ($fieldDiff !== $privateFieldData) { + foreach ($resourceMetadata as $apiResource) { + foreach ($apiResource->getGraphQlOperations() as $operation) { + if (!$operation instanceof Subscription) { continue; } - } - $resolverContext = ['fields' => $subscriptionFields, 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true]; - $operation = (new Subscription())->withName('mercure_subscription')->withShortName($shortName); - $data = $this->normalizeProcessor->process($object, $operation, [], $resolverContext); - - unset($data['clientSubscriptionId']); + $mercure = $resourceMetadata->getOperation()->getMercure() ?? false; + $operationMercure = $operation->getMercure() ?? false; + if ($mercure !== false && $operationMercure !== false) { + /** @noinspection SlowArrayOperationsInLoopInspection */ + $mercure = array_merge($mercure, $operationMercure); + } + $private = $mercure['private'] ?? false; + $privateFieldsConfig = $mercure['private_fields'] ?? []; + $privateFieldData = []; + if ($private && $privateFieldsConfig) { + foreach ($privateFieldsConfig as $privateField) { + $privateFieldData['__private_field_' . $privateField] = $this->getResourceId( + $privateField, + $object + ); + } + } - if ($data !== $subscriptionResult) { - $payloads[] = [$subscriptionId, $data]; + $iri = $this->iriConverter->getIriFromResource($object); + // Add collection subscriptions + $subscriptions = array_merge( + $this->getSubscriptionsFromIri($this->getCollectionIri($iri), $privateFieldData), + $this->getSubscriptionsFromIri($iri) + ); + + 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 + ]; + $subscriptionOperation = (new Subscription())->withName('mercure_subscription')->withShortName( + $shortName + ); + $data = $this->normalizeProcessor->process($object, $subscriptionOperation, [], $resolverContext); + + unset($data['clientSubscriptionId']); + + if ($data !== $subscriptionResult) { + $payloads[] = [$subscriptionId, $data]; + } + } } } diff --git a/src/Symfony/Doctrine/EventListener/PublishMercureUpdatesListener.php b/src/Symfony/Doctrine/EventListener/PublishMercureUpdatesListener.php index fee2c7d9521..37040140fbc 100644 --- a/src/Symfony/Doctrine/EventListener/PublishMercureUpdatesListener.php +++ b/src/Symfony/Doctrine/EventListener/PublishMercureUpdatesListener.php @@ -340,7 +340,7 @@ private function buildUpdate(string|array $iri, string $data, array $options): U private function getResourceId(string $privateField, object $object): string { $id = $object->{'get'.ucfirst($privateField)}()->getId(); - if ($id instanceof \Stringable) { + if ($id instanceof \Stringable || is_numeric($id)) { return (string) $id; } From 14994de3cf8c7975366a6a7a30d36982afe67289 Mon Sep 17 00:00:00 2001 From: psihius Date: Wed, 2 Jul 2025 22:25:48 +0300 Subject: [PATCH 09/11] feat(graphql): Update getResourceId to handle scalar values --- src/GraphQl/Subscription/SubscriptionManager.php | 5 ++++- .../Doctrine/EventListener/PublishMercureUpdatesListener.php | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/GraphQl/Subscription/SubscriptionManager.php b/src/GraphQl/Subscription/SubscriptionManager.php index 3e6d87ba305..a833afde5ab 100644 --- a/src/GraphQl/Subscription/SubscriptionManager.php +++ b/src/GraphQl/Subscription/SubscriptionManager.php @@ -130,7 +130,10 @@ 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)}(); + if (is_object($id) && method_exists($id, 'getId')) { + $id = $id->getId(); + } if ($id instanceof \Stringable || is_numeric($id)) { return (string) $id; } diff --git a/src/Symfony/Doctrine/EventListener/PublishMercureUpdatesListener.php b/src/Symfony/Doctrine/EventListener/PublishMercureUpdatesListener.php index 37040140fbc..cc0675d8eb2 100644 --- a/src/Symfony/Doctrine/EventListener/PublishMercureUpdatesListener.php +++ b/src/Symfony/Doctrine/EventListener/PublishMercureUpdatesListener.php @@ -339,7 +339,10 @@ private function buildUpdate(string|array $iri, string $data, array $options): U private function getResourceId(string $privateField, object $object): string { - $id = $object->{'get'.ucfirst($privateField)}()->getId(); + $id = $object->{'get'.ucfirst($privateField)}(); + if (is_object($id) && method_exists($id, 'getId')) { + $id = $id->getId(); + } if ($id instanceof \Stringable || is_numeric($id)) { return (string) $id; } From e4452a4cc55de1ffacd6365b5f6d40dae23be6f4 Mon Sep 17 00:00:00 2001 From: psihius Date: Sat, 21 Mar 2026 20:46:54 +0200 Subject: [PATCH 10/11] feat(graphql): post-rebase argument sorting --- src/Metadata/GraphQl/Subscription.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Metadata/GraphQl/Subscription.php b/src/Metadata/GraphQl/Subscription.php index 75dc8457100..bc5d57bae51 100644 --- a/src/Metadata/GraphQl/Subscription.php +++ b/src/Metadata/GraphQl/Subscription.php @@ -130,11 +130,10 @@ class : $class, stateOptions : $stateOptions, parameters : $parameters, queryParameterValidationEnabled: $queryParameterValidationEnabled, - policy: $policy, - rules: $rules, - extraProperties: $extraProperties, - map: $map - map : $map, + rules : $rules, + policy : $policy, + extraProperties : $extraProperties, + map : $map, ); } From f42ceee283bdaacd7399d35f6545f388f599c8b1 Mon Sep 17 00:00:00 2001 From: psihius Date: Sun, 22 Mar 2026 04:12:00 +0200 Subject: [PATCH 11/11] refactor(graphql): V2 of GraphQL subscriptions and testing coverage Signed-off-by: psihius --- src/GraphQl/Serializer/ItemNormalizer.php | 23 +- .../State/Processor/SubscriptionProcessor.php | 4 - .../Subscription/SubscriptionManager.php | 275 +++--- .../SubscriptionManagerInterface.php | 2 +- .../MercureSubscriptionChildDummy.php | 29 + .../Tests/Serializer/ItemNormalizerTest.php | 112 +++ .../Processor/SubscriptionProcessorTest.php | 50 +- .../Subscription/SubscriptionManagerTest.php | 701 ++++++++++++++- src/GraphQl/Tests/Type/FieldsBuilderTest.php | 41 + src/GraphQl/Tests/Type/SchemaBuilderTest.php | 36 + src/GraphQl/Tests/Type/TypeBuilderTest.php | 69 ++ src/GraphQl/Type/TypeBuilder.php | 1 - src/Metadata/GraphQl/Subscription.php | 116 ++- .../GraphQl/SubscriptionCollection.php | 139 +++ .../PropertyAccessorValueExtractorTest.php | 48 ++ .../Util/PropertyAccessorValueExtractor.php | 38 + .../PublishMercureUpdatesListener.php | 22 +- .../PublishMercureUpdatesListenerTest.php | 806 ++++++++++++++++++ .../ApiResource/GraphQlSubscriptionPair.php | 48 ++ .../GraphQl/SubscriptionSchemaTest.php | 110 +++ 20 files changed, 2460 insertions(+), 210 deletions(-) create mode 100644 src/GraphQl/Tests/Fixtures/ApiResource/MercureSubscriptionChildDummy.php create mode 100644 src/Metadata/GraphQl/SubscriptionCollection.php create mode 100644 src/Metadata/Tests/Util/PropertyAccessorValueExtractorTest.php create mode 100644 src/Metadata/Util/PropertyAccessorValueExtractor.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/GraphQlSubscriptionPair.php create mode 100644 tests/Functional/GraphQl/SubscriptionSchemaTest.php diff --git a/src/GraphQl/Serializer/ItemNormalizer.php b/src/GraphQl/Serializer/ItemNormalizer.php index 01b6ed4597c..989ca7d1276 100644 --- a/src/GraphQl/Serializer/ItemNormalizer.php +++ b/src/GraphQl/Serializer/ItemNormalizer.php @@ -133,37 +133,34 @@ protected function normalizeCollectionOfRelations(ApiProperty $propertyMetadata, // Handle relationships for mercure subscriptions 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)) - // Convert the collection to an array - ->toArray(); - - // Handle pagination if it's enabled in the query - return $this->addPagination($attributeValue, $data, $context); + $data['collection'] = []; + foreach ($attributeValue as $item) { + $data['collection'][] = $this->normalize($item, $format, $relationContext); + } + + return $this->addPagination($attributeValue->count(), $data, $context); } // to-many are handled directly by the GraphQL resolver return []; } - private function addPagination(Collection $collection, array $data, array $context): array + private function addPagination(int $totalCount, 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); + $data['paginationInfo']['hasNextPage'] = $totalCount > ($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)); + $data['paginationInfo']['lastPage'] = (int) ceil($totalCount / ($context['pagination']['itemsPerPage'] ?? 10)); } if (\array_key_exists('totalCount', $context['attributes']['paginationInfo'])) { - $data['paginationInfo']['totalCount'] = $collection->count(); + $data['paginationInfo']['totalCount'] = $totalCount; } } diff --git a/src/GraphQl/State/Processor/SubscriptionProcessor.php b/src/GraphQl/State/Processor/SubscriptionProcessor.php index 6f66482df98..118e5724b80 100644 --- a/src/GraphQl/State/Processor/SubscriptionProcessor.php +++ b/src/GraphQl/State/Processor/SubscriptionProcessor.php @@ -16,7 +16,6 @@ use ApiPlatform\GraphQl\Subscription\MercureSubscriptionIriGeneratorInterface; 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; @@ -50,9 +49,6 @@ 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 a833afde5ab..7371074ac10 100644 --- a/src/GraphQl/Subscription/SubscriptionManager.php +++ b/src/GraphQl/Subscription/SubscriptionManager.php @@ -14,16 +14,23 @@ namespace ApiPlatform\GraphQl\Subscription; use ApiPlatform\GraphQl\Resolver\Util\IdentifierTrait; +use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Exception\OperationNotFoundException; use ApiPlatform\Metadata\GraphQl\Operation; use ApiPlatform\Metadata\GraphQl\Subscription; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\Metadata\Util\PropertyAccessorValueExtractor; use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; use ApiPlatform\Metadata\Util\SortTrait; use ApiPlatform\State\ProcessorInterface; use GraphQL\Type\Definition\ResolveInfo; use Psr\Cache\CacheItemPoolInterface; - +use Psr\Cache\CacheItemInterface; +use Symfony\Component\PropertyAccess\Exception\AccessException; +use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; /** * Manages all the queried subscriptions by creating their ID * and saving to a cache the information needed to publish updated data. @@ -55,58 +62,44 @@ public function retrieveSubscriptionId(array $context, ?array $result, ?Operatio $options = $operation ? ($operation->getMercure() ?? false) : false; $private = $options['private'] ?? false; $privateFields = $options['private_fields'] ?? []; + $this->validateMercureOptions($private, $privateFields); $previousObject = $context['graphql_context']['previous_object'] ?? null; - $privateFieldData = []; - if ($private && $privateFields && $previousObject) { - foreach ($options['private_fields'] as $privateField) { - $fieldData = $this->getResourceId($privateField, $previousObject); - $fields['__private_field_'.$privateField] = $fieldData; - $privateFieldData[] = $fieldData; - } - } - if ($operation instanceof Subscription && $operation->isCollection()) { + $privateFieldData = $this->getPrivateFieldData($private, $privateFields, $previousObject); + $privatePartitionKey = $this->getPrivatePartitionKey($privateFieldData); + + if ($operation instanceof CollectionOperationInterface) { $subscriptionId = $this->updateSubscriptionCollectionCacheData( $iri, $fields, - $privateFieldData + $privatePartitionKey ); } else { $subscriptionId = $this->updateSubscriptionItemCacheData( $iri, $fields, $result, - $private, - $privateFields, - $previousObject + $privatePartitionKey ); } return $subscriptionId; } - public function getPushPayloads(object $object, string $type): array + public function getPushPayloads(object $object, string $type = 'update'): array { if ('delete' === $type) { - $payloads = $this->getDeletePushPayloads($object); - } else { - $payloads = $this->getCreatedOrUpdatedPayloads($object); + return $this->getDeletePushPayloads($object); } - return $payloads; + return $this->getCreatedOrUpdatedPayloads($object, $type); } /** * @return array */ - private function getSubscriptionsFromIri(string $iri, array $fields = []): array + private function getSubscriptionsFromIri(string $iri, ?string $privatePartitionKey = null): array { - $subscriptionsCacheItem = $this->subscriptionsCache->getItem( - $this->generatePrivateCacheKeyPart( - $this->encodeIriToCacheKey($iri), - $fields - ) - - ); + $subscriptionsCacheItem = $this->getSubscriptionsCacheItem($iri, $privatePartitionKey); if ($subscriptionsCacheItem->isHit()) { return $subscriptionsCacheItem->get(); @@ -115,9 +108,14 @@ private function getSubscriptionsFromIri(string $iri, array $fields = []): array return []; } - private function removeItemFromSubscriptionCache(string $iri): void + private function getSubscriptionsCacheItem(string $iri, ?string $privatePartitionKey = null): CacheItemInterface { - $cacheKey = $this->encodeIriToCacheKey($iri); + return $this->subscriptionsCache->getItem($this->generateCacheKey($iri, $privatePartitionKey)); + } + + private function removeItemFromSubscriptionCache(string $iri, ?string $privatePartitionKey = null): void + { + $cacheKey = $this->generateCacheKey($iri, $privatePartitionKey); if ($this->subscriptionsCache->hasItem($cacheKey)) { $this->subscriptionsCache->deleteItem($cacheKey); } @@ -128,105 +126,172 @@ private function encodeIriToCacheKey(string $iri): string return str_replace('/', '_', $iri); } - private function getResourceId(mixed $privateField, object $previousObject): string + private function getPrivateFieldValue(string $privateField, object $object): string { - $id = $previousObject->{'get'.ucfirst($privateField)}(); - if (is_object($id) && method_exists($id, 'getId')) { - $id = $id->getId(); + return PropertyAccessorValueExtractor::getValue($object, $privateField); + } + + private function getCollectionIri(string $iri): string + { + return substr($iri, 0, strrpos($iri, '/')); + } + + private function getCollectionSubscriptionIri(string $resourceClass, object $object, ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory): string + { + $resourceMetadata = $resourceMetadataCollectionFactory->create($resourceClass); + + try { + $collectionOperation = $resourceMetadata->getOperation(forceCollection: true, forceGraphQl: true); + + return $this->iriConverter->getIriFromResource($resourceClass, UrlGeneratorInterface::ABS_PATH, $collectionOperation) ?? $this->getCollectionIri($this->iriConverter->getIriFromResource($object)); + } catch (OperationNotFoundException) { + return $this->getCollectionIri($this->iriConverter->getIriFromResource($object)); + } + } + + /** + * @return array + */ + private function getPrivateFieldData(bool $private, array $privateFields, ?object $object): array + { + if (!$private || [] === $privateFields || null === $object) { + return []; } - if ($id instanceof \Stringable || is_numeric($id)) { - return (string) $id; + + $privateFieldData = []; + foreach ($privateFields as $privateField) { + try { + $privateFieldData[$privateField] = $this->getPrivateFieldValue($privateField, $object); + } catch (NoSuchPropertyException|AccessException) { + continue; + } } - return $id; + return $privateFieldData; } - private function getCollectionIri(string $iri): string + private function getPrivatePartitionKey(array $privateFieldData): ?string { - return substr($iri, 0, strrpos($iri, '/')); + if ([] === $privateFieldData) { + return null; + } + + $privatePartitionData = []; + foreach ($privateFieldData as $field => $value) { + $privatePartitionData[] = \sprintf('%s=%s', $field, $value); + } + + return hash('sha256', implode('|', $privatePartitionData)); + } + + private function validateMercureOptions(bool $private, array $privateFields): void + { + if ([] !== $privateFields && !$private) { + throw new InvalidArgumentException('"private_fields" requires "mercure.private" to be true.'); + } } - private function getCreatedOrUpdatedPayloads(object $object): array + private function getCreatedOrUpdatedPayloads(object $object, string $type): array { $resourceClass = $this->getObjectClass($object); $resourceMetadata = $this->resourceMetadataCollectionFactory->create($resourceClass); $shortName = $resourceMetadata->getOperation()->getShortName(); - $payloads = []; + $payloadsBySubscriptionId = []; foreach ($resourceMetadata as $apiResource) { foreach ($apiResource->getGraphQlOperations() as $operation) { if (!$operation instanceof Subscription) { continue; } - $mercure = $resourceMetadata->getOperation()->getMercure() ?? false; - $operationMercure = $operation->getMercure() ?? false; - if ($mercure !== false && $operationMercure !== false) { - /** @noinspection SlowArrayOperationsInLoopInspection */ - $mercure = array_merge($mercure, $operationMercure); + if ('create' === $type && !$operation instanceof CollectionOperationInterface) { + continue; } + $mercure = $operation->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 - ); - } - } + $privateFieldData = $this->getPrivateFieldData($private, $privateFieldsConfig, $object); + $privatePartitionKey = $this->getPrivatePartitionKey($privateFieldData); $iri = $this->iriConverter->getIriFromResource($object); - // Add collection subscriptions - $subscriptions = array_merge( - $this->getSubscriptionsFromIri($this->getCollectionIri($iri), $privateFieldData), - $this->getSubscriptionsFromIri($iri) + $collectionIri = $this->getCollectionSubscriptionIri($resourceClass, $object, $this->resourceMetadataCollectionFactory); + $this->appendNormalizedPayloads( + $payloadsBySubscriptionId, + $this->getSubscriptionsFromIri($collectionIri, $privatePartitionKey), + $object, + $shortName ); - 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 - ]; - $subscriptionOperation = (new Subscription())->withName('mercure_subscription')->withShortName( - $shortName + if ('create' !== $type) { + $itemSubscriptionsCacheItem = $this->getSubscriptionsCacheItem($iri, $privatePartitionKey); + $itemSubscriptions = $itemSubscriptionsCacheItem->isHit() ? $itemSubscriptionsCacheItem->get() : []; + $updatedItemSubscriptions = $this->appendNormalizedPayloads( + $payloadsBySubscriptionId, + $itemSubscriptions, + $object, + $shortName, + true ); - $data = $this->normalizeProcessor->process($object, $subscriptionOperation, [], $resolverContext); - - unset($data['clientSubscriptionId']); - if ($data !== $subscriptionResult) { - $payloads[] = [$subscriptionId, $data]; + if ($updatedItemSubscriptions !== $itemSubscriptions) { + $itemSubscriptionsCacheItem->set($updatedItemSubscriptions); + $this->subscriptionsCache->save($itemSubscriptionsCacheItem); } } } } - return $payloads; + return array_values($payloadsBySubscriptionId); + } + + /** + * @param array $payloadsBySubscriptionId + * @param-out array $payloadsBySubscriptionId + * @param array, array}> $subscriptions + * + * @return array, array}> + */ + private function appendNormalizedPayloads(array &$payloadsBySubscriptionId, array $subscriptions, object $object, string $shortName, bool $updateCachedResult = false): array + { + $subscriptionOperation = (new Subscription())->withName('mercure_subscription')->withShortName($shortName); + + foreach ($subscriptions as $index => [$subscriptionId, $subscriptionFields, $subscriptionResult]) { + $resolverContext = [ + 'fields' => $subscriptionFields, + 'is_collection' => false, + 'is_mutation' => false, + 'is_subscription' => true, + ]; + $data = $this->normalizeProcessor->process($object, $subscriptionOperation, [], $resolverContext); + + unset($data['clientSubscriptionId']); + + if ($data !== $subscriptionResult) { + $payloadsBySubscriptionId[$subscriptionId] = [$subscriptionId, $data]; + + if ($updateCachedResult) { + $subscriptions[$index][2] = $data; + } + } + } + + return $subscriptions; } private function getDeletePushPayloads(object $object): array { $iri = $object->id; - $subscriptions = array_merge( - $this->getSubscriptionsFromIri($iri), - $this->getSubscriptionsFromIri($this->getCollectionIri($iri), $object->private), - ); - + $privatePartitionKey = $this->getPrivatePartitionKey($object->private); $payloads = []; $payload = ['type' => 'delete', 'payload' => ['id' => $object->id, 'iri' => $object->iri, 'type' => $object->type]]; - foreach ($subscriptions as [$subscriptionId, $subscriptionFields, $subscriptionResult]) { + // Check for resource class + $collectionIri = isset($object->resourceClass) ? $this->getCollectionSubscriptionIri($object->resourceClass, (object) ['id' => $iri], $this->resourceMetadataCollectionFactory) : $this->getCollectionIri($iri); + foreach ($this->getSubscriptionsFromIri($iri, $privatePartitionKey) as [$subscriptionId, $subscriptionFields, $subscriptionResult]) { + $payloads[] = [$subscriptionId, $payload]; + } + foreach ($this->getSubscriptionsFromIri($collectionIri, $privatePartitionKey) as [$subscriptionId, $subscriptionFields, $subscriptionResult]) { $payloads[] = [$subscriptionId, $payload]; } - $this->removeItemFromSubscriptionCache($iri); + $this->removeItemFromSubscriptionCache($iri, $privatePartitionKey); return $payloads; } @@ -235,11 +300,9 @@ private function updateSubscriptionItemCacheData( string $iri, array $fields, ?array $result, - bool $private, - array $privateFields, - ?object $previousObject, + ?string $privatePartitionKey = null, ): string { - $subscriptionsCacheItem = $this->subscriptionsCache->getItem($this->encodeIriToCacheKey($iri)); + $subscriptionsCacheItem = $this->subscriptionsCache->getItem($this->generateCacheKey($iri, $privatePartitionKey)); $subscriptions = []; if ($subscriptionsCacheItem->isHit()) { /* @@ -254,14 +317,7 @@ private function updateSubscriptionItemCacheData( } 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); - } + $subscriptionId = $this->subscriptionIdentifierGenerator->generateSubscriptionIdentifier($fields); $subscriptions[] = [$subscriptionId, $fields, $result]; $subscriptionsCacheItem->set($subscriptions); $this->subscriptionsCache->save($subscriptionsCacheItem); @@ -271,16 +327,10 @@ private function updateSubscriptionItemCacheData( private function updateSubscriptionCollectionCacheData( string $iri, - array $fields, - array $privateFieldData, + array $fields, + ?string $privatePartitionKey = null, ): string { - - $subscriptionCollectionCacheItem = $this->subscriptionsCache->getItem( - $this->generatePrivateCacheKeyPart( - $this->encodeIriToCacheKey($this->getCollectionIri($iri)), - $privateFieldData - ), - ); + $subscriptionCollectionCacheItem = $this->subscriptionsCache->getItem($this->generateCacheKey($this->getCollectionIri($iri), $privatePartitionKey)); $collectionSubscriptions = []; if ($subscriptionCollectionCacheItem->isHit()) { $collectionSubscriptions = $subscriptionCollectionCacheItem->get(); @@ -298,12 +348,13 @@ private function updateSubscriptionCollectionCacheData( return $subscriptionId; } - private function generatePrivateCacheKeyPart(string $iriKey, array $fields = []): string + private function generateCacheKey(string $iri, ?string $privatePartitionKey = null): string { - if (empty($fields)) { - return $iriKey; + $cacheKey = $this->encodeIriToCacheKey($iri); + if (null === $privatePartitionKey) { + return $cacheKey; } - return $iriKey.'_'.implode('_', $fields); - } + return $cacheKey.'_'.$privatePartitionKey; + } } diff --git a/src/GraphQl/Subscription/SubscriptionManagerInterface.php b/src/GraphQl/Subscription/SubscriptionManagerInterface.php index 604cb1135ec..2e64162025e 100644 --- a/src/GraphQl/Subscription/SubscriptionManagerInterface.php +++ b/src/GraphQl/Subscription/SubscriptionManagerInterface.php @@ -25,5 +25,5 @@ interface SubscriptionManagerInterface */ public function retrieveSubscriptionId(array $context, ?array $result): ?string; - public function getPushPayloads(object $object, string $type): array; + public function getPushPayloads(object $object, string $type = 'update'): array; } diff --git a/src/GraphQl/Tests/Fixtures/ApiResource/MercureSubscriptionChildDummy.php b/src/GraphQl/Tests/Fixtures/ApiResource/MercureSubscriptionChildDummy.php new file mode 100644 index 00000000000..417fe5734e2 --- /dev/null +++ b/src/GraphQl/Tests/Fixtures/ApiResource/MercureSubscriptionChildDummy.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\GraphQl\Tests\Fixtures\ApiResource; + +final class MercureSubscriptionChildDummy +{ + private string $name; + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } +} diff --git a/src/GraphQl/Tests/Serializer/ItemNormalizerTest.php b/src/GraphQl/Tests/Serializer/ItemNormalizerTest.php index e528ee4e941..9982dea495a 100644 --- a/src/GraphQl/Tests/Serializer/ItemNormalizerTest.php +++ b/src/GraphQl/Tests/Serializer/ItemNormalizerTest.php @@ -15,22 +15,29 @@ use ApiPlatform\GraphQl\Serializer\ItemNormalizer; use ApiPlatform\GraphQl\Tests\Fixtures\ApiResource\Dummy; +use ApiPlatform\GraphQl\Tests\Fixtures\ApiResource\MercureSubscriptionChildDummy; use ApiPlatform\GraphQl\Tests\Fixtures\ApiResource\SecuredDummy; use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\QueryCollection; use ApiPlatform\Metadata\IdentifiersExtractorInterface; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\ResourceAccessCheckerInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; +use Doctrine\Common\Collections\ArrayCollection; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\TypeInfo\Type; /** * @author Kévin Dunglas @@ -254,6 +261,111 @@ public function testNormalizeNoResolverData(): void ])); } + public function testNormalizeMercureSubscriptionNestedCollectionRelations(): void + { + $firstChild = new MercureSubscriptionChildDummy(); + $firstChild->setName('alpha'); + $secondChild = new MercureSubscriptionChildDummy(); + $secondChild->setName('beta'); + $thirdChild = new MercureSubscriptionChildDummy(); + $thirdChild->setName('gamma'); + + $children = new ArrayCollection([$firstChild, $secondChild, $thirdChild]); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(MercureSubscriptionChildDummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['name'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(MercureSubscriptionChildDummy::class, 'name', Argument::type('array'))->willReturn( + (new ApiProperty()) + ->withNativeType(Type::string()) + ->withReadable(true) + ); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(MercureSubscriptionChildDummy::class)->willReturn(new ResourceMetadataCollection(MercureSubscriptionChildDummy::class, [ + (new ApiResource())->withGraphQlOperations([ + 'collection_query' => (new QueryCollection())->withName('collection_query')->withClass(MercureSubscriptionChildDummy::class), + ]), + ])); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, MercureSubscriptionChildDummy::class)->willReturn(MercureSubscriptionChildDummy::class); + $resourceClassResolverProphecy->getResourceClass($firstChild, null)->willReturn(MercureSubscriptionChildDummy::class); + $resourceClassResolverProphecy->getResourceClass($secondChild, null)->willReturn(MercureSubscriptionChildDummy::class); + $resourceClassResolverProphecy->getResourceClass($thirdChild, null)->willReturn(MercureSubscriptionChildDummy::class); + $resourceClassResolverProphecy->isResourceClass(MercureSubscriptionChildDummy::class)->willReturn(true); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->normalize('alpha', ItemNormalizer::FORMAT, Argument::type('array'))->willReturn('alpha'); + $serializerProphecy->normalize('beta', ItemNormalizer::FORMAT, Argument::type('array'))->willReturn('beta'); + $serializerProphecy->normalize('gamma', ItemNormalizer::FORMAT, Argument::type('array'))->willReturn('gamma'); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $identifiersExtractorProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + null, + null, + null, + null, + $resourceMetadataCollectionFactoryProphecy->reveal() + ); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $relationProperty = (new ApiProperty()) + ->withNativeType(Type::collection(Type::object(ArrayCollection::class), Type::object(MercureSubscriptionChildDummy::class), Type::int())) + ->withReadable(true) + ->withWritable(false) + ->withReadableLink(true); + + $normalizeCollectionOfRelations = \Closure::bind( + static fn (ItemNormalizer $normalizer, ApiProperty $property, iterable $attributeValue, string $resourceClass, ?string $format, array $context): array => $normalizer->normalizeCollectionOfRelations($property, $attributeValue, $resourceClass, $format, $context), + null, + ItemNormalizer::class + ); + + $this->assertSame([ + 'collection' => [ + ['name' => 'alpha'], + ['name' => 'beta'], + ['name' => 'gamma'], + ], + 'paginationInfo' => [ + 'hasNextPage' => true, + 'itemsPerPage' => 2, + 'lastPage' => 2, + 'totalCount' => 3, + ], + ], $normalizeCollectionOfRelations( + $normalizer, + $relationProperty, + $children, + MercureSubscriptionChildDummy::class, + ItemNormalizer::FORMAT, + [ + 'graphql_operation_name' => 'mercure_subscription', + 'attributes' => [ + 'collection' => ['name' => true], + 'paginationInfo' => [ + 'hasNextPage' => true, + 'itemsPerPage' => true, + 'lastPage' => true, + 'totalCount' => true, + ], + ], + 'pagination' => ['itemsPerPage' => 2], + 'no_resolver_data' => true, + ] + )); + } + public function testDenormalize(): void { $context = ['resource_class' => Dummy::class, 'api_allow_update' => true]; diff --git a/src/GraphQl/Tests/State/Processor/SubscriptionProcessorTest.php b/src/GraphQl/Tests/State/Processor/SubscriptionProcessorTest.php index f9ee045f8dc..759e6d36d0a 100644 --- a/src/GraphQl/Tests/State/Processor/SubscriptionProcessorTest.php +++ b/src/GraphQl/Tests/State/Processor/SubscriptionProcessorTest.php @@ -15,8 +15,10 @@ use ApiPlatform\GraphQl\State\Processor\SubscriptionProcessor; use ApiPlatform\GraphQl\Subscription\MercureSubscriptionIriGeneratorInterface; +use ApiPlatform\GraphQl\Subscription\OperationAwareSubscriptionManagerInterface; use ApiPlatform\GraphQl\Subscription\SubscriptionManagerInterface; use ApiPlatform\Metadata\GraphQl\Subscription; +use ApiPlatform\Metadata\GraphQl\SubscriptionCollection; use ApiPlatform\State\ProcessorInterface; use PHPUnit\Framework\TestCase; @@ -31,9 +33,11 @@ public function testProcess(): void $subscriptionManager = $this->createMock(SubscriptionManagerInterface::class); $subscriptionManager->expects($this->once())->method('retrieveSubscriptionId')->willReturn('/1'); $mercureSubscriptionIriGenerator = $this->createMock(MercureSubscriptionIriGeneratorInterface::class); - $mercureSubscriptionIriGenerator->expects($this->once())->method('generateMercureUrl')->with('/1', $operation->getMercure()['hub']); + $mercureSubscriptionIriGenerator->expects($this->once())->method('generateMercureUrl')->with('/1', $operation->getMercure()['hub'])->willReturn('mercure-url'); $processor = new SubscriptionProcessor($decorated, $subscriptionManager, $mercureSubscriptionIriGenerator); - $processor->process([], $operation, [], $context); + $result = $processor->process([], $operation, [], $context); + + $this->assertSame('mercure-url', $result['mercureUrl']); } public function testProcessWithoutId(): void @@ -63,4 +67,46 @@ public function testProcessWithoutMercure(): void $processor = new SubscriptionProcessor($decorated, $subscriptionManager, $mercureSubscriptionIriGenerator); $processor->process([], $operation, [], $context); } + + public function testProcessForwardsCollectionOperationToOperationAwareManager(): void + { + $operation = new SubscriptionCollection(mercure: ['hub' => 'mercure.rocks']); + $context = ['context' => 'value']; + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once())->method('process')->willReturn([]); + $subscriptionManager = $this->createMock(OperationAwareSubscriptionManagerInterface::class); + $subscriptionManager->expects($this->once())->method('retrieveSubscriptionId')->with($context, [], $operation)->willReturn('/1'); + $mercureSubscriptionIriGenerator = $this->createMock(MercureSubscriptionIriGeneratorInterface::class); + $mercureSubscriptionIriGenerator->expects($this->once())->method('generateMercureUrl')->with('/1', $operation->getMercure()['hub'])->willReturn('mercure-url'); + $processor = new SubscriptionProcessor($decorated, $subscriptionManager, $mercureSubscriptionIriGenerator); + + $result = $processor->process([], $operation, [], $context); + + $this->assertSame('mercure-url', $result['mercureUrl']); + } + + public function testProcessCollectionSubscriptionKeepsDecoratedPayloadShape(): void + { + $operation = new SubscriptionCollection(mercure: ['hub' => 'mercure.rocks']); + $context = ['context' => 'value']; + $decoratedPayload = [ + 'shortName' => ['id' => '/dummies/1'], + 'clientSubscriptionId' => 'client-subscription-id', + ]; + + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once())->method('process')->willReturn($decoratedPayload); + $subscriptionManager = $this->createMock(OperationAwareSubscriptionManagerInterface::class); + $subscriptionManager->expects($this->once())->method('retrieveSubscriptionId')->with($context, $decoratedPayload, $operation)->willReturn('/1'); + $mercureSubscriptionIriGenerator = $this->createMock(MercureSubscriptionIriGeneratorInterface::class); + $mercureSubscriptionIriGenerator->expects($this->once())->method('generateMercureUrl')->with('/1', $operation->getMercure()['hub'])->willReturn('mercure-url'); + $processor = new SubscriptionProcessor($decorated, $subscriptionManager, $mercureSubscriptionIriGenerator); + + $result = $processor->process([], $operation, [], $context); + + $this->assertSame(['id' => '/dummies/1'], $result['shortName']); + $this->assertSame('client-subscription-id', $result['clientSubscriptionId']); + $this->assertSame('mercure-url', $result['mercureUrl']); + $this->assertArrayNotHasKey('isCollection', $result); + } } diff --git a/src/GraphQl/Tests/Subscription/SubscriptionManagerTest.php b/src/GraphQl/Tests/Subscription/SubscriptionManagerTest.php index d895c1c1024..04a5137126b 100644 --- a/src/GraphQl/Tests/Subscription/SubscriptionManagerTest.php +++ b/src/GraphQl/Tests/Subscription/SubscriptionManagerTest.php @@ -17,15 +17,19 @@ use ApiPlatform\GraphQl\Subscription\SubscriptionManager; use ApiPlatform\GraphQl\Tests\Fixtures\ApiResource\Dummy; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GraphQl\Subscription; +use ApiPlatform\Metadata\GraphQl\SubscriptionCollection; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\State\ProcessorInterface; use GraphQL\Type\Definition\ResolveInfo; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Cache\CacheItemInterface; @@ -58,6 +62,22 @@ protected function setUp(): void $this->subscriptionManager = new SubscriptionManager($this->subscriptionsCacheProphecy->reveal(), $this->subscriptionIdentifierGeneratorProphecy->reveal(), $this->normalizeProcessor->reveal(), $this->iriConverterProphecy->reveal(), $this->resourceMetadataCollectionFactory->reveal()); } + private function createCollectionSubscription(array|bool|null $mercure = null): SubscriptionCollection + { + return (new SubscriptionCollection()) + ->withName('update_collection') + ->withShortName('Dummy') + ->withMercure($mercure); + } + + private function createItemSubscription(array|bool|null $mercure = null): Subscription + { + return (new Subscription()) + ->withName('update') + ->withShortName('Dummy') + ->withMercure($mercure); + } + public function testRetrieveSubscriptionIdNoIdentifier(): void { $info = $this->prophesize(ResolveInfo::class); @@ -171,12 +191,243 @@ public function testRetrieveSubscriptionIdHitCachedDifferentFieldsOrder(): void $this->assertSame('subscriptionIdFoo', $this->subscriptionManager->retrieveSubscriptionId($context, $result)); } + public function testRetrieveSubscriptionIdPartitionedPrivateItemUsesDedicatedCacheKey(): void + { + $infoProphecy = $this->prophesize(ResolveInfo::class); + $fields = ['fields' => true]; + $infoProphecy->getFieldSelection(\PHP_INT_MAX)->willReturn($fields); + + $previousObject = new class { + public function getTenant(): int + { + return 42; + } + }; + + $context = [ + 'args' => ['input' => ['id' => '/foos/34']], + 'info' => $infoProphecy->reveal(), + 'is_collection' => false, + 'is_mutation' => false, + 'is_subscription' => true, + 'graphql_context' => ['previous_object' => $previousObject], + ]; + $result = ['result', 'clientSubscriptionId' => 'client-subscription-id']; + $operation = new Subscription(mercure: ['private' => true, 'private_fields' => ['tenant']]); + + $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $cacheItemProphecy->isHit()->willReturn(false); + $subscriptionId = 'subscriptionId'; + $this->subscriptionIdentifierGeneratorProphecy->generateSubscriptionIdentifier($fields)->willReturn($subscriptionId); + $cacheItemProphecy->set([[$subscriptionId, $fields, ['result']]])->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_foos_34_'.hash('sha256', 'tenant=42'))->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->save($cacheItemProphecy->reveal())->shouldBeCalled(); + + $this->assertSame($subscriptionId, $this->subscriptionManager->retrieveSubscriptionId($context, $result, $operation)); + } + + public function testRetrieveSubscriptionIdPartitionedPrivateItemUsesPropertyAccess(): void + { + $infoProphecy = $this->prophesize(ResolveInfo::class); + $fields = ['fields' => true]; + $infoProphecy->getFieldSelection(\PHP_INT_MAX)->willReturn($fields); + + $previousObject = new class { + public int $tenant = 42; + }; + + $context = [ + 'args' => ['input' => ['id' => '/foos/34']], + 'info' => $infoProphecy->reveal(), + 'is_collection' => false, + 'is_mutation' => false, + 'is_subscription' => true, + 'graphql_context' => ['previous_object' => $previousObject], + ]; + $result = ['result', 'clientSubscriptionId' => 'client-subscription-id']; + $operation = new Subscription(mercure: ['private' => true, 'private_fields' => ['tenant']]); + + $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $cacheItemProphecy->isHit()->willReturn(false); + $subscriptionId = 'propertyAccessSubscriptionId'; + $this->subscriptionIdentifierGeneratorProphecy->generateSubscriptionIdentifier($fields)->willReturn($subscriptionId); + $cacheItemProphecy->set([[$subscriptionId, $fields, ['result']]])->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_foos_34_'.hash('sha256', 'tenant=42'))->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->save($cacheItemProphecy->reveal())->shouldBeCalled(); + + $this->assertSame($subscriptionId, $this->subscriptionManager->retrieveSubscriptionId($context, $result, $operation)); + } + + public function testRetrieveSubscriptionIdSharedPrivateItemDoesNotPartitionCacheKey(): void + { + $infoProphecy = $this->prophesize(ResolveInfo::class); + $fields = ['fields' => true]; + $infoProphecy->getFieldSelection(\PHP_INT_MAX)->willReturn($fields); + + $context = [ + 'args' => ['input' => ['id' => '/foos/34']], + 'info' => $infoProphecy->reveal(), + 'is_collection' => false, + 'is_mutation' => false, + 'is_subscription' => true, + ]; + $result = ['result', 'clientSubscriptionId' => 'client-subscription-id']; + $operation = new Subscription(mercure: ['private' => true]); + + $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $cacheItemProphecy->isHit()->willReturn(false); + $subscriptionId = 'sharedPrivateItemSubscriptionId'; + $this->subscriptionIdentifierGeneratorProphecy->generateSubscriptionIdentifier($fields)->willReturn($subscriptionId); + $cacheItemProphecy->set([[$subscriptionId, $fields, ['result']]])->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_foos_34')->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->save($cacheItemProphecy->reveal())->shouldBeCalled(); + + $this->assertSame($subscriptionId, $this->subscriptionManager->retrieveSubscriptionId($context, $result, $operation)); + } + + public function testRetrieveSubscriptionIdPartitionKeyUsesDeclaredFieldOrderAndNames(): void + { + $infoProphecy = $this->prophesize(ResolveInfo::class); + $fields = ['fields' => true]; + $infoProphecy->getFieldSelection(\PHP_INT_MAX)->willReturn($fields); + + $previousObject = new class { + public function getRegion(): string + { + return 'eu'; + } + + public function getTenant(): int + { + return 42; + } + }; + + $context = [ + 'args' => ['input' => ['id' => '/foos/34']], + 'info' => $infoProphecy->reveal(), + 'is_collection' => false, + 'is_mutation' => false, + 'is_subscription' => true, + 'graphql_context' => ['previous_object' => $previousObject], + ]; + $result = ['result', 'clientSubscriptionId' => 'client-subscription-id']; + $operation = new Subscription(mercure: ['private' => true, 'private_fields' => ['region', 'tenant']]); + + $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $cacheItemProphecy->isHit()->willReturn(false); + $subscriptionId = 'orderedPartitionSubscriptionId'; + $this->subscriptionIdentifierGeneratorProphecy->generateSubscriptionIdentifier($fields)->willReturn($subscriptionId); + $cacheItemProphecy->set([[$subscriptionId, $fields, ['result']]])->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_foos_34_'.hash('sha256', 'region=eu|tenant=42'))->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->save($cacheItemProphecy->reveal())->shouldBeCalled(); + + $this->assertSame($subscriptionId, $this->subscriptionManager->retrieveSubscriptionId($context, $result, $operation)); + } + + public function testRetrieveSubscriptionIdRejectsPrivateFieldsWithoutPrivateMercure(): void + { + $infoProphecy = $this->prophesize(ResolveInfo::class); + $infoProphecy->getFieldSelection(\PHP_INT_MAX)->willReturn(['fields' => true]); + + $context = [ + 'args' => ['input' => ['id' => '/foos/34']], + 'info' => $infoProphecy->reveal(), + 'is_collection' => false, + 'is_mutation' => false, + 'is_subscription' => true, + ]; + $operation = new Subscription(mercure: ['private_fields' => ['tenant']]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('"private_fields" requires "mercure.private" to be true.'); + + $this->subscriptionManager->retrieveSubscriptionId($context, ['result'], $operation); + } + + public function testRetrieveSubscriptionIdCollectionOperationUsesCollectionRegistrationPath(): void + { + $infoProphecy = $this->prophesize(ResolveInfo::class); + $fields = ['fields' => true]; + $infoProphecy->getFieldSelection(\PHP_INT_MAX)->willReturn($fields); + + $context = ['args' => ['input' => ['id' => '/foos/34']], 'info' => $infoProphecy->reveal(), 'is_collection' => true, 'is_mutation' => false, 'is_subscription' => true]; + $operation = $this->createCollectionSubscription(); + + $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $cacheItemProphecy->isHit()->willReturn(false); + $subscriptionId = 'collectionSubscriptionId'; + $this->subscriptionIdentifierGeneratorProphecy->generateSubscriptionIdentifier($fields + ['__collection' => true])->willReturn($subscriptionId); + $cacheItemProphecy->set([[$subscriptionId, $fields, []]])->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_foos')->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->save($cacheItemProphecy->reveal())->shouldBeCalled(); + + $this->assertSame($subscriptionId, $this->subscriptionManager->retrieveSubscriptionId($context, null, $operation)); + } + + public function testRetrieveSubscriptionIdSharedPrivateCollectionDoesNotPartitionCacheKey(): void + { + $infoProphecy = $this->prophesize(ResolveInfo::class); + $fields = ['fields' => true]; + $infoProphecy->getFieldSelection(\PHP_INT_MAX)->willReturn($fields); + + $context = ['args' => ['input' => ['id' => '/foos/34']], 'info' => $infoProphecy->reveal(), 'is_collection' => true, 'is_mutation' => false, 'is_subscription' => true]; + $operation = $this->createCollectionSubscription(['private' => true]); + + $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $cacheItemProphecy->isHit()->willReturn(false); + $subscriptionId = 'sharedPrivateCollectionSubscriptionId'; + $this->subscriptionIdentifierGeneratorProphecy->generateSubscriptionIdentifier($fields + ['__collection' => true])->willReturn($subscriptionId); + $cacheItemProphecy->set([[$subscriptionId, $fields, []]])->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_foos')->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->save($cacheItemProphecy->reveal())->shouldBeCalled(); + + $this->assertSame($subscriptionId, $this->subscriptionManager->retrieveSubscriptionId($context, null, $operation)); + } + + public function testRetrieveSubscriptionIdPartitionedPrivateCollectionUsesDedicatedCacheKey(): void + { + $infoProphecy = $this->prophesize(ResolveInfo::class); + $fields = ['fields' => true]; + $infoProphecy->getFieldSelection(\PHP_INT_MAX)->willReturn($fields); + + $previousObject = new class { + public function getTenant(): int + { + return 42; + } + }; + + $context = [ + 'args' => ['input' => ['id' => '/foos/34']], + 'info' => $infoProphecy->reveal(), + 'is_collection' => true, + 'is_mutation' => false, + 'is_subscription' => true, + 'graphql_context' => ['previous_object' => $previousObject], + ]; + $operation = $this->createCollectionSubscription(['private' => true, 'private_fields' => ['tenant']]); + + $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $cacheItemProphecy->isHit()->willReturn(false); + $subscriptionId = 'partitionedCollectionSubscriptionId'; + $this->subscriptionIdentifierGeneratorProphecy->generateSubscriptionIdentifier($fields + ['__collection' => true])->willReturn($subscriptionId); + $cacheItemProphecy->set([[$subscriptionId, $fields, []]])->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_foos_'.hash('sha256', 'tenant=42'))->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->save($cacheItemProphecy->reveal())->shouldBeCalled(); + + $this->assertSame($subscriptionId, $this->subscriptionManager->retrieveSubscriptionId($context, null, $operation)); + } + public function testGetPushPayloadsNoHit(): void { $object = new Dummy(); + $itemSubscription = $this->createItemSubscription(true); $this->resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ - (new ApiResource())->withOperations(new Operations([(new Get())->withShortName('Dummy')])), + (new ApiResource()) + ->withOperations(new Operations([(new Get())->withShortName('Dummy')])) + ->withGraphQlOperations(['update' => $itemSubscription]), ])); $this->iriConverterProphecy->getIriFromResource($object)->willReturn('/dummies/2'); @@ -193,12 +444,20 @@ public function testGetPushPayloadsNoHit(): void public function testGetPushPayloadsHit(): void { $object = new Dummy(); + $itemSubscription = $this->createItemSubscription(true); + $collectionOperation = $this->createCollectionSubscription(true); $this->resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ - (new ApiResource())->withOperations(new Operations([(new Get())->withShortName('Dummy')])), + (new ApiResource()) + ->withOperations(new Operations([(new Get())->withShortName('Dummy')])) + ->withGraphQlOperations([ + 'update' => $itemSubscription, + 'update_collection' => $collectionOperation, + ]), ])); $this->iriConverterProphecy->getIriFromResource($object)->willReturn('/dummies/2'); + $this->iriConverterProphecy->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_PATH, $collectionOperation)->shouldBeCalled()->willReturn('/graphql/dummies'); $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); $cacheItemProphecy->isHit()->willReturn(true); @@ -206,6 +465,10 @@ public function testGetPushPayloadsHit(): void ['subscriptionIdFoo', ['fieldsFoo'], ['resultFoo']], ['subscriptionIdBar', ['fieldsBar'], ['resultBar']], ]); + $cacheItemProphecy->set([ + ['subscriptionIdFoo', ['fieldsFoo'], ['newResultFoo']], + ['subscriptionIdBar', ['fieldsBar'], ['resultBar']], + ])->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); $cacheItemProphecyCollection = $this->prophesize(CacheItemInterface::class); $cacheItemProphecyCollection->isHit()->willReturn(true); $cacheItemProphecyCollection->get()->willReturn([ @@ -213,7 +476,8 @@ public function testGetPushPayloadsHit(): void ['subscriptionIdBar', ['fieldsBar'], []], ]); $this->subscriptionsCacheProphecy->getItem('_dummies_2')->willReturn($cacheItemProphecy->reveal()); - $this->subscriptionsCacheProphecy->getItem('_dummies')->willReturn($cacheItemProphecyCollection->reveal()); + $this->subscriptionsCacheProphecy->getItem('_graphql_dummies')->shouldBeCalled()->willReturn($cacheItemProphecyCollection->reveal()); + $this->subscriptionsCacheProphecy->save($cacheItemProphecy->reveal())->shouldBeCalled(); $this->normalizeProcessor->process( $object, @@ -233,6 +497,435 @@ public function testGetPushPayloadsHit(): void ['resultBar', 'clientSubscriptionId' => 'client-subscription-id'] ); - $this->assertEquals([['subscriptionIdFoo', ['newResultFoo']], ['subscriptionIdFoo', ['newResultFoo']]], $this->subscriptionManager->getPushPayloads($object, 'update')); + $this->assertEquals([['subscriptionIdFoo', ['newResultFoo']], ['subscriptionIdBar', ['resultBar']]], $this->subscriptionManager->getPushPayloads($object, 'update')); + } + + public function testGetPushPayloadsUpdatesCachedItemSnapshotAfterPublishing(): void + { + $object = new Dummy(); + $itemSubscription = $this->createItemSubscription(true); + $collectionOperation = $this->createCollectionSubscription(true); + + $this->resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource()) + ->withOperations(new Operations([(new Get())->withShortName('Dummy')])) + ->withGraphQlOperations([ + 'update' => $itemSubscription, + 'update_collection' => $collectionOperation, + ]), + ])); + + $this->iriConverterProphecy->getIriFromResource($object)->willReturn('/dummies/2'); + $this->iriConverterProphecy->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_PATH, $collectionOperation)->willReturn('/graphql/dummies'); + + $itemCacheItemFirstCallProphecy = $this->prophesize(CacheItemInterface::class); + $itemCacheItemFirstCallProphecy->isHit()->willReturn(true); + $itemCacheItemFirstCallProphecy->get()->willReturn([ + ['subscriptionIdFoo', ['fieldsFoo'], ['staleResultFoo']], + ]); + $itemCacheItemFirstCallProphecy->set([ + ['subscriptionIdFoo', ['fieldsFoo'], ['freshResultFoo']], + ])->shouldBeCalled()->willReturn($itemCacheItemFirstCallProphecy->reveal()); + + $itemCacheItemSecondCallProphecy = $this->prophesize(CacheItemInterface::class); + $itemCacheItemSecondCallProphecy->isHit()->willReturn(true); + $itemCacheItemSecondCallProphecy->get()->willReturn([ + ['subscriptionIdFoo', ['fieldsFoo'], ['freshResultFoo']], + ]); + $itemCacheItemSecondCallProphecy->set(Argument::any())->shouldNotBeCalled(); + + $collectionCacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $collectionCacheItemProphecy->isHit()->willReturn(false); + + $this->subscriptionsCacheProphecy->getItem('_graphql_dummies')->willReturn($collectionCacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_dummies_2')->willReturn( + $itemCacheItemFirstCallProphecy->reveal(), + $itemCacheItemSecondCallProphecy->reveal() + ); + $this->subscriptionsCacheProphecy->save($itemCacheItemFirstCallProphecy->reveal())->shouldBeCalledTimes(1); + + $this->normalizeProcessor->process( + $object, + (new Subscription())->withName('mercure_subscription')->withShortName('Dummy'), + [], + ['fields' => ['fieldsFoo'], 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true] + )->willReturn( + ['freshResultFoo', 'clientSubscriptionId' => 'client-subscription-id'], + ['freshResultFoo', 'clientSubscriptionId' => 'client-subscription-id'] + ); + + $this->assertEquals([['subscriptionIdFoo', ['freshResultFoo']]], $this->subscriptionManager->getPushPayloads($object, 'update')); + $this->assertEquals([], $this->subscriptionManager->getPushPayloads($object, 'update')); + } + + public function testGetPushPayloadsCreateTargetsCollectionSubscriptionsOnly(): void + { + $object = new Dummy(); + $itemSubscription = $this->createItemSubscription(true); + $collectionOperation = $this->createCollectionSubscription(true); + + $this->resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource()) + ->withOperations(new Operations([(new Get())->withShortName('Dummy')])) + ->withGraphQlOperations([ + 'update' => $itemSubscription, + 'update_collection' => $collectionOperation, + ]), + ])); + + $this->iriConverterProphecy->getIriFromResource($object)->willReturn('/dummies/2'); + $this->iriConverterProphecy->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_PATH, $collectionOperation)->shouldBeCalled()->willReturn('/graphql/dummies'); + + $cacheItemProphecyCollection = $this->prophesize(CacheItemInterface::class); + $cacheItemProphecyCollection->isHit()->willReturn(true); + $cacheItemProphecyCollection->get()->willReturn([ + ['collectionSubscriptionId', ['collectionFields'], []], + ]); + $this->subscriptionsCacheProphecy->getItem('_graphql_dummies')->shouldBeCalled()->willReturn($cacheItemProphecyCollection->reveal()); + $this->subscriptionsCacheProphecy->getItem('_dummies_2')->shouldNotBeCalled(); + + $this->normalizeProcessor->process( + $object, + (new Subscription())->withName('mercure_subscription')->withShortName('Dummy'), + [], + ['fields' => ['collectionFields'], 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true] + )->willReturn( + ['createdResult', 'clientSubscriptionId' => 'client-subscription-id'] + ); + + $this->assertEquals([['collectionSubscriptionId', ['createdResult']]], $this->subscriptionManager->getPushPayloads($object, 'create')); + } + + public function testGetPushPayloadsCreateUsesSharedPrivateCollectionCacheKey(): void + { + $object = new Dummy(); + $collectionOperation = $this->createCollectionSubscription(['private' => true]); + + $this->resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource()) + ->withOperations(new Operations([ + (new Get())->withShortName('Dummy')->withMercure(['private' => true]), + ])) + ->withGraphQlOperations([ + 'update_collection' => $collectionOperation, + ]), + ])); + + $this->iriConverterProphecy->getIriFromResource($object)->willReturn('/dummies/2'); + $this->iriConverterProphecy->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_PATH, $collectionOperation)->shouldBeCalled()->willReturn('/graphql/dummies'); + + $collectionCacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $collectionCacheItemProphecy->isHit()->willReturn(true); + $collectionCacheItemProphecy->get()->willReturn([ + ['sharedPrivateCollectionSubscriptionId', ['collectionFields'], []], + ]); + + $this->subscriptionsCacheProphecy->getItem('_graphql_dummies')->shouldBeCalled()->willReturn($collectionCacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem(Argument::containingString(hash('sha256', 'tenant=')))->shouldNotBeCalled(); + + $this->normalizeProcessor->process( + $object, + (new Subscription())->withName('mercure_subscription')->withShortName('Dummy'), + [], + ['fields' => ['collectionFields'], 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true] + )->willReturn( + ['sharedPrivateCreatedResult', 'clientSubscriptionId' => 'client-subscription-id'] + ); + + $this->assertEquals([['sharedPrivateCollectionSubscriptionId', ['sharedPrivateCreatedResult']]], $this->subscriptionManager->getPushPayloads($object, 'create')); + } + + public function testGetPushPayloadsCreateUsesPartitionedPrivateCollectionCacheKey(): void + { + $object = new class extends Dummy { + public function getTenant(): int + { + return 42; + } + }; + $collectionOperation = $this->createCollectionSubscription(['private' => true, 'private_fields' => ['tenant']]); + $partitionKey = hash('sha256', 'tenant=42'); + + $this->resourceMetadataCollectionFactory->create($object::class)->willReturn(new ResourceMetadataCollection($object::class, [ + (new ApiResource()) + ->withOperations(new Operations([ + (new Get())->withShortName('Dummy')->withMercure(['private' => true, 'private_fields' => ['tenant']]), + ])) + ->withGraphQlOperations([ + 'update_collection' => $collectionOperation, + ]), + ])); + + $this->iriConverterProphecy->getIriFromResource($object)->willReturn('/dummies/2'); + $this->iriConverterProphecy->getIriFromResource($object::class, UrlGeneratorInterface::ABS_PATH, $collectionOperation)->shouldBeCalled()->willReturn('/graphql/dummies'); + + $collectionCacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $collectionCacheItemProphecy->isHit()->willReturn(true); + $collectionCacheItemProphecy->get()->willReturn([ + ['partitionedCollectionSubscriptionId', ['collectionFields'], []], + ]); + + $this->subscriptionsCacheProphecy->getItem('_graphql_dummies_'.$partitionKey)->shouldBeCalled()->willReturn($collectionCacheItemProphecy->reveal()); + + $this->normalizeProcessor->process( + $object, + (new Subscription())->withName('mercure_subscription')->withShortName('Dummy'), + [], + ['fields' => ['collectionFields'], 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true] + )->willReturn( + ['partitionedCreatedResult', 'clientSubscriptionId' => 'client-subscription-id'] + ); + + $this->assertEquals([['partitionedCollectionSubscriptionId', ['partitionedCreatedResult']]], $this->subscriptionManager->getPushPayloads($object, 'create')); + } + + public function testGetPushPayloadsUpdatePublishesCollectionSubscriptionWithoutItemSubscription(): void + { + $object = new Dummy(); + $itemSubscription = $this->createItemSubscription(true); + $collectionOperation = $this->createCollectionSubscription(true); + + $this->resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource()) + ->withOperations(new Operations([(new Get())->withShortName('Dummy')])) + ->withGraphQlOperations([ + 'update' => $itemSubscription, + 'update_collection' => $collectionOperation, + ]), + ])); + + $this->iriConverterProphecy->getIriFromResource($object)->willReturn('/dummies/2'); + $this->iriConverterProphecy->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_PATH, $collectionOperation)->shouldBeCalled()->willReturn('/graphql/dummies'); + + $itemCacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $itemCacheItemProphecy->isHit()->willReturn(false); + + $collectionCacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $collectionCacheItemProphecy->isHit()->willReturn(true); + $collectionCacheItemProphecy->get()->willReturn([ + ['collectionSubscriptionId', ['collectionFields'], []], + ]); + + $this->subscriptionsCacheProphecy->getItem('_dummies_2')->shouldBeCalled()->willReturn($itemCacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_graphql_dummies')->shouldBeCalled()->willReturn($collectionCacheItemProphecy->reveal()); + + $this->normalizeProcessor->process( + $object, + (new Subscription())->withName('mercure_subscription')->withShortName('Dummy'), + [], + ['fields' => ['collectionFields'], 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true] + )->willReturn( + ['updatedCollectionResult', 'clientSubscriptionId' => 'client-subscription-id'] + ); + + $this->assertEquals([['collectionSubscriptionId', ['updatedCollectionResult']]], $this->subscriptionManager->getPushPayloads($object, 'update')); + } + + public function testGetPushPayloadsUpdateUsesSharedPrivateCollectionAndItemCacheKeys(): void + { + $object = new Dummy(); + $itemSubscription = $this->createItemSubscription(['private' => true]); + $collectionOperation = $this->createCollectionSubscription(['private' => true]); + + $this->resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource()) + ->withOperations(new Operations([ + (new Get())->withShortName('Dummy')->withMercure(['private' => true]), + ])) + ->withGraphQlOperations([ + 'update' => $itemSubscription, + 'update_collection' => $collectionOperation, + ]), + ])); + + $this->iriConverterProphecy->getIriFromResource($object)->willReturn('/dummies/2'); + $this->iriConverterProphecy->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_PATH, $collectionOperation)->shouldBeCalled()->willReturn('/graphql/dummies'); + + $itemCacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $itemCacheItemProphecy->isHit()->willReturn(false); + + $collectionCacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $collectionCacheItemProphecy->isHit()->willReturn(true); + $collectionCacheItemProphecy->get()->willReturn([ + ['sharedPrivateCollectionSubscriptionId', ['collectionFields'], []], + ]); + + $this->subscriptionsCacheProphecy->getItem('_dummies_2')->shouldBeCalled()->willReturn($itemCacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_graphql_dummies')->shouldBeCalled()->willReturn($collectionCacheItemProphecy->reveal()); + + $this->normalizeProcessor->process( + $object, + (new Subscription())->withName('mercure_subscription')->withShortName('Dummy'), + [], + ['fields' => ['collectionFields'], 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true] + )->willReturn( + ['sharedPrivateUpdatedResult', 'clientSubscriptionId' => 'client-subscription-id'] + ); + + $this->assertEquals([['sharedPrivateCollectionSubscriptionId', ['sharedPrivateUpdatedResult']]], $this->subscriptionManager->getPushPayloads($object, 'update')); + } + + public function testGetPushPayloadsUpdateUsesPartitionedPrivateCollectionAndItemCacheKeys(): void + { + $object = new class extends Dummy { + public function getTenant(): int + { + return 42; + } + }; + $itemSubscription = $this->createItemSubscription(['private' => true, 'private_fields' => ['tenant']]); + $collectionOperation = $this->createCollectionSubscription(['private' => true, 'private_fields' => ['tenant']]); + $partitionKey = hash('sha256', 'tenant=42'); + + $this->resourceMetadataCollectionFactory->create($object::class)->willReturn(new ResourceMetadataCollection($object::class, [ + (new ApiResource()) + ->withOperations(new Operations([ + (new Get())->withShortName('Dummy')->withMercure(['private' => true, 'private_fields' => ['tenant']]), + ])) + ->withGraphQlOperations([ + 'update' => $itemSubscription, + 'update_collection' => $collectionOperation, + ]), + ])); + + $this->iriConverterProphecy->getIriFromResource($object)->willReturn('/dummies/2'); + $this->iriConverterProphecy->getIriFromResource($object::class, UrlGeneratorInterface::ABS_PATH, $collectionOperation)->shouldBeCalled()->willReturn('/graphql/dummies'); + + $itemCacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $itemCacheItemProphecy->isHit()->willReturn(false); + + $collectionCacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $collectionCacheItemProphecy->isHit()->willReturn(true); + $collectionCacheItemProphecy->get()->willReturn([ + ['partitionedCollectionSubscriptionId', ['collectionFields'], []], + ]); + + $this->subscriptionsCacheProphecy->getItem('_dummies_2_'.$partitionKey)->shouldBeCalled()->willReturn($itemCacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_graphql_dummies_'.$partitionKey)->shouldBeCalled()->willReturn($collectionCacheItemProphecy->reveal()); + + $this->normalizeProcessor->process( + $object, + (new Subscription())->withName('mercure_subscription')->withShortName('Dummy'), + [], + ['fields' => ['collectionFields'], 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true] + )->willReturn( + ['partitionedUpdatedResult', 'clientSubscriptionId' => 'client-subscription-id'] + ); + + $this->assertEquals([['partitionedCollectionSubscriptionId', ['partitionedUpdatedResult']]], $this->subscriptionManager->getPushPayloads($object, 'update')); + } + + public function testGetPushPayloadsDeleteReturnsLightweightPayloadAndRemovesItemCache(): void + { + $object = new class { + public string $id = '/dummies/2'; + public string $iri = '/dummies/2'; + public string $type = 'Dummy'; + public array $private = []; + }; + + $itemCacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $itemCacheItemProphecy->isHit()->willReturn(true); + $itemCacheItemProphecy->get()->willReturn([ + ['itemSubscriptionId', ['itemFields'], ['result']], + ]); + + $collectionCacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $collectionCacheItemProphecy->isHit()->willReturn(true); + $collectionCacheItemProphecy->get()->willReturn([ + ['collectionSubscriptionId', ['collectionFields'], []], + ]); + + $this->subscriptionsCacheProphecy->getItem('_dummies_2')->shouldBeCalled()->willReturn($itemCacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_dummies')->shouldBeCalled()->willReturn($collectionCacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->hasItem('_dummies_2')->shouldBeCalled()->willReturn(true); + $this->subscriptionsCacheProphecy->deleteItem('_dummies_2')->shouldBeCalled(); + + $payload = ['type' => 'delete', 'payload' => ['id' => '/dummies/2', 'iri' => '/dummies/2', 'type' => 'Dummy']]; + + $this->assertEquals([ + ['itemSubscriptionId', $payload], + ['collectionSubscriptionId', $payload], + ], $this->subscriptionManager->getPushPayloads($object, 'delete')); + } + + public function testGetPushPayloadsDeleteReturnsPartitionedPrivatePayloadsAndRemovesPartitionedItemCache(): void + { + $object = new class { + public string $id = '/dummies/2'; + public string $iri = '/dummies/2'; + public string $type = 'Dummy'; + public array $private = ['tenant' => '42']; + }; + + $partitionKey = hash('sha256', 'tenant=42'); + + $itemCacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $itemCacheItemProphecy->isHit()->willReturn(true); + $itemCacheItemProphecy->get()->willReturn([ + ['partitionedItemSubscriptionId', ['itemFields'], ['result']], + ]); + + $collectionCacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $collectionCacheItemProphecy->isHit()->willReturn(true); + $collectionCacheItemProphecy->get()->willReturn([ + ['partitionedCollectionSubscriptionId', ['collectionFields'], []], + ]); + + $this->subscriptionsCacheProphecy->getItem('_dummies_2_'.$partitionKey)->shouldBeCalled()->willReturn($itemCacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_dummies_'.$partitionKey)->shouldBeCalled()->willReturn($collectionCacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->hasItem('_dummies_2_'.$partitionKey)->shouldBeCalled()->willReturn(true); + $this->subscriptionsCacheProphecy->deleteItem('_dummies_2_'.$partitionKey)->shouldBeCalled(); + + $payload = ['type' => 'delete', 'payload' => ['id' => '/dummies/2', 'iri' => '/dummies/2', 'type' => 'Dummy']]; + + $this->assertEquals([ + ['partitionedItemSubscriptionId', $payload], + ['partitionedCollectionSubscriptionId', $payload], + ], $this->subscriptionManager->getPushPayloads($object, 'delete')); + } + + public function testGetPushPayloadsDeleteUsesMetadataBasedCollectionSubscriptionIri(): void + { + $object = new class { + public string $resourceClass = Dummy::class; + public string $id = '/dummies/2'; + public string $iri = '/dummies/2'; + public string $type = 'Dummy'; + public array $private = []; + }; + $collectionOperation = $this->createCollectionSubscription(true); + + $this->resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource())->withGraphQlOperations([ + 'update_collection' => $collectionOperation, + ]), + ])); + + $this->iriConverterProphecy->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_PATH, $collectionOperation)->shouldBeCalled()->willReturn('/graphql/dummies'); + + $itemCacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $itemCacheItemProphecy->isHit()->willReturn(true); + $itemCacheItemProphecy->get()->willReturn([ + ['itemSubscriptionId', ['itemFields'], ['result']], + ]); + + $collectionCacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $collectionCacheItemProphecy->isHit()->willReturn(true); + $collectionCacheItemProphecy->get()->willReturn([ + ['collectionSubscriptionId', ['collectionFields'], []], + ]); + + $this->subscriptionsCacheProphecy->getItem('_dummies_2')->shouldBeCalled()->willReturn($itemCacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->getItem('_graphql_dummies')->shouldBeCalled()->willReturn($collectionCacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->hasItem('_dummies_2')->shouldBeCalled()->willReturn(true); + $this->subscriptionsCacheProphecy->deleteItem('_dummies_2')->shouldBeCalled(); + + $payload = ['type' => 'delete', 'payload' => ['id' => '/dummies/2', 'iri' => '/dummies/2', 'type' => 'Dummy']]; + + $this->assertEquals([ + ['itemSubscriptionId', $payload], + ['collectionSubscriptionId', $payload], + ], $this->subscriptionManager->getPushPayloads($object, 'delete')); } } diff --git a/src/GraphQl/Tests/Type/FieldsBuilderTest.php b/src/GraphQl/Tests/Type/FieldsBuilderTest.php index 268c7d56295..86a85555f5b 100644 --- a/src/GraphQl/Tests/Type/FieldsBuilderTest.php +++ b/src/GraphQl/Tests/Type/FieldsBuilderTest.php @@ -28,6 +28,7 @@ use ApiPlatform\Metadata\GraphQl\Query; use ApiPlatform\Metadata\GraphQl\QueryCollection; use ApiPlatform\Metadata\GraphQl\Subscription; +use ApiPlatform\Metadata\GraphQl\SubscriptionCollection; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Property\PropertyNameCollection; @@ -461,6 +462,46 @@ public static function subscriptionFieldsProvider(): array ], ], ], + 'collection subscription' => [\stdClass::class, (new SubscriptionCollection())->withClass(\stdClass::class)->withName('action_collection')->withShortName('ShortName')->withMercure(true), $graphqlType = new ObjectType(['name' => 'subscription', 'fields' => []]), $inputGraphqlType = new ObjectType(['name' => 'input', 'fields' => []]), $subscriptionResolver = static function (): void { + }, + [ + 'action_collectionShortNameSubscribe' => [ + 'type' => $graphqlType, + 'description' => 'Subscribes to the action event of a ShortName.', + 'args' => [ + 'input' => [ + 'type' => $inputGraphqlType, + 'description' => null, + 'args' => [], + 'resolve' => null, + 'deprecationReason' => null, + ], + ], + 'resolve' => $subscriptionResolver, + 'deprecationReason' => null, + ], + ], + ], + 'collection subscription custom description' => [\stdClass::class, (new SubscriptionCollection())->withClass(\stdClass::class)->withName('action_collection')->withShortName('ShortName')->withMercure(true)->withDescription('Custom collection description.'), $graphqlType = new ObjectType(['name' => 'subscription', 'fields' => []]), $inputGraphqlType = new ObjectType(['name' => 'input', 'fields' => []]), $subscriptionResolver = static function (): void { + }, + [ + 'action_collectionShortNameSubscribe' => [ + 'type' => $graphqlType, + 'description' => 'Custom collection description.', + 'args' => [ + 'input' => [ + 'type' => $inputGraphqlType, + 'description' => null, + 'args' => [], + 'resolve' => null, + 'deprecationReason' => null, + ], + ], + 'resolve' => $subscriptionResolver, + 'deprecationReason' => null, + ], + ], + ], ]; } diff --git a/src/GraphQl/Tests/Type/SchemaBuilderTest.php b/src/GraphQl/Tests/Type/SchemaBuilderTest.php index 5ba6b42af2e..a4c7b93f9b4 100644 --- a/src/GraphQl/Tests/Type/SchemaBuilderTest.php +++ b/src/GraphQl/Tests/Type/SchemaBuilderTest.php @@ -23,6 +23,7 @@ use ApiPlatform\Metadata\GraphQl\Query; use ApiPlatform\Metadata\GraphQl\QueryCollection; use ApiPlatform\Metadata\GraphQl\Subscription; +use ApiPlatform\Metadata\GraphQl\SubscriptionCollection; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; @@ -79,6 +80,7 @@ public function testGetSchema(string $resourceClass, ResourceMetadataCollection $this->fieldsBuilderProphecy->getCollectionQueryFields($resourceClass, Argument::that(static fn (Operation $arg): bool => 'custom_collection_query' === $arg->getName()), [])->willReturn(['custom_collection_query' => ['custom_collection_query_fields']]); $this->fieldsBuilderProphecy->getMutationFields($resourceClass, Argument::that(static fn (Operation $arg): bool => 'mutation' === $arg->getName()))->willReturn(['mutation' => ['mutation_fields']]); $this->fieldsBuilderProphecy->getSubscriptionFields($resourceClass, Argument::that(static fn (Operation $arg): bool => 'update' === $arg->getName()))->willReturn(['subscription' => ['subscription_fields']]); + $this->fieldsBuilderProphecy->getSubscriptionFields($resourceClass, Argument::that(static fn (Operation $arg): bool => 'update_collection' === $arg->getName()))->willReturn(['collectionSubscription' => ['collection_subscription_fields']]); $this->resourceNameCollectionFactoryProphecy->create()->willReturn(new ResourceNameCollection([$resourceClass])); $this->resourceMetadataCollectionFactoryProphecy->create($resourceClass)->willReturn($resourceMetadata); @@ -176,6 +178,40 @@ public static function schemaProvider(): array ], ]), ], + 'collection subscription' => [$resourceClass = 'resourceClass', new ResourceMetadataCollection($resourceClass, [(new ApiResource())->withGraphQlOperations(['update_collection' => (new SubscriptionCollection())->withName('update_collection')->withMercure(true)])]), + new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'node' => ['node_fields'], + ], + ]), + null, + new ObjectType([ + 'name' => 'Subscription', + 'fields' => [ + 'collectionSubscription' => ['collection_subscription_fields'], + ], + ]), + ], + 'item and collection subscriptions' => [$resourceClass = 'resourceClass', new ResourceMetadataCollection($resourceClass, [(new ApiResource())->withGraphQlOperations([ + 'update' => (new Subscription())->withName('update')->withMercure(true), + 'update_collection' => (new SubscriptionCollection())->withName('update_collection')->withMercure(true), + ])]), + new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'node' => ['node_fields'], + ], + ]), + null, + new ObjectType([ + 'name' => 'Subscription', + 'fields' => [ + 'subscription' => ['subscription_fields'], + 'collectionSubscription' => ['collection_subscription_fields'], + ], + ]), + ], ]; } } diff --git a/src/GraphQl/Tests/Type/TypeBuilderTest.php b/src/GraphQl/Tests/Type/TypeBuilderTest.php index ddef6ba5b4e..b7e726c6399 100644 --- a/src/GraphQl/Tests/Type/TypeBuilderTest.php +++ b/src/GraphQl/Tests/Type/TypeBuilderTest.php @@ -26,6 +26,7 @@ use ApiPlatform\Metadata\GraphQl\Query; use ApiPlatform\Metadata\GraphQl\QueryCollection; use ApiPlatform\Metadata\GraphQl\Subscription; +use ApiPlatform\Metadata\GraphQl\SubscriptionCollection; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\State\Pagination\Pagination; use GraphQL\Type\Definition\EnumType; @@ -470,6 +471,74 @@ public function testGetResourceObjectTypeSubscriptionNested(): void $resourceObjectType->config['fields'](); } + public function testGetResourceObjectTypeCollectionSubscription(): void + { + $resourceMetadata = new ResourceMetadataCollection(\stdClass::class, [(new ApiResource())->withGraphQlOperations([ + 'update_collection' => (new SubscriptionCollection())->withName('update_collection')->withShortName('shortName')->withDescription('description')->withMercure(true), + 'item_query' => (new Query())->withShortName('shortName')->withDescription('description'), + 'collection_query' => new QueryCollection(), + ])]); + $this->typesContainerProphecy->has('update_collectionShortNameSubscriptionPayload')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('update_collectionShortNameSubscriptionPayload', Argument::type(ObjectType::class))->shouldBeCalled(); + $this->typesContainerProphecy->has('Node')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); + + $operation = (new SubscriptionCollection())->withName('update_collection')->withShortName('shortName')->withDescription('description')->withMercure(true)->withClass(\stdClass::class); + /** @var ObjectType $resourceObjectType */ + $resourceObjectType = $this->typeBuilder->getResourceObjectType($resourceMetadata, $operation, null, ['input' => false]); + $this->assertSame('update_collectionShortNameSubscriptionPayload', $resourceObjectType->name); + $this->assertSame('description', $resourceObjectType->description); + $this->assertSame($this->defaultFieldResolver, $resourceObjectType->resolveFieldFn); + $this->assertArrayHasKey('interfaces', $resourceObjectType->config); + $this->assertEquals([], $resourceObjectType->config['interfaces']); + $this->assertArrayHasKey('fields', $resourceObjectType->config); + + $this->typesContainerProphecy->has('shortName')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('shortName', Argument::type(ObjectType::class))->shouldBeCalled(); + + $fieldsType = $resourceObjectType->config['fields'](); + $this->assertArrayHasKey('shortName', $fieldsType); + $this->assertArrayHasKey('clientSubscriptionId', $fieldsType); + $this->assertArrayHasKey('mercureUrl', $fieldsType); + $this->assertSame(GraphQLType::string(), $fieldsType['clientSubscriptionId']); + $this->assertSame(GraphQLType::string(), $fieldsType['mercureUrl']); + } + + public function testGetResourceObjectTypeCollectionSubscriptionUsesItemQueryAsWrappedPayload(): void + { + $itemQuery = (new Query())->withName('item_query')->withShortName('shortName')->withDescription('item description')->withClass(\stdClass::class); + $collectionQuery = (new QueryCollection())->withName('collection_query')->withShortName('shortName')->withDescription('collection description')->withClass(\stdClass::class); + $collectionSubscription = (new SubscriptionCollection())->withName('update_collection')->withShortName('shortName')->withDescription('subscription description')->withMercure(true)->withClass(\stdClass::class); + $resourceMetadata = new ResourceMetadataCollection(\stdClass::class, [(new ApiResource())->withGraphQlOperations([ + 'update_collection' => $collectionSubscription, + 'item_query' => $itemQuery, + 'collection_query' => $collectionQuery, + ])]); + + $this->typesContainerProphecy->has('update_collectionShortNameSubscriptionPayload')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('update_collectionShortNameSubscriptionPayload', Argument::type(ObjectType::class))->shouldBeCalled(); + $this->typesContainerProphecy->has('Node')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); + $this->typesContainerProphecy->has('shortName')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('shortName', Argument::type(ObjectType::class))->shouldBeCalled(); + + /** @var ObjectType $resourceObjectType */ + $resourceObjectType = $this->typeBuilder->getResourceObjectType($resourceMetadata, $collectionSubscription, null, ['input' => false]); + + $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderEnumInterface::class); + $fieldsBuilderProphecy->getResourceObjectTypeFields(\stdClass::class, $itemQuery, false, 0, null)->shouldBeCalled()->willReturn([]); + $this->fieldsBuilderLocatorProphecy->get('api_platform.graphql.fields_builder')->shouldBeCalled()->willReturn($fieldsBuilderProphecy->reveal()); + + $fields = $resourceObjectType->config['fields'](); + /** @var ObjectType $wrappedType */ + $wrappedType = $fields['shortName']; + $wrappedType->config['fields'](); + + $this->assertArrayHasKey('shortName', $fields); + $this->assertArrayHasKey('clientSubscriptionId', $fields); + $this->assertArrayHasKey('mercureUrl', $fields); + } + public function testGetNodeInterface(): void { $this->typesContainerProphecy->has('Node')->shouldBeCalled()->willReturn(false); diff --git a/src/GraphQl/Type/TypeBuilder.php b/src/GraphQl/Type/TypeBuilder.php index 81102cdfa6f..289bb8762a4 100644 --- a/src/GraphQl/Type/TypeBuilder.php +++ b/src/GraphQl/Type/TypeBuilder.php @@ -358,7 +358,6 @@ 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 bc5d57bae51..7f7afe0640b 100644 --- a/src/Metadata/GraphQl/Subscription.php +++ b/src/Metadata/GraphQl/Subscription.php @@ -17,7 +17,7 @@ use ApiPlatform\State\OptionsInterface; #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] -final class Subscription extends Operation +class Subscription extends Operation { public function __construct( ?string $resolver = null, @@ -76,69 +76,63 @@ public function __construct( ?string $policy = null, array $extraProperties = [], ?bool $map = null, - 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, - 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, + 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, - rules : $rules, - policy : $policy, - extraProperties : $extraProperties, - map : $map, + rules: $rules, + policy: $policy, + extraProperties: $extraProperties, + map: $map, ); } - - public function isCollection(): bool - { - return $this->collection; - } } diff --git a/src/Metadata/GraphQl/SubscriptionCollection.php b/src/Metadata/GraphQl/SubscriptionCollection.php new file mode 100644 index 00000000000..f53d50e90af --- /dev/null +++ b/src/Metadata/GraphQl/SubscriptionCollection.php @@ -0,0 +1,139 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata\GraphQl; + +use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\Parameters; +use ApiPlatform\State\OptionsInterface; + +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] +final class SubscriptionCollection extends Subscription implements CollectionOperationInterface +{ + public function __construct( + ?string $resolver = null, + ?array $args = null, + ?array $extraArgs = null, + ?array $links = null, + ?string $securityAfterResolver = null, + ?string $securityMessageAfterResolver = null, + + ?string $shortName = null, + ?string $class = null, + ?bool $paginationEnabled = null, + ?string $paginationType = null, + ?int $paginationItemsPerPage = null, + ?int $paginationMaximumItemsPerPage = null, + ?bool $paginationPartial = null, + ?bool $paginationClientEnabled = null, + ?bool $paginationClientItemsPerPage = null, + ?bool $paginationClientPartial = null, + ?bool $paginationFetchJoinCollection = null, + ?bool $paginationUseOutputWalkers = null, + ?array $order = null, + ?string $description = null, + ?array $normalizationContext = null, + ?array $denormalizationContext = null, + ?bool $collectDenormalizationErrors = null, + string|\Stringable|null $security = null, + ?string $securityMessage = null, + string|\Stringable|null $securityPostDenormalize = null, + ?string $securityPostDenormalizeMessage = null, + string|\Stringable|null $securityPostValidation = null, + ?string $securityPostValidationMessage = null, + ?string $deprecationReason = null, + ?array $filters = null, + ?array $validationContext = null, + $input = null, + $output = null, + $mercure = null, + $messenger = null, + ?int $urlGenerationStrategy = null, + ?bool $read = null, + ?bool $deserialize = null, + ?bool $validate = null, + ?bool $write = null, + ?bool $serialize = null, + ?bool $fetchPartial = null, + ?bool $forceEager = null, + ?int $priority = null, + ?string $name = null, + $provider = null, + $processor = null, + ?OptionsInterface $stateOptions = null, + array|Parameters|null $parameters = null, + ?bool $queryParameterValidationEnabled = null, + mixed $rules = null, + ?string $policy = null, + array $extraProperties = [], + ?bool $map = null, + ) { + 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, + urlGenerationStrategy: $urlGenerationStrategy, + read: $read, + deserialize: $deserialize, + validate: $validate, + write: $write, + serialize: $serialize, + fetchPartial: $fetchPartial, + forceEager: $forceEager, + priority: $priority, + name: $name ?: 'update_collection_subscription', + provider: $provider, + processor: $processor, + stateOptions: $stateOptions, + parameters: $parameters, + queryParameterValidationEnabled: $queryParameterValidationEnabled, + rules: $rules, + policy: $policy, + extraProperties: $extraProperties, + map: $map, + ); + } +} diff --git a/src/Metadata/Tests/Util/PropertyAccessorValueExtractorTest.php b/src/Metadata/Tests/Util/PropertyAccessorValueExtractorTest.php new file mode 100644 index 00000000000..242e41d400e --- /dev/null +++ b/src/Metadata/Tests/Util/PropertyAccessorValueExtractorTest.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata\Tests\Util; + +use ApiPlatform\Metadata\Util\PropertyAccessorValueExtractor; +use PHPUnit\Framework\TestCase; + +final class PropertyAccessorValueExtractorTest extends TestCase +{ + public function testGetValueReturnsScalarProperty(): void + { + $object = new class { + public string $tenant = 'tenant-1'; + }; + + $this->assertSame('tenant-1', PropertyAccessorValueExtractor::getValue($object, 'tenant')); + } + + public function testGetValueReturnsNestedIdentifierValue(): void + { + $object = new class { + public object $tenant; + + public function __construct() + { + $this->tenant = new class { + public function getId(): string + { + return 'tenant-1'; + } + }; + } + }; + + $this->assertSame('tenant-1', PropertyAccessorValueExtractor::getValue($object, 'tenant')); + } +} diff --git a/src/Metadata/Util/PropertyAccessorValueExtractor.php b/src/Metadata/Util/PropertyAccessorValueExtractor.php new file mode 100644 index 00000000000..b852e014afa --- /dev/null +++ b/src/Metadata/Util/PropertyAccessorValueExtractor.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata\Util; + +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; + +final class PropertyAccessorValueExtractor +{ + private static ?PropertyAccessorInterface $propertyAccessor = null; + + public static function getValue(object $object, string $property): string + { + self::$propertyAccessor ??= PropertyAccess::createPropertyAccessor(); + + $value = self::$propertyAccessor->getValue($object, $property); + if (\is_object($value) && method_exists($value, 'getId')) { + $value = $value->getId(); + } + + if ($value instanceof \Stringable || is_numeric($value)) { + return (string) $value; + } + + return $value; + } +} diff --git a/src/Symfony/Doctrine/EventListener/PublishMercureUpdatesListener.php b/src/Symfony/Doctrine/EventListener/PublishMercureUpdatesListener.php index cc0675d8eb2..1cb2bdcce40 100644 --- a/src/Symfony/Doctrine/EventListener/PublishMercureUpdatesListener.php +++ b/src/Symfony/Doctrine/EventListener/PublishMercureUpdatesListener.php @@ -25,6 +25,7 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\Metadata\Util\PropertyAccessorValueExtractor; use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; use Doctrine\Common\EventArgs; use Doctrine\ODM\MongoDB\Event\OnFlushEventArgs as MongoDbOdmOnFlushEventArgs; @@ -35,6 +36,8 @@ use Symfony\Component\Mercure\HubRegistry; use Symfony\Component\Mercure\Update; use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Component\PropertyAccess\Exception\AccessException; +use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; use Symfony\Component\Serializer\SerializerInterface; /** @@ -224,14 +227,17 @@ private function storeObjectToPublish(object $object, string $property): void $privateFields = $mercureOptions['private_fields'] ?? []; if ($private && $privateFields) { foreach ($privateFields as $privateField) { - if (property_exists($object, $privateField)) { - $privateData[$privateField] = $this->getResourceId($privateField, $object); + try { + $privateData[$privateField] = $this->getPrivateFieldValue($privateField, $object); + } catch (NoSuchPropertyException|AccessException) { + continue; } } } $this->deletedObjects[] = [ 'object' => (object) [ + 'resourceClass' => $resourceClass, 'id' => $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation), 'iri' => $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_URL, $operation), 'type' => 1 === \count($types) ? $types[0] : $types, @@ -337,16 +343,8 @@ 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 + private function getPrivateFieldValue(string $privateField, object $object): string { - $id = $object->{'get'.ucfirst($privateField)}(); - if (is_object($id) && method_exists($id, 'getId')) { - $id = $id->getId(); - } - if ($id instanceof \Stringable || is_numeric($id)) { - return (string) $id; - } - - return $id; + return PropertyAccessorValueExtractor::getValue($object, $privateField); } } diff --git a/src/Symfony/Tests/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php b/src/Symfony/Tests/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php index 181e023cc08..8118dcd6336 100644 --- a/src/Symfony/Tests/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php +++ b/src/Symfony/Tests/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php @@ -364,6 +364,812 @@ public function testPublishGraphQlUpdates(): void $this->assertEquals(['2', '["data"]'], $data); } + public function testPublishGraphQlCreateUpdates(): void + { + $toInsert = new Dummy(); + $toInsert->setId(1); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(Argument::type(Dummy::class))->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($toInsert, UrlGeneratorInterface::ABS_URL, Argument::any())->willReturn('http://example.com/dummies/1'); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [(new ApiResource())->withOperations(new Operations([ + 'get' => (new Get())->withMercure(['enable_async_update' => false])->withNormalizationContext(['groups' => ['foo', 'bar']]), + ]))])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->serialize($toInsert, 'jsonld', ['groups' => ['foo', 'bar']])->willReturn('1'); + + $formats = ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']]; + + $topics = []; + $private = []; + $retry = []; + $data = []; + + $defaultHub = $this->createMockHub(static function (Update $update) use (&$topics, &$private, &$retry, &$data): string { + $topics = array_merge($topics, $update->getTopics()); + $private[] = $update->isPrivate(); + $retry[] = $update->getRetry(); + $data[] = $update->getData(); + + return 'id'; + }); + + $graphQlSubscriptionManagerProphecy = $this->prophesize(GraphQlSubscriptionManagerInterface::class); + $graphQlSubscriptionId = 'subscription-id'; + $graphQlSubscriptionData = ['data']; + $graphQlSubscriptionManagerProphecy->getPushPayloads($toInsert, 'create')->willReturn([[$graphQlSubscriptionId, $graphQlSubscriptionData]]); + $graphQlMercureSubscriptionIriGenerator = $this->prophesize(GraphQlMercureSubscriptionIriGeneratorInterface::class); + $topicIri = 'subscription-topic-iri'; + $graphQlMercureSubscriptionIriGenerator->generateTopicIri($graphQlSubscriptionId)->willReturn($topicIri); + + $listener = new PublishMercureUpdatesListener( + $resourceClassResolverProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceMetadataFactoryProphecy->reveal(), + $serializerProphecy->reveal(), + $formats, + null, + new HubRegistry($defaultHub, ['default' => $defaultHub]), + $graphQlSubscriptionManagerProphecy->reveal(), + $graphQlMercureSubscriptionIriGenerator->reveal(), + null, + true, + ); + + $uowProphecy = $this->prophesize(UnitOfWork::class); + $uowProphecy->getScheduledEntityInsertions()->willReturn([$toInsert])->shouldBeCalled(); + $uowProphecy->getScheduledEntityUpdates()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityDeletions()->willReturn([])->shouldBeCalled(); + + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled(); + $eventArgs = new OnFlushEventArgs($emProphecy->reveal()); + + $listener->onFlush($eventArgs); + $listener->postFlush(); + + $this->assertEquals(['http://example.com/dummies/1', 'subscription-topic-iri'], $topics); + $this->assertEquals([false, false], $private); + $this->assertEquals([null, null], $retry); + $this->assertEquals(['1', '["data"]'], $data); + } + + public function testPublishGraphQlCreateUpdatesForCollectionSubscriptions(): void + { + $toInsert = new Dummy(); + $toInsert->setId(1); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(Argument::type(Dummy::class))->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($toInsert, UrlGeneratorInterface::ABS_URL, Argument::any())->willReturn('http://example.com/dummies/1'); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [(new ApiResource())->withOperations(new Operations([ + 'get' => (new Get())->withMercure(['enable_async_update' => false])->withNormalizationContext(['groups' => ['foo', 'bar']]), + ]))])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->serialize($toInsert, 'jsonld', ['groups' => ['foo', 'bar']])->willReturn('1'); + + $formats = ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']]; + + $topics = []; + $private = []; + $retry = []; + $data = []; + + $defaultHub = $this->createMockHub(static function (Update $update) use (&$topics, &$private, &$retry, &$data): string { + $topics = array_merge($topics, $update->getTopics()); + $private[] = $update->isPrivate(); + $retry[] = $update->getRetry(); + $data[] = $update->getData(); + + return 'id'; + }); + + $graphQlSubscriptionManagerProphecy = $this->prophesize(GraphQlSubscriptionManagerInterface::class); + $graphQlCollectionSubscriptionPayloads = [ + ['collection-subscription-id-1', ['data' => ['collection' => 'first']]], + ['collection-subscription-id-2', ['data' => ['collection' => 'second']]], + ]; + $graphQlSubscriptionManagerProphecy->getPushPayloads($toInsert, 'create')->willReturn($graphQlCollectionSubscriptionPayloads); + $graphQlMercureSubscriptionIriGenerator = $this->prophesize(GraphQlMercureSubscriptionIriGeneratorInterface::class); + $graphQlMercureSubscriptionIriGenerator->generateTopicIri('collection-subscription-id-1')->willReturn('collection-subscription-topic-iri-1'); + $graphQlMercureSubscriptionIriGenerator->generateTopicIri('collection-subscription-id-2')->willReturn('collection-subscription-topic-iri-2'); + + $listener = new PublishMercureUpdatesListener( + $resourceClassResolverProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceMetadataFactoryProphecy->reveal(), + $serializerProphecy->reveal(), + $formats, + null, + new HubRegistry($defaultHub, ['default' => $defaultHub]), + $graphQlSubscriptionManagerProphecy->reveal(), + $graphQlMercureSubscriptionIriGenerator->reveal(), + null, + true, + ); + + $uowProphecy = $this->prophesize(UnitOfWork::class); + $uowProphecy->getScheduledEntityInsertions()->willReturn([$toInsert])->shouldBeCalled(); + $uowProphecy->getScheduledEntityUpdates()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityDeletions()->willReturn([])->shouldBeCalled(); + + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled(); + $eventArgs = new OnFlushEventArgs($emProphecy->reveal()); + + $listener->onFlush($eventArgs); + $listener->postFlush(); + + $this->assertEquals(['http://example.com/dummies/1', 'collection-subscription-topic-iri-1', 'collection-subscription-topic-iri-2'], $topics); + $this->assertEquals([false, false, false], $private); + $this->assertEquals([null, null, null], $retry); + $this->assertEquals(['1', '{"data":{"collection":"first"}}', '{"data":{"collection":"second"}}'], $data); + } + + public function testPublishGraphQlDeleteUpdates(): void + { + $toDelete = new Dummy(); + $toDelete->setId(2); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(Argument::type(Dummy::class))->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($toDelete, UrlGeneratorInterface::ABS_PATH, Argument::any())->willReturn('/dummies/2')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource($toDelete, UrlGeneratorInterface::ABS_URL, Argument::any())->willReturn('http://example.com/dummies/2')->shouldBeCalled(); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [(new ApiResource())->withOperations(new Operations([ + 'get' => (new Get())->withMercure(['enable_async_update' => false])->withShortName('Dummy')->withNormalizationContext(['groups' => ['foo', 'bar']]), + ]))])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + + $formats = ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']]; + + $topics = []; + $private = []; + $retry = []; + $data = []; + + $defaultHub = $this->createMockHub(static function (Update $update) use (&$topics, &$private, &$retry, &$data): string { + $topics = array_merge($topics, $update->getTopics()); + $private[] = $update->isPrivate(); + $retry[] = $update->getRetry(); + $data[] = $update->getData(); + + return 'id'; + }); + + $graphQlSubscriptionManagerProphecy = $this->prophesize(GraphQlSubscriptionManagerInterface::class); + $graphQlSubscriptionId = 'subscription-id'; + $graphQlSubscriptionData = ['data']; + $graphQlSubscriptionManagerProphecy->getPushPayloads(Argument::that(static fn ($object): bool => $object instanceof \stdClass && Dummy::class === $object->resourceClass && '/dummies/2' === $object->id && 'http://example.com/dummies/2' === $object->iri), 'delete')->willReturn([[$graphQlSubscriptionId, $graphQlSubscriptionData]]); + $graphQlMercureSubscriptionIriGenerator = $this->prophesize(GraphQlMercureSubscriptionIriGeneratorInterface::class); + $topicIri = 'subscription-topic-iri'; + $graphQlMercureSubscriptionIriGenerator->generateTopicIri($graphQlSubscriptionId)->willReturn($topicIri); + + $listener = new PublishMercureUpdatesListener( + $resourceClassResolverProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceMetadataFactoryProphecy->reveal(), + $serializerProphecy->reveal(), + $formats, + null, + new HubRegistry($defaultHub, ['default' => $defaultHub]), + $graphQlSubscriptionManagerProphecy->reveal(), + $graphQlMercureSubscriptionIriGenerator->reveal(), + null, + true, + ); + + $uowProphecy = $this->prophesize(UnitOfWork::class); + $uowProphecy->getScheduledEntityInsertions()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityUpdates()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityDeletions()->willReturn([$toDelete])->shouldBeCalled(); + + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled(); + $eventArgs = new OnFlushEventArgs($emProphecy->reveal()); + + $listener->onFlush($eventArgs); + $listener->postFlush(); + + $this->assertEquals(['http://example.com/dummies/2', 'subscription-topic-iri'], $topics); + $this->assertEquals([false, false], $private); + $this->assertEquals([null, null], $retry); + $this->assertEquals(['{"@id":"\/dummies\/2","@type":"Dummy"}', '["data"]'], $data); + } + + public function testPublishGraphQlDeleteUpdatesKeepsPrivateMercureFlag(): void + { + $toDelete = new Dummy(); + $toDelete->setId(2); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(Argument::type(Dummy::class))->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($toDelete, UrlGeneratorInterface::ABS_PATH, Argument::any())->willReturn('/dummies/2')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource($toDelete, UrlGeneratorInterface::ABS_URL, Argument::any())->willReturn('http://example.com/dummies/2')->shouldBeCalled(); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [(new ApiResource())->withOperations(new Operations([ + 'get' => (new Get())->withMercure(['private' => true, 'enable_async_update' => false])->withShortName('Dummy')->withNormalizationContext(['groups' => ['foo', 'bar']]), + ]))])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + + $formats = ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']]; + + $topics = []; + $private = []; + $retry = []; + $data = []; + + $defaultHub = $this->createMockHub(static function (Update $update) use (&$topics, &$private, &$retry, &$data): string { + $topics = array_merge($topics, $update->getTopics()); + $private[] = $update->isPrivate(); + $retry[] = $update->getRetry(); + $data[] = $update->getData(); + + return 'id'; + }); + + $graphQlSubscriptionManagerProphecy = $this->prophesize(GraphQlSubscriptionManagerInterface::class); + $graphQlSubscriptionId = 'subscription-id'; + $graphQlSubscriptionData = ['data']; + $graphQlSubscriptionManagerProphecy->getPushPayloads(Argument::that(static fn ($object): bool => $object instanceof \stdClass && Dummy::class === $object->resourceClass && '/dummies/2' === $object->id && 'http://example.com/dummies/2' === $object->iri && [] === $object->private), 'delete')->willReturn([[$graphQlSubscriptionId, $graphQlSubscriptionData]]); + $graphQlMercureSubscriptionIriGenerator = $this->prophesize(GraphQlMercureSubscriptionIriGeneratorInterface::class); + $topicIri = 'subscription-topic-iri'; + $graphQlMercureSubscriptionIriGenerator->generateTopicIri($graphQlSubscriptionId)->willReturn($topicIri); + + $listener = new PublishMercureUpdatesListener( + $resourceClassResolverProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceMetadataFactoryProphecy->reveal(), + $serializerProphecy->reveal(), + $formats, + null, + new HubRegistry($defaultHub, ['default' => $defaultHub]), + $graphQlSubscriptionManagerProphecy->reveal(), + $graphQlMercureSubscriptionIriGenerator->reveal(), + null, + true, + ); + + $uowProphecy = $this->prophesize(UnitOfWork::class); + $uowProphecy->getScheduledEntityInsertions()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityUpdates()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityDeletions()->willReturn([$toDelete])->shouldBeCalled(); + + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled(); + $eventArgs = new OnFlushEventArgs($emProphecy->reveal()); + + $listener->onFlush($eventArgs); + $listener->postFlush(); + + $this->assertEquals(['http://example.com/dummies/2', 'subscription-topic-iri'], $topics); + $this->assertEquals([true, true], $private); + $this->assertEquals([null, null], $retry); + $this->assertEquals(['{"@id":"\/dummies\/2","@type":"Dummy"}', '["data"]'], $data); + } + + public function testPublishGraphQlUpdatesForCollectionSubscriptions(): void + { + $toUpdate = new Dummy(); + $toUpdate->setId(2); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(Argument::type(Dummy::class))->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($toUpdate, UrlGeneratorInterface::ABS_URL, Argument::any())->willReturn('http://example.com/dummies/2'); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [(new ApiResource())->withOperations(new Operations([ + 'get' => (new Get())->withMercure(['enable_async_update' => false])->withNormalizationContext(['groups' => ['foo', 'bar']]), + ]))])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->serialize($toUpdate, 'jsonld', ['groups' => ['foo', 'bar']])->willReturn('2'); + + $formats = ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']]; + + $topics = []; + $private = []; + $retry = []; + $data = []; + + $defaultHub = $this->createMockHub(static function (Update $update) use (&$topics, &$private, &$retry, &$data): string { + $topics = array_merge($topics, $update->getTopics()); + $private[] = $update->isPrivate(); + $retry[] = $update->getRetry(); + $data[] = $update->getData(); + + return 'id'; + }); + + $graphQlSubscriptionManagerProphecy = $this->prophesize(GraphQlSubscriptionManagerInterface::class); + $graphQlCollectionSubscriptionPayloads = [ + ['collection-subscription-id-1', ['data' => ['collection' => 'first']]], + ['collection-subscription-id-2', ['data' => ['collection' => 'second']]], + ]; + $graphQlSubscriptionManagerProphecy->getPushPayloads($toUpdate, 'update')->willReturn($graphQlCollectionSubscriptionPayloads); + $graphQlMercureSubscriptionIriGenerator = $this->prophesize(GraphQlMercureSubscriptionIriGeneratorInterface::class); + $graphQlMercureSubscriptionIriGenerator->generateTopicIri('collection-subscription-id-1')->willReturn('collection-subscription-topic-iri-1'); + $graphQlMercureSubscriptionIriGenerator->generateTopicIri('collection-subscription-id-2')->willReturn('collection-subscription-topic-iri-2'); + + $listener = new PublishMercureUpdatesListener( + $resourceClassResolverProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceMetadataFactoryProphecy->reveal(), + $serializerProphecy->reveal(), + $formats, + null, + new HubRegistry($defaultHub, ['default' => $defaultHub]), + $graphQlSubscriptionManagerProphecy->reveal(), + $graphQlMercureSubscriptionIriGenerator->reveal(), + null, + true, + ); + + $uowProphecy = $this->prophesize(UnitOfWork::class); + $uowProphecy->getScheduledEntityInsertions()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityUpdates()->willReturn([$toUpdate])->shouldBeCalled(); + $uowProphecy->getScheduledEntityDeletions()->willReturn([])->shouldBeCalled(); + + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled(); + $eventArgs = new OnFlushEventArgs($emProphecy->reveal()); + + $listener->onFlush($eventArgs); + $listener->postFlush(); + + $this->assertEquals(['http://example.com/dummies/2', 'collection-subscription-topic-iri-1', 'collection-subscription-topic-iri-2'], $topics); + $this->assertEquals([false, false, false], $private); + $this->assertEquals([null, null, null], $retry); + $this->assertEquals(['2', '{"data":{"collection":"first"}}', '{"data":{"collection":"second"}}'], $data); + } + + public function testPublishGraphQlUpdatesKeepsPrivateMercureFlag(): void + { + $toUpdate = new Dummy(); + $toUpdate->setId(2); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(Argument::type(Dummy::class))->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($toUpdate, UrlGeneratorInterface::ABS_URL, Argument::any())->willReturn('http://example.com/dummies/2'); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [(new ApiResource())->withOperations(new Operations([ + 'get' => (new Get())->withMercure(['private' => true, 'enable_async_update' => false])->withNormalizationContext(['groups' => ['foo', 'bar']]), + ]))])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->serialize($toUpdate, 'jsonld', ['groups' => ['foo', 'bar']])->willReturn('2'); + + $formats = ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']]; + + $topics = []; + $private = []; + $retry = []; + $data = []; + + $defaultHub = $this->createMockHub(static function (Update $update) use (&$topics, &$private, &$retry, &$data): string { + $topics = array_merge($topics, $update->getTopics()); + $private[] = $update->isPrivate(); + $retry[] = $update->getRetry(); + $data[] = $update->getData(); + + return 'id'; + }); + + $graphQlSubscriptionManagerProphecy = $this->prophesize(GraphQlSubscriptionManagerInterface::class); + $graphQlSubscriptionId = 'subscription-id'; + $graphQlSubscriptionData = ['data']; + $graphQlSubscriptionManagerProphecy->getPushPayloads($toUpdate, 'update')->willReturn([[$graphQlSubscriptionId, $graphQlSubscriptionData]]); + $graphQlMercureSubscriptionIriGenerator = $this->prophesize(GraphQlMercureSubscriptionIriGeneratorInterface::class); + $topicIri = 'subscription-topic-iri'; + $graphQlMercureSubscriptionIriGenerator->generateTopicIri($graphQlSubscriptionId)->willReturn($topicIri); + + $listener = new PublishMercureUpdatesListener( + $resourceClassResolverProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceMetadataFactoryProphecy->reveal(), + $serializerProphecy->reveal(), + $formats, + null, + new HubRegistry($defaultHub, ['default' => $defaultHub]), + $graphQlSubscriptionManagerProphecy->reveal(), + $graphQlMercureSubscriptionIriGenerator->reveal(), + null, + true, + ); + + $uowProphecy = $this->prophesize(UnitOfWork::class); + $uowProphecy->getScheduledEntityInsertions()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityUpdates()->willReturn([$toUpdate])->shouldBeCalled(); + $uowProphecy->getScheduledEntityDeletions()->willReturn([])->shouldBeCalled(); + + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled(); + $eventArgs = new OnFlushEventArgs($emProphecy->reveal()); + + $listener->onFlush($eventArgs); + $listener->postFlush(); + + $this->assertEquals(['http://example.com/dummies/2', 'subscription-topic-iri'], $topics); + $this->assertEquals([true, true], $private); + $this->assertEquals([null, null], $retry); + $this->assertEquals(['2', '["data"]'], $data); + } + + public function testPublishGraphQlCreateUpdatesKeepsPrivatePartitionContext(): void + { + $toInsert = new class { + private int $id = 1; + private int $tenant = 42; + + public function getId(): int + { + return $this->id; + } + + public function getTenant(): int + { + return $this->tenant; + } + }; + $resourceClass = $toInsert::class; + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(Argument::type($resourceClass))->willReturn($resourceClass); + $resourceClassResolverProphecy->isResourceClass($resourceClass)->willReturn(true); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($toInsert, UrlGeneratorInterface::ABS_URL, Argument::any())->willReturn('http://example.com/partitioned_dummies/1'); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactoryProphecy->create($resourceClass)->willReturn(new ResourceMetadataCollection($resourceClass, [(new ApiResource())->withOperations(new Operations([ + 'get' => (new Get())->withMercure(['private' => true, 'private_fields' => ['tenant'], 'enable_async_update' => false])->withNormalizationContext(['groups' => ['foo', 'bar']]), + ]))])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->serialize($toInsert, 'jsonld', ['groups' => ['foo', 'bar']])->willReturn('1'); + + $formats = ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']]; + + $topics = []; + $private = []; + $retry = []; + $data = []; + + $defaultHub = $this->createMockHub(static function (Update $update) use (&$topics, &$private, &$retry, &$data): string { + $topics = array_merge($topics, $update->getTopics()); + $private[] = $update->isPrivate(); + $retry[] = $update->getRetry(); + $data[] = $update->getData(); + + return 'id'; + }); + + $graphQlSubscriptionManagerProphecy = $this->prophesize(GraphQlSubscriptionManagerInterface::class); + $graphQlSubscriptionId = 'subscription-id'; + $graphQlSubscriptionData = ['data']; + $graphQlSubscriptionManagerProphecy->getPushPayloads($toInsert, 'create')->willReturn([[$graphQlSubscriptionId, $graphQlSubscriptionData]]); + $graphQlMercureSubscriptionIriGenerator = $this->prophesize(GraphQlMercureSubscriptionIriGeneratorInterface::class); + $topicIri = 'subscription-topic-iri'; + $graphQlMercureSubscriptionIriGenerator->generateTopicIri($graphQlSubscriptionId)->willReturn($topicIri); + + $listener = new PublishMercureUpdatesListener( + $resourceClassResolverProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceMetadataFactoryProphecy->reveal(), + $serializerProphecy->reveal(), + $formats, + null, + new HubRegistry($defaultHub, ['default' => $defaultHub]), + $graphQlSubscriptionManagerProphecy->reveal(), + $graphQlMercureSubscriptionIriGenerator->reveal(), + null, + true, + ); + + $uowProphecy = $this->prophesize(UnitOfWork::class); + $uowProphecy->getScheduledEntityInsertions()->willReturn([$toInsert])->shouldBeCalled(); + $uowProphecy->getScheduledEntityUpdates()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityDeletions()->willReturn([])->shouldBeCalled(); + + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled(); + $eventArgs = new OnFlushEventArgs($emProphecy->reveal()); + + $listener->onFlush($eventArgs); + $listener->postFlush(); + + $this->assertEquals(['http://example.com/partitioned_dummies/1', 'subscription-topic-iri'], $topics); + $this->assertEquals([true, true], $private); + $this->assertEquals([null, null], $retry); + $this->assertEquals(['1', '["data"]'], $data); + } + + public function testPublishGraphQlUpdatesKeepsPrivatePartitionContext(): void + { + $toUpdate = new class { + private int $id = 2; + private int $tenant = 42; + + public function getId(): int + { + return $this->id; + } + + public function getTenant(): int + { + return $this->tenant; + } + }; + $resourceClass = $toUpdate::class; + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(Argument::type($resourceClass))->willReturn($resourceClass); + $resourceClassResolverProphecy->isResourceClass($resourceClass)->willReturn(true); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($toUpdate, UrlGeneratorInterface::ABS_URL, Argument::any())->willReturn('http://example.com/partitioned_dummies/2'); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactoryProphecy->create($resourceClass)->willReturn(new ResourceMetadataCollection($resourceClass, [(new ApiResource())->withOperations(new Operations([ + 'get' => (new Get())->withMercure(['private' => true, 'private_fields' => ['tenant'], 'enable_async_update' => false])->withNormalizationContext(['groups' => ['foo', 'bar']]), + ]))])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->serialize($toUpdate, 'jsonld', ['groups' => ['foo', 'bar']])->willReturn('2'); + + $formats = ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']]; + + $topics = []; + $private = []; + $retry = []; + $data = []; + + $defaultHub = $this->createMockHub(static function (Update $update) use (&$topics, &$private, &$retry, &$data): string { + $topics = array_merge($topics, $update->getTopics()); + $private[] = $update->isPrivate(); + $retry[] = $update->getRetry(); + $data[] = $update->getData(); + + return 'id'; + }); + + $graphQlSubscriptionManagerProphecy = $this->prophesize(GraphQlSubscriptionManagerInterface::class); + $graphQlSubscriptionId = 'subscription-id'; + $graphQlSubscriptionData = ['data']; + $graphQlSubscriptionManagerProphecy->getPushPayloads($toUpdate, 'update')->willReturn([[$graphQlSubscriptionId, $graphQlSubscriptionData]]); + $graphQlMercureSubscriptionIriGenerator = $this->prophesize(GraphQlMercureSubscriptionIriGeneratorInterface::class); + $topicIri = 'subscription-topic-iri'; + $graphQlMercureSubscriptionIriGenerator->generateTopicIri($graphQlSubscriptionId)->willReturn($topicIri); + + $listener = new PublishMercureUpdatesListener( + $resourceClassResolverProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceMetadataFactoryProphecy->reveal(), + $serializerProphecy->reveal(), + $formats, + null, + new HubRegistry($defaultHub, ['default' => $defaultHub]), + $graphQlSubscriptionManagerProphecy->reveal(), + $graphQlMercureSubscriptionIriGenerator->reveal(), + null, + true, + ); + + $uowProphecy = $this->prophesize(UnitOfWork::class); + $uowProphecy->getScheduledEntityInsertions()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityUpdates()->willReturn([$toUpdate])->shouldBeCalled(); + $uowProphecy->getScheduledEntityDeletions()->willReturn([])->shouldBeCalled(); + + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled(); + $eventArgs = new OnFlushEventArgs($emProphecy->reveal()); + + $listener->onFlush($eventArgs); + $listener->postFlush(); + + $this->assertEquals(['http://example.com/partitioned_dummies/2', 'subscription-topic-iri'], $topics); + $this->assertEquals([true, true], $private); + $this->assertEquals([null, null], $retry); + $this->assertEquals(['2', '["data"]'], $data); + } + + public function testPublishGraphQlDeleteUpdatesKeepsPrivatePartitionData(): void + { + $toDelete = new class { + private int $id = 2; + private int $tenant = 42; + + public function getId(): int + { + return $this->id; + } + + public function getTenant(): int + { + return $this->tenant; + } + }; + $resourceClass = $toDelete::class; + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(Argument::type($resourceClass))->willReturn($resourceClass); + $resourceClassResolverProphecy->isResourceClass($resourceClass)->willReturn(true); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($toDelete, UrlGeneratorInterface::ABS_PATH, Argument::any())->willReturn('/partitioned_dummies/2')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource($toDelete, UrlGeneratorInterface::ABS_URL, Argument::any())->willReturn('http://example.com/partitioned_dummies/2')->shouldBeCalled(); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactoryProphecy->create($resourceClass)->willReturn(new ResourceMetadataCollection($resourceClass, [(new ApiResource())->withOperations(new Operations([ + 'get' => (new Get())->withMercure(['private' => true, 'private_fields' => ['tenant'], 'enable_async_update' => false])->withShortName('PartitionedDummy')->withNormalizationContext(['groups' => ['foo', 'bar']]), + ]))])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + + $formats = ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']]; + + $topics = []; + $private = []; + $retry = []; + $data = []; + + $defaultHub = $this->createMockHub(static function (Update $update) use (&$topics, &$private, &$retry, &$data): string { + $topics = array_merge($topics, $update->getTopics()); + $private[] = $update->isPrivate(); + $retry[] = $update->getRetry(); + $data[] = $update->getData(); + + return 'id'; + }); + + $graphQlSubscriptionManagerProphecy = $this->prophesize(GraphQlSubscriptionManagerInterface::class); + $graphQlSubscriptionId = 'subscription-id'; + $graphQlSubscriptionData = ['data']; + $graphQlSubscriptionManagerProphecy->getPushPayloads(Argument::that(static fn ($object) => $object instanceof \stdClass && $resourceClass === $object->resourceClass && '/partitioned_dummies/2' === $object->id && 'http://example.com/partitioned_dummies/2' === $object->iri && ['tenant' => '42'] === $object->private), 'delete')->willReturn([[$graphQlSubscriptionId, $graphQlSubscriptionData]]); + $graphQlMercureSubscriptionIriGenerator = $this->prophesize(GraphQlMercureSubscriptionIriGeneratorInterface::class); + $topicIri = 'subscription-topic-iri'; + $graphQlMercureSubscriptionIriGenerator->generateTopicIri($graphQlSubscriptionId)->willReturn($topicIri); + + $listener = new PublishMercureUpdatesListener( + $resourceClassResolverProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceMetadataFactoryProphecy->reveal(), + $serializerProphecy->reveal(), + $formats, + null, + new HubRegistry($defaultHub, ['default' => $defaultHub]), + $graphQlSubscriptionManagerProphecy->reveal(), + $graphQlMercureSubscriptionIriGenerator->reveal(), + null, + true, + ); + + $uowProphecy = $this->prophesize(UnitOfWork::class); + $uowProphecy->getScheduledEntityInsertions()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityUpdates()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityDeletions()->willReturn([$toDelete])->shouldBeCalled(); + + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled(); + $eventArgs = new OnFlushEventArgs($emProphecy->reveal()); + + $listener->onFlush($eventArgs); + $listener->postFlush(); + + $this->assertEquals(['http://example.com/partitioned_dummies/2', 'subscription-topic-iri'], $topics); + $this->assertEquals([true, true], $private); + $this->assertEquals([null, null], $retry); + $this->assertEquals(['{"@id":"\/partitioned_dummies\/2","@type":"PartitionedDummy"}', '["data"]'], $data); + } + + public function testPublishGraphQlDeleteUpdatesKeepsPrivatePartitionDataUsingPropertyAccess(): void + { + $toDelete = new class { + public int $id = 2; + public int $tenant = 42; + }; + $resourceClass = $toDelete::class; + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(Argument::type($resourceClass))->willReturn($resourceClass); + $resourceClassResolverProphecy->isResourceClass($resourceClass)->willReturn(true); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($toDelete, UrlGeneratorInterface::ABS_PATH, Argument::any())->willReturn('/partitioned_dummies/2')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResource($toDelete, UrlGeneratorInterface::ABS_URL, Argument::any())->willReturn('http://example.com/partitioned_dummies/2')->shouldBeCalled(); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactoryProphecy->create($resourceClass)->willReturn(new ResourceMetadataCollection($resourceClass, [(new ApiResource())->withOperations(new Operations([ + 'get' => (new Get())->withMercure(['private' => true, 'private_fields' => ['tenant'], 'enable_async_update' => false])->withShortName('PartitionedDummy')->withNormalizationContext(['groups' => ['foo', 'bar']]), + ]))])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + + $formats = ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']]; + + $topics = []; + $private = []; + $retry = []; + $data = []; + + $defaultHub = $this->createMockHub(static function (Update $update) use (&$topics, &$private, &$retry, &$data): string { + $topics = array_merge($topics, $update->getTopics()); + $private[] = $update->isPrivate(); + $retry[] = $update->getRetry(); + $data[] = $update->getData(); + + return 'id'; + }); + + $graphQlSubscriptionManagerProphecy = $this->prophesize(GraphQlSubscriptionManagerInterface::class); + $graphQlSubscriptionId = 'subscription-id'; + $graphQlSubscriptionData = ['data']; + $graphQlSubscriptionManagerProphecy->getPushPayloads(Argument::that(static fn ($object) => $object instanceof \stdClass && $resourceClass === $object->resourceClass && '/partitioned_dummies/2' === $object->id && 'http://example.com/partitioned_dummies/2' === $object->iri && ['tenant' => '42'] === $object->private), 'delete')->willReturn([[$graphQlSubscriptionId, $graphQlSubscriptionData]]); + $graphQlMercureSubscriptionIriGenerator = $this->prophesize(GraphQlMercureSubscriptionIriGeneratorInterface::class); + $topicIri = 'subscription-topic-iri'; + $graphQlMercureSubscriptionIriGenerator->generateTopicIri($graphQlSubscriptionId)->willReturn($topicIri); + + $listener = new PublishMercureUpdatesListener( + $resourceClassResolverProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceMetadataFactoryProphecy->reveal(), + $serializerProphecy->reveal(), + $formats, + null, + new HubRegistry($defaultHub, ['default' => $defaultHub]), + $graphQlSubscriptionManagerProphecy->reveal(), + $graphQlMercureSubscriptionIriGenerator->reveal(), + null, + true, + ); + + $uowProphecy = $this->prophesize(UnitOfWork::class); + $uowProphecy->getScheduledEntityInsertions()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityUpdates()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityDeletions()->willReturn([$toDelete])->shouldBeCalled(); + + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled(); + $eventArgs = new OnFlushEventArgs($emProphecy->reveal()); + + $listener->onFlush($eventArgs); + $listener->postFlush(); + + $this->assertEquals(['http://example.com/partitioned_dummies/2', 'subscription-topic-iri'], $topics); + $this->assertEquals([true, true], $private); + $this->assertEquals([null, null], $retry); + $this->assertEquals(['{"@id":"\/partitioned_dummies\/2","@type":"PartitionedDummy"}', '["data"]'], $data); + } + public function testPublishUpdateWithMultipleResources(): void { $toInsert = new DummyMercureMultiResource(); diff --git a/tests/Fixtures/TestBundle/ApiResource/GraphQlSubscriptionPair.php b/tests/Fixtures/TestBundle/ApiResource/GraphQlSubscriptionPair.php new file mode 100644 index 00000000000..b7729bf99e2 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/GraphQlSubscriptionPair.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\GraphQl\Subscription; +use ApiPlatform\Metadata\GraphQl\SubscriptionCollection; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + operations: [new Get(), new GetCollection()], + graphQlOperations: [ + new Query(name: 'item_query'), + new QueryCollection(name: 'collection_query'), + new Subscription(mercure: true, name: 'update'), + new SubscriptionCollection(mercure: true, name: 'update_collection'), + ], + provider: [self::class, 'provide'], +)] +final class GraphQlSubscriptionPair +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $resource = new self(); + $resource->id = isset($uriVariables['id']) ? (int) $uriVariables['id'] : 1; + + return $resource; + } +} diff --git a/tests/Functional/GraphQl/SubscriptionSchemaTest.php b/tests/Functional/GraphQl/SubscriptionSchemaTest.php new file mode 100644 index 00000000000..d49f971175d --- /dev/null +++ b/tests/Functional/GraphQl/SubscriptionSchemaTest.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\GraphQl; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\GraphQlSubscriptionPair; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class SubscriptionSchemaTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [GraphQlSubscriptionPair::class]; + } + + public function testItemAndCollectionSubscriptionsCoexistInSchema(): void + { + $response = self::createClient()->request('POST', '/graphql', ['json' => [ + 'query' => <<<'GRAPHQL' +{ + __schema { + subscriptionType { + fields { + name + } + } + } +} +GRAPHQL, + ]]); + + $this->assertResponseIsSuccessful(); + $json = $response->toArray(false); + $this->assertArrayNotHasKey('errors', $json); + + $fieldNames = array_column($json['data']['__schema']['subscriptionType']['fields'], 'name'); + + $this->assertContains('updateGraphQlSubscriptionPairSubscribe', $fieldNames); + $this->assertContains('update_collectionGraphQlSubscriptionPairSubscribe', $fieldNames); + } + + public function testItemSubscriptionReturnsMercureMetadata(): void + { + $response = self::createClient()->request('POST', '/graphql', ['json' => [ + 'query' => <<<'GRAPHQL' +subscription { + updateGraphQlSubscriptionPairSubscribe(input: {id: "/graph_ql_subscription_pairs/1"}) { + graphQlSubscriptionPair { + id + } + clientSubscriptionId + mercureUrl + } +} +GRAPHQL, + ]]); + + $this->assertResponseIsSuccessful(); + $json = $response->toArray(false); + $this->assertArrayNotHasKey('errors', $json); + + $payload = $json['data']['updateGraphQlSubscriptionPairSubscribe']; + $this->assertSame('/graph_ql_subscription_pairs/1', $payload['graphQlSubscriptionPair']['id']); + $this->assertNull($payload['clientSubscriptionId']); + $this->assertNotEmpty($payload['mercureUrl']); + } + + public function testCollectionSubscriptionReturnsMercureMetadata(): void + { + $response = self::createClient()->request('POST', '/graphql', ['json' => [ + 'query' => <<<'GRAPHQL' +subscription { + update_collectionGraphQlSubscriptionPairSubscribe(input: {id: "/graph_ql_subscription_pairs/1"}) { + graphQlSubscriptionPair { + id + } + clientSubscriptionId + mercureUrl + } +} +GRAPHQL, + ]]); + + $this->assertResponseIsSuccessful(); + $json = $response->toArray(false); + $this->assertArrayNotHasKey('errors', $json); + + $payload = $json['data']['update_collectionGraphQlSubscriptionPairSubscribe']; + $this->assertNull($payload['graphQlSubscriptionPair']); + $this->assertNull($payload['clientSubscriptionId']); + $this->assertNotEmpty($payload['mercureUrl']); + } +}