From b348baa55b4560d4d01661609f08902c18f74392 Mon Sep 17 00:00:00 2001 From: aaa2000 Date: Fri, 12 Dec 2025 17:06:26 +0100 Subject: [PATCH 1/2] fix #7465 Add uuid filter --- .../Orm/Filter/AbstractUuidFilter.php | 209 +++++++++++++ src/Doctrine/Orm/Filter/UlidFilter.php | 53 ++++ src/Doctrine/Orm/Filter/UuidBinaryFilter.php | 30 ++ src/Doctrine/Orm/Filter/UuidFilter.php | 18 ++ .../Bundle/Resources/config/doctrine_orm.php | 14 + .../Entity/Uuid/RamseyUuidBinaryDevice.php | 48 +++ .../Uuid/RamseyUuidBinaryDeviceEndpoint.php | 55 ++++ .../Entity/Uuid/RamseyUuidDevice.php | 48 +++ .../Entity/Uuid/RamseyUuidDeviceEndpoint.php | 55 ++++ .../Entity/Uuid/SymfonyUlidDevice.php | 44 +++ .../Entity/Uuid/SymfonyUlidDeviceEndpoint.php | 55 ++++ .../Entity/Uuid/SymfonyUuidDevice.php | 47 +++ .../Entity/Uuid/SymfonyUuidDeviceEndpoint.php | 54 ++++ tests/Fixtures/app/config/config_common.yml | 1 + .../Uuid/UuidFilterBaseTestCase.php | 294 ++++++++++++++++++ .../UuidFilterWithRamseyUuidBinaryTest.php | 57 ++++ .../Uuid/UuidFilterWithRamseyUuidTest.php | 57 ++++ .../Uuid/UuidFilterWithSymfonyUlidTest.php | 103 ++++++ .../Uuid/UuidFilterWithSymfonyUuidTest.php | 57 ++++ tests/SetupClassResourcesTrait.php | 2 +- 20 files changed, 1300 insertions(+), 1 deletion(-) create mode 100644 src/Doctrine/Orm/Filter/AbstractUuidFilter.php create mode 100644 src/Doctrine/Orm/Filter/UlidFilter.php create mode 100644 src/Doctrine/Orm/Filter/UuidBinaryFilter.php create mode 100644 src/Doctrine/Orm/Filter/UuidFilter.php create mode 100644 tests/Fixtures/TestBundle/Entity/Uuid/RamseyUuidBinaryDevice.php create mode 100644 tests/Fixtures/TestBundle/Entity/Uuid/RamseyUuidBinaryDeviceEndpoint.php create mode 100644 tests/Fixtures/TestBundle/Entity/Uuid/RamseyUuidDevice.php create mode 100644 tests/Fixtures/TestBundle/Entity/Uuid/RamseyUuidDeviceEndpoint.php create mode 100644 tests/Fixtures/TestBundle/Entity/Uuid/SymfonyUlidDevice.php create mode 100644 tests/Fixtures/TestBundle/Entity/Uuid/SymfonyUlidDeviceEndpoint.php create mode 100644 tests/Fixtures/TestBundle/Entity/Uuid/SymfonyUuidDevice.php create mode 100644 tests/Fixtures/TestBundle/Entity/Uuid/SymfonyUuidDeviceEndpoint.php create mode 100644 tests/Functional/Uuid/UuidFilterBaseTestCase.php create mode 100644 tests/Functional/Uuid/UuidFilterWithRamseyUuidBinaryTest.php create mode 100644 tests/Functional/Uuid/UuidFilterWithRamseyUuidTest.php create mode 100644 tests/Functional/Uuid/UuidFilterWithSymfonyUlidTest.php create mode 100644 tests/Functional/Uuid/UuidFilterWithSymfonyUuidTest.php diff --git a/src/Doctrine/Orm/Filter/AbstractUuidFilter.php b/src/Doctrine/Orm/Filter/AbstractUuidFilter.php new file mode 100644 index 00000000000..cfda29e2aa7 --- /dev/null +++ b/src/Doctrine/Orm/Filter/AbstractUuidFilter.php @@ -0,0 +1,209 @@ + + * + * 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\Doctrine\Orm\Filter; + +use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; +use Doctrine\DBAL\ArrayParameterType; +use Doctrine\DBAL\ParameterType; +use Doctrine\DBAL\Types\ConversionException; +use Doctrine\DBAL\Types\Type; +use Doctrine\ORM\Query\Expr\Join; +use Doctrine\ORM\QueryBuilder; + +/** + * @internal + */ +class AbstractUuidFilter extends AbstractFilter implements OpenApiParameterFilterInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + + private const UUID_SCHEMA = [ + 'type' => 'string', + 'format' => 'uuid', + ]; + + protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + if ( + null === $value + || !$this->isPropertyEnabled($property, $resourceClass) + || !$this->isPropertyMapped($property, $resourceClass, true) + ) { + return; + } + + $alias = $queryBuilder->getRootAliases()[0]; + $field = $property; + + $values = $this->normalizeValues((array) $value, $property); + if (null === $values) { + return; + } + + $associations = []; + if ($this->isPropertyNested($property, $resourceClass)) { + [$alias, $field, $associations] = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $resourceClass, Join::INNER_JOIN); + } + + $metadata = $this->getNestedMetadata($resourceClass, $associations); + + if ($metadata->hasField($field)) { + $values = $this->convertValuesToTheDatabaseRepresentationIfNecessary($queryBuilder, $this->getDoctrineFieldType($property, $resourceClass), $values); + $this->addWhere($queryBuilder, $queryNameGenerator, $alias, $field, $values); + + return; + } + + // metadata doesn't have the field, nor an association on the field + if (!$metadata->hasAssociation($field)) { + return; + } + + // association, let's fetch the entity (or reference to it) if we can so we can make sure we get its orm id + $associationResourceClass = $metadata->getAssociationTargetClass($field); + $associationMetadata = $this->getClassMetadata($associationResourceClass); + $associationFieldIdentifier = $associationMetadata->getIdentifierFieldNames()[0]; + $doctrineTypeField = $this->getDoctrineFieldType($associationFieldIdentifier, $associationResourceClass); + + $associationAlias = $alias; + $associationField = $field; + + if ($metadata->isCollectionValuedAssociation($associationField) || $metadata->isAssociationInverseSide($field)) { + $associationAlias = QueryBuilderHelper::addJoinOnce($queryBuilder, $queryNameGenerator, $alias, $associationField); + $associationField = $associationFieldIdentifier; + } + + $values = $this->convertValuesToTheDatabaseRepresentationIfNecessary($queryBuilder, $doctrineTypeField, $values); + $this->addWhere($queryBuilder, $queryNameGenerator, $associationAlias, $associationField, $values); + } + + /** + * Converts values to their database representation. + */ + private function convertValuesToTheDatabaseRepresentationIfNecessary(QueryBuilder $queryBuilder, ?string $doctrineFieldType, array $values): array + { + if ($doctrineFieldType && Type::hasType($doctrineFieldType)) { + $doctrineType = Type::getType($doctrineFieldType); + $platform = $queryBuilder->getEntityManager()->getConnection()->getDatabasePlatform(); + $databaseValues = []; + + foreach ($values as $value) { + try { + $databaseValues[] = $doctrineType->convertToDatabaseValue($value, $platform); + } catch (ConversionException $e) { + $this->logger->notice('Invalid value conversion value to its database representation', [ + 'exception' => $e, + ]); + $databaseValues[] = null; + } + } + + return $databaseValues; + } + + return $values; + } + + /** + * Adds where clause. + */ + private function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, mixed $values): void + { + if (!\is_array($values)) { + $values = [$values]; + } + + $valueParameter = ':'.$queryNameGenerator->generateParameterName($field); + $aliasedField = \sprintf('%s.%s', $alias, $field); + + if (1 === \count($values)) { + $queryBuilder + ->andWhere($queryBuilder->expr()->eq($aliasedField, $valueParameter)) + ->setParameter($valueParameter, $values[0], $this->getDoctrineParameterType()); + + return; + } + + $queryBuilder + ->andWhere($queryBuilder->expr()->in($aliasedField, $valueParameter)) + ->setParameter($valueParameter, $values, $this->getDoctrineArrayParameterType()); + } + + protected function getDoctrineParameterType(): ?ParameterType + { + return null; + } + + protected function getDoctrineArrayParameterType(): ?ArrayParameterType + { + return null; + } + + public function getOpenApiParameters(Parameter $parameter): array + { + $in = $parameter instanceof QueryParameter ? 'query' : 'header'; + $key = $parameter->getKey(); + + return [ + new OpenApiParameter( + name: $key, + in: $in, + schema: self::UUID_SCHEMA, + style: 'form', + explode: false + ), + new OpenApiParameter( + name: $key.'[]', + in: $in, + description: 'One or more Uuids', + schema: [ + 'type' => 'array', + 'items' => self::UUID_SCHEMA, + ], + style: 'deepObject', + explode: true + ), + ]; + } + + /** + * Normalize the values array. + */ + protected function normalizeValues(array $values, string $property): ?array + { + foreach ($values as $key => $value) { + if (!\is_string($value)) { + unset($values[$key]); + } + } + + if (0 === \count($values)) { + $this->getLogger()->notice('Invalid filter ignored', [ + 'exception' => new InvalidArgumentException(\sprintf('At least one value is required, multiple values should be in "%1$s[]=019b3c90-e265-72e5-a594-17b446a4067f&%1$s[]=019b3c9b-bce6-76dc-a066-9a44f4ec253f" format', $property)), + ]); + + return null; + } + + return array_values($values); + } +} diff --git a/src/Doctrine/Orm/Filter/UlidFilter.php b/src/Doctrine/Orm/Filter/UlidFilter.php new file mode 100644 index 00000000000..f4f49edbe3d --- /dev/null +++ b/src/Doctrine/Orm/Filter/UlidFilter.php @@ -0,0 +1,53 @@ + + * + * 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\Doctrine\Orm\Filter; + +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; + +final class UlidFilter extends AbstractUuidFilter +{ + private const ULID_SCHEMA = [ + 'type' => 'string', + 'format' => 'ulid', + ]; + + public function getOpenApiParameters(Parameter $parameter): array + { + $in = $parameter instanceof QueryParameter ? 'query' : 'header'; + $key = $parameter->getKey(); + + return [ + new OpenApiParameter( + name: $key, + in: $in, + schema: self::ULID_SCHEMA, + style: 'form', + explode: false + ), + new OpenApiParameter( + name: $key.'[]', + in: $in, + description: 'One or more Ulids', + schema: [ + 'type' => 'array', + 'items' => self::ULID_SCHEMA, + ], + style: 'deepObject', + explode: true + ), + ]; + } +} diff --git a/src/Doctrine/Orm/Filter/UuidBinaryFilter.php b/src/Doctrine/Orm/Filter/UuidBinaryFilter.php new file mode 100644 index 00000000000..3d894d01fb9 --- /dev/null +++ b/src/Doctrine/Orm/Filter/UuidBinaryFilter.php @@ -0,0 +1,30 @@ + + * + * 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\Doctrine\Orm\Filter; + +use Doctrine\DBAL\ArrayParameterType; +use Doctrine\DBAL\ParameterType; + +final class UuidBinaryFilter extends AbstractUuidFilter +{ + protected function getDoctrineParameterType(): ParameterType + { + return ParameterType::BINARY; + } + + protected function getDoctrineArrayParameterType(): ArrayParameterType + { + return ArrayParameterType::BINARY; + } +} diff --git a/src/Doctrine/Orm/Filter/UuidFilter.php b/src/Doctrine/Orm/Filter/UuidFilter.php new file mode 100644 index 00000000000..416615c3949 --- /dev/null +++ b/src/Doctrine/Orm/Filter/UuidFilter.php @@ -0,0 +1,18 @@ + + * + * 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\Doctrine\Orm\Filter; + +final class UuidFilter extends AbstractUuidFilter +{ +} diff --git a/src/Symfony/Bundle/Resources/config/doctrine_orm.php b/src/Symfony/Bundle/Resources/config/doctrine_orm.php index 57504522571..e6d324a9471 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_orm.php +++ b/src/Symfony/Bundle/Resources/config/doctrine_orm.php @@ -222,6 +222,20 @@ ->parent('api_platform.doctrine.orm.search_filter') ->args([[]]); + $services->set('api_platform.doctrine.orm.uuid_filter', 'ApiPlatform\Doctrine\Orm\Filter\UuidFilter') + ->arg(0, service('doctrine')) + ->arg(3, service('logger')->ignoreOnInvalid()) + ->arg('$nameConverter', service('api_platform.name_converter')->ignoreOnInvalid()); + $services->alias('ApiPlatform\Doctrine\Orm\Filter\UuidFilter', 'api_platform.doctrine.orm.uuid_filter'); + + $services->set('api_platform.doctrine.orm.ulid_filter', 'ApiPlatform\Doctrine\Orm\Filter\UlidFilter') + ->parent('api_platform.doctrine.orm.uuid_filter'); + $services->alias('ApiPlatform\Doctrine\Orm\Filter\UlidFilter', 'api_platform.doctrine.orm.ulid_filter'); + + $services->set('api_platform.doctrine.orm.uuid_binary_filter', 'ApiPlatform\Doctrine\Orm\Filter\UuidBinaryFilter') + ->parent('api_platform.doctrine.orm.uuid_filter'); + $services->alias('ApiPlatform\Doctrine\Orm\Filter\UuidBinaryFilter', 'api_platform.doctrine.orm.uuid_binary_filter'); + $services->set('api_platform.doctrine.orm.metadata.resource.metadata_collection_factory', 'ApiPlatform\Doctrine\Orm\Metadata\Resource\DoctrineOrmResourceCollectionMetadataFactory') ->decorate('api_platform.metadata.resource.metadata_collection_factory', null, 40) ->args([ diff --git a/tests/Fixtures/TestBundle/Entity/Uuid/RamseyUuidBinaryDevice.php b/tests/Fixtures/TestBundle/Entity/Uuid/RamseyUuidBinaryDevice.php new file mode 100644 index 00000000000..8674dc8495b --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Uuid/RamseyUuidBinaryDevice.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\Uuid; + +use ApiPlatform\Doctrine\Orm\Filter\UuidBinaryFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; +use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; + +#[ApiResource(operations: [ + new Get(), + new GetCollection( + parameters: [ + 'id' => new QueryParameter( + filter: new UuidBinaryFilter(), + ), + ] + ), + new Post(), +])] +#[ORM\Entity] +class RamseyUuidBinaryDevice +{ + #[ORM\Id] + #[ORM\Column(type: 'uuid_binary', unique: true)] + public UuidInterface $id; + + public function __construct(?UuidInterface $id = null) + { + $this->id = $id ?? Uuid::uuid7(); + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Uuid/RamseyUuidBinaryDeviceEndpoint.php b/tests/Fixtures/TestBundle/Entity/Uuid/RamseyUuidBinaryDeviceEndpoint.php new file mode 100644 index 00000000000..a0967282724 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Uuid/RamseyUuidBinaryDeviceEndpoint.php @@ -0,0 +1,55 @@ + + * + * 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\Uuid; + +use ApiPlatform\Doctrine\Orm\Filter\UuidBinaryFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; +use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; + +#[ApiResource(operations: [ + new Get(), + new GetCollection( + parameters: [ + 'id' => new QueryParameter( + filter: new UuidBinaryFilter(), + ), + 'myDevice' => new QueryParameter( + filter: new UuidBinaryFilter(), + ), + ] + ), + new Post(), +])] +#[ORM\Entity] +class RamseyUuidBinaryDeviceEndpoint +{ + #[ORM\Id] + #[ORM\Column(type: 'uuid_binary', unique: true)] + public UuidInterface $id; + + #[ORM\ManyToOne] + public ?RamseyUuidBinaryDevice $myDevice = null; + + public function __construct(?UuidInterface $id = null, ?RamseyUuidBinaryDevice $myDevice = null) + { + $this->id = $id ?? Uuid::uuid7(); + $this->myDevice = $myDevice; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Uuid/RamseyUuidDevice.php b/tests/Fixtures/TestBundle/Entity/Uuid/RamseyUuidDevice.php new file mode 100644 index 00000000000..96b0a6e0b44 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Uuid/RamseyUuidDevice.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\Uuid; + +use ApiPlatform\Doctrine\Orm\Filter\UuidFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; +use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; + +#[ApiResource(operations: [ + new Get(), + new GetCollection( + parameters: [ + 'id' => new QueryParameter( + filter: new UuidFilter(), + ), + ] + ), + new Post(), +])] +#[ORM\Entity] +class RamseyUuidDevice +{ + #[ORM\Id] + #[ORM\Column(type: 'uuid', unique: true)] + public UuidInterface $id; + + public function __construct(?UuidInterface $id = null) + { + $this->id = $id ?? Uuid::uuid7(); + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Uuid/RamseyUuidDeviceEndpoint.php b/tests/Fixtures/TestBundle/Entity/Uuid/RamseyUuidDeviceEndpoint.php new file mode 100644 index 00000000000..e2c1f146907 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Uuid/RamseyUuidDeviceEndpoint.php @@ -0,0 +1,55 @@ + + * + * 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\Uuid; + +use ApiPlatform\Doctrine\Orm\Filter\UuidFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; +use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; + +#[ApiResource(operations: [ + new Get(), + new GetCollection( + parameters: [ + 'id' => new QueryParameter( + filter: new UuidFilter(), + ), + 'myDevice' => new QueryParameter( + filter: new UuidFilter(), + ), + ] + ), + new Post(), +])] +#[ORM\Entity] +class RamseyUuidDeviceEndpoint +{ + #[ORM\Id] + #[ORM\Column(type: 'uuid', unique: true)] + public UuidInterface $id; + + #[ORM\ManyToOne] + public ?RamseyUuidDevice $myDevice = null; + + public function __construct(?UuidInterface $id = null, ?RamseyUuidDevice $myDevice = null) + { + $this->id = $id ?? Uuid::uuid7(); + $this->myDevice = $myDevice; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Uuid/SymfonyUlidDevice.php b/tests/Fixtures/TestBundle/Entity/Uuid/SymfonyUlidDevice.php new file mode 100644 index 00000000000..df72af374b8 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Uuid/SymfonyUlidDevice.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\Fixtures\TestBundle\Entity\Uuid; + +use ApiPlatform\Doctrine\Orm\Filter\UlidFilter; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Uid\Ulid; + +#[Get] +#[GetCollection( + parameters: [ + 'id' => new QueryParameter( + filter: new UlidFilter(), + ), + ] +)] +#[Post] +#[ORM\Entity] +class SymfonyUlidDevice +{ + #[ORM\Id] + #[ORM\Column(type: 'ulid', unique: true)] + public Ulid $id; + + public function __construct(?Ulid $id = null) + { + $this->id = $id ?? new Ulid(); + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Uuid/SymfonyUlidDeviceEndpoint.php b/tests/Fixtures/TestBundle/Entity/Uuid/SymfonyUlidDeviceEndpoint.php new file mode 100644 index 00000000000..8f717b0e6a0 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Uuid/SymfonyUlidDeviceEndpoint.php @@ -0,0 +1,55 @@ + + * + * 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\Uuid; + +use ApiPlatform\Doctrine\Orm\Filter\UlidFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Bridge\Doctrine\Types\UlidType; +use Symfony\Component\Uid\Ulid; + +#[ApiResource(operations: [ + new Get(), + new GetCollection( + parameters: [ + 'id' => new QueryParameter( + filter: new UlidFilter(), + ), + 'myDevice' => new QueryParameter( + filter: new UlidFilter(), + ), + ] + ), + new Post(), +])] +#[ORM\Entity] +class SymfonyUlidDeviceEndpoint +{ + #[ORM\Id] + #[ORM\Column(type: UlidType::NAME, unique: true)] + public Ulid $id; + + #[ORM\ManyToOne] + public ?SymfonyUlidDevice $myDevice = null; + + public function __construct(?Ulid $id = null, ?SymfonyUlidDevice $myDevice = null) + { + $this->id = $id ?? new Ulid(); + $this->myDevice = $myDevice; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Uuid/SymfonyUuidDevice.php b/tests/Fixtures/TestBundle/Entity/Uuid/SymfonyUuidDevice.php new file mode 100644 index 00000000000..04d85978ab3 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Uuid/SymfonyUuidDevice.php @@ -0,0 +1,47 @@ + + * + * 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\Uuid; + +use ApiPlatform\Doctrine\Orm\Filter\UuidFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Uid\Uuid; + +#[ApiResource(operations: [ + new Get(), + new GetCollection( + parameters: [ + 'id' => new QueryParameter( + filter: new UuidFilter(), + ), + ] + ), + new Post(), +])] +#[ORM\Entity] +class SymfonyUuidDevice +{ + #[ORM\Id] + #[ORM\Column(type: 'symfony_uuid', unique: true)] + public Uuid $id; + + public function __construct(?Uuid $id = null) + { + $this->id = $id ?? Uuid::v7(); + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Uuid/SymfonyUuidDeviceEndpoint.php b/tests/Fixtures/TestBundle/Entity/Uuid/SymfonyUuidDeviceEndpoint.php new file mode 100644 index 00000000000..af9cc3ffa2c --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Uuid/SymfonyUuidDeviceEndpoint.php @@ -0,0 +1,54 @@ + + * + * 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\Uuid; + +use ApiPlatform\Doctrine\Orm\Filter\UuidFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Uid\Uuid; + +#[ApiResource(operations: [ + new Get(), + new GetCollection( + parameters: [ + 'id' => new QueryParameter( + filter: new UuidFilter(), + ), + 'myDevice' => new QueryParameter( + filter: new UuidFilter(), + ), + ] + ), + new Post(), +])] +#[ORM\Entity] +class SymfonyUuidDeviceEndpoint +{ + #[ORM\Id] + #[ORM\Column(type: 'symfony_uuid', unique: true)] + public Uuid $id; + + #[ORM\ManyToOne] + public ?SymfonyUuidDevice $myDevice = null; + + public function __construct(?Uuid $id = null, ?SymfonyUuidDevice $myDevice = null) + { + $this->id = $id ?? Uuid::v7(); + $this->myDevice = $myDevice; + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index eb58c616ae8..bfc811306b5 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -10,6 +10,7 @@ doctrine: charset: 'UTF8' types: uuid: Ramsey\Uuid\Doctrine\UuidType + uuid_binary: Ramsey\Uuid\Doctrine\UuidBinaryType symfony_uuid: Symfony\Bridge\Doctrine\Types\UuidType orm: diff --git a/tests/Functional/Uuid/UuidFilterBaseTestCase.php b/tests/Functional/Uuid/UuidFilterBaseTestCase.php new file mode 100644 index 00000000000..8f9ad5dc245 --- /dev/null +++ b/tests/Functional/Uuid/UuidFilterBaseTestCase.php @@ -0,0 +1,294 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Uuid; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +abstract class UuidFilterBaseTestCase extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + protected function setUp(): void + { + parent::setUp(); + + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + } + + /** + * @return class-string + */ + abstract protected static function getDeviceEndpointClass(): string; + + /** + * @return class-string + */ + abstract protected static function getDeviceClass(): string; + + abstract public function getUrlPrefix(): string; + + abstract public function geTypePrefix(): string; + + public function createDeviceEndpoint(mixed ...$args): object + { + return (new \ReflectionClass(static::getDeviceEndpointClass()))->newInstanceArgs($args); + } + + public function createDevice(mixed ...$args): object + { + return (new \ReflectionClass(static::getDeviceClass()))->newInstanceArgs($args); + } + + public function testSearchFilterByUuid(): void + { + $this->recreateSchema(static::getResources()); + + $manager = $this->getManager(); + $manager->persist($device = $this->createDevice()); + $manager->persist($this->createDevice()); + $manager->flush(); + + $response = self::createClient()->request('GET', '/'.$this->getUrlPrefix().'_devices', [ + 'query' => [ + 'id' => $device->id->toString(), + ], + ]); + + self::assertResponseIsSuccessful(); + $json = $response->toArray(); + + self::assertArraySubset(['hydra:totalItems' => 1], $json); + self::assertArraySubset( + [ + 'hydra:member' => [ + [ + '@id' => '/'.$this->getUrlPrefix().'_devices/'.$device->id->toString(), + '@type' => $this->geTypePrefix().'Device', + 'id' => $device->id->toString(), + ], + ], + ], + $json + ); + } + + public function testSearchFilterByManyUuid(): void + { + $this->recreateSchema(static::getResources()); + + $manager = $this->getManager(); + $manager->persist($device = $this->createDevice()); + $manager->persist($otherDevice = $this->createDevice()); + $manager->persist($this->createDevice()); + $manager->flush(); + + $response = self::createClient()->request('GET', '/'.$this->getUrlPrefix().'_devices', [ + 'query' => [ + 'id' => [ + $device->id->toString(), + $otherDevice->id->toString(), + ], + ], + ]); + + self::assertResponseIsSuccessful(); + $json = $response->toArray(); + + self::assertArraySubset(['hydra:totalItems' => 2], $json); + self::assertArraySubset( + [ + 'hydra:member' => [ + [ + '@id' => '/'.$this->getUrlPrefix().'_devices/'.$device->id->toString(), + '@type' => $this->geTypePrefix().'Device', + 'id' => $device->id->toString(), + ], + [ + '@id' => '/'.$this->getUrlPrefix().'_devices/'.$otherDevice->id->toString(), + '@type' => $this->geTypePrefix().'Device', + 'id' => $otherDevice->id->toString(), + ], + ], + ], + $json + ); + } + + public function testSearchFilterByInvalidUuid(): void + { + $this->recreateSchema(static::getResources()); + + $manager = $this->getManager(); + $manager->persist($this->createDevice()); + $manager->persist($this->createDevice()); + $manager->flush(); + + $response = self::createClient()->request('GET', '/'.$this->getUrlPrefix().'_devices', [ + 'query' => [ + 'id' => 'invalid-uuid', + ], + ]); + + self::assertResponseIsSuccessful(); + $json = $response->toArray(); + + self::assertArraySubset(['hydra:totalItems' => 0], $json); + self::assertArraySubset( + [ + 'hydra:member' => [], + ], + $json + ); + } + + public function testSearchFilterOnManyToOneRelationByUuid(): void + { + $this->recreateSchema(static::getResources()); + + $manager = $this->getManager(); + $manager->persist($fooDevice = $this->createDevice()); + $manager->persist($barDevice = $this->createDevice()); + $manager->persist($this->createDeviceEndpoint(null, $fooDevice)); + $manager->persist($barDeviceEndpoint = $this->createDeviceEndpoint(null, $barDevice)); + $manager->flush(); + + $response = self::createClient()->request('GET', '/'.$this->getUrlPrefix().'_device_endpoints', [ + 'query' => [ + 'myDevice' => $barDevice->id->toString(), + ], + ]); + + self::assertResponseIsSuccessful(); + $json = $response->toArray(); + + self::assertArraySubset(['hydra:totalItems' => 1], $json); + self::assertArraySubset( + [ + 'hydra:member' => [ + [ + '@id' => '/'.$this->getUrlPrefix().'_device_endpoints/'.$barDeviceEndpoint->id->toString(), + '@type' => $this->geTypePrefix().'DeviceEndpoint', + 'id' => $barDeviceEndpoint->id->toString(), + ], + ], + ], + $json + ); + } + + public function testSearchFilterOnManyToOneRelationByManyUuid(): void + { + $this->recreateSchema(static::getResources()); + + $manager = $this->getManager(); + $manager->persist($fooDevice = $this->createDevice()); + $manager->persist($barDevice = $this->createDevice()); + $manager->persist($bazDevice = $this->createDevice()); + $manager->persist($fooDeviceEndpoint = $this->createDeviceEndpoint(null, $fooDevice)); + $manager->persist($barDeviceEndpoint = $this->createDeviceEndpoint(null, $barDevice)); + $manager->persist($this->createDeviceEndpoint(null, $bazDevice)); + $manager->flush(); + + $response = self::createClient()->request('GET', '/'.$this->getUrlPrefix().'_device_endpoints', [ + 'query' => [ + 'myDevice' => [ + $fooDevice->id->toString(), + $barDevice->id->toString(), + ], + ], + ]); + + self::assertResponseIsSuccessful(); + $json = $response->toArray(); + + self::assertArraySubset(['hydra:totalItems' => 2], $json); + self::assertArraySubset( + [ + 'hydra:member' => [ + [ + '@id' => '/'.$this->getUrlPrefix().'_device_endpoints/'.$fooDeviceEndpoint->id->toString(), + '@type' => $this->geTypePrefix().'DeviceEndpoint', + 'id' => $fooDeviceEndpoint->id->toString(), + ], + [ + '@id' => '/'.$this->getUrlPrefix().'_device_endpoints/'.$barDeviceEndpoint->id->toString(), + '@type' => $this->geTypePrefix().'DeviceEndpoint', + 'id' => $barDeviceEndpoint->id->toString(), + ], + ], + ], + $json + ); + } + + public function testGetOpenApiDescription(): void + { + $response = self::createClient()->request('GET', '/docs', [ + 'headers' => ['Accept' => 'application/vnd.openapi+json'], + ]); + + $json = $response->toArray(); + + self::assertContains( + [ + 'name' => 'id', + 'in' => 'query', + 'description' => '', + 'required' => false, + 'deprecated' => false, + 'schema' => [ + 'type' => 'string', + 'format' => 'uuid', + ], + 'style' => 'form', + 'explode' => false, + ], + $json['paths']['/'.$this->getUrlPrefix().'_device_endpoints']['get']['parameters'] + ); + + self::assertContains( + [ + 'name' => 'id[]', + 'in' => 'query', + 'description' => 'One or more Uuids', + 'required' => false, + 'deprecated' => false, + 'schema' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'string', + 'format' => 'uuid', + ], + ], + 'style' => 'deepObject', + 'explode' => true, + ], + $json['paths']['/'.$this->getUrlPrefix().'_device_endpoints']['get']['parameters'] + ); + } + + protected function tearDown(): void + { + $this->recreateSchema(static::getResources()); + + parent::tearDown(); + } +} diff --git a/tests/Functional/Uuid/UuidFilterWithRamseyUuidBinaryTest.php b/tests/Functional/Uuid/UuidFilterWithRamseyUuidBinaryTest.php new file mode 100644 index 00000000000..1473b82c4d6 --- /dev/null +++ b/tests/Functional/Uuid/UuidFilterWithRamseyUuidBinaryTest.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Uuid; + +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Uuid\RamseyUuidBinaryDevice; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Uuid\RamseyUuidBinaryDeviceEndpoint; + +class UuidFilterWithRamseyUuidBinaryTest extends UuidFilterBaseTestCase +{ + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + self::getDeviceClass(), + self::getDeviceEndpointClass(), + ]; + } + + /** + * @return class-string + */ + protected static function getDeviceEndpointClass(): string + { + return RamseyUuidBinaryDeviceEndpoint::class; + } + + /** + * @return class-string + */ + protected static function getDeviceClass(): string + { + return RamseyUuidBinaryDevice::class; + } + + public function getUrlPrefix(): string + { + return 'ramsey_uuid_binary'; + } + + public function geTypePrefix(): string + { + return 'RamseyUuidBinary'; + } +} diff --git a/tests/Functional/Uuid/UuidFilterWithRamseyUuidTest.php b/tests/Functional/Uuid/UuidFilterWithRamseyUuidTest.php new file mode 100644 index 00000000000..3482591c475 --- /dev/null +++ b/tests/Functional/Uuid/UuidFilterWithRamseyUuidTest.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Uuid; + +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Uuid\RamseyUuidDevice; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Uuid\RamseyUuidDeviceEndpoint; + +class UuidFilterWithRamseyUuidTest extends UuidFilterBaseTestCase +{ + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + self::getDeviceClass(), + self::getDeviceEndpointClass(), + ]; + } + + /** + * @return class-string + */ + protected static function getDeviceEndpointClass(): string + { + return RamseyUuidDeviceEndpoint::class; + } + + /** + * @return class-string + */ + protected static function getDeviceClass(): string + { + return RamseyUuidDevice::class; + } + + public function getUrlPrefix(): string + { + return 'ramsey_uuid'; + } + + public function geTypePrefix(): string + { + return 'RamseyUuid'; + } +} diff --git a/tests/Functional/Uuid/UuidFilterWithSymfonyUlidTest.php b/tests/Functional/Uuid/UuidFilterWithSymfonyUlidTest.php new file mode 100644 index 00000000000..6ae18b273a2 --- /dev/null +++ b/tests/Functional/Uuid/UuidFilterWithSymfonyUlidTest.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Uuid; + +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Uuid\SymfonyUlidDevice; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Uuid\SymfonyUlidDeviceEndpoint; + +class UuidFilterWithSymfonyUlidTest extends UuidFilterBaseTestCase +{ + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + self::getDeviceClass(), + self::getDeviceEndpointClass(), + ]; + } + + /** + * @return class-string + */ + protected static function getDeviceEndpointClass(): string + { + return SymfonyUlidDeviceEndpoint::class; + } + + /** + * @return class-string + */ + protected static function getDeviceClass(): string + { + return SymfonyUlidDevice::class; + } + + public function getUrlPrefix(): string + { + return 'symfony_ulid'; + } + + public function geTypePrefix(): string + { + return 'SymfonyUlid'; + } + + public function testGetOpenApiDescription(): void + { + $response = self::createClient()->request('GET', '/docs', [ + 'headers' => ['Accept' => 'application/vnd.openapi+json'], + ]); + + $json = $response->toArray(); + + self::assertContains( + [ + 'name' => 'id', + 'in' => 'query', + 'description' => '', + 'required' => false, + 'deprecated' => false, + 'schema' => [ + 'type' => 'string', + 'format' => 'ulid', + ], + 'style' => 'form', + 'explode' => false, + ], + $json['paths']['/'.$this->getUrlPrefix().'_device_endpoints']['get']['parameters'] + ); + + self::assertContains( + [ + 'name' => 'id[]', + 'in' => 'query', + 'description' => 'One or more Ulids', + 'required' => false, + 'deprecated' => false, + 'schema' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'string', + 'format' => 'ulid', + ], + ], + 'style' => 'deepObject', + 'explode' => true, + ], + $json['paths']['/'.$this->getUrlPrefix().'_device_endpoints']['get']['parameters'] + ); + } +} diff --git a/tests/Functional/Uuid/UuidFilterWithSymfonyUuidTest.php b/tests/Functional/Uuid/UuidFilterWithSymfonyUuidTest.php new file mode 100644 index 00000000000..ffcdcaf766e --- /dev/null +++ b/tests/Functional/Uuid/UuidFilterWithSymfonyUuidTest.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Uuid; + +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Uuid\SymfonyUuidDevice; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Uuid\SymfonyUuidDeviceEndpoint; + +class UuidFilterWithSymfonyUuidTest extends UuidFilterBaseTestCase +{ + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + self::getDeviceClass(), + self::getDeviceEndpointClass(), + ]; + } + + /** + * @return class-string + */ + protected static function getDeviceEndpointClass(): string + { + return SymfonyUuidDeviceEndpoint::class; + } + + /** + * @return class-string + */ + protected static function getDeviceClass(): string + { + return SymfonyUuidDevice::class; + } + + public function getUrlPrefix(): string + { + return 'symfony_uuid'; + } + + public function geTypePrefix(): string + { + return 'SymfonyUuid'; + } +} diff --git a/tests/SetupClassResourcesTrait.php b/tests/SetupClassResourcesTrait.php index 0fdcc232511..32f08efc2f9 100644 --- a/tests/SetupClassResourcesTrait.php +++ b/tests/SetupClassResourcesTrait.php @@ -21,7 +21,7 @@ trait SetupClassResourcesTrait public static function setUpBeforeClass(): void { - static::writeResources(self::getResources()); + static::writeResources(static::getResources()); } public static function tearDownAfterClass(): void From 7f6964d78b82bc71994776c61791b84418258cce Mon Sep 17 00:00:00 2001 From: aaa2000 Date: Mon, 22 Dec 2025 20:24:01 +0100 Subject: [PATCH 2/2] Fix phpunit test test introduced https://github.com/api-platform/core/pull/7569 --- tests/Functional/JsonApiTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Functional/JsonApiTest.php b/tests/Functional/JsonApiTest.php index 9e90a391b2a..7f25d47c6ed 100644 --- a/tests/Functional/JsonApiTest.php +++ b/tests/Functional/JsonApiTest.php @@ -43,7 +43,7 @@ public function testError(): void $this->assertJsonContains([ 'errors' => [ [ - 'status' => '400', + 'status' => 400, 'detail' => 'Resource "nonexistent" not found.', ], ],