Skip to content

Commit cec6ecc

Browse files
committed
Use filters on nested properties
1 parent 57128e8 commit cec6ecc

7 files changed

Lines changed: 462 additions & 0 deletions

File tree

doc/filters.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ explicitly::
3535
use EasyCorp\Bundle\EasyAdminBundle\Config\Filters;
3636
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
3737
use EasyCorp\Bundle\EasyAdminBundle\Filter\BooleanFilter;
38+
use EasyCorp\Bundle\EasyAdminBundle\Filter\NestedFilter;
39+
use EasyCorp\Bundle\EasyAdminBundle\Filter\TextFilter;
3840

3941
class ProductCrudController extends AbstractCrudController
4042
{
@@ -48,6 +50,11 @@ explicitly::
4850
// most of the times there is no need to define the
4951
// filter type because EasyAdmin can guess it automatically
5052
->add(BooleanFilter::new('published'))
53+
54+
// Use filter on nested property
55+
->add(NestedFilter::wrap(
56+
TextFilter::new('options.name')
57+
))
5158
;
5259
}
5360
}
@@ -79,6 +86,9 @@ These are the built-in filters provided by EasyAdmin:
7986
* ``TextFilter``: applied by default to string/text fields. It's rendered as a
8087
``<select>`` list with the condition (contains/not contains/etc.) and an ``<input>`` or
8188
``<textarea>`` to define the comparison value.
89+
* ``NestedFilter``: A wrapper allowing to use any filters on nested properties.
90+
This filter is able to apply left joins until the last property in the given path
91+
and let the wrapped filter applies its conditions to query.
8292

8393
Custom Filters
8494
--------------

src/Dto/FilterDataDto.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ public static function new(int $index, FilterDto $filterDto, string $entityAlias
3232
return $filterData;
3333
}
3434

