Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 55cb458

Browse files
committedDec 24, 2024··
feat(doctrine): search filters like laravel eloquent filters
1 parent 9389b4f commit 55cb458

23 files changed

+1600
-0
lines changed
 

Diff for: ‎src/Doctrine/Orm/Filter/ExactSearchFilter.php

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Doctrine\Orm\Filter;
15+
16+
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface;
17+
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
18+
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
19+
use ApiPlatform\Metadata\Operation;
20+
use ApiPlatform\Metadata\Parameter;
21+
use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter;
22+
use Doctrine\ORM\QueryBuilder;
23+
use Doctrine\Persistence\ManagerRegistry;
24+
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
25+
26+
final class ExactSearchFilter implements FilterInterface, ManagerRegistryAwareInterface, OpenApiParameterFilterInterface
27+
{
28+
use FilterInterfaceTrait;
29+
30+
public function __construct(
31+
private ?ManagerRegistry $managerRegistry = null,
32+
private readonly ?array $properties = null,
33+
private readonly ?NameConverterInterface $nameConverter = null,
34+
) {
35+
}
36+
37+
protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
38+
{
39+
if (
40+
null === $value
41+
|| !$this->isPropertyEnabled($property, $resourceClass)
42+
|| !$this->isPropertyMapped($property, $resourceClass, true)
43+
) {
44+
return;
45+
}
46+
47+
$alias = $queryBuilder->getRootAliases()[0];
48+
$parameterName = $queryNameGenerator->generateParameterName($property);
49+
50+
$queryBuilder
51+
->andWhere(\sprintf('%s.%s = :%s', $alias, $property, $parameterName))
52+
->setParameter($parameterName, $value);
53+
}
54+
55+
public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null
56+
{
57+
return new OpenApiParameter(name: $parameter->getKey().'[]', in: 'query', style: 'deepObject', explode: true);
58+
}
59+
}

Diff for: ‎src/Doctrine/Orm/Filter/FilterInterfaceTrait.php

+78
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Doctrine\Orm\Filter;
15+
16+
use ApiPlatform\Doctrine\Common\PropertyHelperTrait;
17+
use ApiPlatform\Doctrine\Orm\PropertyHelperTrait as OrmPropertyHelperTrait;
18+
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
19+
use ApiPlatform\Metadata\Exception\RuntimeException;
20+
use ApiPlatform\Metadata\Operation;
21+
use Doctrine\ORM\QueryBuilder;
22+
use Doctrine\Persistence\ManagerRegistry;
23+
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
24+
25+
trait FilterInterfaceTrait
26+
{
27+
use OrmPropertyHelperTrait;
28+
use PropertyHelperTrait;
29+
30+
public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
31+
{
32+
foreach ($context['filters'] as $property => $value) {
33+
$this->filterProperty($this->denormalizePropertyName($property), $value, $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context);
34+
}
35+
}
36+
37+
public function getDescription(string $resourceClass): array
38+
{
39+
throw new RuntimeException('Not implemented.');
40+
}
41+
42+
/**
43+
* Determines whether the given property is enabled.
44+
*/
45+
protected function isPropertyEnabled(string $property, string $resourceClass): bool
46+
{
47+
if (null === $this->properties) {
48+
// to ensure sanity, nested properties must still be explicitly enabled
49+
return !$this->isPropertyNested($property, $resourceClass);
50+
}
51+
52+
return \array_key_exists($property, $this->properties);
53+
}
54+
55+
protected function denormalizePropertyName(string|int $property): string
56+
{
57+
if (!$this->nameConverter instanceof NameConverterInterface) {
58+
return (string) $property;
59+
}
60+
61+
return implode('.', array_map($this->nameConverter->denormalize(...), explode('.', (string) $property)));
62+
}
63+
64+
public function hasManagerRegistry(): bool
65+
{
66+
return $this->managerRegistry instanceof ManagerRegistry;
67+
}
68+
69+
public function getManagerRegistry(): ManagerRegistry
70+
{
71+
return $this->managerRegistry;
72+
}
73+
74+
public function setManagerRegistry(ManagerRegistry $managerRegistry): void
75+
{
76+
$this->managerRegistry = $managerRegistry;
77+
}
78+
}

Diff for: ‎src/Doctrine/Orm/Filter/IriSearchFilter.php

+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Doctrine\Orm\Filter;
15+
16+
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface;
17+
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
18+
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
19+
use ApiPlatform\Metadata\Operation;
20+
use ApiPlatform\Metadata\Parameter;
21+
use ApiPlatform\Metadata\ParameterProviderFilterInterface;
22+
use ApiPlatform\Metadata\PropertiesAwareInterface;
23+
use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter;
24+
use ApiPlatform\State\Provider\IriConverterParameterProvider;
25+
use Doctrine\DBAL\Types\Types;
26+
use Doctrine\ORM\QueryBuilder;
27+
use Doctrine\Persistence\ManagerRegistry;
28+
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
29+
30+
final class IriSearchFilter implements FilterInterface, ManagerRegistryAwareInterface, OpenApiParameterFilterInterface, PropertiesAwareInterface, ParameterProviderFilterInterface
31+
{
32+
use FilterInterfaceTrait;
33+
34+
public function __construct(
35+
private ?ManagerRegistry $managerRegistry = null,
36+
private readonly ?array $properties = null,
37+
private readonly ?NameConverterInterface $nameConverter = null,
38+
) {
39+
}
40+
41+
protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
42+
{
43+
if (
44+
null === $value
45+
|| !$this->isPropertyEnabled($property, $resourceClass)
46+
|| !$this->isPropertyMapped($property, $resourceClass, true)
47+
) {
48+
return;
49+
}
50+
51+
$value = $context['parameter']->getValue();
52+
53+
$alias = $queryBuilder->getRootAliases()[0];
54+
$parameterName = $queryNameGenerator->generateParameterName($property);
55+
56+
$queryBuilder
57+
->andWhere(\sprintf('%s.%s = :%s', $alias, $property, $parameterName))
58+
->setParameter($parameterName, $value);
59+
}
60+
61+
/**
62+
* {@inheritdoc}
63+
*/
64+
public function getType(string $doctrineType): string
65+
{
66+
// TODO: remove this test when doctrine/dbal:3 support is removed
67+
if (\defined(Types::class.'::ARRAY') && Types::ARRAY === $doctrineType) {
68+
return 'array';
69+
}
70+
71+
return match ($doctrineType) {
72+
Types::BIGINT, Types::INTEGER, Types::SMALLINT => 'int',
73+
Types::BOOLEAN => 'bool',
74+
Types::DATE_MUTABLE, Types::TIME_MUTABLE, Types::DATETIME_MUTABLE, Types::DATETIMETZ_MUTABLE, Types::DATE_IMMUTABLE, Types::TIME_IMMUTABLE, Types::DATETIME_IMMUTABLE, Types::DATETIMETZ_IMMUTABLE => \DateTimeInterface::class,
75+
Types::FLOAT => 'float',
76+
default => 'string',
77+
};
78+
}
79+
80+
public static function getParameterProvider(): string
81+
{
82+
return IriConverterParameterProvider::class;
83+
}
84+
85+
public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null
86+
{
87+
return new OpenApiParameter(name: $parameter->getKey().'[]', in: 'query', style: 'deepObject', explode: true);
88+
}
89+
}

