From 2914a375fd571152d38503bdff1fe1e1e252bc92 Mon Sep 17 00:00:00 2001 From: soyuka Date: Sat, 6 Jun 2026 09:55:39 +0200 Subject: [PATCH] feat(doctrine): per-property filter map in FreeTextQueryFilter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accept `array` for `FreeTextQueryFilter::$filter` so a different filter can apply per property (e.g. partial search on `name`, exact match on `uuid`). Single-filter form unchanged — BC-safe. When the map form is used, the iterated properties default to the map keys; the existing `$properties` arg still wins when set. Refs #8249 --- .../Odm/Filter/FreeTextQueryFilter.php | 45 +++++++++++++++---- .../Orm/Filter/FreeTextQueryFilter.php | 45 +++++++++++++++---- .../Fixtures/TestBundle/Document/Chicken.php | 4 ++ tests/Fixtures/TestBundle/Entity/Chicken.php | 4 ++ .../Parameters/FreeTextQueryFilterTest.php | 17 +++++++ 5 files changed, 97 insertions(+), 18 deletions(-) diff --git a/src/Doctrine/Odm/Filter/FreeTextQueryFilter.php b/src/Doctrine/Odm/Filter/FreeTextQueryFilter.php index b4f545e8f83..4262dca5e45 100644 --- a/src/Doctrine/Odm/Filter/FreeTextQueryFilter.php +++ b/src/Doctrine/Odm/Filter/FreeTextQueryFilter.php @@ -28,24 +28,51 @@ final class FreeTextQueryFilter implements FilterInterface, ManagerRegistryAware use ManagerRegistryAwareTrait; /** - * @param list $properties an array of properties, defaults to `parameter->getProperties()` + * @param FilterInterface|array $filter a filter applied to every property, + * or a map of `property => filter` to use a + * dedicated filter per property + * @param list|null $properties an array of properties, defaults to + * the map keys when `$filter` is a map, + * otherwise to `parameter->getProperties()` */ - public function __construct(private readonly FilterInterface $filter, private readonly ?array $properties = null) + public function __construct(private readonly FilterInterface|array $filter, private readonly ?array $properties = null) { } public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void { - if ($this->filter instanceof ManagerRegistryAwareInterface) { - $this->filter->setManagerRegistry($this->getManagerRegistry()); - } + $filterMap = \is_array($this->filter) ? $this->filter : null; + + if (null === $filterMap) { + if ($this->filter instanceof ManagerRegistryAwareInterface) { + $this->filter->setManagerRegistry($this->getManagerRegistry()); + } - if ($this->filter instanceof LoggerAwareInterface) { - $this->filter->setLogger($this->getLogger()); + if ($this->filter instanceof LoggerAwareInterface) { + $this->filter->setLogger($this->getLogger()); + } } $parameter = $context['parameter']; - foreach ($this->properties ?? $parameter->getProperties() ?? [] as $property) { + $properties = $this->properties ?? (null !== $filterMap ? array_keys($filterMap) : $parameter->getProperties()) ?? []; + + foreach ($properties as $property) { + $filter = null !== $filterMap ? ($filterMap[$property] ?? null) : $this->filter; + + if (null === $filter) { + continue; + } + + if (null !== $filterMap) { + if ($filter instanceof ManagerRegistryAwareInterface) { + $filter->setManagerRegistry($this->getManagerRegistry()); + } + + if ($filter instanceof LoggerAwareInterface) { + $filter->setLogger($this->getLogger()); + } + } + $subParameter = $parameter->withProperty($property); $nestedPropertiesInfo = $parameter->getExtraProperties()['nested_properties_info'] ?? []; @@ -57,7 +84,7 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera ]); $newContext = ['parameter' => $subParameter, 'match' => $context['match'] ?? $aggregationBuilder->match()->expr()] + $context; - $this->filter->apply( + $filter->apply( $aggregationBuilder, $resourceClass, $operation, diff --git a/src/Doctrine/Orm/Filter/FreeTextQueryFilter.php b/src/Doctrine/Orm/Filter/FreeTextQueryFilter.php index a269ac41137..5f4b76e96a4 100644 --- a/src/Doctrine/Orm/Filter/FreeTextQueryFilter.php +++ b/src/Doctrine/Orm/Filter/FreeTextQueryFilter.php @@ -31,27 +31,54 @@ final class FreeTextQueryFilter implements FilterInterface, ManagerRegistryAware use ManagerRegistryAwareTrait; /** - * @param list $properties an array of properties, defaults to `parameter->getProperties()` + * @param FilterInterface|array $filter a filter applied to every property, + * or a map of `property => filter` to use a + * dedicated filter per property + * @param list|null $properties an array of properties, defaults to + * the map keys when `$filter` is a map, + * otherwise to `parameter->getProperties()` */ - public function __construct(private readonly FilterInterface $filter, private readonly ?array $properties = null) + public function __construct(private readonly FilterInterface|array $filter, private readonly ?array $properties = null) { } public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { - if ($this->filter instanceof ManagerRegistryAwareInterface) { - $this->filter->setManagerRegistry($this->getManagerRegistry()); - } + $filterMap = \is_array($this->filter) ? $this->filter : null; + + if (null === $filterMap) { + if ($this->filter instanceof ManagerRegistryAwareInterface) { + $this->filter->setManagerRegistry($this->getManagerRegistry()); + } - if ($this->filter instanceof LoggerAwareInterface) { - $this->filter->setLogger($this->getLogger()); + if ($this->filter instanceof LoggerAwareInterface) { + $this->filter->setLogger($this->getLogger()); + } } $parameter = $context['parameter']; $qb = clone $queryBuilder; $qb->resetDQLPart('where'); $qb->setParameters(new ArrayCollection()); - foreach ($this->properties ?? $parameter->getProperties() ?? [] as $property) { + $properties = $this->properties ?? (null !== $filterMap ? array_keys($filterMap) : $parameter->getProperties()) ?? []; + + foreach ($properties as $property) { + $filter = null !== $filterMap ? ($filterMap[$property] ?? null) : $this->filter; + + if (null === $filter) { + continue; + } + + if (null !== $filterMap) { + if ($filter instanceof ManagerRegistryAwareInterface) { + $filter->setManagerRegistry($this->getManagerRegistry()); + } + + if ($filter instanceof LoggerAwareInterface) { + $filter->setLogger($this->getLogger()); + } + } + $subParameter = $parameter->withProperty($property); $nestedPropertiesInfo = $parameter->getExtraProperties()['nested_properties_info'] ?? []; @@ -62,7 +89,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q : [], ]); - $this->filter->apply( + $filter->apply( $qb, $queryNameGenerator, $resourceClass, diff --git a/tests/Fixtures/TestBundle/Document/Chicken.php b/tests/Fixtures/TestBundle/Document/Chicken.php index c9385f1f41c..55fd33676c1 100644 --- a/tests/Fixtures/TestBundle/Document/Chicken.php +++ b/tests/Fixtures/TestBundle/Document/Chicken.php @@ -47,6 +47,10 @@ ), 'autocomplete' => new QueryParameter(filter: new FreeTextQueryFilter(new OrFilter(new ExactFilter())), properties: ['name', 'ean']), 'q' => new QueryParameter(filter: new FreeTextQueryFilter(new PartialSearchFilter()), properties: ['name', 'ean']), + 'qmixed' => new QueryParameter(filter: new FreeTextQueryFilter([ + 'name' => new OrFilter(new PartialSearchFilter()), + 'ean' => new OrFilter(new ExactFilter()), + ]), description: 'Partial name match or exact ean match'), 'ownerNamePartial' => new QueryParameter( filter: new PartialSearchFilter(), property: 'owner.name', diff --git a/tests/Fixtures/TestBundle/Entity/Chicken.php b/tests/Fixtures/TestBundle/Entity/Chicken.php index 3f785481b44..95f8e8f9598 100644 --- a/tests/Fixtures/TestBundle/Entity/Chicken.php +++ b/tests/Fixtures/TestBundle/Entity/Chicken.php @@ -47,6 +47,10 @@ ), 'autocomplete' => new QueryParameter(filter: new FreeTextQueryFilter(new OrFilter(new ExactFilter())), properties: ['name', 'ean']), 'q' => new QueryParameter(filter: new FreeTextQueryFilter(new PartialSearchFilter()), properties: ['name', 'ean']), + 'qmixed' => new QueryParameter(filter: new FreeTextQueryFilter([ + 'name' => new OrFilter(new PartialSearchFilter()), + 'ean' => new OrFilter(new ExactFilter()), + ]), description: 'Partial name match or exact ean match'), 'ownerNamePartial' => new QueryParameter( filter: new PartialSearchFilter(), property: 'owner.name', diff --git a/tests/Functional/Parameters/FreeTextQueryFilterTest.php b/tests/Functional/Parameters/FreeTextQueryFilterTest.php index 31e051b51f6..b09f8c6ef47 100644 --- a/tests/Functional/Parameters/FreeTextQueryFilterTest.php +++ b/tests/Functional/Parameters/FreeTextQueryFilterTest.php @@ -100,6 +100,23 @@ public function testFreeTextQueryFilterWithTwoLevelTraversalPartial(): void $this->assertCount(2, $response['member']); } + public function testFreeTextQueryFilterWithPerPropertyFilterMap(): void + { + $client = $this->createClient(); + + $response = $client->request('GET', '/chickens?qmixed=Henri')->toArray(); + $this->assertJsonContains(['totalItems' => 1]); + $this->assertSame('Henriette', $response['member'][0]['name']); + + $response = $client->request('GET', '/chickens?qmixed=978020137963')->toArray(); + $this->assertJsonContains(['totalItems' => 1]); + $this->assertSame('978020137963', $response['member'][0]['ean']); + + $response = $client->request('GET', '/chickens?qmixed=97802')->toArray(); + $this->assertJsonContains(['totalItems' => 1]); + $this->assertSame('978020137962', $response['member'][0]['name']); + } + public function testFreeTextQueryFilterWithTwoLevelTraversalPartialWithPropertyPlaceholder(): void { $client = $this->createClient();