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
38 changes: 36 additions & 2 deletions src/Factory/FilterFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
namespace EasyCorp\Bundle\EasyAdminBundle\Factory;

use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\FieldMapping;
use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection;
use EasyCorp\Bundle\EasyAdminBundle\Collection\FilterCollection;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Filter\FilterConfiguratorInterface;
Expand Down Expand Up @@ -67,14 +66,20 @@ public function __construct(
public function create(FilterConfigDto $filterConfig, FieldCollection $fields, EntityDto $entityDto): FilterCollection
{
$builtFilters = [];
$flattenedFilters = $this->flattenFilterArray($filterConfig->all());

/** @var FilterInterface|string $filter */
foreach ($filterConfig->all() as $property => $filter) {
foreach ($flattenedFilters as $property => $filter) {
if (\is_string($filter)) {
$guessedFilterClass = $this->guessFilterClass($entityDto, $property);
/** @var FilterInterface $filter */
$filter = $guessedFilterClass::new($property);
}

if (!$filter instanceof FilterInterface) {
continue;
}

$filterDto = $filter->getAsDto();

$context = $this->adminContextProvider->getContext();
Expand All @@ -94,8 +99,37 @@ public function create(FilterConfigDto $filterConfig, FieldCollection $fields, E
return FilterCollection::new($builtFilters);
}

/**
* Flattens nested arrays created by KeyValueStore's dot notation handling.
* For example, ['author' => ['country' => FilterObject]] becomes ['author.country' => FilterObject].
*
* @param array<string, mixed> $filters
*
* @return array<string, FilterInterface|string>
*/
private function flattenFilterArray(array $filters, string $prefix = ''): array
{
$flattened = [];

foreach ($filters as $key => $value) {
$fullKey = '' === $prefix ? $key : $prefix.'.'.$key;

if (\is_array($value)) {
$flattened = array_merge($flattened, $this->flattenFilterArray($value, $fullKey));
} else {
$flattened[$fullKey] = $value;
}
}

return $flattened;
}

private function guessFilterClass(EntityDto $entityDto, string $propertyName): string
{
if (str_contains($propertyName, '.')) {
return TextFilter::class;
}

if ($entityDto->getClassMetadata()->hasAssociation($propertyName)) {
return EntityFilter::class;
}
Expand Down
15 changes: 12 additions & 3 deletions src/Filter/ArrayFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,18 +73,27 @@ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto,

$useQuotes = Types::SIMPLE_ARRAY === $fieldDto->getDoctrineMetadata()->get('type');

$aliasToUse = $alias;
$propertyToUse = $property;

if (str_contains($property, '.')) {
[$joinAlias, $propertyPath] = $this->createJoinForAssociationFilter($queryBuilder, $alias, $property, $parameterName);
$aliasToUse = $joinAlias;
$propertyToUse = $propertyPath;
}

if (null === $value || [] === $value) {
$queryBuilder->andWhere(sprintf('%s.%s %s', $alias, $property, $comparison));
$queryBuilder->andWhere(sprintf('%s.%s %s', $aliasToUse, $propertyToUse, $comparison));
} else {
$clause = ComparisonType::CONTAINS_ALL === $comparison ? new Andx() : new Orx();
$comparison = ComparisonType::CONTAINS_ALL === $comparison ? 'LIKE' : $comparison;
foreach ($value as $key => $item) {
$itemParameterName = sprintf('%s_%s', $parameterName, $key);
$clause->add(sprintf('%s.%s %s :%s', $alias, $property, $comparison, $itemParameterName));
$clause->add(sprintf('%s.%s %s :%s', $aliasToUse, $propertyToUse, $comparison, $itemParameterName));
$queryBuilder->setParameter($itemParameterName, $useQuotes ? '%"'.$item.'"%' : '%'.$item.'%');
}
if (ComparisonType::NOT_CONTAINS === $comparison) {
$clause->add(sprintf('%s.%s IS NULL', $alias, $property));
$clause->add(sprintf('%s.%s IS NULL', $aliasToUse, $propertyToUse));
}
$queryBuilder->andWhere($clause);
}
Expand Down
17 changes: 14 additions & 3 deletions src/Filter/BooleanFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,19 @@ public static function new(string $propertyName, $label = null): self

public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto, ?FieldDto $fieldDto, EntityDto $entityDto): void
{
$queryBuilder
->andWhere(sprintf('%s.%s %s :%s', $filterDataDto->getEntityAlias(), $filterDataDto->getProperty(), $filterDataDto->getComparison(), $filterDataDto->getParameterName()))
->setParameter($filterDataDto->getParameterName(), $filterDataDto->getValue());
$alias = $filterDataDto->getEntityAlias();
$property = $filterDataDto->getProperty();
$comparison = $filterDataDto->getComparison();
$parameterName = $filterDataDto->getParameterName();
$value = $filterDataDto->getValue();

if (str_contains($property, '.')) {
[$joinAlias, $propertyPath] = $this->createJoinForAssociationFilter($queryBuilder, $alias, $property, $parameterName);
$queryBuilder->andWhere(sprintf('%s.%s %s :%s', $joinAlias, $propertyPath, $comparison, $parameterName));
} else {
$queryBuilder->andWhere(sprintf('%s.%s %s :%s', $alias, $property, $comparison, $parameterName));
}

$queryBuilder->setParameter($parameterName, $value);
}
}
15 changes: 12 additions & 3 deletions src/Filter/ChoiceFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,22 @@ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto,
$value = $filterDataDto->getValue();
$isMultiple = (bool) $filterDataDto->getFormTypeOption('value_type_options.multiple');

$aliasToUse = $alias;
$propertyToUse = $property;

if (str_contains($property, '.')) {
[$joinAlias, $propertyPath] = $this->createJoinForAssociationFilter($queryBuilder, $alias, $property, $parameterName);
$aliasToUse = $joinAlias;
$propertyToUse = $propertyPath;
}

if (null === $value || ($isMultiple && 0 === \count($value))) {
$queryBuilder->andWhere(sprintf('%s.%s %s', $alias, $property, $comparison));
$queryBuilder->andWhere(sprintf('%s.%s %s', $aliasToUse, $propertyToUse, $comparison));
} else {
$orX = new Orx();
$orX->add(sprintf('%s.%s %s (:%s)', $alias, $property, $comparison, $parameterName));
$orX->add(sprintf('%s.%s %s (:%s)', $aliasToUse, $propertyToUse, $comparison, $parameterName));
if (ComparisonType::NEQ === $comparison || 'NOT IN' === $comparison) {
$orX->add(sprintf('%s.%s IS NULL', $alias, $property));
$orX->add(sprintf('%s.%s IS NULL', $aliasToUse, $propertyToUse));
}
$queryBuilder->andWhere($orX)
->setParameter($parameterName, $value);
Expand Down
10 changes: 8 additions & 2 deletions src/Filter/ComparisonFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,13 @@ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto,
$parameterName = $filterDataDto->getParameterName();
$value = $filterDataDto->getValue();

$queryBuilder->andWhere(sprintf('%s.%s %s :%s', $alias, $property, $comparison, $parameterName))
->setParameter($parameterName, $value);
if (str_contains($property, '.')) {
[$joinAlias, $propertyPath] = $this->createJoinForAssociationFilter($queryBuilder, $alias, $property, $parameterName);
$queryBuilder->andWhere(sprintf('%s.%s %s :%s', $joinAlias, $propertyPath, $comparison, $parameterName));
} else {
$queryBuilder->andWhere(sprintf('%s.%s %s :%s', $alias, $property, $comparison, $parameterName));
}

$queryBuilder->setParameter($parameterName, $value);
}
}
25 changes: 19 additions & 6 deletions src/Filter/DateTimeFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,26 @@ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto,
$value = $filterDataDto->getValue();
$value2 = $filterDataDto->getValue2();

if (ComparisonType::BETWEEN === $comparison) {
$queryBuilder->andWhere(sprintf('%s.%s BETWEEN :%s and :%s', $alias, $property, $parameterName, $parameter2Name))
->setParameter($parameterName, $value)
->setParameter($parameter2Name, $value2);
if (str_contains($property, '.')) {
[$joinAlias, $propertyPath] = $this->createJoinForAssociationFilter($queryBuilder, $alias, $property, $parameterName);

if (ComparisonType::BETWEEN === $comparison) {
$queryBuilder->andWhere(sprintf('%s.%s BETWEEN :%s and :%s', $joinAlias, $propertyPath, $parameterName, $parameter2Name))
->setParameter($parameterName, $value)
->setParameter($parameter2Name, $value2);
} else {
$queryBuilder->andWhere(sprintf('%s.%s %s :%s', $joinAlias, $propertyPath, $comparison, $parameterName))
->setParameter($parameterName, $value);
}
} else {
$queryBuilder->andWhere(sprintf('%s.%s %s :%s', $alias, $property, $comparison, $parameterName))
->setParameter($parameterName, $value);
if (ComparisonType::BETWEEN === $comparison) {
$queryBuilder->andWhere(sprintf('%s.%s BETWEEN :%s and :%s', $alias, $property, $parameterName, $parameter2Name))
->setParameter($parameterName, $value)
->setParameter($parameter2Name, $value2);
} else {
$queryBuilder->andWhere(sprintf('%s.%s %s :%s', $alias, $property, $comparison, $parameterName))
->setParameter($parameterName, $value);
}
}
}
}
36 changes: 31 additions & 5 deletions src/Filter/EntityFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,37 @@ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto,
$value = $filterDataDto->getValue();
$isMultiple = (bool) $filterDataDto->getFormTypeOption('value_type_options.multiple');

if ($entityDto->getClassMetadata()->isCollectionValuedAssociation($property)) {
$aliasToUse = $alias;
$propertyToUse = $property;

if (str_contains($property, '.')) {
[$joinAlias, $propertyPath] = $this->createJoinForAssociationFilter($queryBuilder, $alias, $property, $parameterName);
$aliasToUse = $joinAlias;
$propertyToUse = $propertyPath;
}

$classMetadata = $entityDto->getClassMetadata();
if (str_contains($property, '.')) {
$em = $queryBuilder->getEntityManager();
$metadata = $classMetadata;
$parts = explode('.', $property);
$lastProperty = array_pop($parts);
foreach ($parts as $association) {
if (!$metadata->hasAssociation($association)) {
break;
}
$targetClass = $metadata->getAssociationTargetClass($association);
$metadata = $em->getClassMetadata($targetClass);
}
$classMetadata = $metadata;
$propertyToUse = $lastProperty;
}

if ($classMetadata->hasAssociation($propertyToUse) && $classMetadata->isCollectionValuedAssociation($propertyToUse)) {
// the 'ea_' prefix is needed to avoid errors when using reserved words as assocAlias ('order', 'group', etc.)
// see https://github.com/EasyCorp/EasyAdminBundle/pull/4344
$assocAlias = 'ea_'.$filterDataDto->getParameterName();
$queryBuilder->leftJoin(sprintf('%s.%s', $alias, $property), $assocAlias);
$queryBuilder->leftJoin(sprintf('%s.%s', $aliasToUse, $propertyToUse), $assocAlias);

if (0 === \count($value)) {
$queryBuilder->andWhere(sprintf('%s %s', $assocAlias, $comparison));
Expand All @@ -79,12 +105,12 @@ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto,
->setParameter($parameterName, $this->processParameterValue($queryBuilder, $value));
}
} elseif (null === $value || ($isMultiple && 0 === \count($value))) {
$queryBuilder->andWhere(sprintf('%s.%s %s', $alias, $property, $comparison));
$queryBuilder->andWhere(sprintf('%s.%s %s', $aliasToUse, $propertyToUse, $comparison));
} else {
$orX = new Orx();
$orX->add(sprintf('%s.%s %s (:%s)', $alias, $property, $comparison, $parameterName));
$orX->add(sprintf('%s.%s %s (:%s)', $aliasToUse, $propertyToUse, $comparison, $parameterName));
if (ComparisonType::NEQ === $comparison) {
$orX->add(sprintf('%s.%s IS NULL', $alias, $property));
$orX->add(sprintf('%s.%s IS NULL', $aliasToUse, $propertyToUse));
}
$queryBuilder->andWhere($orX)
->setParameter($parameterName, $this->processParameterValue($queryBuilder, $value));
Expand Down
42 changes: 42 additions & 0 deletions src/Filter/FilterTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,46 @@ public function getAsDto(): FilterDto
{
return $this->dto;
}

/**
* Creates JOIN clauses for association field filters (e.g., "author.country").
* Handles nested associations by creating multiple joins if needed.
*
* @param QueryBuilder $queryBuilder The query builder instance
* @param string $rootAlias The root entity alias (e.g., "entity")
* @param string $propertyPath The full property path (e.g., "author.country" or "author.address.city")
* @param string $parameterName Unique parameter name for the filter
*
* @return array{0: string, 1: string} Returns [joinAlias, finalProperty]
*/
protected function createJoinForAssociationFilter(QueryBuilder $queryBuilder, string $rootAlias, string $propertyPath, string $parameterName): array
{
$parts = explode('.', $propertyPath);
$finalProperty = array_pop($parts);
$currentAlias = $rootAlias;

foreach ($parts as $index => $associationName) {
$joinAlias = sprintf('%s_%s_%d', $associationName, $parameterName, $index);
$joinPath = sprintf('%s.%s', $currentAlias, $associationName);
$existingJoins = $queryBuilder->getDQLPart('join');
$joinExists = false;

foreach ($existingJoins as $joins) {
foreach ($joins as $join) {
if ($join->getJoin() === $joinPath && $join->getAlias() === $joinAlias) {
$joinExists = true;
break 2;
}
}
}

if (!$joinExists) {
$queryBuilder->leftJoin($joinPath, $joinAlias);
}

$currentAlias = $joinAlias;
}

return [$currentAlias, $finalProperty];
}
}
12 changes: 10 additions & 2 deletions src/Filter/NullFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,16 @@ public function setChoiceLabels(string|TranslatableInterface $nullChoiceLabel, s

public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto, ?FieldDto $fieldDto, EntityDto $entityDto): void
{
$alias = $filterDataDto->getEntityAlias();
$property = $filterDataDto->getProperty();
$parameterName = $filterDataDto->getParameterName();
$comparison = self::CHOICE_VALUE_NULL === $filterDataDto->getValue() ? 'IS' : 'IS NOT';
$queryBuilder
->andWhere(sprintf('%s.%s %s NULL', $filterDataDto->getEntityAlias(), $filterDataDto->getProperty(), $comparison));

if (str_contains($property, '.')) {
[$joinAlias, $propertyPath] = $this->createJoinForAssociationFilter($queryBuilder, $alias, $property, $parameterName);
$queryBuilder->andWhere(sprintf('%s.%s %s NULL', $joinAlias, $propertyPath, $comparison));
} else {
$queryBuilder->andWhere(sprintf('%s.%s %s NULL', $alias, $property, $comparison));
}
}
}
25 changes: 19 additions & 6 deletions src/Filter/NumericFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,26 @@ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto,
$value2 *= $divisor;
}