Diff for: ‎src/Doctrine/Orm/Filter/PartialSearchFilter.php

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Doctrine\Orm\Filter;
15+
16+
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface;
17+
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
18+
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
19+
use ApiPlatform\Metadata\Operation;
20+
use ApiPlatform\Metadata\Parameter;
21+
use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter;
22+
use Doctrine\ORM\QueryBuilder;
23+
use Doctrine\Persistence\ManagerRegistry;
24+
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
25+
26+
final class PartialSearchFilter implements FilterInterface, ManagerRegistryAwareInterface, OpenApiParameterFilterInterface
27+
{
28+
use FilterInterfaceTrait;
29+
30+
public function __construct(
31+
private ?ManagerRegistry $managerRegistry = null,
32+
private readonly ?array $properties = null,
33+
private readonly ?NameConverterInterface $nameConverter = null,
34+
) {
35+
}
36+
37+
protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
38+
{
39+
if (
40+
null === $value
41+
|| !$this->isPropertyEnabled($property, $resourceClass)
42+
|| !$this->isPropertyMapped($property, $resourceClass, true)
43+
) {
44+
return;
45+
}
46+
47+
$alias = $queryBuilder->getRootAliases()[0];
48+
$parameterName = $queryNameGenerator->generateParameterName($property);
49+
50+
$queryBuilder
51+
->andWhere(\sprintf('%s.%s LIKE :%s', $alias, $property, $parameterName))
52+
->setParameter($parameterName, '%'.$value.'%');
53+
}
54+
55+
public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null
56+
{
57+
return new OpenApiParameter(name: $parameter->getKey().'[]', in: 'query', style: 'deepObject', explode: true);
58+
}
59+
}

Diff for: ‎src/Doctrine/Orm/Filter/StartSearchFilter.php

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Doctrine\Orm\Filter;
15+
16+
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface;
17+
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
18+
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
19+
use ApiPlatform\Metadata\Operation;
20+
use ApiPlatform\Metadata\Parameter;
21+
use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter;
22+
use Doctrine\ORM\QueryBuilder;
23+
use Doctrine\Persistence\ManagerRegistry;
24+
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
25+
26+
final class StartSearchFilter implements FilterInterface, ManagerRegistryAwareInterface, OpenApiParameterFilterInterface
27+
{
28+
use FilterInterfaceTrait;
29+
30+
public function __construct(
31+
private ?ManagerRegistry $managerRegistry = null,
32+
private readonly ?array $properties = null,
33+
private readonly ?NameConverterInterface $nameConverter = null,
34+
) {
35+
}
36+
37+
protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
38+
{
39+
if (
40+
null === $value
41+
|| !$this->isPropertyEnabled($property, $resourceClass)
42+
|| !$this->isPropertyMapped($property, $resourceClass, true)
43+
) {
44+
return;
45+
}
46+
47+
$alias = $queryBuilder->getRootAliases()[0];
48+
$parameterName = $queryNameGenerator->generateParameterName($property);
49+
50+
$queryBuilder
51+
->andWhere(\sprintf('%s.%s LIKE :%s', $alias, $property, $parameterName))
52+
->setParameter($parameterName, $value.'%');
53+
}
54+
55+
public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null
56+
{
57+
return new OpenApiParameter(name: $parameter->getKey().'[]', in: 'query', style: 'deepObject', explode: true);
58+
}
59+
}

Diff for: ‎src/Metadata/Parameter.php

+7
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,13 @@ public function getValue(mixed $default = new ParameterNotFound()): mixed
127127
return $this->extraProperties['_api_values'] ?? $default;
128128
}
129129

130+
public function setValue(mixed $value): static
131+
{
132+
$this->extraProperties['_api_values'] = $value;
133+
134+
return $this;
135+
}
136+
130137
/**
131138
* @return array<string, mixed>
132139
*/

Diff for: ‎src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php

