Skip to content

Commit cc46e69

Browse files
committed
feat(graphql): reworked how collection subscriptions work, added opt in mechanism
1 parent c9cefd8 commit cc46e69

File tree

5 files changed

+194
-106
lines changed

5 files changed

+194
-106
lines changed

src/GraphQl/Serializer/ItemNormalizer.php

+48
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use ApiPlatform\GraphQl\State\Provider\NoopProvider;
1717
use ApiPlatform\Metadata\ApiProperty;
1818
use ApiPlatform\Metadata\GraphQl\Query;
19+
use ApiPlatform\Metadata\GraphQl\QueryCollection;
1920
use ApiPlatform\Metadata\IdentifiersExtractorInterface;
2021
use ApiPlatform\Metadata\IriConverterInterface;
2122
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
@@ -26,6 +27,7 @@
2627
use ApiPlatform\Metadata\Util\ClassInfoTrait;
2728
use ApiPlatform\Serializer\CacheKeyTrait;
2829
use ApiPlatform\Serializer\ItemNormalizer as BaseItemNormalizer;
30+
use Doctrine\Common\Collections\Collection;
2931
use Psr\Log\LoggerInterface;
3032
use Psr\Log\NullLogger;
3133
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
@@ -120,10 +122,56 @@ protected function normalizeCollectionOfRelations(ApiProperty $propertyMetadata,
120122
return [...$attributeValue];
121123
}
122124

125+
// Handle relationships for mercure subscriptions
126+
if ($operation instanceof QueryCollection && $context['graphql_operation_name'] === 'mercure_subscription' && $attributeValue instanceof Collection && !$attributeValue->isEmpty()) {
127+
$relationContext = $context;
128+
// Grab collection attributes
129+
$relationContext['attributes'] = $context['attributes']['collection'];
130+
// Iterate over the collection and normalize each item
131+
$data['collection'] = $attributeValue
132+
->map(
133+
function($item) use ($format, $relationContext) {
134+
// Convert collection entity/item to array
135+
$normalized = $this->normalize($item, $format, $relationContext);
136+
// Add IRI to the normalized data to match GraphQL responses for ApiPlatform
137+
if (isset($normalized['id'])) {
138+
$normalized['_id'] = $normalized['id'];
139+
$normalized['id'] = $this->iriConverter->getIriFromResource($item);
140+
}
141+
return $normalized;
142+
}
143+
)
144+
// Convert the collection to an array
145+
->toArray();
146+
// Handle pagination if it's enabled in the query
147+
$data = $this->addPagination($attributeValue, $data, $context);
148+
return $data;
149+
}
150+
123151
// to-many are handled directly by the GraphQL resolver
124152
return [];
125153
}
126154

155+
private function addPagination(Collection $collection, array $data, array $context): array
156+
{
157+
if ($context['attributes']['paginationInfo'] ?? false) {
158+
$data['paginationInfo'] = [];
159+
if (array_key_exists('hasNextPage', $context['attributes']['paginationInfo'])) {
160+
$data['paginationInfo']['hasNextPage'] = $collection->count() > ($context['pagination']['itemsPerPage'] ?? 10);
161+
}
162+
if (array_key_exists('itemsPerPage', $context['attributes']['paginationInfo'])) {
163+
$data['paginationInfo']['itemsPerPage'] = $context['pagination']['itemsPerPage'] ?? 10;
164+
}
165+
if (array_key_exists('lastPage', $context['attributes']['paginationInfo'])) {
166+
$data['paginationInfo']['lastPage'] = (int) ceil($collection->count() / ($context['pagination']['itemsPerPage'] ?? 10));
167+
}
168+
if (array_key_exists('totalCount', $context['attributes']['paginationInfo'])) {
169+
$data['paginationInfo']['totalCount'] = $collection->count();
170+
}
171+
}
172+
return $data;
173+
}
174+
127175
/**
128176
* {@inheritdoc}
129177
*/

src/GraphQl/State/Processor/SubscriptionProcessor.php

+4
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use ApiPlatform\GraphQl\Subscription\OperationAwareSubscriptionManagerInterface;
1818
use ApiPlatform\GraphQl\Subscription\SubscriptionManagerInterface;
1919
use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation;
20+
use ApiPlatform\Metadata\GraphQl\Subscription;
2021
use ApiPlatform\Metadata\Operation;
2122
use ApiPlatform\State\ProcessorInterface;
2223

@@ -49,6 +50,9 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
4950

