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
1 change: 1 addition & 0 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@

->set(EntityFilterConfigurator::class)
->arg(0, new Reference(AdminUrlGenerator::class))
->arg(1, service(EntityRepository::class))

->set(LanguageFilterConfigurator::class)

Expand Down
18 changes: 10 additions & 8 deletions src/Dto/FilterDataDto.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
final class FilterDataDto
{
private int $index;
private string $entityAlias;
/** @var array{entity_dto: EntityDto, entity_alias: string, property_name: string} */
private array $resolvedProperty;
private FilterDto $filterDto;
/** @var string */
private $comparison;
Expand All @@ -20,14 +21,15 @@ private function __construct()
}

/**
* @param array{comparison: string, value: mixed, value2?: mixed} $formData
* @param array{comparison: string, value: mixed, value2?: mixed} $formData
* @param array{entity_dto: EntityDto, entity_alias: string, property_name: string} $resolvedProperty
*/
public static function new(int $index, FilterDto $filterDto, string $entityAlias, array $formData): self
public static function new(int $index, FilterDto $filterDto, array $resolvedProperty, array $formData): self
{
$filterData = new self();
$filterData->index = $index;
$filterData->filterDto = $filterDto;
$filterData->entityAlias = $entityAlias;
$filterData->resolvedProperty = $resolvedProperty;
$filterData->comparison = $formData['comparison'];
$filterData->value = $formData['value'];
$filterData->value2 = $formData['value2'] ?? null;
Expand All @@ -37,12 +39,12 @@ public static function new(int $index, FilterDto $filterDto, string $entityAlias

public function getEntityAlias(): string
{
return $this->entityAlias;
return $this->resolvedProperty['entity_alias'];
}

public function getProperty(): string
{
return $this->filterDto->getProperty();
return $this->resolvedProperty['property_name'];
}

public function getFormTypeOption(string $optionName): mixed
Expand All @@ -67,11 +69,11 @@ public function getValue2(): mixed

public function getParameterName(): string
{
return sprintf('%s_%d', str_replace('.', '_', $this->getProperty()), $this->index);
return sprintf('%s_%d', str_replace('.', '_', $this->filterDto->getProperty()), $this->index);
}

public function getParameter2Name(): string
{
return sprintf('%s_%d', str_replace('.', '_', $this->getProperty()), $this->index + 1);
return sprintf('%s_%d', str_replace('.', '_', $this->filterDto->getProperty()), $this->index + 1);
}
}
9 changes: 5 additions & 4 deletions src/Filter/Configurator/EntityConfigurator.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use EasyCorp\Bundle\EasyAdminBundle\Dto\FilterDto;
use EasyCorp\Bundle\EasyAdminBundle\Filter\EntityFilter;
use EasyCorp\Bundle\EasyAdminBundle\Form\Type\CrudAutocompleteType;
use EasyCorp\Bundle\EasyAdminBundle\Orm\EntityRepository;
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGeneratorInterface;

/**
Expand All @@ -20,6 +21,7 @@ final class EntityConfigurator implements FilterConfiguratorInterface
{
public function __construct(
private AdminUrlGeneratorInterface $adminUrlGenerator,
private EntityRepository $entityRepository,
) {
}

Expand All @@ -30,10 +32,9 @@ public function supports(FilterDto $filterDto, ?FieldDto $fieldDto, EntityDto $e

public function configure(FilterDto $filterDto, ?FieldDto $fieldDto, EntityDto $entityDto, AdminContext $context): void
{
$propertyName = $filterDto->getProperty();
if (!$entityDto->getClassMetadata()->hasAssociation($propertyName)) {
return;
}
$resolvedProperty = $this->entityRepository->resolveNestedAssociations(null, $entityDto, $filterDto->getProperty(), true);
$entityDto = $resolvedProperty['entity_dto'];
$propertyName = $resolvedProperty['property_name'];

// TODO: add the 'em' form type option too?
$filterDto->setFormTypeOptionIfNotSet('value_type_options.class', $entityDto->getClassMetadata()->getAssociationTargetClass($propertyName));
Expand Down
140 changes: 88 additions & 52 deletions src/Orm/EntityRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use EasyCorp\Bundle\EasyAdminBundle\Factory\EntityFactory;
use EasyCorp\Bundle\EasyAdminBundle\Factory\FormFactory;
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
use EasyCorp\Bundle\EasyAdminBundle\Filter\EntityFilter;
use EasyCorp\Bundle\EasyAdminBundle\Form\Type\ComparisonType;
use EasyCorp\Bundle\EasyAdminBundle\Form\Type\FiltersFormType;
use Symfony\Component\Uid\Ulid;
Expand All @@ -30,14 +31,17 @@
/**
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
*/
final readonly class EntityRepository implements EntityRepositoryInterface
final class EntityRepository implements EntityRepositoryInterface
{
/** @var array<string, string> */
private array $associationAlreadyJoined = [];

public function __construct(
private AdminContextProviderInterface $adminContextProvider,
private ManagerRegistry $doctrine,
private EntityFactory $entityFactory,
private FormFactory $formFactory,
private EventDispatcherInterface $eventDispatcher,
private readonly AdminContextProviderInterface $adminContextProvider,
private readonly ManagerRegistry $doctrine,
private readonly EntityFactory $entityFactory,
private readonly FormFactory $formFactory,
private readonly EventDispatcherInterface $eventDispatcher,
) {
}

Expand Down Expand Up @@ -232,10 +236,21 @@ private function addFilterClause(QueryBuilder $queryBuilder, SearchDto $searchDt
];
}

/** @var string $rootAlias */
$rootAlias = current($queryBuilder->getRootAliases());
if (false !== $filterForm->getConfig()->getOption('mapped')) {
try {
$resolvedProperty = $this->resolveNestedAssociations($queryBuilder, $entityDto, $originalPropertyName, EntityFilter::class === $filter->getFqcn());
} catch (\InvalidArgumentException $exception) {
throw new \InvalidArgumentException(sprintf('%s If your filter is unmapped, you must set the "mapped" option to false.', $exception->getMessage()));
}
} else {
$resolvedProperty = [
'entity_dto' => $entityDto,
'entity_alias' => current($queryBuilder->getRootAliases()),
'property_name' => $originalPropertyName,
];
}

$filterDataDto = FilterDataDto::new($i, $filter, $rootAlias, $submittedData);
$filterDataDto = FilterDataDto::new($i, $filter, $resolvedProperty, $submittedData);
$filter->apply($queryBuilder, $filterDataDto, $fields->getByProperty($originalPropertyName), $entityDto);

++$i;
Expand Down Expand Up @@ -263,47 +278,11 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc
$configuredSearchableProperties = $searchDto->getSearchableProperties();
$searchableProperties = (null === $configuredSearchableProperties || 0 === \count($configuredSearchableProperties)) ? $entityDto->getClassMetadata()->getFieldNames() : $configuredSearchableProperties;

$entitiesAlreadyJoined = [];
foreach ($searchableProperties as $searchableProperty) {
// support arbitrarily nested associations (e.g. foo.bar.baz.qux)
$associatedProperties = explode('.', $searchableProperty);
$numAssociatedProperties = \count($associatedProperties);
$parentEntityDto = $entityDto;
$parentEntityAlias = 'entity';
$fullPropertyName = $parentPropertyName = $associatedPropertyName = '';

for ($i = 0; $i < $numAssociatedProperties; ++$i) {
$associatedPropertyName = $associatedProperties[$i];
$fullPropertyName = trim($fullPropertyName.'.'.$associatedPropertyName, '.');

if ($this->isAssociation($parentEntityDto, $associatedPropertyName)) {
if ($i === $numAssociatedProperties - 1) {
throw new \InvalidArgumentException(sprintf('The "%s" property included in the setSearchFields() method is not a valid search field. When using associated properties in search, you must also define the exact field used in the search (e.g. \'%s.id\', \'%s.name\', etc.)', $searchableProperty, $searchableProperty, $searchableProperty));
}

$associatedEntityDto = $this->entityFactory->create($parentEntityDto->getClassMetadata()->getAssociationTargetClass($associatedPropertyName));

if (!isset($entitiesAlreadyJoined[$fullPropertyName])) {
$aliasIndex = \count($entitiesAlreadyJoined);
$entitiesAlreadyJoined[$fullPropertyName] ??= Escaper::escapeDqlAlias($associatedPropertyName.(0 === $aliasIndex ? '' : $aliasIndex));
$queryBuilder->leftJoin(Escaper::escapeDqlAlias($parentEntityAlias).'.'.$associatedPropertyName, $entitiesAlreadyJoined[$fullPropertyName]);
}

$parentEntityDto = $associatedEntityDto;
$parentEntityAlias = $entitiesAlreadyJoined[$fullPropertyName];
$parentPropertyName = '';
} else {
// Normal & Embedded class properties
$associatedPropertyName = $parentPropertyName = trim($parentPropertyName.'.'.$associatedPropertyName, '.');
}
}

if (!isset($parentEntityDto->getClassMetadata()->fieldMappings[$associatedPropertyName])) {
throw new \InvalidArgumentException(sprintf('The "%s" property included in the setSearchFields() method is not a valid search field. The field "%s" does not exist in "%s".', $searchableProperty, $associatedPropertyName, $searchableProperty));
}
$resolvedProperty = $this->resolveNestedAssociations($queryBuilder, $entityDto, $searchableProperty);

// In Doctrine ORM 3.x, FieldMapping implements \ArrayAccess; in 4.x it's an object with properties
$fieldMapping = $parentEntityDto->getClassMetadata()->getFieldMapping($associatedPropertyName);
$fieldMapping = $resolvedProperty['entity_dto']->getClassMetadata()->getFieldMapping($resolvedProperty['property_name']);
// In Doctrine ORM 2.x, getFieldMapping() returns an array
/** @phpstan-ignore-next-line function.impossibleType */
if (\is_array($fieldMapping)) {
Expand Down Expand Up @@ -332,16 +311,16 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc
&& !$isUlidProperty
&& !$isJsonProperty
) {
$entityFqcn = $parentEntityDto->getFqcn();
$entityFqcn = $resolvedProperty['entity_dto']->getFqcn();

/** @var \ReflectionNamedType|\ReflectionUnionType|null $idClassType */
$idClassType = null;
$reflectionClass = new \ReflectionClass($entityFqcn);

// this is needed to handle inherited properties
while (false !== $reflectionClass) {
if ($reflectionClass->hasProperty($associatedPropertyName)) {
$reflection = $reflectionClass->getProperty($associatedPropertyName);
if ($reflectionClass->hasProperty($resolvedProperty['property_name'])) {
$reflection = $reflectionClass->getProperty($resolvedProperty['property_name']);
$idClassType = $reflection->getType();
break;
}
Expand All @@ -360,9 +339,9 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc
}

$searchablePropertiesConfig[] = [
'entity_name' => $parentEntityAlias,
'entity_name' => $resolvedProperty['entity_alias'],
'property_data_type' => $propertyDataType,
'property_name' => $associatedPropertyName,
'property_name' => $resolvedProperty['property_name'],
'is_boolean' => $isBoolean,
'is_small_integer' => $isSmallIntegerProperty,
'is_integer' => $isIntegerProperty,
Expand All @@ -377,6 +356,63 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc
return $searchablePropertiesConfig;
}

/**
* Support arbitrarily nested associations (e.g. foo.bar.baz.qux).
*
* @return array{
* entity_dto: EntityDto,
* entity_alias: string,
* property_name: string,
* }
*/
public function resolveNestedAssociations(?QueryBuilder $queryBuilder, EntityDto $rootEntityDto, string $propertyName, bool $mustEndWithAssociation = false): array
{
$associatedProperties = explode('.', $propertyName);
$numAssociatedProperties = \count($associatedProperties);
$resolvedEntityDto = $rootEntityDto;
$parentEntityAlias = 'entity';
$fullPropertyName = $compoundPropertyName = $resolvedPropertyName = '';

for ($i = 0; $i < $numAssociatedProperties; ++$i) {
$resolvedPropertyName = trim($compoundPropertyName.'.'.$associatedProperties[$i], '.');
$fullPropertyName = trim($fullPropertyName.'.'.$resolvedPropertyName, '.');

if ($this->isAssociation($resolvedEntityDto, $resolvedPropertyName)) {
if ($i === $numAssociatedProperties - 1) {
if (!$mustEndWithAssociation) {
throw new \InvalidArgumentException(sprintf('The "%s" property is not valid. When using associated properties, you must also define the exact field to target (e.g. "%s.id", "%s.name", etc.)', $propertyName, $propertyName, $propertyName));
}

// Skip join when the last property is an association
continue;
}

if (isset($queryBuilder) && !isset($this->associationAlreadyJoined[$fullPropertyName])) {
$aliasIndex = \count($this->associationAlreadyJoined);
$this->associationAlreadyJoined[$fullPropertyName] ??= Escaper::escapeDqlAlias($resolvedPropertyName.(0 === $aliasIndex ? '' : $aliasIndex));
$queryBuilder->leftJoin(Escaper::escapeDqlAlias($parentEntityAlias).'.'.$resolvedPropertyName, $this->associationAlreadyJoined[$fullPropertyName]);
}

$parentEntityAlias = $this->associationAlreadyJoined[$fullPropertyName] ?? null;
$resolvedEntityDto = $this->entityFactory->create($resolvedEntityDto->getClassMetadata()->getAssociationTargetClass($resolvedPropertyName));
$compoundPropertyName = '';
} else {
// Normal & Embedded class properties
$compoundPropertyName = $resolvedPropertyName;
}
}

if (!$mustEndWithAssociation && !isset($resolvedEntityDto->getClassMetadata()->fieldMappings[$resolvedPropertyName])) {
throw new \InvalidArgumentException(sprintf('The "%s" property is not valid. The field "%s" does not exist in "%s".', $propertyName, $resolvedPropertyName, $propertyName));
}

return [
'entity_dto' => $resolvedEntityDto,
'entity_alias' => $parentEntityAlias,
'property_name' => $resolvedPropertyName,
];
}

private function isAssociation(EntityDto $entityDto, string $propertyName): bool
{
$propertyNameParts = explode('.', $propertyName, 2);
Expand Down
Loading