35+
public function getIndex(): int
36+
{
37+
return $this->index;
38+
}
39+
3540
public function getEntityAlias(): string
3641
{
3742
return $this->entityAlias;
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<?php
2+
3+
namespace EasyCorp\Bundle\EasyAdminBundle\Filter\Configurator;
4+
5+
use Doctrine\Persistence\ManagerRegistry;
6+
use Doctrine\Persistence\ObjectManager;
7+
use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext;
8+
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Filter\FilterConfiguratorInterface;
9+
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Filter\FilterInterface;
10+
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
11+
use EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto;
12+
use EasyCorp\Bundle\EasyAdminBundle\Dto\FilterDto;
13+
use EasyCorp\Bundle\EasyAdminBundle\Filter\NestedFilter;
14+
15+
/**
16+
* @author Brandon Marcachi <brandon.marcachi@gmail.com>
17+
*/
18+
final class NestedConfigurator implements FilterConfiguratorInterface
19+
{
20+
private $doctrine;
21+
private $filterConfigurators;
22+
23+
public function __construct(ManagerRegistry $doctrine, iterable $filterConfigurators = [])
24+
{
25+
$this->doctrine = $doctrine;
26+
$this->filterConfigurators = $filterConfigurators;
27+
}
28+
29+
public function supports(FilterDto $filterDto, ?FieldDto $fieldDto, EntityDto $entityDto, AdminContext $context): bool
30+
{
31+
return NestedFilter::class === $filterDto->getFqcn();
32+
}
33+
34+
public function configure(FilterDto $filterDto, ?FieldDto $fieldDto, EntityDto $entityDto, AdminContext $context): void
35+
{
36+
$entityFqcn = $entityDto->getFqcn();
37+
38+
[$targetClassMetadata, $targetProperty] = NestedFilter::extractTargets(
39+
$this->getObjectManager($entityFqcn),
40+
$entityFqcn,
41+
$filterDto->getProperty()
42+
);
43+
44+
$wrappedEntityDto = new EntityDto($targetClassMetadata->getName(), $targetClassMetadata);
45+
$wrappedFilter = $this->extractWrappedFilter($filterDto);
46+
$wrappedFilterDto = $wrappedFilter->getAsDto();
47+
$wrappedFilterDto->setProperty($targetProperty);
48+
49+
$this->configureFilter($wrappedFilterDto, $wrappedEntityDto, $context);
50+
51+
$filterDto->setFormType($wrappedFilterDto->getFormType());
52+
$filterDto->setFormTypeOptions($wrappedFilterDto->getFormTypeOptions());
53+
54+
$this->removeWrappedFilterOption($filterDto);
55+
}
56+
57+
private function extractWrappedFilter(FilterDto $filterDto): FilterInterface
58+
{
59+
return $filterDto->getFormTypeOption(NestedFilter::FORM_OPTION_WRAPPED_FILTER);
60+
}
61+
62+
private function removeWrappedFilterOption(FilterDto $filterDto): void
63+
{
64+
[$root, $filterKey] = explode('.', NestedFilter::FORM_OPTION_WRAPPED_FILTER);
65+
66+
$data = $filterDto->getFormTypeOption($root);
67+
unset($data[$filterKey]);
68+
$filterDto->setFormTypeOption($root, $data);
69+
}
70+
71+
private function configureFilter(FilterDto $filterDto, EntityDto $entityDto, AdminContext $context): void
72+
{
73+
foreach ($this->filterConfigurators as $configurator) {
74+
if ($configurator->supports($filterDto, null, $entityDto, $context)) {
75+
$configurator->configure($filterDto, null, $entityDto, $context);
76+
}
77+
}
78+
}
79+
80+
private function getObjectManager(string $entityFqcn): ObjectManager
81+
{
82+
if (null === $objectManager = $this->doctrine->getManagerForClass($entityFqcn)) {
83+
throw new \RuntimeException(sprintf('There is no Doctrine Object Manager defined for the "%s" class.', $entityFqcn));
84+
}
85+
86+
return $objectManager;
87+
}
88+
}

src/Filter/NestedFilter.php

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
<?php
2+
3+
namespace EasyCorp\Bundle\EasyAdminBundle\Filter;
4+
5+
use Doctrine\ORM\QueryBuilder;
6+
use Doctrine\Persistence\ObjectManager;
7+
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Filter\FilterInterface;
8+
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
9+
use EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto;
10+
use EasyCorp\Bundle\EasyAdminBundle\Dto\FilterDataDto;
11+
12+
/**
13+
* @author Brandon Marcachi <brandon.marcachi@gmail.com>
14+
*/
15+
final class NestedFilter implements FilterInterface
16+
{
17+
use FilterTrait;
18+
19+
public const FORM_OPTION_WRAPPED_FILTER = 'attr.wrapped_filter';
20+
21+
public const PATH_SEPARATOR_EXPECTED = '.';
22+
public const PATH_SEPARATOR = '_';
23+
24+
/** @var FilterInterface */
25+
private $wrappedFilter;
26+
27+
public static function new(string $propertyName, string $label = null): self
28+
{
29+
throw new \RuntimeException('Instead of this method, use the "wrap()" method.');
30+
}
31+
32+
public static function wrap(FilterInterface $filter): FilterInterface
33+
{
34+
$filterDto = $filter->getAsDto();
35+
$property = $filterDto->getProperty();
36+
37+
if (false === strpos($property, self::PATH_SEPARATOR_EXPECTED)) {
38+
return $filter;
39+
}
40+
41+
return (new self())
42+
->setFilterFqcn(__CLASS__)
43+
->setProperty($property)
44+
->setFormType($filterDto->getFormType())
45+
->setFormTypeOptions($filterDto->getFormTypeOptions())
46+
->setWrappedFilter($filter)
47+
;
48+
}
49+
50+
public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto, ?FieldDto $fieldDto, EntityDto $entityDto): void
51+
{
52+
$propertyPath = $filterDataDto->getProperty();
53+
54+
[$targetClassMetadata, $targetProperty] = self::extractTargets(
55+
$queryBuilder->getEntityManager(),
56+
$entityDto->getFqcn(),
57+
$propertyPath
58+
);
59+
60+
$wrappedEntityDto = new EntityDto($targetClassMetadata->getName(), $targetClassMetadata);
61+
$wrappedFilter = $this->getWrappedFilter();
62+
$wrappedFilterDto = $wrappedFilter->getAsDto();
63+
$wrappedFilterDto->setProperty($targetProperty);
64+
65+
// Apply required left joins and get the alias we have to work with
66+
$alias = $this->applyLeftJoins($queryBuilder, $filterDataDto->getEntityAlias(), $propertyPath);
67+
68+
// Recreate FilterDataDto adapted for the wrapped filter
69+
$wrappedFilterDataDto = FilterDataDto::new($filterDataDto->getIndex(), $wrappedFilterDto, $alias, [
70+
'value' => $filterDataDto->getValue(),
71+
'value2' => $filterDataDto->getValue2(),
72+
'comparison' => $filterDataDto->getComparison(),
73+
]);
74+
75+
$wrappedFilterDto->apply($queryBuilder, $wrappedFilterDataDto, null, $wrappedEntityDto);
76+
}
77+
78+
public static function extractTargets(ObjectManager $objectManager, string $class, string $propertyPath): array
79+
{
80+
$segments = explode(self::PATH_SEPARATOR, $propertyPath);
81+
$metadata = $objectManager->getClassMetadata($class);
82+
$lastIndex = \count($segments) - 1;
83+
$property = null;
84+
85+
foreach ($segments as $i => $prop) {
86+
if (!$metadata->hasField($prop) && !$metadata->hasAssociation($prop)) {
87+
self::throwInvalidPropertyPathException($propertyPath, $class);
88+
}
89+
90+
// The target property must be at the end of path
91+
if ($i === $lastIndex) {
92+
$property = $prop;
93+
break;
94+
}
95+
96+
if (!$metadata->hasAssociation($prop)) {
97+
self::throwInvalidPropertyPathException($propertyPath, $class);
98+
}
99+
100+
// Move to next nested class
101+
$metadata = $objectManager->getClassMetadata($metadata->getAssociationTargetClass($prop));
102+
}
103+
104+
return [$metadata, $property];
105+
}
106+
107+
public function setWrappedFilter(FilterInterface $filter): self
108+
{
109+
$this->wrappedFilter = $filter;
110+
111+
return $this->setFormTypeOption(self::FORM_OPTION_WRAPPED_FILTER, $filter);
112+
}
113+
114+
public function getWrappedFilter(): FilterInterface
115+
{
116+
return $this->wrappedFilter;
117+
}
118+
119+
public function setProperty(string $propertyName): self
120+
{
121+
// Replace dots with underscore to avoid errors
122+
$this->dto->setProperty(
123+
str_replace(self::PATH_SEPARATOR_EXPECTED, self::PATH_SEPARATOR, $propertyName)
124+
);
125+
126+
return $this;
127+
}
128+
129+
private function applyLeftJoins(QueryBuilder $qb, string $alias, string $propertyPath): string
130+
{
131+
$path = explode(self::PATH_SEPARATOR, $propertyPath);
132+
$lastIndex = \count($path) - 1;
133+
$currentAlias = $alias;
134+
135+
foreach ($path as $i => $prop) {
136+
if ($i === $lastIndex) {
137+
break;
138+
}
139+
140+
$nextAlias = sprintf('%s_%s', $currentAlias, $prop);
141+
if (!\in_array($nextAlias, $qb->getAllAliases(), true)) {
142+
$qb->leftJoin(sprintf('%s.%s', $currentAlias, $prop), $nextAlias);
143+
}
144+
145+
$currentAlias = $nextAlias;
146+
}
147+
148+
return $currentAlias;
149+
}
150+
151+
private static function throwInvalidPropertyPathException(string $propertyPath, string $class): void
152+
{
153+
throw new \InvalidArgumentException(sprintf(
154+
'The property path "%s" for class "%s" is invalid.',
155+
str_replace(self::PATH_SEPARATOR, self::PATH_SEPARATOR_EXPECTED, $propertyPath),
156+
$class
157+
));
158+
}
159+
}

