Skip to content

Commit c395c51

Browse files
committed
refactor(doctrine): make DateFilter standalone, drop AbstractFilter
DateFilter (ORM + ODM) stops extending the deprecated AbstractFilter, the last of the three 5.0 survivors (after ExistsFilter). It stays a genuine survivor rather than a ComparisonFilter overlay: its date semantics — isDateField, \DateTime/\DateTimeImmutable coercion by column immutability, typed setParameter, and the INCLUDE_NULL_* null-management — are not expressible through ComparisonFilter. The decouple is structural: the AbstractFilter::apply() loop is reintroduced as a local apply() (driving filterProperty over $context['filters'], the universal value source for both legacy and QueryParameter declarations), and the inherited helpers (ctor, properties accessors, isPropertyEnabled, name-converter handling, logger) are inlined. ManagerRegistryAwareInterface is kept for BC injection and the deprecated getDescription(); the ORM filter reads class metadata from $queryBuilder->getEntityManager() so the active path no longer depends on the registry, while ODM keeps it (its aggregation Builder exposes no DocumentManager).
1 parent 5b59c6c commit c395c51

5 files changed

Lines changed: 248 additions & 10 deletions

File tree

src/Doctrine/Odm/Filter/DateFilter.php

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515

1616
use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
1717
use ApiPlatform\Doctrine\Common\Filter\DateFilterTrait;
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;
22+
use ApiPlatform\Doctrine\Odm\PropertyHelperTrait as MongoDbOdmPropertyHelperTrait;
1823
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
1924
use ApiPlatform\Metadata\JsonSchemaFilterInterface;
2025
use ApiPlatform\Metadata\OpenApiParameterFilterInterface;
@@ -23,6 +28,10 @@
2328
use ApiPlatform\Metadata\QueryParameter;
2429
use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter;
2530
use Doctrine\ODM\MongoDB\Aggregation\Builder;
31+
use Doctrine\Persistence\ManagerRegistry;
32+
use Psr\Log\LoggerInterface;
33+
use Psr\Log\NullLogger;
34+
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
2635