+1
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ private function setDefaults(string $key, Parameter $parameter, string $resource
196196
if ($filter instanceof SerializerFilterInterface && null === $parameter->getProvider()) {
197197
$parameter = $parameter->withProvider('api_platform.serializer.filter_parameter_provider');
198198
}
199+
199200
$currentKey = $key;
200201
if (null === $parameter->getProperty() && isset($properties[$key])) {
201202
$parameter = $parameter->withProperty($key);

Diff for: ‎src/State/Provider/IriConverterParameterProvider.php

+65
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\State\Provider;
15+
16+
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
17+
use ApiPlatform\Metadata\IdentifiersExtractor;
18+
use ApiPlatform\Metadata\IriConverterInterface;
19+
use ApiPlatform\Metadata\Operation;
20+
use ApiPlatform\Metadata\Parameter;
21+
use ApiPlatform\State\ParameterProviderInterface;
22+
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
23+
24+
final readonly class IriConverterParameterProvider implements ParameterProviderInterface
25+
{
26+
public function __construct(
27+
private IriConverterInterface $iriConverter,
28+
private PropertyAccessorInterface $propertyAccessor,
29+
private ?IdentifiersExtractor $identifiersExtractor = null,
30+
) {
31+
}
32+
33+
public function provide(Parameter $parameter, array $parameters = [], array $context = []): ?Operation
34+
{
35+
$operation = $context['operation'] ?? null;
36+
$value = $parameter->getValue();
37+
if (!$value) {
38+
return $operation;
39+
}
40+
41+
$id = $this->getIdFromValue($value);
42+
$parameter->setValue($id);
43+
44+
return $operation;
45+
}
46+
47+
protected function getIdFromValue(string $value): mixed
48+
{
49+
try {
50+
$item = $this->iriConverter->getResourceFromIri($value, ['fetch_data' => false]);
51+
52+
if (null === $this->identifiersExtractor) {
53+
return $this->propertyAccessor->getValue($item, 'id');
54+
}
55+
56+
$identifiers = $this->identifiersExtractor->getIdentifiersFromItem($item);
57+
58+
return 1 === \count($identifiers) ? array_pop($identifiers) : $identifiers;
59+
} catch (InvalidArgumentException) {
60+
// Do nothing, return the raw value
61+
}
62+
63+
return $value;
64+
}
65+
}

Diff for: ‎src/Symfony/Bundle/Resources/config/doctrine_orm.xml

+28
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,34 @@
199199
<argument type="collection"></argument>
200200
</service>
201201

202+
<service id="api_platform.doctrine.orm.iri_search_filter" class="ApiPlatform\Doctrine\Orm\Filter\IriSearchFilter" public="false">
203+
<argument type="service" id="doctrine"/>
204+
<argument key="$nameConverter" type="service" id="api_platform.name_converter" on-invalid="ignore"/>
205+
206+
<tag name="api_platform.filter" priority="-100"/>
207+
</service>
208+
209+
<service id="api_platform.doctrine.orm.exact_search_filter" class="ApiPlatform\Doctrine\Orm\Filter\ExactSearchFilter" public="false">
210+
<argument type="service" id="doctrine"/>
211+
<argument key="$nameConverter" type="service" id="api_platform.name_converter" on-invalid="ignore"/>
212+
213+
<tag name="api_platform.filter" priority="-100"/>
214+
</service>
215+
216+
<service id="api_platform.doctrine.orm.partial_search_filter" class="ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter" public="false">
217+
<argument type="service" id="doctrine"/>
218+
<argument key="$nameConverter" type="service" id="api_platform.name_converter" on-invalid="ignore"/>
219+
220+
<tag name="api_platform.filter" priority="-100"/>
221+
</service>
222+
223+
<service id="api_platform.doctrine.orm.start_search_filter" class="ApiPlatform\Doctrine\Orm\Filter\StartSearchFilter" public="false">
224+
<argument type="service" id="doctrine"/>
225+
<argument key="$nameConverter" type="service" id="api_platform.name_converter" on-invalid="ignore"/>
226+
227+
<tag name="api_platform.filter" priority="-100"/>
228+
</service>
229+
202230
<service id="api_platform.doctrine.orm.metadata.resource.metadata_collection_factory" class="ApiPlatform\Doctrine\Orm\Metadata\Resource\DoctrineOrmResourceCollectionMetadataFactory" decorates="api_platform.metadata.resource.metadata_collection_factory" decoration-priority="40">
203231
<argument type="service" id="doctrine" />
204232
<argument type="service" id="api_platform.doctrine.orm.metadata.resource.metadata_collection_factory.inner" />

Diff for: ‎src/Symfony/Bundle/Resources/config/state/provider.xml

+8
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@
1818
<argument type="service" id="api_platform.serializer.context_builder" />
1919
</service>
2020

21+
<service id="ApiPlatform\State\Provider\IriConverterParameterProvider" class="ApiPlatform\State\Provider\IriConverterParameterProvider" public="false">
22+
<argument type="service" id="api_platform.iri_converter"/>
23+
<argument type="service" id="property_accessor" />
24+
<argument type="service" id="api_platform.api.identifiers_extractor" />
25+
26+
<tag name="api_platform.parameter_provider" key="ApiPlatform\State\Provider\IriConverterParameterProvider" priority="-895" />
27+
</service>
28+
2129
<service id="api_platform.state_provider.deserialize" class="ApiPlatform\State\Provider\DeserializeProvider" decorates="api_platform.state_provider.main" decoration-priority="300">
2230
<argument type="service" id="api_platform.state_provider.deserialize.inner" />
2331
<argument type="service" id="api_platform.serializer" />

Diff for: ‎src/Symfony/Bundle/Resources/config/symfony/events.xml

+8
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@
3333
<tag name="kernel.event_listener" event="kernel.request" method="onKernelRequest" priority="4" />
3434
</service>
3535

36+
<service id="ApiPlatform\State\Provider\IriConverterParameterProvider" class="ApiPlatform\State\Provider\IriConverterParameterProvider" public="false">
37+
<argument type="service" id="api_platform.iri_converter"/>
38+
<argument type="service" id="property_accessor" />
39+
<argument type="service" id="api_platform.api.identifiers_extractor" />
40+
41+
<tag name="api_platform.parameter_provider" key="ApiPlatform\State\Provider\IriConverterParameterProvider" priority="-895" />
42+
</service>
43+
3644
<service id="api_platform.state_provider.deserialize" class="ApiPlatform\State\Provider\DeserializeProvider">
3745
<argument>null</argument>
3846
<argument type="service" id="api_platform.serializer" />

Diff for: ‎tests/Fixtures/TestBundle/Entity/DummyAuthor.php

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity;
15+
16+
use ApiPlatform\Metadata\GetCollection;
17+
use Doctrine\Common\Collections\ArrayCollection;
18+
use Doctrine\Common\Collections\Collection;
19+
use Doctrine\ORM\Mapping as ORM;
20+
21+
#[GetCollection]
22+
#[ORM\Entity]
23+
class DummyAuthor
24+
{
25+
public function __construct(
26+
#[ORM\Id]
27+
#[ORM\GeneratedValue(strategy: 'AUTO')]
28+
#[ORM\Column]
29+
public ?int $id = null,
30+
31+
#[ORM\Column]
32+
public ?string $name = null,
33+
34+
#[ORM\OneToMany(targetEntity: DummyBook::class, mappedBy: 'dummyAuthor')]
35+
public ?Collection $dummyBooks = new ArrayCollection(),
36+
) {
37+
}
38+
39+
public function getId(): ?int
40+
{
41+
return $this->id;
42+
}
43+
44+
public function getName(): string
45+
{
46+
return $this->name;
47+
}
48+
49+
public function setName(string $name): void
50+
{
51+
$this->name = $name;
52+
}
53+
54+
public function getDummyBooks(): Collection
55+
{
56+
return $this->dummyBooks;
57+
}
58+
}
+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity;
15+
16+
use ApiPlatform\Metadata\GetCollection;
17+
use Doctrine\Common\Collections\ArrayCollection;
18+
use Doctrine\Common\Collections\Collection;
19+
use Doctrine\ORM\Mapping as ORM;
20+
21+
#[GetCollection]
22+
#[ORM\Entity]
23+
class DummyAuthorExact
24+
{
25+
public function __construct(
26+
#[ORM\Id]
27+
#[ORM\GeneratedValue(strategy: 'AUTO')]
28+
#[ORM\Column]
29+
public ?int $id = null,
30+
31+
#[ORM\Column]
32+
public ?string $name = null,
33+
34+
#[ORM\OneToMany(targetEntity: DummyBookExact::class, mappedBy: 'dummyAuthorExact')]
35+
public ?Collection $dummyBookExacts = new ArrayCollection(),
36+
) {
37+
}
38+
39+
public function getId(): ?int
40+
{
41+
return $this->id;
42+
}
43+
44+
public function getName(): string
45+
{
46+
return $this->name;
47+
}
48+
49+
public function setName(string $name): void
50+
{
51+
$this->name = $name;
52+
}
53+
54+
public function getDummyBookExacts(): Collection
55+
{
56+
return $this->dummyBookExacts;
57+
}
58+
}
+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity;
15+
16+
use ApiPlatform\Metadata\GetCollection;
17+
use Doctrine\Common\Collections\ArrayCollection;
18+
use Doctrine\Common\Collections\Collection;
19+
use Doctrine\ORM\Mapping as ORM;
20+
21+
#[GetCollection]
22+
#[ORM\Entity]
23+
class DummyAuthorPartial
24+
{
25+
public function __construct(
26+
#[ORM\Id]
27+
#[ORM\GeneratedValue(strategy: 'AUTO')]
28+
#[ORM\Column]
29+
public ?int $id = null,
30+
31+
#[ORM\Column]
32+
public ?string $name = null,
33+
34+
#[ORM\OneToMany(targetEntity: DummyBookPartial::class, mappedBy: 'dummyAuthorPartial')]
35+
public ?Collection $dummyBookPartials = new ArrayCollection(),
36+
) {
37+
}
38+
39+
public function getId(): ?int
40+
{
41+
return $this->id;
42+
}
43+
44+
public function getName(): string
45+
{
46+
return $this->name;
47+
}
48+
49+
public function setName(string $name): void
50+
{
51+
$this->name = $name;
52+
}
53+
54+
public function getDummyBookPartials(): Collection
55+
{
56+
return $this->dummyBookPartials;
57+
}
58+
}
+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity;
15+
16+
use ApiPlatform\Metadata\GetCollection;
17+
use Doctrine\Common\Collections\ArrayCollection;
18+
use Doctrine\Common\Collections\Collection;
19+
use Doctrine\ORM\Mapping as ORM;
20+
21+
#[GetCollection]
22+
#[ORM\Entity]
23+
class DummyAuthorStart
24+
{
25+
public function __construct(
26+
#[ORM\Id]
27+
#[ORM\GeneratedValue(strategy: 'AUTO')]
28+
#[ORM\Column]
29+
public ?int $id = null,
30+
31+
#[ORM\Column]
32+
public ?string $name = null,
33+
34+
#[ORM\OneToMany(targetEntity: DummyBookStart::class, mappedBy: 'dummyAuthorStart')]
35+
public ?Collection $dummyBookStarts = new ArrayCollection(),
36+
) {
37+
}
38+
39+
public function getId(): ?int
40+
{
41+
return $this->id;
42+
}
43+
44+
public function getName(): string
45+
{
46+
return $this->name;
47+
}
48+
49+
public function setName(string $name): void
50+
{
51+
$this->name = $name;
52+
}
53+
54+
public function getDummyBookStarts(): Collection
55+
{
56+
return $this->dummyBookStarts;
57+
}
58+
}

Diff for: ‎tests/Fixtures/TestBundle/Entity/DummyBook.php

+83
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity;
15+
16+
use ApiPlatform\Doctrine\Orm\Filter\IriSearchFilter;
17+
use ApiPlatform\Metadata\GetCollection;
18+
use ApiPlatform\Metadata\QueryParameter;
19+
use Doctrine\ORM\Mapping as ORM;
20+
21+
#[GetCollection(
22+
parameters: [
23+
'dummyAuthor' => new QueryParameter(
24+
filter: new IriSearchFilter()
25+
),
26+
],
27+
)]
28+
#[ORM\Entity]
29+
class DummyBook
30+
{
31+
public function __construct(
32+
#[ORM\Id]
33+
#[ORM\GeneratedValue(strategy: 'AUTO')]
34+
#[ORM\Column]
35+
public ?int $id = null,
36+
37+
#[ORM\Column]
38+
public ?string $title = null,
39+
40+
#[ORM\Column]
41+
public ?string $isbn = null,
42+
43+
#[ORM\ManyToOne(targetEntity: DummyAuthor::class, inversedBy: 'dummyBooks')]
44+
#[ORM\JoinColumn(nullable: false)]
45+
public ?DummyAuthor $dummyAuthor = null,
46+
) {
47+
}
48+
49+
public function getId(): ?int
50+
{
51+
return $this->id;
52+
}
53+
54+
public function getTitle(): string
55+
{
56+
return $this->title;
57+
}
58+
59+
public function setTitle(string $title): void
60+
{
61+
$this->title = $title;
62+
}
63+
64+
public function getIsbn(): string
65+
{
66+
return $this->isbn;
67+
}
68+
69+
public function setIsbn(string $isbn): void
70+
{
71+
$this->isbn = $isbn;
72+
}
73+
74+
public function getDummyAuthor(): DummyAuthor
75+
{
76+
return $this->dummyAuthor;
77+
}
78+
79+
public function setDummyAuthor(DummyAuthor $dummyAuthor): void
80+
{
81+
$this->dummyAuthor = $dummyAuthor;
82+
}
83+
}

