Skip to content

Commit 380bf26

Browse files
committed
refactor(doctrine): make ExistsFilter standalone, drop AbstractFilter
Both ORM and ODM ExistsFilter stop extending the deprecated AbstractFilter (5.0 survivor rewrite). The change is structural: apply() still reads its value from $context['filters'] so the legacy declaration paths (Operation::$filters services, #[ApiFilter]) — which never produce a Parameter — keep working until 6.0. The concrete helpers previously inherited from AbstractFilter (ctor, properties accessors, isPropertyEnabled, name-converter handling, logger) are inlined; ManagerRegistryAwareInterface is kept for BC injection and the deprecated getDescription(). The ORM filter now reads class metadata from $queryBuilder->getEntityManager() instead of the injected ManagerRegistry, so the active filter path no longer depends on it; ODM keeps the registry since its aggregation Builder exposes no DocumentManager (consistent with the canonical ExactFilter).
1 parent 63d345d commit 380bf26

3 files changed

Lines changed: 210 additions & 12 deletions

File tree

src/Doctrine/Odm/Filter/ExistsFilter.php

Lines changed: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@
1515

1616
use ApiPlatform\Doctrine\Common\Filter\ExistsFilterInterface;
1717
use ApiPlatform\Doctrine\Common\Filter\ExistsFilterTrait;
18+
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface;
19+
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait;
20+
use ApiPlatform\Doctrine\Common\Filter\NameConverterAwareInterface;
21+
use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface;
1822
use ApiPlatform\Doctrine\Common\Filter\PropertyPlaceholderOpenApiParameterTrait;
23+
use ApiPlatform\Doctrine\Odm\PropertyHelperTrait as MongoDbOdmPropertyHelperTrait;
1924
use ApiPlatform\Metadata\JsonSchemaFilterInterface;
2025
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
2126
use ApiPlatform\Metadata\Operation;
@@ -24,6 +29,7 @@
2429
use Doctrine\ODM\MongoDB\Mapping\ClassMetadata;
2530
use Doctrine\Persistence\ManagerRegistry;
2631
use Psr\Log\LoggerInterface;
32+
use Psr\Log\NullLogger;
2733
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
2834

2935
/**
@@ -110,21 +116,87 @@
110116
*
111117
* @author Teoh Han Hui <teohhanhui@gmail.com>
112118
* @author Alan Poulain <contact@alanpoulain.eu>
113-
*
114-
* @deprecated since API Platform 4.4: extending {@see AbstractFilter} is deprecated. In 5.0 this filter is rewritten as a standalone filter (reading its value from the QueryParameter instead of the legacy `context['filters']` lookup) — same class name, same URL syntax, drop-in. Declare it through a QueryParameter to migrate.
115119
*/
116-
final class ExistsFilter extends AbstractFilter implements ExistsFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface
120+
final class ExistsFilter implements ExistsFilterInterface, FilterInterface, JsonSchemaFilterInterface, ManagerRegistryAwareInterface, NameConverterAwareInterface, OpenApiParameterFilterInterface, PropertyAwareFilterInterface
117121
{
118122
use ExistsFilterTrait;
123+
use ManagerRegistryAwareTrait;
124+
use MongoDbOdmPropertyHelperTrait;
119125
use PropertyPlaceholderOpenApiParameterTrait;
120126

121-
public function __construct(?ManagerRegistry $managerRegistry = null, ?LoggerInterface $logger = null, ?array $properties = null, string $existsParameterName = self::QUERY_PARAMETER_KEY, ?NameConverterInterface $nameConverter = null)
122-
{
123-
parent::__construct($managerRegistry, $logger, $properties, $nameConverter);
127+
private LoggerInterface $logger;
124128

129+
/**
130+
* @param array<string, mixed>|null $properties
131+
*/
132+
public function __construct(?ManagerRegistry $managerRegistry = null, ?LoggerInterface $logger = null, private ?array $properties = null, string $existsParameterName = self::QUERY_PARAMETER_KEY, private ?NameConverterInterface $nameConverter = null)
133+
{
134+
$this->managerRegistry = $managerRegistry;
135+
$this->logger = $logger ?? new NullLogger();
125136
$this->existsParameterName = $existsParameterName;
126137
}
127138

139+
public function getProperties(): ?array
140+
{
141+
return $this->properties;
142+
}
143+
144+
/**
145+
* @param array<string, mixed> $properties
146+
*/
147+
public function setProperties(array $properties): void
148+
{
149+
$this->properties = $properties;
150+
}
151+
152+
public function hasNameConverter(): bool
153+
{
154+
return $this->nameConverter instanceof NameConverterInterface;
155+
}
156+
157+
public function getNameConverter(): ?NameConverterInterface
158+
{
159+
return $this->nameConverter;
160+
}
161+
162+
public function setNameConverter(NameConverterInterface $nameConverter): void
163+
{
164+
$this->nameConverter = $nameConverter;
165+
}
166+
167+
protected function getLogger(): LoggerInterface
168+
{
169+
return $this->logger;
170+
}
171+
172+
protected function isPropertyEnabled(string $property, string $resourceClass): bool
173+
{
174+
if (null === $this->properties) {
175+
// to ensure sanity, nested properties must still be explicitly enabled
176+
return !$this->isPropertyNested($property, $resourceClass);
177+
}
178+
179+
return \array_key_exists($property, $this->properties);
180+
}
181+
182+
protected function denormalizePropertyName(string|int $property): string
183+
{
184+
if (!$this->nameConverter instanceof NameConverterInterface) {
185+
return (string) $property;
186+
}
187+
188+
return implode('.', array_map($this->nameConverter->denormalize(...), explode('.', (string) $property)));
189+
}
190+
191+
protected function normalizePropertyName(string $property): string
192+
{
193+
if (!$this->nameConverter instanceof NameConverterInterface) {
194+
return $property;
195+
}
196+
197+
return implode('.', array_map($this->nameConverter->normalize(...), explode('.', $property)));
198+
}
199+
128200
/**
129201
* {@inheritdoc}
130202
*/

src/Doctrine/Orm/Filter/ExistsFilter.php

Lines changed: 122 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,29 @@
1515

1616
use ApiPlatform\Doctrine\Common\Filter\ExistsFilterInterface;
1717
use ApiPlatform\Doctrine\Common\Filter\ExistsFilterTrait;
18+
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface;
19+
use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait;
20+
use ApiPlatform\Doctrine\Common\Filter\NameConverterAwareInterface;
21+
use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface;
1822
use ApiPlatform\Doctrine\Common\Filter\PropertyPlaceholderOpenApiParameterTrait;
1923
use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper;
2024
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
25+
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
2126
use ApiPlatform\Metadata\JsonSchemaFilterInterface;
2227
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
2328
use ApiPlatform\Metadata\Operation;
2429
use ApiPlatform\Metadata\Parameter;
30+
use Doctrine\ORM\EntityManagerInterface;
2531
use Doctrine\ORM\Mapping\AssociationMapping;
2632
use Doctrine\ORM\Mapping\ClassMetadata;
2733
use Doctrine\ORM\Mapping\ManyToManyOwningSideMapping;
2834
use Doctrine\ORM\Mapping\ToOneOwningSideMapping;
2935
use Doctrine\ORM\Query\Expr\Join;
3036
use Doctrine\ORM\QueryBuilder;
3137
use Doctrine\Persistence\ManagerRegistry;
38+
use Doctrine\Persistence\Mapping\ClassMetadata as LegacyClassMetadata;
3239
use Psr\Log\LoggerInterface;
40+
use Psr\Log\NullLogger;
3341
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
3442

3543
/**
@@ -116,26 +124,134 @@
116124
* Given that the collection endpoint is `/books`, you can filter books with the following query: `/books?exists[comment]=true`.
117125
*
118126
* @author Teoh Han Hui <teohhanhui@gmail.com>
119-
*
120-
* @deprecated since API Platform 4.4: extending {@see AbstractFilter} is deprecated. In 5.0 this filter is rewritten as a standalone filter (reading its value from the QueryParameter instead of the legacy `context['filters']` lookup) — same class name, same URL syntax, drop-in. Declare it through a QueryParameter to migrate.
121127
*/
122-
final class ExistsFilter extends AbstractFilter implements ExistsFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface
128+
final class ExistsFilter implements ExistsFilterInterface, FilterInterface, JsonSchemaFilterInterface, ManagerRegistryAwareInterface, NameConverterAwareInterface, OpenApiParameterFilterInterface, PropertyAwareFilterInterface
123129
{
124130
use ExistsFilterTrait;
131+
use ManagerRegistryAwareTrait;
125132
use PropertyPlaceholderOpenApiParameterTrait;
126133

127-
public function __construct(?ManagerRegistry $managerRegistry = null, ?LoggerInterface $logger = null, ?array $properties = null, string $existsParameterName = self::QUERY_PARAMETER_KEY, ?NameConverterInterface $nameConverter = null)
128-
{
129-
parent::__construct($managerRegistry, $logger, $properties, $nameConverter);
134+
private LoggerInterface $logger;
135+
136+
/**
137+
* Resolved from the QueryBuilder in apply(); metadata is read from it so the active filter path
138+
* never touches the injected ManagerRegistry (kept only for the deprecated getDescription() and
139+
* for BC injection through ManagerRegistryAwareInterface).
140+
*/
141+
private ?EntityManagerInterface $entityManager = null;
130142

143+
/**
144+
* @param array<string, mixed>|null $properties
145+
*/
146+
public function __construct(?ManagerRegistry $managerRegistry = null, ?LoggerInterface $logger = null, private ?array $properties = null, string $existsParameterName = self::QUERY_PARAMETER_KEY, private ?NameConverterInterface $nameConverter = null)
147+
{
148+
$this->managerRegistry = $managerRegistry;
149+
$this->logger = $logger ?? new NullLogger();
131150
$this->existsParameterName = $existsParameterName;
132151
}
133152

153+
public function getProperties(): ?array
154+
{
155+
return $this->properties;
156+
}
157+
158+
/**
159+
* @param array<string, mixed> $properties
160+
*/
161+
public function setProperties(array $properties): void
162+
{
163+
$this->properties = $properties;
164+
}
165+
166+
public function hasNameConverter(): bool
167+
{
168+
return $this->nameConverter instanceof NameConverterInterface;
169+
}
170+
171+
public function getNameConverter(): ?NameConverterInterface
172+
{
173+
return $this->nameConverter;
174+
}
175+
176+
public function setNameConverter(NameConverterInterface $nameConverter): void
177+
{
178+
$this->nameConverter = $nameConverter;
179+
}
180+
181+
protected function getLogger(): LoggerInterface
182+
{
183+
return $this->logger;
184+
}
185+
186+
protected function isPropertyEnabled(string $property, string $resourceClass): bool
187+
{
188+
if (null === $this->properties) {
189+
// to ensure sanity, nested properties must still be explicitly enabled
190+
return !$this->isPropertyNested($property, $resourceClass);
191+
}
192+
193+
return \array_key_exists($property, $this->properties);
194+
}
195+
196+
protected function denormalizePropertyName(string|int $property): string
197+
{
198+
if (!$this->nameConverter instanceof NameConverterInterface) {
199+
return (string) $property;
200+
}
201+
202+
return implode('.', array_map($this->nameConverter->denormalize(...), explode('.', (string) $property)));
203+
}
204+
205+
protected function normalizePropertyName(string $property): string
206+
{
207+
if (!$this->nameConverter instanceof NameConverterInterface) {
208+
return $property;
209+
}
210+
211+
return implode('.', array_map($this->nameConverter->normalize(...), explode('.', $property)));
212+
}
213+
214+
protected function getClassMetadata(string $resourceClass): LegacyClassMetadata
215+
{
216+
if ($this->entityManager instanceof EntityManagerInterface) {
217+
return $this->entityManager->getClassMetadata($resourceClass);
218+
}
219+
220+
// Legacy getDescription() runs without a QueryBuilder: fall back to the injected registry.
221+
if ($this->hasManagerRegistry() && ($manager = $this->getManagerRegistry()->getManagerForClass($resourceClass))) {
222+
return $manager->getClassMetadata($resourceClass);
223+
}
224+
225+
return new ClassMetadata($resourceClass);
226+
}
227+
228+
/**
229+
* @return array{0: string, 1: string, 2: string[]}
230+
*/
231+
protected function addJoinsForNestedProperty(string $property, string $rootAlias, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $joinType): array
232+
{
233+
$propertyParts = $this->splitPropertyParts($property, $resourceClass);
234+
$parentAlias = $rootAlias;
235+
$alias = null;
236+
237+
foreach ($propertyParts['associations'] as $association) {
238+
$alias = QueryBuilderHelper::addJoinOnce($queryBuilder, $queryNameGenerator, $parentAlias, $association, $joinType);
239+
$parentAlias = $alias;
240+
}
241+
242+
if (null === $alias) {
243+
throw new InvalidArgumentException(\sprintf('Cannot add joins for property "%s" - property is not nested.', $property));
244+
}
245+
246+
return [$alias, $propertyParts['field'], $propertyParts['associations']];
247+
}
248+
134249
/**
135250
* {@inheritdoc}
136251
*/
137252
public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
138253
{
254+
$this->entityManager = $queryBuilder->getEntityManager();
139255
$parameter = $context['parameter'] ?? null;
140256
$propertyKey = $parameter?->getProperty();
141257

tests/Functional/Parameters/ExistsFilterTest.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313

1414
namespace ApiPlatform\Tests\Functional\Parameters;
1515

16+
use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
17+
use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter;
1618
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
1719
use ApiPlatform\Tests\Fixtures\TestBundle\Document\FilteredExistsParameter as FilteredExistsParameterDocument;
1820
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilteredExistsParameter;
@@ -50,6 +52,14 @@ protected function setUp(): void
5052
$this->loadFixtures($entityClass);
5153
}
5254

55+
public function testExistsFilterIsStandalone(): void
56+
{
57+
self::assertFalse(
58+
is_subclass_of(ExistsFilter::class, AbstractFilter::class),
59+
'ExistsFilter must not extend the deprecated AbstractFilter (5.0 standalone rewrite).'
60+
);
61+
}
62+
5363
#[DataProvider('existsFilterScenariosProvider')]
5464
public function testExistsFilterResponses(string $url, int $expectedCount): void
5565
{

0 commit comments

Comments
 (0)