5051
$hub = \is_array($mercure) ? ($mercure['hub'] ?? null) : null;
5152
$data['mercureUrl'] = $this->mercureSubscriptionIriGenerator->generateMercureUrl($subscriptionId, $hub);
53+
if ($operation instanceof Subscription) {
54+
$data['isCollection'] = $operation->isCollection();
55+
}
5256
}
5357

5458
return $data;

src/GraphQl/Subscription/SubscriptionManager.php

+81-52
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ public function retrieveSubscriptionId(array $context, ?array $result, ?Operatio
5050
if (empty($iri)) {
5151
return null;
5252
}
53+
5354
$options = $operation->getMercure() ?? false;
5455
$private = $options['private'] ?? false;
5556
$privateFields = $options['private_fields'] ?? [];
@@ -59,33 +60,21 @@ public function retrieveSubscriptionId(array $context, ?array $result, ?Operatio
5960
$fields['__private_field_'.$privateField] = $this->getResourceId($privateField, $previousObject);
6061
}
6162
}
62-
$subscriptionsCacheItem = $this->subscriptionsCache->getItem($this->encodeIriToCacheKey($iri));
63-
$subscriptions = [];
64-
if ($subscriptionsCacheItem->isHit()) {
65-
$subscriptions = $subscriptionsCacheItem->get();
66-
foreach ($subscriptions as [$subscriptionId, $subscriptionFields, $subscriptionResult]) {
67-
if ($subscriptionFields === $fields) {
68-
return $subscriptionId;
69-
}
70-
}
71-
}
72-
73-
$subscriptionId = $this->subscriptionIdentifierGenerator->generateSubscriptionIdentifier($fields);
74-
unset($result['clientSubscriptionId']);
75-
if ($private && $privateFields && $previousObject) {
76-
foreach ($options['private_fields'] as $privateField) {
77-
unset($result['__private_field_'.$privateField]);
78-
}
63+
if ($operation->isCollection()) {
64+
$subscriptionId = $this->updateSubscriptionCollectionCacheData(
65+
$iri,
66+
$fields,
67+
);
68+
} else {
69+
$subscriptionId = $this->updateSubscriptionItemCacheData(
70+
$iri,
71+
$fields,
72+
$result,
73+
$private,
74+
$privateFields,
75+
$previousObject
76+
);
7977
}
80-
$subscriptions[] = [$subscriptionId, $fields, $result];
81-
$subscriptionsCacheItem->set($subscriptions);
82-
$this->subscriptionsCache->save($subscriptionsCacheItem);
83-
84-
$this->updateSubscriptionCollectionCacheData(
85-
$iri,
86-
$fields,
87-
$subscriptions,
88-
);
8978

9079
return $subscriptionId;
9180
}
@@ -123,25 +112,9 @@ private function removeItemFromSubscriptionCache(string $iri): void
123112
}
124113
}
125114

126-
private function updateSubscriptionCollectionCacheData(
127-
?string $iri,
128-
array $fields,
129-
array $subscriptions,
130-
): void
115+
private function encodeIriToCacheKey(string $iri): string
131116
{
132-
$subscriptionCollectionCacheItem = $this->subscriptionsCache->getItem(
133-
$this->encodeIriToCacheKey($this->getCollectionIri($iri)),
134-
);
135-
if ($subscriptionCollectionCacheItem->isHit()) {
136-
$collectionSubscriptions = $subscriptionCollectionCacheItem->get();
137-
foreach ($collectionSubscriptions as [$subscriptionId, $subscriptionFields, $subscriptionResult]) {
138-
if ($subscriptionFields === $fields) {
139-
return;
140-
}
141-
}
142-
}
143-
$subscriptionCollectionCacheItem->set($subscriptions);
144-
$this->subscriptionsCache->save($subscriptionCollectionCacheItem);
117+
return str_replace('/', '_', $iri);
145118
}
146119

147120
private function getResourceId(mixed $privateField, object $previousObject): string
@@ -161,11 +134,11 @@ private function getCollectionIri(string $iri): string
161134
private function getCreatedOrUpdatedPayloads(object $object): array
162135
{
163136
$iri = $this->iriConverter->getIriFromResource($object);
164-
$subscriptions = $this->getSubscriptionsFromIri($iri);
165-
if ($subscriptions === []) {
166-
// Get subscriptions from collection Iri
167-
$subscriptions = $this->getSubscriptionsFromIri($this->getCollectionIri($iri));
168-
}
137+
// Add collection subscriptions
138+
$subscriptions = array_merge(
139+
$this->getSubscriptionsFromIri($this->getCollectionIri($iri)),
140+
$this->getSubscriptionsFromIri($iri)
141+
);
169142

170143
$resourceClass = $this->getObjectClass($object);
171144
$resourceMetadata = $this->resourceMetadataCollectionFactory->create($resourceClass);
@@ -190,7 +163,7 @@ private function getCreatedOrUpdatedPayloads(object $object): array
190163
}
191164
}
192165
$resolverContext = ['fields' => $subscriptionFields, 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true];
193-
$operation = (new Subscription())->withName('update_subscription')->withShortName($shortName);
166+
$operation = (new Subscription())->withName('mercure_subscription')->withShortName($shortName);
194167
$data = $this->normalizeProcessor->process($object, $operation, [], $resolverContext);
195168

