From 31dda0721541e9e161f0f2a27391c11ec713fa50 Mon Sep 17 00:00:00 2001 From: Bartek Wajda Date: Fri, 14 Mar 2025 14:15:36 +0100 Subject: [PATCH] IBX-9631: Implement parser fetching content fields from regular expression --- .../Resources/config/services/utils.yaml | 10 + ...ntentTypeFieldsExpressionDoctrineLexer.php | 74 ++++++++ .../ContentTypeFieldsExpressionParser.php | 173 ++++++++++++++++++ ...entTypeFieldsExpressionParserInterface.php | 19 ++ src/lib/Util/ContentTypeFieldsExtractor.php | 152 +++++++++++++++ .../ContentTypeFieldsExtractorInterface.php | 19 ++ tests/integration/AdminUiIbexaTestKernel.php | 11 ++ .../Util/ContentTypeFieldsExtractorTest.php | 78 ++++++++ .../ContentTypeFieldsExpressionParserTest.php | 133 ++++++++++++++ 9 files changed, 669 insertions(+) create mode 100644 src/lib/Util/ContentTypeFieldsExpressionDoctrineLexer.php create mode 100644 src/lib/Util/ContentTypeFieldsExpressionParser.php create mode 100644 src/lib/Util/ContentTypeFieldsExpressionParserInterface.php create mode 100644 src/lib/Util/ContentTypeFieldsExtractor.php create mode 100644 src/lib/Util/ContentTypeFieldsExtractorInterface.php create mode 100644 tests/integration/Util/ContentTypeFieldsExtractorTest.php create mode 100644 tests/lib/Util/ContentTypeFieldsExpressionParserTest.php diff --git a/src/bundle/Resources/config/services/utils.yaml b/src/bundle/Resources/config/services/utils.yaml index 45f619f827..80b95f6891 100644 --- a/src/bundle/Resources/config/services/utils.yaml +++ b/src/bundle/Resources/config/services/utils.yaml @@ -6,3 +6,13 @@ services: Ibexa\AdminUi\Util\: resource: "../../../../lib/Util" + + Ibexa\AdminUi\Util\ContentTypeFieldsExpressionParserInterface: + alias: Ibexa\AdminUi\Util\ContentTypeFieldsExpressionParser + + Ibexa\AdminUi\Util\ContentTypeFieldsExpressionParser: ~ + + Ibexa\AdminUi\Util\ContentTypeFieldsExtractorInterface: + alias: Ibexa\AdminUi\Util\ContentTypeFieldsExtractor + + Ibexa\AdminUi\Util\ContentTypeFieldsExtractor: ~ diff --git a/src/lib/Util/ContentTypeFieldsExpressionDoctrineLexer.php b/src/lib/Util/ContentTypeFieldsExpressionDoctrineLexer.php new file mode 100644 index 0000000000..da413839a3 --- /dev/null +++ b/src/lib/Util/ContentTypeFieldsExpressionDoctrineLexer.php @@ -0,0 +1,74 @@ + + */ +final class ContentTypeFieldsExpressionDoctrineLexer extends AbstractLexer +{ + public const T_LBRACE = 1; + public const T_RBRACE = 2; + public const T_COMMA = 3; + public const T_SLASH = 4; + public const T_WILDCARD = 5; + public const T_IDENTIFIER = 6; + + /** + * @return list + */ + protected function getCatchablePatterns(): array + { + return [ + '[a-zA-Z_][a-zA-Z0-9_-]*', + '\*', + '[\{\},\/]', + ]; + } + + /** + * @return list + */ + protected function getNonCatchablePatterns(): array + { + return [ + '\s+', + ]; + } + + /** + * @param string $value + */ + protected function getType(&$value): int + { + if ($value === '{') { + return self::T_LBRACE; + } + + if ($value === '}') { + return self::T_RBRACE; + } + + if ($value === ',') { + return self::T_COMMA; + } + + if ($value === '/') { + return self::T_SLASH; + } + + if ($value === '*') { + return self::T_WILDCARD; + } + + return self::T_IDENTIFIER; + } +} diff --git a/src/lib/Util/ContentTypeFieldsExpressionParser.php b/src/lib/Util/ContentTypeFieldsExpressionParser.php new file mode 100644 index 0000000000..5c64baed50 --- /dev/null +++ b/src/lib/Util/ContentTypeFieldsExpressionParser.php @@ -0,0 +1,173 @@ +lexer = new ContentTypeFieldsExpressionDoctrineLexer(); + } + + public function parseExpression(string $expression): array + { + // Content type group can be omitted therefore we need to know how many parts are there + $slashCount = substr_count($expression, '/'); + + $this->lexer->setInput($expression); + $this->lexer->moveNext(); + + $groupTokens = null; // Content type groups are optional + $contentTypeTokens = null; + $fieldTokens = null; + + while ($this->lexer->lookahead !== null) { + $this->lexer->moveNext(); + + if ($slashCount === 2) { + $groupTokens = $this->parseSection(); + $this->expectSlash(); + $contentTypeTokens = $this->parseSection(); + $this->expectSlash(); + $fieldTokens = $this->parseSection(); + } elseif ($slashCount === 1) { + $groupTokens = null; + $contentTypeTokens = $this->parseSection(); + $this->expectSlash(); + $fieldTokens = $this->parseSection(); + } else { + throw new RuntimeException('Invalid expression, expected one or two T_SLASH delimiters.'); + } + } + + $parsedTokens = [ + $groupTokens, + $contentTypeTokens, + $fieldTokens, + ]; + + if (array_filter($parsedTokens) === []) { + throw new RuntimeException('Choosing every possible content type field is not allowed.'); + } + + return $parsedTokens; + } + + /** + * @return non-empty-list|null + */ + private function parseSection(): ?array + { + $items = []; + + if ($this->lexer->token === null) { + throw new RuntimeException('A token inside a section cannot be empty.'); + } + + // Multiple elements between braces + if ($this->lexer->token->isA(ContentTypeFieldsExpressionDoctrineLexer::T_LBRACE)) { + $items[] = $this->getTokenFromInsideBracket(); + + while ($this->lexer->token->isA(ContentTypeFieldsExpressionDoctrineLexer::T_COMMA)) { + $items[] = $this->getTokenFromInsideBracket(); + } + + if (!$this->lexer->token->isA(ContentTypeFieldsExpressionDoctrineLexer::T_RBRACE)) { + throw new RuntimeException('Expected T_RBRACE to close the list.'); + } + + $this->lexer->moveNext(); + } else { + // Otherwise, expect a single identifier or wildcard. + $token = $this->expectIdentifierOrWildcard(); + + if ($token === null) { + return null; + } + + $items[] = $token; + } + + return $items; + } + + private function getTokenFromInsideBracket(): string + { + $this->lexer->moveNext(); + + $token = $this->expectIdentifierOrWildcard(); + if ($token === null) { + throw new RuntimeException('Wildcards cannot be mixed with identifiers inside the expression.'); + } + + return $token; + } + + /** + * @throws \RuntimeException + */ + private function expectSlash(): void + { + if ($this->lexer->token === null) { + throw new RuntimeException( + sprintf( + 'Expected token of type "%s" but got "null"', + ContentTypeFieldsExpressionDoctrineLexer::T_SLASH, + ), + ); + } + + if (!$this->lexer->token->isA(ContentTypeFieldsExpressionDoctrineLexer::T_SLASH)) { + throw new RuntimeException( + sprintf( + 'Expected token of type "%s" but got "%s"', + ContentTypeFieldsExpressionDoctrineLexer::T_SLASH, + $this->lexer->token->type, + ), + ); + } + + $this->lexer->moveNext(); + } + + private function expectIdentifierOrWildcard(): ?string + { + if ($this->lexer->token === null) { + throw new RuntimeException( + sprintf( + 'Expected token of type "%s" but got "null"', + ContentTypeFieldsExpressionDoctrineLexer::T_SLASH, + ), + ); + } + + if (!in_array( + $this->lexer->token->type, + [ + ContentTypeFieldsExpressionDoctrineLexer::T_IDENTIFIER, + ContentTypeFieldsExpressionDoctrineLexer::T_WILDCARD, + ], + true, + )) { + throw new RuntimeException('Expected an identifier or wildcard.'); + } + + $value = $this->lexer->token->isA(ContentTypeFieldsExpressionDoctrineLexer::T_WILDCARD) + ? null + : $this->lexer->token->value; + + $this->lexer->moveNext(); + + return $value; + } +} diff --git a/src/lib/Util/ContentTypeFieldsExpressionParserInterface.php b/src/lib/Util/ContentTypeFieldsExpressionParserInterface.php new file mode 100644 index 0000000000..4a24ad599e --- /dev/null +++ b/src/lib/Util/ContentTypeFieldsExpressionParserInterface.php @@ -0,0 +1,19 @@ +|null, non-empty-list|null, non-empty-list|null} + * + * @throws \RuntimeException + */ + public function parseExpression(string $expression): array; +} diff --git a/src/lib/Util/ContentTypeFieldsExtractor.php b/src/lib/Util/ContentTypeFieldsExtractor.php new file mode 100644 index 0000000000..cc04662a5f --- /dev/null +++ b/src/lib/Util/ContentTypeFieldsExtractor.php @@ -0,0 +1,152 @@ +expressionParser = $expressionParser; + $this->contentTypeService = $contentTypeService; + } + + public function extractFieldsFromExpression(string $expression): array + { + $extractedMetadata = $this->expressionParser->parseExpression($expression); + + $contentTypes = $this->resolveContentTypes($extractedMetadata); + + return $this->mergeFieldIds($extractedMetadata[2], $contentTypes); + } + + /** + * @param array{non-empty-list|null, non-empty-list|null, non-empty-list|null} $extractedMetadata + * + * @return list + */ + private function resolveContentTypes(array $extractedMetadata): array + { + $contentTypeGroupIdentifiers = $extractedMetadata[0]; + $contentTypeIdentifiers = $extractedMetadata[1]; + + // Resolve content type groups first + if ($contentTypeGroupIdentifiers === null) { + $contentTypeGroups = $this->contentTypeService->loadContentTypeGroups(); + } else { + $contentTypeGroups = []; + foreach ($contentTypeGroupIdentifiers as $contentTypeGroupIdentifier) { + $contentTypeGroups[] = $this->contentTypeService->loadContentTypeGroupByIdentifier($contentTypeGroupIdentifier); + } + } + + $contentTypes = []; + + // Then resolve content types + if ($contentTypeIdentifiers === null) { + foreach ($contentTypeGroups as $contentTypeGroup) { + $contentTypesInsideGroup = $this->contentTypeService->loadContentTypes($contentTypeGroup); + foreach ($contentTypesInsideGroup as $contentType) { + $contentTypes[] = $contentType; + } + } + } else { + $contentTypes = array_map( + [$this->contentTypeService, 'loadContentTypeByIdentifier'], + $contentTypeIdentifiers, + ); + + $this->validateContentTypesInsideGroups($contentTypes, $contentTypeGroupIdentifiers); + } + + return $contentTypes; + } + + /** + * @param array|null $fieldIdentifiers + * + * @return array + */ + private function resolveFieldIds(?array $fieldIdentifiers, ContentType $contentType): array + { + $fieldDefinitions = $contentType->getFieldDefinitions(); + + if ($fieldIdentifiers === null) { + return $fieldDefinitions->map( + static fn (FieldDefinition $fieldDefinition): int => $fieldDefinition->getId(), + ); + } + + return $fieldDefinitions + ->filter( + static fn (FieldDefinition $fieldDefinition): bool => in_array($fieldDefinition->getIdentifier(), $fieldIdentifiers, true), + ) + ->map(static fn (FieldDefinition $fieldDefinition): int => $fieldDefinition->getId()); + } + + /** + * @param non-empty-list $contentTypes + * @param list $contentTypeGroupIdentifiers + */ + private function validateContentTypesInsideGroups( + array $contentTypes, + ?array $contentTypeGroupIdentifiers + ): void { + if ($contentTypeGroupIdentifiers === null) { + return; + } + + foreach ($contentTypes as $contentType) { + $groupsIdentifiers = array_map( + static fn (ContentTypeGroup $group): string => $group->identifier, + $contentType->getContentTypeGroups(), + ); + + if (array_intersect($contentTypeGroupIdentifiers, $groupsIdentifiers) === []) { + throw new LogicException( + sprintf( + 'Groups of content type "%s" have no common identifiers with chosen groups: "%s".', + $contentType->getIdentifier(), + implode(', ', $contentTypeGroupIdentifiers), + ), + ); + } + } + } + + /** + * @param list|null $fieldIdentifiers + * @param iterable $contentTypes + * + * @return list + */ + private function mergeFieldIds(?array $fieldIdentifiers, iterable $contentTypes): array + { + $finalFieldIds = []; + foreach ($contentTypes as $contentType) { + $finalFieldIds = array_merge( + $finalFieldIds, + $this->resolveFieldIds($fieldIdentifiers, $contentType), + ); + } + + return $finalFieldIds; + } +} diff --git a/src/lib/Util/ContentTypeFieldsExtractorInterface.php b/src/lib/Util/ContentTypeFieldsExtractorInterface.php new file mode 100644 index 0000000000..663d7bbc92 --- /dev/null +++ b/src/lib/Util/ContentTypeFieldsExtractorInterface.php @@ -0,0 +1,19 @@ + + * + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException + */ + public function extractFieldsFromExpression(string $expression): array; +} diff --git a/tests/integration/AdminUiIbexaTestKernel.php b/tests/integration/AdminUiIbexaTestKernel.php index 2e99759eae..cd973c1cac 100644 --- a/tests/integration/AdminUiIbexaTestKernel.php +++ b/tests/integration/AdminUiIbexaTestKernel.php @@ -10,6 +10,7 @@ use DAMA\DoctrineTestBundle\DAMADoctrineTestBundle; use Hautelook\TemplatedUriBundle\HautelookTemplatedUriBundle; +use Ibexa\AdminUi\Util\ContentTypeFieldsExtractorInterface; use Ibexa\Bundle\AdminUi\IbexaAdminUiBundle; use Ibexa\Bundle\ContentForms\IbexaContentFormsBundle; use Ibexa\Bundle\DesignEngine\IbexaDesignEngineBundle; @@ -18,6 +19,7 @@ use Ibexa\Bundle\Search\IbexaSearchBundle; use Ibexa\Bundle\Test\Rest\IbexaTestRestBundle; use Ibexa\Bundle\User\IbexaUserBundle; +use Ibexa\Contracts\Core\Persistence\Content\Type\Handler as ContentTypeHandler; use Ibexa\Contracts\Core\Repository\BookmarkService; use Ibexa\Contracts\Test\Core\IbexaTestKernel; use Ibexa\Rest\Server\Controller\JWT; @@ -61,6 +63,15 @@ protected static function getExposedServicesByClass(): iterable yield from parent::getExposedServicesByClass(); yield BookmarkService::class; + + yield ContentTypeFieldsExtractorInterface::class; + + yield ContentTypeHandler::class; + } + + protected static function getExposedServicesById(): iterable + { + yield from parent::getExposedServicesById(); } public function registerContainerConfiguration(LoaderInterface $loader): void diff --git a/tests/integration/Util/ContentTypeFieldsExtractorTest.php b/tests/integration/Util/ContentTypeFieldsExtractorTest.php new file mode 100644 index 0000000000..419030174e --- /dev/null +++ b/tests/integration/Util/ContentTypeFieldsExtractorTest.php @@ -0,0 +1,78 @@ +contentTypeFieldsExtractor = self::getServiceByClassName(ContentTypeFieldsExtractorInterface::class); + $this->contentTypeHandler = self::getServiceByClassName(ContentTypeHandler::class); + } + + public function testExtractWithContentTypeGroupNames(): void + { + $expression = '{Media,Content}/*/name'; + + $extractedFieldIds = $this->contentTypeFieldsExtractor->extractFieldsFromExpression($expression); + + foreach ($extractedFieldIds as $fieldId) { + $fieldDefinition = $this->contentTypeHandler->getFieldDefinition($fieldId, 0); + + self::assertSame('name', $fieldDefinition->identifier); + } + } + + public function testExtractWithContentTypeNames(): void + { + $expression = '*/user/{first_name,last_name}'; + + $extractedFieldIds = $this->contentTypeFieldsExtractor->extractFieldsFromExpression($expression); + + $firstNameField = $this->contentTypeHandler->getFieldDefinition($extractedFieldIds[0], 0); + $lastNameField = $this->contentTypeHandler->getFieldDefinition($extractedFieldIds[1], 0); + + self::assertSame('first_name', $firstNameField->identifier); + self::assertSame('last_name', $lastNameField->identifier); + } + + public function testExtractWithContentTypeAndGroupNames(): void + { + $expression = 'Users/user/{first_name,last_name}'; + + $extractedFieldIds = $this->contentTypeFieldsExtractor->extractFieldsFromExpression($expression); + + $firstNameField = $this->contentTypeHandler->getFieldDefinition($extractedFieldIds[0], 0); + $lastNameField = $this->contentTypeHandler->getFieldDefinition($extractedFieldIds[1], 0); + + self::assertSame('first_name', $firstNameField->identifier); + self::assertSame('last_name', $lastNameField->identifier); + } + + public function testExtractWithContentTypeAndGroupNamesFailsWithTypesOutsideGroups(): void + { + self::expectException(LogicException::class); + + $expression = 'Content/user/{first_name,last_name}'; + + $this->contentTypeFieldsExtractor->extractFieldsFromExpression($expression); + } +} diff --git a/tests/lib/Util/ContentTypeFieldsExpressionParserTest.php b/tests/lib/Util/ContentTypeFieldsExpressionParserTest.php new file mode 100644 index 0000000000..d2c893308c --- /dev/null +++ b/tests/lib/Util/ContentTypeFieldsExpressionParserTest.php @@ -0,0 +1,133 @@ +contentTypeFieldsExpressionExtractor = new ContentTypeFieldsExpressionParser(); + } + + /** + * @param array{0: non-empty-list|null, 1: non-empty-list|null, 2: non-empty-list|null} $expectedResult + * + * @dataProvider dataProviderForTestParse + */ + public function testParse(string $expression, array $expectedResult): void + { + $result = $this->contentTypeFieldsExpressionExtractor->parseExpression($expression); + + self::assertSame($expectedResult, $result); + } + + /** + * @dataProvider dataProviderForTestParseInvalidExpressions + */ + public function testParseInvalidExpression(string $expression): void + { + $this->expectException(RuntimeException::class); + + $this->contentTypeFieldsExpressionExtractor->parseExpression($expression); + } + + /** + * @return iterable|null, 1: non-empty-list|null, 2: non-empty-list|null}}> + */ + public function dataProviderForTestParse(): iterable + { + yield 'product content type group, every content type, few fields' => [ + 'product/*/{name, description}', + [ + ['product'], + null, + ['name', 'description'], + ], + ]; + + yield 'product content type group, every content type, singular field' => [ + 'product/*/name', + [ + ['product'], + null, + ['name'], + ], + ]; + + yield 'media content type group, file content type, singular field' => [ + 'media/file/name', + [ + ['media'], + ['file'], + ['name'], + ], + ]; + + yield 'media content type group, file content type, few field' => [ + 'media/file/{name,path}', + [ + ['media'], + ['file'], + ['name', 'path'], + ], + ]; + + yield 'file content type, few fields, without group' => [ + 'file/{name, description}', + [ + null, + ['file'], + ['name', 'description'], + ], + ]; + + yield 'file content type, all fields, without group' => [ + 'file/*', + [ + null, + ['file'], + null, + ], + ]; + } + + /** + * @return iterable + */ + public function dataProviderForTestParseInvalidExpressions(): iterable + { + yield 'file content type, without fields' => [ + 'file/', + ]; + + yield 'file content type, without fields, two slashes' => [ + '/file/', + ]; + + yield 'file content type, two fields, starts with slash' => [ + '/file/{field1, field2}', + ]; + + yield 'file content type' => [ + 'file', + ]; + + yield 'file content type, fields being identifier and wildcard' => [ + 'file/{field1, *}', + ]; + } +}