if (ComparisonType::BETWEEN === $comparison) {
$queryBuilder->andWhere(sprintf('%s.%s BETWEEN :%s and :%s', $alias, $property, $parameterName, $parameter2Name))
->setParameter($parameterName, $value)
->setParameter($parameter2Name, $value2);
if (str_contains($property, '.')) {
[$joinAlias, $propertyPath] = $this->createJoinForAssociationFilter($queryBuilder, $alias, $property, $parameterName);

if (ComparisonType::BETWEEN === $comparison) {
$queryBuilder->andWhere(sprintf('%s.%s BETWEEN :%s and :%s', $joinAlias, $propertyPath, $parameterName, $parameter2Name))
->setParameter($parameterName, $value)
->setParameter($parameter2Name, $value2);
} else {
$queryBuilder->andWhere(sprintf('%s.%s %s :%s', $joinAlias, $propertyPath, $comparison, $parameterName))
->setParameter($parameterName, $value);
}
} else {
$queryBuilder->andWhere(sprintf('%s.%s %s :%s', $alias, $property, $comparison, $parameterName))
->setParameter($parameterName, $value);
if (ComparisonType::BETWEEN === $comparison) {
$queryBuilder->andWhere(sprintf('%s.%s BETWEEN :%s and :%s', $alias, $property, $parameterName, $parameter2Name))
->setParameter($parameterName, $value)
->setParameter($parameter2Name, $value2);
} else {
$queryBuilder->andWhere(sprintf('%s.%s %s :%s', $alias, $property, $comparison, $parameterName))
->setParameter($parameterName, $value);
}
}
}
}
10 changes: 8 additions & 2 deletions src/Filter/TextFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,13 @@ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto,
$parameterName = $filterDataDto->getParameterName();
$value = $filterDataDto->getValue();

