diff --git a/CHANGELOG.md b/CHANGELOG.md index 51251e6e3..87db5b623 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,21 @@ ## CHANGELOG +## v1.13.1 (2025-09-24) + +#### Details + +- [#1037](https://github.com/Sylius/SyliusResourceBundle/pull/1037) Add missing event short name on apply state machine transition ([@loic425](https://github.com/loic425)) +- [#1045](https://github.com/Sylius/SyliusResourceBundle/pull/1045) Fix - Add missing redirect & redirect arguments ([@loic425](https://github.com/loic425)) +- [#1064](https://github.com/Sylius/SyliusResourceBundle/pull/1064) Fix missing behat transliterator package ([@loic425](https://github.com/loic425)) +- [#1063](https://github.com/Sylius/SyliusResourceBundle/pull/1063) Add an error message for non existing repository method & suggest to use CreatePaginatorTrait ([@loic425](https://github.com/loic425)) + ## v1.13.0 (2025-06-04) +#### Details + - [#1025](https://github.com/Sylius/SyliusResourceBundle/pull/1025) Fix implicit nullable parameter + add ECS rule to 1.12 ([@GSadee](https://github.com/GSadee)) - [#1024](https://github.com/Sylius/SyliusResourceBundle/pull/1024) Fix implicit nullable parameter + add ECS rule ([@GSadee](https://github.com/GSadee)) -#### Details - ## v1.13.0-BETA.1 (2025-05-28) #### Details diff --git a/composer.json b/composer.json index 7b74a17d6..712ef595a 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ ], "require": { "php": "^8.1", + "behat/transliterator": "^1.2", "babdev/pagerfanta-bundle": "^4.4", "doctrine/annotations": "^2.0", "doctrine/collections": "^2.2", diff --git a/psalm.xml b/psalm.xml index 81de9bc5f..455f091bc 100644 --- a/psalm.xml +++ b/psalm.xml @@ -40,12 +40,14 @@ + + diff --git a/src/Bundle/Doctrine/ORM/CreatePaginatorTrait.php b/src/Bundle/Doctrine/ORM/CreatePaginatorTrait.php new file mode 100644 index 000000000..06962574a --- /dev/null +++ b/src/Bundle/Doctrine/ORM/CreatePaginatorTrait.php @@ -0,0 +1,104 @@ + + */ + public function createPaginator(array $criteria = [], array $sorting = []): iterable + { + $queryBuilder = $this->createQueryBuilder('o'); + + $this->applyCriteria($queryBuilder, $criteria); + $this->applySorting($queryBuilder, $sorting); + + return $this->getPaginator($queryBuilder); + } + + protected function getPaginator(QueryBuilder $queryBuilder): PagerfantaInterface + { + if (!class_exists(QueryAdapter::class)) { + throw new \LogicException('You can not use the "paginator" if Pargefanta Doctrine ORM Adapter is not available. Try running "composer require pagerfanta/doctrine-orm-adapter".'); + } + + // Use output walkers option in the query adapter should be false as it affects performance greatly (see sylius/sylius#3775) + return new Pagerfanta(new QueryAdapter($queryBuilder, false, false)); + } + + /** + * @param array $objects + */ + protected function getArrayPaginator($objects): PagerfantaInterface + { + return new Pagerfanta(new ArrayAdapter($objects)); + } + + protected function applyCriteria(QueryBuilder $queryBuilder, array $criteria = []): void + { + foreach ($criteria as $property => $value) { + if (!in_array($property, array_merge($this->getClassMetadata()->getAssociationNames(), $this->getClassMetadata()->getFieldNames()), true)) { + continue; + } + + $name = $this->getPropertyName($property); + + if (null === $value) { + $queryBuilder->andWhere($queryBuilder->expr()->isNull($name)); + } elseif (is_array($value)) { + $queryBuilder->andWhere($queryBuilder->expr()->in($name, $value)); + } elseif ('' !== $value) { + $parameter = str_replace('.', '_', $property); + $queryBuilder + ->andWhere($queryBuilder->expr()->eq($name, ':' . $parameter)) + ->setParameter($parameter, $value) + ; + } + } + } + + protected function applySorting(QueryBuilder $queryBuilder, array $sorting = []): void + { + foreach ($sorting as $property => $order) { + if (!in_array($property, array_merge($this->getClassMetadata()->getAssociationNames(), $this->getClassMetadata()->getFieldNames()), true)) { + continue; + } + + if (!empty($order)) { + $queryBuilder->addOrderBy($this->getPropertyName($property), $order); + } + } + } + + protected function getPropertyName(string $name): string + { + if (!str_contains($name, '.')) { + return 'o' . '.' . $name; + } + + return $name; + } +} diff --git a/src/Bundle/Doctrine/ORM/ResourceRepositoryTrait.php b/src/Bundle/Doctrine/ORM/ResourceRepositoryTrait.php index abd6fad6c..d8d6caaa6 100644 --- a/src/Bundle/Doctrine/ORM/ResourceRepositoryTrait.php +++ b/src/Bundle/Doctrine/ORM/ResourceRepositoryTrait.php @@ -13,23 +13,16 @@ namespace Sylius\Bundle\ResourceBundle\Doctrine\ORM; -use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\Mapping\ClassMetadata; -use Doctrine\ORM\QueryBuilder; -use Pagerfanta\Adapter\ArrayAdapter; -use Pagerfanta\Doctrine\ORM\QueryAdapter; -use Pagerfanta\Pagerfanta; +use Doctrine\ORM\EntityRepository as DoctrineEntityRepository; use Sylius\Resource\Model\ResourceInterface; /** - * @property EntityManagerInterface $_em - * @property ClassMetadata $_class - * - * @method QueryBuilder createQueryBuilder(string $alias, string $indexBy = null) - * @method ?object find($id, $lockMode = null, $lockVersion = null) + * @mixin DoctrineEntityRepository */ trait ResourceRepositoryTrait { + use CreatePaginatorTrait; + public function add(ResourceInterface $resource): void { $this->getEntityManager()->persist($resource); @@ -43,80 +36,4 @@ public function remove(ResourceInterface $resource): void $this->getEntityManager()->flush(); } } - - /** - * @return iterable - */ - public function createPaginator(array $criteria = [], array $sorting = []): iterable - { - $queryBuilder = $this->createQueryBuilder('o'); - - $this->applyCriteria($queryBuilder, $criteria); - $this->applySorting($queryBuilder, $sorting); - - return $this->getPaginator($queryBuilder); - } - - protected function getPaginator(QueryBuilder $queryBuilder): Pagerfanta - { - if (!class_exists(QueryAdapter::class)) { - throw new \LogicException('You can not use the "paginator" if Pargefanta Doctrine ORM Adapter is not available. Try running "composer require pagerfanta/doctrine-orm-adapter".'); - } - - // Use output walkers option in the query adapter should be false as it affects performance greatly (see sylius/sylius#3775) - return new Pagerfanta(new QueryAdapter($queryBuilder, false, false)); - } - - /** - * @param array $objects - */ - protected function getArrayPaginator($objects): Pagerfanta - { - return new Pagerfanta(new ArrayAdapter($objects)); - } - - protected function applyCriteria(QueryBuilder $queryBuilder, array $criteria = []): void - { - foreach ($criteria as $property => $value) { - if (!in_array($property, array_merge($this->getClassMetadata()->getAssociationNames(), $this->getClassMetadata()->getFieldNames()), true)) { - continue; - } - - $name = $this->getPropertyName($property); - - if (null === $value) { - $queryBuilder->andWhere($queryBuilder->expr()->isNull($name)); - } elseif (is_array($value)) { - $queryBuilder->andWhere($queryBuilder->expr()->in($name, $value)); - } elseif ('' !== $value) { - $parameter = str_replace('.', '_', $property); - $queryBuilder - ->andWhere($queryBuilder->expr()->eq($name, ':' . $parameter)) - ->setParameter($parameter, $value) - ; - } - } - } - - protected function applySorting(QueryBuilder $queryBuilder, array $sorting = []): void - { - foreach ($sorting as $property => $order) { - if (!in_array($property, array_merge($this->getClassMetadata()->getAssociationNames(), $this->getClassMetadata()->getFieldNames()), true)) { - continue; - } - - if (!empty($order)) { - $queryBuilder->addOrderBy($this->getPropertyName($property), $order); - } - } - } - - protected function getPropertyName(string $name): string - { - if (false === strpos($name, '.')) { - return 'o' . '.' . $name; - } - - return $name; - } } diff --git a/src/Bundle/Resources/config/services.xml b/src/Bundle/Resources/config/services.xml index de604d93c..a276b30e3 100644 --- a/src/Bundle/Resources/config/services.xml +++ b/src/Bundle/Resources/config/services.xml @@ -69,7 +69,7 @@ - Sylius\Component\Resource\Repository\RepositoryInterface + Doctrine\Persistence\ObjectRepository resource repository diff --git a/src/Component/spec/Symfony/Request/State/ProviderSpec.php b/src/Component/spec/Symfony/Request/State/ProviderSpec.php index 05d3fbb89..cca8a3af6 100644 --- a/src/Component/spec/Symfony/Request/State/ProviderSpec.php +++ b/src/Component/spec/Symfony/Request/State/ProviderSpec.php @@ -16,10 +16,12 @@ use Pagerfanta\Pagerfanta; use PhpSpec\ObjectBehavior; use Psr\Container\ContainerInterface; +use Sylius\Bundle\ResourceBundle\Doctrine\ORM\CreatePaginatorTrait; use Sylius\Component\Resource\Tests\Dummy\RepositoryWithCallables; use Sylius\Resource\Context\Context; use Sylius\Resource\Context\Option\RequestOption; use Sylius\Resource\Doctrine\Persistence\RepositoryInterface; +use Sylius\Resource\Exception\RuntimeException; use Sylius\Resource\Metadata\Index; use Sylius\Resource\Metadata\Operation; use Sylius\Resource\Symfony\ExpressionLanguage\ArgumentParserInterface; @@ -172,4 +174,38 @@ function it_calls_repository_as_string_with_specific_repository_method_an_argume $response = $this->provide($operation, new Context(new RequestOption($request->getWrappedObject()))); $response->shouldReturn($stdClass); } + + function it_throws_an_exception_when_repository_method_does_not_exist( + Operation $operation, + Request $request, + ContainerInterface $locator, + ): void { + $operation->getRepository()->willReturn('App\Repository'); + $operation->getRepositoryMethod()->willReturn('notFoundMethod'); + $operation->getRepositoryArguments()->willReturn(['id' => "request.attributes.get('id')"]); + + $locator->has('App\Repository')->willReturn(true); + $locator->get('App\Repository')->willReturn(new \stdClass()); + + $errorMessage = sprintf('Method "notFoundMethod" not found on repository "%s". You can either add it or configure another one in the repositoryMethod option for your operation.', \stdClass::class); + + $this->shouldThrow(new RuntimeException($errorMessage))->during('provide', [$operation, new Context(new RequestOption($request->getWrappedObject()))]); + } + + function it_throws_an_exception_when_repository_method_does_not_exist_and_suggest_to_use_create_paginator_if_it_is_appropriated( + Operation $operation, + Request $request, + ContainerInterface $locator, + ): void { + $operation->getRepository()->willReturn('App\Repository'); + $operation->getRepositoryMethod()->willReturn('createPaginator'); + $operation->getRepositoryArguments()->willReturn(['id' => "request.attributes.get('id')"]); + + $locator->has('App\Repository')->willReturn(true); + $locator->get('App\Repository')->willReturn(new \stdClass()); + + $errorMessage = sprintf('Method "createPaginator" not found on repository "%s". You can use the "%s" trait on this repository class.', \stdClass::class, CreatePaginatorTrait::class); + + $this->shouldThrow(new RuntimeException($errorMessage))->during('provide', [$operation, new Context(new RequestOption($request->getWrappedObject()))]); + } } diff --git a/src/Component/src/Metadata/ResourceMetadata.php b/src/Component/src/Metadata/ResourceMetadata.php index c2ee0a9c3..718b30c25 100644 --- a/src/Component/src/Metadata/ResourceMetadata.php +++ b/src/Component/src/Metadata/ResourceMetadata.php @@ -27,9 +27,9 @@ public function __construct( private ?string $pluralName = null, private ?string $applicationName = null, private ?string $identifier = null, - protected ?array $normalizationContext = null, - protected ?array $denormalizationContext = null, - protected ?array $validationContext = null, + private ?array $normalizationContext = null, + private ?array $denormalizationContext = null, + private ?array $validationContext = null, private ?string $class = null, private string|false|null $driver = null, private ?array $vars = null, diff --git a/src/Component/src/Symfony/Request/State/Provider.php b/src/Component/src/Symfony/Request/State/Provider.php index cea4eda91..8db9cfa97 100644 --- a/src/Component/src/Symfony/Request/State/Provider.php +++ b/src/Component/src/Symfony/Request/State/Provider.php @@ -13,10 +13,12 @@ namespace Sylius\Resource\Symfony\Request\State; -use Pagerfanta\Pagerfanta; +use Pagerfanta\PagerfantaInterface; use Psr\Container\ContainerInterface; +use Sylius\Bundle\ResourceBundle\Doctrine\ORM\CreatePaginatorTrait; use Sylius\Resource\Context\Context; use Sylius\Resource\Context\Option\RequestOption; +use Sylius\Resource\Exception\RuntimeException; use Sylius\Resource\Metadata\BulkOperationInterface; use Sylius\Resource\Metadata\CollectionOperationInterface; use Sylius\Resource\Metadata\Operation; @@ -59,14 +61,28 @@ public function provide(Operation $operation, Context $context): object|array|nu $defaultMethod = 'findById'; } - $method = $operation->getRepositoryMethod() ?? $defaultMethod; + $customMethod = $operation->getRepositoryMethod(); + $method = $customMethod ?? $defaultMethod; if (!$this->locator->has($repository)) { - throw new \RuntimeException(sprintf('Repository "%s" not found on operation "%s"', $repository, $operation->getName() ?? '')); + throw new RuntimeException(sprintf('Repository "%s" not found on operation "%s".', $repository, $operation->getName() ?? '')); } + /** @var object $repositoryInstance */ $repositoryInstance = $this->locator->get($repository); + if ( + !str_starts_with($method, 'find') && // to allow magic calls on Doctrine repository methods + !\method_exists($repositoryInstance, $method)) { + $errorMessage = sprintf('Method "%s" not found on repository "%s". You can either add it or configure another one in the repositoryMethod option for your operation.', $method, get_debug_type($repositoryInstance)); + + if ('createPaginator' === $method) { + $errorMessage = sprintf('Method "%s" not found on repository "%s". You can use the "%s" trait on this repository class.', $method, get_debug_type($repositoryInstance), CreatePaginatorTrait::class); + } + + throw new RuntimeException($errorMessage); + } + // make it as callable /** @var callable $repository */ $repository = [$repositoryInstance, $method]; @@ -91,7 +107,7 @@ public function provide(Operation $operation, Context $context): object|array|nu $data = $repository(...$arguments); - if ($data instanceof Pagerfanta) { + if ($data instanceof PagerfantaInterface) { $currentPage = $request->query->getInt('page', 1); $data->setCurrentPage($currentPage); } diff --git a/tests/Application/src/Subscription/Entity/Subscription.php b/tests/Application/src/Subscription/Entity/Subscription.php index 1e2aa6e50..8129afc1e 100644 --- a/tests/Application/src/Subscription/Entity/Subscription.php +++ b/tests/Application/src/Subscription/Entity/Subscription.php @@ -37,6 +37,7 @@ routePrefix: '/admin', )] #[Index(grid: 'app_subscription')] +#[Index(shortName: 'withoutGrid', template: 'crud/index.html.twig')] #[Create] #[Update] #[Delete] @@ -66,7 +67,7 @@ #[Api\Delete] #[Api\Get] -#[ORM\Entity] +#[ORM\Entity(repositoryClass: SubscriptionRepository::class)] class Subscription implements ResourceInterface { #[ORM\Column(type: 'string')] diff --git a/tests/Application/src/Subscription/Entity/SubscriptionRepository.php b/tests/Application/src/Subscription/Entity/SubscriptionRepository.php new file mode 100644 index 000000000..05f2574c0 --- /dev/null +++ b/tests/Application/src/Subscription/Entity/SubscriptionRepository.php @@ -0,0 +1,29 @@ +registry, Subscription::class); + } +} diff --git a/tests/Application/templates/crud/index.html.twig b/tests/Application/templates/crud/index.html.twig index 7e26ad5c0..b2f7bc811 100644 --- a/tests/Application/templates/crud/index.html.twig +++ b/tests/Application/templates/crud/index.html.twig @@ -4,7 +4,7 @@

