From fcf8ea03a25b11988f70ae68a38bf0617e8498ad Mon Sep 17 00:00:00 2001 From: Kai Dederichs Date: Wed, 11 Jan 2023 23:31:27 +0100 Subject: [PATCH 1/5] chore(deprecation): Only use ValueResolverInterface if it exists --- .../PayloadArgumentResolver.php | 132 +++++++++++++----- 1 file changed, 95 insertions(+), 37 deletions(-) diff --git a/src/Symfony/Bundle/ArgumentResolver/PayloadArgumentResolver.php b/src/Symfony/Bundle/ArgumentResolver/PayloadArgumentResolver.php index 3c4d538e8f9..57f5483266b 100644 --- a/src/Symfony/Bundle/ArgumentResolver/PayloadArgumentResolver.php +++ b/src/Symfony/Bundle/ArgumentResolver/PayloadArgumentResolver.php @@ -18,63 +18,121 @@ use ApiPlatform\Util\OperationRequestInitiatorTrait; use ApiPlatform\Util\RequestAttributesExtractor; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface; use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; -final class PayloadArgumentResolver implements ValueResolverInterface -{ - use OperationRequestInitiatorTrait; - - public function __construct( - ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, - private readonly SerializerContextBuilderInterface $serializationContextBuilder - ) { - $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; - } - - public function supports(Request $request, ArgumentMetadata $argument): bool +if(class_exists(ValueResolverInterface::class)) { + final class PayloadArgumentResolver implements ValueResolverInterface { - if ($argument->isVariadic()) { - return false; + use OperationRequestInitiatorTrait; + + public function __construct( + ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, + private readonly SerializerContextBuilderInterface $serializationContextBuilder + ) { + $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; } - $class = $argument->getType(); + public function supports(Request $request, ArgumentMetadata $argument): bool + { + if ($argument->isVariadic()) { + return false; + } - if (null === $class) { - return false; - } + $class = $argument->getType(); + + if (null === $class) { + return false; + } + + if (null === $request->attributes->get('data')) { + return false; + } - if (null === $request->attributes->get('data')) { - return false; + $inputClass = $this->getExpectedInputClass($request); + + if (null === $inputClass) { + return false; + } + + return $inputClass === $class || is_subclass_of($inputClass, $class); } - $inputClass = $this->getExpectedInputClass($request); + public function resolve(Request $request, ArgumentMetadata $argument): \Generator + { + if (!$this->supports($request, $argument)) { + yield []; + } - if (null === $inputClass) { - return false; + yield $request->attributes->get('data'); } - return $inputClass === $class || is_subclass_of($inputClass, $class); - } + private function getExpectedInputClass(Request $request): ?string + { + $operation = $this->initializeOperation($request); + if (Request::METHOD_DELETE === $request->getMethod() || $request->isMethodSafe() || !($operation?->canDeserialize() ?? true)) { + return null; + } - public function resolve(Request $request, ArgumentMetadata $argument): \Generator + $context = $this->serializationContextBuilder->createFromRequest($request, false, RequestAttributesExtractor::extractAttributes($request)); + + return $context['input']['class'] ?? $context['resource_class'] ?? null; + } + } +} else { + final class PayloadArgumentResolver implements ArgumentValueResolverInterface { - if (!$this->supports($request, $argument)) { - yield []; + use OperationRequestInitiatorTrait; + + public function __construct( + ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, + private readonly SerializerContextBuilderInterface $serializationContextBuilder + ) { + $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; } - yield $request->attributes->get('data'); - } + public function supports(Request $request, ArgumentMetadata $argument): bool + { + if ($argument->isVariadic()) { + return false; + } - private function getExpectedInputClass(Request $request): ?string - { - $operation = $this->initializeOperation($request); - if (Request::METHOD_DELETE === $request->getMethod() || $request->isMethodSafe() || !($operation?->canDeserialize() ?? true)) { - return null; + $class = $argument->getType(); + + if (null === $class) { + return false; + } + + if (null === $request->attributes->get('data')) { + return false; + } + + $inputClass = $this->getExpectedInputClass($request); + + if (null === $inputClass) { + return false; + } + + return $inputClass === $class || is_subclass_of($inputClass, $class); + } + + public function resolve(Request $request, ArgumentMetadata $argument): \Generator + { + yield $request->attributes->get('data'); } - $context = $this->serializationContextBuilder->createFromRequest($request, false, RequestAttributesExtractor::extractAttributes($request)); + private function getExpectedInputClass(Request $request): ?string + { + $operation = $this->initializeOperation($request); + if (Request::METHOD_DELETE === $request->getMethod() || $request->isMethodSafe() || !($operation?->canDeserialize() ?? true)) { + return null; + } - return $context['input']['class'] ?? $context['resource_class'] ?? null; + $context = $this->serializationContextBuilder->createFromRequest($request, false, RequestAttributesExtractor::extractAttributes($request)); + + return $context['input']['class'] ?? $context['resource_class'] ?? null; + } } } + From 02184896d6729fe49c59ffa2d04a1aac5da993ea Mon Sep 17 00:00:00 2001 From: Kai Dederichs Date: Wed, 11 Jan 2023 23:32:26 +0100 Subject: [PATCH 2/5] chore(deprecation): fix CS --- .../Bundle/ArgumentResolver/PayloadArgumentResolver.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Symfony/Bundle/ArgumentResolver/PayloadArgumentResolver.php b/src/Symfony/Bundle/ArgumentResolver/PayloadArgumentResolver.php index 57f5483266b..8a170ddb23b 100644 --- a/src/Symfony/Bundle/ArgumentResolver/PayloadArgumentResolver.php +++ b/src/Symfony/Bundle/ArgumentResolver/PayloadArgumentResolver.php @@ -22,7 +22,7 @@ use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; -if(class_exists(ValueResolverInterface::class)) { +if (class_exists(ValueResolverInterface::class)) { final class PayloadArgumentResolver implements ValueResolverInterface { use OperationRequestInitiatorTrait; @@ -135,4 +135,3 @@ private function getExpectedInputClass(Request $request): ?string } } } - From b94c87c860b3a2a40fb733fe862cc14ffed7a6c6 Mon Sep 17 00:00:00 2001 From: Kai Dederichs Date: Thu, 12 Jan 2023 10:16:48 +0100 Subject: [PATCH 3/5] chore(deprecation): Add new CompatibleValueResolverInterface --- .../CompatibleValueResolverInterface.php | 29 ++++ .../PayloadArgumentResolver.php | 131 +++++------------- 2 files changed, 67 insertions(+), 93 deletions(-) create mode 100644 src/Symfony/Bundle/ArgumentResolver/CompatibleValueResolverInterface.php diff --git a/src/Symfony/Bundle/ArgumentResolver/CompatibleValueResolverInterface.php b/src/Symfony/Bundle/ArgumentResolver/CompatibleValueResolverInterface.php new file mode 100644 index 00000000000..34242eb8f79 --- /dev/null +++ b/src/Symfony/Bundle/ArgumentResolver/CompatibleValueResolverInterface.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\Bundle\ArgumentResolver; + +use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface; +use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; + +if (interface_exists(ValueResolverInterface::class)) { + /** @internal */ + interface CompatibleValueResolverInterface extends ValueResolverInterface + { + } +} else { + /** @internal */ + interface CompatibleValueResolverInterface extends ArgumentValueResolverInterface + { + } +} diff --git a/src/Symfony/Bundle/ArgumentResolver/PayloadArgumentResolver.php b/src/Symfony/Bundle/ArgumentResolver/PayloadArgumentResolver.php index 8a170ddb23b..3c66690951d 100644 --- a/src/Symfony/Bundle/ArgumentResolver/PayloadArgumentResolver.php +++ b/src/Symfony/Bundle/ArgumentResolver/PayloadArgumentResolver.php @@ -18,120 +18,65 @@ use ApiPlatform\Util\OperationRequestInitiatorTrait; use ApiPlatform\Util\RequestAttributesExtractor; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface; use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; -if (class_exists(ValueResolverInterface::class)) { - final class PayloadArgumentResolver implements ValueResolverInterface - { - use OperationRequestInitiatorTrait; - - public function __construct( - ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, - private readonly SerializerContextBuilderInterface $serializationContextBuilder - ) { - $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; - } +final class PayloadArgumentResolver implements CompatibleValueResolverInterface +{ + use OperationRequestInitiatorTrait; - public function supports(Request $request, ArgumentMetadata $argument): bool - { - if ($argument->isVariadic()) { - return false; - } + public function __construct( + ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, + private readonly SerializerContextBuilderInterface $serializationContextBuilder + ) { + $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; + } - $class = $argument->getType(); + public function supports(Request $request, ArgumentMetadata $argument): bool + { + if ($argument->isVariadic()) { + return false; + } - if (null === $class) { - return false; - } + $class = $argument->getType(); - if (null === $request->attributes->get('data')) { - return false; - } + if (null === $class) { + return false; + } - $inputClass = $this->getExpectedInputClass($request); + if (null === $request->attributes->get('data')) { + return false; + } - if (null === $inputClass) { - return false; - } + $inputClass = $this->getExpectedInputClass($request); - return $inputClass === $class || is_subclass_of($inputClass, $class); + if (null === $inputClass) { + return false; } - public function resolve(Request $request, ArgumentMetadata $argument): \Generator - { + return $inputClass === $class || is_subclass_of($inputClass, $class); + } + + public function resolve(Request $request, ArgumentMetadata $argument): \Generator + { + if (interface_exists(ValueResolverInterface::class)) { if (!$this->supports($request, $argument)) { yield []; } - - yield $request->attributes->get('data'); } - private function getExpectedInputClass(Request $request): ?string - { - $operation = $this->initializeOperation($request); - if (Request::METHOD_DELETE === $request->getMethod() || $request->isMethodSafe() || !($operation?->canDeserialize() ?? true)) { - return null; - } - - $context = $this->serializationContextBuilder->createFromRequest($request, false, RequestAttributesExtractor::extractAttributes($request)); - - return $context['input']['class'] ?? $context['resource_class'] ?? null; - } + yield $request->attributes->get('data'); } -} else { - final class PayloadArgumentResolver implements ArgumentValueResolverInterface - { - use OperationRequestInitiatorTrait; - - public function __construct( - ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, - private readonly SerializerContextBuilderInterface $serializationContextBuilder - ) { - $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; - } - - public function supports(Request $request, ArgumentMetadata $argument): bool - { - if ($argument->isVariadic()) { - return false; - } - - $class = $argument->getType(); - - if (null === $class) { - return false; - } - - if (null === $request->attributes->get('data')) { - return false; - } - $inputClass = $this->getExpectedInputClass($request); - - if (null === $inputClass) { - return false; - } - - return $inputClass === $class || is_subclass_of($inputClass, $class); - } - - public function resolve(Request $request, ArgumentMetadata $argument): \Generator - { - yield $request->attributes->get('data'); + private function getExpectedInputClass(Request $request): ?string + { + $operation = $this->initializeOperation($request); + if (Request::METHOD_DELETE === $request->getMethod() || $request->isMethodSafe() || !($operation?->canDeserialize() ?? true)) { + return null; } - private function getExpectedInputClass(Request $request): ?string - { - $operation = $this->initializeOperation($request); - if (Request::METHOD_DELETE === $request->getMethod() || $request->isMethodSafe() || !($operation?->canDeserialize() ?? true)) { - return null; - } - - $context = $this->serializationContextBuilder->createFromRequest($request, false, RequestAttributesExtractor::extractAttributes($request)); + $context = $this->serializationContextBuilder->createFromRequest($request, false, RequestAttributesExtractor::extractAttributes($request)); - return $context['input']['class'] ?? $context['resource_class'] ?? null; - } + return $context['input']['class'] ?? $context['resource_class'] ?? null; } } From b49768e3706d31adf46b5a37db433ebf9a05d590 Mon Sep 17 00:00:00 2001 From: Nico Haase Date: Thu, 12 Jan 2023 15:47:06 +0100 Subject: [PATCH 4/5] fix(graphql): use depth for nested resource class operation (#5314) * test: add reproducer for bug 5310 * fix(graphql): use depth for nested resource class operation Co-authored-by: Alan Poulain --- features/graphql/query.feature | 27 ++++++++ src/GraphQl/Type/FieldsBuilder.php | 2 +- tests/Behat/DoctrineContext.php | 17 +++++ .../Fixtures/TestBundle/Entity/TreeDummy.php | 63 +++++++++++++++++++ 4 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 tests/Fixtures/TestBundle/Entity/TreeDummy.php diff --git a/features/graphql/query.feature b/features/graphql/query.feature index 4463a717b87..ce4c7c2b0e5 100644 --- a/features/graphql/query.feature +++ b/features/graphql/query.feature @@ -68,6 +68,33 @@ Feature: GraphQL query support And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[0].node.name" should be equal to "RelatedOneToManyDummy12" And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[2].node.name" should be equal to "RelatedOneToManyDummy32" + @createSchema @!mongodb + Scenario: Retrieve an item with child relation to the same resource + Given there are tree dummies + When I send the following GraphQL request: + """ + { + treeDummies { + edges { + node { + id + children { + totalCount + } + } + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON node "errors" should not exist + And the JSON node "data.treeDummies.edges[0].node.id" should be equal to "/tree_dummies/1" + And the JSON node "data.treeDummies.edges[0].node.children.totalCount" should be equal to "1" + And the JSON node "data.treeDummies.edges[1].node.id" should be equal to "/tree_dummies/2" + And the JSON node "data.treeDummies.edges[1].node.children.totalCount" should be equal to "0" + @createSchema Scenario: Retrieve a Relay Node Given there are 2 dummy objects with relatedDummy diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index 26a9c7d42c3..c6bc5bc14a8 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -262,7 +262,7 @@ private function getResourceFieldConfiguration(?string $property, ?string $field } $resourceOperation = $rootOperation; - if ($resourceClass && $rootOperation->getClass() && $this->resourceClassResolver->isResourceClass($resourceClass) && $rootOperation->getClass() !== $resourceClass) { + if ($resourceClass && $depth >= 1 && $this->resourceClassResolver->isResourceClass($resourceClass)) { $resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($resourceClass); $resourceOperation = $resourceMetadataCollection->getOperation($isCollectionType ? 'collection_query' : 'item_query'); } diff --git a/tests/Behat/DoctrineContext.php b/tests/Behat/DoctrineContext.php index 7237a47359e..0cf1a057471 100644 --- a/tests/Behat/DoctrineContext.php +++ b/tests/Behat/DoctrineContext.php @@ -167,6 +167,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SymfonyUuidDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Taxon; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\TreeDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\UrlEncodedId; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\User; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\UuidIdentifierDummy; @@ -799,6 +800,22 @@ public function thereAreMultiRelationsDummyObjectsHavingEachAManyToOneRelationMa $this->manager->flush(); } + /** + * @Given there are tree dummies + */ + public function thereAreTreeDummies(): void + { + $parentDummy = new TreeDummy(); + $this->manager->persist($parentDummy); + + $childDummy = new TreeDummy(); + $childDummy->setParent($parentDummy); + + $this->manager->persist($childDummy); + + $this->manager->flush(); + } + /** * @Given there are :nb dummy objects with dummyDate * @Given there is :nb dummy object with dummyDate diff --git a/tests/Fixtures/TestBundle/Entity/TreeDummy.php b/tests/Fixtures/TestBundle/Entity/TreeDummy.php new file mode 100644 index 00000000000..ae5a44713c3 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/TreeDummy.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource(graphQlOperations: [new Query(), new QueryCollection()])] +#[ORM\Entity] +class TreeDummy +{ + #[ORM\Column(type: 'integer', nullable: true)] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + #[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')] + public ?TreeDummy $parent = null; + + /** @var Collection */ + #[ORM\OneToMany(targetEntity: self::class, mappedBy: 'parent')] + public Collection $children; + + public function __construct() + { + $this->children = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getParent(): ?self + { + return $this->parent; + } + + public function setParent(?self $parent): void + { + $this->parent = $parent; + } + + public function getChildren(): Collection + { + return $this->children; + } +} From 376e2eeda0e359439eda193646c1b792e71231b8 Mon Sep 17 00:00:00 2001 From: Kai Dederichs Date: Thu, 12 Jan 2023 15:58:08 +0100 Subject: [PATCH 5/5] ci: test against lowest dependencies (#5329) --- .github/workflows/ci.yml | 87 ++++++++++++++++++++++++++++++++++++++++ composer.json | 7 +++- 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2abe509c757..f56716bd336 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -753,3 +753,90 @@ jobs: run: tests/Fixtures/app/console cache:clear --ansi - name: Run Behat tests run: vendor/bin/behat --out=std --format=progress --profile=default --no-interaction + + phpunit-symfony-lowest: + name: PHPUnit (PHP ${{ matrix.php }}) (Symfony lowest) + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + matrix: + php: + - '8.1' + - '8.2' + fail-fast: false + # env: + # See https://github.com/doctrine/DoctrineMongoDBBundle/pull/673 + #SYMFONY_DEPRECATIONS_HELPER: max[direct]=0 + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: pecl, composer + extensions: intl, bcmath, curl, openssl, mbstring + coverage: none + ini-values: memory_limit=-1 + - name: Get composer cache directory + id: composercache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: ${{ steps.composercache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- + - name: Remove cache + run: rm -Rf tests/Fixtures/app/var/cache/* + - name: Update project dependencies + run: composer update --prefer-lowest --no-interaction --no-progress --ansi + - name: Install PHPUnit + run: vendor/bin/simple-phpunit --version + - name: Clear test app cache + run: tests/Fixtures/app/console cache:clear --ansi + - name: Run PHPUnit tests + run: vendor/bin/simple-phpunit + + behat-symfony-lowest: + name: Behat (PHP ${{ matrix.php }}) (Symfony lowest) + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + matrix: + php: + - '8.1' + - '8.2' + fail-fast: false + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: pecl, composer + extensions: intl, bcmath, curl, openssl, mbstring + coverage: none + ini-values: memory_limit=-1 + - name: Install additional packages + run: sudo apt-get install moreutils + - name: Get composer cache directory + id: composercache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: ${{ steps.composercache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- + - name: Remove cache + run: rm -Rf tests/Fixtures/app/var/cache/* + - name: Update project dependencies + run: composer update --prefer-lowest --no-interaction --no-progress --ansi + - name: Install PHPUnit + run: vendor/bin/simple-phpunit --version + - name: Clear test app cache + run: tests/Fixtures/app/console cache:clear --ansi + - name: Run Behat tests + run: vendor/bin/behat --out=std --format=progress --profile=default --no-interaction diff --git a/composer.json b/composer.json index 621ef2022e2..2d2f8455fe3 100644 --- a/composer.json +++ b/composer.json @@ -24,13 +24,13 @@ "symfony/property-info": "^6.1", "symfony/serializer": "^6.1", "symfony/web-link": "^6.1", - "willdurand/negotiation": "^2.0.3 || ^3.0" + "willdurand/negotiation": "^3.0" }, "require-dev": { "behat/behat": "^3.1", "behat/mink": "^1.9@dev", "doctrine/cache": "^1.11 || ^2.1", - "doctrine/common": "^2.11 || ^3.0", + "doctrine/common": "^2.11 || ^3.1", "doctrine/data-fixtures": "^1.2.2", "doctrine/dbal": "^2.6 || ^3.0", "doctrine/doctrine-bundle": "^1.12 || ^2.0", @@ -94,6 +94,9 @@ "doctrine/dbal": "<2.10", "doctrine/mongodb-odm": "<2.2", "doctrine/persistence": "<1.3", + "symfony/service-contracts": "<3", + "phpunit/phpunit": "<9.5", + "phpspec/prophecy": "<1.15", "elasticsearch/elasticsearch": ">=8.0" }, "suggest": {