diff --git a/features/graphql/collection.feature b/features/graphql/collection.feature
index 1540549f7d8..9767505171a 100644
--- a/features/graphql/collection.feature
+++ b/features/graphql/collection.feature
@@ -910,3 +910,49 @@ Feature: GraphQL collection support
Then the response status code should be 200
And the response should be in JSON
And the JSON node "data.fooDummies.collection" should have 1 element
+
+ @createSchema
+ Scenario: Retrieve paginated collections using mixed pagination
+ Given there are 5 fooDummy objects with fake names
+ When I send the following GraphQL request:
+ """
+ {
+ fooDummies(page: 1) {
+ collection {
+ id
+ name
+ soManies(first: 2) {
+ edges {
+ node {
+ content
+ }
+ cursor
+ }
+ pageInfo {
+ startCursor
+ endCursor
+ hasNextPage
+ hasPreviousPage
+ }
+ }
+ }
+ paginationInfo {
+ itemsPerPage
+ lastPage
+ totalCount
+ }
+ }
+ }
+ """
+ Then the response status code should be 200
+ And the response should be in JSON
+ And the JSON node "data.fooDummies.collection" should have 3 elements
+ And the JSON node "data.fooDummies.collection[2].id" should exist
+ And the JSON node "data.fooDummies.collection[2].name" should exist
+ And the JSON node "data.fooDummies.collection[2].soManies" should exist
+ And the JSON node "data.fooDummies.collection[2].soManies.edges" should have 2 elements
+ And the JSON node "data.fooDummies.collection[2].soManies.edges[1].node.content" should be equal to "So many 1"
+ And the JSON node "data.fooDummies.collection[2].soManies.pageInfo.startCursor" should be equal to "MA=="
+ And the JSON node "data.fooDummies.paginationInfo.itemsPerPage" should be equal to the number 3
+ And the JSON node "data.fooDummies.paginationInfo.lastPage" should be equal to the number 2
+ And the JSON node "data.fooDummies.paginationInfo.totalCount" should be equal to the number 5
diff --git a/features/main/default_order.feature b/features/main/default_order.feature
index 91211014315..7458c2456e9 100644
--- a/features/main/default_order.feature
+++ b/features/main/default_order.feature
@@ -79,35 +79,61 @@ Feature: Default order
"@type": "FooDummy",
"id": 5,
"name": "Balbo",
- "dummy": "/dummies/5"
+ "dummy": "/dummies/5",
+ "soManies": [
+ "/so_manies/13",
+ "/so_manies/14",
+ "/so_manies/15"
+ ]
+
},
{
"@id": "/foo_dummies/3",
"@type": "FooDummy",
"id": 3,
"name": "Sthenelus",
- "dummy": "/dummies/3"
+ "dummy": "/dummies/3",
+ "soManies": [
+ "/so_manies/7",
+ "/so_manies/8",
+ "/so_manies/9"
+ ]
},
{
"@id": "/foo_dummies/2",
"@type": "FooDummy",
"id": 2,
"name": "Ephesian",
- "dummy": "/dummies/2"
+ "dummy": "/dummies/2",
+ "soManies": [
+ "/so_manies/4",
+ "/so_manies/5",
+ "/so_manies/6"
+ ]
},
{
"@id": "/foo_dummies/1",
"@type": "FooDummy",
"id": 1,
"name": "Hawsepipe",
- "dummy": "/dummies/1"
+ "dummy": "/dummies/1",
+ "soManies": [
+ "/so_manies/1",
+ "/so_manies/2",
+ "/so_manies/3"
+ ]
},
{
"@id": "/foo_dummies/4",
"@type": "FooDummy",
"id": 4,
"name": "Separativeness",
- "dummy": "/dummies/4"
+ "dummy": "/dummies/4",
+ "soManies": [
+ "/so_manies/10",
+ "/so_manies/11",
+ "/so_manies/12"
+ ]
}
],
"hydra:totalItems": 5,
diff --git a/src/GraphQl/Metadata/Factory/GraphQlNestedOperationResourceMetadataFactory.php b/src/GraphQl/Metadata/Factory/GraphQlNestedOperationResourceMetadataFactory.php
new file mode 100644
index 00000000000..05163b0c2b7
--- /dev/null
+++ b/src/GraphQl/Metadata/Factory/GraphQlNestedOperationResourceMetadataFactory.php
@@ -0,0 +1,67 @@
+
+ *
+ * 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\Metadata\Factory;
+
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Resource\Factory\OperationDefaultsTrait;
+use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
+use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter;
+
+final class GraphQlNestedOperationResourceMetadataFactory implements ResourceMetadataCollectionFactoryInterface
+{
+ use OperationDefaultsTrait;
+
+ public function __construct(array $defaults, private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null, ?LoggerInterface $logger = null)
+ {
+ $this->defaults = $defaults;
+ $this->camelCaseToSnakeCaseNameConverter = new CamelCaseToSnakeCaseNameConverter();
+ $this->logger = $logger ?? new NullLogger();
+ }
+
+ public function create(string $resourceClass): ResourceMetadataCollection
+ {
+ $resourceMetadataCollection = new ResourceMetadataCollection($resourceClass);
+
+ if ($this->decorated) {
+ $resourceMetadataCollection = $this->decorated->create($resourceClass);
+ }
+
+ if (0 < \count($resourceMetadataCollection)) {
+ return $resourceMetadataCollection;
+ }
+
+ $shortName = (false !== $pos = strrpos($resourceClass, '\\')) ? substr($resourceClass, $pos + 1) : $resourceClass;
+
+ $apiResource = new ApiResource(
+ class: $resourceClass,
+ shortName: $shortName
+ );
+
+ if (class_exists($resourceClass)) {
+ $refl = new \ReflectionClass($resourceClass);
+ $attribute = $refl->getAttributes(ApiResource::class)[0] ?? null;
+ $attributeInstance = $attribute?->newInstance();
+ if ($filters = $attributeInstance?->getFilters()) {
+ $apiResource = $apiResource->withFilters($filters);
+ }
+ }
+
+ $resourceMetadataCollection[0] = $this->addDefaultGraphQlOperations($apiResource);
+
+ return $resourceMetadataCollection;
+ }
+}
diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php
index 8817899c8ef..d64f1cb800b 100644
--- a/src/GraphQl/Type/FieldsBuilder.php
+++ b/src/GraphQl/Type/FieldsBuilder.php
@@ -20,9 +20,7 @@
use ApiPlatform\Metadata\GraphQl\Mutation;
use ApiPlatform\Metadata\GraphQl\Operation;
use ApiPlatform\Metadata\GraphQl\Query;
-use ApiPlatform\Metadata\GraphQl\QueryCollection;
use ApiPlatform\Metadata\GraphQl\Subscription;
-use ApiPlatform\Metadata\Operation as AbstractOperation;
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
@@ -47,7 +45,7 @@
*/
final class FieldsBuilder implements FieldsBuilderInterface
{
- public function __construct(private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ResourceClassResolverInterface $resourceClassResolver, private readonly TypesContainerInterface $typesContainer, private readonly TypeBuilderInterface $typeBuilder, private readonly TypeConverterInterface $typeConverter, private readonly ResolverFactoryInterface $itemResolverFactory, private readonly ResolverFactoryInterface $collectionResolverFactory, private readonly ResolverFactoryInterface $itemMutationResolverFactory, private readonly ResolverFactoryInterface $itemSubscriptionResolverFactory, private readonly ContainerInterface $filterLocator, private readonly Pagination $pagination, private readonly ?NameConverterInterface $nameConverter, private readonly string $nestingSeparator)
+ public function __construct(private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ResourceClassResolverInterface $resourceClassResolver, private readonly TypesContainerInterface $typesContainer, private readonly TypeBuilderInterface $typeBuilder, private readonly TypeConverterInterface $typeConverter, private readonly ResolverFactoryInterface $itemResolverFactory, private readonly ResolverFactoryInterface $collectionResolverFactory, private readonly ResolverFactoryInterface $itemMutationResolverFactory, private readonly ResolverFactoryInterface $itemSubscriptionResolverFactory, private readonly ContainerInterface $filterLocator, private readonly Pagination $pagination, private readonly ?NameConverterInterface $nameConverter, private readonly string $nestingSeparator, private readonly ?ResourceMetadataCollectionFactoryInterface $graphQlNestedOperationResourceMetadataFactory = null)
{
}
@@ -256,7 +254,23 @@ private function getResourceFieldConfiguration(?string $property, ?string $field
$resourceClass = $type->getClassName();
}
- $graphqlType = $this->convertType($type, $input, $rootOperation, $resourceClass ?? '', $rootResource, $property, $depth, $forceNullable);
+ $resourceOperation = $rootOperation;
+ if ($resourceClass && $rootOperation->getClass() && $this->resourceClassResolver->isResourceClass($resourceClass) && $rootOperation->getClass() !== $resourceClass) {
+ $resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($resourceClass);
+ try {
+ $resourceOperation = $resourceMetadataCollection->getOperation($isCollectionType ? 'collection_query' : 'item_query');
+ } catch (OperationNotFoundException) {
+ // If there is no query operation for a nested resource we force one to exist
+ $nestedResourceMetadataCollection = $this->graphQlNestedOperationResourceMetadataFactory->create($resourceClass);
+ $resourceOperation = $nestedResourceMetadataCollection->getOperation($isCollectionType ? 'collection_query' : 'item_query');
+ }
+ }
+
+ if (!$resourceOperation instanceof Operation) {
+ throw new \LogicException('The resource operation should be a GraphQL operation.');
+ }
+
+ $graphqlType = $this->convertType($type, $input, $resourceOperation, $rootOperation, $resourceClass ?? '', $rootResource, $property, $depth, $forceNullable);
$graphqlWrappedType = $graphqlType instanceof WrappingType ? $graphqlType->getWrappedType(true) : $graphqlType;
$isStandardGraphqlType = \in_array($graphqlWrappedType, GraphQLType::getStandardTypes(), true);
@@ -271,43 +285,22 @@ private function getResourceFieldConfiguration(?string $property, ?string $field
$args = [];
- $resolverOperation = $rootOperation;
-
- if ($resourceClass && $this->resourceClassResolver->isResourceClass($resourceClass) && $rootOperation->getClass() !== $resourceClass) {
- $resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($resourceClass);
- $resolverOperation = $resourceMetadataCollection->getOperation(null, $isCollectionType);
-
- if (!$resolverOperation instanceof Operation) {
- $resolverOperation = ($isCollectionType ? new QueryCollection() : new Query())->withOperation($resolverOperation);
- }
- }
-
if (!$input && !$rootOperation instanceof Mutation && !$rootOperation instanceof Subscription && !$isStandardGraphqlType && $isCollectionType) {
- if ($this->pagination->isGraphQlEnabled($rootOperation)) {
- $args = $this->getGraphQlPaginationArgs($rootOperation);
- }
-
- // Find the collection operation to get filters, there might be a smarter way to do this
- $operation = null;
- if (!empty($resourceClass)) {
- $resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($resourceClass);
- try {
- $operation = $resourceMetadataCollection->getOperation(null, true);
- } catch (OperationNotFoundException) {
- }
+ if ($this->pagination->isGraphQlEnabled($resourceOperation)) {
+ $args = $this->getGraphQlPaginationArgs($resourceOperation);
}
- $args = $this->getFilterArgs($args, $resourceClass, $rootResource, $rootOperation, $property, $depth, $operation);
+ $args = $this->getFilterArgs($args, $resourceClass, $rootResource, $resourceOperation, $rootOperation, $property, $depth);
}
if ($isStandardGraphqlType || $input) {
$resolve = null;
} elseif (($rootOperation instanceof Mutation || $rootOperation instanceof Subscription) && $depth <= 0) {
- $resolve = $rootOperation instanceof Mutation ? ($this->itemMutationResolverFactory)($resourceClass, $rootResource, $resolverOperation) : ($this->itemSubscriptionResolverFactory)($resourceClass, $rootResource, $resolverOperation);
+ $resolve = $rootOperation instanceof Mutation ? ($this->itemMutationResolverFactory)($resourceClass, $rootResource, $resourceOperation) : ($this->itemSubscriptionResolverFactory)($resourceClass, $rootResource, $resourceOperation);
} elseif ($this->typeBuilder->isCollection($type)) {
- $resolve = ($this->collectionResolverFactory)($resourceClass, $rootResource, $resolverOperation);
+ $resolve = ($this->collectionResolverFactory)($resourceClass, $rootResource, $resourceOperation);
} else {
- $resolve = ($this->itemResolverFactory)($resourceClass, $rootResource, $resolverOperation);
+ $resolve = ($this->itemResolverFactory)($resourceClass, $rootResource, $resourceOperation);
}
return [
@@ -368,13 +361,13 @@ private function getGraphQlPaginationArgs(Operation $queryOperation): array
return $args;
}
- private function getFilterArgs(array $args, ?string $resourceClass, string $rootResource, Operation $rootOperation, ?string $property, int $depth, ?AbstractOperation $operation = null): array
+ private function getFilterArgs(array $args, ?string $resourceClass, string $rootResource, Operation $resourceOperation, Operation $rootOperation, ?string $property, int $depth): array
{
- if (null === $operation || null === $resourceClass) {
+ if (null === $resourceClass) {
return $args;
}
- foreach ($operation->getFilters() ?? [] as $filterId) {
+ foreach ($resourceOperation->getFilters() ?? [] as $filterId) {
if (!$this->filterLocator->has($filterId)) {
continue;
}
@@ -382,7 +375,7 @@ private function getFilterArgs(array $args, ?string $resourceClass, string $root
foreach ($this->filterLocator->get($filterId)->getDescription($resourceClass) as $key => $value) {
$nullable = isset($value['required']) ? !$value['required'] : true;
$filterType = \in_array($value['type'], Type::$builtinTypes, true) ? new Type($value['type'], $nullable) : new Type('object', $nullable, $value['type']);
- $graphqlFilterType = $this->convertType($filterType, false, $rootOperation, $resourceClass, $rootResource, $property, $depth);
+ $graphqlFilterType = $this->convertType($filterType, false, $resourceOperation, $rootOperation, $resourceClass, $rootResource, $property, $depth);
if (str_ends_with($key, '[]')) {
$graphqlFilterType = GraphQLType::listOf($graphqlFilterType);
@@ -399,14 +392,14 @@ private function getFilterArgs(array $args, ?string $resourceClass, string $root
array_walk_recursive($parsed, static function (&$value) use ($graphqlFilterType): void {
$value = $graphqlFilterType;
});
- $args = $this->mergeFilterArgs($args, $parsed, $operation, $key);
+ $args = $this->mergeFilterArgs($args, $parsed, $resourceOperation, $key);
}
}
return $this->convertFilterArgsToTypes($args);
}
- private function mergeFilterArgs(array $args, array $parsed, ?AbstractOperation $operation = null, string $original = ''): array
+ private function mergeFilterArgs(array $args, array $parsed, ?Operation $operation = null, string $original = ''): array
{
foreach ($parsed as $key => $value) {
// Never override keys that cannot be merged
@@ -470,7 +463,7 @@ private function convertFilterArgsToTypes(array $args): array
*
* @throws InvalidTypeException
*/
- private function convertType(Type $type, bool $input, Operation $rootOperation, string $resourceClass, string $rootResource, ?string $property, int $depth, bool $forceNullable = false): GraphQLType|ListOfType|NonNull
+ private function convertType(Type $type, bool $input, Operation $resourceOperation, Operation $rootOperation, string $resourceClass, string $rootResource, ?string $property, int $depth, bool $forceNullable = false): GraphQLType|ListOfType|NonNull
{
$graphqlType = $this->typeConverter->convertType($type, $input, $rootOperation, $resourceClass, $rootResource, $property, $depth);
@@ -487,7 +480,7 @@ private function convertType(Type $type, bool $input, Operation $rootOperation,
}
if ($this->typeBuilder->isCollection($type)) {
- return $this->pagination->isGraphQlEnabled($rootOperation) && !$input ? $this->typeBuilder->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $rootOperation) : GraphQLType::listOf($graphqlType);
+ return $this->pagination->isGraphQlEnabled($resourceOperation) && !$input ? $this->typeBuilder->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $resourceOperation) : GraphQLType::listOf($graphqlType);
}
return $forceNullable || !$graphqlType instanceof NullableType || $type->isNullable() || ($rootOperation instanceof Mutation && 'update' === $rootOperation->getName())
diff --git a/src/Metadata/Resource/Factory/AttributesResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/AttributesResourceMetadataCollectionFactory.php
index 9255c236fa8..9b125aed5c3 100644
--- a/src/Metadata/Resource/Factory/AttributesResourceMetadataCollectionFactory.php
+++ b/src/Metadata/Resource/Factory/AttributesResourceMetadataCollectionFactory.php
@@ -14,20 +14,12 @@
namespace ApiPlatform\Metadata\Resource\Factory;
use ApiPlatform\Exception\ResourceClassNotFoundException;
-use ApiPlatform\Exception\RuntimeException;
use ApiPlatform\Metadata\ApiResource;
-use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
-use ApiPlatform\Metadata\GraphQl\DeleteMutation;
-use ApiPlatform\Metadata\GraphQl\Mutation;
use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation;
-use ApiPlatform\Metadata\GraphQl\Query;
-use ApiPlatform\Metadata\GraphQl\QueryCollection;
-use ApiPlatform\Metadata\GraphQl\Subscription;
use ApiPlatform\Metadata\HttpOperation;
-use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Operations;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
@@ -45,12 +37,12 @@
*/
final class AttributesResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface
{
- private readonly LoggerInterface $logger;
- private readonly CamelCaseToSnakeCaseNameConverter $camelCaseToSnakeCaseNameConverter;
+ use OperationDefaultsTrait;
- public function __construct(private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null, LoggerInterface $logger = null, private readonly array $defaults = [], private readonly bool $graphQlEnabled = false)
+ public function __construct(private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null, LoggerInterface $logger = null, array $defaults = [], private readonly bool $graphQlEnabled = false)
{
$this->logger = $logger ?? new NullLogger();
+ $this->defaults = $defaults;
$this->camelCaseToSnakeCaseNameConverter = new CamelCaseToSnakeCaseNameConverter();
}
@@ -164,115 +156,6 @@ private function buildResourceOperations(array $attributes, string $resourceClas
return $resources;
}
- private function getOperationWithDefaults(ApiResource $resource, Operation $operation, bool $generated = false): array
- {
- // Inherit from resource defaults
- foreach (get_class_methods($resource) as $methodName) {
- if (!str_starts_with($methodName, 'get')) {
- continue;
- }
-
- if (!method_exists($operation, $methodName) || null !== $operation->{$methodName}()) {
- continue;
- }
-
- if (null === ($value = $resource->{$methodName}())) {
- continue;
- }
-
- $operation = $operation->{'with'.substr($methodName, 3)}($value);
- }
-
- $operation = $operation->withExtraProperties(array_merge(
- $resource->getExtraProperties(),
- $operation->getExtraProperties(),
- $generated ? ['generated_operation' => true] : []
- ));
-
- // Add global defaults attributes to the operation
- $operation = $this->addGlobalDefaults($operation);
-
- if ($operation instanceof GraphQlOperation) {
- if (!$operation->getName()) {
- throw new RuntimeException('No GraphQL operation name.');
- }
-
- if ($operation instanceof Mutation) {
- $operation = $operation->withDescription(ucfirst("{$operation->getName()}s a {$resource->getShortName()}."));
- }
-
- return [$operation->getName(), $operation];
- }
-
- if (!$operation instanceof HttpOperation) {
- throw new RuntimeException(sprintf('Operation should be an instance of "%s"', HttpOperation::class));
- }
-
- if ($operation->getRouteName()) {
- /** @var HttpOperation $operation */
- $operation = $operation->withName($operation->getRouteName());
- }
-
- // Check for name conflict
- if ($operation->getName()) {
- if (null !== $resource->getOperations() && !$resource->getOperations()->has($operation->getName())) {
- return [$operation->getName(), $operation];
- }
-
- $this->logger->warning(sprintf('The operation "%s" already exists on the resource "%s", pick a different name or leave it empty. In the meantime we will generate a unique name.', $operation->getName(), $resource->getClass()));
- /** @var HttpOperation $operation */
- $operation = $operation->withName('');
- }
-
- return [
- sprintf(
- '_api_%s_%s%s',
- $operation->getUriTemplate() ?: $operation->getShortName(),
- strtolower($operation->getMethod() ?? HttpOperation::METHOD_GET),
- $operation instanceof CollectionOperationInterface ? '_collection' : '',
- ),
- $operation,
- ];
- }
-
- private function addGlobalDefaults(ApiResource|HttpOperation|GraphQlOperation $operation): ApiResource|HttpOperation|GraphQlOperation
- {
- $extraProperties = [];
- foreach ($this->defaults as $key => $value) {
- $upperKey = ucfirst($this->camelCaseToSnakeCaseNameConverter->denormalize($key));
- $getter = 'get'.$upperKey;
-
- if (!method_exists($operation, $getter)) {
- if (!isset($extraProperties[$key])) {
- $extraProperties[$key] = $value;
- }
- } else {
- $currentValue = $operation->{$getter}();
-
- if (\is_array($currentValue) && $currentValue) {
- $operation = $operation->{'with'.$upperKey}(array_merge($value, $currentValue));
- }
-
- if (null !== $currentValue) {
- continue;
- }
-
- $operation = $operation->{'with'.$upperKey}($value);
- }
- }
-
- return $operation->withExtraProperties(array_merge($extraProperties, $operation->getExtraProperties()));
- }
-
- private function getResourceWithDefaults(string $resourceClass, string $shortName, ApiResource $resource): ApiResource
- {
- $resource = $resource
- ->withShortName($resource->getShortName() ?? $shortName)
- ->withClass($resourceClass);
-
- return $this->addGlobalDefaults($resource);
- }
-
private function hasResourceAttributes(\ReflectionClass $reflectionClass): bool
{
foreach ($reflectionClass->getAttributes() as $attribute) {
@@ -303,22 +186,6 @@ private function hasSameOperation(ApiResource $resource, string $operationClass,
return false;
}
- private function addDefaultGraphQlOperations(ApiResource $resource): ApiResource
- {
- $graphQlOperations = [];
- foreach ([new QueryCollection(), new Query(), (new Mutation())->withName('update'), (new DeleteMutation())->withName('delete'), (new Mutation())->withName('create')] as $i => $operation) {
- [$key, $operation] = $this->getOperationWithDefaults($resource, $operation);
- $graphQlOperations[$key] = $operation;
- }
-
- if ($resource->getMercure()) {
- [$key, $operation] = $this->getOperationWithDefaults($resource, (new Subscription())->withDescription("Subscribes to the update event of a {$operation->getShortName()}."));
- $graphQlOperations[$key] = $operation;
- }
-
- return $resource->withGraphQlOperations($graphQlOperations);
- }
-
private function getDefaultHttpOperations($resource): iterable
{
$post = new Post();
diff --git a/src/Metadata/Resource/Factory/ExtractorResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ExtractorResourceMetadataCollectionFactory.php
index c44358897ca..86c154f7bc4 100644
--- a/src/Metadata/Resource/Factory/ExtractorResourceMetadataCollectionFactory.php
+++ b/src/Metadata/Resource/Factory/ExtractorResourceMetadataCollectionFactory.php
@@ -19,7 +19,12 @@
use ApiPlatform\Metadata\Extractor\ResourceExtractorInterface;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\GraphQl\DeleteMutation;
+use ApiPlatform\Metadata\GraphQl\Mutation;
+use ApiPlatform\Metadata\GraphQl\Query;
+use ApiPlatform\Metadata\GraphQl\QueryCollection;
use ApiPlatform\Metadata\HttpOperation;
+use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Operations;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
@@ -88,9 +93,7 @@ private function buildResources(array $nodes, string $resourceClass): array
}
}
- if (isset($node['graphQlOperations'])) {
- $resource = $resource->withGraphQlOperations($this->buildGraphQlOperations($node['graphQlOperations'], $resource));
- }
+ $resource = $resource->withGraphQlOperations($this->buildGraphQlOperations($node['graphQlOperations'] ?? null, $resource));
$resources[] = $resource->withOperations(new Operations($this->buildOperations($node['operations'] ?? null, $resource)));
}
@@ -148,6 +151,20 @@ private function buildGraphQlOperations(?array $data, ApiResource $resource): ar
{
$operations = [];
+ if (null === $data) {
+ foreach ([new QueryCollection(), new Query(), (new Mutation())->withName('update'), (new DeleteMutation())->withName('delete'), (new Mutation())->withName('create')] as $operation) {
+ $operation = $this->getOperationWithDefaults($resource, $operation);
+
+ if ($operation instanceof Mutation) {
+ $operation = $operation->withDescription(ucfirst("{$operation->getName()}s a {$resource->getShortName()}."));
+ }
+
+ $operations[$operation->getName()] = $operation;
+ }
+
+ return $operations;
+ }
+
foreach ($data as $attributes) {
/** @var HttpOperation $operation */
$operation = (new $attributes['graphql_operation_class']())->withShortName($resource->getShortName());
@@ -175,7 +192,7 @@ private function buildGraphQlOperations(?array $data, ApiResource $resource): ar
return $operations;
}
- private function getOperationWithDefaults(ApiResource $resource, HttpOperation $operation): HttpOperation
+ private function getOperationWithDefaults(ApiResource $resource, Operation $operation): Operation
{
foreach (($this->defaults['attributes'] ?? []) as $key => $value) {
$key = $this->camelCaseToSnakeCaseNameConverter->denormalize($key);
diff --git a/src/Metadata/Resource/Factory/FiltersResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/FiltersResourceMetadataCollectionFactory.php
index bb68448c4c2..10a711fe71a 100644
--- a/src/Metadata/Resource/Factory/FiltersResourceMetadataCollectionFactory.php
+++ b/src/Metadata/Resource/Factory/FiltersResourceMetadataCollectionFactory.php
@@ -50,17 +50,21 @@ public function create(string $resourceClass): ResourceMetadataCollection
$filters = array_keys($this->readFilterAttributes($reflectionClass));
foreach ($resourceMetadataCollection as $i => $resource) {
- foreach ($operations = $resource->getOperations() as $operationName => $operation) {
+ foreach ($operations = $resource->getOperations() ?? [] as $operationName => $operation) {
$operations->add($operationName, $operation->withFilters(array_unique(array_merge($resource->getFilters() ?? [], $operation->getFilters() ?? [], $filters))));
}
- $resourceMetadataCollection[$i] = $resource->withOperations($operations);
+ if ($operations) {
+ $resourceMetadataCollection[$i] = $resource->withOperations($operations);
+ }
foreach ($graphQlOperations = $resource->getGraphQlOperations() ?? [] as $operationName => $operation) {
$graphQlOperations[$operationName] = $operation->withFilters(array_unique(array_merge($resource->getFilters() ?? [], $operation->getFilters() ?? [], $filters)));
}
- $resourceMetadataCollection[$i] = $resource->withGraphQlOperations($graphQlOperations);
+ if ($graphQlOperations) {
+ $resourceMetadataCollection[$i] = $resource->withGraphQlOperations($graphQlOperations);
+ }
}
return $resourceMetadataCollection;
diff --git a/src/Metadata/Resource/Factory/OperationDefaultsTrait.php b/src/Metadata/Resource/Factory/OperationDefaultsTrait.php
new file mode 100644
index 00000000000..01f3bdc346f
--- /dev/null
+++ b/src/Metadata/Resource/Factory/OperationDefaultsTrait.php
@@ -0,0 +1,160 @@
+
+ *
+ * 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\Resource\Factory;
+
+use ApiPlatform\Exception\RuntimeException;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\CollectionOperationInterface;
+use ApiPlatform\Metadata\GraphQl\DeleteMutation;
+use ApiPlatform\Metadata\GraphQl\Mutation;
+use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation;
+use ApiPlatform\Metadata\GraphQl\Query;
+use ApiPlatform\Metadata\GraphQl\QueryCollection;
+use ApiPlatform\Metadata\GraphQl\Subscription;
+use ApiPlatform\Metadata\HttpOperation;
+use ApiPlatform\Metadata\Operation;
+use Psr\Log\LoggerInterface;
+use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter;
+
+trait OperationDefaultsTrait
+{
+ private CamelCaseToSnakeCaseNameConverter $camelCaseToSnakeCaseNameConverter;
+ private array $defaults = [];
+ private LoggerInterface $logger;
+
+ private function addGlobalDefaults(ApiResource|Operation $operation): ApiResource|Operation
+ {
+ $extraProperties = [];
+ foreach ($this->defaults as $key => $value) {
+ $upperKey = ucfirst($this->camelCaseToSnakeCaseNameConverter->denormalize($key));
+ $getter = 'get'.$upperKey;
+
+ if (!method_exists($operation, $getter)) {
+ if (!isset($extraProperties[$key])) {
+ $extraProperties[$key] = $value;
+ }
+ } else {
+ $currentValue = $operation->{$getter}();
+
+ if (\is_array($currentValue) && $currentValue) {
+ $operation = $operation->{'with'.$upperKey}(array_merge($value, $currentValue));
+ }
+
+ if (null !== $currentValue) {
+ continue;
+ }
+
+ $operation = $operation->{'with'.$upperKey}($value);
+ }
+ }
+
+ return $operation->withExtraProperties(array_merge($extraProperties, $operation->getExtraProperties()));
+ }
+
+ private function getResourceWithDefaults(string $resourceClass, string $shortName, ApiResource $resource): ApiResource
+ {
+ $resource = $resource
+ ->withShortName($resource->getShortName() ?? $shortName)
+ ->withClass($resourceClass);
+
+ return $this->addGlobalDefaults($resource);
+ }
+
+ private function addDefaultGraphQlOperations(ApiResource $resource): ApiResource
+ {
+ $graphQlOperations = [];
+ foreach ([new QueryCollection(), new Query(), (new Mutation())->withName('update'), (new DeleteMutation())->withName('delete'), (new Mutation())->withName('create')] as $i => $operation) {
+ [$key, $operation] = $this->getOperationWithDefaults($resource, $operation);
+ $graphQlOperations[$key] = $operation;
+ }
+
+ if ($resource->getMercure()) {
+ [$key, $operation] = $this->getOperationWithDefaults($resource, (new Subscription())->withDescription("Subscribes to the update event of a {$operation->getShortName()}."));
+ $graphQlOperations[$key] = $operation;
+ }
+
+ return $resource->withGraphQlOperations($graphQlOperations);
+ }
+
+ private function getOperationWithDefaults(ApiResource $resource, Operation $operation, bool $generated = false): array
+ {
+ // Inherit from resource defaults
+ foreach (get_class_methods($resource) as $methodName) {
+ if (!str_starts_with($methodName, 'get')) {
+ continue;
+ }
+
+ if (!method_exists($operation, $methodName) || null !== $operation->{$methodName}()) {
+ continue;
+ }
+
+ if (null === ($value = $resource->{$methodName}())) {
+ continue;
+ }
+
+ $operation = $operation->{'with'.substr($methodName, 3)}($value);
+ }
+
+ $operation = $operation->withExtraProperties(array_merge(
+ $resource->getExtraProperties(),
+ $operation->getExtraProperties(),
+ $generated ? ['generated_operation' => true] : []
+ ));
+
+ // Add global defaults attributes to the operation
+ $operation = $this->addGlobalDefaults($operation);
+
+ if ($operation instanceof GraphQlOperation) {
+ if (!$operation->getName()) {
+ throw new RuntimeException('No GraphQL operation name.');
+ }
+
+ if ($operation instanceof Mutation) {
+ $operation = $operation->withDescription(ucfirst("{$operation->getName()}s a {$resource->getShortName()}."));
+ }
+
+ return [$operation->getName(), $operation];
+ }
+
+ if (!$operation instanceof HttpOperation) {
+ throw new RuntimeException(sprintf('Operation should be an instance of "%s"', HttpOperation::class));
+ }
+
+ if ($operation->getRouteName()) {
+ /** @var HttpOperation $operation */
+ $operation = $operation->withName($operation->getRouteName());
+ }
+
+ // Check for name conflict
+ if ($operation->getName()) {
+ if (null !== $resource->getOperations() && !$resource->getOperations()->has($operation->getName())) {
+ return [$operation->getName(), $operation];
+ }
+
+ $this->logger->warning(sprintf('The operation "%s" already exists on the resource "%s", pick a different name or leave it empty. In the meantime we will generate a unique name.', $operation->getName(), $resource->getClass()));
+ /** @var HttpOperation $operation */
+ $operation = $operation->withName('');
+ }
+
+ return [
+ sprintf(
+ '_api_%s_%s%s',
+ $operation->getUriTemplate() ?: $operation->getShortName(),
+ strtolower($operation->getMethod() ?? HttpOperation::METHOD_GET),
+ $operation instanceof CollectionOperationInterface ? '_collection' : '',
+ ),
+ $operation,
+ ];
+ }
+}
diff --git a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml
index 4cecc9d339a..29f9a7a761f 100644
--- a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml
+++ b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml
@@ -156,6 +156,11 @@
+
+
+
+
+
diff --git a/src/Symfony/Bundle/Resources/config/doctrine_orm.xml b/src/Symfony/Bundle/Resources/config/doctrine_orm.xml
index a46540ab0a9..0b6949bb9b8 100644
--- a/src/Symfony/Bundle/Resources/config/doctrine_orm.xml
+++ b/src/Symfony/Bundle/Resources/config/doctrine_orm.xml
@@ -166,6 +166,12 @@
+
+
+
+
+
+
diff --git a/src/Symfony/Bundle/Resources/config/graphql.xml b/src/Symfony/Bundle/Resources/config/graphql.xml
index b5a8e4e80e5..0f713b273b9 100644
--- a/src/Symfony/Bundle/Resources/config/graphql.xml
+++ b/src/Symfony/Bundle/Resources/config/graphql.xml
@@ -133,6 +133,7 @@
%api_platform.graphql.nesting_separator%
+
@@ -274,6 +275,14 @@
+
+
+ %api_platform.defaults%
+
+
+
+
+
diff --git a/tests/Behat/DoctrineContext.php b/tests/Behat/DoctrineContext.php
index 5e02353fcfb..37aaa67205b 100644
--- a/tests/Behat/DoctrineContext.php
+++ b/tests/Behat/DoctrineContext.php
@@ -286,10 +286,10 @@ public function thereArePaginationEntities(int $nb): void
public function thereAreOfTheseSoManyObjects(int $nb): void
{
for ($i = 1; $i <= $nb; ++$i) {
- $dummy = $this->isOrm() ? new SoMany() : new SoManyDocument();
- $dummy->content = 'Many #'.$i;
+ $soMany = $this->buildSoMany();
+ $soMany->content = 'Many #'.$i;
- $this->manager->persist($dummy);
+ $this->manager->persist($soMany);
}
$this->manager->flush();
@@ -340,6 +340,12 @@ public function thereAreFooDummyObjectsWithFakeNames($nb): void
$foo = $this->buildFooDummy();
$foo->setName($names[$i]);
$foo->setDummy($dummy);
+ for ($j = 0; $j < 3; ++$j) {
+ $soMany = $this->buildSoMany();
+ $soMany->content = "So many $j";
+ $soMany->fooDummy = $foo;
+ $foo->soManies->add($soMany);
+ }
$this->manager->persist($foo);
}
@@ -2200,6 +2206,11 @@ private function buildRelatedSecureDummy(): RelatedSecuredDummy|RelatedSecuredDu
return $this->isOrm() ? new RelatedSecuredDummy() : new RelatedSecuredDummyDocument();
}
+ private function buildSoMany(): SoMany|SoManyDocument
+ {
+ return $this->isOrm() ? new SoMany() : new SoManyDocument();
+ }
+
private function buildThirdLevel(): ThirdLevel|ThirdLevelDocument
{
return $this->isOrm() ? new ThirdLevel() : new ThirdLevelDocument();
diff --git a/tests/Fixtures/TestBundle/Document/FooDummy.php b/tests/Fixtures/TestBundle/Document/FooDummy.php
index cc9fe959f25..8736509e855 100644
--- a/tests/Fixtures/TestBundle/Document/FooDummy.php
+++ b/tests/Fixtures/TestBundle/Document/FooDummy.php
@@ -15,6 +15,8 @@
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GraphQl\QueryCollection;
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\Common\Collections\Collection;
use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
/**
@@ -42,6 +44,17 @@ class FooDummy
#[ODM\ReferenceOne(targetDocument: Dummy::class, cascade: ['persist'], storeAs: 'id')]
private ?Dummy $dummy = null;
+ /**
+ * @var Collection
+ */
+ #[ODM\ReferenceMany(targetDocument: SoMany::class, cascade: ['persist'], storeAs: 'id')]
+ public Collection $soManies;
+
+ public function __construct()
+ {
+ $this->soManies = new ArrayCollection();
+ }
+
public function getId(): ?int
{
return $this->id;
diff --git a/tests/Fixtures/TestBundle/Entity/FooDummy.php b/tests/Fixtures/TestBundle/Entity/FooDummy.php
index b744c0c2a55..92596dad682 100644
--- a/tests/Fixtures/TestBundle/Entity/FooDummy.php
+++ b/tests/Fixtures/TestBundle/Entity/FooDummy.php
@@ -15,6 +15,8 @@
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GraphQl\QueryCollection;
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
/**
@@ -44,6 +46,17 @@ class FooDummy
#[ORM\ManyToOne(targetEntity: Dummy::class, cascade: ['persist'])]
private ?Dummy $dummy = null;
+ /**
+ * @var Collection
+ */
+ #[ORM\OneToMany(targetEntity: SoMany::class, mappedBy: 'fooDummy', cascade: ['persist'])]
+ public Collection $soManies;
+
+ public function __construct()
+ {
+ $this->soManies = new ArrayCollection();
+ }
+
public function getId(): ?int
{
return $this->id;
diff --git a/tests/Fixtures/TestBundle/Entity/RelatedDummy.php b/tests/Fixtures/TestBundle/Entity/RelatedDummy.php
index d3f0126dccb..a6e60f79169 100644
--- a/tests/Fixtures/TestBundle/Entity/RelatedDummy.php
+++ b/tests/Fixtures/TestBundle/Entity/RelatedDummy.php
@@ -35,7 +35,15 @@
*
* @author Kévin Dunglas
*/
-#[ApiResource(graphQlOperations: [new Query(name: 'item_query'), new Mutation(name: 'update', normalizationContext: ['groups' => ['chicago', 'fakemanytomany']], denormalizationContext: ['groups' => ['friends']])], types: ['https://schema.org/Product'], normalizationContext: ['groups' => ['friends']], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'])]
+#[ApiResource(
+ graphQlOperations: [
+ new Query(name: 'item_query'),
+ new Mutation(name: 'update', normalizationContext: ['groups' => ['chicago', 'fakemanytomany']], denormalizationContext: ['groups' => ['friends']]),
+ ],
+ types: ['https://schema.org/Product'],
+ normalizationContext: ['groups' => ['friends']],
+ filters: ['related_dummy.friends', 'related_dummy.complex_sub_query']
+)]
#[ApiResource(uriTemplate: '/dummies/{id}/related_dummies{._format}', uriVariables: ['id' => new Link(fromClass: Dummy::class, identifiers: ['id'], fromProperty: 'relatedDummies')], status: 200, types: ['https://schema.org/Product'], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'], normalizationContext: ['groups' => ['friends']], operations: [new GetCollection()])]
#[ApiResource(uriTemplate: '/dummies/{id}/related_dummies/{relatedDummies}{._format}', uriVariables: ['id' => new Link(fromClass: Dummy::class, identifiers: ['id'], fromProperty: 'relatedDummies'), 'relatedDummies' => new Link(fromClass: self::class, identifiers: ['id'])], status: 200, types: ['https://schema.org/Product'], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'], normalizationContext: ['groups' => ['friends']], operations: [new Get()])]
#[ApiResource(uriTemplate: '/related_dummies/{id}/id{._format}', uriVariables: ['id' => new Link(fromClass: self::class, identifiers: ['id'])], status: 200, types: ['https://schema.org/Product'], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'], normalizationContext: ['groups' => ['friends']], operations: [new Get()])]
diff --git a/tests/Fixtures/TestBundle/Entity/SoMany.php b/tests/Fixtures/TestBundle/Entity/SoMany.php
index 65a2ad93790..e3770b8007d 100644
--- a/tests/Fixtures/TestBundle/Entity/SoMany.php
+++ b/tests/Fixtures/TestBundle/Entity/SoMany.php
@@ -31,4 +31,7 @@ class SoMany
public $id;
#[ORM\Column(nullable: true)]
public $content;
+
+ #[ORM\ManyToOne]
+ public ?FooDummy $fooDummy;
}
diff --git a/tests/GraphQl/Metadata/Factory/GraphQlNestedOperationResourceMetadataFactoryTest.php b/tests/GraphQl/Metadata/Factory/GraphQlNestedOperationResourceMetadataFactoryTest.php
new file mode 100644
index 00000000000..65c4b65c51f
--- /dev/null
+++ b/tests/GraphQl/Metadata/Factory/GraphQlNestedOperationResourceMetadataFactoryTest.php
@@ -0,0 +1,44 @@
+
+ *
+ * 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\GraphQl\Metadata\Factory;
+
+use ApiPlatform\GraphQl\Metadata\Factory\GraphQlNestedOperationResourceMetadataFactory;
+use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
+use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
+use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy;
+use PHPUnit\Framework\TestCase;
+use Prophecy\PhpUnit\ProphecyTrait;
+
+final class GraphQlNestedOperationResourceMetadataFactoryTest extends TestCase
+{
+ use ProphecyTrait;
+
+ public function testCreate(): void
+ {
+ $decorated = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class);
+ $decorated->create('someClass')->shouldBeCalled()->willReturn(new ResourceMetadataCollection('someClass'));
+
+ $metadataFactory = new GraphQlNestedOperationResourceMetadataFactory(['status' => 500], $decorated->reveal());
+ $apiResource = $metadataFactory->create('someClass')[0];
+ $this->assertCount(5, $apiResource->getGraphQlOperations());
+ }
+
+ public function testCreateWithResource(): void
+ {
+ $metadataFactory = new GraphQlNestedOperationResourceMetadataFactory(['status' => 500]);
+ $apiResource = $metadataFactory->create(RelatedDummy::class)[0];
+ $this->assertNotEmpty($apiResource->getFilters());
+ $this->assertEquals('RelatedDummy', $apiResource->getShortName());
+ }
+}
diff --git a/tests/GraphQl/Type/FieldsBuilderTest.php b/tests/GraphQl/Type/FieldsBuilderTest.php
index c7bb3ba897a..c5b59ad50b7 100644
--- a/tests/GraphQl/Type/FieldsBuilderTest.php
+++ b/tests/GraphQl/Type/FieldsBuilderTest.php
@@ -56,29 +56,18 @@ class FieldsBuilderTest extends TestCase
use ProphecyTrait;
private ObjectProphecy $propertyNameCollectionFactoryProphecy;
-
private ObjectProphecy $propertyMetadataFactoryProphecy;
-
private ObjectProphecy $resourceMetadataCollectionFactoryProphecy;
-
+ private ObjectProphecy $graphQlNestedOperationResourceMetadataFactoryProphecy;
private ObjectProphecy $typesContainerProphecy;
-
private ObjectProphecy $typeBuilderProphecy;
-
private ObjectProphecy $typeConverterProphecy;
-
private ObjectProphecy $itemResolverFactoryProphecy;
-
private ObjectProphecy $collectionResolverFactoryProphecy;
-
private ObjectProphecy $itemMutationResolverFactoryProphecy;
-
private ObjectProphecy $itemSubscriptionResolverFactoryProphecy;
-
private ObjectProphecy $filterLocatorProphecy;
-
private ObjectProphecy $resourceClassResolverProphecy;
-
private FieldsBuilder $fieldsBuilder;
/**
@@ -98,12 +87,13 @@ protected function setUp(): void
$this->itemSubscriptionResolverFactoryProphecy = $this->prophesize(ResolverFactoryInterface::class);
$this->filterLocatorProphecy = $this->prophesize(ContainerInterface::class);
$this->resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class);
+ $this->graphQlNestedOperationResourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class);
$this->fieldsBuilder = $this->buildFieldsBuilder();
}
private function buildFieldsBuilder(?AdvancedNameConverterInterface $advancedNameConverter = null): FieldsBuilder
{
- return new FieldsBuilder($this->propertyNameCollectionFactoryProphecy->reveal(), $this->propertyMetadataFactoryProphecy->reveal(), $this->resourceMetadataCollectionFactoryProphecy->reveal(), $this->resourceClassResolverProphecy->reveal(), $this->typesContainerProphecy->reveal(), $this->typeBuilderProphecy->reveal(), $this->typeConverterProphecy->reveal(), $this->itemResolverFactoryProphecy->reveal(), $this->collectionResolverFactoryProphecy->reveal(), $this->itemMutationResolverFactoryProphecy->reveal(), $this->itemSubscriptionResolverFactoryProphecy->reveal(), $this->filterLocatorProphecy->reveal(), new Pagination(), $advancedNameConverter ?? new CustomConverter(), '__');
+ return new FieldsBuilder($this->propertyNameCollectionFactoryProphecy->reveal(), $this->propertyMetadataFactoryProphecy->reveal(), $this->resourceMetadataCollectionFactoryProphecy->reveal(), $this->resourceClassResolverProphecy->reveal(), $this->typesContainerProphecy->reveal(), $this->typeBuilderProphecy->reveal(), $this->typeConverterProphecy->reveal(), $this->itemResolverFactoryProphecy->reveal(), $this->collectionResolverFactoryProphecy->reveal(), $this->itemMutationResolverFactoryProphecy->reveal(), $this->itemSubscriptionResolverFactoryProphecy->reveal(), $this->filterLocatorProphecy->reveal(), new Pagination(), $advancedNameConverter ?? new CustomConverter(), '__', $this->graphQlNestedOperationResourceMetadataFactoryProphecy->reveal());
}
public function testGetNodeQueryFields(): void
@@ -139,7 +129,6 @@ public function testGetItemQueryFields(string $resourceClass, Operation $operati
$this->typeConverterProphecy->convertType(Argument::type(Type::class), false, Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), $resourceClass, $resourceClass, null, 0)->willReturn($graphqlType);
$this->typeConverterProphecy->resolveType(Argument::type('string'))->willReturn(GraphQLType::string());
$this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn(false);
- $this->resourceMetadataCollectionFactoryProphecy->create($resourceClass)->willReturn(new ResourceMetadataCollection($resourceClass, [(new ApiResource())->withGraphQlOperations([$operation->getName() => $operation])]));
$this->itemResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, $operation)->willReturn($resolver);
$queryFields = $this->fieldsBuilder->getItemQueryFields($resourceClass, $operation, $configuration);
@@ -150,8 +139,8 @@ public function testGetItemQueryFields(string $resourceClass, Operation $operati
public function itemQueryFieldsProvider(): array
{
return [
- 'no resource field configuration' => ['resourceClass', (new Query())->withName('action'), [], null, null, []],
- 'nominal standard type case with deprecation reason and description' => ['resourceClass', (new Query())->withName('action')->withShortName('ShortName')->withDeprecationReason('not useful')->withDescription('Custom description.'), [], GraphQLType::string(), null,
+ 'no resource field configuration' => ['resourceClass', (new Query())->withClass('resourceClass')->withName('action'), [], null, null, []],
+ 'nominal standard type case with deprecation reason and description' => ['resourceClass', (new Query())->withClass('resourceClass')->withName('action')->withShortName('ShortName')->withDeprecationReason('not useful')->withDescription('Custom description.'), [], GraphQLType::string(), null,
[
'actionShortName' => [
'type' => GraphQLType::string(),
@@ -164,7 +153,7 @@ public function itemQueryFieldsProvider(): array
],
],
],
- 'nominal item case' => ['resourceClass', (new Query())->withName('action')->withShortName('ShortName'), [], $graphqlType = new ObjectType(['name' => 'item']), $resolver = function (): void {
+ 'nominal item case' => ['resourceClass', (new Query())->withClass('resourceClass')->withName('action')->withShortName('ShortName'), [], $graphqlType = new ObjectType(['name' => 'item']), $resolver = function (): void {
},
[
'actionShortName' => [
@@ -179,7 +168,7 @@ public function itemQueryFieldsProvider(): array
],
],
'empty overridden args and add fields' => [
- 'resourceClass', (new Query())->withShortName('ShortName'), ['args' => [], 'name' => 'customActionName'], GraphQLType::string(), null,
+ 'resourceClass', (new Query())->withClass('resourceClass')->withShortName('ShortName'), ['args' => [], 'name' => 'customActionName'], GraphQLType::string(), null,
[
'shortName' => [
'type' => GraphQLType::string(),
@@ -192,7 +181,7 @@ public function itemQueryFieldsProvider(): array
],
],
'override args with custom ones' => [
- 'resourceClass', (new Query())->withShortName('ShortName'), ['args' => ['customArg' => ['type' => 'a type']]], GraphQLType::string(), null,
+ 'resourceClass', (new Query())->withClass('resourceClass')->withShortName('ShortName'), ['args' => ['customArg' => ['type' => 'a type']]], GraphQLType::string(), null,
[
'shortName' => [
'type' => GraphQLType::string(),
@@ -220,7 +209,6 @@ public function testGetCollectionQueryFields(string $resourceClass, Operation $o
$this->typeConverterProphecy->resolveType(Argument::type('string'))->willReturn(GraphQLType::string());
$this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn(true);
$this->typeBuilderProphecy->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $operation)->willReturn($graphqlType);
- $this->resourceMetadataCollectionFactoryProphecy->create($resourceClass)->willReturn(new ResourceMetadataCollection($resourceClass, [(new ApiResource())->withGraphQlOperations([$operation->getName() => $operation])]));
$this->collectionResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, $operation)->willReturn($resolver);
$this->filterLocatorProphecy->has('my_filter')->willReturn(true);
$filterProphecy = $this->prophesize(FilterInterface::class);
@@ -244,8 +232,8 @@ public function testGetCollectionQueryFields(string $resourceClass, Operation $o
public function collectionQueryFieldsProvider(): array
{
return [
- 'no resource field configuration' => ['resourceClass', (new QueryCollection())->withName('action'), [], null, null, []],
- 'nominal collection case with deprecation reason and description' => ['resourceClass', (new QueryCollection())->withName('action')->withShortName('ShortName')->withDeprecationReason('not useful')->withDescription('Custom description.'), [], $graphqlType = GraphQLType::listOf(new ObjectType(['name' => 'collection'])), $resolver = function (): void {
+ 'no resource field configuration' => ['resourceClass', (new QueryCollection())->withClass('resourceClass')->withName('action'), [], null, null, []],
+ 'nominal collection case with deprecation reason and description' => ['resourceClass', (new QueryCollection())->withClass('resourceClass')->withName('action')->withShortName('ShortName')->withDeprecationReason('not useful')->withDescription('Custom description.'), [], $graphqlType = GraphQLType::listOf(new ObjectType(['name' => 'collection'])), $resolver = function (): void {
},
[
'actionShortNames' => [
@@ -274,7 +262,7 @@ public function collectionQueryFieldsProvider(): array
],
],
],
- 'collection with filters' => ['resourceClass', (new QueryCollection())->withName('action')->withShortName('ShortName')->withFilters(['my_filter']), [], $graphqlType = GraphQLType::listOf(new ObjectType(['name' => 'collection'])), $resolver = function (): void {
+ 'collection with filters' => ['resourceClass', (new QueryCollection())->withClass('resourceClass')->withName('action')->withShortName('ShortName')->withFilters(['my_filter']), [], $graphqlType = GraphQLType::listOf(new ObjectType(['name' => 'collection'])), $resolver = function (): void {
},
[
'actionShortNames' => [
@@ -308,7 +296,7 @@ public function collectionQueryFieldsProvider(): array
],
],
'collection empty overridden args and add fields' => [
- 'resourceClass', (new QueryCollection())->withArgs([])->withName('action')->withShortName('ShortName'), ['args' => [], 'name' => 'customActionName'], $graphqlType = GraphQLType::listOf(new ObjectType(['name' => 'collection'])), $resolver = function (): void {
+ 'resourceClass', (new QueryCollection())->withArgs([])->withClass('resourceClass')->withName('action')->withShortName('ShortName'), ['args' => [], 'name' => 'customActionName'], $graphqlType = GraphQLType::listOf(new ObjectType(['name' => 'collection'])), $resolver = function (): void {
},
[
'actionShortNames' => [
@@ -322,7 +310,7 @@ public function collectionQueryFieldsProvider(): array
],
],
'collection override args with custom ones' => [
- 'resourceClass', (new QueryCollection())->withName('action')->withShortName('ShortName'), ['args' => ['customArg' => ['type' => 'a type']]], $graphqlType = GraphQLType::listOf(new ObjectType(['name' => 'collection'])), $resolver = function (): void {
+ 'resourceClass', (new QueryCollection())->withClass('resourceClass')->withName('action')->withShortName('ShortName'), ['args' => ['customArg' => ['type' => 'a type']]], $graphqlType = GraphQLType::listOf(new ObjectType(['name' => 'collection'])), $resolver = function (): void {
},
[
'actionShortNames' => [
@@ -338,7 +326,7 @@ public function collectionQueryFieldsProvider(): array
],
],
],
- 'collection with page-based pagination enabled' => ['resourceClass', (new QueryCollection())->withName('action')->withShortName('ShortName')->withPaginationType('page')->withFilters(['my_filter']), [], $graphqlType = GraphQLType::listOf(new ObjectType(['name' => 'collection'])), $resolver = function (): void {
+ 'collection with page-based pagination enabled' => ['resourceClass', (new QueryCollection())->withClass('resourceClass')->withName('action')->withShortName('ShortName')->withPaginationType('page')->withFilters(['my_filter']), [], $graphqlType = GraphQLType::listOf(new ObjectType(['name' => 'collection'])), $resolver = function (): void {
},
[
'actionShortNames' => [
@@ -371,8 +359,6 @@ public function testGetMutationFields(string $resourceClass, Operation $operatio
$this->typeConverterProphecy->convertType(Argument::type(Type::class), false, Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), $resourceClass, $resourceClass, null, 0)->willReturn($graphqlType);
$this->typeConverterProphecy->convertType(Argument::type(Type::class), true, Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), $resourceClass, $resourceClass, null, 0)->willReturn($inputGraphqlType);
$this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn(false);
- $this->typeBuilderProphecy->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $operation->getName())->willReturn($graphqlType);
- $this->resourceMetadataCollectionFactoryProphecy->create($resourceClass)->willReturn(new ResourceMetadataCollection($resourceClass, [(new ApiResource())->withGraphQlOperations([$operation->getName() => $operation])]));
$this->itemMutationResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, $operation)->willReturn($mutationResolver);
$mutationFields = $this->fieldsBuilder->getMutationFields($resourceClass, $operation);
@@ -383,7 +369,7 @@ public function testGetMutationFields(string $resourceClass, Operation $operatio
public function mutationFieldsProvider(): array
{
return [
- 'nominal case with deprecation reason' => ['resourceClass', (new Mutation())->withName('action')->withShortName('ShortName')->withDeprecationReason('not useful'), $graphqlType = new ObjectType(['name' => 'mutation']), $inputGraphqlType = new ObjectType(['name' => 'input']), $mutationResolver = function (): void {
+ 'nominal case with deprecation reason' => ['resourceClass', (new Mutation())->withClass('resourceClass')->withName('action')->withShortName('ShortName')->withDeprecationReason('not useful'), $graphqlType = new ObjectType(['name' => 'mutation']), $inputGraphqlType = new ObjectType(['name' => 'input']), $mutationResolver = function (): void {
},
[
'actionShortName' => [
@@ -403,7 +389,7 @@ public function mutationFieldsProvider(): array
],
],
],
- 'custom description' => ['resourceClass', (new Mutation())->withName('action')->withShortName('ShortName')->withDescription('Custom description.'), $graphqlType = new ObjectType(['name' => 'mutation']), $inputGraphqlType = new ObjectType(['name' => 'input']), $mutationResolver = function (): void {
+ 'custom description' => ['resourceClass', (new Mutation())->withClass('resourceClass')->withName('action')->withShortName('ShortName')->withDescription('Custom description.'), $graphqlType = new ObjectType(['name' => 'mutation']), $inputGraphqlType = new ObjectType(['name' => 'input']), $mutationResolver = function (): void {
},
[
'actionShortName' => [
@@ -435,7 +421,6 @@ public function testGetSubscriptionFields(string $resourceClass, Operation $oper
$this->typeConverterProphecy->convertType(Argument::type(Type::class), false, Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), $resourceClass, $resourceClass, null, 0)->willReturn($graphqlType);
$this->typeConverterProphecy->convertType(Argument::type(Type::class), true, Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), $resourceClass, $resourceClass, null, 0)->willReturn($inputGraphqlType);
$this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn(false);
- $this->typeBuilderProphecy->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $operation->getName())->willReturn($graphqlType);
$this->resourceMetadataCollectionFactoryProphecy->create($resourceClass)->willReturn(new ResourceMetadataCollection($resourceClass, [(new ApiResource())->withGraphQlOperations([$operation->getName() => $operation])]));
$this->itemSubscriptionResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, $operation)->willReturn($subscriptionResolver);
@@ -447,9 +432,9 @@ public function testGetSubscriptionFields(string $resourceClass, Operation $oper
public function subscriptionFieldsProvider(): array
{
return [
- 'mercure not enabled' => ['resourceClass', (new Subscription())->withName('action')->withShortName('ShortName'), new ObjectType(['name' => 'subscription']), new ObjectType(['name' => 'input']), null, [],
+ 'mercure not enabled' => ['resourceClass', (new Subscription())->withClass('resourceClass')->withName('action')->withShortName('ShortName'), new ObjectType(['name' => 'subscription']), new ObjectType(['name' => 'input']), null, [],
],
- 'nominal case with deprecation reason' => ['resourceClass', (new Subscription())->withName('action')->withShortName('ShortName')->withMercure(true)->withDeprecationReason('not useful'), $graphqlType = new ObjectType(['name' => 'subscription']), $inputGraphqlType = new ObjectType(['name' => 'input']), $subscriptionResolver = function (): void {
+ 'nominal case with deprecation reason' => ['resourceClass', (new Subscription())->withClass('resourceClass')->withName('action')->withShortName('ShortName')->withMercure(true)->withDeprecationReason('not useful'), $graphqlType = new ObjectType(['name' => 'subscription']), $inputGraphqlType = new ObjectType(['name' => 'input']), $subscriptionResolver = function (): void {
},
[
'actionShortNameSubscribe' => [
@@ -469,7 +454,7 @@ public function subscriptionFieldsProvider(): array
],
],
],
- 'custom description' => ['resourceClass', (new Subscription())->withName('action')->withShortName('ShortName')->withMercure(true)->withDescription('Custom description.'), $graphqlType = new ObjectType(['name' => 'subscription']), $inputGraphqlType = new ObjectType(['name' => 'input']), $subscriptionResolver = function (): void {
+ 'custom description' => ['resourceClass', (new Subscription())->withClass('resourceClass')->withName('action')->withShortName('ShortName')->withMercure(true)->withDescription('Custom description.'), $graphqlType = new ObjectType(['name' => 'subscription']), $inputGraphqlType = new ObjectType(['name' => 'input']), $subscriptionResolver = function (): void {
},
[
'actionShortNameSubscribe' => [
@@ -498,6 +483,8 @@ public function subscriptionFieldsProvider(): array
public function testGetResourceObjectTypeFields(string $resourceClass, Operation $operation, array $properties, bool $input, int $depth, ?array $ioMetadata, array $expectedResourceObjectTypeFields, ?AdvancedNameConverterInterface $advancedNameConverter = null): void
{
$this->resourceClassResolverProphecy->isResourceClass($resourceClass)->willReturn(true);
+ $this->resourceClassResolverProphecy->isResourceClass('nestedResourceClass')->willReturn(true);
+ $this->resourceClassResolverProphecy->isResourceClass('nestedResourceNoQueryClass')->willReturn(true);
$this->resourceClassResolverProphecy->isResourceClass(Argument::type('string'))->willReturn(false);
$this->propertyNameCollectionFactoryProphecy->create($resourceClass)->willReturn(new PropertyNameCollection(array_keys($properties)));
foreach ($properties as $propertyName => $propertyMetadata) {
@@ -505,20 +492,32 @@ public function testGetResourceObjectTypeFields(string $resourceClass, Operation
$this->typeConverterProphecy->convertType(new Type(Type::BUILTIN_TYPE_NULL), Argument::type('bool'), Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), '', $resourceClass, $propertyName, $depth + 1)->willReturn(null);
$this->typeConverterProphecy->convertType(new Type(Type::BUILTIN_TYPE_CALLABLE), Argument::type('bool'), Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), '', $resourceClass, $propertyName, $depth + 1)->willReturn('NotRegisteredType');
$this->typeConverterProphecy->convertType(Argument::type(Type::class), Argument::type('bool'), Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), '', $resourceClass, $propertyName, $depth + 1)->willReturn(GraphQLType::string());
+ $this->typeConverterProphecy->convertType(new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_STRING)), Argument::type('bool'), Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), '', $resourceClass, $propertyName, $depth + 1)->willReturn(GraphQLType::nonNull(GraphQLType::listOf(GraphQLType::nonNull(GraphQLType::string()))));
+
if ('propertyObject' === $propertyName) {
$this->typeConverterProphecy->convertType(Argument::type(Type::class), Argument::type('bool'), Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), 'objectClass', $resourceClass, $propertyName, $depth + 1)->willReturn(new ObjectType(['name' => 'objectType']));
- $this->resourceMetadataCollectionFactoryProphecy->create('objectClass')->willReturn(new ResourceMetadataCollection($resourceClass, [(new ApiResource())->withGraphQlOperations(['item_query' => new Query()])]));
$this->itemResolverFactoryProphecy->__invoke('objectClass', $resourceClass, $operation)->willReturn(static function (): void {
});
}
- $this->typeConverterProphecy->convertType(Argument::type(Type::class), true, Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), 'anotherResourceClass', $propertyName, $depth + 1)->willReturn(GraphQLType::string());
- $this->typeConverterProphecy->convertType(new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_STRING)), Argument::type('bool'), Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), '', $resourceClass, $propertyName, $depth + 1)->willReturn(GraphQLType::nonNull(GraphQLType::listOf(GraphQLType::nonNull(GraphQLType::string()))));
+ if ('propertyNestedResource' === $propertyName) {
+ $nestedResourceQueryOperation = new Query();
+ $this->resourceMetadataCollectionFactoryProphecy->create('nestedResourceClass')->willReturn(new ResourceMetadataCollection('nestedResourceClass', [(new ApiResource())->withGraphQlOperations(['item_query' => $nestedResourceQueryOperation])]));
+ $this->typeConverterProphecy->convertType(Argument::type(Type::class), Argument::type('bool'), Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), 'nestedResourceClass', $resourceClass, $propertyName, $depth + 1)->willReturn(new ObjectType(['name' => 'objectType']));
+ $this->itemResolverFactoryProphecy->__invoke('nestedResourceClass', $resourceClass, $nestedResourceQueryOperation)->willReturn(static function (): void {
+ });
+ }
+ if ('propertyNestedResourceNoQuery' === $propertyName) {
+ $nestedResourceQueryOperation = new Query();
+ $this->resourceMetadataCollectionFactoryProphecy->create('nestedResourceNoQueryClass')->willReturn(new ResourceMetadataCollection('nestedResourceNoQueryClass', [(new ApiResource())->withDescription('A description.')->withGraphQlOperations([])]));
+ $this->graphQlNestedOperationResourceMetadataFactoryProphecy->create('nestedResourceNoQueryClass')->shouldBeCalled()->willReturn(new ResourceMetadataCollection('nestedResourceNoQueryClass', [(new ApiResource())->withGraphQlOperations(['item_query' => $nestedResourceQueryOperation])]));
+ $this->typeConverterProphecy->convertType(Argument::type(Type::class), Argument::type('bool'), Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), 'nestedResourceNoQueryClass', $resourceClass, $propertyName, $depth + 1)->willReturn(new ObjectType(['name' => 'objectType']));
+ $this->itemResolverFactoryProphecy->__invoke('nestedResourceNoQueryClass', $resourceClass, $nestedResourceQueryOperation)->willReturn(static function (): void {
+ });
+ }
}
$this->typesContainerProphecy->has('NotRegisteredType')->willReturn(false);
$this->typesContainerProphecy->all()->willReturn([]);
$this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn(false);
- $this->resourceMetadataCollectionFactoryProphecy->create('resourceClass')->willReturn(new ResourceMetadataCollection($resourceClass, [(new ApiResource())->withGraphQlOperations([$operation->getName() => $operation])]));
- $this->resourceMetadataCollectionFactoryProphecy->create('anotherResourceClass')->willReturn(new ResourceMetadataCollection($resourceClass, [(new ApiResource())->withGraphQlOperations(['item_query' => new Query()])]));
$fieldsBuilder = $this->fieldsBuilder;
if ($advancedNameConverter) {
@@ -535,7 +534,7 @@ public function resourceObjectTypeFieldsProvider(): array
$advancedNameConverter->normalize('field', 'resourceClass')->willReturn('normalizedField');
return [
- 'query' => ['resourceClass', new Query(),
+ 'query' => ['resourceClass', (new Query())->withClass('resourceClass'),
[
'property' => new ApiProperty(),
'propertyBool' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL)])->withReadable(true)->withWritable(false),
@@ -563,7 +562,7 @@ public function resourceObjectTypeFieldsProvider(): array
],
],
],
- 'query with advanced name converter' => ['resourceClass', new Query(),
+ 'query with advanced name converter' => ['resourceClass', (new Query())->withClass('resourceClass'),
[
'field' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withReadable(true)->withWritable(false),
],
@@ -582,7 +581,7 @@ public function resourceObjectTypeFieldsProvider(): array
],
$advancedNameConverter->reveal(),
],
- 'query input' => ['resourceClass', new Query(),
+ 'query input' => ['resourceClass', (new Query())->withClass('resourceClass'),
[
'property' => new ApiProperty(),
'propertyBool' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL)])->withReadable(false)->withWritable(false),
@@ -601,7 +600,7 @@ public function resourceObjectTypeFieldsProvider(): array
],
],
],
- 'query with simple non-null string array property' => ['resourceClass', new Query(),
+ 'query with simple non-null string array property' => ['resourceClass', (new Query())->withClass('resourceClass'),
[
'property' => (new ApiProperty())->withBuiltinTypes([
new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_STRING)),
@@ -621,12 +620,40 @@ public function resourceObjectTypeFieldsProvider(): array
],
],
],
- 'mutation non input' => ['resourceClass', (new Mutation())->withName('mutation'),
+ 'query with nested resources' => ['resourceClass', (new Query())->withClass('resourceClass'),
+ [
+ 'propertyNestedResource' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, false, 'nestedResourceClass')])->withReadable(true)->withWritable(true),
+ 'propertyNestedResourceNoQuery' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, false, 'nestedResourceNoQueryClass')])->withReadable(true)->withWritable(true),
+ ],
+ false, 0, null,
+ [
+ 'id' => [
+ 'type' => GraphQLType::nonNull(GraphQLType::id()),
+ ],
+ 'propertyNestedResource' => [
+ 'type' => GraphQLType::nonNull(new ObjectType(['name' => 'objectType'])),
+ 'description' => null,
+ 'args' => [],
+ 'resolve' => static function (): void {
+ },
+ 'deprecationReason' => null,
+ ],
+ 'propertyNestedResourceNoQuery' => [
+ 'type' => GraphQLType::nonNull(new ObjectType(['name' => 'objectType'])),
+ 'description' => null,
+ 'args' => [],
+ 'resolve' => static function (): void {
+ },
+ 'deprecationReason' => null,
+ ],
+ ],
+ ],
+ 'mutation non input' => ['resourceClass', (new Mutation())->withClass('resourceClass')->withName('mutation'),
[
'property' => new ApiProperty(),
'propertyBool' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL)])->withReadable(false)->withWritable(true),
'propertyReadable' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL)])->withReadable(true)->withWritable(true),
- 'propertyObject' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL, false, 'objectClass')])->withReadable(true)->withWritable(true),
+ 'propertyObject' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, false, 'objectClass')])->withReadable(true)->withWritable(true),
],
false, 0, null,
[
@@ -650,7 +677,7 @@ public function resourceObjectTypeFieldsProvider(): array
],
],
],
- 'mutation input' => ['resourceClass', (new Mutation())->withName('mutation'),
+ 'mutation input' => ['resourceClass', (new Mutation())->withClass('resourceClass')->withName('mutation'),
[
'property' => new ApiProperty(),
'propertyBool' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL)])->withDescription('propertyBool description')->withReadable(false)->withWritable(true)->withDeprecationReason('not useful'),
@@ -686,7 +713,7 @@ public function resourceObjectTypeFieldsProvider(): array
'clientMutationId' => GraphQLType::string(),
],
],
- 'mutation nested input' => ['resourceClass', (new Mutation())->withName('mutation'),
+ 'mutation nested input' => ['resourceClass', (new Mutation())->withClass('resourceClass')->withName('mutation'),
[
'propertyBool' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL)])->withReadable(false)->withWritable(true),
],
@@ -705,7 +732,7 @@ public function resourceObjectTypeFieldsProvider(): array
'clientMutationId' => GraphQLType::string(),
],
],
- 'delete mutation input' => ['resourceClass', (new Mutation())->withName('delete'),
+ 'delete mutation input' => ['resourceClass', (new Mutation())->withClass('resourceClass')->withName('delete'),
[
'propertyBool' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL)])->withReadable(false)->withWritable(true),
],
@@ -717,7 +744,7 @@ public function resourceObjectTypeFieldsProvider(): array
'clientMutationId' => GraphQLType::string(),
],
],
- 'create mutation input' => ['resourceClass', (new Mutation())->withName('create'),
+ 'create mutation input' => ['resourceClass', (new Mutation())->withClass('resourceClass')->withName('create'),
[
'propertyBool' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL)])->withReadable(false)->withWritable(true),
],
@@ -733,7 +760,7 @@ public function resourceObjectTypeFieldsProvider(): array
'clientMutationId' => GraphQLType::string(),
],
],
- 'update mutation input' => ['resourceClass', (new Mutation())->withName('update'),
+ 'update mutation input' => ['resourceClass', (new Mutation())->withClass('resourceClass')->withName('update'),
[
'propertyBool' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL)])->withReadable(false)->withWritable(true),
],
@@ -752,7 +779,7 @@ public function resourceObjectTypeFieldsProvider(): array
'clientMutationId' => GraphQLType::string(),
],
],
- 'subscription non input' => ['resourceClass', new Subscription(),
+ 'subscription non input' => ['resourceClass', (new Subscription())->withClass('resourceClass'),
[
'property' => new ApiProperty(),
'propertyBool' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL)])->withReadable(false)->withWritable(true),
@@ -772,7 +799,7 @@ public function resourceObjectTypeFieldsProvider(): array
],
],
],
- 'subscription input' => ['resourceClass', new Subscription(),
+ 'subscription input' => ['resourceClass', (new Subscription())->withClass('resourceClass'),
[
'property' => new ApiProperty(),
'propertyBool' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL)])->withDescription('propertyBool description')->withReadable(false)->withWritable(true)->withDeprecationReason('not useful'),
@@ -787,13 +814,13 @@ public function resourceObjectTypeFieldsProvider(): array
'clientSubscriptionId' => GraphQLType::string(),
],
],
- 'null io metadata non input' => ['resourceClass', new Query(),
+ 'null io metadata non input' => ['resourceClass', (new Query())->withClass('resourceClass'),
[
'propertyBool' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL)])->withReadable(false)->withWritable(true),
],
false, 0, ['class' => null], [],
],
- 'null io metadata input' => ['resourceClass', new Query(),
+ 'null io metadata input' => ['resourceClass', (new Query())->withClass('resourceClass'),
[
'propertyBool' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL)])->withReadable(false)->withWritable(true),
],
@@ -802,7 +829,7 @@ public function resourceObjectTypeFieldsProvider(): array
'clientMutationId' => GraphQLType::string(),
],
],
- 'invalid types' => ['resourceClass', new Query(),
+ 'invalid types' => ['resourceClass', (new Query())->withClass('resourceClass'),
[
'propertyInvalidType' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_NULL)])->withReadable(true)->withWritable(false),
'propertyNotRegisteredType' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_CALLABLE)])->withReadable(true)->withWritable(false),
diff --git a/tests/GraphQl/Type/TypeBuilderTest.php b/tests/GraphQl/Type/TypeBuilderTest.php
index 66b0e7cd855..77ba4f7e842 100644
--- a/tests/GraphQl/Type/TypeBuilderTest.php
+++ b/tests/GraphQl/Type/TypeBuilderTest.php
@@ -483,7 +483,7 @@ public function testCursorBasedGetResourcePaginatedCollectionType(): void
$this->typesContainerProphecy->set('StringPageInfo', Argument::type(ObjectType::class))->shouldBeCalled();
/** @var ObjectType $resourcePaginatedCollectionType */
- $resourcePaginatedCollectionType = $this->typeBuilder->getResourcePaginatedCollectionType(GraphQLType::string(), 'StringResourceClass', $operation);
+ $resourcePaginatedCollectionType = $this->typeBuilder->getResourcePaginatedCollectionType(GraphQLType::string(), 'test', $operation);
$this->assertSame('StringCursorConnection', $resourcePaginatedCollectionType->name);
$this->assertSame('Cursor connection for String.', $resourcePaginatedCollectionType->description);
@@ -538,7 +538,7 @@ public function testPageBasedGetResourcePaginatedCollectionType(): void
$this->typesContainerProphecy->set('StringPaginationInfo', Argument::type(ObjectType::class))->shouldBeCalled();
/** @var ObjectType $resourcePaginatedCollectionType */
- $resourcePaginatedCollectionType = $this->typeBuilder->getResourcePaginatedCollectionType(GraphQLType::string(), 'StringResourceClass', $operation);
+ $resourcePaginatedCollectionType = $this->typeBuilder->getResourcePaginatedCollectionType(GraphQLType::string(), 'test', $operation);
$this->assertSame('StringPageConnection', $resourcePaginatedCollectionType->name);
$this->assertSame('Page connection for String.', $resourcePaginatedCollectionType->description);
diff --git a/tests/Metadata/Extractor/ResourceMetadataCompatibilityTest.php b/tests/Metadata/Extractor/ResourceMetadataCompatibilityTest.php
index 1cd97c28814..3c59cc9c12c 100644
--- a/tests/Metadata/Extractor/ResourceMetadataCompatibilityTest.php
+++ b/tests/Metadata/Extractor/ResourceMetadataCompatibilityTest.php
@@ -20,10 +20,13 @@
use ApiPlatform\Metadata\Extractor\YamlResourceExtractor;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\GraphQl\DeleteMutation;
use ApiPlatform\Metadata\GraphQl\Mutation;
use ApiPlatform\Metadata\GraphQl\Query;
+use ApiPlatform\Metadata\GraphQl\QueryCollection;
use ApiPlatform\Metadata\GraphQl\Subscription;
use ApiPlatform\Metadata\HttpOperation;
+use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Operations;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
@@ -453,7 +456,16 @@ private function buildApiResources(): array
$operations[$operationName] = $this->getOperationWithDefaults($resource, $operation)->withName($operationName);
}
- $resources[] = $resource->withOperations(new Operations($operations));
+ $resource = $resource->withOperations(new Operations($operations));
+
+ // Build default GraphQL operations
+ $graphQlOperations = [];
+ foreach ([new QueryCollection(), new Query(), (new Mutation())->withName('update'), (new DeleteMutation())->withName('delete'), (new Mutation())->withName('create')] as $graphQlOperation) {
+ $description = $graphQlOperation instanceof Mutation ? ucfirst("{$graphQlOperation->getName()}s a {$resource->getShortName()}.") : null;
+ $graphQlOperations[$graphQlOperation->getName()] = $this->getOperationWithDefaults($resource, $graphQlOperation)->withName($graphQlOperation->getName())->withDescription($description);
+ }
+
+ $resources[] = $resource->withGraphQlOperations($graphQlOperations);
continue;
}
@@ -591,7 +603,7 @@ private function withGraphQlOperations(array $values, ?array $fixtures): array
return $operations;
}
- private function getOperationWithDefaults(ApiResource $resource, HttpOperation $operation): HttpOperation
+ private function getOperationWithDefaults(ApiResource $resource, Operation $operation): Operation
{
foreach (get_class_methods($resource) as $methodName) {
if (!str_starts_with($methodName, 'get')) {
diff --git a/tests/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php
index 006ad7e788d..6b4ede1efd7 100644
--- a/tests/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php
+++ b/tests/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php
@@ -671,6 +671,8 @@ public function testGraphQlConfiguration(): void
'api_platform.graphql.normalizer.validation_exception',
'api_platform.graphql.normalizer.http_exception',
'api_platform.graphql.normalizer.runtime_exception',
+ 'api_platform.graphql_metadata.resource.metadata_collection_factory',
+ 'api_platform.graphql_metadata.resource.metadata_collection_factory.filters'
];
$aliases = [