{{ (operation.resource.applicationName ~ '.ui.' ~ operation.resource.pluralName)|trans }}

{% set grid = resources %} - {% set definition = grid.definition %} + {% set definition = grid.definition|default(null) %} {% if definition.actionGroups.bulk is defined and definition.getEnabledActions('bulk')|length > 0 %} {% for action in definition.getEnabledActions('bulk') %} @@ -12,33 +12,39 @@ {% endfor %} {% endif %} - - - - {% for field in definition.fields %} - {% if field.enabled %} - - {% endif %} - {% if definition.actionGroups.item is defined and definition.getEnabledActions('item')|length > 0 %} - - {% endif %} - {% endfor %} - - - - {% for resource in resources.data %} + {% if not definition %} + This template doesn't support non-grid data. + {% endif %} + + {% if definition %} +
{{ field.label|trans }}Actions
+ - {% for field in definition.enabledFields %} - - + {% for field in definition.fields %} + {% if field.enabled %} + + {% endif %} + {% if definition.actionGroups.item is defined and definition.getEnabledActions('item')|length > 0 %} + + {% endif %} {% endfor %} - {% endfor %} - + + + {% for resource in resources.data %} + + {% for field in definition.enabledFields %} + + + {% endfor %} + + {% endfor %} + -
{{ sylius_grid_render_field(grid, field, resource) }} - {% for action in definition.getEnabledActions('item') %} - {{ sylius_grid_render_action(grid, action, resource) }} - {% endfor %} - {{ field.label|trans }}Actions
{{ sylius_grid_render_field(grid, field, resource) }} + {% for action in definition.getEnabledActions('item') %} + {{ sylius_grid_render_action(grid, action, resource) }} + {% endfor %} +
+ + {% endif %} {% endblock %}