Skip to content

Commit 1fcceae

Browse files
committed
feat(doctrine): add ORM ExactFilter
Continues the work at api-platform#7079 and before at api-platform#6865
1 parent 4a093c9 commit 1fcceae

File tree

5 files changed

+355
-5
lines changed

5 files changed

+355
-5
lines changed
+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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\Orm\Util\QueryNameGeneratorInterface;
17+
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
18+
use ApiPlatform\Metadata\Operation;
19+
use ApiPlatform\Metadata\Parameter;
20+
use ApiPlatform\Metadata\ParameterProviderFilterInterface;
21+
use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter;
22+
use ApiPlatform\State\Provider\IriConverterParameterProvider;
23+
use Doctrine\ORM\QueryBuilder;
24+
25+
final class ExactFilter implements FilterInterface, OpenApiParameterFilterInterface, ParameterProviderFilterInterface
26+
{
27+
public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
28+
{
29+
if (!$parameter = $context['parameter'] ?? null) {
30+
return;
31+
}
32+
33+
$value = $parameter->getValue();
34+
if (!\is_array($value)) {
35+
$value = [$value];
36+
}
37+
38+
$property = $parameter->getProperty();
39+
$alias = $queryBuilder->getRootAliases()[0];
40+
$parameterName = $queryNameGenerator->generateParameterName($property);
41+
42+
$queryBuilder
43+
->andWhere(\sprintf('%s.%s = :%s', $alias, $property, $parameterName))
44+
->setParameter($parameterName, $value);
45+
}
46+
47+
public static function getParameterProvider(): string
48+
{
49+
return IriConverterParameterProvider::class;
50+
}
51+
52+
public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null
53+
{
54+
return new OpenApiParameter(name: $parameter->getKey().'[]', in: 'query', style: 'deepObject', explode: true);
55+
}
56+
57+
public function getDescription(string $resourceClass): array
58+
{
59+
return [];
60+
}
61+
}

src/State/Provider/IriConverterParameterProvider.php

+9-5
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace ApiPlatform\State\Provider;
1515

16+
use ApiPlatform\Doctrine\Orm\Filter\IriFilter;
1617
use ApiPlatform\Metadata\IriConverterInterface;
1718
use ApiPlatform\Metadata\Operation;
1819
use ApiPlatform\Metadata\Parameter;
@@ -32,17 +33,20 @@ public function __construct(
3233
public function provide(Parameter $parameter, array $parameters = [], array $context = []): ?Operation
3334
{
3435
$operation = $context['operation'] ?? null;
35-
if (!($value = $parameter->getValue()) || $value instanceof ParameterNotFound) {
36+
$parameterValue = $parameter->getValue();
37+
38+
$isParameterValueNotSet = !$parameterValue || $parameterValue instanceof ParameterNotFound;
39+
if (!$parameter->getFilter() instanceof IriFilter || $isParameterValueNotSet) {
3640
return $operation;
3741
}
3842

39-
if (!\is_array($value)) {
40-
$value = [$value];
43+
if (!\is_array($parameterValue)) {
44+
$parameterValue = [$parameterValue];
4145
}
4246

4347
$entities = [];
44-
foreach ($value as $v) {
45-
$entities[] = $this->iriConverter->getResourceFromIri($v, ['fetch_data' => false]);
48+
foreach ($parameterValue as $iri) {
49+
$entities[] = $this->iriConverter->getResourceFromIri($iri, ['fetch_data' => false]);
4650
}
4751

4852
$parameter->setValue($entities);
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+
}
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\ExactFilter;
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 ExactFilter()
25+
),
26+
'title' => new QueryParameter(
27+
filter: new ExactFilter()
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+
}
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 ExactFilterTest 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+
}

0 commit comments

Comments
 (0)