From 1ca2843c31a40ec4d10e03e38fd497a0b7559c14 Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Thu, 27 Feb 2020 14:30:31 +0100 Subject: [PATCH] initial bundle (#1) --- .github/workflows/php.yml | 32 +++ ...dcraftedInTheAlpsSuluResourceExtension.php | 22 ++ Exception/MissingResultException.php | 25 +++ Exception/ModelNotFoundException.php | 53 +++++ .../DoctrineListRepresentationFactory.php | 92 +++++++++ ...rineListRepresentationFactoryInterface.php | 20 ++ ...octrineNestedListRepresentationFactory.php | 188 ++++++++++++++++++ ...stedListRepresentationFactoryInterface.php | 24 +++ MessageBus/HandleTrait.php | 24 +++ MessageBus/RegisterMessageBusTrait.php | 74 +++++++ Middleware/DoctrineFlushMiddleware.php | 50 +++++ Payload/PayloadTrait.php | 180 +++++++++++++++++ README.md | 21 +- Resources/config/services.xml | 35 ++++ composer.json | 31 +-- 15 files changed, 850 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/php.yml create mode 100644 DependencyInjection/HandcraftedInTheAlpsSuluResourceExtension.php create mode 100644 Exception/MissingResultException.php create mode 100755 Exception/ModelNotFoundException.php create mode 100644 ListRepresentation/DoctrineListRepresentationFactory.php create mode 100644 ListRepresentation/DoctrineListRepresentationFactoryInterface.php create mode 100644 ListRepresentation/DoctrineNestedListRepresentationFactory.php create mode 100644 ListRepresentation/DoctrineNestedListRepresentationFactoryInterface.php create mode 100644 MessageBus/HandleTrait.php create mode 100644 MessageBus/RegisterMessageBusTrait.php create mode 100644 Middleware/DoctrineFlushMiddleware.php create mode 100644 Payload/PayloadTrait.php create mode 100644 Resources/config/services.xml diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml new file mode 100644 index 0000000..b24dfb0 --- /dev/null +++ b/.github/workflows/php.yml @@ -0,0 +1,32 @@ +name: PHP + +on: pull_request + +jobs: + tests: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v1 + + - name: Validate composer.json and composer.lock + run: composer validate + + - name: Get Composer Cache Directory + id: composer-cache + run: | + echo "::set-output name=dir::$(composer config cache-files-dir)" + - uses: actions/cache@v1 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: | + ${{ runner.os }}-composer- + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-suggest + + - name: Linting code + run: composer lint + + - name: Run unit tests + run: composer phpunit diff --git a/DependencyInjection/HandcraftedInTheAlpsSuluResourceExtension.php b/DependencyInjection/HandcraftedInTheAlpsSuluResourceExtension.php new file mode 100644 index 0000000..31e8cec --- /dev/null +++ b/DependencyInjection/HandcraftedInTheAlpsSuluResourceExtension.php @@ -0,0 +1,22 @@ +load('services.xml'); + } +} diff --git a/Exception/MissingResultException.php b/Exception/MissingResultException.php new file mode 100644 index 0000000..f35bd32 --- /dev/null +++ b/Exception/MissingResultException.php @@ -0,0 +1,25 @@ +method = $method; + } + + public function getMethod(): string + { + return $this->method; + } +} diff --git a/Exception/ModelNotFoundException.php b/Exception/ModelNotFoundException.php new file mode 100755 index 0000000..4f4aa2f --- /dev/null +++ b/Exception/ModelNotFoundException.php @@ -0,0 +1,53 @@ + $value) { + $criteriaMessages[] = sprintf('with %s "%s"', $key, $value); + } + + $message = sprintf( + 'Entity "%s" with %s not found', + $entity, + implode(' and ', $criteriaMessages) + ); + + parent::__construct($message, $code, $previous); + + $this->entity = $entity; + $this->criteria = $criteria; + } + + public function getEntity(): string + { + return $this->entity; + } + + /** + * @return mixed[] + */ + public function getCriteria(): array + { + return $this->criteria; + } +} diff --git a/ListRepresentation/DoctrineListRepresentationFactory.php b/ListRepresentation/DoctrineListRepresentationFactory.php new file mode 100644 index 0000000..8b729b1 --- /dev/null +++ b/ListRepresentation/DoctrineListRepresentationFactory.php @@ -0,0 +1,92 @@ +restHelper = $restHelper; + $this->listRestHelper = $listRestHelper; + $this->listBuilderFactory = $listBuilderFactory; + $this->fieldDescriptorFactory = $fieldDescriptorFactory; + } + + /** + * @param mixed[] $filters + * @param mixed[] $parameters + */ + public function createDoctrineListRepresentation( + string $resourceKey, + array $filters = [], + array $parameters = [] + ): PaginatedRepresentation { + /** @var DoctrineFieldDescriptor[] $fieldDescriptors */ + $fieldDescriptors = $this->fieldDescriptorFactory->getFieldDescriptors($resourceKey); + + $listBuilder = $this->listBuilderFactory->create($fieldDescriptors['id']->getEntityName()); + $this->restHelper->initializeListBuilder($listBuilder, $fieldDescriptors); + + foreach ($parameters as $key => $value) { + $listBuilder->setParameter($key, $value); + } + + foreach ($filters as $key => $value) { + $listBuilder->where($fieldDescriptors[$key], $value); + } + + $items = $listBuilder->execute(); + + // sort the items to reflect the order of the given ids if the list was requested to include specific ids + $requestedIds = $this->listRestHelper->getIds(); + if (null !== $requestedIds) { + usort($items, static function ($item1, $item2) use ($requestedIds) { + $item1Position = array_search($item1['id'], $requestedIds, true); + $item2Position = array_search($item2['id'], $requestedIds, true); + + return $item1Position - $item2Position; + }); + } + + return new PaginatedRepresentation( + $items, + $resourceKey, + (int) $listBuilder->getCurrentPage(), + (int) $listBuilder->getLimit(), + (int) $listBuilder->count() + ); + } +} diff --git a/ListRepresentation/DoctrineListRepresentationFactoryInterface.php b/ListRepresentation/DoctrineListRepresentationFactoryInterface.php new file mode 100644 index 0000000..9526a48 --- /dev/null +++ b/ListRepresentation/DoctrineListRepresentationFactoryInterface.php @@ -0,0 +1,20 @@ +restHelper = $restHelper; + $this->listBuilderFactory = $listBuilderFactory; + $this->fieldDescriptorFactory = $fieldDescriptorFactory; + $this->entityManager = $entityManager; + } + + /** + * @param mixed[] $filters + * @param mixed[] $parameters + * @param int|string|null $parentId + * @param int[]|string[] $expandedIds + */ + public function createDoctrineListRepresentation( + string $resourceKey, + array $filters = [], + array $parameters = [], + $parentId = null, + array $expandedIds = [] + ): CollectionRepresentation { + /** @var DoctrineFieldDescriptor[] $fieldDescriptors */ + $fieldDescriptors = $this->fieldDescriptorFactory->getFieldDescriptors($resourceKey); + $listBuilder = $this->listBuilderFactory->create($fieldDescriptors['id']->getEntityName()); + $this->restHelper->initializeListBuilder($listBuilder, $fieldDescriptors); + + foreach ($parameters as $key => $value) { + $listBuilder->setParameter($key, $value); + } + + foreach ($filters as $key => $value) { + $listBuilder->where($fieldDescriptors[$key], $value); + } + + // disable pagination to simplify tree handling and select tree related properties that are used below + $listBuilder->limit(PHP_INT_MAX); + $listBuilder->addSelectField($fieldDescriptors['lft']); + $listBuilder->addSelectField($fieldDescriptors['rgt']); + $listBuilder->addSelectField($fieldDescriptors['parentId']); + + // collect entities of which the children should be included in the response + $idsToExpand = array_merge( + [$parentId], + $this->findIdsOnPathsBetween($fieldDescriptors['id']->getEntityName(), $parentId, $expandedIds), + $expandedIds + ); + + // generate expressions to select only entities that are children of the collected expand-entities + $expandExpressions = []; + foreach ($idsToExpand as $idToExpand) { + $expandExpressions[] = $listBuilder->createWhereExpression( + $fieldDescriptors['parentId'], + $idToExpand, + ListBuilderInterface::WHERE_COMPARATOR_EQUAL + ); + } + + if (1 === \count($expandExpressions)) { + $listBuilder->addExpression($expandExpressions[0]); + } elseif (\count($expandExpressions) > 1) { + $orExpression = $listBuilder->createOrExpression($expandExpressions); + $listBuilder->addExpression($orExpression); + } + + return new CollectionRepresentation( + $this->generateNestedRows($parentId, $resourceKey, $listBuilder->execute()), + $resourceKey + ); + } + + /** + * @param int|string|null $startId + * @param int[]|string[] $endIds + * + * @return int[]|string[] + */ + private function findIdsOnPathsBetween(string $entityClass, $startId, array $endIds): array + { + // there are no paths and therefore no ids if we dont have any end-ids + if (0 === \count($endIds)) { + return []; + } + + $queryBuilder = $this->entityManager->createQueryBuilder() + ->from($entityClass, 'entity') + ->select('entity.id'); + + // if this start-id is not set we want to include all paths from the root to our end-ids + if ($startId) { + $queryBuilder->from($entityClass, 'startEntity') + ->andWhere('startEntity.id = :startIds') + ->andWhere('entity.lft > startEntity.lft') + ->andWhere('entity.rgt < startEntity.rgt') + ->setParameter('startIds', $startId); + } + + $queryBuilder->from($entityClass, 'endEntity') + ->andWhere('endEntity.id IN (:endIds)') + ->andWhere('entity.lft < endEntity.lft') + ->andWhere('entity.rgt > endEntity.rgt') + ->setParameter('endIds', $endIds); + + return array_map('current', $queryBuilder->getQuery()->getScalarResult()); + } + + /** + * @param int|string|null $parentId + * @param mixed[] $flatRows + * + * @return mixed[] + */ + private function generateNestedRows($parentId, string $resourceKey, array $flatRows): array + { + // add hasChildren property that is expected by the sulu frontend + foreach ($flatRows as &$row) { + $row['hasChildren'] = ($row['lft'] + 1) !== $row['rgt']; + } + + // group rows by the id of their parent + $rowsByParentId = []; + foreach ($flatRows as &$row) { + $rowParentId = $row['parentId']; + if (!\array_key_exists($rowParentId, $rowsByParentId)) { + $rowsByParentId[$rowParentId] = []; + } + $rowsByParentId[$rowParentId][] = &$row; + } + + // embed children rows int their parent rows + foreach ($flatRows as &$row) { + $rowId = $row['id']; + if (\array_key_exists($rowId, $rowsByParentId)) { + $row['_embedded'] = [ + $resourceKey => $rowsByParentId[$rowId], + ]; + } + } + + // remove tree related properties from the response + foreach ($flatRows as &$row) { + unset($row['rgt']); + unset($row['lft']); + unset($row['parentId']); + } + + return $rowsByParentId[$parentId] ?? []; + } +} diff --git a/ListRepresentation/DoctrineNestedListRepresentationFactoryInterface.php b/ListRepresentation/DoctrineNestedListRepresentationFactoryInterface.php new file mode 100644 index 0000000..d74269d --- /dev/null +++ b/ListRepresentation/DoctrineNestedListRepresentationFactoryInterface.php @@ -0,0 +1,24 @@ +doHandle($message); + } catch (HandlerFailedException $exception) { + throw $exception->getPrevious(); + } + } +} diff --git a/MessageBus/RegisterMessageBusTrait.php b/MessageBus/RegisterMessageBusTrait.php new file mode 100644 index 0000000..f353e8b --- /dev/null +++ b/MessageBus/RegisterMessageBusTrait.php @@ -0,0 +1,74 @@ +hasDefinition($busId)) { + throw new \LogicException(sprintf('Message bus "%s" has already been registered!', $busId)); + } + + // We can not prepend the message bus in framework bundle as we don't want that it is accidentally the default + // bus of a project. So we create the bus here ourselves be reimplementing the logic of the FrameworkExtension. + // See: https://github.com/symfony/symfony/blob/v4.3.6/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php#L1647-L1686 + + $defaultMiddleware = [ + 'before' => [ + ['id' => 'add_bus_name_stamp_middleware'], + ['id' => 'reject_redelivered_message_middleware'], + ['id' => 'dispatch_after_current_bus'], + ['id' => 'failed_message_processing_middleware'], + ], + 'after' => [ + ['id' => 'send_message'], + ['id' => 'handle_message'], + ], + ]; + + // argument to add_bus_name_stamp_middleware + $defaultMiddleware['before'][0]['arguments'] = [$busId]; + + $middlewareConfig = array_merge($defaultMiddleware['before'], $middleware, $defaultMiddleware['after']); + + $container->setParameter(sprintf('%s.middleware', $busId), $middlewareConfig); + $container + ->register($busId, MessageBus::class) + ->addArgument([]) + ->addTag('messenger.bus') + ->setPublic(true); + $container->registerAliasForArgument($busId, MessageBusInterface::class); + } + + protected function registerFlushMiddleware(ContainerBuilder $container, string $middlewareId = 'doctrine_flush_middleware'): void + { + if ($container->hasDefinition($middlewareId)) { + throw new \LogicException(sprintf('Middleware "%s" has already been registered!', $middlewareId)); + } + + $container + ->register($middlewareId, DoctrineFlushMiddleware::class) + ->addArgument(new Reference('doctrine.orm.default_entity_manager')) + ->setPublic(true); + } + + protected function registerMessageBusWithFlushMiddleware(ContainerBuilder $container, string $busId, string $middlewareId = 'doctrine_flush_middleware'): void + { + $this->registerFlushMiddleware($container, $middlewareId); + + $middleware = [ + ['id' => $middlewareId], + ]; + + $this->registerMessageBus($container, $busId, $middleware); + } +} diff --git a/Middleware/DoctrineFlushMiddleware.php b/Middleware/DoctrineFlushMiddleware.php new file mode 100644 index 0000000..cd14780 --- /dev/null +++ b/Middleware/DoctrineFlushMiddleware.php @@ -0,0 +1,50 @@ +entityManager = $entityManager; + } + + /** + * {@inheritdoc} + */ + public function handle(Envelope $envelope, StackInterface $stack): Envelope + { + ++$this->messageDepth; + + try { + $envelope = $stack->next()->handle($envelope, $stack); + } finally { + // need to decrease message depth in every case to start handling of next message at depth 0 + --$this->messageDepth; + } + + // flush unit-of-work to the database after the root message was handled successfully + if (0 === $this->messageDepth) { + $this->entityManager->flush(); + } + + return $envelope; + } +} diff --git a/Payload/PayloadTrait.php b/Payload/PayloadTrait.php new file mode 100644 index 0000000..158ae57 --- /dev/null +++ b/Payload/PayloadTrait.php @@ -0,0 +1,180 @@ +payload = $payload; + } + + /** + * @return mixed[] + */ + public function getPayload(): array + { + return $this->payload; + } + + public function keyExists(string $key): bool + { + return \array_key_exists($key, $this->payload); + } + + /** + * @return mixed + */ + public function getValue(string $key) + { + Assert::keyExists($this->payload, $key); + + return $this->payload[$key]; + } + + public function getBoolValue(string $key): bool + { + $value = $this->getValue($key); + + Assert::boolean($value); + + return $value; + } + + public function getNullableBoolValue(string $key): ?bool + { + $value = $this->getValue($key); + if (null === $value) { + return null; + } + + Assert::boolean($value); + + return $value; + } + + public function getStringValue(string $key): string + { + $value = $this->getValue($key); + + Assert::string($value); + + return $value; + } + + public function getNullableStringValue(string $key): ?string + { + $value = $this->getValue($key); + if (null === $value) { + return null; + } + + Assert::string($value); + + return $value; + } + + public function getDateTimeValueValue(string $key): \DateTimeImmutable + { + return new \DateTimeImmutable($this->getStringValue($key)); + } + + public function getNullableDateTimeValue(string $key): ?\DateTimeImmutable + { + $value = $this->getNullableStringValue($key); + if (!$value) { + return null; + } + + return new \DateTimeImmutable($value); + } + + public function getFloatValue(string $key): float + { + $value = $this->getValue($key); + + if (\is_int($value)) { + $value = (float) $value; + } + + Assert::float($value); + + return $value; + } + + public function getNullableFloatValue(string $key): ?float + { + $value = $this->getValue($key); + if (null === $value) { + return null; + } + + if (\is_int($value)) { + $value = (float) $value; + } + + Assert::float($value); + + return $value; + } + + public function getIntValue(string $key): int + { + $value = $this->getValue($key); + + Assert::integer($value); + + return $value; + } + + public function getNullableIntValue(string $key): ?int + { + $value = $this->getValue($key); + if (null === $value) { + return null; + } + + Assert::integer($value); + + return $value; + } + + /** + * @return mixed[] + */ + public function getArrayValue(string $key): array + { + $value = $this->getValue($key); + + Assert::isArray($value); + + return $value; + } + + /** + * @return mixed[]|null + */ + public function getNullableArrayValue(string $key): ?array + { + $value = $this->getValue($key); + if (null === $value) { + return null; + } + + Assert::isArray($value); + + return $value; + } +} diff --git a/README.md b/README.md index b697a5c..f1f4377 100644 --- a/README.md +++ b/README.md @@ -1 +1,20 @@ -# SuluResourceBundle +# HandcraftedInTheAlps - SuluResourceBundle + +## Installation + +### Add repository to `composer.json` + +```bash +composer require handcraftedinthealps/sulu-resource-bundle +``` + +### Register Bundle in `config/bundles.php` + +```php + ['all' => true], +]; +``` diff --git a/Resources/config/services.xml b/Resources/config/services.xml new file mode 100644 index 0000000..327236e --- /dev/null +++ b/Resources/config/services.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/composer.json b/composer.json index 59f89b2..aa6ffc6 100644 --- a/composer.json +++ b/composer.json @@ -5,11 +5,18 @@ "description": "Provide resource functionality for Sulu CMS.", "require": { "php": "^7.2", - "symfony/http-kernel": "^4.3 || ^5.0" + "doctrine/collections": "^1.6", + "doctrine/orm": "^2.7", + "sulu/sulu": "^2.0", + "symfony/dependency-injection": "^4.3 || ^5.0", + "symfony/event-dispatcher": "^4.3 || ^5.0", + "symfony/http-kernel": "^4.3 || ^5.0", + "symfony/messenger": "^4.3 || ^5.0", + "webmozart/assert": "^1.6" }, "require-dev": { "friendsofphp/php-cs-fixer": "^2.12", - "handcraftedinthealps/code-coverage-checker": "^0.2.0", + "jackalope/jackalope-doctrine-dbal": "^1.3.4", "jangregor/phpstan-prophecy": "^0.5.1", "phpstan/phpstan": "^0.12.1", "phpstan/phpstan-phpunit": "^0.12", @@ -17,8 +24,7 @@ "sensiolabs-de/deptrac-shim": "^0.5.0", "symfony/monolog-bundle": "^3.1", "symfony/phpunit-bridge": "^4.3", - "thecodingmachine/phpstan-strict-rules": "^0.12", - "zendframework/zendsearch": "@dev" + "thecodingmachine/phpstan-strict-rules": "^0.12" }, "autoload": { "psr-4": { @@ -36,7 +42,6 @@ "phpstan": "vendor/bin/phpstan analyse", "php-cs": "vendor/bin/php-cs-fixer fix --verbose --diff --dry-run", "php-cs-fix": "vendor/bin/php-cs-fixer fix", - "check-code-coverage": "vendor/bin/code-coverage-checker", "phpunit": "vendor/bin/simple-phpunit" }, "config": { @@ -46,19 +51,5 @@ "branch-alias": { "dev-master": "1.0-dev" } - }, - "repositories": [ - { - "type": "git", - "url": "https://github.com/luca-rath/messenger-utils" - }, - { - "type": "git", - "url": "https://github.com/luca-rath/model-utils" - }, - { - "type": "git", - "url": "https://github.com/luca-rath/testing-utils" - } - ] + } }