196169
unset($data['clientSubscriptionId']);
@@ -219,8 +192,64 @@ private function getDeletePushPayloads(object $object): array
219192
return $payloads;
220193
}
221194

222-
private function encodeIriToCacheKey(string $iri): string
195+
private function updateSubscriptionItemCacheData(
196+
string $iri,
197+
array $fields,
198+
?array $result,
199+
bool $private,
200+
array $privateFields,
201+
?object $previousObject
202+
): string
223203
{
224-
return str_replace('/', '_', $iri);
204+
$subscriptionsCacheItem = $this->subscriptionsCache->getItem($this->encodeIriToCacheKey($iri));
205+
$subscriptions = [];
206+
if ($subscriptionsCacheItem->isHit()) {
207+
/*
208+
* @var array<array{string, array<string, string|array>, array<string, string|array>}>
209+
*/
210+
$subscriptions = $subscriptionsCacheItem->get();
211+
foreach ($subscriptions as [$subscriptionId, $subscriptionFields, $subscriptionResult]) {
212+
if ($subscriptionFields === $fields) {
213+
return $subscriptionId;
214+
}
215+
}
216+
}
217+
218+
$subscriptionId = $this->subscriptionIdentifierGenerator->generateSubscriptionIdentifier($fields);
219+
unset($result['clientSubscriptionId']);
220+
if ($private && $privateFields && $previousObject) {
221+
foreach ($privateFields as $privateField) {
222+
unset($result['__private_field_' . $privateField]);
223+
}
224+
}
225+
$subscriptions[] = [$subscriptionId, $fields, $result];
226+
$subscriptionsCacheItem->set($subscriptions);
227+
$this->subscriptionsCache->save($subscriptionsCacheItem);
228+
return $subscriptionId;
229+
}
230+
231+
232+
233+
private function updateSubscriptionCollectionCacheData(
234+
string $iri,
235+
array $fields,
236+
): string
237+
{
238+
$subscriptionCollectionCacheItem = $this->subscriptionsCache->getItem(
239+
$this->encodeIriToCacheKey($this->getCollectionIri($iri)),
240+
);
241+
if ($subscriptionCollectionCacheItem->isHit()) {
242+
$collectionSubscriptions = $subscriptionCollectionCacheItem->get();
243+
foreach ($collectionSubscriptions as [$subscriptionId, $subscriptionFields]) {
244+
if ($subscriptionFields === $fields) {
245+
return $subscriptionId;
246+
}
247+
}
248+
}
249+
$subscriptionId = $this->subscriptionIdentifierGenerator->generateSubscriptionIdentifier($fields + ['__collection' => true]);
250+
$subscriptions[] = [$subscriptionId, $fields, []];
251+
$subscriptionCollectionCacheItem->set($subscriptions);
252+
$this->subscriptionsCache->save($subscriptionCollectionCacheItem);
253+
return $subscriptionId;
225254
}
226255
}

src/GraphQl/Type/TypeBuilder.php

