diff --git a/src/Doctrine/Common/Filter/LoggerAwareInterface.php b/src/Doctrine/Common/Filter/LoggerAwareInterface.php new file mode 100644 index 00000000000..aa765210b46 --- /dev/null +++ b/src/Doctrine/Common/Filter/LoggerAwareInterface.php @@ -0,0 +1,25 @@ + + * + * 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\Common\Filter; + +use Psr\Log\LoggerInterface; + +interface LoggerAwareInterface +{ + public function hasLogger(): bool; + + public function getLogger(): LoggerInterface; + + public function setLogger(LoggerInterface $logger): void; +} diff --git a/src/Doctrine/Common/Filter/LoggerAwareTrait.php b/src/Doctrine/Common/Filter/LoggerAwareTrait.php new file mode 100644 index 00000000000..9ed56d1ba6e --- /dev/null +++ b/src/Doctrine/Common/Filter/LoggerAwareTrait.php @@ -0,0 +1,37 @@ + + * + * 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\Common\Filter; + +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; + +trait LoggerAwareTrait +{ + private ?LoggerInterface $logger = null; + + public function hasLogger(): bool + { + return $this->logger instanceof LoggerInterface; + } + + public function getLogger(): LoggerInterface + { + return $this->logger ??= new NullLogger(); + } + + public function setLogger(LoggerInterface $logger): void + { + $this->logger = $logger; + } +} diff --git a/src/Doctrine/Common/Filter/ManagerRegistryAwareTrait.php b/src/Doctrine/Common/Filter/ManagerRegistryAwareTrait.php new file mode 100644 index 00000000000..c7547ac71c2 --- /dev/null +++ b/src/Doctrine/Common/Filter/ManagerRegistryAwareTrait.php @@ -0,0 +1,41 @@ + + * + * 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\Common\Filter; + +use ApiPlatform\Metadata\Exception\RuntimeException; +use Doctrine\Persistence\ManagerRegistry; + +trait ManagerRegistryAwareTrait +{ + private ?ManagerRegistry $managerRegistry = null; + + public function hasManagerRegistry(): bool + { + return $this->managerRegistry instanceof ManagerRegistry; + } + + public function getManagerRegistry(): ManagerRegistry + { + if (!$this->hasManagerRegistry()) { + throw new RuntimeException('ManagerRegistry must be initialized before accessing it.'); + } + + return $this->managerRegistry; + } + + public function setManagerRegistry(ManagerRegistry $managerRegistry): void + { + $this->managerRegistry = $managerRegistry; + } +} diff --git a/src/Doctrine/Common/Filter/OpenApiFilterTrait.php b/src/Doctrine/Common/Filter/OpenApiFilterTrait.php new file mode 100644 index 00000000000..7df7e8b9cfb --- /dev/null +++ b/src/Doctrine/Common/Filter/OpenApiFilterTrait.php @@ -0,0 +1,28 @@ + + * + * 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\Common\Filter; + +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; + +/** + * @author Vincent Amstoutz + */ +trait OpenApiFilterTrait +{ + public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null + { + return new OpenApiParameter(name: $parameter->getKey().'[]', in: 'query', style: 'deepObject', explode: true); + } +} diff --git a/src/Doctrine/Odm/Extension/ParameterExtension.php b/src/Doctrine/Odm/Extension/ParameterExtension.php index 9420003e453..585ea9ec059 100644 --- a/src/Doctrine/Odm/Extension/ParameterExtension.php +++ b/src/Doctrine/Odm/Extension/ParameterExtension.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Doctrine\Odm\Extension; +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface; use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; use ApiPlatform\Doctrine\Common\ParameterValueExtractorTrait; use ApiPlatform\Doctrine\Odm\Filter\AbstractFilter; @@ -22,6 +23,7 @@ use Doctrine\Bundle\MongoDBBundle\ManagerRegistry; use Doctrine\ODM\MongoDB\Aggregation\Builder; use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; /** * Reads operation parameters and execute its filter. @@ -35,6 +37,7 @@ final class ParameterExtension implements AggregationCollectionExtensionInterfac public function __construct( private readonly ContainerInterface $filterLocator, private readonly ?ManagerRegistry $managerRegistry = null, + private readonly ?LoggerInterface $logger = null, ) { } @@ -67,6 +70,10 @@ private function applyFilter(Builder $aggregationBuilder, ?string $resourceClass $filter->setManagerRegistry($this->managerRegistry); } + if ($this->logger && $filter instanceof LoggerAwareInterface && !$filter->hasLogger()) { + $filter->setLogger($this->logger); + } + if ($filter instanceof AbstractFilter && !$filter->getProperties()) { $propertyKey = $parameter->getProperty() ?? $parameter->getKey(); @@ -82,12 +89,19 @@ private function applyFilter(Builder $aggregationBuilder, ?string $resourceClass $filter->setProperties($properties ?? []); } - $filterContext = ['filters' => $values, 'parameter' => $parameter]; + $filterContext = ['filters' => $values, 'parameter' => $parameter, 'match' => $context['match'] ?? null]; $filter->apply($aggregationBuilder, $resourceClass, $operation, $filterContext); // update by reference if (isset($filterContext['mongodb_odm_sort_fields'])) { $context['mongodb_odm_sort_fields'] = $filterContext['mongodb_odm_sort_fields']; } + if (isset($filterContext['match'])) { + $context['match'] = $filterContext['match']; + } + } + + if (isset($context['match'])) { + $aggregationBuilder->match()->addAnd($context['match']); } } diff --git a/src/Doctrine/Odm/Filter/ExactFilter.php b/src/Doctrine/Odm/Filter/ExactFilter.php new file mode 100644 index 00000000000..ccb883d86e6 --- /dev/null +++ b/src/Doctrine/Odm/Filter/ExactFilter.php @@ -0,0 +1,86 @@ + + * + * 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\Odm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ODM\MongoDB\Aggregation\Builder; +use Doctrine\ODM\MongoDB\DocumentManager; +use Doctrine\ODM\MongoDB\LockException; +use Doctrine\ODM\MongoDB\Mapping\MappingException; + +/** + * @author Vincent Amstoutz + */ +final class ExactFilter implements FilterInterface, OpenApiParameterFilterInterface, ManagerRegistryAwareInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use ManagerRegistryAwareTrait; + use OpenApiFilterTrait; + + /** + * @throws MappingException + * @throws LockException + */ + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + $parameter = $context['parameter']; + $property = $parameter->getProperty(); + $value = $parameter->getValue(); + $operator = $context['operator'] ?? 'addAnd'; + $match = $context['match'] = $context['match'] ?? + $aggregationBuilder + ->matchExpr(); + + $documentManager = $this->getManagerRegistry()->getManagerForClass($resourceClass); + if (!$documentManager instanceof DocumentManager) { + return; + } + + $classMetadata = $documentManager->getClassMetadata($resourceClass); + + if (!$classMetadata->hasReference($property)) { + $match + ->{$operator}($aggregationBuilder->matchExpr()->field($property)->{is_iterable($value) ? 'in' : 'equals'}($value)); + + return; + } + + $mapping = $classMetadata->getFieldMapping($property); + $method = $classMetadata->isSingleValuedAssociation($property) ? 'references' : 'includesReferenceTo'; + + if (is_iterable($value)) { + $or = $aggregationBuilder->matchExpr(); + + foreach ($value as $v) { + $or->addOr($aggregationBuilder->matchExpr()->field($property)->{$method}($documentManager->getPartialReference($mapping['targetDocument'], $v))); + } + + $match->{$operator}($or); + + return; + } + + $match + ->{$operator}( + $aggregationBuilder->matchExpr() + ->field($property) + ->{$method}($documentManager->getPartialReference($mapping['targetDocument'], $value)) + ); + } +} diff --git a/src/Doctrine/Odm/Filter/FilterInterface.php b/src/Doctrine/Odm/Filter/FilterInterface.php index 11e006abb8f..4395a25df55 100644 --- a/src/Doctrine/Odm/Filter/FilterInterface.php +++ b/src/Doctrine/Odm/Filter/FilterInterface.php @@ -15,6 +15,7 @@ use ApiPlatform\Metadata\FilterInterface as BaseFilterInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; use Doctrine\ODM\MongoDB\Aggregation\Builder; /** @@ -26,6 +27,8 @@ interface FilterInterface extends BaseFilterInterface { /** * Applies the filter. + * + * @param array|array{filters?: array|array, parameter?: Parameter, mongodb_odm_sort_fields?: array, ...} $context */ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void; } diff --git a/src/Doctrine/Odm/Filter/FreeTextQueryFilter.php b/src/Doctrine/Odm/Filter/FreeTextQueryFilter.php new file mode 100644 index 00000000000..2dde16d6ecc --- /dev/null +++ b/src/Doctrine/Odm/Filter/FreeTextQueryFilter.php @@ -0,0 +1,62 @@ + + * + * 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\Odm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Operation; +use Doctrine\ODM\MongoDB\Aggregation\Builder; + +final class FreeTextQueryFilter implements FilterInterface, ManagerRegistryAwareInterface, LoggerAwareInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use LoggerAwareTrait; + use ManagerRegistryAwareTrait; + + /** + * @param list $properties an array of properties, defaults to `parameter->getProperties()` + */ + public function __construct(private readonly FilterInterface $filter, private readonly ?array $properties = null) + { + } + + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + if ($this->filter instanceof ManagerRegistryAwareInterface) { + $this->filter->setManagerRegistry($this->getManagerRegistry()); + } + + if ($this->filter instanceof LoggerAwareInterface) { + $this->filter->setLogger($this->getLogger()); + } + + $parameter = $context['parameter']; + foreach ($this->properties ?? $parameter->getProperties() ?? [] as $property) { + $newContext = ['parameter' => $parameter->withProperty($property), 'match' => $context['match'] ?? $aggregationBuilder->match()->expr()] + $context; + $this->filter->apply( + $aggregationBuilder, + $resourceClass, + $operation, + $newContext, + ); + + if (isset($newContext['match'])) { + $context['match'] = $newContext['match']; + } + } + } +} diff --git a/src/Doctrine/Odm/Filter/IriFilter.php b/src/Doctrine/Odm/Filter/IriFilter.php new file mode 100644 index 00000000000..4f0d742dc1b --- /dev/null +++ b/src/Doctrine/Odm/Filter/IriFilter.php @@ -0,0 +1,87 @@ + + * + * 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\Odm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\ParameterProviderFilterInterface; +use ApiPlatform\State\ParameterProvider\IriConverterParameterProvider; +use Doctrine\ODM\MongoDB\Aggregation\Builder; +use Doctrine\ODM\MongoDB\DocumentManager; +use Doctrine\ODM\MongoDB\Mapping\MappingException; + +/** + * @author Vincent Amstoutz + */ +final class IriFilter implements FilterInterface, OpenApiParameterFilterInterface, ParameterProviderFilterInterface, ManagerRegistryAwareInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use ManagerRegistryAwareTrait; + use OpenApiFilterTrait; + + /** + * @throws MappingException + */ + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + $parameter = $context['parameter']; + $value = $parameter->getValue(); + $operator = $context['operator'] ?? 'addAnd'; + $match = $context['match'] = $context['match'] ?? + $aggregationBuilder + ->matchExpr(); + + $documentManager = $this->getManagerRegistry()->getManagerForClass($resourceClass); + if (!$documentManager instanceof DocumentManager) { + return; + } + + $classMetadata = $documentManager->getClassMetadata($resourceClass); + $property = $parameter->getProperty(); + if (!$classMetadata->hasReference($property)) { + return; + } + + $method = $classMetadata->isSingleValuedAssociation($property) ? 'references' : 'includesReferenceTo'; + + if (is_iterable($value)) { + $or = $aggregationBuilder->matchExpr(); + + foreach ($value as $v) { + $or->addOr($aggregationBuilder->matchExpr()->field($property)->{$method}($v)); + } + + $match->{$operator}($or); + + return; + } + + $match + ->{$operator}( + $aggregationBuilder + ->matchExpr() + ->field($property) + ->{$method}($value) + ); + } + + public static function getParameterProvider(): string + { + return IriConverterParameterProvider::class; + } +} diff --git a/src/Doctrine/Odm/Filter/OrFilter.php b/src/Doctrine/Odm/Filter/OrFilter.php new file mode 100644 index 00000000000..9017bceba13 --- /dev/null +++ b/src/Doctrine/Odm/Filter/OrFilter.php @@ -0,0 +1,56 @@ + + * + * 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\Odm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ODM\MongoDB\Aggregation\Builder; + +/** + * @author Vincent Amstoutz + */ +final class OrFilter implements FilterInterface, OpenApiParameterFilterInterface, ManagerRegistryAwareInterface, LoggerAwareInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use LoggerAwareTrait; + use ManagerRegistryAwareTrait; + use OpenApiFilterTrait; + + public function __construct(private readonly FilterInterface $filter) + { + } + + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + if ($this->filter instanceof ManagerRegistryAwareInterface) { + $this->filter->setManagerRegistry($this->getManagerRegistry()); + } + + if ($this->filter instanceof LoggerAwareInterface) { + $this->filter->setLogger($this->getLogger()); + } + + $newContext = ['operator' => 'addOr'] + $context; + $this->filter->apply($aggregationBuilder, $resourceClass, $operation, $newContext); + if (isset($newContext['match'])) { + $context['match'] = $newContext['match']; + } + } +} diff --git a/src/Doctrine/Odm/Filter/PartialSearchFilter.php b/src/Doctrine/Odm/Filter/PartialSearchFilter.php new file mode 100644 index 00000000000..f5fd2f1bb32 --- /dev/null +++ b/src/Doctrine/Odm/Filter/PartialSearchFilter.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\Doctrine\Odm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ODM\MongoDB\Aggregation\Builder; +use MongoDB\BSON\Regex; + +/** + * @author Vincent Amstoutz + */ +final class PartialSearchFilter implements FilterInterface, OpenApiParameterFilterInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use OpenApiFilterTrait; + + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + $parameter = $context['parameter']; + $property = $parameter->getProperty(); + $values = $parameter->getValue(); + $match = $context['match'] = $context['match'] ?? + $aggregationBuilder + ->matchExpr(); + $operator = $context['operator'] ?? 'addAnd'; + + if (!is_iterable($values)) { + $escapedValue = preg_quote($values, '/'); + $match->{$operator}( + $aggregationBuilder->matchExpr()->field($property)->equals(new Regex($escapedValue, 'i')) + ); + + return; + } + + $or = $aggregationBuilder->matchExpr(); + foreach ($values as $value) { + $escapedValue = preg_quote($value, '/'); + + $or->addOr( + $aggregationBuilder->matchExpr() + ->field($property) + ->equals(new Regex($escapedValue, 'i')) + ); + } + + $match->{$operator}($or); + } +} diff --git a/src/Doctrine/Orm/Extension/ParameterExtension.php b/src/Doctrine/Orm/Extension/ParameterExtension.php index 17d02352fd4..70533ad584d 100644 --- a/src/Doctrine/Orm/Extension/ParameterExtension.php +++ b/src/Doctrine/Orm/Extension/ParameterExtension.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Doctrine\Orm\Extension; +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareInterface; use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; use ApiPlatform\Doctrine\Common\ParameterValueExtractorTrait; use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter; @@ -23,6 +24,7 @@ use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; /** * Reads operation parameters and execute its filter. @@ -36,6 +38,7 @@ final class ParameterExtension implements QueryCollectionExtensionInterface, Que public function __construct( private readonly ContainerInterface $filterLocator, private readonly ?ManagerRegistry $managerRegistry = null, + private readonly ?LoggerInterface $logger = null, ) { } @@ -68,6 +71,10 @@ private function applyFilter(QueryBuilder $queryBuilder, QueryNameGeneratorInter $filter->setManagerRegistry($this->managerRegistry); } + if ($this->logger && $filter instanceof LoggerAwareInterface && !$filter->hasLogger()) { + $filter->setLogger($this->logger); + } + if ($filter instanceof AbstractFilter && !$filter->getProperties()) { $propertyKey = $parameter->getProperty() ?? $parameter->getKey(); diff --git a/src/Doctrine/Orm/Filter/ExactFilter.php b/src/Doctrine/Orm/Filter/ExactFilter.php new file mode 100644 index 00000000000..37956151713 --- /dev/null +++ b/src/Doctrine/Orm/Filter/ExactFilter.php @@ -0,0 +1,49 @@ + + * + * 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\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; + +/** + * @author Vincent Amstoutz + */ +final class ExactFilter implements FilterInterface, OpenApiParameterFilterInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use OpenApiFilterTrait; + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $parameter = $context['parameter']; + $value = $parameter->getValue(); + $property = $parameter->getProperty(); + $alias = $queryBuilder->getRootAliases()[0]; + $parameterName = $queryNameGenerator->generateParameterName($property); + + if (\is_array($value)) { + $queryBuilder + ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s.%s IN (:%s)', $alias, $property, $parameterName)); + } else { + $queryBuilder + ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s.%s = :%s', $alias, $property, $parameterName)); + } + + $queryBuilder->setParameter($parameterName, $value); + } +} diff --git a/src/Doctrine/Orm/Filter/FilterInterface.php b/src/Doctrine/Orm/Filter/FilterInterface.php index 4cfa337dfb6..7539574ff37 100644 --- a/src/Doctrine/Orm/Filter/FilterInterface.php +++ b/src/Doctrine/Orm/Filter/FilterInterface.php @@ -16,6 +16,7 @@ use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\FilterInterface as BaseFilterInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; use Doctrine\ORM\QueryBuilder; /** @@ -27,6 +28,8 @@ interface FilterInterface extends BaseFilterInterface { /** * Applies the filter. + * + * @param array{filters?: array|array, parameter?: Parameter, ...} $context */ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void; } diff --git a/src/Doctrine/Orm/Filter/FreeTextQueryFilter.php b/src/Doctrine/Orm/Filter/FreeTextQueryFilter.php new file mode 100644 index 00000000000..c73504c597a --- /dev/null +++ b/src/Doctrine/Orm/Filter/FreeTextQueryFilter.php @@ -0,0 +1,59 @@ + + * + * 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\Common\Filter\LoggerAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; + +final class FreeTextQueryFilter implements FilterInterface, ManagerRegistryAwareInterface, LoggerAwareInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use LoggerAwareTrait; + use ManagerRegistryAwareTrait; + + /** + * @param list $properties an array of properties, defaults to `parameter->getProperties()` + */ + public function __construct(private readonly FilterInterface $filter, private readonly ?array $properties = null) + { + } + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + if ($this->filter instanceof ManagerRegistryAwareInterface) { + $this->filter->setManagerRegistry($this->getManagerRegistry()); + } + + if ($this->filter instanceof LoggerAwareInterface) { + $this->filter->setLogger($this->getLogger()); + } + + $parameter = $context['parameter']; + foreach ($this->properties ?? $parameter->getProperties() ?? [] as $property) { + $this->filter->apply( + $queryBuilder, + $queryNameGenerator, + $resourceClass, + $operation, + ['parameter' => $parameter->withProperty($property)] + $context + ); + } + } +} diff --git a/src/Doctrine/Orm/Filter/IriFilter.php b/src/Doctrine/Orm/Filter/IriFilter.php new file mode 100644 index 00000000000..32acf8f59f1 --- /dev/null +++ b/src/Doctrine/Orm/Filter/IriFilter.php @@ -0,0 +1,58 @@ + + * + * 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\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\ParameterProviderFilterInterface; +use ApiPlatform\State\ParameterProvider\IriConverterParameterProvider; +use Doctrine\ORM\QueryBuilder; + +/** + * @author Vincent Amstoutz + */ +final class IriFilter implements FilterInterface, OpenApiParameterFilterInterface, ParameterProviderFilterInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use OpenApiFilterTrait; + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $parameter = $context['parameter']; + $value = $parameter->getValue(); + $property = $parameter->getProperty(); + $alias = $queryBuilder->getRootAliases()[0]; + $parameterName = $queryNameGenerator->generateParameterName($property); + + $queryBuilder->join(\sprintf('%s.%s', $alias, $property), $parameterName); + + if (is_iterable($value)) { + $queryBuilder + ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s IN (:%s)', $parameterName, $parameterName)); + $queryBuilder->setParameter($parameterName, $value); + } else { + $queryBuilder + ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s = :%s', $parameterName, $parameterName)); + $queryBuilder->setParameter($parameterName, $value); + } + } + + public static function getParameterProvider(): string + { + return IriConverterParameterProvider::class; + } +} diff --git a/src/Doctrine/Orm/Filter/OrFilter.php b/src/Doctrine/Orm/Filter/OrFilter.php new file mode 100644 index 00000000000..d8e020221a7 --- /dev/null +++ b/src/Doctrine/Orm/Filter/OrFilter.php @@ -0,0 +1,61 @@ + + * + * 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\Common\Filter\LoggerAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\LoggerAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; + +/** + * @author Vincent Amstoutz + * + * @experimental + */ +final class OrFilter implements FilterInterface, OpenApiParameterFilterInterface, ManagerRegistryAwareInterface, LoggerAwareInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use LoggerAwareTrait; + use ManagerRegistryAwareTrait; + use OpenApiFilterTrait; + + public function __construct(private readonly FilterInterface $filter) + { + } + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + if ($this->filter instanceof ManagerRegistryAwareInterface) { + $this->filter->setManagerRegistry($this->getManagerRegistry()); + } + + if ($this->filter instanceof LoggerAwareInterface) { + $this->filter->setLogger($this->getLogger()); + } + + $this->filter->apply( + $queryBuilder, + $queryNameGenerator, + $resourceClass, + $operation, + ['whereClause' => 'orWhere'] + $context + ); + } +} diff --git a/src/Doctrine/Orm/Filter/PartialSearchFilter.php b/src/Doctrine/Orm/Filter/PartialSearchFilter.php new file mode 100644 index 00000000000..90cde75c3fa --- /dev/null +++ b/src/Doctrine/Orm/Filter/PartialSearchFilter.php @@ -0,0 +1,65 @@ + + * + * 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\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; + +/** + * @author Vincent Amstoutz + */ +final class PartialSearchFilter implements FilterInterface, OpenApiParameterFilterInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use OpenApiFilterTrait; + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $parameter = $context['parameter']; + $property = $parameter->getProperty(); + $alias = $queryBuilder->getRootAliases()[0]; + $field = $alias.'.'.$property; + $parameterName = $queryNameGenerator->generateParameterName($property); + $values = $parameter->getValue(); + + if (!is_iterable($values)) { + $queryBuilder->setParameter($parameterName, '%'.strtolower($values).'%'); + + $queryBuilder->{$context['whereClause'] ?? 'andWhere'}($queryBuilder->expr()->like( + 'LOWER('.$field.')', + ':'.$parameterName + )); + + return; + } + + $likeExpressions = []; + foreach ($values as $val) { + $parameterName = $queryNameGenerator->generateParameterName($property); + $likeExpressions[] = $queryBuilder->expr()->like( + 'LOWER('.$field.')', + ':'.$parameterName + ); + $queryBuilder->setParameter($parameterName, '%'.strtolower($val).'%'); + } + + $queryBuilder->{$context['whereClause'] ?? 'andWhere'}( + $queryBuilder->expr()->orX(...$likeExpressions) + ); + } +} diff --git a/src/Metadata/BackwardCompatibleFilterDescriptionTrait.php b/src/Metadata/BackwardCompatibleFilterDescriptionTrait.php new file mode 100644 index 00000000000..ace2d185f39 --- /dev/null +++ b/src/Metadata/BackwardCompatibleFilterDescriptionTrait.php @@ -0,0 +1,27 @@ + + * + * 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; + +/** + * @author Vincent Amstoutz + * + * @internal + */ +trait BackwardCompatibleFilterDescriptionTrait +{ + public function getDescription(string $resourceClass): array + { + return []; + } +} diff --git a/src/Metadata/FilterInterface.php b/src/Metadata/FilterInterface.php index 509ef4ed8eb..72a8db12f97 100644 --- a/src/Metadata/FilterInterface.php +++ b/src/Metadata/FilterInterface.php @@ -50,6 +50,8 @@ interface FilterInterface * @param class-string $resourceClass * * @return array}> + * + * @deprecated in 4.2, to be removed in 5.0 */ public function getDescription(string $resourceClass): array; } diff --git a/src/State/ParameterProvider/IriConverterParameterProvider.php b/src/State/ParameterProvider/IriConverterParameterProvider.php index 2f817f8e7ab..3d28f5be729 100644 --- a/src/State/ParameterProvider/IriConverterParameterProvider.php +++ b/src/State/ParameterProvider/IriConverterParameterProvider.php @@ -13,21 +13,25 @@ namespace ApiPlatform\State\ParameterProvider; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Exception\ItemNotFoundException; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Parameter; use ApiPlatform\State\ParameterNotFound; use ApiPlatform\State\ParameterProviderInterface; +use Psr\Log\LoggerInterface; /** * @experimental * - * @author Vincent Amstoutz + * @author Vincent Amstoutz */ final readonly class IriConverterParameterProvider implements ParameterProviderInterface { public function __construct( private IriConverterInterface $iriConverter, + private LoggerInterface $logger, ) { } @@ -38,12 +42,28 @@ public function provide(Parameter $parameter, array $parameters = [], array $con return $operation; } - $iriConverterContext = ['fetch_data' => $parameter->getExtraProperties()['fetch_data'] ?? false]; + $extraProperties = $parameter->getExtraProperties(); + $iriConverterContext = ['fetch_data' => $extraProperties['fetch_data'] ?? false]; if (\is_array($value)) { $entities = []; foreach ($value as $v) { - $entities[] = $this->iriConverter->getResourceFromIri($v, $iriConverterContext); + try { + $entities[] = $this->iriConverter->getResourceFromIri($v, $iriConverterContext); + } catch (InvalidArgumentException|ItemNotFoundException $exception) { + if ($exception instanceof ItemNotFoundException && true === ($extraProperties['throw_not_found'] ?? false)) { + throw $exception; + } + + $this->logger->error( + message: 'Operation failed due to an invalid argument or a missing item', + context: [ + 'exception' => $exception->getMessage(), + ] + ); + + break; + } } $parameter->setValue($entities); @@ -51,7 +71,20 @@ public function provide(Parameter $parameter, array $parameters = [], array $con return $operation; } - $parameter->setValue($this->iriConverter->getResourceFromIri($value, $iriConverterContext)); + try { + $parameter->setValue($this->iriConverter->getResourceFromIri($value, $iriConverterContext)); + } catch (InvalidArgumentException|ItemNotFoundException $exception) { + if ($exception instanceof ItemNotFoundException && true === ($extraProperties['throw_not_found'] ?? false)) { + throw $exception; + } + + $this->logger->error( + message: 'Operation failed due to an invalid argument or a missing item', + context: [ + 'exception' => $exception->getMessage(), + ] + ); + } return $operation; } diff --git a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml index 8b39e43a9c8..449d61f172f 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml +++ b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml @@ -138,6 +138,7 @@ + diff --git a/src/Symfony/Bundle/Resources/config/doctrine_orm.xml b/src/Symfony/Bundle/Resources/config/doctrine_orm.xml index 7f34ecacc27..0e0e9e3d5c5 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_orm.xml +++ b/src/Symfony/Bundle/Resources/config/doctrine_orm.xml @@ -151,6 +151,7 @@ + diff --git a/src/Symfony/Bundle/Resources/config/state/parameter_provider.xml b/src/Symfony/Bundle/Resources/config/state/parameter_provider.xml index d92a1a9aba5..bb5c12e204b 100644 --- a/src/Symfony/Bundle/Resources/config/state/parameter_provider.xml +++ b/src/Symfony/Bundle/Resources/config/state/parameter_provider.xml @@ -6,6 +6,7 @@ + diff --git a/tests/Fixtures/TestBundle/Document/Chicken.php b/tests/Fixtures/TestBundle/Document/Chicken.php new file mode 100644 index 00000000000..2fe846ecae3 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/Chicken.php @@ -0,0 +1,94 @@ + + * + * 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\Document; + +use ApiPlatform\Doctrine\Odm\Filter\ExactFilter; +use ApiPlatform\Doctrine\Odm\Filter\FreeTextQueryFilter; +use ApiPlatform\Doctrine\Odm\Filter\IriFilter; +use ApiPlatform\Doctrine\Odm\Filter\OrFilter; +use ApiPlatform\Doctrine\Odm\Filter\PartialSearchFilter; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ODM\Document] +#[GetCollection( + normalizationContext: ['hydra_prefix' => false], + parameters: [ + 'chickenCoop' => new QueryParameter(filter: new IriFilter()), + 'chickenCoopId' => new QueryParameter(filter: new ExactFilter(), property: 'chickenCoop'), + 'name' => new QueryParameter(filter: new ExactFilter()), + 'namePartial' => new QueryParameter( + filter: new PartialSearchFilter(), + property: 'name', + ), + 'autocomplete' => new QueryParameter(filter: new FreeTextQueryFilter(new OrFilter(new ExactFilter())), properties: ['name', 'ean']), + 'q' => new QueryParameter(filter: new FreeTextQueryFilter(new PartialSearchFilter()), properties: ['name', 'ean']), + ], +)] +class Chicken +{ + #[ODM\Id(type: 'string', strategy: 'INCREMENT')] + private ?string $id = null; + + #[ODM\Field(type: 'string')] + private string $name; + + #[ODM\Field(type: 'string', nullable: true)] + private ?string $ean; + + #[ODM\ReferenceOne(targetDocument: ChickenCoop::class, inversedBy: 'chickens')] + private ?ChickenCoop $chickenCoop = null; + + public function getId(): ?string + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getEan(): ?string + { + return $this->ean; + } + + public function setEan(string $ean): self + { + $this->ean = $ean; + + return $this; + } + + public function getChickenCoop(): ?ChickenCoop + { + return $this->chickenCoop; + } + + public function setChickenCoop(?ChickenCoop $chickenCoop): self + { + $this->chickenCoop = $chickenCoop; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Document/ChickenCoop.php b/tests/Fixtures/TestBundle/Document/ChickenCoop.php new file mode 100644 index 00000000000..df1a0a8a7fa --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/ChickenCoop.php @@ -0,0 +1,71 @@ + + * + * 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\Document; + +use ApiPlatform\Metadata\GetCollection; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ODM\Document] +#[GetCollection( + normalizationContext: ['hydra_prefix' => false] +)] +class ChickenCoop +{ + #[ODM\Id(type: 'int', strategy: 'INCREMENT')] + private ?int $id = null; + + #[ODM\ReferenceMany(targetDocument: Chicken::class, mappedBy: 'chickenCoop')] + private Collection $chickens; + + public function __construct() + { + $this->chickens = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + /** + * @return Collection + */ + public function getChickens(): Collection + { + return $this->chickens; + } + + public function addChicken(Chicken $chicken): self + { + if (!$this->chickens->contains($chicken)) { + $this->chickens[] = $chicken; + $chicken->setChickenCoop($this); + } + + return $this; + } + + public function removeChicken(Chicken $chicken): self + { + if ($this->chickens->removeElement($chicken)) { + if ($chicken->getChickenCoop() === $this) { + $chicken->setChickenCoop(null); + } + } + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Document/Company.php b/tests/Fixtures/TestBundle/Document/Company.php index ca88faa6e44..ca4d697201f 100644 --- a/tests/Fixtures/TestBundle/Document/Company.php +++ b/tests/Fixtures/TestBundle/Document/Company.php @@ -25,9 +25,15 @@ #[GetCollection] #[Get] #[Post] -#[ApiResource(uriTemplate: '/employees/{employeeId}/rooms/{roomId}/company/{companyId}', uriVariables: ['employeeId' => ['from_class' => Employee::class, 'from_property' => 'company']])] +#[ApiResource( + uriTemplate: '/employees/{employeeId}/rooms/{roomId}/company/{companyId}', + uriVariables: ['employeeId' => ['from_class' => Employee::class, 'from_property' => 'company']] +)] #[Get] -#[ApiResource(uriTemplate: '/employees/{employeeId}/company', uriVariables: ['employeeId' => ['from_class' => Employee::class, 'from_property' => 'company']])] +#[ApiResource( + uriTemplate: '/employees/{employeeId}/company', + uriVariables: ['employeeId' => ['from_class' => Employee::class, 'from_property' => 'company']] +)] #[ODM\Document] class Company { diff --git a/tests/Fixtures/TestBundle/Entity/Chicken.php b/tests/Fixtures/TestBundle/Entity/Chicken.php new file mode 100644 index 00000000000..f3533f59801 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Chicken.php @@ -0,0 +1,97 @@ + + * + * 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\Doctrine\Orm\Filter\ExactFilter; +use ApiPlatform\Doctrine\Orm\Filter\FreeTextQueryFilter; +use ApiPlatform\Doctrine\Orm\Filter\IriFilter; +use ApiPlatform\Doctrine\Orm\Filter\OrFilter; +use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +#[GetCollection( + normalizationContext: ['hydra_prefix' => false], + parameters: [ + 'chickenCoop' => new QueryParameter(filter: new IriFilter()), + 'chickenCoopId' => new QueryParameter(filter: new ExactFilter(), property: 'chickenCoop'), + 'name' => new QueryParameter(filter: new ExactFilter()), + 'namePartial' => new QueryParameter( + filter: new PartialSearchFilter(), + property: 'name', + ), + 'autocomplete' => new QueryParameter(filter: new FreeTextQueryFilter(new OrFilter(new ExactFilter())), properties: ['name', 'ean']), + 'q' => new QueryParameter(filter: new FreeTextQueryFilter(new PartialSearchFilter()), properties: ['name', 'ean']), + ], +)] +class Chicken +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'string', length: 255)] + private string $name; + + #[ORM\Column(type: 'string', length: 255, nullable: true)] + private ?string $ean; + + #[ORM\ManyToOne(targetEntity: ChickenCoop::class, inversedBy: 'chickens')] + #[ORM\JoinColumn(nullable: false)] + private ChickenCoop $chickenCoop; + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getEan(): ?string + { + return $this->ean; + } + + public function setEan(string $ean): self + { + $this->ean = $ean; + + return $this; + } + + public function getChickenCoop(): ?ChickenCoop + { + return $this->chickenCoop; + } + + public function setChickenCoop(?ChickenCoop $chickenCoop): self + { + $this->chickenCoop = $chickenCoop; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/ChickenCoop.php b/tests/Fixtures/TestBundle/Entity/ChickenCoop.php new file mode 100644 index 00000000000..49b52c98b18 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/ChickenCoop.php @@ -0,0 +1,73 @@ + + * + * 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\GetCollection; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +#[GetCollection( + normalizationContext: ['hydra_prefix' => false], +)] +class ChickenCoop +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\OneToMany(targetEntity: Chicken::class, mappedBy: 'chickenCoop', cascade: ['persist'])] + private Collection $chickens; + + public function __construct() + { + $this->chickens = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + /** + * @return Collection + */ + public function getChickens(): Collection + { + return $this->chickens; + } + + public function addChicken(Chicken $chicken): self + { + if (!$this->chickens->contains($chicken)) { + $this->chickens[] = $chicken; + $chicken->setChickenCoop($this); + } + + return $this; + } + + public function removeChicken(Chicken $chicken): self + { + if ($this->chickens->removeElement($chicken)) { + if ($chicken->getChickenCoop() === $this) { + $chicken->setChickenCoop(null); + } + } + + return $this; + } +} diff --git a/tests/Functional/NotSkipNullToOneRelationTest.php b/tests/Functional/NotSkipNullToOneRelationTest.php index 9a0b0db26ef..c6943383d0c 100644 --- a/tests/Functional/NotSkipNullToOneRelationTest.php +++ b/tests/Functional/NotSkipNullToOneRelationTest.php @@ -17,11 +17,6 @@ use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue4372\RelatedEntity; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue4372\ToOneRelationPropertyMayBeNull; use ApiPlatform\Tests\SetupClassResourcesTrait; -use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; class NotSkipNullToOneRelationTest extends ApiTestCase { @@ -37,14 +32,6 @@ public static function getResources(): array return [ToOneRelationPropertyMayBeNull::class, RelatedEntity::class]; } - /** - * @throws TransportExceptionInterface - * @throws ServerExceptionInterface - * @throws RedirectionExceptionInterface - * @throws DecodingExceptionInterface - * @throws ClientExceptionInterface - * @throws \JsonException - */ public function testNullRelationsAreNotSkippedWhenConfigured(): void { if ($this->isMongoDB()) { @@ -114,14 +101,6 @@ public function testNullRelationsAreNotSkippedWhenConfigured(): void ); } - /** - * @throws TransportExceptionInterface - * @throws ServerExceptionInterface - * @throws RedirectionExceptionInterface - * @throws DecodingExceptionInterface - * @throws ClientExceptionInterface - * @throws \JsonException - */ public function testNullRelationsAreSkippedByDefault(): void { if ($this->isMongoDB()) { @@ -189,13 +168,6 @@ public function testNullRelationsAreSkippedByDefault(): void ); } - /** - * @throws ClientExceptionInterface - * @throws DecodingExceptionInterface - * @throws RedirectionExceptionInterface - * @throws ServerExceptionInterface - * @throws TransportExceptionInterface - */ private function checkRoutesAreCorrectlySetUp(): void { self::createClient()->request( diff --git a/tests/Functional/Parameters/BooleanFilterTest.php b/tests/Functional/Parameters/BooleanFilterTest.php index 7c7c02b8f6f..6a2a9e553a2 100644 --- a/tests/Functional/Parameters/BooleanFilterTest.php +++ b/tests/Functional/Parameters/BooleanFilterTest.php @@ -20,11 +20,6 @@ use ApiPlatform\Tests\SetupClassResourcesTrait; use Doctrine\ODM\MongoDB\MongoDBException; use PHPUnit\Framework\Attributes\DataProvider; -use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; final class BooleanFilterTest extends ApiTestCase { @@ -53,13 +48,6 @@ protected function setUp(): void $this->loadFixtures($entityClass); } - /** - * @throws ServerExceptionInterface - * @throws RedirectionExceptionInterface - * @throws DecodingExceptionInterface - * @throws ClientExceptionInterface - * @throws TransportExceptionInterface - */ #[DataProvider('booleanFilterScenariosProvider')] public function testBooleanFilterResponses(string $url, int $expectedActiveItemCount, bool $expectedActiveStatus): void { @@ -88,13 +76,6 @@ public static function booleanFilterScenariosProvider(): \Generator yield 'enabled_alias_numeric_0' => ['/filtered_boolean_parameters?enabled=0', 1, false]; } - /** - * @throws RedirectionExceptionInterface - * @throws DecodingExceptionInterface - * @throws ClientExceptionInterface - * @throws TransportExceptionInterface - * @throws ServerExceptionInterface - */ #[DataProvider('booleanFilterNullAndEmptyScenariosProvider')] public function testBooleanFilterWithNullAndEmptyValues(string $url): void { diff --git a/tests/Functional/Parameters/DateFilterTest.php b/tests/Functional/Parameters/DateFilterTest.php index 2187e758a87..77174da8c58 100644 --- a/tests/Functional/Parameters/DateFilterTest.php +++ b/tests/Functional/Parameters/DateFilterTest.php @@ -20,11 +20,6 @@ use ApiPlatform\Tests\SetupClassResourcesTrait; use Doctrine\ODM\MongoDB\MongoDBException; use PHPUnit\Framework\Attributes\DataProvider; -use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; final class DateFilterTest extends ApiTestCase { @@ -52,13 +47,6 @@ protected function setUp(): void $this->loadFixtures($entityClass); } - /** - * @throws TransportExceptionInterface - * @throws ServerExceptionInterface - * @throws RedirectionExceptionInterface - * @throws DecodingExceptionInterface - * @throws ClientExceptionInterface - */ #[DataProvider('dateFilterScenariosProvider')] public function testDateFilterResponses(string $url, int $expectedCount): void { @@ -90,13 +78,6 @@ public static function dateFilterScenariosProvider(): \Generator yield 'date_alias_old_way_after_last_one' => ['/filtered_date_parameters?date_old_way[after]=2024-12-31', 1]; } - /** - * @throws RedirectionExceptionInterface - * @throws DecodingExceptionInterface - * @throws ClientExceptionInterface - * @throws TransportExceptionInterface - * @throws ServerExceptionInterface - */ #[DataProvider('dateFilterNullAndEmptyScenariosProvider')] public function testDateFilterWithNullAndEmptyValues(string $url, int $expectedCount): void { diff --git a/tests/Functional/Parameters/ExactFilterTest.php b/tests/Functional/Parameters/ExactFilterTest.php new file mode 100644 index 00000000000..61e16f9bddd --- /dev/null +++ b/tests/Functional/Parameters/ExactFilterTest.php @@ -0,0 +1,140 @@ + + * + * 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\Parameters; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Chicken as DocumentChicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ChickenCoop as DocumentChickenCoop; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Chicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ChickenCoop; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\MongoDBException; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * @author Vincent Amstoutz + */ +final class ExactFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Chicken::class, ChickenCoop::class]; + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + $entities = $this->isMongoDB() + ? [DocumentChicken::class, DocumentChickenCoop::class] + : [Chicken::class, ChickenCoop::class]; + + $this->recreateSchema($entities); + $this->loadFixtures(); + } + + #[DataProvider('exactSearchFilterProvider')] + public function testExactSearchFilter(string $url, int $expectedCount, array $expectedNames): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['member']; + + $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + + $names = array_map(fn ($chicken) => $chicken['name'], $filteredItems); + sort($names); + sort($expectedNames); + + $this->assertSame($expectedNames, $names, 'The names do not match the expected values.'); + } + + public static function exactSearchFilterProvider(): \Generator + { + yield 'filter by exact name "Gertrude"' => [ + '/chickens?name=Gertrude', + 1, + ['Gertrude'], + ]; + + yield 'filter by a non-existent name' => [ + '/chickens?name=Kevin', + 0, + [], + ]; + + yield 'filter by exact coop id' => [ + '/chickens?chickenCoopId=1', + 1, + ['Gertrude'], + ]; + + yield 'filter by coop id and correct name' => [ + '/chickens?chickenCoopId=1&name=Gertrude', + 1, + ['Gertrude'], + ]; + + yield 'filter by coop id and incorrect name' => [ + '/chickens?chickenCoopId=1&name=Henriette', + 0, + [], + ]; + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(): void + { + $manager = $this->getManager(); + + $chickenClass = $this->isMongoDB() ? DocumentChicken::class : Chicken::class; + $coopClass = $this->isMongoDB() ? DocumentChickenCoop::class : ChickenCoop::class; + + $chickenCoop1 = new $coopClass(); + $chickenCoop2 = new $coopClass(); + + $chicken1 = new $chickenClass(); + $chicken1->setName('Gertrude'); + $chicken1->setChickenCoop($chickenCoop1); + + $chicken2 = new $chickenClass(); + $chicken2->setName('Henriette'); + $chicken2->setChickenCoop($chickenCoop2); + + $chickenCoop1->addChicken($chicken1); + $chickenCoop2->addChicken($chicken2); + + $manager->persist($chickenCoop1); + $manager->persist($chickenCoop2); + $manager->persist($chicken1); + $manager->persist($chicken2); + + $manager->flush(); + } +} diff --git a/tests/Functional/Parameters/ExistsFilterTest.php b/tests/Functional/Parameters/ExistsFilterTest.php index b6e68b7522a..122854154bf 100644 --- a/tests/Functional/Parameters/ExistsFilterTest.php +++ b/tests/Functional/Parameters/ExistsFilterTest.php @@ -20,12 +20,10 @@ use ApiPlatform\Tests\SetupClassResourcesTrait; use Doctrine\ODM\MongoDB\MongoDBException; use PHPUnit\Framework\Attributes\DataProvider; -use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +/** + * @author Vincent Amstoutz + */ final class ExistsFilterTest extends ApiTestCase { use RecreateSchemaTrait; @@ -52,13 +50,6 @@ protected function setUp(): void $this->loadFixtures($entityClass); } - /** - * @throws TransportExceptionInterface - * @throws ServerExceptionInterface - * @throws RedirectionExceptionInterface - * @throws DecodingExceptionInterface - * @throws ClientExceptionInterface - */ #[DataProvider('existsFilterScenariosProvider')] public function testExistsFilterResponses(string $url, int $expectedCount): void { diff --git a/tests/Functional/Parameters/FreeTextQueryFilterTest.php b/tests/Functional/Parameters/FreeTextQueryFilterTest.php new file mode 100644 index 00000000000..55dbfb0b8b4 --- /dev/null +++ b/tests/Functional/Parameters/FreeTextQueryFilterTest.php @@ -0,0 +1,88 @@ + + * + * 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\Parameters; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Chicken as DocumentChicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ChickenCoop as DocumentChickenCoop; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Chicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ChickenCoop; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\MongoDBException; + +final class FreeTextQueryFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ChickenCoop::class, Chicken::class]; + } + + public function testFreeTextQueryFilter(): void + { + $client = $this->createClient(); + $client->request('GET', '/chickens?q=9780')->toArray(); + $this->assertJsonContains(['totalItems' => 1]); + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + $this->recreateSchema([$this->isMongoDB() ? DocumentChicken::class : Chicken::class, $this->isMongoDB() ? DocumentChickenCoop::class : ChickenCoop::class]); + $this->loadFixtures(); + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(): void + { + $manager = $this->getManager(); + $chickenClass = $this->isMongoDB() ? DocumentChicken::class : Chicken::class; + $coopClass = $this->isMongoDB() ? DocumentChickenCoop::class : ChickenCoop::class; + + $chickenCoop1 = new $coopClass(); + $chickenCoop2 = new $coopClass(); + + $chicken1 = new $chickenClass(); + $chicken1->setName('978020137962'); + $chicken1->setEan('978020137962'); + $chicken1->setChickenCoop($chickenCoop1); + + $chicken2 = new $chickenClass(); + $chicken2->setName('Henriette'); + $chicken2->setEan('978020137963'); + $chicken2->setChickenCoop($chickenCoop2); + + $chickenCoop1->addChicken($chicken1); + $chickenCoop2->addChicken($chicken2); + + $manager->persist($chicken1); + $manager->persist($chicken2); + $manager->persist($chickenCoop1); + $manager->persist($chickenCoop2); + $manager->flush(); + } +} diff --git a/tests/Functional/Parameters/IriFilterTest.php b/tests/Functional/Parameters/IriFilterTest.php new file mode 100644 index 00000000000..8cd6ead2074 --- /dev/null +++ b/tests/Functional/Parameters/IriFilterTest.php @@ -0,0 +1,92 @@ + + * + * 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\Parameters; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Chicken as DocumentChicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ChickenCoop as DocumentChickenCoop; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Chicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ChickenCoop; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\MongoDBException; + +final class IriFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ChickenCoop::class, Chicken::class]; + } + + public function testIriFilter(): void + { + $client = $this->createClient(); + $res = $client->request('GET', '/chickens?chickenCoop=/chicken_coops/2')->toArray(); + $this->assertCount(1, $res['member']); + $this->assertEquals('/chicken_coops/2', $res['member'][0]['chickenCoop']); + } + + public function testIriFilterMultiple(): void + { + $client = $this->createClient(); + $res = $client->request('GET', '/chickens?chickenCoop[]=/chicken_coops/2&chickenCoop[]=/chicken_coops/1')->toArray(); + $this->assertCount(2, $res['member']); + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + $this->recreateSchema([$this->isMongoDB() ? DocumentChicken::class : Chicken::class, $this->isMongoDB() ? DocumentChickenCoop::class : ChickenCoop::class]); + $this->loadFixtures(); + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(): void + { + $manager = $this->getManager(); + + $chickenCoop1 = new ($this->isMongoDB() ? DocumentChickenCoop::class : ChickenCoop::class)(); + $chickenCoop2 = new ($this->isMongoDB() ? DocumentChickenCoop::class : ChickenCoop::class)(); + + $chicken1 = new ($this->isMongoDB() ? DocumentChicken::class : Chicken::class)(); + $chicken1->setName('Gertrude'); + $chicken1->setChickenCoop($chickenCoop1); + + $chicken2 = new ($this->isMongoDB() ? DocumentChicken::class : Chicken::class)(); + $chicken2->setName('Henriette'); + $chicken2->setChickenCoop($chickenCoop2); + + $chickenCoop1->addChicken($chicken1); + $chickenCoop2->addChicken($chicken2); + + $manager->persist($chicken1); + $manager->persist($chicken2); + $manager->persist($chickenCoop1); + $manager->persist($chickenCoop2); + $manager->flush(); + } +} diff --git a/tests/Functional/Parameters/NumericFilterTest.php b/tests/Functional/Parameters/NumericFilterTest.php index 4ad7b7dbab6..03512ac1462 100644 --- a/tests/Functional/Parameters/NumericFilterTest.php +++ b/tests/Functional/Parameters/NumericFilterTest.php @@ -20,11 +20,6 @@ use ApiPlatform\Tests\SetupClassResourcesTrait; use Doctrine\ODM\MongoDB\MongoDBException; use PHPUnit\Framework\Attributes\DataProvider; -use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; final class NumericFilterTest extends ApiTestCase { @@ -52,13 +47,6 @@ protected function setUp(): void $this->loadFixtures($entityClass); } - /** - * @throws TransportExceptionInterface - * @throws ServerExceptionInterface - * @throws RedirectionExceptionInterface - * @throws DecodingExceptionInterface - * @throws ClientExceptionInterface - */ #[DataProvider('rangeFilterScenariosProvider')] public function testRangeFilterResponses(string $url, int $expectedCount): void { @@ -78,13 +66,6 @@ public static function rangeFilterScenariosProvider(): \Generator yield 'amount_alias_int_equal' => ['/filtered_numeric_parameters?amount=20', 2]; } - /** - * @throws TransportExceptionInterface - * @throws ServerExceptionInterface - * @throws RedirectionExceptionInterface - * @throws DecodingExceptionInterface - * @throws ClientExceptionInterface - */ #[DataProvider('nullAndEmptyScenariosProvider')] public function testRangeFilterWithNullAndEmptyValues(string $url, int $expectedCount): void { diff --git a/tests/Functional/Parameters/OrFilterTest.php b/tests/Functional/Parameters/OrFilterTest.php new file mode 100644 index 00000000000..4c67a50308c --- /dev/null +++ b/tests/Functional/Parameters/OrFilterTest.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Parameters; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Chicken as DocumentChicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ChickenCoop as DocumentChickenCoop; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Chicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ChickenCoop; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\MongoDBException; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * @author Vincent Amstoutz + */ +final class OrFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [Chicken::class, ChickenCoop::class]; + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + protected function setUp(): void + { + $entities = $this->isMongoDB() + ? [DocumentChicken::class, DocumentChickenCoop::class] + : [Chicken::class, ChickenCoop::class]; + + $this->recreateSchema($entities); + $this->loadFixtures(); + } + + #[DataProvider('orFilterDataProvider')] + public function testOrFilter(string $url, int $expectedCount): void + { + $client = self::createClient(); + $client->request('GET', $url); + + $this->assertResponseIsSuccessful(); + $this->assertJsonContains(['totalItems' => $expectedCount]); + } + + public static function orFilterDataProvider(): \Generator + { + yield 'ean through autocomplete' => [ + 'url' => '/chickens?autocomplete=978020137962', + 'expectedCount' => 1, + ]; + + yield 'name through autocomplete' => [ + 'url' => '/chickens?autocomplete=Gertrude', + 'expectedCount' => 1, + ]; + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(): void + { + $manager = $this->getManager(); + $chickenClass = $this->isMongoDB() ? DocumentChicken::class : Chicken::class; + $coopClass = $this->isMongoDB() ? DocumentChickenCoop::class : ChickenCoop::class; + + $chickenCoop1 = new $coopClass(); + $chickenCoop2 = new $coopClass(); + + $chicken1 = new $chickenClass(); + $chicken1->setName('Gertrude'); + $chicken1->setEan('978020137962'); + $chicken1->setChickenCoop($chickenCoop1); + + $chicken2 = new $chickenClass(); + $chicken2->setName('Henriette'); + $chicken2->setEan('978020137963'); + $chicken2->setChickenCoop($chickenCoop2); + + $chickenCoop1->addChicken($chicken1); + $chickenCoop2->addChicken($chicken2); + + $manager->persist($chicken1); + $manager->persist($chicken2); + $manager->persist($chickenCoop1); + $manager->persist($chickenCoop2); + $manager->flush(); + } +} diff --git a/tests/Functional/Parameters/OrderFilterTest.php b/tests/Functional/Parameters/OrderFilterTest.php index 6b7fb6a23d2..c893caae55e 100644 --- a/tests/Functional/Parameters/OrderFilterTest.php +++ b/tests/Functional/Parameters/OrderFilterTest.php @@ -20,11 +20,6 @@ use ApiPlatform\Tests\SetupClassResourcesTrait; use Doctrine\ODM\MongoDB\MongoDBException; use PHPUnit\Framework\Attributes\DataProvider; -use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; final class OrderFilterTest extends ApiTestCase { @@ -52,13 +47,6 @@ protected function setUp(): void $this->loadFixtures($entityClass); } - /** - * @throws TransportExceptionInterface - * @throws ServerExceptionInterface - * @throws RedirectionExceptionInterface - * @throws DecodingExceptionInterface - * @throws ClientExceptionInterface - */ #[DataProvider('orderFilterScenariosProvider')] public function testOrderFilterResponses(string $url, array $expectedOrder): void { diff --git a/tests/Functional/Parameters/PartialSearchFilterTest.php b/tests/Functional/Parameters/PartialSearchFilterTest.php new file mode 100644 index 00000000000..72f6cc744d7 --- /dev/null +++ b/tests/Functional/Parameters/PartialSearchFilterTest.php @@ -0,0 +1,152 @@ + + * + * 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\Parameters; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Chicken as DocumentChicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ChickenCoop as DocumentChickenCoop; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Chicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ChickenCoop; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\MongoDBException; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * @author Vincent Amstoutz + */ +final class PartialSearchFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Chicken::class, ChickenCoop::class]; + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + $entities = $this->isMongoDB() + ? [DocumentChicken::class, DocumentChickenCoop::class] + : [Chicken::class, ChickenCoop::class]; + + $this->recreateSchema($entities); + $this->loadFixtures(); + } + + #[DataProvider('partialSearchFilterProvider')] + public function testPartialSearchFilter(string $url, int $expectedCount, array $expectedNames): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['member']; + + $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + + $names = array_map(fn ($chicken) => $chicken['name'], $filteredItems); + sort($names); + sort($expectedNames); + + $this->assertSame($expectedNames, $names, 'The returned names do not match the expected values.'); + } + + public static function partialSearchFilterProvider(): \Generator + { + yield 'filter by partial name "ertrude"' => [ + '/chickens?namePartial=ertrude', + 1, + ['Gertrude'], + ]; + + yield 'filter by partial name "riette"' => [ + '/chickens?namePartial=riette', + 1, + ['Henriette'], + ]; + + yield 'filter by partial name "e" (should match both)' => [ + '/chickens?namePartial=e', + 2, + ['Gertrude', 'Henriette'], + ]; + + yield 'filter by partial name with no matching entities' => [ + '/chickens?namePartial=Zebra', + 0, + [], + ]; + + yield 'filter with multiple partial names "rude" OR "iette"' => [ + '/chickens?namePartial[]=rude&namePartial[]=iette', + 2, + ['Gertrude', 'Henriette'], + ]; + + yield 'filter with multiple partial names, one matching "Gert," the other not matching "Zebra"' => [ + '/chickens?namePartial[]=Gert&namePartial[]=Zebra', + 1, + ['Gertrude'], + ]; + + yield 'filter with multiple partial names without matches' => [ + '/chickens?namePartial[]=Toto&namePartial[]=Match', + 0, + [], + ]; + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(): void + { + $manager = $this->getManager(); + + $chickenClass = $this->isMongoDB() ? DocumentChicken::class : Chicken::class; + $coopClass = $this->isMongoDB() ? DocumentChickenCoop::class : ChickenCoop::class; + + $chickenCoop1 = new $coopClass(); + $chickenCoop2 = new $coopClass(); + + $chicken1 = new $chickenClass(); + $chicken1->setName('Gertrude'); + $chicken1->setChickenCoop($chickenCoop1); + + $chicken2 = new $chickenClass(); + $chicken2->setName('Henriette'); + $chicken2->setChickenCoop($chickenCoop2); + + $chickenCoop1->addChicken($chicken1); + $chickenCoop2->addChicken($chicken2); + + $manager->persist($chickenCoop1); + $manager->persist($chickenCoop2); + $manager->persist($chicken1); + $manager->persist($chicken2); + + $manager->flush(); + } +} diff --git a/tests/Functional/Parameters/RangeFilterTest.php b/tests/Functional/Parameters/RangeFilterTest.php index 51d1d232f16..0ad31aa5c29 100644 --- a/tests/Functional/Parameters/RangeFilterTest.php +++ b/tests/Functional/Parameters/RangeFilterTest.php @@ -20,11 +20,6 @@ use ApiPlatform\Tests\SetupClassResourcesTrait; use Doctrine\ODM\MongoDB\MongoDBException; use PHPUnit\Framework\Attributes\DataProvider; -use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; -use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; final class RangeFilterTest extends ApiTestCase { @@ -52,13 +47,6 @@ protected function setUp(): void $this->loadFixtures($entityClass); } - /** - * @throws TransportExceptionInterface - * @throws ServerExceptionInterface - * @throws RedirectionExceptionInterface - * @throws DecodingExceptionInterface - * @throws ClientExceptionInterface - */ #[DataProvider('rangeFilterScenariosProvider')] public function testRangeFilterResponses(string $url, int $expectedCount): void { @@ -93,13 +81,6 @@ public static function rangeFilterScenariosProvider(): \Generator yield 'amount_alias_lte_and_between' => ['/filtered_range_parameters?amount[lte]=30&amount[between]=15..40', 2]; } - /** - * @throws TransportExceptionInterface - * @throws ServerExceptionInterface - * @throws RedirectionExceptionInterface - * @throws DecodingExceptionInterface - * @throws ClientExceptionInterface - */ #[DataProvider('nullAndEmptyScenariosProvider')] public function testRangeFilterWithNullAndEmptyValues(string $url, int $expectedCount): void {