Diff for: ‎tests/Fixtures/TestBundle/Entity/DummyBookExact.php

+86
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity;
15+
16+
use ApiPlatform\Doctrine\Orm\Filter\ExactSearchFilter;
17+
use ApiPlatform\Metadata\GetCollection;
18+
use ApiPlatform\Metadata\QueryParameter;
19+
use Doctrine\ORM\Mapping as ORM;
20+
21+
#[GetCollection(
22+
parameters: [
23+
'dummyAuthorExact' => new QueryParameter(
24+
filter: new ExactSearchFilter()
25+
),
26+
'title' => new QueryParameter(
27+
filter: new ExactSearchFilter()
28+
),
29+
],
30+
)]
31+
#[ORM\Entity]
32+
class DummyBookExact
33+
{
34+
public function __construct(
35+
#[ORM\Id]
36+
#[ORM\GeneratedValue(strategy: 'AUTO')]
37+
#[ORM\Column]
38+
public ?int $id = null,
39+
40+
#[ORM\Column]
41+
public ?string $title = null,
42+
43+
#[ORM\Column]
44+
public ?string $isbn = null,
45+
46+
#[ORM\ManyToOne(targetEntity: DummyAuthorExact::class, inversedBy: 'dummyBookExacts')]
47+
#[ORM\JoinColumn(nullable: false)]
48+
public ?DummyAuthorExact $dummyAuthorExact = null,
49+
) {
50+
}
51+
52+
public function getId(): ?int
53+
{
54+
return $this->id;
55+
}
56+
57+
public function getTitle(): string
58+
{
59+
return $this->title;
60+
}
61+
62+
public function setTitle(string $title): void
63+
{
64+
$this->title = $title;
65+
}
66+
67+
public function getIsbn(): string
68+
{
69+
return $this->isbn;
70+
}
71+
72+
public function setIsbn(string $isbn): void
73+
{
74+
$this->isbn = $isbn;
75+
}
76+
77+
public function getDummyAuthorExact(): DummyAuthorExact
78+
{
79+
return $this->dummyAuthorExact;
80+
}
81+
82+
public function setDummyAuthorExact(DummyAuthorExact $dummyAuthorExact): void
83+
{
84+
$this->dummyAuthorExact = $dummyAuthorExact;
85+
}
86+
}
+83
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity;
15+
16+
use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter;
17+
use ApiPlatform\Metadata\GetCollection;
18+
use ApiPlatform\Metadata\QueryParameter;
19+
use Doctrine\ORM\Mapping as ORM;
20+
21+
#[GetCollection(
22+
parameters: [
23+
'title' => new QueryParameter(
24+
filter: new PartialSearchFilter()
25+
),
26+
],
27+
)]
28+
#[ORM\Entity]
29+
class DummyBookPartial
30+
{
31+
public function __construct(
32+
#[ORM\Id]
33+
#[ORM\GeneratedValue(strategy: 'AUTO')]
34+
#[ORM\Column]
35+
public ?int $id = null,
36+
37+
#[ORM\Column]
38+
public ?string $title = null,
39+
40+
#[ORM\Column]
41+
public ?string $isbn = null,
42+
43+
#[ORM\ManyToOne(targetEntity: DummyAuthorPartial::class, inversedBy: 'dummyBookPartials')]
44+
#[ORM\JoinColumn(nullable: false)]
45+
public ?DummyAuthorPartial $dummyAuthorPartial = null,
46+
) {
47+
}
48+
49+
public function getId(): ?int
50+
{
51+
return $this->id;
52+
}
53+
54+
public function getTitle(): string
55+
{
56+
return $this->title;
57+
}
58+
59+
public function setTitle(string $title): void
60+
{
61+
$this->title = $title;
62+
}
63+
64+
public function getIsbn(): string
65+
{
66+
return $this->isbn;
67+
}
68+
69+
public function setIsbn(string $isbn): void
70+
{
71+
$this->isbn = $isbn;
72+
}
73+
74+
public function getDummyAuthorPartial(): DummyAuthorPartial
75+
{
76+
return $this->dummyAuthorPartial;
77+
}
78+
79+
public function setDummyAuthorPartial(DummyAuthorPartial $dummyAuthorPartial): void
80+
{
81+
$this->dummyAuthorPartial = $dummyAuthorPartial;
82+
}
83+
}

