Skip to content

Commit a68a6d4

Browse files
upgrade filters
1 parent 1470b1f commit a68a6d4

File tree

4 files changed

+279
-0
lines changed

4 files changed

+279
-0
lines changed

src/Core/Bridge/Symfony/Bundle/Command/UpgradeApiResourceCommand.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use ApiPlatform\Core\Upgrade\ColorConsoleDiffFormatter;
2121
use ApiPlatform\Core\Upgrade\SubresourceTransformer;
2222
use ApiPlatform\Core\Upgrade\UpgradeApiResourceVisitor;
23+
use ApiPlatform\Core\Upgrade\UpgradeApiFilterVisitor;
2324
use ApiPlatform\Core\Upgrade\UpgradeApiSubresourceVisitor;
2425
use ApiPlatform\Exception\ResourceClassNotFoundException;
2526
use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
@@ -106,6 +107,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int
106107

107108
$traverser = new NodeTraverser();
108109
[$attribute, $isAnnotation] = $this->readApiResource($resourceClass);
110+
111+
$traverser->addVisitor(new UpgradeApiFilterVisitor($this->reader, $resourceClass, $attribute));
109112

110113
if (!$attribute) {
111114
continue;
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
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\Core\Upgrade;
15+
16+
use ApiPlatform\Core\Annotation\ApiFilter as LegacyApiFilter;
17+
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\SearchFilter as LegacySearchFilter;
18+
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\ExistsFilter as LegacyExistsFilter;
19+
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\DateFilter as LegacyDateFilter;
20+
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
21+
use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter;
22+
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
23+
use ApiPlatform\Metadata\ApiFilter;
24+
use ApiPlatform\Metadata\Resource\DeprecationMetadataTrait;
25+
use Doctrine\Common\Annotations\AnnotationReader;
26+
use PhpParser\Node;
27+
use PhpParser\NodeVisitorAbstract;
28+
29+
final class UpgradeApiFilterVisitor extends NodeVisitorAbstract
30+
{
31+
use DeprecationMetadataTrait;
32+
use RemoveAnnotationTrait;
33+
34+
private ?AnnotationReader $reader;
35+
private \ReflectionClass $reflectionClass;
36+
37+
public function __construct(?AnnotationReader $reader, string $resourceClass)
38+
{
39+
$this->reader = $reader;
40+
$this->reflectionClass = new \ReflectionClass($resourceClass);
41+
}
42+
43+
/**
44+
* @return int|Node|null
45+
*/
46+
public function enterNode(Node $node)
47+
{
48+
if ($node instanceof Node\Stmt\Namespace_) {
49+
$namespaces = [
50+
ApiFilter::class,
51+
SearchFilter::class,
52+
ExistsFilter::class,
53+
DateFilter::class,
54+
];
55+
56+
foreach ($node->stmts as $k => $stmt) {
57+
if (!$stmt instanceof Node\Stmt\Use_) {
58+
break;
59+
}
60+
61+
$useStatement = implode('\\', $stmt->uses[0]->name->parts);
62+
63+
if (LegacyApiFilter::class === $useStatement) {
64+
unset($node->stmts[$k]);
65+
continue;
66+
}
67+
if (LegacySearchFilter::class === $useStatement) {
68+
unset($node->stmts[$k]);
69+
continue;
70+
}
71+
if (LegacyExistsFilter::class === $useStatement) {
72+
unset($node->stmts[$k]);
73+
continue;
74+
}
75+
if (LegacyDateFilter::class === $useStatement) {
76+
unset($node->stmts[$k]);
77+
continue;
78+
}
79+
80+
81+
82+
83+
if (false !== ($key = array_search($useStatement, $namespaces, true))) {
84+
unset($namespaces[$key]);
85+
}
86+
}
87+
88+
foreach ($namespaces as $namespace) {
89+
array_unshift($node->stmts, new Node\Stmt\Use_([
90+
new Node\Stmt\UseUse(
91+
new Node\Name(
92+
$namespace
93+
)
94+
),
95+
]));
96+
}
97+
}
98+
99+
if ($node instanceof Node\Stmt\Property || $node instanceof Node\Stmt\Class_) {
100+
if ($node instanceof Node\Stmt\Property) {
101+
$reflection = $this->reflectionClass->getProperty($node->props[0]->name->__toString());
102+
} else {
103+
$reflection = $this->reflectionClass;
104+
}
105+
106+
// filter annotation : array
107+
$filterAnnotations = $this->readApiFilters($reflection);
108+
109+
foreach ($this->readApiFilters($reflection) as $annotation) {
110+
[$filterAnnotation, $isAnnotation] = $annotation;
111+
if ($isAnnotation) {
112+
$this->removeAnnotation($node);
113+
} else {
114+
$this->removeAttribute($node);
115+
}
116+
117+
$arguments = [];
118+
119+
foreach ([
120+
'strategy',
121+
'filterClass',
122+
'properties',
123+
'arguments',
124+
] as $key) {
125+
$value = $filterAnnotation->{$key};
126+
if (null === $value || [] === $value) {
127+
continue;
128+
}
129+
$arguments[$key] = $this->valueToNode($value);
130+
131+
}
132+
foreach ($filterAnnotation->attributes ?? [] as $key => $value) {
133+
if (null === $value || [] === $value) {
134+
continue;
135+
}
136+
137+
[$key, $value] = $this->getKeyValue($key, $value);
138+
$arguments[$key] = $this->valueToNode($value);
139+
}
140+
141+
array_unshift($node->attrGroups, new Node\AttributeGroup([
142+
new Node\Attribute(
143+
new Node\Name('ApiFilter'),
144+
$this->arrayToArguments($arguments),
145+
),
146+
]));
147+
}
148+
}
149+
150+
}
151+
152+
private function readApiFilters(\ReflectionProperty|\ReflectionClass $reflection): ?\Generator
153+
{
154+
if (\PHP_VERSION_ID >= 80000 && $attributes = $reflection->getAttributes(LegacyApiFilter::class)) {
155+
yield from array_map(function($attribute) {
156+
return $attribute->newInstance();
157+
} , $attributes);
158+
}
159+
160+
if (null === $this->reader) {
161+
throw new \RuntimeException(sprintf('Resource "%s" not found.', $reflection->getDeclaringClass()->getName()));
162+
}
163+
164+
if ($reflection instanceof \ReflectionProperty) {
165+
$annotations = $this->reader->getPropertyAnnotations($reflection);
166+
} else {
167+
$annotations = $this->reader->getClassAnnotations($reflection);
168+
169+
}
170+
171+
foreach ($annotations as $annotation) {
172+
if ($annotation instanceof LegacyApiFilter) {
173+
yield [$annotation, true];
174+
}
175+
}
176+
}
177+
178+
private function valueToNode(mixed $value)
179+
{
180+
if (\is_string($value)) {
181+
if (class_exists($value)) {
182+
return new Node\Expr\ClassConstFetch(new Node\Name($this->getShortName($value)), 'class');
183+
}
184+
185+
return new Node\Scalar\String_($value);
186+
}
187+
188+
if (\is_bool($value)) {
189+
return new Node\Expr\ConstFetch(new Node\Name($value ? 'true' : 'false'));
190+
}
191+
192+
if (is_numeric($value)) {
193+
return \is_int($value) ? new Node\Scalar\LNumber($value) : new Node\Scalar\DNumber($value);
194+
}
195+
196+
if (\is_array($value)) {
197+
return new Node\Expr\Array_(
198+
array_map(function ($key, $value) {
199+
return new Node\Expr\ArrayItem(
200+
$this->valueToNode($value),
201+
\is_string($key) ? $this->valueToNode($key) : null,
202+
);
203+
}, array_keys($value), array_values($value)),
204+
[
205+
'kind' => Node\Expr\Array_::KIND_SHORT,
206+
]
207+
);
208+
}
209+
}
210+
211+
private function removeAnnotation(Node\Stmt\Property|Node\Stmt\Class_ $node)
212+
{
213+
$comment = $node->getDocComment();
214+
215+
if (preg_match('/@ApiFilter/', $comment->getText())) {
216+
$node->setDocComment($this->removeAnnotationByTag($comment, 'ApiFilter'));
217+
}
218+
}
219+
220+
private function removeAttribute(Node\Stmt\Property|Node\Stmt\Class_ $node)
221+
{
222+
foreach ($node->attrGroups as $k => $attrGroupNode) {
223+
foreach ($attrGroupNode->attrs as $i => $attribute) {
224+
if (str_ends_with(implode('\\', $attribute->name->parts), 'ApiFilter')) {
225+
unset($node->attrGroups[$k]);
226+
break;
227+
}
228+
}
229+
}
230+
}
231+
232+
/**
233+
* @return Node\Arg[]
234+
*/
235+
private function arrayToArguments(array $arguments)
236+
{
237+
$args = [];
238+
foreach ($arguments as $key => $value) {
239+
if ($value)
240+
$args[] = new Node\Arg($value, false, false, [], new Node\Identifier($key));
241+
}
242+
243+
return $args;
244+
}
245+
246+
247+
private function getShortName(string $class): string
248+
{
249+
if (false !== $pos = strrpos($class, '\\')) {
250+
return substr($class, $pos + 1);
251+
}
252+
253+
return $class;
254+
}
255+
256+
}

tests/Core/Bridge/Symfony/Bundle/Command/UpgradeApiResourceCommandTest.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,22 @@ public function testDebugResource()
9292
$expectedStrings = [
9393
'-use ApiPlatform\\Core\\Annotation\\ApiSubresource',
9494
'-use ApiPlatform\\Core\\Annotation\\ApiResource',
95+
'-use ApiPlatform\\Core\\Annotation\\ApiFilter',
96+
'-use ApiPlatform\\Core\\Bridge\\Doctrine\\Orm\\Filter\\SearchFilter;',
97+
'-use ApiPlatform\\Core\\Bridge\\Doctrine\\Orm\\Filter\\ExistsFilter;',
98+
'-use ApiPlatform\\Core\\Bridge\\Doctrine\\Orm\\Filter\\DateFilter;',
9599
'+use ApiPlatform\\Metadata\\ApiResource',
100+
'+use ApiPlatform\\Metadata\\ApiFilter',
101+
'+use ApiPlatform\Doctrine\Orm\Filter\SearchFilter',
102+
'+use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter',
103+
'+use ApiPlatform\Doctrine\Orm\Filter\DateFilter',
96104
'+use ApiPlatform\\Metadata\\Get',
97105
"+#[ApiResource(graphQlOperations: [new Query(name: 'item_query'), new Mutation(name: 'update', normalizationContext: ['groups' => ['chicago', 'fakemanytomany']], denormalizationContext: ['groups' => ['friends']])], types: ['https://schema.org/Product'], normalizationContext: ['groups' => ['friends']], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'])]",
98106
"#[ApiResource(uriTemplate: '/related_dummies/{id}/id.{_format}', uriVariables: ['id' => new Link(fromClass: self::class, identifiers: ['id'])], status: 200, types: ['https://schema.org/Product'], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'], normalizationContext: ['groups' => ['friends']], operations: [new Get()])]",
107+
"+#[ApiFilter(filterClass: SearchFilter::class, properties: ['id', 'name'])]",
108+
"+ #[ApiFilter(filterClass: SearchFilter::class)]",
109+
"+ #[ApiFilter(filterClass: ExistsFilter::class)]",
110+
"+ #[ApiFilter(filterClass: DateFilter::class)]",
99111
];
100112

101113
$display = $commandTester->getDisplay();

tests/Fixtures/TestBundle/Entity/RelatedDummy.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@
1515

1616
use ApiPlatform\Core\Annotation\ApiProperty;
1717
use ApiPlatform\Core\Annotation\ApiResource;
18+
use ApiPlatform\Core\Annotation\ApiFilter;
1819
use ApiPlatform\Core\Annotation\ApiSubresource;
20+
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\SearchFilter;
21+
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\ExistsFilter;
22+
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\DateFilter;
1923
use Doctrine\Common\Collections\ArrayCollection;
2024
use Doctrine\Common\Collections\Collection;
2125
use Doctrine\ORM\Mapping as ORM;
@@ -29,6 +33,7 @@
2933
*
3034
* @ApiResource(graphql={"item_query", "update"={"normalization_context"={"groups"={"chicago", "fakemanytomany"}}, "denormalization_context"={"groups"={"friends"}}}}, iri="https://schema.org/Product", attributes={"normalization_context"={"groups"={"friends"}}, "filters"={"related_dummy.friends", "related_dummy.complex_sub_query"}})
3135
* @ORM\Entity
36+
* @ApiFilter(SearchFilter::class, properties={"id", "name"})
3237
*/
3338
class RelatedDummy extends ParentDummy
3439
{
@@ -53,6 +58,8 @@ class RelatedDummy extends ParentDummy
5358
/**
5459
* @ORM\Column
5560
* @Groups({"barcelona", "chicago", "friends"})
61+
* @ApiFilter(SearchFilter::class)
62+
* @ApiFilter(ExistsFilter::class)
5663
*/
5764
protected $symfony = 'symfony';
5865

@@ -62,6 +69,7 @@ class RelatedDummy extends ParentDummy
6269
* @ORM\Column(type="datetime", nullable=true)
6370
* @Assert\DateTime
6471
* @Groups({"friends"})
72+
* @ApiFilter(DateFilter::class)
6573
*/
6674
public $dummyDate;
6775

0 commit comments

Comments
 (0)