From 2e371ef6a4ec9d8dd8ddb017438de5751685e062 Mon Sep 17 00:00:00 2001 From: Evgen Yurchenko Date: Sun, 18 May 2025 22:54:01 +0300 Subject: [PATCH 1/3] MapEntityCollection concept --- .../DoctrineFilterInterface.php | 21 ++ .../EntityCollectionAsset/MappingType.php | 16 ++ .../EntityCollectionResolver.php | 202 ++++++++++++++++++ .../Attribute/MapEntityCollection.php | 92 ++++++++ 4 files changed, 331 insertions(+) create mode 100644 src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityCollectionAsset/DoctrineFilterInterface.php create mode 100644 src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityCollectionAsset/MappingType.php create mode 100644 src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityCollectionResolver.php create mode 100644 src/Symfony/Bridge/Doctrine/Attribute/MapEntityCollection.php diff --git a/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityCollectionAsset/DoctrineFilterInterface.php b/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityCollectionAsset/DoctrineFilterInterface.php new file mode 100644 index 0000000000000..17e50767ae1d7 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityCollectionAsset/DoctrineFilterInterface.php @@ -0,0 +1,21 @@ +getAttributesOfType(MapEntityCollection::class, ArgumentMetadata::IS_INSTANCEOF); + } + + #[AsEventListener(priority: -10)] + public function mapEntityCollection(ControllerArgumentsEvent $event): void + { + $arguments = $event->getArguments(); + foreach ($arguments as $key => $argument) { + if ($argument instanceof MapEntityCollection) { + $arguments[$key] = $this->doResolve($argument, $event); + $event->setArguments($arguments); + break; + } + } + } + + private function doResolve(MapEntityCollection $attribute, ControllerArgumentsEvent $event): object|iterable + { + $objectManager = $this->registry->getManagerForClass($attribute->getClass()); + + if (!$objectManager) { + throw new \RuntimeException(sprintf('No manager found for class "%s".', $attribute->getClass())); + } + + $queryBuilder = $objectManager + ->getRepository($attribute->getClass()) + ->createQueryBuilder(self::QUERY_ROOT_ALIAS); + + $this->doctrineParameterProcessing($queryBuilder, $attribute->getDoctrineParameters(), $event); + + $queryStringObject = $attribute->getQueryString() ? + $event->getNamedArguments()[$attribute->getQueryString()] : null; + + foreach ($attribute->getFilters() as $filter) { + /** @var DoctrineFilterInterface $queryFilter */ + $queryFilter = $this->container->get($filter); + $queryFilter->applyFilter($queryBuilder, $attribute, $event->getRequest(), $queryStringObject); + } + + if ($queryStringObject) { + $limit = null; + $offset = null; + $page = null; + foreach ($this->propertyInfoExtractor->getProperties($queryStringObject) as $property) { + $propertyMapping = $attribute->getQueryMapping()[$property] ?? null; + $value = $this->propertyAccessor->getValue($queryStringObject, $property); + + switch ($propertyMapping) { + case MappingType::LIMIT: + $limit = $value; + break; + case MappingType::OFFSET: + $offset = $value; + break; + case MappingType::PAGE: + $page = $value; + break; + case MappingType::IGNORE: + break; + default: + $this->addCondition($queryBuilder, $property, $value); + } + } + + if (null !== $page || null !== $offset) { + if (!$limit) { + throw new UnprocessableEntityHttpException( + 'The "limit" parameter is required when using "page" or "offset".' + ); + } + + $queryBuilder + ->setMaxResults($limit) + ->setFirstResult($offset ? $offset : ($page - 1) * $limit); + } + } + + if (!$queryBuilder->getDQLPart('orderBy')) { + foreach ($attribute->getDefaultOrdering() as $property => $direction) { + $queryBuilder->addOrderBy( + $this->getQueryProperty( + $attribute->getNameConverter() ? + $attribute->getNameConverter()->denormalize($property) : $property + ), + $direction->value + ); + } + } + + return $attribute->isReturnPaginator() ? new Paginator($queryBuilder) : $queryBuilder->getQuery()->getResult(); + } + + /** + * @param array $parameters + */ + private function doctrineParameterProcessing( + QueryBuilder $queryBuilder, + array $parameters, + ControllerArgumentsEvent $event + ): void + { + foreach ($parameters as $key => $value) { + if (in_array($key, [MappingType::LIMIT, MappingType::PAGE, MappingType::OFFSET], true)) { + throw new LogicException(sprintf('Doctrine parameter "%s" is not supported.', $key)); + } + + $this->addCondition( + queryBuilder: $queryBuilder, + propertyName: $key, + value: $this->buildValue($value, $event->getRequest()->attributes->all()) + ); + } + } + + private function addCondition(QueryBuilder $queryBuilder, string $propertyName, mixed $value): void + { + if (MappingType::IGNORE === $value) { + return; + } + + $expr = $queryBuilder->expr(); + $queryPropertyAlias = $this->getQueryProperty($propertyName); + + if (MappingType::NULL === $value) { + $expression = $expr->isNull($queryPropertyAlias); + } elseif (MappingType::NOT_NULL) { + $expression = $expr->isNotNull($queryPropertyAlias); + } elseif (is_array($value)) { + $expression = $expr->in($queryPropertyAlias, $value); + } else { + $expression = $expr->eq($queryPropertyAlias, $value); + } + + $queryBuilder->andWhere($expression); + } + + /** + * @param array $additionalData + */ + private function buildValue(mixed $value, array $additionalData = []): mixed + { + if (is_string($value) && isset($additionalData[$value])) { + return $additionalData[$value]; + } + + if (MappingType::CURRENT_USER_EXPRESSION === $value) { + return (new ExpressionLanguage())->evaluate($value, ['user' => $this->token->getUser()]); + } + + return $value; + } + + private function getQueryProperty(string $property): string + { + return sprintf('%s.%s', self::QUERY_ROOT_ALIAS, $property); + } +} diff --git a/src/Symfony/Bridge/Doctrine/Attribute/MapEntityCollection.php b/src/Symfony/Bridge/Doctrine/Attribute/MapEntityCollection.php new file mode 100644 index 0000000000000..2c502f0463fe0 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Attribute/MapEntityCollection.php @@ -0,0 +1,92 @@ + $queryMapping + * @param array $doctrineParameters + * @param class-string[] $filters + * @param array $defaultOrdering + */ + public function __construct( + private readonly string $class, + private readonly ?string $queryString = null, + private readonly array $queryMapping = [], + private readonly array $doctrineParameters = [], + private readonly array $filters = [], + private readonly array $defaultOrdering = [], + private readonly bool $returnPaginator = true, + private readonly ?NameConverterInterface $nameConverter = null, + ) + { + parent::__construct(EntityCollectionResolver::class); + } + + /** + * @return class-string + */ + public function getClass(): string + { + return $this->class; + } + + public function getQueryString(): ?string + { + return $this->queryString; + } + + /** + * @return array + */ + public function getQueryMapping(): array + { + return $this->queryMapping; + } + + /** + * @return array + */ + public function getDoctrineParameters(): array + { + return $this->doctrineParameters; + } + + /** + * @return class-string[] + */ + public function getFilters(): array + { + return $this->filters; + } + + /** + * @return array + */ + public function getDefaultOrdering(): array + { + return $this->defaultOrdering; + } + + public function isReturnPaginator(): bool + { + return $this->returnPaginator; + } + + public function getNameConverter(): ?NameConverterInterface + { + return $this->nameConverter; + } +} From 5faa5407c38e3bedea440f56ded8d07e6f560285 Mon Sep 17 00:00:00 2001 From: Evgen Yurchenko Date: Mon, 19 May 2025 00:05:49 +0300 Subject: [PATCH 2/3] MapEntityCollection fix stan and license commit --- .../DoctrineFilterInterface.php | 9 +++++++++ .../EntityCollectionAsset/MappingType.php | 9 +++++++++ .../EntityCollectionResolver.php | 19 +++++++++++++++---- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityCollectionAsset/DoctrineFilterInterface.php b/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityCollectionAsset/DoctrineFilterInterface.php index 17e50767ae1d7..34f3b41f7e45c 100644 --- a/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityCollectionAsset/DoctrineFilterInterface.php +++ b/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityCollectionAsset/DoctrineFilterInterface.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + declare(strict_types=1); namespace Symfony\Bridge\Doctrine\ArgumentResolver\EntityCollectionAsset; diff --git a/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityCollectionAsset/MappingType.php b/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityCollectionAsset/MappingType.php index 2ca806af6255f..316bd4d0f44d7 100644 --- a/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityCollectionAsset/MappingType.php +++ b/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityCollectionAsset/MappingType.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + declare(strict_types=1); namespace Symfony\Bridge\Doctrine\ArgumentResolver\EntityCollectionAsset; diff --git a/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityCollectionResolver.php b/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityCollectionResolver.php index f84756e543b91..1008e3617aa14 100644 --- a/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityCollectionResolver.php +++ b/src/Symfony/Bridge/Doctrine/ArgumentResolver/EntityCollectionResolver.php @@ -1,9 +1,19 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + declare(strict_types=1); namespace Symfony\Bridge\Doctrine\ArgumentResolver; +use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\Tools\Pagination\Paginator; use Doctrine\Persistence\ManagerRegistry; @@ -14,6 +24,7 @@ use Symfony\Bridge\Doctrine\Attribute\MapEntityCollection; use Symfony\Component\DependencyInjection\Attribute\AutowireLocator; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; +use Symfony\Component\ExpressionLanguage\Expression; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Attribute\AsTargetedValueResolver; @@ -63,7 +74,7 @@ private function doResolve(MapEntityCollection $attribute, ControllerArgumentsEv { $objectManager = $this->registry->getManagerForClass($attribute->getClass()); - if (!$objectManager) { + if (!$objectManager instanceof EntityManagerInterface) { throw new \RuntimeException(sprintf('No manager found for class "%s".', $attribute->getClass())); } @@ -145,8 +156,8 @@ private function doctrineParameterProcessing( ): void { foreach ($parameters as $key => $value) { - if (in_array($key, [MappingType::LIMIT, MappingType::PAGE, MappingType::OFFSET], true)) { - throw new LogicException(sprintf('Doctrine parameter "%s" is not supported.', $key)); + if (in_array($value, [MappingType::LIMIT, MappingType::PAGE, MappingType::OFFSET], true)) { + throw new LogicException(sprintf('Doctrine parameter "%s" is not supported.', $value)); } $this->addCondition( @@ -188,7 +199,7 @@ private function buildValue(mixed $value, array $additionalData = []): mixed return $additionalData[$value]; } - if (MappingType::CURRENT_USER_EXPRESSION === $value) { + if ($value instanceof Expression) { return (new ExpressionLanguage())->evaluate($value, ['user' => $this->token->getUser()]); } From 9e689770e5ecac541c9dbec379501bdab34eca3e Mon Sep 17 00:00:00 2001 From: Evgen Yurchenko Date: Mon, 19 May 2025 00:05:54 +0300 Subject: [PATCH 3/3] MapEntityCollection fix stan and license commit --- .../Bridge/Doctrine/Attribute/MapEntityCollection.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Symfony/Bridge/Doctrine/Attribute/MapEntityCollection.php b/src/Symfony/Bridge/Doctrine/Attribute/MapEntityCollection.php index 2c502f0463fe0..6c47e9a1fa216 100644 --- a/src/Symfony/Bridge/Doctrine/Attribute/MapEntityCollection.php +++ b/src/Symfony/Bridge/Doctrine/Attribute/MapEntityCollection.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + declare(strict_types=1); namespace Symfony\Bridge\Doctrine\Attribute;