Diff for: ‎tests/Fixtures/TestBundle/Entity/DummyBookStart.php

+83
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity;
15+
16+
use ApiPlatform\Doctrine\Orm\Filter\StartSearchFilter;
17+
use ApiPlatform\Metadata\GetCollection;
18+
use ApiPlatform\Metadata\QueryParameter;
19+
use Doctrine\ORM\Mapping as ORM;
20+
21+
#[GetCollection(
22+
parameters: [
23+
'title' => new QueryParameter(
24+
filter: new StartSearchFilter()
25+
),
26+
],
27+
)]
28+
#[ORM\Entity]
29+
class DummyBookStart
30+
{
31+
public function __construct(
32+
#[ORM\Id]
33+
#[ORM\GeneratedValue(strategy: 'AUTO')]
34+
#[ORM\Column]
35+
public ?int $id = null,
36+
37+
#[ORM\Column]
38+
public ?string $title = null,
39+
40+
#[ORM\Column]
41+
public ?string $isbn = null,
42+
43+
#[ORM\ManyToOne(targetEntity: DummyAuthorStart::class, inversedBy: 'dummyBookStarts')]
44+
#[ORM\JoinColumn(nullable: false)]
45+
public ?DummyAuthorStart $dummyAuthorStart = null,
46+
) {
47+
}
48+
49+
public function getId(): ?int
50+
{
51+
return $this->id;
52+
}
53+
54+
public function getTitle(): string
55+
{
56+
return $this->title;
57+
}
58+
59+
public function setTitle(string $title): void
60+
{
61+
$this->title = $title;
62+
}
63+
64+
public function getIsbn(): string
65+
{
66+
return $this->isbn;
67+
}
68+
69+
public function setIsbn(string $isbn): void
70+
{
71+
$this->isbn = $isbn;
72+
}
73+
74+
public function getDummyAuthorStart(): DummyAuthorStart
75+
{
76+
return $this->dummyAuthorStart;
77+
}
78+
79+
public function setDummyAuthorStart(DummyAuthorStart $dummyAuthorStart): void
80+
{
81+
$this->dummyAuthorStart = $dummyAuthorStart;
82+
}
83+
}
+141
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Functional\Parameters;
15+
16+
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
17+
use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyAuthorExact as DummyAuthorExactDocument;
18+
use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyBookExact as DummyBookExactDocument;
19+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyAuthorExact;
20+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyBookExact;
21+
use ApiPlatform\Tests\RecreateSchemaTrait;
22+
use ApiPlatform\Tests\SetupClassResourcesTrait;
23+
use Doctrine\ODM\MongoDB\MongoDBException;
24+
use PHPUnit\Framework\Attributes\DataProvider;
25+
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
26+
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
27+
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
28+
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
29+
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
30+
31+
final class ExactSearchFilterTest extends ApiTestCase
32+
{
33+
use RecreateSchemaTrait;
34+
use SetupClassResourcesTrait;
35+
36+
/**
37+
* @return class-string[]
38+
*/
39+
public static function getResources(): array
40+
{
41+
return [DummyBookExact::class, DummyAuthorExact::class];
42+
}
43+
44+
/**
45+
* @throws MongoDBException
46+
* @throws \Throwable
47+
*/
48+
protected function setUp(): void
49+
{
50+
// TODO: implement ODM classes
51+
$authorEntityClass = $this->isMongoDB() ? DummyAuthorExactDocument::class : DummyAuthorExact::class;
52+
$bookEntityClass = $this->isMongoDB() ? DummyBookExactDocument::class : DummyBookExact::class;
53+
54+
$this->recreateSchema([$authorEntityClass, $bookEntityClass]);
55+
$this->loadFixtures($authorEntityClass, $bookEntityClass);
56+
}
57+
58+
/**
59+
* @throws ServerExceptionInterface
60+
* @throws RedirectionExceptionInterface
61+
* @throws DecodingExceptionInterface
62+
* @throws ClientExceptionInterface
63+
* @throws TransportExceptionInterface
64+
*/
65+
#[DataProvider('exactSearchFilterProvider')]
66+
public function testExactSearchFilter(string $url, int $expectedCount, array $expectedTitles): void
67+
{
68+
$response = self::createClient()->request('GET', $url);
69+
$this->assertResponseIsSuccessful();
70+
71+
$responseData = $response->toArray();
72+
$filteredItems = $responseData['hydra:member'];
73+
74+
$this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url));
75+
76+
$titles = array_map(fn ($book) => $book['title'], $filteredItems);
77+
sort($titles);
78+
sort($expectedTitles);
79+
80+
$this->assertSame($expectedTitles, $titles, 'The titles do not match the expected values.');
81+
}
82+
83+
public static function exactSearchFilterProvider(): \Generator
84+
{
85+
yield 'filter_by_author_exact_id_1' => [
86+
'/dummy_book_exacts?dummyAuthorExact=1',
87+
2,
88+
['Book 1', 'Book 2'],
89+
];
90+
yield 'filter_by_author_exact_id_1_and_title_book_1' => [
91+
'/dummy_book_exacts?dummyAuthorExact=1&title=Book 1',
92+
1,
93+
['Book 1'],
94+
];
95+
yield 'filter_by_author_exact_id_1_and_title_book_3' => [
96+
'/dummy_book_exacts?dummyAuthorExact=1&title=Book 3',
97+
0,
98+
[],
99+
];
100+
yield 'filter_by_author_exact_id_3_and_title_book_3' => [
101+
'/dummy_book_exacts?dummyAuthorExact=2&title=Book 3',
102+
1,
103+
['Book 3'],
104+
];
105+
}
106+
107+
/**
108+
* @throws \Throwable
109+
* @throws MongoDBException
110+
*/
111+
private function loadFixtures(string $authorEntityClass, string $bookEntityClass): void
112+
{
113+
$manager = $this->getManager();
114+
115+
$authors = [];
116+
foreach ([['name' => 'Author 1'], ['name' => 'Author 2']] as $authorData) {
117+
$author = new $authorEntityClass(name: $authorData['name']);
118+
$manager->persist($author);
119+
$authors[] = $author;
120+
}
121+
122+
$books = [
123+
['title' => 'Book 1', 'isbn' => '1234567890123', 'author' => $authors[0]],
124+
['title' => 'Book 2', 'isbn' => '1234567890124', 'author' => $authors[0]],
125+
['title' => 'Book 3', 'isbn' => '1234567890125', 'author' => $authors[1]],
126+
];
127+
128+
foreach ($books as $bookData) {
129+
$book = new $bookEntityClass(
130+
title: $bookData['title'],
131+
isbn: $bookData['isbn'],
132+
dummyAuthorExact: $bookData['author']
133+
);
134+
135+
$author->dummyBookExacts->add($book);
136+
$manager->persist($book);
137+
}
138+
139+
$manager->flush();
140+
}
141+
}

