Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions doc/filters.rst
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ explicitly::
use EasyCorp\Bundle\EasyAdminBundle\Config\Filters;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Filter\BooleanFilter;
use EasyCorp\Bundle\EasyAdminBundle\Filter\NestedFilter;
use EasyCorp\Bundle\EasyAdminBundle\Filter\TextFilter;

class ProductCrudController extends AbstractCrudController
{
Expand All @@ -48,6 +50,11 @@ explicitly::
// most of the times there is no need to define the
// filter type because EasyAdmin can guess it automatically
->add(BooleanFilter::new('published'))

// Use filter on nested property
->add(NestedFilter::wrap(
TextFilter::new('options.name')
))
;
}
}
Expand Down Expand Up @@ -79,6 +86,9 @@ These are the built-in filters provided by EasyAdmin:
* ``TextFilter``: applied by default to string/text fields. It's rendered as a
``<select>`` list with the condition (contains/not contains/etc.) and an ``<input>`` or
``<textarea>`` to define the comparison value.
* ``NestedFilter``: A wrapper allowing to use any filters on nested properties.
This filter is able to apply left joins until the last property in the given path
and let the wrapped filter applies its conditions to query.

Custom Filters
--------------
Expand Down
5 changes: 5 additions & 0 deletions src/Dto/FilterDataDto.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ public static function new(int $index, FilterDto $filterDto, string $entityAlias
return $filterData;
}

public function getIndex(): int
{
return $this->index;
}