2736
/**
2837
* The date filter allows to filter a collection by date intervals.
@@ -120,20 +129,101 @@
120129
* @author Kévin Dunglas <dunglas@gmail.com>
121130
* @author Théo FIDRY <theo.fidry@gmail.com>
122131
* @author Alan Poulain <contact@alanpoulain.eu>
123-
*
124-
* @deprecated since API Platform 4.4: extending {@see AbstractFilter} is deprecated. In 5.0 this filter is rewritten as a standalone overlay over {@see ComparisonFilter} (translating the `[before]`/`[strictly_before]`/`[after]`/`[strictly_after]` syntax) — same class name, same URL syntax, drop-in. Declare it through a QueryParameter to migrate.
125132
*/
126-
final class DateFilter extends AbstractFilter implements DateFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface
133+
final class DateFilter implements DateFilterInterface, FilterInterface, JsonSchemaFilterInterface, ManagerRegistryAwareInterface, NameConverterAwareInterface, OpenApiParameterFilterInterface, PropertyAwareFilterInterface
127134
{
128135
use DateFilterTrait;
136+
use ManagerRegistryAwareTrait;
137+
use MongoDbOdmPropertyHelperTrait;
129138

130139
public const DOCTRINE_DATE_TYPES = [
131140
'date' => true,
132141
'date_immutable' => true,
133142
];
134143

144+
private LoggerInterface $logger;
145+
146+
/**
147+
* @param array<string, mixed>|null $properties
148+
*/
149+
public function __construct(?ManagerRegistry $managerRegistry = null, ?LoggerInterface $logger = null, private ?array $properties = null, private ?NameConverterInterface $nameConverter = null)
150+
{
151+
$this->managerRegistry = $managerRegistry;
152+
$this->logger = $logger ?? new NullLogger();
153+
}
154+
155+
public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
156+
{
157+
foreach ($context['filters'] ?? [] as $property => $value) {
158+
$this->filterProperty($this->denormalizePropertyName($property), $value, $aggregationBuilder, $resourceClass, $operation, $context);
159+
}
160+
}
161+
162+
public function getProperties(): ?array
163+
{
164+
return $this->properties;
165+
}
166+
167+
/**
168+
* @param array<string, mixed> $properties
169+
*/
170+
public function setProperties(array $properties): void
171+
{
172+
$this->properties = $properties;
173+
}
174+
175+
public function hasNameConverter(): bool
176+
{
177+
return $this->nameConverter instanceof NameConverterInterface;
178+
}
179+
180+
public function getNameConverter(): ?NameConverterInterface
181+
{
182+
return $this->nameConverter;
183+
}
184+
185+
public function setNameConverter(NameConverterInterface $nameConverter): void
186+
{
187+
$this->nameConverter = $nameConverter;
188+
}
189+
190+
protected function getLogger(): LoggerInterface
191+
{
192+
return $this->logger;
193+
}
194+
195+
protected function isPropertyEnabled(string $property, string $resourceClass): bool
196+
{
197+
if (null === $this->properties) {
198+
// to ensure sanity, nested properties must still be explicitly enabled
199+
return !$this->isPropertyNested($property, $resourceClass);
200+
}
201+
202+
return \array_key_exists($property, $this->properties);
203+
}
204+
205+
protected function denormalizePropertyName(string|int $property): string
206+
{
207+
if (!$this->nameConverter instanceof NameConverterInterface) {
208+
return (string) $property;
209+
}
210+
211+
return implode('.', array_map($this->nameConverter->denormalize(...), explode('.', (string) $property)));
212+
}
213+
214+
protected function normalizePropertyName(string $property): string
215+
{
216+
if (!$this->nameConverter instanceof NameConverterInterface) {
217+
return $property;
218+
}
219+
220+
return implode('.', array_map($this->nameConverter->normalize(...), explode('.', $property)));
221+
}
222+
135223
/**
136-
* {@inheritdoc}
224+
* @param array<string, mixed> $context
225+
*
226+
* @param-out array<string, mixed> $context
137227
*/
138228
protected function filterProperty(string $property, mixed $value, Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
139229
{

src/Doctrine/Odm/Filter/ExistsFilter.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,9 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera
215215
}
216216

217217
/**
218-
* {@inheritdoc}
218+
* @param array<string, mixed> $context
219+
*
220+
* @param-out array<string, mixed> $context
219221
*/
220222
protected function filterProperty(string $property, mixed $value, Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void
221223
{

src/Doctrine/Orm/Filter/DateFilter.php

Lines changed: 137 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515

1616
use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
1717
use ApiPlatform\Doctrine\Common\Filter\DateFilterTrait;
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;
22+
use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper;
1823
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
1924
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
2025
use ApiPlatform\Metadata\JsonSchemaFilterInterface;
@@ -25,8 +30,15 @@
2530
use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter;
2631
use Doctrine\DBAL\Types\Type as DBALType;
2732
use Doctrine\DBAL\Types\Types;
33+
use Doctrine\ORM\EntityManagerInterface;
34+
use Doctrine\ORM\Mapping\ClassMetadata;
2835
use Doctrine\ORM\Query\Expr\Join;
2936
use Doctrine\ORM\QueryBuilder;
37+
use Doctrine\Persistence\ManagerRegistry;
38+
use Doctrine\Persistence\Mapping\ClassMetadata as LegacyClassMetadata;
39+
use Psr\Log\LoggerInterface;
40+
use Psr\Log\NullLogger;
41+
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
3042

3143
/**
3244
* The date filter allows to filter a collection by date intervals.
@@ -124,12 +136,11 @@
124136
*
125137
* @author Kévin Dunglas <dunglas@gmail.com>
126138
* @author Théo FIDRY <theo.fidry@gmail.com>
127-
*
128-
* @deprecated since API Platform 4.4: extending {@see AbstractFilter} is deprecated. In 5.0 this filter is rewritten as a standalone overlay over {@see ComparisonFilter} (translating the `[before]`/`[strictly_before]`/`[after]`/`[strictly_after]` syntax) — same class name, same URL syntax, drop-in. Declare it through a QueryParameter to migrate.
129139
*/
130-
final class DateFilter extends AbstractFilter implements DateFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface
140+
final class DateFilter implements DateFilterInterface, FilterInterface, JsonSchemaFilterInterface, ManagerRegistryAwareInterface, NameConverterAwareInterface, OpenApiParameterFilterInterface, PropertyAwareFilterInterface
131141
{
132142
use DateFilterTrait;
143+
use ManagerRegistryAwareTrait;
133144

134145
public const DOCTRINE_DATE_TYPES = [
135146
Types::DATE_MUTABLE => true,
@@ -142,6 +153,129 @@ final class DateFilter extends AbstractFilter implements DateFilterInterface, Js
142153
Types::TIME_IMMUTABLE => true,
143154
];
144155

156+
private LoggerInterface $logger;
157+
158+
/**
159+
* Resolved from the QueryBuilder in apply(); metadata is read from it so the active filter path
160+
* never touches the injected ManagerRegistry (kept only for the deprecated getDescription() and
161+
* for BC injection through ManagerRegistryAwareInterface).
162+
*/
163+
private ?EntityManagerInterface $entityManager = null;
164+
165+
/**
166+
* @param array<string, mixed>|null $properties
167+
*/
168+
public function __construct(?ManagerRegistry $managerRegistry = null, ?LoggerInterface $logger = null, private ?array $properties = null, private ?NameConverterInterface $nameConverter = null)
169+
{
170+
$this->managerRegistry = $managerRegistry;
171+
$this->logger = $logger ?? new NullLogger();
172+
}
173+
174+
public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
175+
{
176+
$this->entityManager = $queryBuilder->getEntityManager();
177+
178+
foreach ($context['filters'] ?? [] as $property => $value) {
179+
$this->filterProperty($this->denormalizePropertyName($property), $value, $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context);
180+
}
181+
}
182+
183+
public function getProperties(): ?array
184+
{
185+
return $this->properties;
186+
}
187+
188+
/**
189+
* @param array<string, mixed> $properties
190+
*/
191+
public function setProperties(array $properties): void
192+
{
193+
$this->properties = $properties;
194+
}
195+
196+
public function hasNameConverter(): bool
197+
{
198+
return $this->nameConverter instanceof NameConverterInterface;
199+
}
200+
201+
public function getNameConverter(): ?NameConverterInterface
202+
{
203+
return $this->nameConverter;
204+
}
205+
206+
public function setNameConverter(NameConverterInterface $nameConverter): void
207+
{
208+
$this->nameConverter = $nameConverter;
209+
}
210+
211+
protected function getLogger(): LoggerInterface
212+
{
213+
return $this->logger;
214+
}
215+
216+
protected function isPropertyEnabled(string $property, string $resourceClass): bool
217+
{
218+
if (null === $this->properties) {
219+
// to ensure sanity, nested properties must still be explicitly enabled
220+
return !$this->isPropertyNested($property, $resourceClass);
221+
}
222+
223+
return \array_key_exists($property, $this->properties);
224+
}
225+
226+
protected function denormalizePropertyName(string|int $property): string
227+
{
228+
if (!$this->nameConverter instanceof NameConverterInterface) {
229+
return (string) $property;
230+
}
231+
232+
return implode('.', array_map($this->nameConverter->denormalize(...), explode('.', (string) $property)));
233+
}
234+
235+
protected function normalizePropertyName(string $property): string
236+
{
237+
if (!$this->nameConverter instanceof NameConverterInterface) {
238+
return $property;
239+
}
240+
241+
return implode('.', array_map($this->nameConverter->normalize(...), explode('.', $property)));
242+
}
243+
244+
protected function getClassMetadata(string $resourceClass): LegacyClassMetadata
245+
{
246+
if ($this->entityManager instanceof EntityManagerInterface) {
247+
return $this->entityManager->getClassMetadata($resourceClass);
248+
}
249+
250+
// Legacy getDescription() runs without a QueryBuilder: fall back to the injected registry.
251+
if ($this->hasManagerRegistry() && ($manager = $this->getManagerRegistry()->getManagerForClass($resourceClass))) {
252+
return $manager->getClassMetadata($resourceClass);
253+
}
254+
255+
return new ClassMetadata($resourceClass);
256+
}
257+
258+
/**
259+
* @return array{0: string, 1: string, 2: string[]}
260+
*/
261+
protected function addJoinsForNestedProperty(string $property, string $rootAlias, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $joinType): array
262+
{
263+
$propertyParts = $this->splitPropertyParts($property, $resourceClass);
264+
$parentAlias = $rootAlias;
265+
$alias = null;
266+
267+
foreach ($propertyParts['associations'] as $association) {
268+
$alias = QueryBuilderHelper::addJoinOnce($queryBuilder, $queryNameGenerator, $parentAlias, $association, $joinType);
269+
$parentAlias = $alias;
270+
}
271+
272+
if (null === $alias) {
273+
throw new InvalidArgumentException(\sprintf('Cannot add joins for property "%s" - property is not nested.', $property));
274+
}
275+
276+
return [$alias, $propertyParts['field'], $propertyParts['associations']];
277+
}
278+
145279
/**
146280
* {@inheritdoc}
147281
*/

tests/Functional/Parameters/DateFilterTest.php

Lines changed: 11 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\DateFilter;
1618
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
1719
use ApiPlatform\Tests\Fixtures\TestBundle\Document\FilteredDateParameter as FilteredDateParameterDocument;
1820
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilteredDateParameter;
@@ -36,6 +38,15 @@ public static function getResources(): array
3638
return [FilteredDateParameter::class];
3739
}
3840

41+
public function testDateFilterIsStandalone(): void
42+
{
43+
self::assertNotContains(
44+
AbstractFilter::class,
45+
class_parents(DateFilter::class) ?: [],
46+
'DateFilter must not extend the deprecated AbstractFilter (5.0 standalone rewrite).'
47+
);
48+
}
49+
3950
/**
4051
* @throws \Throwable
4152
*/

tests/Functional/Parameters/ExistsFilterTest.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,9 @@ protected function setUp(): void
5454

5555
public function testExistsFilterIsStandalone(): void
5656
{
57-
self::assertFalse(
58-
is_subclass_of(ExistsFilter::class, AbstractFilter::class),
57+
self::assertNotContains(
58+
AbstractFilter::class,
59+
class_parents(ExistsFilter::class) ?: [],
5960
'ExistsFilter must not extend the deprecated AbstractFilter (5.0 standalone rewrite).'
6061
);
6162
}

0 commit comments

Comments
 (0)