Diff for: ‎tests/Functional/Parameters/IriSearchFilterTest.php

+142
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Functional\Parameters;
15+
16+
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
17+
use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyAuthor as DummyAuthorDocument;
18+
use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyBook as DummyBookDocument;
19+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyAuthor;
20+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyBook;
21+
use ApiPlatform\Tests\RecreateSchemaTrait;
22+
use ApiPlatform\Tests\SetupClassResourcesTrait;
23+
use Doctrine\ODM\MongoDB\MongoDBException;
24+
use PHPUnit\Framework\Attributes\DataProvider;
25+
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
26+
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
27+
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
28+
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
29+
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
30+
31+
final class IriSearchFilterTest extends ApiTestCase
32+
{
33+
use RecreateSchemaTrait;
34+
use SetupClassResourcesTrait;
35+
36+
/**
37+
* @return class-string[]
38+
*/
39+
public static function getResources(): array
40+
{
41+
return [DummyBook::class, DummyAuthor::class];
42+
}
43+
44+
/**
45+
* @throws MongoDBException
46+
* @throws \Throwable
47+
*/
48+
protected function setUp(): void
49+
{
50+
// TODO: implement ODM classes
51+
$authorEntityClass = $this->isMongoDB() ? DummyAuthorDocument::class : DummyAuthor::class;
52+
$bookEntityClass = $this->isMongoDB() ? DummyBookDocument::class : DummyBook::class;
53+
54+
$this->recreateSchema([$authorEntityClass, $bookEntityClass]);
55+
$this->loadFixtures($authorEntityClass, $bookEntityClass);
56+
}
57+
58+
/**
59+
* @throws ServerExceptionInterface
60+
* @throws RedirectionExceptionInterface
61+
* @throws DecodingExceptionInterface
62+
* @throws ClientExceptionInterface
63+
* @throws TransportExceptionInterface
64+
*/
65+
#[DataProvider('iriFilterScenariosProvider')]
66+
public function testIriFilterResponses(string $url, int $expectedCount, string $expectedAuthorIri): void
67+
{
68+
$response = self::createClient()->request('GET', $url);
69+
$this->assertResponseIsSuccessful();
70+
71+
$responseData = $response->toArray();
72+
$filteredItems = $responseData['hydra:member'];
73+
74+
$this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url));
75+
76+
foreach ($filteredItems as $item) {
77+
$errorMessage = \sprintf('Expected the book to be associated with author IRI %s', $expectedAuthorIri);
78+
$this->assertSame($expectedAuthorIri, $item['dummyAuthor'], $errorMessage);
79+
}
80+
}
81+
82+
public static function iriFilterScenariosProvider(): \Generator
83+
{
84+
yield 'filter_by_author1' => [
85+
'/dummy_books?dummyAuthor=/dummy_authors/1',
86+
2,
87+
'/dummy_authors/1',
88+
];
89+
yield 'filter_by_author_id_1' => [
90+
'/dummy_books?dummyAuthor=1',
91+
2,
92+
'/dummy_authors/1',
93+
];
94+
yield 'filter_by_author2' => [
95+
'/dummy_books?dummyAuthor=/dummy_authors/2',
96+
1,
97+
'/dummy_authors/2',
98+
];
99+
yield 'filter_by_author_id_2' => [
100+
'/dummy_books?dummyAuthor=2',
101+
1,
102+
'/dummy_authors/2',
103+
];
104+
}
105+
106+
/**
107+
* @throws \Throwable
108+
* @throws MongoDBException
109+
*/
110+
private function loadFixtures(string $authorEntityClass, string $bookEntityClass): void
111+
{
112+
$manager = $this->getManager();
113+
114+
$authors = [];
115+
foreach ([['name' => 'Author 1'], ['name' => 'Author 2']] as $authorData) {
116+
/** @var DummyAuthor|DummyAuthorDocument $author */
117+
$author = new $authorEntityClass(name: $authorData['name']);
118+
$manager->persist($author);
119+
$authors[] = $author;
120+
}
121+
122+
$books = [
123+
['title' => 'Book 1', 'isbn' => '1234567890123', 'author' => $authors[0]],
124+
['title' => 'Book 2', 'isbn' => '1234567890124', 'author' => $authors[0]],
125+
['title' => 'Book 3', 'isbn' => '1234567890125', 'author' => $authors[1]],
126+
];
127+
128+
foreach ($books as $bookData) {
129+
/** @var DummyBook|DummyBookDocument $book */
130+
$book = new $bookEntityClass(
131+
title: $bookData['title'],
132+
isbn: $bookData['isbn'],
133+
dummyAuthor: $bookData['author']
134+
);
135+
136+
$author->dummyBooks->add($book);
137+
$manager->persist($book);
138+
}
139+
140+
$manager->flush();
141+
}
142+
}
+142
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Functional\Parameters;
15+
16+
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
17+
use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyAuthorPartial as DummyAuthorPartialDocument;
18+
use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyBookPartial as DummyBookPartialDocument;
19+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyAuthorPartial;
20+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyBookPartial;
21+
use ApiPlatform\Tests\RecreateSchemaTrait;
22+
use ApiPlatform\Tests\SetupClassResourcesTrait;
23+
use Doctrine\ODM\MongoDB\MongoDBException;
24+
use PHPUnit\Framework\Attributes\DataProvider;
25+
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
26+
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
27+
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
28+
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
29+
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
30+
31+
final class PartialSearchFilterTest extends ApiTestCase
32+
{
33+
use RecreateSchemaTrait;
34+
use SetupClassResourcesTrait;
35+
36+
/**
37+
* @return class-string[]
38+
*/
39+
public static function getResources(): array
40+
{
41+
return [DummyBookPartial::class, DummyAuthorPartial::class];
42+
}
43+
44+
/**
45+
* @throws MongoDBException
46+
* @throws \Throwable
47+
*/
48+
protected function setUp(): void
49+
{
50+
// TODO: implement ODM classes
51+
$authorEntityClass = $this->isMongoDB() ? DummyAuthorPartialDocument::class : DummyAuthorPartial::class;
52+
$bookEntityClass = $this->isMongoDB() ? DummyBookPartialDocument::class : DummyBookPartial::class;
53+
54+
$this->recreateSchema([$authorEntityClass, $bookEntityClass]);
55+
$this->loadFixtures($authorEntityClass, $bookEntityClass);
56+
}
57+
58+
/**
59+
* @throws ServerExceptionInterface
60+
* @throws RedirectionExceptionInterface
61+
* @throws DecodingExceptionInterface
62+
* @throws ClientExceptionInterface
63+
* @throws TransportExceptionInterface
64+
*/
65+
#[DataProvider('partialSearchFilterProvider')]
66+
public function testPartialSearchFilter(string $url, int $expectedCount, array $expectedTerms): void
67+
{
68+
$response = self::createClient()->request('GET', $url);
69+
$this->assertResponseIsSuccessful();
70+
71+
$responseData = $response->toArray();
72+
$filteredItems = $responseData['hydra:member'];
73+
74+
$this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url));
75+
76+
$titles = array_map(fn ($book) => $book['title'], $filteredItems);
77+
foreach ($titles as $expectedTitle) {
78+
$this->assertContains($expectedTitle, $titles, \sprintf('The title "%s" was not found in the results.', $expectedTitle));
79+
}
80+
}
81+
82+
public static function partialSearchFilterProvider(): \Generator
83+
{
84+
yield 'filter_by_partial_title_term_book' => [
85+
'/dummy_book_partials?title=Book',
86+
3,
87+
['Book'],
88+
];
89+
yield 'filter_by_partial_title_term_1' => [
90+
'/dummy_book_partials?title=1',
91+
1,
92+
['Book 1'],
93+
];
94+
yield 'filter_by_partial_title_term_3' => [
95+
'/dummy_book_partials?title=3',
96+
1,
97+
['Book 3'],
98+
];
99+
yield 'filter_by_partial_title_with_no_matching_entities' => [
100+
'/dummy_book_partials?title=99',
101+
0,
102+
[],
103+
];
104+
}
105+
106+
/**
107+
* @throws \Throwable
108+
* @throws MongoDBException
109+
*/
110+
private function loadFixtures(string $authorEntityClass, string $bookEntityClass): void
111+
{
112+
$manager = $this->getManager();
113+
114+
$authors = [];
115+
foreach ([['name' => 'Author 1'], ['name' => 'Author 2']] as $authorData) {
116+
/** @var DummyAuthorPartial|DummyAuthorPartialDocument $author */
117+
$author = new $authorEntityClass(name: $authorData['name']);
118+
$manager->persist($author);
119+
$authors[] = $author;
120+
}
121+
122+
$books = [
123+
['title' => 'Book 1', 'isbn' => '1234567890123', 'author' => $authors[0]],
124+
['title' => 'Book 2', 'isbn' => '1234567890124', 'author' => $authors[0]],
125+
['title' => 'Book 3', 'isbn' => '1234567890125', 'author' => $authors[1]],
126+
];
127+
128+
foreach ($books as $bookData) {
129+
/** @var DummyBookPartial|DummyBookPartialDocument $book */
130+
$book = new $bookEntityClass(
131+
title: $bookData['title'],
132+
isbn: $bookData['isbn'],
133+
dummyAuthorPartial: $bookData['author']
134+
);
135+
136+
$author->dummyBookPartials->add($book);
137+
$manager->persist($book);
138+
}
139+
140+
$manager->flush();
141+
}
142+
}
+147
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Functional\Parameters;
15+
16+
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
17+
use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyAuthorStart as DummyAuthorStartDocument;
18+
use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyBookStart as DummyBookStartDocument;
19+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyAuthorStart;
20+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyBookStart;
21+
use ApiPlatform\Tests\RecreateSchemaTrait;
22+
use ApiPlatform\Tests\SetupClassResourcesTrait;
23+
use Doctrine\ODM\MongoDB\MongoDBException;
24+
use PHPUnit\Framework\Attributes\DataProvider;
25+
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
26+
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
27+
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
28+
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
29+
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
30+
31+
final class StartSearchFilterTest extends ApiTestCase
32+
{
33+
use RecreateSchemaTrait;
34+
use SetupClassResourcesTrait;
35+
36+
/**
37+
* @return class-string[]
38+
*/
39+
public static function getResources(): array
40+
{
41+
return [DummyBookStart::class, DummyAuthorStart::class];
42+
}
43+
44+
/**
45+
* @throws MongoDBException
46+
* @throws \Throwable
47+
*/
48+
protected function setUp(): void
49+
{
50+
// TODO: implement ODM classes
51+
$authorEntityClass = $this->isMongoDB() ? DummyAuthorStartDocument::class : DummyAuthorStart::class;
52+
$bookEntityClass = $this->isMongoDB() ? DummyBookStartDocument::class : DummyBookStart::class;
53+
54+
$this->recreateSchema([$authorEntityClass, $bookEntityClass]);
55+
$this->loadFixtures($authorEntityClass, $bookEntityClass);
56+
}
57+
58+
/**
59+
* @throws ServerExceptionInterface
60+
* @throws RedirectionExceptionInterface
61+
* @throws DecodingExceptionInterface
62+
* @throws ClientExceptionInterface
63+
* @throws TransportExceptionInterface
64+
*/
65+
#[DataProvider('startSearchFilterProvider')]
66+
public function testStartSearchFilter(string $url, int $expectedCount, array $expectedTitles): void
67+
{
68+
$response = self::createClient()->request('GET', $url);
69+
$this->assertResponseIsSuccessful();
70+
71+
$responseData = $response->toArray();
72+
$filteredItems = $responseData['hydra:member'];
73+
74+
$this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url));
75+
76+
$titles = array_map(fn ($book) => $book['title'], $filteredItems);
77+
foreach ($expectedTitles as $expectedTitle) {
78+
$this->assertContains($expectedTitle, $titles, \sprintf('The title "%s" was not found in the results.', $expectedTitle));
79+
}
80+
}
81+
82+
public static function startSearchFilterProvider(): \Generator
83+
{
84+
yield 'filter_by_starting_title_book' => [
85+
'/dummy_book_starts?title=Book',
86+
3,
87+
['Book 1', 'Book 2', 'Book 3'],
88+
];
89+
yield 'filter_by_starting_title_book_1' => [
90+
'/dummy_book_starts?title=Book 1',
91+
1,
92+
['Book 1'],
93+
];
94+
yield 'filter_by_starting_title_b' => [
95+
'/dummy_book_starts?title=B',
96+
3,
97+
['Book 1', 'Book 2', 'Book 3'],
98+
];
99+
yield 'filter_by_starting_title_nonexistent' => [
100+
'/dummy_book_starts?title=NonExistent',
101+
0,
102+
[],
103+
];
104+
yield 'filter_by_title_ending_on_start_filter' => [
105+
'/dummy_book_starts?title=3',
106+
0,
107+
[],
108+
];
109+
}
110+
111+
/**
112+
* @throws \Throwable
113+
* @throws MongoDBException
114+
*/
115+
private function loadFixtures(string $authorEntityClass, string $bookEntityClass): void
116+
{
117+
$manager = $this->getManager();
118+
119+
$authors = [];
120+
foreach ([['name' => 'Author 1'], ['name' => 'Author 2']] as $authorData) {
121+
/** @var DummyAuthorStart|DummyAuthorStartDocument $author */
122+
$author = new $authorEntityClass(name: $authorData['name']);
123+
$manager->persist($author);
124+
$authors[] = $author;
125+
}
126+
127+
$books = [
128+
['title' => 'Book 1', 'isbn' => '1234567890123', 'author' => $authors[0]],
129+
['title' => 'Book 2', 'isbn' => '1234567890124', 'author' => $authors[0]],
130+
['title' => 'Book 3', 'isbn' => '1234567890125', 'author' => $authors[1]],
131+
];
132+
133+
foreach ($books as $bookData) {
134+
/** @var DummyBookStart|DummyBookStartDocument $book */
135+
$book = new $bookEntityClass(
136+
title: $bookData['title'],
137+
isbn: $bookData['isbn'],
138+
dummyAuthorStart: $bookData['author'],
139+
);
140+
141+
$author->dummyBookStarts->add($book);
142+
$manager->persist($book);
143+
}
144+
145+
$manager->flush();
146+
}
147+
}

0 commit comments

Comments
 (0)
Please sign in to comment.