+1
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,7 @@ private function getResourceObjectTypeConfiguration(string $shortName, ResourceM
355355

356356
if ($operation instanceof Subscription) {
357357
$fields['clientSubscriptionId'] = GraphQLType::string();
358+
$fields['isCollection'] = GraphQLType::boolean();
358359
if ($operation->getMercure()) {
359360
$fields['mercureUrl'] = GraphQLType::string();
360361
}

src/Metadata/GraphQl/Subscription.php

+60-54
Original file line numberDiff line numberDiff line change
@@ -76,63 +76,69 @@ public function __construct(
7676
mixed $rules = null,
7777
?string $policy = null,
7878
array $extraProperties = [],
79+
protected bool $collection = false,
7980
) {
8081
parent::__construct(
81-
resolver: $resolver,
82-
args: $args,
83-
extraArgs: $extraArgs,
84-
links: $links,
85-
securityAfterResolver: $securityAfterResolver,
86-
securityMessageAfterResolver: $securityMessageAfterResolver,
87-
shortName: $shortName,
88-
class: $class,
89-
paginationEnabled: $paginationEnabled,
90-
paginationType: $paginationType,
91-
paginationItemsPerPage: $paginationItemsPerPage,
92-
paginationMaximumItemsPerPage: $paginationMaximumItemsPerPage,
93-
paginationPartial: $paginationPartial,
94-
paginationClientEnabled: $paginationClientEnabled,
95-
paginationClientItemsPerPage: $paginationClientItemsPerPage,
96-
paginationClientPartial: $paginationClientPartial,
97-
paginationFetchJoinCollection: $paginationFetchJoinCollection,
98-
paginationUseOutputWalkers: $paginationUseOutputWalkers,
99-
order: $order,
100-
description: $description,
101-
normalizationContext: $normalizationContext,
102-
denormalizationContext: $denormalizationContext,
103-
collectDenormalizationErrors: $collectDenormalizationErrors,
104-
security: $security,
105-
securityMessage: $securityMessage,
106-
securityPostDenormalize: $securityPostDenormalize,
107-
securityPostDenormalizeMessage: $securityPostDenormalizeMessage,
108-
securityPostValidation: $securityPostValidation,
109-
securityPostValidationMessage: $securityPostValidationMessage,
110-
deprecationReason: $deprecationReason,
111-
filters: $filters,
112-
validationContext: $validationContext,
113-
input: $input,
114-
output: $output,
115-
mercure: $mercure,
116-
messenger: $messenger,
117-
elasticsearch: $elasticsearch,
118-
urlGenerationStrategy: $urlGenerationStrategy,
119-
read: $read,
120-
deserialize: $deserialize,
121-
validate: $validate,
122-
write: $write,
123-
serialize: $serialize,
124-
fetchPartial: $fetchPartial,
125-
forceEager: $forceEager,
126-
priority: $priority,
127-
name: $name ?: 'update_subscription',
128-
provider: $provider,
129-
processor: $processor,
130-
stateOptions: $stateOptions,
131-
parameters: $parameters,
82+
resolver : $resolver,
83+
args : $args,
84+
extraArgs : $extraArgs,
85+
links : $links,
86+
securityAfterResolver : $securityAfterResolver,
87+
securityMessageAfterResolver : $securityMessageAfterResolver,
88+
shortName : $shortName,
89+
class : $class,
90+
paginationEnabled : $paginationEnabled,
91+
paginationType : $paginationType,
92+
paginationItemsPerPage : $paginationItemsPerPage,
93+
paginationMaximumItemsPerPage : $paginationMaximumItemsPerPage,
94+
paginationPartial : $paginationPartial,
95+
paginationClientEnabled : $paginationClientEnabled,
96+
paginationClientItemsPerPage : $paginationClientItemsPerPage,
97+
paginationClientPartial : $paginationClientPartial,
98+
paginationFetchJoinCollection : $paginationFetchJoinCollection,
99+
paginationUseOutputWalkers : $paginationUseOutputWalkers,
100+
order : $order,
101+
description : $description,
102+
normalizationContext : $normalizationContext,
103+
denormalizationContext : $denormalizationContext,
104+
collectDenormalizationErrors : $collectDenormalizationErrors,
105+
security : $security,
106+
securityMessage : $securityMessage,
107+
securityPostDenormalize : $securityPostDenormalize,
108+
securityPostDenormalizeMessage : $securityPostDenormalizeMessage,
109+
securityPostValidation : $securityPostValidation,
110+
securityPostValidationMessage : $securityPostValidationMessage,
111+
deprecationReason : $deprecationReason,
112+
filters : $filters,
113+
validationContext : $validationContext,
114+
input : $input,
115+
output : $output,
116+
mercure : $mercure,
117+
messenger : $messenger,
118+
elasticsearch : $elasticsearch,
119+
urlGenerationStrategy : $urlGenerationStrategy,
120+
read : $read,
121+
deserialize : $deserialize,
122+
validate : $validate,
123+
write : $write,
124+
serialize : $serialize,
125+
fetchPartial : $fetchPartial,
126+
forceEager : $forceEager,
127+
priority : $priority,
128+
name : $name ?: 'update_subscription',
129+
provider : $provider,
130+
processor : $processor,
131+
stateOptions : $stateOptions,
132+
parameters : $parameters,
132133
queryParameterValidationEnabled: $queryParameterValidationEnabled,
133-
policy: $policy,
134-
rules: $rules,
135-
extraProperties: $extraProperties,
134+
rules : $rules,
135+
policy : $policy,
136+
extraProperties : $extraProperties,
136137
);
137138
}
139+
140+
public function isCollection(): bool
141+
{
142+
return $this->collection;
143+
}
138144
}

0 commit comments

Comments
 (0)