$queryBuilder->andWhere(sprintf('%s.%s %s :%s', $alias, $property, $comparison, $parameterName))
->setParameter($parameterName, $value);
if (str_contains($property, '.')) {
[$joinAlias, $propertyPath] = $this->createJoinForAssociationFilter($queryBuilder, $alias, $property, $parameterName);
$queryBuilder->andWhere(sprintf('%s.%s %s :%s', $joinAlias, $propertyPath, $comparison, $parameterName))
->setParameter($parameterName, $value);
} else {
$queryBuilder->andWhere(sprintf('%s.%s %s :%s', $alias, $property, $comparison, $parameterName))
->setParameter($parameterName, $value);
}
}
}
11 changes: 9 additions & 2 deletions src/Form/Type/FiltersFormType.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,17 @@ class FiltersFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$nameMapping = [];

/** @var FilterDto $filter */
foreach ($options['ea_filters'] as $filter) {
$builder->add($filter->getProperty(), $filter->getFormType(), $filter->getFormTypeOptions());
foreach ($options['ea_filters'] as $filterName => $filter) {
$normalizedName = str_replace('.', '_', $filterName);
$nameMapping[$normalizedName] = $filterName;

$builder->add($normalizedName, $filter->getFormType(), $filter->getFormTypeOptions());
}

$builder->setAttribute('ea_filter_name_mapping', $nameMapping);
}

public function configureOptions(OptionsResolver $resolver): void
Expand Down
Loading