Skip to content

Commit 5eb5971

Browse files
committed
feat(doctrine): add ORM PartialSearchFilter
Continues the work at #7079 and before at #6865
1 parent 0e3e4ef commit 5eb5971

File tree

4 files changed

+348
-0
lines changed

4 files changed

+348
-0
lines changed
Lines changed: 65 additions & 0 deletions
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 <[email protected]>
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 PartialSearchFilter 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+
35+
$property = $parameter->getProperty();
36+
$alias = $queryBuilder->getRootAliases()[0];
37+
$field = $alias.'.'.$property;
38+
39+
$parameterName = $queryNameGenerator->generateParameterName($property);
40+
41+
$likeExpression = $queryBuilder->expr()->like(
42+
'LOWER('.$field.')',
43+
':'.$parameterName
44+
);
45+
46+
$queryBuilder
47+
->andWhere($likeExpression)
48+
->setParameter($parameterName, '%'.strtolower($value).'%');
49+
}
50+
51+
public static function getParameterProvider(): string
52+
{
53+
return IriConverterParameterProvider::class;
54+
}
55+
56+
public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null
57+
{
58+
return new OpenApiParameter(name: $parameter->getKey().'[]', in: 'query', style: 'deepObject', explode: true);
59+
}
60+
61+
public function getDescription(string $resourceClass): array
62+
{
63+
return [];
64+
}
65+
}
Lines changed: 58 additions & 0 deletions
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 <[email protected]>
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+
}
Lines changed: 83 additions & 0 deletions
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 <[email protected]>
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+
}
Lines changed: 142 additions & 0 deletions
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 <[email protected]>
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+
}

0 commit comments

Comments
 (0)