diff --git a/src/Doctrine/Common/Filter/OpenApiFilterTrait.php b/src/Doctrine/Common/Filter/OpenApiFilterTrait.php new file mode 100644 index 00000000000..2fb06bf0105 --- /dev/null +++ b/src/Doctrine/Common/Filter/OpenApiFilterTrait.php @@ -0,0 +1,33 @@ + + * + * 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); + } + + public function getDescription(string $resourceClass): array + { + return []; + } +} diff --git a/src/Doctrine/Odm/Filter/ExactFilter.php b/src/Doctrine/Odm/Filter/ExactFilter.php new file mode 100644 index 00000000000..ac5a890a11c --- /dev/null +++ b/src/Doctrine/Odm/Filter/ExactFilter.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\Doctrine\Odm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ODM\MongoDB\Aggregation\Builder; + +/** + * @author Vincent Amstoutz + */ +final class ExactFilter implements FilterInterface, OpenApiParameterFilterInterface +{ + use OpenApiFilterTrait; + + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + if (!$parameter = $context['parameter'] ?? null) { + return; + } + + $values = (array) $parameter->getValue(); + + // TODO: handle nested properties + $property = $parameter->getProperty(); + + $aggregationBuilder + ->match() + ->field($property) + ->in($values); + } +} 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/IriFilter.php b/src/Doctrine/Odm/Filter/IriFilter.php new file mode 100644 index 00000000000..be35187578c --- /dev/null +++ b/src/Doctrine/Odm/Filter/IriFilter.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\Doctrine\Odm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\ParameterProviderFilterInterface; +use ApiPlatform\State\ParameterProvider\IriConverterParameterProvider; +use Doctrine\ODM\MongoDB\Aggregation\Builder; + +/** + * @author Vincent Amstoutz + */ +final class IriFilter implements FilterInterface, OpenApiParameterFilterInterface, ParameterProviderFilterInterface +{ + use OpenApiFilterTrait; + + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + if (!$parameter = $context['parameter'] ?? null) { + return; + } + + $value = $parameter->getValue(); + if (!\is_array($value)) { + $value = [$value]; + } + + // TODO: handle nested properties + $property = $parameter->getProperty(); + + $aggregationBuilder + ->match() + ->field($property) + ->in($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..f2ba7dd4a47 --- /dev/null +++ b/src/Doctrine/Odm/Filter/OrFilter.php @@ -0,0 +1,43 @@ + + * + * 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\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ODM\MongoDB\Aggregation\Builder; + +/** + * @author Vincent Amstoutz + */ +final class OrFilter implements FilterInterface, OpenApiParameterFilterInterface +{ + use OpenApiFilterTrait; + /** + * @var array + */ + private readonly array $filters; + + public function __construct(FilterInterface ...$filters) + { + $this->filters = $filters; + } + + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + foreach ($this->filters as $filter) { + $filter->apply($aggregationBuilder, $resourceClass, $operation, $context); + } + } +} diff --git a/src/Doctrine/Odm/Filter/PartialSearchFilter.php b/src/Doctrine/Odm/Filter/PartialSearchFilter.php new file mode 100644 index 00000000000..9390831a1c0 --- /dev/null +++ b/src/Doctrine/Odm/Filter/PartialSearchFilter.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\Odm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +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 OpenApiFilterTrait; + + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + if (!$parameter = $context['parameter'] ?? null) { + return; + } + + $value = $parameter->getValue(); + if (!\is_string($value) || '' === $value) { + return; + } + + // TODO: handle nested properties + $property = $parameter->getProperty(); + $escapedValue = preg_quote($value, '/'); + + $aggregationBuilder + ->match() + ->field($property) + ->equals(new Regex($escapedValue, 'i')); + } +} diff --git a/src/Doctrine/Orm/Filter/ExactFilter.php b/src/Doctrine/Orm/Filter/ExactFilter.php new file mode 100644 index 00000000000..723ad161a3f --- /dev/null +++ b/src/Doctrine/Orm/Filter/ExactFilter.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\Doctrine\Orm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; + +/** + * @author Vincent Amstoutz + */ +final class ExactFilter implements FilterInterface, OpenApiParameterFilterInterface +{ + use OpenApiFilterTrait; + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + if (!$parameter = $context['parameter'] ?? null) { + return; + } + + $value = $parameter->getValue(); + if (!\is_array($value)) { + $value = [$value]; + } + + $property = $parameter->getProperty(); + $alias = $queryBuilder->getRootAliases()[0]; + $parameterName = $queryNameGenerator->generateParameterName($property); + + $queryBuilder + ->andWhere(\sprintf('%s.%s = :%s', $alias, $property, $parameterName)) + ->setParameter($parameterName, $value); + } +} diff --git a/src/Doctrine/Orm/Filter/ExistsFilter.php b/src/Doctrine/Orm/Filter/ExistsFilter.php index 482405759d4..e84715bc70f 100644 --- a/src/Doctrine/Orm/Filter/ExistsFilter.php +++ b/src/Doctrine/Orm/Filter/ExistsFilter.php @@ -142,8 +142,11 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q return; } - foreach ($context['filters'][$this->existsParameterName] ?? [] as $property => $value) { - $this->filterProperty($this->denormalizePropertyName($property), $value, $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); + $properties = $context['filters'][$this->existsParameterName]; + if ([] !== $properties) { + foreach ($properties as $property => $value) { + $this->filterProperty($this->denormalizePropertyName($property), $value, $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); + } } } 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/IriFilter.php b/src/Doctrine/Orm/Filter/IriFilter.php new file mode 100644 index 00000000000..de8aaf85178 --- /dev/null +++ b/src/Doctrine/Orm/Filter/IriFilter.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\Orm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +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 OpenApiFilterTrait; + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + if (!$parameter = $context['parameter'] ?? null) { + return; + } + + $value = $parameter->getValue(); + if (!\is_array($value)) { + $value = [$value]; + } + + $property = $parameter->getProperty(); + $alias = $queryBuilder->getRootAliases()[0]; + $parameterName = $queryNameGenerator->generateParameterName($property); + + $queryBuilder + ->join(\sprintf('%s.%s', $alias, $property), $parameterName) + ->andWhere(\sprintf('%s IN(:%s)', $parameterName, $parameterName)) + ->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..f60573fb0a7 --- /dev/null +++ b/src/Doctrine/Orm/Filter/OrFilter.php @@ -0,0 +1,52 @@ + + * + * 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\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\ParameterProviderFilterInterface; +use ApiPlatform\State\ParameterProvider\IriConverterParameterProvider; +use Doctrine\ORM\QueryBuilder; + +/** + * @author Vincent Amstoutz + */ +final class OrFilter implements FilterInterface, OpenApiParameterFilterInterface, ParameterProviderFilterInterface +{ + use OpenApiFilterTrait; + + /** + * @var array + */ + private readonly array $filters; + + public function __construct(FilterInterface ...$filters) + { + $this->filters = $filters; + } + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + foreach ($this->filters as $filter) { + $filter->apply($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); + } + } + + public static function getParameterProvider(): string + { + return IriConverterParameterProvider::class; + } +} diff --git a/src/Doctrine/Orm/Filter/PartialSearchFilter.php b/src/Doctrine/Orm/Filter/PartialSearchFilter.php new file mode 100644 index 00000000000..b658a7509f7 --- /dev/null +++ b/src/Doctrine/Orm/Filter/PartialSearchFilter.php @@ -0,0 +1,52 @@ + + * + * 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\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; + +/** + * @author Vincent Amstoutz + */ +final class PartialSearchFilter implements FilterInterface, OpenApiParameterFilterInterface +{ + use OpenApiFilterTrait; + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + if (!$parameter = $context['parameter'] ?? null) { + return; + } + + $value = $parameter->getValue(); + + $property = $parameter->getProperty(); + $alias = $queryBuilder->getRootAliases()[0]; + $field = $alias.'.'.$property; + + $parameterName = $queryNameGenerator->generateParameterName($property); + + $likeExpression = $queryBuilder->expr()->like( + 'LOWER('.$field.')', + ':'.$parameterName + ); + + $queryBuilder + ->andWhere($likeExpression) + ->setParameter($parameterName, '%'.strtolower($value).'%'); + } +} diff --git a/src/State/ParameterProvider/IriConverterParameterProvider.php b/src/State/ParameterProvider/IriConverterParameterProvider.php index 2f817f8e7ab..a81faa8bfda 100644 --- a/src/State/ParameterProvider/IriConverterParameterProvider.php +++ b/src/State/ParameterProvider/IriConverterParameterProvider.php @@ -13,6 +13,8 @@ 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; @@ -22,7 +24,7 @@ /** * @experimental * - * @author Vincent Amstoutz + * @author Vincent Amstoutz */ final readonly class IriConverterParameterProvider implements ParameterProviderInterface { @@ -40,19 +42,23 @@ public function provide(Parameter $parameter, array $parameters = [], array $con $iriConverterContext = ['fetch_data' => $parameter->getExtraProperties()['fetch_data'] ?? false]; - if (\is_array($value)) { - $entities = []; - foreach ($value as $v) { - $entities[] = $this->iriConverter->getResourceFromIri($v, $iriConverterContext); - } + try { + if (\is_array($value)) { + $entities = []; + foreach ($value as $v) { + $entities[] = $this->iriConverter->getResourceFromIri($v, $iriConverterContext); + } + + $parameter->setValue($entities); - $parameter->setValue($entities); + return $operation; + } + $parameter->setValue($this->iriConverter->getResourceFromIri($value, $iriConverterContext)); + } catch (InvalidArgumentException|ItemNotFoundException) { return $operation; } - $parameter->setValue($this->iriConverter->getResourceFromIri($value, $iriConverterContext)); - return $operation; } } diff --git a/tests/Fixtures/TestBundle/Document/Chicken.php b/tests/Fixtures/TestBundle/Document/Chicken.php new file mode 100644 index 00000000000..4132c57ac82 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/Chicken.php @@ -0,0 +1,78 @@ + + * + * 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\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( + parameters: [ + 'chickenCoop' => new QueryParameter(filter: new ExactFilter()), + 'name' => new QueryParameter(filter: new ExactFilter()), + 'namePartial' => new QueryParameter( + filter: new PartialSearchFilter(), + property: 'name', + ), + 'relation' => new QueryParameter( + filter: new OrFilter(new IriFilter(), new ExactFilter()), + property: 'chickenCoop' + ), + ], +)] +class Chicken +{ + #[ODM\Id(type: 'string', strategy: 'INCREMENT')] + private ?string $id = null; + + #[ODM\Field(type: 'string')] + private string $name; + + #[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 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..bea4f8aeade --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/ChickenCoop.php @@ -0,0 +1,74 @@ + + * + * 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\IriFilter; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +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], + parameters: ['chickens' => new QueryParameter(filter: new IriFilter())]) +] +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..6b3ab63fc03 100644 --- a/tests/Fixtures/TestBundle/Document/Company.php +++ b/tests/Fixtures/TestBundle/Document/Company.php @@ -25,13 +25,19 @@ #[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 { - #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + #[ODM\Id(type: 'int', strategy: 'INCREMENT')] private ?int $id = null; #[ODM\Field] diff --git a/tests/Fixtures/TestBundle/Entity/Chicken.php b/tests/Fixtures/TestBundle/Entity/Chicken.php new file mode 100644 index 00000000000..22f5b6afce8 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Chicken.php @@ -0,0 +1,81 @@ + + * + * 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\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( + parameters: [ + 'chickenCoop' => new QueryParameter(filter: new ExactFilter()), + 'name' => new QueryParameter(filter: new ExactFilter()), + 'namePartial' => new QueryParameter( + filter: new PartialSearchFilter(), + property: 'name', + ), + 'relation' => new QueryParameter( + filter: new OrFilter(new IriFilter(), new ExactFilter()), + property: 'chickenCoop' + ), + ], +)] +class Chicken +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'string', length: 255)] + private string $name; + + #[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 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..410261adb84 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/ChickenCoop.php @@ -0,0 +1,76 @@ + + * + * 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\IriFilter; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +#[GetCollection( + normalizationContext: ['hydra_prefix' => false], + parameters: ['chickens' => new QueryParameter(filter: new IriFilter())] +)] +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/Parameters/ExactFilterTest.php b/tests/Functional/Parameters/ExactFilterTest.php new file mode 100644 index 00000000000..91fb1a936f5 --- /dev/null +++ b/tests/Functional/Parameters/ExactFilterTest.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; +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 ExactFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Chicken::class, ChickenCoop::class]; + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + self::$alwaysBootKernel = false; + + $entities = $this->isMongoDB() + ? [DocumentChicken::class, DocumentChickenCoop::class] + : [Chicken::class, ChickenCoop::class]; + + $this->recreateSchema($entities); + $this->loadFixtures(); + } + + /** + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + * @throws TransportExceptionInterface + */ + #[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['hydra: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?chickenCoop=1', + 1, + ['Gertrude'], + ]; + + yield 'filter by coop id and correct name' => [ + '/chickens?chickenCoop=1&name=Gertrude', + 1, + ['Gertrude'], + ]; + + yield 'filter by coop id and incorrect name' => [ + '/chickens?chickenCoop=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..e2bc02c10ec 100644 --- a/tests/Functional/Parameters/ExistsFilterTest.php +++ b/tests/Functional/Parameters/ExistsFilterTest.php @@ -26,6 +26,9 @@ use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +/** + * @author Vincent Amstoutz + */ final class ExistsFilterTest extends ApiTestCase { use RecreateSchemaTrait; diff --git a/tests/Functional/Parameters/IriFilterTest.php b/tests/Functional/Parameters/IriFilterTest.php new file mode 100644 index 00000000000..451d0e241d8 --- /dev/null +++ b/tests/Functional/Parameters/IriFilterTest.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\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', '/chicken_coops?chickens=/chickens/2')->toArray(); + $this->assertCount(1, $res['member']); + $this->assertEquals(['/chickens/2'], $res['member'][0]['chickens']); + } + + public function testIriFilterMultiple(): void + { + $client = $this->createClient(); + $res = $client->request('GET', '/chicken_coops?chickens[]=/chickens/2&chickens[]=/chickens/1')->toArray(); + $this->assertCount(2, $res['member']); + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + self::$alwaysBootKernel = false; + + $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/OrFilterTest.php b/tests/Functional/Parameters/OrFilterTest.php new file mode 100644 index 00000000000..70806f11014 --- /dev/null +++ b/tests/Functional/Parameters/OrFilterTest.php @@ -0,0 +1,142 @@ + + * + * 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; +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 OrFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + 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(); + } + + /** + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + */ + #[DataProvider('filterDataProvider')] + public function testOrFilter(string $url, int $expectedCount, array $expectedNames): void + { + $client = self::createClient(); + $client->request('GET', $url); + + $this->assertResponseIsSuccessful(); + $this->assertJsonContains(['hydra:totalItems' => $expectedCount]); + + if ($expectedCount > 0) { + $names = array_column($client->getResponse()->toArray()['hydra:member'], 'name'); + sort($names); + sort($expectedNames); + $this->assertSame($expectedNames, $names); + } + } + + public static function filterDataProvider(): \Generator + { + yield 'filtre par ID du poulailler de Gertrude' => [ + 'url' => '/chickens?relation=1', + 'expectedCount' => 1, + 'expectedNames' => ['Gertrude'], + ]; + + yield 'filtre par IRI du poulailler de Gertrude' => [ + 'url' => '/chickens?relation=/chicken_coops/1', + 'expectedCount' => 1, + 'expectedNames' => ['Gertrude'], + ]; + + yield 'filtre par ID du poulailler de Henriette' => [ + 'url' => '/chickens?relation=2', + 'expectedCount' => 1, + 'expectedNames' => ['Henriette'], + ]; + + yield 'filtre par IRI du poulailler de Henriette' => [ + 'url' => '/chickens?relation=/chicken_coops/2', + 'expectedCount' => 1, + 'expectedNames' => ['Henriette'], + ]; + + yield 'filtre avec un ID inexistant' => [ + 'url' => '/chickens?relation=999', + 'expectedCount' => 0, + 'expectedNames' => [], + ]; + } + + /** + * @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(); + $manager->persist($chickenCoop1); + $manager->persist($chickenCoop2); + + $chicken1 = new $chickenClass(); + $chicken1->setName('Gertrude'); + $chicken1->setChickenCoop($chickenCoop1); + $manager->persist($chicken1); + + $chicken2 = new $chickenClass(); + $chicken2->setName('Henriette'); + $chicken2->setChickenCoop($chickenCoop2); + $manager->persist($chicken2); + + $manager->flush(); + } +} diff --git a/tests/Functional/Parameters/PartialSearchFilterTest.php b/tests/Functional/Parameters/PartialSearchFilterTest.php new file mode 100644 index 00000000000..f9531c90b7b --- /dev/null +++ b/tests/Functional/Parameters/PartialSearchFilterTest.php @@ -0,0 +1,146 @@ + + * + * 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; +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 PartialSearchFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Chicken::class, ChickenCoop::class]; + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + self::$alwaysBootKernel = false; + + $entities = $this->isMongoDB() + ? [DocumentChicken::class, DocumentChickenCoop::class] + : [Chicken::class, ChickenCoop::class]; + + $this->recreateSchema($entities); + $this->loadFixtures(); + } + + /** + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + * @throws TransportExceptionInterface + */ + #[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['hydra: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, + [], + ]; + } + + /** + * @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(); + } +}