From 39643da85a96939fc8ac123fd8eb68817f485412 Mon Sep 17 00:00:00 2001 From: Dragos Protung Date: Thu, 11 Dec 2025 11:12:44 +0100 Subject: [PATCH 1/4] Update EasyAdmin --- composer.json | 2 +- src/Controller/BaseCrudDtoController.php | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/composer.json b/composer.json index 25e0446..2acaecb 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,7 @@ "php": "~8.3.0 || ~8.4.0 || ~8.5.0", "ext-dom": "*", "azjezz/psl": "^2.9.1 || ^3.0.0 || ^4.0.0", - "easycorp/easyadmin-bundle": "~4.25.0", + "easycorp/easyadmin-bundle": "~4.25.0 || ~4.26.0 || ~4.27.0", "symfony/asset": "^6.4 || ^7.2", "symfony/cache": "^6.4 || ^7.2", "symfony/config": "^6.4 || ^7.2", diff --git a/src/Controller/BaseCrudDtoController.php b/src/Controller/BaseCrudDtoController.php index c655307..9de49dc 100644 --- a/src/Controller/BaseCrudDtoController.php +++ b/src/Controller/BaseCrudDtoController.php @@ -18,7 +18,8 @@ use EasyCorp\Bundle\EasyAdminBundle\Event\BeforeEntityUpdatedEvent; use EasyCorp\Bundle\EasyAdminBundle\Exception\ForbiddenActionException; use EasyCorp\Bundle\EasyAdminBundle\Exception\InsufficientEntityPermissionException; -use EasyCorp\Bundle\EasyAdminBundle\Factory\EntityFactory; +use EasyCorp\Bundle\EasyAdminBundle\Factory\ActionFactory; +use EasyCorp\Bundle\EasyAdminBundle\Factory\FieldFactory; use EasyCorp\Bundle\EasyAdminBundle\Field\BooleanField; use EasyCorp\Bundle\EasyAdminBundle\Security\Permission; use Override; @@ -114,9 +115,9 @@ public function edit(AdminContext $context): KeyValueStore|Response throw new InsufficientEntityPermissionException($context); } - $this->container->get(EntityFactory::class)->processFields($context->getEntity(), FieldCollection::new($this->configureFields(Crud::PAGE_EDIT))); + $this->container->get(FieldFactory::class)->processFields($context->getEntity(), FieldCollection::new($this->configureFields(Crud::PAGE_EDIT)), Crud::PAGE_EDIT); $context->getCrud()->setFieldAssets($this->getFieldAssets($context->getEntity()->getFields())); - $this->container->get(EntityFactory::class)->processActions($context->getEntity(), $context->getCrud()->getActionsConfig()); + $this->container->get(ActionFactory::class)->processEntityActions($context->getEntity(), $context->getCrud()->getActionsConfig()); /** @var TDto $dto */ $dto = $context->getEntity()->getInstance(); @@ -217,9 +218,9 @@ public function new(AdminContext $context): KeyValueStore|Response $context->getEntity()->setInstance(null); $context->getEntity()->setInstance($this->createDto()); - $this->container->get(EntityFactory::class)->processFields($context->getEntity(), FieldCollection::new($this->configureFields(Crud::PAGE_NEW))); + $this->container->get(FieldFactory::class)->processFields($context->getEntity(), FieldCollection::new($this->configureFields(Crud::PAGE_NEW)), Crud::PAGE_NEW); $context->getCrud()->setFieldAssets($this->getFieldAssets($context->getEntity()->getFields())); - $this->container->get(EntityFactory::class)->processActions($context->getEntity(), $context->getCrud()->getActionsConfig()); + $this->container->get(ActionFactory::class)->processEntityActions($context->getEntity(), $context->getCrud()->getActionsConfig()); $newForm = $this->createNewForm($context->getEntity(), $context->getCrud()->getNewFormOptions(), $context); $newForm->handleRequest($context->getRequest()); From ccefcba68c766b6aa59f819749045b4e4acc4a87 Mon Sep 17 00:00:00 2001 From: cezarystepkowski Date: Fri, 12 Dec 2025 12:33:03 +0100 Subject: [PATCH 2/4] Add support for extracting global action groups --- src/Test/Controller/IndexActionTestCase.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Test/Controller/IndexActionTestCase.php b/src/Test/Controller/IndexActionTestCase.php index 2335718..b7c65eb 100644 --- a/src/Test/Controller/IndexActionTestCase.php +++ b/src/Test/Controller/IndexActionTestCase.php @@ -163,8 +163,13 @@ protected function extractDatagridHeaders(): array */ protected function extractGlobalActions(): array { + $globalActions = $this->getClient()->getCrawler()->filter('.global-actions'); + if ($globalActions->count() === 0) { + return []; + } + return $this->mapActions( - $this->getClient()->getCrawler()->filter('.global-actions')->filter('[data-action-name]'), + $globalActions->children('[data-action-group-name], [data-action-name]'), ); } From 97e4c197899efd9b58654f8b799a0e2c6cbb09c9 Mon Sep 17 00:00:00 2001 From: Dragos Protung Date: Fri, 12 Dec 2025 18:15:37 +0100 Subject: [PATCH 3/4] Removed support for rendering in dropdown --- .github/workflows/build.yml | 3 +- composer.json | 2 +- src/Controller/BaseCrudController.php | 11 ------- src/Resources/views/crud/index.html.twig | 38 ------------------------ 4 files changed, 3 insertions(+), 51 deletions(-) delete mode 100644 src/Resources/views/crud/index.html.twig diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f81aad2..bcfb837 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,7 +26,8 @@ jobs: - 8.5 symfony: - 6.4.* - - 7.* + - 7.4.* +# - 8.* dependencies: [ lowest, highest ] name: 'PHP ${{ matrix.php }} + Symfony ${{ matrix.symfony }} + ${{ matrix.dependencies }} dependencies' steps: diff --git a/composer.json b/composer.json index 2acaecb..6ba11c8 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,7 @@ "php": "~8.3.0 || ~8.4.0 || ~8.5.0", "ext-dom": "*", "azjezz/psl": "^2.9.1 || ^3.0.0 || ^4.0.0", - "easycorp/easyadmin-bundle": "~4.25.0 || ~4.26.0 || ~4.27.0", + "easycorp/easyadmin-bundle": "~4.27.5", "symfony/asset": "^6.4 || ^7.2", "symfony/cache": "^6.4 || ^7.2", "symfony/config": "^6.4 || ^7.2", diff --git a/src/Controller/BaseCrudController.php b/src/Controller/BaseCrudController.php index 32c72f4..0617176 100644 --- a/src/Controller/BaseCrudController.php +++ b/src/Controller/BaseCrudController.php @@ -114,17 +114,6 @@ protected function addConfirmationForAction( return $action; } - protected function renderInDropdown(Action $action, bool $shouldRenderInDropdown): Action - { - $action - ->getAsDto() - ->addHtmlAttributes( - ['data-protung-easyadmin-plus-extension-action-render-in-dropdown' => $shouldRenderInDropdown ? '1' : '-1'], - ); - - return $action; - } - protected function currentAdminContext(): AdminContext { $currentAdminContext = $this->getContext(); diff --git a/src/Resources/views/crud/index.html.twig b/src/Resources/views/crud/index.html.twig deleted file mode 100644 index 123227c..0000000 --- a/src/Resources/views/crud/index.html.twig +++ /dev/null @@ -1,38 +0,0 @@ -{# @var ea \EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext #} -{% extends '@EasyAdmin/crud/index.html.twig' %} - -{% block entity_actions %} - {% if ea.crud.showEntityActionsAsDropdown %} - {% set dropdownActions = entity.actions|filter(action => action.getHtmlAttributes['data-protung-easyadmin-plus-extension-action-render-in-dropdown'] | default(1) == 1) %} - {% set notDropdownActions = entity.actions|filter(action => action.getHtmlAttributes['data-protung-easyadmin-plus-extension-action-render-in-dropdown'] | default(1) == -1) %} - - {% for action in notDropdownActions %} -
- {{ include(action.templatePath, { action: action, entity: entity, isIncludedInDropdown: false }, with_context = false) }} -
- {% endfor %} - {% if dropdownActions|length > 0 %} - - {% endif %} - - {% else %} - {{ parent() }} - {% endif %} -{% endblock entity_actions %} From 934e42d6cd9cfb7014264698c7b0f685b00fbaf0 Mon Sep 17 00:00:00 2001 From: Dragos Protung Date: Mon, 15 Dec 2025 11:18:47 +0100 Subject: [PATCH 4/4] Update overwritten services --- composer.json | 28 +-- phpcs.xml.dist | 1 + psalm.baseline.xml | 50 +++--- src/Field/Configurator/EntityConfigurator.php | 2 +- .../Configurator/EntityConfigurator.php | 4 +- src/Orm/EntityPaginator.php | 6 +- src/Orm/EntityRepository.php | 163 +++++++++++++----- src/Resources/config/services.php | 8 +- tests/Test/TestApplication/TestKernel.php | 1 - 9 files changed, 174 insertions(+), 89 deletions(-) diff --git a/composer.json b/composer.json index 6ba11c8..cb6b8d3 100644 --- a/composer.json +++ b/composer.json @@ -28,22 +28,22 @@ "ext-dom": "*", "azjezz/psl": "^2.9.1 || ^3.0.0 || ^4.0.0", "easycorp/easyadmin-bundle": "~4.27.5", - "symfony/asset": "^6.4 || ^7.2", - "symfony/cache": "^6.4 || ^7.2", - "symfony/config": "^6.4 || ^7.2", - "symfony/dependency-injection": "^6.4 || ^7.2", - "symfony/dom-crawler": "^6.4 || ^7.2", - "symfony/event-dispatcher": "^6.4 || ^7.2", - "symfony/filesystem": "^6.4 || ^7.2", - "symfony/form": "^6.4 || ^7.2", - "symfony/framework-bundle": "^6.4 || ^7.2", - "symfony/http-foundation": "^6.4 || ^7.2", - "symfony/http-kernel": "^6.4 || ^7.2", + "symfony/asset": "^6.4 || ^7.4", + "symfony/cache": "^6.4 || ^7.4", + "symfony/config": "^6.4 || ^7.4", + "symfony/dependency-injection": "^6.4 || ^7.4", + "symfony/dom-crawler": "^6.4 || ^7.4", + "symfony/event-dispatcher": "^6.4 || ^7.4", + "symfony/filesystem": "^6.4 || ^7.4", + "symfony/form": "^6.4 || ^7.4", + "symfony/framework-bundle": "^6.4 || ^7.4", + "symfony/http-foundation": "^6.4 || ^7.4", + "symfony/http-kernel": "^6.4 || ^7.4", "symfony/polyfill-php83": "^1.29", - "symfony/property-access": "^6.4 || ^7.2", + "symfony/property-access": "^6.4 || ^7.4", "symfony/service-contracts": "^2.4 || ^3.0", - "symfony/translation": "^6.4 || ^7.2", - "symfony/twig-bundle": "^6.4 || ^7.2" + "symfony/translation": "^6.4 || ^7.4", + "symfony/twig-bundle": "^6.4 || ^7.4" }, "require-dev": { "doctrine/coding-standard": "^14.0.0", diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 6466a39..b7f8441 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -16,6 +16,7 @@ + diff --git a/psalm.baseline.xml b/psalm.baseline.xml index 493f1b6..c7f4463 100644 --- a/psalm.baseline.xml +++ b/psalm.baseline.xml @@ -1,5 +1,5 @@ - + @@ -109,26 +109,17 @@ + + + + + getClassMetadata()->getAssociationMapping($sortProperty)['mappedBy']]]> - - - - - - get('mappedBy')]]> - get('targetEntity')]]> - - - - - - - @@ -136,18 +127,35 @@ - - - - - getPrimaryKeyName()]]> - + + + + + + ]]> + + + + + diff --git a/src/Field/Configurator/EntityConfigurator.php b/src/Field/Configurator/EntityConfigurator.php index 241ad8f..4894b38 100644 --- a/src/Field/Configurator/EntityConfigurator.php +++ b/src/Field/Configurator/EntityConfigurator.php @@ -86,7 +86,7 @@ public function configure(FieldDto $field, EntityDto $entityDto, AdminContext $c $field->getValue(), ); - $field->setFormTypeOptionIfNotSet('is_association', $entityDto->isAssociation($propertyName)); + $field->setFormTypeOptionIfNotSet('is_association', $entityDto->getClassMetadata()->hasAssociation($propertyName)); $this->configureOnChange($field, $entityMetadata); $associationType = Type\string()->coerce($field->getCustomOption(EntityField::OPTION_DOCTRINE_ASSOCIATION_TYPE)); diff --git a/src/Filter/Configurator/EntityConfigurator.php b/src/Filter/Configurator/EntityConfigurator.php index 4eace2a..d568920 100644 --- a/src/Filter/Configurator/EntityConfigurator.php +++ b/src/Filter/Configurator/EntityConfigurator.php @@ -60,7 +60,7 @@ public function configure(FilterDto $filterDto, FieldDto|null $fieldDto, EntityD $targetEntityFqcn = Type\string()->coerce($context->getCrudControllers()->findEntityFqcnByCrudFqcn($targetCrudControllerFqcn)); - if (! $entityDto->isAssociation($propertyName)) { + if (! $entityDto->getClassMetadata()->hasAssociation($propertyName)) { $filterDto->setFormTypeOption('value_type_options.class', $targetEntityFqcn); } @@ -73,8 +73,6 @@ public function configure(FilterDto $filterDto, FieldDto|null $fieldDto, EntityD $widgetMode = Type\string()->coerce($fieldDto->getCustomOption(EntityField::OPTION_WIDGET)); if ($widgetMode === EntityField::WIDGET_AUTOCOMPLETE) { $filterDto->setFormTypeOption('value_type_options.attr.data-ea-widget', 'ea-autocomplete'); - } elseif ($widgetMode === EntityField::WIDGET_NATIVE) { - $filterDto->setFormTypeOption('value_type_options.class', $targetEntityFqcn); } if ($autocompleteMode !== true) { diff --git a/src/Orm/EntityPaginator.php b/src/Orm/EntityPaginator.php index d875a99..225344f 100644 --- a/src/Orm/EntityPaginator.php +++ b/src/Orm/EntityPaginator.php @@ -10,12 +10,12 @@ use EasyCorp\Bundle\EasyAdminBundle\Config\Option\EA; use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface; use EasyCorp\Bundle\EasyAdminBundle\Contracts\Orm\EntityPaginatorInterface; +use EasyCorp\Bundle\EasyAdminBundle\Contracts\Provider\AdminContextProviderInterface; use EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto; use EasyCorp\Bundle\EasyAdminBundle\Dto\PaginatorDto; use EasyCorp\Bundle\EasyAdminBundle\Factory\ControllerFactory; use EasyCorp\Bundle\EasyAdminBundle\Factory\EntityFactory; use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField; -use EasyCorp\Bundle\EasyAdminBundle\Provider\AdminContextProvider; use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGeneratorInterface; use Override; use Protung\EasyAdminPlusBundle\Field\EntityField; @@ -33,7 +33,7 @@ { public function __construct( private EntityPaginatorInterface $decoratedPaginator, - private AdminContextProvider $adminContextProvider, + private AdminContextProviderInterface $adminContextProvider, private ControllerFactory $controllerFactory, private EntityFactory $entityFactory, private AdminUrlGeneratorInterface $adminUrlGenerator, @@ -177,7 +177,7 @@ public function getResultsAsJson(): string return $this->decoratedPaginator->getResultsAsJson(); } - $primaryKeyName = Type\string()->coerce($context->getEntity()->getPrimaryKeyName()); + $primaryKeyName = $context->getEntity()->getClassMetadata()->getSingleIdentifierFieldName(); $reindexResults = Dict\reindex( Type\vec(Type\object())->coerce($results), fn (object $entityInstance): string => (string) $this->propertyAccessor->getValue($entityInstance, $primaryKeyName), diff --git a/src/Orm/EntityRepository.php b/src/Orm/EntityRepository.php index 2e7ea4b..0b1bf43 100644 --- a/src/Orm/EntityRepository.php +++ b/src/Orm/EntityRepository.php @@ -15,42 +15,43 @@ use EasyCorp\Bundle\EasyAdminBundle\Collection\FilterCollection; use EasyCorp\Bundle\EasyAdminBundle\Config\Option\SearchMode; use EasyCorp\Bundle\EasyAdminBundle\Contracts\Orm\EntityRepositoryInterface; +use EasyCorp\Bundle\EasyAdminBundle\Contracts\Provider\AdminContextProviderInterface; use EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto; use EasyCorp\Bundle\EasyAdminBundle\Dto\FilterDataDto; use EasyCorp\Bundle\EasyAdminBundle\Dto\SearchDto; use EasyCorp\Bundle\EasyAdminBundle\Event\AfterEntitySearchEvent; use EasyCorp\Bundle\EasyAdminBundle\Factory\EntityFactory; use EasyCorp\Bundle\EasyAdminBundle\Factory\FormFactory; +use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField; use EasyCorp\Bundle\EasyAdminBundle\Form\Type\ComparisonType; use EasyCorp\Bundle\EasyAdminBundle\Orm\Escaper; -use EasyCorp\Bundle\EasyAdminBundle\Provider\AdminContextProvider; use EasyCorp\Bundle\EasyAdminBundle\Registry\CrudControllerRegistry; use InvalidArgumentException; use Override; use Protung\EasyAdminPlusBundle\Field\EntityField; -use Psl\Iter; use Psl\Str; use Psl\Type; use ReflectionClass; use ReflectionNamedType; -use ReflectionProperty; use ReflectionUnionType; use Symfony\Component\Uid\Ulid; use Symfony\Component\Uid\Uuid; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Throwable; -use function assert; use function class_exists; use function count; use function ctype_digit; +use function current; use function explode; use function in_array; use function is_array; use function is_numeric; use function is_subclass_of; use function mb_strtolower; +use function property_exists; use function sprintf; +use function str_contains; /** * This class is fully copied from \EasyCorp\Bundle\EasyAdminBundle\Orm\EntityRepository with code style fixes applied. @@ -60,7 +61,7 @@ final readonly class EntityRepository implements EntityRepositoryInterface { public function __construct( - private AdminContextProvider $adminContextProvider, + private AdminContextProviderInterface $adminContextProvider, private ManagerRegistry $doctrine, private EntityFactory $entityFactory, private FormFactory $formFactory, @@ -72,9 +73,9 @@ public function __construct( #[Override] public function createQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder { + /** @var EntityManagerInterface $entityManager */ $entityManager = $this->doctrine->getManagerForClass($entityDto->getFqcn()); - assert($entityManager instanceof EntityManagerInterface); - $queryBuilder = $entityManager->createQueryBuilder() + $queryBuilder = $entityManager->createQueryBuilder() ->select('entity') ->from($entityDto->getFqcn(), 'entity'); @@ -95,7 +96,7 @@ public function createQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, F $this->addFilterClause($queryBuilder, $searchDto, $entityDto, $filters, $fields); } - $this->addOrderClause($queryBuilder, $searchDto, $entityDto); + $this->addOrderClause($queryBuilder, $searchDto, $entityDto, $fields); return $queryBuilder; } @@ -173,11 +174,11 @@ private function addSearchClause(QueryBuilder $queryBuilder, SearchDto $searchDt $this->eventDispatcher->dispatch(new AfterEntitySearchEvent($queryBuilder, $searchDto, $entityDto)); } - private function addOrderClause(QueryBuilder $queryBuilder, SearchDto $searchDto, EntityDto $entityDto): void + private function addOrderClause(QueryBuilder $queryBuilder, SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields): void { foreach ($searchDto->getSort() as $sortProperty => $sortOrder) { $aliases = $queryBuilder->getAllAliases(); - $sortFieldIsDoctrineAssociation = $entityDto->isAssociation($sortProperty); + $sortFieldIsDoctrineAssociation = $this->isAssociation($entityDto, $sortProperty); if ($sortFieldIsDoctrineAssociation) { $sortFieldParts = explode('.', $sortProperty, 2); @@ -187,14 +188,12 @@ private function addOrderClause(QueryBuilder $queryBuilder, SearchDto $searchDto } if (count($sortFieldParts) === 1) { - if ($entityDto->isToManyAssociation($sortProperty)) { - $metadata = $entityDto->getPropertyMetadata($sortProperty); - - $entityManager = $this->doctrine->getManagerForClass($entityDto->getFqcn()); - assert($entityManager instanceof EntityManagerInterface); + if ($entityDto->getClassMetadata()->isCollectionValuedAssociation($sortProperty)) { + /** @var EntityManagerInterface $entityManager */ + $entityManager = $this->doctrine->getManagerForClass($entityDto->getFqcn()); $countQueryBuilder = $entityManager->createQueryBuilder(); - if ($metadata->get('type') === ClassMetadata::MANY_TO_MANY) { + if ($entityDto->getClassMetadata()->getAssociationMapping($sortProperty)['type'] === ClassMetadata::MANY_TO_MANY) { // many-to-many relation $countQueryBuilder ->select($queryBuilder->expr()->count('subQueryEntity')) @@ -205,15 +204,22 @@ private function addOrderClause(QueryBuilder $queryBuilder, SearchDto $searchDto // one-to-many relation $countQueryBuilder ->select($queryBuilder->expr()->count('subQueryEntity')) - ->from($metadata->get('targetEntity'), 'subQueryEntity') - ->where(sprintf('subQueryEntity.%s = entity', $metadata->get('mappedBy'))); + ->from($entityDto->getClassMetadata()->getAssociationTargetClass($sortProperty), 'subQueryEntity') + ->where(sprintf('subQueryEntity.%s = entity', $entityDto->getClassMetadata()->getAssociationMapping($sortProperty)['mappedBy'])); } $queryBuilder->addSelect(sprintf('(%s) as HIDDEN sub_query_sort', $countQueryBuilder->getDQL())); $queryBuilder->addOrderBy('sub_query_sort', $sortOrder); - $queryBuilder->addOrderBy('entity.' . $entityDto->getPrimaryKeyName(), $sortOrder); + $queryBuilder->addOrderBy('entity.' . $entityDto->getClassMetadata()->getSingleIdentifierFieldName(), $sortOrder); } else { - $queryBuilder->addOrderBy('entity.' . $sortProperty, $sortOrder); + $field = $fields->getByProperty($sortProperty); + $associationSortProperty = $field?->getCustomOption(AssociationField::OPTION_SORT_PROPERTY); + + if ($associationSortProperty === null) { + $queryBuilder->addOrderBy('entity.' . $sortProperty, $sortOrder); + } else { + $queryBuilder->addOrderBy($sortProperty . '.' . $associationSortProperty, $sortOrder); + } } } else { $queryBuilder->addOrderBy($sortProperty, $sortOrder); @@ -255,7 +261,10 @@ private function addFilterClause(QueryBuilder $queryBuilder, SearchDto $searchDt ]; } - $filterDataDto = FilterDataDto::new($i, $filter, Iter\first($queryBuilder->getRootAliases()), $submittedData); + /** @var string $rootAlias */ + $rootAlias = current($queryBuilder->getRootAliases()); + + $filterDataDto = FilterDataDto::new($i, $filter, $rootAlias, $submittedData); $filter->apply($queryBuilder, $filterDataDto, $fields->getByProperty($propertyName), $entityDto); ++$i; @@ -263,17 +272,29 @@ private function addFilterClause(QueryBuilder $queryBuilder, SearchDto $searchDt } /** - * @return list> + * @return array */ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields): array { $searchablePropertiesConfig = []; $configuredSearchableProperties = $searchDto->getSearchableProperties(); - $searchableProperties = $configuredSearchableProperties === null || count($configuredSearchableProperties) === 0 ? $entityDto->getAllPropertyNames() : $configuredSearchableProperties; + $searchableProperties = $configuredSearchableProperties === null || count($configuredSearchableProperties) === 0 ? $entityDto->getClassMetadata()->getFieldNames() : $configuredSearchableProperties; $entitiesAlreadyJoined = []; foreach ($searchableProperties as $propertyName) { - if ($entityDto->isAssociation($propertyName)) { + if ($this->isAssociation($entityDto, $propertyName)) { // support arbitrarily nested associations (e.g. foo.bar.baz.qux) $associatedProperties = explode('.', $propertyName); $numAssociatedProperties = count($associatedProperties); @@ -282,16 +303,15 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc 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.)', $propertyName, $propertyName, $propertyName)); } - $originalPropertyName = $associatedProperties[0]; - $originalPropertyMetadata = $entityDto->getPropertyMetadata($originalPropertyName); + $originalPropertyName = $associatedProperties[0]; $field = $fields->getByProperty($originalPropertyName); if ($field !== null && $field->getFieldFqcn() === EntityField::class) { $targetCrudControllerFqcn = Type\string()->coerce($field->getCustomOption(EntityField::OPTION_CRUD_CONTROLLER)); - $targetEntityFqcn = $this->crudControllerRegistry->findEntityFqcnByCrudFqcn($targetCrudControllerFqcn); + $targetEntityFqcn = Type\nonnull()->coerce($this->crudControllerRegistry->findEntityFqcnByCrudFqcn($targetCrudControllerFqcn)); } else { - $targetEntityFqcn = $originalPropertyMetadata->get('targetEntity'); + $targetEntityFqcn = $entityDto->getClassMetadata()->getAssociationTargetClass($associatedProperties[0]); } $associatedEntityDto = $this->entityFactory->create($targetEntityFqcn); @@ -299,14 +319,14 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc $associatedEntityAlias = $associatedPropertyName = ''; for ($i = 0; $i < $numAssociatedProperties - 1; ++$i) { $associatedEntityName = $associatedProperties[$i]; - $associatedEntityAlias = Escaper::escapeDqlAlias($associatedEntityName); + $associatedEntityAlias = $entitiesAlreadyJoined[$associatedEntityName] ?? Escaper::escapeDqlAlias($associatedEntityName) . ($i === 0 ? '' : $i); $associatedPropertyName = $associatedProperties[$i + 1]; - if (! in_array($associatedEntityName, $entitiesAlreadyJoined, true)) { - $parentEntityName = $i === 0 ? 'entity' : $associatedProperties[$i - 1]; + if (! in_array($associatedEntityAlias, $entitiesAlreadyJoined, true)) { + $parentEntityName = $i === 0 ? 'entity' : $entitiesAlreadyJoined[$associatedProperties[$i - 1]]; if ($field !== null && $field->getFieldFqcn() === EntityField::class) { - $associatedEntityPrimaryKeyName = Type\string()->coerce($associatedEntityDto->getPrimaryKeyName()); + $associatedEntityPrimaryKeyName = Type\string()->coerce($associatedEntityDto->getClassMetadata()->getSingleIdentifierFieldName()); $queryBuilder->leftJoin( $targetEntityFqcn, @@ -324,24 +344,51 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc $queryBuilder->leftJoin(Escaper::escapeDqlAlias($parentEntityName) . '.' . $associatedEntityName, $associatedEntityAlias); } - $entitiesAlreadyJoined[] = $associatedEntityName; + $entitiesAlreadyJoined[$associatedEntityName] = $associatedEntityAlias; } if ($i >= $numAssociatedProperties - 2) { continue; } - $propertyMetadata = $associatedEntityDto->getPropertyMetadata($associatedPropertyName); - $targetEntity = $propertyMetadata->get('targetEntity'); + $targetEntity = $associatedEntityDto->getClassMetadata()->getAssociationTargetClass($associatedPropertyName); $associatedEntityDto = $this->entityFactory->create($targetEntity); } - $entityName = $associatedEntityAlias; - $propertyName = $associatedPropertyName; - $propertyDataType = $associatedEntityDto->getPropertyDataType($propertyName); + $entityName = $associatedEntityAlias; + $propertyName = $associatedPropertyName; + if (! isset($associatedEntityDto->getClassMetadata()->fieldMappings[$propertyName])) { + 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.)', $propertyName, $propertyName, $propertyName)); + } + + // In Doctrine ORM 3.x, FieldMapping implements \ArrayAccess; in 4.x it's an object with properties + $fieldMapping = $associatedEntityDto->getClassMetadata()->getFieldMapping($propertyName); + // In Doctrine ORM 2.x, getFieldMapping() returns an array + /** @phpstan-ignore-next-line function.impossibleType */ + if (is_array($fieldMapping)) { + /** @phpstan-ignore-next-line cast.useless */ + $fieldMapping = (object) $fieldMapping; + } + + /** @phpstan-ignore-next-line function.alreadyNarrowedType */ + $propertyDataType = property_exists($fieldMapping, 'type') ? $fieldMapping->type : $fieldMapping['type']; } else { - $entityName = 'entity'; - $propertyDataType = $entityDto->getPropertyDataType($propertyName); + $entityName = 'entity'; + if (! isset($entityDto->getClassMetadata()->fieldMappings[$propertyName])) { + 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.)', $propertyName, $propertyName, $propertyName)); + } + + // In Doctrine ORM 3.x, FieldMapping implements \ArrayAccess; in 4.x it's an object with properties + $fieldMapping = $entityDto->getClassMetadata()->getFieldMapping($propertyName); + // In Doctrine ORM 2.x, getFieldMapping() returns an array + /** @phpstan-ignore-next-line function.impossibleType */ + if (is_array($fieldMapping)) { + /** @phpstan-ignore-next-line cast.useless */ + $fieldMapping = (object) $fieldMapping; + } + + /** @phpstan-ignore-next-line function.alreadyNarrowedType */ + $propertyDataType = property_exists($fieldMapping, 'type') ? $fieldMapping->type : $fieldMapping['type']; } $isBoolean = $propertyDataType === 'boolean'; @@ -349,7 +396,7 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc $isIntegerProperty = $propertyDataType === 'integer'; $isNumericProperty = in_array($propertyDataType, ['number', 'bigint', 'decimal', 'float'], true); // 'citext' is a PostgreSQL extension (https://github.com/EasyCorp/EasyAdminBundle/issues/2556) - $isTextProperty = in_array($propertyDataType, ['string', 'text', 'citext', 'array', 'simple_array'], true); + $isTextProperty = in_array($propertyDataType, ['ascii_string', 'string', 'text', 'citext', 'array', 'simple_array'], true); $isGuidProperty = in_array($propertyDataType, ['guid', 'uuid'], true); $isUlidProperty = $propertyDataType === 'ulid'; $isJsonProperty = $propertyDataType === 'json'; @@ -364,11 +411,24 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc && ! $isUlidProperty && ! $isJsonProperty ) { - $entityFqcn = $entityName !== 'entity' && isset($associatedEntityDto) + $entityFqcn = $entityName !== 'entity' && isset($associatedEntityDto) ? $associatedEntityDto->getFqcn() : $entityDto->getFqcn(); - $idClassType = (new ReflectionProperty($entityFqcn, $propertyName))->getType(); - assert($idClassType instanceof ReflectionNamedType || $idClassType instanceof ReflectionUnionType || $idClassType === null); + + /** @var ReflectionNamedType|ReflectionUnionType|null $idClassType */ + $idClassType = null; + $reflectionClass = new ReflectionClass($entityFqcn); + + // this is needed to handle inherited properties + while ($reflectionClass !== false) { + if ($reflectionClass->hasProperty($propertyName)) { + $reflection = $reflectionClass->getProperty($propertyName); + $idClassType = $reflection->getType(); + break; + } + + $reflectionClass = $reflectionClass->getParentClass(); + } if ($idClassType !== null) { $idClassName = $idClassType->getName(); @@ -397,4 +457,19 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc return $searchablePropertiesConfig; } + + private function isAssociation(EntityDto $entityDto, string $propertyName): bool + { + if ($entityDto->getClassMetadata()->hasAssociation($propertyName)) { + return true; + } + + if (! str_contains($propertyName, '.')) { + return false; + } + + $propertyNameParts = explode('.', $propertyName, 2); + + return ! isset($entityDto->getClassMetadata()->embeddedClasses[$propertyNameParts[0]]); + } } diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php index 9b90e76..1a07ec4 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -7,10 +7,13 @@ use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldConfiguratorInterface; use EasyCorp\Bundle\EasyAdminBundle\Contracts\Menu\MenuItemMatcherInterface; use EasyCorp\Bundle\EasyAdminBundle\Contracts\Orm\EntityPaginatorInterface; +use EasyCorp\Bundle\EasyAdminBundle\Contracts\Provider\AdminContextProviderInterface; use EasyCorp\Bundle\EasyAdminBundle\DependencyInjection\EasyAdminExtension; use EasyCorp\Bundle\EasyAdminBundle\Menu\MenuItemMatcher; use EasyCorp\Bundle\EasyAdminBundle\Orm\EntityRepository as EasyAdminEntityRepository; +use EasyCorp\Bundle\EasyAdminBundle\Provider\AdminContextProvider; use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator; +use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGeneratorInterface; use Protung\EasyAdminPlusBundle\Field\Configurator\CallbackConfigurableConfigurator; use Protung\EasyAdminPlusBundle\Field\Configurator\CallbackConfigurableConfiguratorAfterCommonPostConfigurator; use Protung\EasyAdminPlusBundle\Field\Configurator\CallbackConfigurableConfiguratorBeforeCommonPreConfigurator; @@ -31,6 +34,9 @@ ->load('Protung\\EasyAdminPlusBundle\\', '../../../src/*') ->exclude(['../../../src/Resources/**/*', '../../../src/Test/**/*']); + $services->alias(AdminContextProviderInterface::class, AdminContextProvider::class); + $services->alias(AdminUrlGeneratorInterface::class, AdminUrlGenerator::class); + $services->set(EntityRepository::class) ->decorate(EasyAdminEntityRepository::class) ->autoconfigure() @@ -44,13 +50,11 @@ $services->set(EntityPaginator::class) ->decorate(EntityPaginatorInterface::class) - ->arg('$adminUrlGenerator', service(AdminUrlGenerator::class)) ->autowire() ->autoconfigure() ->private(); $services->set(AutocompleteActionAdminUrlGenerator::class) - ->arg('$adminUrlGenerator', service(AdminUrlGenerator::class)) ->autowire() ->autoconfigure() ->private(); diff --git a/tests/Test/TestApplication/TestKernel.php b/tests/Test/TestApplication/TestKernel.php index f01f757..0094932 100644 --- a/tests/Test/TestApplication/TestKernel.php +++ b/tests/Test/TestApplication/TestKernel.php @@ -90,7 +90,6 @@ public function configureContainer(ContainerConfigurator $container): void 'path' => '%kernel.cache_dir%/test_database.sqlite', ], 'orm' => [ - 'auto_generate_proxy_classes' => true, 'naming_strategy' => 'doctrine.orm.naming_strategy.underscore_number_aware', 'auto_mapping' => true, ],