diff --git a/eZ/Bundle/EzPublishCoreBundle/DependencyInjection/EzPublishCoreExtension.php b/eZ/Bundle/EzPublishCoreBundle/DependencyInjection/EzPublishCoreExtension.php index cb494898c3d..1f5cae63f8d 100644 --- a/eZ/Bundle/EzPublishCoreBundle/DependencyInjection/EzPublishCoreExtension.php +++ b/eZ/Bundle/EzPublishCoreBundle/DependencyInjection/EzPublishCoreExtension.php @@ -328,6 +328,9 @@ private function handleApiLoading(ContainerBuilder $container, FileLoader $loade // Storage engine $loader->load('storage_engines.yml'); + + $loader->load('query_types.yml'); + $loader->load('sort_spec.yml'); } /** diff --git a/eZ/Bundle/EzPublishCoreBundle/Resources/config/query_types.yml b/eZ/Bundle/EzPublishCoreBundle/Resources/config/query_types.yml new file mode 100644 index 00000000000..52ad6cf22c9 --- /dev/null +++ b/eZ/Bundle/EzPublishCoreBundle/Resources/config/query_types.yml @@ -0,0 +1,36 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + eZ\Publish\Core\QueryType\BuiltIn\AncestorsQueryType: + tags: + - { name: ezpublish.query_type, alias: 'Ancestors' } + + eZ\Publish\Core\QueryType\BuiltIn\ChildrenQueryType: + tags: + - { name: ezpublish.query_type, alias: 'Children' } + + eZ\Publish\Core\QueryType\BuiltIn\SiblingsQueryType: + tags: + - { name: ezpublish.query_type, alias: 'Siblings' } + + eZ\Publish\Core\QueryType\BuiltIn\RelatedToContentQueryType: + tags: + - { name: ezpublish.query_type, alias: 'RelatedTo' } + + eZ\Publish\Core\QueryType\BuiltIn\GeoLocationQueryType: + tags: + - { name: ezpublish.query_type, alias: 'GeoLocation' } + + eZ\Publish\Core\QueryType\BuiltIn\SubtreeQueryType: + tags: + - { name: ezpublish.query_type, alias: 'Subtree' } + + eZ\Publish\Core\QueryType\BuiltIn\SortClausesFactory: + arguments: + $sortClauseArgsParser: '@eZ\Publish\Core\QueryType\BuiltIn\SortSpec\SortClauseParserDispatcher' + + eZ\Publish\Core\QueryType\BuiltIn\SortClausesFactoryInterface: + alias: 'eZ\Publish\Core\QueryType\BuiltIn\SortClausesFactory' diff --git a/eZ/Bundle/EzPublishCoreBundle/Resources/config/sort_spec.yml b/eZ/Bundle/EzPublishCoreBundle/Resources/config/sort_spec.yml new file mode 100644 index 00000000000..9e557ece60b --- /dev/null +++ b/eZ/Bundle/EzPublishCoreBundle/Resources/config/sort_spec.yml @@ -0,0 +1,39 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + eZ\Publish\Core\QueryType\BuiltIn\SortSpec\SortClauseParserDispatcher: + arguments: + $parsers: !tagged_iterator ezplatform.query_type.sort_clause_parser + + eZ\Publish\Core\QueryType\BuiltIn\SortSpec\SortClauseParser\FieldSortClauseParser: + tags: + - { name: ezplatform.query_type.sort_clause_parser } + + eZ\Publish\Core\QueryType\BuiltIn\SortSpec\SortClauseParser\MapDistanceSortClauseParser: + tags: + - { name: ezplatform.query_type.sort_clause_parser } + + eZ\Publish\Core\QueryType\BuiltIn\SortSpec\SortClauseParser\RandomSortClauseParser: + tags: + - { name: ezplatform.query_type.sort_clause_parser } + + eZ\Publish\Core\QueryType\BuiltIn\SortSpec\SortClauseParser\DefaultSortClauseParser: + arguments: + $valueObjectClassMap: + content_id: \eZ\Publish\API\Repository\Values\Content\Query\SortClause\ContentId + content_name: \eZ\Publish\API\Repository\Values\Content\Query\SortClause\ContentName + date_modified: \eZ\Publish\API\Repository\Values\Content\Query\SortClause\DateModified + date_published: \eZ\Publish\API\Repository\Values\Content\Query\SortClause\DatePublished + section_identifier: \eZ\Publish\API\Repository\Values\Content\Query\SortClause\SectionIdentifier + section_name: \eZ\Publish\API\Repository\Values\Content\Query\SortClause\SectionName + location_depth: \eZ\Publish\API\Repository\Values\Content\Query\SortClause\Location\Depth + location_id: \eZ\Publish\API\Repository\Values\Content\Query\SortClause\Location\Id + location_is_main: \eZ\Publish\API\Repository\Values\Content\Query\SortClause\Location\IsMainLocation + location_path: \eZ\Publish\API\Repository\Values\Content\Query\SortClause\Location\Path + location_priority: \eZ\Publish\API\Repository\Values\Content\Query\SortClause\Location\Priority + location_visibility: \eZ\Publish\API\Repository\Values\Content\Query\SortClause\Location\Visibility + tags: + - { name: ezplatform.query_type.sort_clause_parser } diff --git a/eZ/Publish/Core/QueryType/BuiltIn/AbstractLocationQueryType.php b/eZ/Publish/Core/QueryType/BuiltIn/AbstractLocationQueryType.php new file mode 100644 index 00000000000..e8d8434c294 --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/AbstractLocationQueryType.php @@ -0,0 +1,71 @@ +setDefaults([ + 'location' => null, + 'content' => null, + ]); + + $resolver->setAllowedTypes('location', ['null', 'int', Location::class]); + $resolver->setNormalizer( + 'location', + function (Options $options, $value): ?Location { + if (is_int($value)) { + return $this->repository->getLocationService()->loadLocation($value); + } + + return $value; + } + ); + + $resolver->setAllowedTypes('content', ['null', 'int', Content::class, ContentInfo::class]); + $resolver->setNormalizer( + 'content', + function (Options $options, $value): ?ContentInfo { + if (is_int($value)) { + return $this->repository->getContentService()->loadContentInfo($value); + } + + if ($value instanceof Content) { + return $value->contentInfo; + } + + return $value; + } + ); + } + + protected function resolveLocation(array $parameters): ?Location + { + $location = $parameters['location']; + + if ($location === null) { + $content = $parameters['content']; + + if ($content instanceof ContentInfo) { + $location = $content->getMainLocation(); + } + } + + return $location; + } +} diff --git a/eZ/Publish/Core/QueryType/BuiltIn/AbstractQueryType.php b/eZ/Publish/Core/QueryType/BuiltIn/AbstractQueryType.php new file mode 100644 index 00000000000..1a8a74ea27a --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/AbstractQueryType.php @@ -0,0 +1,130 @@ +repository = $repository; + $this->configResolver = $configResolver; + $this->sortClausesFactory = $sortSpecParserFactory; + } + + protected function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'filter' => static function (OptionsResolver $resolver): void { + $resolver->setDefaults([ + 'content_type' => [], + 'visible_only' => true, + 'siteaccess_aware' => true, + ]); + + $resolver->setAllowedTypes('content_type', 'array'); + $resolver->setAllowedTypes('visible_only', 'bool'); + $resolver->setAllowedTypes('siteaccess_aware', 'bool'); + }, + 'offset' => 0, + 'limit' => self::DEFAULT_LIMIT, + 'sort' => [], + ]); + + $resolver->setNormalizer('sort', function (Options $options, $value) { + if (is_string($value)) { + $value = $this->sortClausesFactory->createFromSpecification($value); + } + + if (!is_array($value)) { + $value = [$value]; + } + + return $value; + }); + + $resolver->setAllowedTypes('sort', ['string', 'array', SortClause::class]); + $resolver->setAllowedTypes('offset', 'int'); + $resolver->setAllowedTypes('limit', 'int'); + } + + abstract protected function getQueryFilter(array $parameters): Criterion; + + protected function doGetQuery(array $parameters): Query + { + $query = new Query(); + $query->filter = $this->buildFilters($parameters); + + if ($parameters['sort'] !== null) { + $query->sortClauses = $parameters['sort']; + } + + $query->limit = $parameters['limit']; + $query->offset = $parameters['offset']; + + return $query; + } + + private function buildFilters(array $parameters): Criterion + { + $criteria = [ + $this->getQueryFilter($parameters), + ]; + + if ($parameters['filter']['visible_only']) { + $criteria[] = new Visibility(Visibility::VISIBLE); + } + + if (!empty($parameters['filter']['content_type'])) { + $criteria[] = new ContentTypeIdentifier($parameters['filter']['content_type']); + } + + if ($parameters['filter']['siteaccess_aware']) { + // Limit results to current SiteAccess tree root + $criteria[] = new Subtree($this->getRootLocationPathString()); + } + + return new LogicalAnd($criteria); + } + + private function getRootLocationPathString(): string + { + $rootLocation = $this->repository->getLocationService()->loadLocation( + $this->configResolver->getParameter('content.tree_root.location_id') + ); + + return $rootLocation->pathString; + } +} diff --git a/eZ/Publish/Core/QueryType/BuiltIn/AncestorsQueryType.php b/eZ/Publish/Core/QueryType/BuiltIn/AncestorsQueryType.php new file mode 100644 index 00000000000..e7707cf2eb4 --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/AncestorsQueryType.php @@ -0,0 +1,40 @@ +resolveLocation($parameters); + + if ($location === null) { + return new MatchNone(); + } + + return new LogicalAnd([ + new Ancestor($location->pathString), + new LogicalNot( + new LocationId($location->id) + ), + ]); + } +} diff --git a/eZ/Publish/Core/QueryType/BuiltIn/ChildrenQueryType.php b/eZ/Publish/Core/QueryType/BuiltIn/ChildrenQueryType.php new file mode 100644 index 00000000000..c397ded19a3 --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/ChildrenQueryType.php @@ -0,0 +1,32 @@ +resolveLocation($parameters); + + if ($location === null) { + return new MatchNone(); + } + + return new ParentLocationId($location->id); + } +} diff --git a/eZ/Publish/Core/QueryType/BuiltIn/GeoLocationQueryType.php b/eZ/Publish/Core/QueryType/BuiltIn/GeoLocationQueryType.php new file mode 100644 index 00000000000..24c143b2942 --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/GeoLocationQueryType.php @@ -0,0 +1,71 @@ +setRequired('field'); + $resolver->setAllowedTypes('field', ['string', Field::class]); + $resolver->setNormalizer('field', static function (Options $options, $value) { + if ($value instanceof Field) { + $value = $value->fieldDefIdentifier; + } + + return $value; + }); + + $resolver->setRequired('distance'); + $resolver->setAllowedTypes('distance', ['float', 'int', 'array']); + + $resolver->setRequired('latitude'); + $resolver->setAllowedTypes('latitude', ['float']); + + $resolver->setRequired('longitude'); + $resolver->setAllowedTypes('longitude', ['float']); + + $resolver->setDefault('operator', Operator::LTE); + $resolver->setAllowedTypes('operator', ['string']); + $resolver->setAllowedValues('operator', [ + Operator::IN, + Operator::EQ, + Operator::GT, + Operator::GTE, + Operator::LT, + Operator::LTE, + Operator::BETWEEN, + ]); + } + + protected function getQueryFilter(array $parameters): Criterion + { + return new MapLocationDistance( + $parameters['field'], + $parameters['operator'], + $parameters['distance'], + $parameters['latitude'], + $parameters['longitude'] + ); + } +} diff --git a/eZ/Publish/Core/QueryType/BuiltIn/RelatedToContentQueryType.php b/eZ/Publish/Core/QueryType/BuiltIn/RelatedToContentQueryType.php new file mode 100644 index 00000000000..7b19d89cd32 --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/RelatedToContentQueryType.php @@ -0,0 +1,59 @@ +setRequired(['content']); + $resolver->setAllowedTypes('content', [Content::class, ContentInfo::class, 'int']); + $resolver->setNormalizer('content', function (Options $options, $value) { + if ($value instanceof Content || $value instanceof ContentInfo) { + $value = $value->id; + } + + return $value; + }); + + $resolver->setRequired(['field']); + $resolver->setAllowedTypes('field', ['string', Field::class]); + $resolver->setNormalizer('field', static function (Options $options, $value) { + if ($value instanceof Field) { + $value = $value->fieldDefIdentifier; + } + + return $value; + }); + } + + protected function getQueryFilter(array $parameters): Criterion + { + return new FieldRelation( + $parameters['field'], + Criterion\Operator::CONTAINS, + $parameters['content'] + ); + } +} diff --git a/eZ/Publish/Core/QueryType/BuiltIn/SiblingsQueryType.php b/eZ/Publish/Core/QueryType/BuiltIn/SiblingsQueryType.php new file mode 100644 index 00000000000..c83f580b863 --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/SiblingsQueryType.php @@ -0,0 +1,31 @@ +resolveLocation($parameters); + + if ($location === null) { + return new MatchNone(); + } + + return Criterion\Sibling::fromLocation($location); + } +} diff --git a/eZ/Publish/Core/QueryType/BuiltIn/SortClausesFactory.php b/eZ/Publish/Core/QueryType/BuiltIn/SortClausesFactory.php new file mode 100644 index 00000000000..f3d4dd6f0d3 --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/SortClausesFactory.php @@ -0,0 +1,42 @@ +sortClauseParser = $sortClauseArgsParser; + } + + /** + * @throws \eZ\Publish\Core\QueryType\BuiltIn\SortSpec\Exception\SyntaxErrorException + * + * @return \eZ\Publish\API\Repository\Values\Content\Query\SortClause[] + */ + public function createFromSpecification(string $specification): array + { + $lexer = new SortSpecLexer(); + $lexer->tokenize($specification); + + $parser = new SortSpecParser($this->sortClauseParser, $lexer); + + return $parser->parseSortClausesList(); + } +} diff --git a/eZ/Publish/Core/QueryType/BuiltIn/SortClausesFactoryInterface.php b/eZ/Publish/Core/QueryType/BuiltIn/SortClausesFactoryInterface.php new file mode 100644 index 00000000000..af1a57ddbd5 --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/SortClausesFactoryInterface.php @@ -0,0 +1,22 @@ +getValue(), + $token->getType(), + $token->getPosition(), + implode(' ', $expectedTypes) + ); + + return new self($message); + } +} diff --git a/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/Exception/UnsupportedSortClauseException.php b/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/Exception/UnsupportedSortClauseException.php new file mode 100644 index 00000000000..55ef224e957 --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/Exception/UnsupportedSortClauseException.php @@ -0,0 +1,27 @@ +valueObjectClassMap = $valueObjectClassMap; + } + + public function parse(SortSpecParserInterface $parser, string $name): SortClause + { + if (isset($this->valueObjectClassMap[$name])) { + $class = $this->valueObjectClassMap[$name]; + + return new $class($parser->parseSortDirection()); + } + + throw new UnsupportedSortClauseException($name); + } + + public function supports(string $name): bool + { + return isset($this->valueObjectClassMap[$name]); + } +} diff --git a/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/SortClauseParser/FieldSortClauseParser.php b/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/SortClauseParser/FieldSortClauseParser.php new file mode 100644 index 00000000000..ee247029a8b --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/SortClauseParser/FieldSortClauseParser.php @@ -0,0 +1,43 @@ +match(Token::TYPE_ID)->getValue(); + $parser->match(Token::TYPE_DOT); + $args[] = $parser->match(Token::TYPE_ID)->getValue(); + $args[] = $parser->parseSortDirection(); + + return new Field(...$args); + } + + public function supports(string $name): bool + { + return $name === self::SUPPORTED_CLAUSE_NAME; + } +} diff --git a/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/SortClauseParser/MapDistanceSortClauseParser.php b/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/SortClauseParser/MapDistanceSortClauseParser.php new file mode 100644 index 00000000000..530c11439f8 --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/SortClauseParser/MapDistanceSortClauseParser.php @@ -0,0 +1,45 @@ +match(Token::TYPE_ID)->getValue(); + $parser->match(Token::TYPE_DOT); + $args[] = $parser->match(Token::TYPE_ID)->getValue(); + $args[] = $parser->match(Token::TYPE_FLOAT)->getValueAsFloat(); + $args[] = $parser->match(Token::TYPE_FLOAT)->getValueAsFloat(); + $args[] = $parser->parseSortDirection(); + + return new MapLocationDistance(...$args); + } + + public function supports(string $name): bool + { + return $name === self::SUPPORTED_CLAUSE_NAME; + } +} diff --git a/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/SortClauseParser/RandomSortClauseParser.php b/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/SortClauseParser/RandomSortClauseParser.php new file mode 100644 index 00000000000..f618e9b8678 --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/SortClauseParser/RandomSortClauseParser.php @@ -0,0 +1,44 @@ +isNextToken(Token::TYPE_INT)) { + $seed = $parser->match(Token::TYPE_INT)->getValueAsInt(); + } + + $sortDirection = $parser->parseSortDirection(); + + return new Random($seed, $sortDirection); + } + + public function supports(string $name): bool + { + return $name === self::SUPPORTED_CLAUSE_NAME; + } +} diff --git a/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/SortClauseParserDispatcher.php b/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/SortClauseParserDispatcher.php new file mode 100644 index 00000000000..acf47943b36 --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/SortClauseParserDispatcher.php @@ -0,0 +1,49 @@ +parsers = $parsers; + } + + public function parse(SortSpecParserInterface $parser, string $name): SortClause + { + $sortClauseParser = $this->findParser($name); + if ($sortClauseParser instanceof SortClauseParserInterface) { + return $sortClauseParser->parse($parser, $name); + } + + throw new UnsupportedSortClauseException($name); + } + + public function supports(string $name): bool + { + return $this->findParser($name) instanceof SortClauseParserInterface; + } + + private function findParser(string $name): ?SortClauseParserInterface + { + foreach ($this->parsers as $parser) { + if ($parser->supports($name)) { + return $parser; + } + } + + return null; + } +} diff --git a/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/SortClauseParserInterface.php b/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/SortClauseParserInterface.php new file mode 100644 index 00000000000..36b4690f0b4 --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/SortClauseParserInterface.php @@ -0,0 +1,21 @@ +tokens; + } + + public function consume(): Token + { + $this->current = $this->next; + $this->next = $this->tokens[++$this->position] ?? null; + + return $this->current; + } + + public function isEOF(): bool + { + return $this->next === null || $this->next->isA(Token::TYPE_EOF); + } + + public function peek(): ?Token + { + return $this->next; + } + + public function getInput(): string + { + return $this->input; + } + + public function tokenize(string $input): void + { + $this->reset(); + + $this->input = $input; + $this->tokens = []; + foreach ($this->split($input) as $match) { + [$value, $position] = $match; + $value = trim($value); + + if ($value === '') { + // Skip whitespaces + continue; + } + + $this->tokens[] = new Token( + $this->getTokenType($value), + $value, + $position + ); + } + + $this->tokens[] = new Token(Token::TYPE_EOF); + $this->next = $this->tokens[0] ?? null; + } + + private function reset(): void + { + $this->position = 0; + $this->next = null; + $this->current = null; + } + + private function split(string $input): array + { + $regexp = sprintf( + '/^(asc)|(desc)|(\\.)|(,)|(%s)|\s+$/iu', + implode(')|(', [ + self::FLOAT_PATTERN, + self::INT_PATTERN, + self::ID_PATTERN, + ]), + ); + + $flags = PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_OFFSET_CAPTURE; + + return preg_split($regexp, $input, -1, $flags); + } + + private function getTokenType(string $value): string + { + switch ($value) { + case self::K_ASC: + return Token::TYPE_ASC; + case self::K_DESC: + return Token::TYPE_DESC; + case '.': + return Token::TYPE_DOT; + case ',': + return Token::TYPE_COMMA; + } + + if ($this->isInt($value)) { + return Token::TYPE_INT; + } + + if ($this->isFloat($value)) { + return Token::TYPE_FLOAT; + } + + if ($this->isID($value)) { + return Token::TYPE_ID; + } + + return Token::TYPE_NONE; + } + + private function isInt(string $value): bool + { + return preg_match('/^' . self::INT_PATTERN . '$/', $value) === 1; + } + + private function isFloat(string $value): bool + { + return preg_match('/^' . self::FLOAT_PATTERN . '$/', $value) === 1; + } + + private function isID(string $value): bool + { + return preg_match('/^' . self::ID_PATTERN . '$/', $value) === 1; + } +} diff --git a/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/SortSpecLexerInterface.php b/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/SortSpecLexerInterface.php new file mode 100644 index 00000000000..45286282363 --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/SortSpecLexerInterface.php @@ -0,0 +1,37 @@ + ::= ("," )? + * ::= ? ? + * ::= | | + * ::= "." + * ::= "." + * ::= ? + * ::= "asc" | "desc" + * + * ::= [a-zA-Z_][a-zA-Z0-9_]* + * ::= -?[0-9]+\.[0-9]+ + * ::= -?[0-9]+ + */ +final class SortSpecParser implements SortSpecParserInterface +{ + private const DEFAULT_SORT_DIRECTION = Query::SORT_ASC; + + /** @var \eZ\Publish\Core\QueryType\BuiltIn\SortSpec\SortSpecLexerInterface */ + private $lexer; + + /** @var \eZ\Publish\Core\QueryType\BuiltIn\SortSpec\SortClauseParserInterface */ + private $sortClauseParser; + + public function __construct(SortClauseParserInterface $sortClauseParser, SortSpecLexerInterface $lexer = null) + { + if ($lexer === null) { + $lexer = new SortSpecLexer(); + } + + $this->sortClauseParser = $sortClauseParser; + $this->lexer = $lexer; + } + + /** + * @return \eZ\Publish\API\Repository\Values\Content\Query\SortClause[] + */ + public function parseSortClausesList(): array + { + $sortClauses = []; + while (!$this->lexer->isEOF()) { + $sortClauses[] = $this->parseSortClause(); + if ($this->isNextToken(Token::TYPE_COMMA)) { + $this->match(Token::TYPE_COMMA); + } + } + + return $sortClauses; + } + + public function parseSortClause(): SortClause + { + $name = $this->match(Token::TYPE_ID)->getValue(); + + return $this->sortClauseParser->parse($this, $name); + } + + public function parseSortDirection(): string + { + if ($this->isNextToken(Token::TYPE_ASC, Token::TYPE_DESC)) { + $token = $this->matchAnyOf(Token::TYPE_ASC, Token::TYPE_DESC); + + switch ($token->getType()) { + case Token::TYPE_ASC: + return Query::SORT_ASC; + case Token::TYPE_DESC: + return Query::SORT_DESC; + } + } + + return self::DEFAULT_SORT_DIRECTION; + } + + public function isNextToken(string ...$types): bool + { + $nextToken = $this->lexer->peek(); + + if ($nextToken !== null) { + foreach ($types as $type) { + if ($nextToken->isA($type)) { + return true; + } + } + } + + return false; + } + + public function match(string $type): Token + { + return $this->matchAnyOf($type); + } + + public function matchAnyOf(string ...$types): Token + { + if ($this->isNextToken(...$types)) { + return $this->lexer->consume(); + } + + throw SyntaxErrorException::fromUnexpectedToken( + $this->lexer->getInput(), + $this->lexer->peek(), + $types + ); + } +} diff --git a/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/SortSpecParserInterface.php b/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/SortSpecParserInterface.php new file mode 100644 index 00000000000..7e9b4541ca2 --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/SortSpecParserInterface.php @@ -0,0 +1,26 @@ +defaultSortClauseParser = new DefaultSortClauseParser([ + 'depth' => Location\Depth::class, + 'priority' => Location\Priority::class, + 'id' => Location\Id::class, + ]); + } + + public function testParse(): void + { + $parser = $this->createMock(SortSpecParserInterface::class); + $parser->method('parseSortDirection')->willReturn(Query::SORT_ASC); + + $this->assertEquals( + new Location\Depth(Query::SORT_ASC), + $this->defaultSortClauseParser->parse($parser, 'depth') + ); + + $this->assertEquals( + new Location\Priority(Query::SORT_ASC), + $this->defaultSortClauseParser->parse($parser, 'priority') + ); + } + + public function testParseThrowsUnsupportedSortClauseException(): void + { + $this->expectException(UnsupportedSortClauseException::class); + $this->expectExceptionMessage(sprintf( + 'Could not find %s for unsupported sort clause', + SortClauseParserInterface::class + )); + + $this->defaultSortClauseParser->parse( + $this->createMock(SortSpecParserInterface::class), + 'unsupported' + ); + } + + public function testSupports(): void + { + $this->assertTrue($this->defaultSortClauseParser->supports('depth')); + $this->assertTrue($this->defaultSortClauseParser->supports('priority')); + $this->assertTrue($this->defaultSortClauseParser->supports('id')); + + $this->assertFalse($this->defaultSortClauseParser->supports('unsupported')); + } +} diff --git a/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/Tests/SortClauseParser/FieldSortClauseParserTest.php b/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/Tests/SortClauseParser/FieldSortClauseParserTest.php new file mode 100644 index 00000000000..23568d66b20 --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/Tests/SortClauseParser/FieldSortClauseParserTest.php @@ -0,0 +1,59 @@ +fieldSortClauseParser = new FieldSortClauseParser(); + } + + public function testParse(): void + { + $parser = $this->createMock(SortSpecParserInterface::class); + $parser + ->method('match') + ->withConsecutive( + [Token::TYPE_ID], + [Token::TYPE_DOT], + [Token::TYPE_ID] + ) + ->willReturnOnConsecutiveCalls( + new Token(Token::TYPE_ID, self::EXAMPLE_CONTENT_TYPE_ID), + new Token(Token::TYPE_DOT), + new Token(Token::TYPE_ID, self::EXAMPLE_FIELD_ID) + ); + + $parser->method('parseSortDirection')->willReturn(Query::SORT_ASC); + + $this->assertEquals( + new Field(self::EXAMPLE_CONTENT_TYPE_ID, self::EXAMPLE_FIELD_ID, Query::SORT_ASC), + $this->fieldSortClauseParser->parse($parser, 'field') + ); + } + + public function testSupports(): void + { + $this->assertTrue($this->fieldSortClauseParser->supports('field')); + } +} diff --git a/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/Tests/SortClauseParser/MapDistanceSortClauseParserTest.php b/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/Tests/SortClauseParser/MapDistanceSortClauseParserTest.php new file mode 100644 index 00000000000..bc8e3af32f7 --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/Tests/SortClauseParser/MapDistanceSortClauseParserTest.php @@ -0,0 +1,71 @@ +mapDistanceSortClauseParser = new MapDistanceSortClauseParser(); + } + + public function testParse(): void + { + $parser = $this->createMock(SortSpecParserInterface::class); + $parser + ->method('match') + ->withConsecutive( + [Token::TYPE_ID], + [Token::TYPE_DOT], + [Token::TYPE_ID], + [Token::TYPE_FLOAT], + [Token::TYPE_FLOAT] + ) + ->willReturnOnConsecutiveCalls( + new Token(Token::TYPE_ID, self::EXAMPLE_CONTENT_TYPE_ID), + new Token(Token::TYPE_DOT), + new Token(Token::TYPE_ID, self::EXAMPLE_FIELD_ID), + new Token(Token::TYPE_FLOAT, (string)self::EXAMPLE_LAT), + new Token(Token::TYPE_FLOAT, (string)self::EXAMPLE_LON) + ); + + $parser->method('parseSortDirection')->willReturn(Query::SORT_ASC); + + $this->assertEquals( + new MapLocationDistance( + self::EXAMPLE_CONTENT_TYPE_ID, + self::EXAMPLE_FIELD_ID, + self::EXAMPLE_LAT, + self::EXAMPLE_LON, + Query::SORT_ASC + ), + $this->mapDistanceSortClauseParser->parse($parser, 'map_distance') + ); + } + + public function testSupports(): void + { + $this->assertTrue($this->mapDistanceSortClauseParser->supports('map_distance')); + } +} diff --git a/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/Tests/SortClauseParser/RandomSortClauseParserTest.php b/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/Tests/SortClauseParser/RandomSortClauseParserTest.php new file mode 100644 index 00000000000..9713215fb99 --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/Tests/SortClauseParser/RandomSortClauseParserTest.php @@ -0,0 +1,55 @@ +randomSortClauseParser = new RandomSortClauseParser(); + } + + public function testParse(): void + { + $parser = $this->createMock(SortSpecParserInterface::class); + $parser + ->method('isNextToken') + ->with(Token::TYPE_INT) + ->willReturn(true); + + $parser + ->method('match') + ->with(Token::TYPE_INT) + ->willReturn(new Token(Token::TYPE_INT, (string)self::EXAMPLE_SEED)); + + $parser->method('parseSortDirection')->willReturn(Query::SORT_ASC); + + $this->assertEquals( + new Random(self::EXAMPLE_SEED, Query::SORT_ASC), + $this->randomSortClauseParser->parse($parser, 'random') + ); + } + + public function testSupports(): void + { + $this->assertTrue($this->randomSortClauseParser->supports('random')); + } +} diff --git a/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/Tests/SortClauseParserDispatcherTest.php b/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/Tests/SortClauseParserDispatcherTest.php new file mode 100644 index 00000000000..db0e6565e33 --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/Tests/SortClauseParserDispatcherTest.php @@ -0,0 +1,64 @@ +createMock(SortSpecParserInterface::class); + $sortClause = $this->createMock(SortClause::class); + + $parser = $this->createMock(SortClauseParserInterface::class); + $parser->method('supports')->with(self::EXAMPLE_SORT_CLAUSE)->willReturn(true); + $parser->method('parse')->with($sortSpecParser, self::EXAMPLE_SORT_CLAUSE)->willReturn($sortClause); + + $dispatcher = new SortClauseParserDispatcher([$parser]); + + $this->assertEquals( + $sortClause, + $dispatcher->parse($sortSpecParser, self::EXAMPLE_SORT_CLAUSE) + ); + } + + public function testParseThrowsUnsupportedSortClauseException(): void + { + $this->expectException(UnsupportedSortClauseException::class); + $this->expectExceptionMessage(sprintf( + 'Could not find %s for %s sort clause', + SortClauseParserInterface::class, + self::EXAMPLE_SORT_CLAUSE + )); + + $parser = $this->createMock(SortClauseParserInterface::class); + $parser->method('supports')->with(self::EXAMPLE_SORT_CLAUSE)->willReturn(false); + + $dispatcher = new SortClauseParserDispatcher([$parser]); + $dispatcher->parse($this->createMock(SortSpecParserInterface::class), self::EXAMPLE_SORT_CLAUSE); + } + + public function testSupports(): void + { + $parser = $this->createMock(SortClauseParserInterface::class); + $parser->method('supports')->with(self::EXAMPLE_SORT_CLAUSE)->willReturn(true); + + $dispatcher = new SortClauseParserDispatcher([$parser]); + + $this->assertTrue($dispatcher->supports(self::EXAMPLE_SORT_CLAUSE)); + } +} diff --git a/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/Tests/SortSpecLexerStub.php b/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/Tests/SortSpecLexerStub.php new file mode 100644 index 00000000000..ead2d6d48d8 --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/Tests/SortSpecLexerStub.php @@ -0,0 +1,60 @@ +tokens = $tokens; + $this->position = -1; + } + + public function consume(): Token + { + ++$this->position; + + return $this->tokens[$this->position]; + } + + public function isEOF(): bool + { + return $this->position + 1 >= count($this->tokens) - 1; + } + + public function tokenize(string $input): void + { + $this->input = $input; + } + + public function getInput(): string + { + return (string)$this->input; + } + + public function peek(): ?Token + { + return $this->tokens[$this->position + 1] ?? null; + } +} diff --git a/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/Tests/SortSpecLexerTest.php b/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/Tests/SortSpecLexerTest.php new file mode 100644 index 00000000000..73fbc9c608e --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/Tests/SortSpecLexerTest.php @@ -0,0 +1,185 @@ +tokenize($input); + + $this->assertEquals($expectedTokens, $lexer->getAll()); + } + + public function dataProviderForTokenize(): iterable + { + yield 'keyword: asc' => [ + 'asc', + [ + new Token(Token::TYPE_ASC, 'asc', 0), + new Token(Token::TYPE_EOF, ''), + ], + ]; + + yield 'keyword: desc' => [ + 'desc', + [ + new Token(Token::TYPE_DESC, 'desc', 0), + new Token(Token::TYPE_EOF, ''), + ], + ]; + + yield 'id: simple' => [ + 'foo', + [ + new Token(Token::TYPE_ID, 'foo', 0), + new Token(Token::TYPE_EOF, ''), + ], + ]; + + yield 'id: full alphabet' => [ + 'fO0_bA9', + [ + new Token(Token::TYPE_ID, 'fO0_bA9', 0), + new Token(Token::TYPE_EOF, ''), + ], + ]; + + yield 'int: < 0' => [ + '-10', + [ + new Token(Token::TYPE_INT, '-10', 0), + new Token(Token::TYPE_EOF, ''), + ], + ]; + + yield 'int: 0' => [ + '0', + [ + new Token(Token::TYPE_INT, '0', 0), + new Token(Token::TYPE_EOF, ''), + ], + ]; + + yield 'int: > 0' => [ + '100', + [ + new Token(Token::TYPE_INT, '100', 0), + new Token(Token::TYPE_EOF, ''), + ], + ]; + + yield 'float: 0.0' => [ + '0.0', + [ + new Token(Token::TYPE_FLOAT, '0.0', 0), + new Token(Token::TYPE_EOF, ''), + ], + ]; + + yield 'float: 0.0 < x < 1.0' => [ + '0.5', + [ + new Token(Token::TYPE_FLOAT, '0.5', 0), + new Token(Token::TYPE_EOF, ''), + ], + ]; + + yield 'float: -1.0 < x < 0.0' => [ + '-0.25', + [ + new Token(Token::TYPE_FLOAT, '-0.25', 0), + new Token(Token::TYPE_EOF, ''), + ], + ]; + + yield 'float: > 1.0' => [ + '40.67', + [ + new Token(Token::TYPE_FLOAT, '40.67', 0), + new Token(Token::TYPE_EOF, ''), + ], + ]; + + yield 'float: < -1.0' => [ + '-25.00', + [ + new Token(Token::TYPE_FLOAT, '-25.00', 0), + new Token(Token::TYPE_EOF, ''), + ], + ]; + + yield 'dot' => [ + '.', + [ + new Token(Token::TYPE_DOT, '.', 0), + new Token(Token::TYPE_EOF, ''), + ], + ]; + + yield 'comma' => [ + ',', + [ + new Token(Token::TYPE_COMMA, ',', 0), + new Token(Token::TYPE_EOF, ''), + ], + ]; + + yield 'unknown' => [ + '???', + [ + new Token(Token::TYPE_NONE, '???', 0), + new Token(Token::TYPE_EOF, ''), + ], + ]; + + yield 'empty input' => [ + '', + [new Token(Token::TYPE_EOF, '')], + ]; + + yield 'sequence' => [ + 'asc desc id 0 0.0 . , ???', + [ + new Token(Token::TYPE_ASC, 'asc', 0), + new Token(Token::TYPE_DESC, 'desc', 4), + new Token(Token::TYPE_ID, 'id', 9), + new Token(Token::TYPE_INT, '0', 12), + new Token(Token::TYPE_FLOAT, '0.0', 14), + new Token(Token::TYPE_DOT, '.', 18), + new Token(Token::TYPE_COMMA, ',', 20), + new Token(Token::TYPE_NONE, '???', 21), + new Token(Token::TYPE_EOF, ''), + ], + ]; + } + + public function testConsume(): void + { + $lexer = new SortSpecLexer(); + $lexer->tokenize('foo, asc'); + + $output = []; + while (!$lexer->isEOF()) { + $output[] = $lexer->consume(); + } + + $this->assertEquals([ + new Token(Token::TYPE_ID, 'foo', 0), + new Token(Token::TYPE_COMMA, ',', 3), + new Token(Token::TYPE_ASC, 'asc', 5), + ], $output); + } +} diff --git a/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/Tests/SortSpecParserTest.php b/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/Tests/SortSpecParserTest.php new file mode 100644 index 00000000000..bc1d17c2dc8 --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/Tests/SortSpecParserTest.php @@ -0,0 +1,134 @@ +createMock(SortClauseParserInterface::class), $lexer); + + $this->assertEquals($expectedDirection, $parser->parseSortDirection()); + } + + public function dataProviderForParseSortDirection(): iterable + { + yield 'asc' => [ + [ + new Token(Token::TYPE_ASC), + new Token(Token::TYPE_EOF), + ], + Query::SORT_ASC, + ]; + + yield 'desc' => [ + [ + new Token(Token::TYPE_DESC), + new Token(Token::TYPE_EOF), + ], + Query::SORT_DESC, + ]; + + yield 'default' => [ + [ + new Token(Token::TYPE_EOF), + ], + Query::SORT_ASC, + ]; + } + + public function testParseSortClauseList(): void + { + $lexer = new SortSpecLexerStub([ + new Token(Token::TYPE_ID, self::EXAMPLE_SORT_CLAUSE_ID), + new Token(Token::TYPE_COMMA), + new Token(Token::TYPE_ID, self::EXAMPLE_SORT_CLAUSE_ID), + new Token(Token::TYPE_EOF), + ]); + + $sortClauseArgsParser = $this->createMock(SortClauseParserInterface::class); + $parser = new SortSpecParser($sortClauseArgsParser, $lexer); + + $sortClauseA = $this->createMock(SortClause::class); + $sortClauseB = $this->createMock(SortClause::class); + + $sortClauseArgsParser + ->method('parse') + ->with($parser, self::EXAMPLE_SORT_CLAUSE_ID) + ->willReturnOnConsecutiveCalls($sortClauseA, $sortClauseB); + + $this->assertEquals( + [$sortClauseA, $sortClauseB], + $parser->parseSortClausesList() + ); + } + + public function testParseSortClause(): void + { + $lexer = new SortSpecLexerStub([ + new Token(Token::TYPE_ID, self::EXAMPLE_SORT_CLAUSE_ID), + new Token(Token::TYPE_EOF), + ]); + + $sortClauseArgsParser = $this->createMock(SortClauseParserInterface::class); + $parser = new SortSpecParser($sortClauseArgsParser, $lexer); + + $sortClause = $this->createMock(SortClause::class); + $sortClauseArgsParser + ->expects($this->once()) + ->method('parse') + ->with($parser, self::EXAMPLE_SORT_CLAUSE_ID) + ->willReturn($sortClause); + + $this->assertEquals($sortClause, $parser->parseSortClause()); + } + + public function testMatch(): void + { + $token = new Token(Token::TYPE_ID, self::EXAMPLE_SORT_CLAUSE_ID); + + $lexer = $this->createMock(SortSpecLexerInterface::class); + $lexer->expects($this->once())->method('peek')->willReturn($token); + $lexer->expects($this->once())->method('consume')->willReturn($token); + + $parser = new SortSpecParser( + $this->createMock(SortClauseParserInterface::class), + $lexer + ); + + $this->assertEquals($token, $parser->match(Token::TYPE_ID)); + } + + public function testMatchAny(): void + { + $token = new Token(Token::TYPE_ASC); + + $lexer = $this->createMock(SortSpecLexerInterface::class); + $lexer->expects($this->once())->method('peek')->willReturn($token); + $lexer->expects($this->once())->method('consume')->willReturn($token); + + $parser = new SortSpecParser( + $this->createMock(SortClauseParserInterface::class), + $lexer + ); + + $this->assertEquals($token, $parser->matchAnyOf(Token::TYPE_ASC, Token::TYPE_DESC)); + } +} diff --git a/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/Token.php b/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/Token.php new file mode 100644 index 00000000000..10ab1111a67 --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/SortSpec/Token.php @@ -0,0 +1,77 @@ +'; + public const TYPE_ASC = ''; + public const TYPE_DESC = ''; + public const TYPE_ID = ''; + public const TYPE_DOT = '<.>'; + public const TYPE_COMMA = '<,>'; + public const TYPE_INT = ''; + public const TYPE_FLOAT = ''; + public const TYPE_EOF = ''; + + /** @var string */ + private $type; + + /** @var string */ + private $value; + + /** @var int */ + private $position; + + public function __construct(string $type, string $value = '', int $position = -1) + { + $this->type = $type; + $this->value = $value; + $this->position = $position; + } + + public function isA(string $type): bool + { + return $this->type === $type; + } + + public function getType(): string + { + return $this->type; + } + + public function getValue(): string + { + return $this->value; + } + + public function getValueAsFloat(): float + { + return (float)$this->value; + } + + public function getValueAsInt(): int + { + return (int)$this->value; + } + + public function getPosition(): int + { + return $this->position; + } + + public function __toString(): string + { + if ($this->value !== null) { + return "{$this->value} ({$this->type})"; + } + + return "{$this->type}"; + } +} diff --git a/eZ/Publish/Core/QueryType/BuiltIn/SubtreeQueryType.php b/eZ/Publish/Core/QueryType/BuiltIn/SubtreeQueryType.php new file mode 100644 index 00000000000..763ebe2c040 --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/SubtreeQueryType.php @@ -0,0 +1,51 @@ +setDefaults([ + 'depth' => -1, + ]); + $resolver->setAllowedTypes('depth', 'int'); + } + + protected function getQueryFilter(array $parameters): Criterion + { + $location = $this->resolveLocation($parameters); + + if ($location === null) { + return new MatchNone(); + } + + if ($parameters['depth'] > -1) { + $depth = $location->depth + (int)$parameters['depth']; + + return new LogicalAnd([ + new Subtree($location->pathString), + new Depth(Operator::LTE, $depth), + ]); + } + + return new Subtree($location->pathString); + } +} diff --git a/eZ/Publish/Core/QueryType/BuiltIn/Tests/AbstractQueryTypeTest.php b/eZ/Publish/Core/QueryType/BuiltIn/Tests/AbstractQueryTypeTest.php new file mode 100644 index 00000000000..39351b90b0d --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/Tests/AbstractQueryTypeTest.php @@ -0,0 +1,103 @@ + self::ROOT_LOCATION_ID, + 'pathString' => self::ROOT_LOCATION_PATH_STRING, + ]); + + $locationService = $this->createMock(LocationService::class); + $locationService + ->method('loadLocation') + ->with(self::ROOT_LOCATION_ID) + ->willReturn($rootLocation); + + $this->repository = $this->createMock(Repository::class); + $this->repository->method('getLocationService')->willReturn($locationService); + + $this->configResolver = $this->createMock(ConfigResolverInterface::class); + $this->configResolver + ->method('getParameter') + ->with('content.tree_root.location_id') + ->willReturn(self::ROOT_LOCATION_ID); + + $this->sortClausesFactory = $this->createMock(SortClausesFactoryInterface::class); + + $this->queryType = $this->createQueryType( + $this->repository, + $this->configResolver, + $this->sortClausesFactory + ); + } + + /** + * @dataProvider dataProviderForGetQuery + */ + final public function testGetQuery(array $parameters, Query $expectedQuery): void + { + $this->assertEquals($expectedQuery, $this->queryType->getQuery($parameters)); + } + + final public function testGetName(): void + { + $this->assertEquals( + $this->getExpectedName(), + $this->queryType->getName() + ); + } + + final public function testGetSupportedParameters(): void + { + $this->assertEqualsCanonicalizing( + $this->getExpectedSupportedParameters(), + $this->queryType->getSupportedParameters() + ); + } + + abstract public function dataProviderForGetQuery(): iterable; + + abstract protected function createQueryType( + Repository $repository, + ConfigResolverInterface $configResolver, + SortClausesFactoryInterface $sortClausesFactory + ): QueryType; + + abstract protected function getExpectedName(): string; + + abstract protected function getExpectedSupportedParameters(): array; +} diff --git a/eZ/Publish/Core/QueryType/BuiltIn/Tests/AncestorsQueryTypeTest.php b/eZ/Publish/Core/QueryType/BuiltIn/Tests/AncestorsQueryTypeTest.php new file mode 100644 index 00000000000..9fe71ad9328 --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/Tests/AncestorsQueryTypeTest.php @@ -0,0 +1,189 @@ + self::EXAMPLE_LOCATION_ID, + 'pathString' => self::EXAMPLE_LOCATION_PATH_STRING, + ]); + + yield 'basic' => [ + [ + 'location' => $location, + ], + new Query([ + 'filter' => new LogicalAnd([ + new LogicalAnd([ + new Ancestor(self::EXAMPLE_LOCATION_PATH_STRING), + new LogicalNot( + new LocationId(self::EXAMPLE_LOCATION_ID) + ), + ]), + new Visibility(Visibility::VISIBLE), + new Subtree(self::ROOT_LOCATION_PATH_STRING), + ]), + ]), + ]; + + yield 'filter by visibility' => [ + [ + 'location' => $location, + 'filter' => [ + 'visible_only' => false, + ], + ], + new Query([ + 'filter' => new LogicalAnd([ + new LogicalAnd([ + new Ancestor(self::EXAMPLE_LOCATION_PATH_STRING), + new LogicalNot( + new LocationId(self::EXAMPLE_LOCATION_ID) + ), + ]), + new Subtree(self::ROOT_LOCATION_PATH_STRING), + ]), + ]), + ]; + + yield 'filter by content type' => [ + [ + 'location' => $location, + 'filter' => [ + 'content_type' => [ + 'article', + 'blog_post', + 'folder', + ], + ], + ], + new Query([ + 'filter' => new LogicalAnd([ + new LogicalAnd([ + new Ancestor(self::EXAMPLE_LOCATION_PATH_STRING), + new LogicalNot( + new LocationId(self::EXAMPLE_LOCATION_ID) + ), + ]), + new Visibility(Visibility::VISIBLE), + new ContentTypeIdentifier([ + 'article', + 'blog_post', + 'folder', + ]), + new Subtree(self::ROOT_LOCATION_PATH_STRING), + ]), + ]), + ]; + + yield 'filter by siteaccess' => [ + [ + 'location' => $location, + 'filter' => [ + 'siteaccess_aware' => false, + ], + ], + new Query([ + 'filter' => new LogicalAnd([ + new LogicalAnd([ + new Ancestor(self::EXAMPLE_LOCATION_PATH_STRING), + new LogicalNot( + new LocationId(self::EXAMPLE_LOCATION_ID) + ), + ]), + new Visibility(Visibility::VISIBLE), + ]), + ]), + ]; + + yield 'limit and offset' => [ + [ + 'location' => $location, + 'limit' => 10, + 'offset' => 100, + ], + new Query([ + 'filter' => new LogicalAnd([ + new LogicalAnd([ + new Ancestor(self::EXAMPLE_LOCATION_PATH_STRING), + new LogicalNot( + new LocationId(self::EXAMPLE_LOCATION_ID) + ), + ]), + new Visibility(Visibility::VISIBLE), + new Subtree(self::ROOT_LOCATION_PATH_STRING), + ]), + 'limit' => 10, + 'offset' => 100, + ]), + ]; + + yield 'sort' => [ + [ + 'location' => $location, + 'sort' => new Priority(Query::SORT_ASC), + ], + new Query([ + 'filter' => new LogicalAnd([ + new LogicalAnd([ + new Ancestor(self::EXAMPLE_LOCATION_PATH_STRING), + new LogicalNot( + new LocationId(self::EXAMPLE_LOCATION_ID) + ), + ]), + new Visibility(Visibility::VISIBLE), + new Subtree(self::ROOT_LOCATION_PATH_STRING), + ]), + 'sortClauses' => [ + new Priority(Query::SORT_ASC), + ], + ]), + ]; + } + + protected function createQueryType( + Repository $repository, + ConfigResolverInterface $configResolver, + SortClausesFactoryInterface $sortClausesFactory + ): QueryType { + return new AncestorsQueryType($repository, $configResolver, $sortClausesFactory); + } + + protected function getExpectedName(): string + { + return 'Ancestors'; + } + + protected function getExpectedSupportedParameters(): array + { + return ['filter', 'offset', 'limit', 'sort', 'location', 'content']; + } +} diff --git a/eZ/Publish/Core/QueryType/BuiltIn/Tests/ChildrenQueryTypeTest.php b/eZ/Publish/Core/QueryType/BuiltIn/Tests/ChildrenQueryTypeTest.php new file mode 100644 index 00000000000..b9a93f42044 --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/Tests/ChildrenQueryTypeTest.php @@ -0,0 +1,155 @@ + self::EXAMPLE_LOCATION_ID, + ]); + + yield 'basic' => [ + [ + 'location' => $location, + ], + new Query([ + 'filter' => new LogicalAnd([ + new ParentLocationId(self::EXAMPLE_LOCATION_ID), + new Visibility(Visibility::VISIBLE), + new Subtree(self::ROOT_LOCATION_PATH_STRING), + ]), + ]), + ]; + + yield 'filter by visibility' => [ + [ + 'location' => $location, + 'filter' => [ + 'visible_only' => false, + ], + ], + new Query([ + 'filter' => new LogicalAnd([ + new ParentLocationId(self::EXAMPLE_LOCATION_ID), + new Subtree(self::ROOT_LOCATION_PATH_STRING), + ]), + ]), + ]; + + yield 'filter by content type' => [ + [ + 'location' => $location, + 'filter' => [ + 'content_type' => [ + 'article', + 'blog_post', + 'folder', + ], + ], + ], + new Query([ + 'filter' => new LogicalAnd([ + new ParentLocationId(self::EXAMPLE_LOCATION_ID), + new Visibility(Visibility::VISIBLE), + new ContentTypeIdentifier([ + 'article', + 'blog_post', + 'folder', + ]), + new Subtree(self::ROOT_LOCATION_PATH_STRING), + ]), + ]), + ]; + + yield 'filter by siteaccess' => [ + [ + 'location' => $location, + 'filter' => [ + 'siteaccess_aware' => false, + ], + ], + new Query([ + 'filter' => new LogicalAnd([ + new ParentLocationId(self::EXAMPLE_LOCATION_ID), + new Visibility(Visibility::VISIBLE), + ]), + ]), + ]; + + yield 'limit and offset' => [ + [ + 'location' => $location, + 'limit' => 10, + 'offset' => 100, + ], + new Query([ + 'filter' => new LogicalAnd([ + new ParentLocationId(self::EXAMPLE_LOCATION_ID), + new Visibility(Visibility::VISIBLE), + new Subtree(self::ROOT_LOCATION_PATH_STRING), + ]), + 'limit' => 10, + 'offset' => 100, + ]), + ]; + + yield 'sort' => [ + [ + 'location' => $location, + 'sort' => new Priority(Query::SORT_ASC), + ], + new Query([ + 'filter' => new LogicalAnd([ + new ParentLocationId(self::EXAMPLE_LOCATION_ID), + new Visibility(Visibility::VISIBLE), + new Subtree(self::ROOT_LOCATION_PATH_STRING), + ]), + 'sortClauses' => [ + new Priority(Query::SORT_ASC), + ], + ]), + ]; + } + + protected function createQueryType( + Repository $repository, + ConfigResolverInterface $configResolver, + SortClausesFactoryInterface $sortClausesFactory + ): QueryType { + return new ChildrenQueryType($repository, $configResolver, $sortClausesFactory); + } + + protected function getExpectedName(): string + { + return 'Children'; + } + + protected function getExpectedSupportedParameters(): array + { + return ['filter', 'offset', 'limit', 'sort', 'location', 'content']; + } +} diff --git a/eZ/Publish/Core/QueryType/BuiltIn/Tests/GeoLocationQueryTypeTest.php b/eZ/Publish/Core/QueryType/BuiltIn/Tests/GeoLocationQueryTypeTest.php new file mode 100644 index 00000000000..19a4767bfb6 --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/Tests/GeoLocationQueryTypeTest.php @@ -0,0 +1,162 @@ + self::EXAMPLE_FIELD, + 'distance' => self::EXAMPLE_DISTANCE, + 'latitude' => self::EXAMPLE_LATITUDE, + 'longitude' => self::EXAMPLE_LONGITUDE, + ]; + + $criterion = new MapLocationDistance( + self::EXAMPLE_FIELD, + Operator::LTE, + self::EXAMPLE_DISTANCE, + self::EXAMPLE_LATITUDE, + self::EXAMPLE_LONGITUDE + ); + + yield 'basic' => [ + $parameters, + new Query([ + 'filter' => new LogicalAnd([ + $criterion, + new Visibility(Visibility::VISIBLE), + new Subtree(self::ROOT_LOCATION_PATH_STRING), + ]), + ]), + ]; + + yield 'filter by visibility' => [ + $parameters + [ + 'filter' => [ + 'visible_only' => false, + ], + ], + new Query([ + 'filter' => new LogicalAnd([ + $criterion, + new Subtree(self::ROOT_LOCATION_PATH_STRING), + ]), + ]), + ]; + + yield 'filter by content type' => [ + $parameters + [ + 'filter' => [ + 'content_type' => [ + 'article', + 'blog_post', + 'folder', + ], + ], + ], + new Query([ + 'filter' => new LogicalAnd([ + $criterion, + new Visibility(Visibility::VISIBLE), + new ContentTypeIdentifier([ + 'article', + 'blog_post', + 'folder', + ]), + new Subtree(self::ROOT_LOCATION_PATH_STRING), + ]), + ]), + ]; + + yield 'filter by siteaccess' => [ + $parameters + [ + 'filter' => [ + 'siteaccess_aware' => false, + ], + ], + new Query([ + 'filter' => new LogicalAnd([ + $criterion, + new Visibility(Visibility::VISIBLE), + ]), + ]), + ]; + + yield 'limit and offset' => [ + $parameters + [ + 'limit' => 10, + 'offset' => 100, + ], + new Query([ + 'filter' => new LogicalAnd([ + $criterion, + new Visibility(Visibility::VISIBLE), + new Subtree(self::ROOT_LOCATION_PATH_STRING), + ]), + 'limit' => 10, + 'offset' => 100, + ]), + ]; + + yield 'sort' => [ + $parameters + [ + 'sort' => new ContentName(Query::SORT_ASC), + ], + new Query([ + 'filter' => new LogicalAnd([ + $criterion, + new Visibility(Visibility::VISIBLE), + new Subtree(self::ROOT_LOCATION_PATH_STRING), + ]), + 'sortClauses' => [ + new ContentName(Query::SORT_ASC), + ], + ]), + ]; + } + + protected function createQueryType( + Repository $repository, + ConfigResolverInterface $configResolver, + SortClausesFactoryInterface $sortClausesFactory + ): QueryType { + return new GeoLocationQueryType($repository, $configResolver, $sortClausesFactory); + } + + protected function getExpectedName(): string + { + return 'GeoLocation'; + } + + protected function getExpectedSupportedParameters(): array + { + return ['filter', 'offset', 'limit', 'sort', 'field', 'distance', 'latitude', 'longitude', 'operator']; + } +} diff --git a/eZ/Publish/Core/QueryType/BuiltIn/Tests/RelatedToContentQueryTypeTest.php b/eZ/Publish/Core/QueryType/BuiltIn/Tests/RelatedToContentQueryTypeTest.php new file mode 100644 index 00000000000..132ec8ac37b --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/Tests/RelatedToContentQueryTypeTest.php @@ -0,0 +1,158 @@ + [ + [ + 'content' => self::EXAMPLE_CONTENT_ID, + 'field' => self::EXAMPLE_FIELD, + ], + new Query([ + 'filter' => new LogicalAnd([ + new FieldRelation(self::EXAMPLE_FIELD, Operator::CONTAINS, self::EXAMPLE_CONTENT_ID), + new Visibility(Visibility::VISIBLE), + new Subtree(self::ROOT_LOCATION_PATH_STRING), + ]), + ]), + ]; + + yield 'filter by visibility' => [ + [ + 'content' => self::EXAMPLE_CONTENT_ID, + 'field' => self::EXAMPLE_FIELD, + 'filter' => [ + 'visible_only' => false, + ], + ], + new Query([ + 'filter' => new LogicalAnd([ + new FieldRelation(self::EXAMPLE_FIELD, Operator::CONTAINS, self::EXAMPLE_CONTENT_ID), + new Subtree(self::ROOT_LOCATION_PATH_STRING), + ]), + ]), + ]; + + yield 'filter by content type' => [ + [ + 'content' => self::EXAMPLE_CONTENT_ID, + 'field' => self::EXAMPLE_FIELD, + 'filter' => [ + 'content_type' => [ + 'article', + 'blog_post', + 'folder', + ], + ], + ], + new Query([ + 'filter' => new LogicalAnd([ + new FieldRelation(self::EXAMPLE_FIELD, Operator::CONTAINS, self::EXAMPLE_CONTENT_ID), + new Visibility(Visibility::VISIBLE), + new ContentTypeIdentifier([ + 'article', + 'blog_post', + 'folder', + ]), + new Subtree(self::ROOT_LOCATION_PATH_STRING), + ]), + ]), + ]; + + yield 'filter by siteaccess' => [ + [ + 'content' => self::EXAMPLE_CONTENT_ID, + 'field' => self::EXAMPLE_FIELD, + 'filter' => [ + 'siteaccess_aware' => false, + ], + ], + new Query([ + 'filter' => new LogicalAnd([ + new FieldRelation(self::EXAMPLE_FIELD, Operator::CONTAINS, self::EXAMPLE_CONTENT_ID), + new Visibility(Visibility::VISIBLE), + ]), + ]), + ]; + + yield 'limit and offset' => [ + [ + 'content' => self::EXAMPLE_CONTENT_ID, + 'field' => self::EXAMPLE_FIELD, + 'limit' => 10, + 'offset' => 100, + ], + new Query([ + 'filter' => new LogicalAnd([ + new FieldRelation(self::EXAMPLE_FIELD, Operator::CONTAINS, self::EXAMPLE_CONTENT_ID), + new Visibility(Visibility::VISIBLE), + new Subtree(self::ROOT_LOCATION_PATH_STRING), + ]), + 'limit' => 10, + 'offset' => 100, + ]), + ]; + + yield 'basic sort' => [ + [ + 'content' => self::EXAMPLE_CONTENT_ID, + 'field' => self::EXAMPLE_FIELD, + 'sort' => new ContentName(Query::SORT_ASC), + ], + new Query([ + 'filter' => new LogicalAnd([ + new FieldRelation(self::EXAMPLE_FIELD, Operator::CONTAINS, self::EXAMPLE_CONTENT_ID), + new Visibility(Visibility::VISIBLE), + new Subtree(self::ROOT_LOCATION_PATH_STRING), + ]), + 'sortClauses' => [ + new ContentName(Query::SORT_ASC), + ], + ]), + ]; + } + + protected function createQueryType( + Repository $repository, + ConfigResolverInterface $configResolver, + SortClausesFactoryInterface $sortClausesFactory + ): QueryType { + return new RelatedToContentQueryType($repository, $configResolver, $sortClausesFactory); + } + + protected function getExpectedName(): string + { + return 'RelatedToContent'; + } + + protected function getExpectedSupportedParameters(): array + { + return ['filter', 'offset', 'limit', 'sort', 'content', 'field']; + } +} diff --git a/eZ/Publish/Core/QueryType/BuiltIn/Tests/SiblingsQueryTypeTest.php b/eZ/Publish/Core/QueryType/BuiltIn/Tests/SiblingsQueryTypeTest.php new file mode 100644 index 00000000000..3716ee33824 --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/Tests/SiblingsQueryTypeTest.php @@ -0,0 +1,156 @@ + self::EXAMPLE_LOCATION_ID, + 'parentLocationId' => self::ROOT_LOCATION_ID, + ]); + + yield 'basic' => [ + [ + 'location' => $location, + ], + new Query([ + 'filter' => new LogicalAnd([ + Sibling::fromLocation($location), + new Visibility(Visibility::VISIBLE), + new Subtree(self::ROOT_LOCATION_PATH_STRING), + ]), + ]), + ]; + + yield 'filter by visibility' => [ + [ + 'location' => $location, + 'filter' => [ + 'visible_only' => false, + ], + ], + new Query([ + 'filter' => new LogicalAnd([ + Sibling::fromLocation($location), + new Subtree(self::ROOT_LOCATION_PATH_STRING), + ]), + ]), + ]; + + yield 'filter by content type' => [ + [ + 'location' => $location, + 'filter' => [ + 'content_type' => [ + 'article', + 'blog_post', + 'folder', + ], + ], + ], + new Query([ + 'filter' => new LogicalAnd([ + Sibling::fromLocation($location), + new Visibility(Visibility::VISIBLE), + new ContentTypeIdentifier([ + 'article', + 'blog_post', + 'folder', + ]), + new Subtree(self::ROOT_LOCATION_PATH_STRING), + ]), + ]), + ]; + + yield 'filter by siteaccess' => [ + [ + 'location' => $location, + 'filter' => [ + 'siteaccess_aware' => false, + ], + ], + new Query([ + 'filter' => new LogicalAnd([ + Sibling::fromLocation($location), + new Visibility(Visibility::VISIBLE), + ]), + ]), + ]; + + yield 'limit and offset' => [ + [ + 'location' => $location, + 'limit' => 10, + 'offset' => 100, + ], + new Query([ + 'filter' => new LogicalAnd([ + Sibling::fromLocation($location), + new Visibility(Visibility::VISIBLE), + new Subtree(self::ROOT_LOCATION_PATH_STRING), + ]), + 'limit' => 10, + 'offset' => 100, + ]), + ]; + + yield 'basic sort' => [ + [ + 'location' => $location, + 'sort' => new Priority(Query::SORT_ASC), + ], + new Query([ + 'filter' => new LogicalAnd([ + Sibling::fromLocation($location), + new Visibility(Visibility::VISIBLE), + new Subtree(self::ROOT_LOCATION_PATH_STRING), + ]), + 'sortClauses' => [ + new Priority(Query::SORT_ASC), + ], + ]), + ]; + } + + protected function createQueryType( + Repository $repository, + ConfigResolverInterface $configResolver, + SortClausesFactoryInterface $sortClausesFactory + ): QueryType { + return new SiblingsQueryType($repository, $configResolver, $sortClausesFactory); + } + + protected function getExpectedName(): string + { + return 'Siblings'; + } + + protected function getExpectedSupportedParameters(): array + { + return ['filter', 'offset', 'limit', 'sort', 'location', 'content']; + } +} diff --git a/eZ/Publish/Core/QueryType/BuiltIn/Tests/SubtreeQueryTest.php b/eZ/Publish/Core/QueryType/BuiltIn/Tests/SubtreeQueryTest.php new file mode 100644 index 00000000000..9ee32945964 --- /dev/null +++ b/eZ/Publish/Core/QueryType/BuiltIn/Tests/SubtreeQueryTest.php @@ -0,0 +1,173 @@ + self::EXAMPLE_LOCATION_ID, + 'depth' => self::EXAMPLE_LOCATION_DEPTH, + 'pathString' => self::EXAMPLE_LOCATION_PATH_STRING, + ]); + + yield 'basic' => [ + [ + 'location' => $location, + ], + new Query([ + 'filter' => new LogicalAnd([ + new Subtree(self::EXAMPLE_LOCATION_PATH_STRING), + new Visibility(Visibility::VISIBLE), + new Subtree(self::ROOT_LOCATION_PATH_STRING), + ]), + ]), + ]; + + yield 'filter by relative depth' => [ + [ + 'location' => $location, + 'depth' => 2, + ], + new Query([ + 'filter' => new LogicalAnd([ + new LogicalAnd([ + new Subtree(self::EXAMPLE_LOCATION_PATH_STRING), + new Depth(Operator::LTE, self::EXAMPLE_LOCATION_DEPTH + 2), + ]), + new Visibility(Visibility::VISIBLE), + new Subtree(self::ROOT_LOCATION_PATH_STRING), + ]), + ]), + ]; + + yield 'filter by visibility' => [ + [ + 'location' => $location, + 'filter' => [ + 'visible_only' => false, + ], + ], + new Query([ + 'filter' => new LogicalAnd([ + new Subtree(self::EXAMPLE_LOCATION_PATH_STRING), + new Subtree(self::ROOT_LOCATION_PATH_STRING), + ]), + ]), + ]; + + yield 'filter by content type' => [ + [ + 'location' => $location, + 'filter' => [ + 'content_type' => [ + 'article', + 'blog_post', + 'folder', + ], + ], + ], + new Query([ + 'filter' => new LogicalAnd([ + new Subtree(self::EXAMPLE_LOCATION_PATH_STRING), + new Visibility(Visibility::VISIBLE), + new ContentTypeIdentifier([ + 'article', + 'blog_post', + 'folder', + ]), + new Subtree(self::ROOT_LOCATION_PATH_STRING), + ]), + ]), + ]; + + yield 'filter by siteaccess' => [ + [ + 'location' => $location, + 'filter' => [ + 'siteaccess_aware' => false, + ], + ], + new Query([ + 'filter' => new LogicalAnd([ + new Subtree(self::EXAMPLE_LOCATION_PATH_STRING), + new Visibility(Visibility::VISIBLE), + ]), + ]), + ]; + + yield 'limit and offset' => [ + [ + 'location' => $location, + 'limit' => 10, + 'offset' => 100, + ], + new Query([ + 'filter' => new LogicalAnd([ + new Subtree(self::EXAMPLE_LOCATION_PATH_STRING), + new Visibility(Visibility::VISIBLE), + new Subtree(self::ROOT_LOCATION_PATH_STRING), + ]), + 'limit' => 10, + 'offset' => 100, + ]), + ]; + + yield 'basic sort' => [ + [ + 'location' => $location, + 'sort' => new Priority(Query::SORT_ASC), + ], + new Query([ + 'filter' => new LogicalAnd([ + new Subtree(self::EXAMPLE_LOCATION_PATH_STRING), + new Visibility(Visibility::VISIBLE), + new Subtree(self::ROOT_LOCATION_PATH_STRING), + ]), + 'sortClauses' => [ + new Priority(Query::SORT_ASC), + ], + ]), + ]; + } + + protected function createQueryType( + Repository $repository, + ConfigResolverInterface $configResolver, + SortClausesFactoryInterface $sortClausesFactory + ): QueryType { + return new SubtreeQueryType($repository, $configResolver, $sortClausesFactory); + } + + protected function getExpectedName(): string + { + return 'Subtree'; + } + + protected function getExpectedSupportedParameters(): array + { + return ['filter', 'offset', 'limit', 'sort', 'location', 'content', 'depth']; + } +} diff --git a/phpunit.xml b/phpunit.xml index 01d6eff96de..5c73ce89a7b 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -34,6 +34,12 @@ eZ/Publish/Core/Persistence/Legacy/Tests + + eZ/Publish/Core/QueryType/BuiltIn/Tests + + + eZ/Publish/Core/QueryType/BuiltIn/SortSpec/Tests + eZ/Publish/Core/Search/Legacy/Tests