public function getEntityAlias(): string
{
return $this->entityAlias;
Expand Down
88 changes: 88 additions & 0 deletions src/Filter/Configurator/NestedConfigurator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

namespace EasyCorp\Bundle\EasyAdminBundle\Filter\Configurator;

use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Persistence\ObjectManager;
use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Filter\FilterConfiguratorInterface;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Filter\FilterInterface;
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
use EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto;
use EasyCorp\Bundle\EasyAdminBundle\Dto\FilterDto;
use EasyCorp\Bundle\EasyAdminBundle\Filter\NestedFilter;

/**
* @author Brandon Marcachi <brandon.marcachi@gmail.com>
*/
final class NestedConfigurator implements FilterConfiguratorInterface
{
private $doctrine;
private $filterConfigurators;

public function __construct(ManagerRegistry $doctrine, iterable $filterConfigurators = [])
{
$this->doctrine = $doctrine;
$this->filterConfigurators = $filterConfigurators;
}

public function supports(FilterDto $filterDto, ?FieldDto $fieldDto, EntityDto $entityDto, AdminContext $context): bool
{
return NestedFilter::class === $filterDto->getFqcn();
}

public function configure(FilterDto $filterDto, ?FieldDto $fieldDto, EntityDto $entityDto, AdminContext $context): void
{
$entityFqcn = $entityDto->getFqcn();

[$targetClassMetadata, $targetProperty] = NestedFilter::extractTargets(
$this->getObjectManager($entityFqcn),
$entityFqcn,
$filterDto->getProperty()
);

$wrappedEntityDto = new EntityDto($targetClassMetadata->getName(), $targetClassMetadata);
$wrappedFilter = $this->extractWrappedFilter($filterDto);
$wrappedFilterDto = $wrappedFilter->getAsDto();
$wrappedFilterDto->setProperty($targetProperty);

$this->configureFilter($wrappedFilterDto, $wrappedEntityDto, $context);

$filterDto->setFormType($wrappedFilterDto->getFormType());
$filterDto->setFormTypeOptions($wrappedFilterDto->getFormTypeOptions());

$this->removeWrappedFilterOption($filterDto);
}

private function extractWrappedFilter(FilterDto $filterDto): FilterInterface
{
return $filterDto->getFormTypeOption(NestedFilter::FORM_OPTION_WRAPPED_FILTER);
}

private function removeWrappedFilterOption(FilterDto $filterDto): void
{
[$root, $filterKey] = explode('.', NestedFilter::FORM_OPTION_WRAPPED_FILTER);

$data = $filterDto->getFormTypeOption($root);
unset($data[$filterKey]);
$filterDto->setFormTypeOption($root, $data);
}

private function configureFilter(FilterDto $filterDto, EntityDto $entityDto, AdminContext $context): void
{
foreach ($this->filterConfigurators as $configurator) {
if ($configurator->supports($filterDto, null, $entityDto, $context)) {
$configurator->configure($filterDto, null, $entityDto, $context);
}
}
}

private function getObjectManager(string $entityFqcn): ObjectManager
{
if (null === $objectManager = $this->doctrine->getManagerForClass($entityFqcn)) {
throw new \RuntimeException(sprintf('There is no Doctrine Object Manager defined for the "%s" class.', $entityFqcn));
}

return $objectManager;
}
}
159 changes: 159 additions & 0 deletions src/Filter/NestedFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
<?php

namespace EasyCorp\Bundle\EasyAdminBundle\Filter;

use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectManager;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Filter\FilterInterface;
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
use EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto;
use EasyCorp\Bundle\EasyAdminBundle\Dto\FilterDataDto;

/**
* @author Brandon Marcachi <brandon.marcachi@gmail.com>
*/
final class NestedFilter implements FilterInterface
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please elaborate a bit: can this be implemented without adding NestedFilter? Why you chose this way?

For me, it looks much more natural to not have a wrapper for filters – similar to Fields. Just add a dot to filter and you're done... Isn't it?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello,

Thank you for the PR and the review. Not sure but I think it's much more complex than just add a dot tbh. Otherwise I really don't understand why this feature is not already implemented. I did a PR for that job and I had to modify much more files to handle all cases due to internal mechanism : #4840

{
use FilterTrait;

public const FORM_OPTION_WRAPPED_FILTER = 'attr.wrapped_filter';

public const PATH_SEPARATOR_EXPECTED = '.';
public const PATH_SEPARATOR = '_';

/** @var FilterInterface */
private $wrappedFilter;

public static function new(string $propertyName, string $label = null): self
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see a reason for this method to be present, it just confuses more. There is no new() method in FilterInterface, so I think this should be removed.

Also, I see a point in renaming wrap() to new() to make it more natural alongside with other filters.

{
throw new \RuntimeException('Instead of this method, use the "wrap()" method.');
}

public static function wrap(FilterInterface $filter): FilterInterface
{
$filterDto = $filter->getAsDto();
$property = $filterDto->getProperty();

if (false === strpos($property, self::PATH_SEPARATOR_EXPECTED)) {
return $filter;
}

return (new self())
->setFilterFqcn(__CLASS__)
->setProperty($property)
->setFormType($filterDto->getFormType())
->setFormTypeOptions($filterDto->getFormTypeOptions())
->setWrappedFilter($filter)
;
}

public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto, ?FieldDto $fieldDto, EntityDto $entityDto): void
{
$propertyPath = $filterDataDto->getProperty();

[$targetClassMetadata, $targetProperty] = self::extractTargets(
$queryBuilder->getEntityManager(),
$entityDto->getFqcn(),
$propertyPath
);

$wrappedEntityDto = new EntityDto($targetClassMetadata->getName(), $targetClassMetadata);
$wrappedFilter = $this->getWrappedFilter();
$wrappedFilterDto = $wrappedFilter->getAsDto();
$wrappedFilterDto->setProperty($targetProperty);

// Apply required left joins and get the alias we have to work with
$alias = $this->applyLeftJoins($queryBuilder, $filterDataDto->getEntityAlias(), $propertyPath);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds support for embeddables.

Suggested change
$alias = $this->applyLeftJoins($queryBuilder, $filterDataDto->getEntityAlias(), $propertyPath);
if ($entityDto->getFqcn() === $targetClassMetadata->getName()) {
$alias = $filterDataDto->getEntityAlias();
} else {
// Apply required left joins and get the alias we have to work with
$alias = $this->applyLeftJoins($queryBuilder, $filterDataDto->getEntityAlias(), $propertyPath);
}


// Recreate FilterDataDto adapted for the wrapped filter
$wrappedFilterDataDto = FilterDataDto::new($filterDataDto->getIndex(), $wrappedFilterDto, $alias, [
'value' => $filterDataDto->getValue(),
'value2' => $filterDataDto->getValue2(),
'comparison' => $filterDataDto->getComparison(),
]);

$wrappedFilterDto->apply($queryBuilder, $wrappedFilterDataDto, null, $wrappedEntityDto);
}

public static function extractTargets(ObjectManager $objectManager, string $class, string $propertyPath): array
{
$segments = explode(self::PATH_SEPARATOR, $propertyPath);
$metadata = $objectManager->getClassMetadata($class);
$lastIndex = \count($segments) - 1;
$property = null;

foreach ($segments as $i => $prop) {
if (!$metadata->hasField($prop) && !$metadata->hasAssociation($prop)) {
self::throwInvalidPropertyPathException($propertyPath, $class);
}

// The target property must be at the end of path
if ($i === $lastIndex) {
$property = $prop;
break;
}
Comment on lines +84 to +94
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds support for embeddables.

Suggested change
foreach ($segments as $i => $prop) {
if (!$metadata->hasField($prop) && !$metadata->hasAssociation($prop)) {
self::throwInvalidPropertyPathException($propertyPath, $class);
}
// The target property must be at the end of path
if ($i === $lastIndex) {
$property = $prop;
break;
}
$prefix = '';
foreach ($segments as $i => $prop) {
$prop = $prefix.$prop;
if (!$metadata->hasField($prop) && !$metadata->hasAssociation($prop)) {
self::throwInvalidPropertyPathException($propertyPath, $class);
}
// The target property must be at the end of path
if ($i === $lastIndex) {
$property = $prop;
break;
}
if ($metadata->hasField($prop)) {
$prefix .= $prop.'.';
continue;
}


if (!$metadata->hasAssociation($prop)) {
self::throwInvalidPropertyPathException($propertyPath, $class);
}

// Move to next nested class
$metadata = $objectManager->getClassMetadata($metadata->getAssociationTargetClass($prop));
}

return [$metadata, $property];
}

public function setWrappedFilter(FilterInterface $filter): self
{
$this->wrappedFilter = $filter;

return $this->setFormTypeOption(self::FORM_OPTION_WRAPPED_FILTER, $filter);
}

public function getWrappedFilter(): FilterInterface
{
return $this->wrappedFilter;
}

public function setProperty(string $propertyName): self
{
// Replace dots with underscore to avoid errors
$this->dto->setProperty(
str_replace(self::PATH_SEPARATOR_EXPECTED, self::PATH_SEPARATOR, $propertyName)
);

return $this;
}

private function applyLeftJoins(QueryBuilder $qb, string $alias, string $propertyPath): string
{
$path = explode(self::PATH_SEPARATOR, $propertyPath);
$lastIndex = \count($path) - 1;
$currentAlias = $alias;

foreach ($path as $i => $prop) {
if ($i === $lastIndex) {
break;
}

$nextAlias = sprintf('%s_%s', $currentAlias, $prop);
if (!\in_array($nextAlias, $qb->getAllAliases(), true)) {
$qb->leftJoin(sprintf('%s.%s', $currentAlias, $prop), $nextAlias);
}

$currentAlias = $nextAlias;
}

return $currentAlias;
}

private static function throwInvalidPropertyPathException(string $propertyPath, string $class): void
{
throw new \InvalidArgumentException(sprintf(
'The property path "%s" for class "%s" is invalid.',
str_replace(self::PATH_SEPARATOR, self::PATH_SEPARATOR_EXPECTED, $propertyPath),
$class
));
}
}
7 changes: 7 additions & 0 deletions src/Resources/config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
use EasyCorp\Bundle\EasyAdminBundle\Filter\Configurator\ComparisonConfigurator as ComparisonFilterConfigurator;
use EasyCorp\Bundle\EasyAdminBundle\Filter\Configurator\DateTimeConfigurator as DateTimeFilterConfigurator;
use EasyCorp\Bundle\EasyAdminBundle\Filter\Configurator\EntityConfigurator as EntityFilterConfigurator;
use EasyCorp\Bundle\EasyAdminBundle\Filter\Configurator\NestedConfigurator as NestedFilterConfigurator;
use EasyCorp\Bundle\EasyAdminBundle\Filter\Configurator\NullConfigurator as NullFilterConfigurator;
use EasyCorp\Bundle\EasyAdminBundle\Filter\Configurator\NumericConfigurator as NumericFilterConfigurator;
use EasyCorp\Bundle\EasyAdminBundle\Filter\Configurator\TextConfigurator as TextFilterConfigurator;
Expand Down Expand Up @@ -286,6 +287,12 @@

->set(TextFilterConfigurator::class)

->set(NestedFilterConfigurator::class)
->arg(0, new Reference('doctrine'))
->arg(1, \function_exists('tagged')
? tagged(EasyAdminExtension::TAG_FILTER_CONFIGURATOR)
: tagged_iterator(EasyAdminExtension::TAG_FILTER_CONFIGURATOR))

->set(ActionFactory::class)
->arg(0, new Reference(AdminContextProvider::class))
->arg(1, new Reference(AuthorizationChecker::class))
Expand Down
60 changes: 60 additions & 0 deletions tests/Filter/Configurator/NestedConfiguratorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

namespace EasyCorp\Bundle\EasyAdminBundle\Tests\Filter;

use Doctrine\Bundle\DoctrineBundle\Registry;
use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext;
use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto;
use EasyCorp\Bundle\EasyAdminBundle\Filter\Configurator\NestedConfigurator;
use EasyCorp\Bundle\EasyAdminBundle\Filter\NestedFilter;
use EasyCorp\Bundle\EasyAdminBundle\Filter\TextFilter;
use EasyCorp\Bundle\EasyAdminBundle\Tests\TestApplication\Entity\User;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

class NestedConfiguratorTest extends KernelTestCase
{
/** @var Registry */
private $doctrine;

/** @var NestedConfigurator */
private $nestedConfigurator;

protected function setUp(): void
{
self::bootKernel();

$container = self::getContainer();

$this->doctrine = $container->get('doctrine');
$this->nestedConfigurator = $container->get(NestedConfigurator::class);
}

public function testConfigure()
{
$class = User::class;
$attr = ['class' => 'foo'];

$textFilter = TextFilter::new('blogPosts.categories.name');
$textFilter->setFormTypeOption('attr', $attr);

$nestedFilter = NestedFilter::wrap($textFilter);

$objectManager = $this->doctrine->getManagerForClass($class);
$entityDto = new EntityDto($class, $objectManager->getClassMetadata($class));
$adminContext = $this->getMockBuilder(AdminContext::class)->disableOriginalConstructor()->getMock();

$wrappedFilter = $nestedFilter->getWrappedFilter();
$wrappedFilterDto = $wrappedFilter->getAsDto();
$nestedFilterDto = $nestedFilter->getAsDto();

self::assertEquals('blogPosts.categories.name', $wrappedFilterDto->getProperty());

$this->nestedConfigurator->configure($nestedFilter->getAsDto(), null, $entityDto, $adminContext);

self::assertEquals('name', $wrappedFilterDto->getProperty());
self::assertEquals('blogPosts_categories_name', $nestedFilterDto->getProperty());

self::assertEquals($wrappedFilterDto->getFormType(), $nestedFilterDto->getFormType());
self::assertEquals($wrappedFilterDto->getFormTypeOptions(), $nestedFilterDto->getFormTypeOptions());
}
}
Loading