src/Resources/config/services.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
use EasyCorp\Bundle\EasyAdminBundle\Filter\Configurator\ComparisonConfigurator as ComparisonFilterConfigurator;
5656
use EasyCorp\Bundle\EasyAdminBundle\Filter\Configurator\DateTimeConfigurator as DateTimeFilterConfigurator;
5757
use EasyCorp\Bundle\EasyAdminBundle\Filter\Configurator\EntityConfigurator as EntityFilterConfigurator;
58+
use EasyCorp\Bundle\EasyAdminBundle\Filter\Configurator\NestedConfigurator as NestedFilterConfigurator;
5859
use EasyCorp\Bundle\EasyAdminBundle\Filter\Configurator\NullConfigurator as NullFilterConfigurator;
5960
use EasyCorp\Bundle\EasyAdminBundle\Filter\Configurator\NumericConfigurator as NumericFilterConfigurator;
6061
use EasyCorp\Bundle\EasyAdminBundle\Filter\Configurator\TextConfigurator as TextFilterConfigurator;
@@ -286,6 +287,12 @@
286287

287288
->set(TextFilterConfigurator::class)
288289

290+
->set(NestedFilterConfigurator::class)
291+
->arg(0, new Reference('doctrine'))
292+
->arg(1, \function_exists('tagged')
293+
? tagged(EasyAdminExtension::TAG_FILTER_CONFIGURATOR)
294+
: tagged_iterator(EasyAdminExtension::TAG_FILTER_CONFIGURATOR))
295+
289296
->set(ActionFactory::class)
290297
->arg(0, new Reference(AdminContextProvider::class))
291298
->arg(1, new Reference(AuthorizationChecker::class))
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
namespace EasyCorp\Bundle\EasyAdminBundle\Tests\Filter;
4+
5+
use Doctrine\Bundle\DoctrineBundle\Registry;
6+
use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext;
7+
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
8+
use EasyCorp\Bundle\EasyAdminBundle\Filter\Configurator\NestedConfigurator;
9+
use EasyCorp\Bundle\EasyAdminBundle\Filter\NestedFilter;
10+
use EasyCorp\Bundle\EasyAdminBundle\Filter\TextFilter;
11+
use EasyCorp\Bundle\EasyAdminBundle\Tests\TestApplication\Entity\User;
12+
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
13+
14+
class NestedConfiguratorTest extends KernelTestCase
15+
{
16+
/** @var Registry */
17+
private $doctrine;
18+
19+
/** @var NestedConfigurator */
20+
private $nestedConfigurator;
21+
22+
protected function setUp(): void
23+
{
24+
self::bootKernel();
25+
26+
$container = self::getContainer();
27+
28+
$this->doctrine = $container->get('doctrine');
29+
$this->nestedConfigurator = $container->get(NestedConfigurator::class);
30+
}
31+
32+
public function testConfigure()
33+
{
34+
$class = User::class;
35+
$attr = ['class' => 'foo'];
36+
37+
$textFilter = TextFilter::new('blogPosts.categories.name');
38+
$textFilter->setFormTypeOption('attr', $attr);
39+
40+
$nestedFilter = NestedFilter::wrap($textFilter);
41+
42+
$objectManager = $this->doctrine->getManagerForClass($class);
43+
$entityDto = new EntityDto($class, $objectManager->getClassMetadata($class));
44+
$adminContext = $this->getMockBuilder(AdminContext::class)->disableOriginalConstructor()->getMock();
45+
46+
$wrappedFilter = $nestedFilter->getWrappedFilter();
47+
$wrappedFilterDto = $wrappedFilter->getAsDto();
48+
$nestedFilterDto = $nestedFilter->getAsDto();
49+
50+
self::assertEquals('blogPosts.categories.name', $wrappedFilterDto->getProperty());
51+
52+
$this->nestedConfigurator->configure($nestedFilter->getAsDto(), null, $entityDto, $adminContext);
53+
54+
self::assertEquals('name', $wrappedFilterDto->getProperty());
55+
self::assertEquals('blogPosts_categories_name', $nestedFilterDto->getProperty());
56+
57+
self::assertEquals($wrappedFilterDto->getFormType(), $nestedFilterDto->getFormType());
58+
self::assertEquals($wrappedFilterDto->getFormTypeOptions(), $nestedFilterDto->getFormTypeOptions());
59+
}
60+
}

0 commit comments

Comments
 (0)