diff --git a/src/contracts/Persistence/Content/Type/CriterionHandlerInterface.php b/src/contracts/Persistence/Content/Type/CriterionHandlerInterface.php new file mode 100644 index 0000000000..da78156259 --- /dev/null +++ b/src/contracts/Persistence/Content/Type/CriterionHandlerInterface.php @@ -0,0 +1,35 @@ +} + */ + public function findContentTypes(?ContentTypeQuery $query = null): array; + /** * @return \Ibexa\Contracts\Core\Persistence\Content\Type[] */ diff --git a/src/contracts/Repository/ContentTypeService.php b/src/contracts/Repository/ContentTypeService.php index 3b30b5a2bc..ef3be97702 100644 --- a/src/contracts/Repository/ContentTypeService.php +++ b/src/contracts/Repository/ContentTypeService.php @@ -18,6 +18,8 @@ use Ibexa\Contracts\Core\Repository\Values\ContentType\FieldDefinition; use Ibexa\Contracts\Core\Repository\Values\ContentType\FieldDefinitionCreateStruct; use Ibexa\Contracts\Core\Repository\Values\ContentType\FieldDefinitionUpdateStruct; +use Ibexa\Contracts\Core\Repository\Values\ContentType\Query\ContentTypeQuery; +use Ibexa\Contracts\Core\Repository\Values\ContentType\SearchResult; use Ibexa\Contracts\Core\Repository\Values\User\User; interface ContentTypeService @@ -173,6 +175,11 @@ public function loadContentTypeDraft(int $contentTypeId, bool $ignoreOwnership = */ public function loadContentTypeList(array $contentTypeIds, array $prioritizedLanguages = []): iterable; + /** + * @param list $prioritizedLanguages Used as prioritized language code on translated properties of returned object. + */ + public function findContentTypes(?ContentTypeQuery $query = null, array $prioritizedLanguages = []): SearchResult; + /** * Get content type objects which belong to the given content type group. * diff --git a/src/contracts/Repository/Decorator/ContentTypeServiceDecorator.php b/src/contracts/Repository/Decorator/ContentTypeServiceDecorator.php index 4b117136cf..3726345dba 100644 --- a/src/contracts/Repository/Decorator/ContentTypeServiceDecorator.php +++ b/src/contracts/Repository/Decorator/ContentTypeServiceDecorator.php @@ -19,6 +19,8 @@ use Ibexa\Contracts\Core\Repository\Values\ContentType\FieldDefinition; use Ibexa\Contracts\Core\Repository\Values\ContentType\FieldDefinitionCreateStruct; use Ibexa\Contracts\Core\Repository\Values\ContentType\FieldDefinitionUpdateStruct; +use Ibexa\Contracts\Core\Repository\Values\ContentType\Query\ContentTypeQuery; +use Ibexa\Contracts\Core\Repository\Values\ContentType\SearchResult; use Ibexa\Contracts\Core\Repository\Values\User\User; abstract class ContentTypeServiceDecorator implements ContentTypeService @@ -107,6 +109,11 @@ public function loadContentTypeList( return $this->innerService->loadContentTypeList($contentTypeIds, $prioritizedLanguages); } + public function findContentTypes(?ContentTypeQuery $query = null, array $prioritizedLanguages = []): SearchResult + { + return $this->innerService->findContentTypes($query, $prioritizedLanguages); + } + public function loadContentTypes( ContentTypeGroup $contentTypeGroup, array $prioritizedLanguages = [] diff --git a/src/contracts/Repository/Values/ContentType/Query/ContentTypeQuery.php b/src/contracts/Repository/Values/ContentType/Query/ContentTypeQuery.php new file mode 100644 index 0000000000..e2618e1718 --- /dev/null +++ b/src/contracts/Repository/Values/ContentType/Query/ContentTypeQuery.php @@ -0,0 +1,81 @@ +criterion = $criterion; + $this->sortClauses = $sortClauses; + $this->offset = $offset; + $this->limit = $limit; + } + + public function getCriterion(): ?CriterionInterface + { + return $this->criterion; + } + + public function setCriterion(?CriterionInterface $criterion): void + { + $this->criterion = $criterion; + } + + public function addSortClause(SortClause $sortClause): void + { + $this->sortClauses[] = $sortClause; + } + + /** + * @return \Ibexa\Contracts\Core\Repository\Values\ContentType\Query\SortClause[] + */ + public function getSortClauses(): array + { + return $this->sortClauses; + } + + public function getOffset(): int + { + return $this->offset; + } + + public function setOffset(int $offset): void + { + $this->offset = $offset; + } + + public function getLimit(): int + { + return $this->limit; + } + + public function setLimit(int $limit): void + { + $this->limit = $limit; + } +} diff --git a/src/contracts/Repository/Values/ContentType/Query/Criterion/ContainsFieldDefinitionId.php b/src/contracts/Repository/Values/ContentType/Query/Criterion/ContainsFieldDefinitionId.php new file mode 100644 index 0000000000..9cf6dbba98 --- /dev/null +++ b/src/contracts/Repository/Values/ContentType/Query/Criterion/ContainsFieldDefinitionId.php @@ -0,0 +1,41 @@ +|int */ + private $value; + + /** + * @param list|int $value + */ + public function __construct($value) + { + $this->value = $value; + } + + /** + * @return list|int + */ + public function getValue() + { + return $this->value; + } + + /** + * @param list|int $value + */ + public function setValue($value): void + { + $this->value = $value; + } +} diff --git a/src/contracts/Repository/Values/ContentType/Query/Criterion/ContentTypeGroupId.php b/src/contracts/Repository/Values/ContentType/Query/Criterion/ContentTypeGroupId.php new file mode 100644 index 0000000000..97b205c314 --- /dev/null +++ b/src/contracts/Repository/Values/ContentType/Query/Criterion/ContentTypeGroupId.php @@ -0,0 +1,41 @@ +|int */ + private $value; + + /** + * @param list|int $value + */ + public function __construct($value) + { + $this->value = $value; + } + + /** + * @return list|int + */ + public function getValue() + { + return $this->value; + } + + /** + * @param list|int $value + */ + public function setValue($value): void + { + $this->value = $value; + } +} diff --git a/src/contracts/Repository/Values/ContentType/Query/Criterion/ContentTypeId.php b/src/contracts/Repository/Values/ContentType/Query/Criterion/ContentTypeId.php new file mode 100644 index 0000000000..d3d115ad1a --- /dev/null +++ b/src/contracts/Repository/Values/ContentType/Query/Criterion/ContentTypeId.php @@ -0,0 +1,41 @@ +|int */ + private $value; + + /** + * @param list|int $value + */ + public function __construct($value) + { + $this->value = $value; + } + + /** + * @return list|int + */ + public function getValue() + { + return $this->value; + } + + /** + * @param list|int $value + */ + public function setValue($value): void + { + $this->value = $value; + } +} diff --git a/src/contracts/Repository/Values/ContentType/Query/Criterion/ContentTypeIdentifier.php b/src/contracts/Repository/Values/ContentType/Query/Criterion/ContentTypeIdentifier.php new file mode 100644 index 0000000000..a61ab9a20d --- /dev/null +++ b/src/contracts/Repository/Values/ContentType/Query/Criterion/ContentTypeIdentifier.php @@ -0,0 +1,41 @@ +|string */ + private $value; + + /** + * @param list|string $value + */ + public function __construct($value) + { + $this->value = $value; + } + + /** + * @return list|string + */ + public function getValue() + { + return $this->value; + } + + /** + * @param list|string $value + */ + public function setValue($value): void + { + $this->value = $value; + } +} diff --git a/src/contracts/Repository/Values/ContentType/Query/Criterion/IsSystem.php b/src/contracts/Repository/Values/ContentType/Query/Criterion/IsSystem.php new file mode 100644 index 0000000000..d01d77d280 --- /dev/null +++ b/src/contracts/Repository/Values/ContentType/Query/Criterion/IsSystem.php @@ -0,0 +1,31 @@ +value = $value; + } + + public function getValue(): bool + { + return $this->value; + } + + public function setValue(bool $value): void + { + $this->value = $value; + } +} diff --git a/src/contracts/Repository/Values/ContentType/Query/Criterion/LogicalAnd.php b/src/contracts/Repository/Values/ContentType/Query/Criterion/LogicalAnd.php new file mode 100644 index 0000000000..333646fef2 --- /dev/null +++ b/src/contracts/Repository/Values/ContentType/Query/Criterion/LogicalAnd.php @@ -0,0 +1,13 @@ + + */ + private array $criteria = []; + + /** + * @param list<\Ibexa\Contracts\Core\Repository\Values\ContentType\Query\CriterionInterface> $criteria + * + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidCriterionArgumentException + */ + public function __construct(array $criteria) + { + foreach ($criteria as $key => $criterion) { + if (!$criterion instanceof CriterionInterface) { + throw new InvalidCriterionArgumentException($key, $criterion, CriterionInterface::class); + } + + $this->criteria[] = $criterion; + } + } + + /** + * @return list<\Ibexa\Contracts\Core\Repository\Values\ContentType\Query\CriterionInterface> + */ + public function getCriteria(): array + { + return $this->criteria; + } +} diff --git a/src/contracts/Repository/Values/ContentType/Query/Criterion/LogicalOr.php b/src/contracts/Repository/Values/ContentType/Query/Criterion/LogicalOr.php new file mode 100644 index 0000000000..dabb4a763a --- /dev/null +++ b/src/contracts/Repository/Values/ContentType/Query/Criterion/LogicalOr.php @@ -0,0 +1,13 @@ +direction = $sortDirection; + $this->target = $sortTarget; + } +} diff --git a/src/contracts/Repository/Values/ContentType/Query/SortClause/Id.php b/src/contracts/Repository/Values/ContentType/Query/SortClause/Id.php new file mode 100644 index 0000000000..11259b2dec --- /dev/null +++ b/src/contracts/Repository/Values/ContentType/Query/SortClause/Id.php @@ -0,0 +1,19 @@ + + */ +final class SearchResult extends ValueObject implements IteratorAggregate +{ + protected int $totalCount = 0; + + /** @var array */ + protected array $items = []; + + public function getTotalCount(): int + { + return $this->totalCount; + } + + /** + * @return array + */ + public function getContentTypes(): array + { + return $this->items; + } + + /** + * @return \Traversable + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->items); + } +} diff --git a/src/lib/Persistence/Cache/ContentTypeHandler.php b/src/lib/Persistence/Cache/ContentTypeHandler.php index ed24ed5fc7..0152d85647 100644 --- a/src/lib/Persistence/Cache/ContentTypeHandler.php +++ b/src/lib/Persistence/Cache/ContentTypeHandler.php @@ -13,6 +13,7 @@ use Ibexa\Contracts\Core\Persistence\Content\Type\Group\UpdateStruct as GroupUpdateStruct; use Ibexa\Contracts\Core\Persistence\Content\Type\Handler as ContentTypeHandlerInterface; use Ibexa\Contracts\Core\Persistence\Content\Type\UpdateStruct; +use Ibexa\Contracts\Core\Repository\Values\ContentType\Query\ContentTypeQuery; class ContentTypeHandler extends AbstractInMemoryPersistenceHandler implements ContentTypeHandlerInterface { @@ -232,6 +233,15 @@ function () use ($groupId) { ); } + public function findContentTypes(?ContentTypeQuery $query = null): array + { + $this->logger->logCall(__METHOD__, [ + 'query' => $query, + ]); + + return $this->persistenceHandler->contentTypeHandler()->findContentTypes($query); + } + public function loadContentTypeList(array $contentTypeIds): array { return $this->getMultipleCacheValues( diff --git a/src/lib/Persistence/Legacy/Content/Type/Gateway.php b/src/lib/Persistence/Legacy/Content/Type/Gateway.php index f5e9b28a13..5d66ed9ffc 100644 --- a/src/lib/Persistence/Legacy/Content/Type/Gateway.php +++ b/src/lib/Persistence/Legacy/Content/Type/Gateway.php @@ -12,6 +12,7 @@ use Ibexa\Contracts\Core\Persistence\Content\Type\FieldDefinition; use Ibexa\Contracts\Core\Persistence\Content\Type\Group; use Ibexa\Contracts\Core\Persistence\Content\Type\Group\UpdateStruct as GroupUpdateStruct; +use Ibexa\Contracts\Core\Repository\Values\ContentType\Query\ContentTypeQuery; use Ibexa\Core\Persistence\Legacy\Content\StorageFieldDefinition; /** @@ -36,6 +37,8 @@ abstract public function insertGroup(Group $group): int; abstract public function updateGroup(GroupUpdateStruct $group): void; + abstract public function countTypes(): int; + abstract public function countTypesInGroup(int $groupId): int; abstract public function countGroupsForType(int $typeId, int $status): int; @@ -171,6 +174,11 @@ abstract public function removeFieldDefinitionTranslation( * Remove items created or modified by User. */ abstract public function removeByUserAndVersion(int $userId, int $version): void; + + /** + * @return array{items: array>, count: int} + */ + abstract public function findContentTypes(?ContentTypeQuery $query = null): array; } class_alias(Gateway::class, 'eZ\Publish\Core\Persistence\Legacy\Content\Type\Gateway'); diff --git a/src/lib/Persistence/Legacy/Content/Type/Gateway/CriterionHandler/ContainsFieldDefinitionId.php b/src/lib/Persistence/Legacy/Content/Type/Gateway/CriterionHandler/ContainsFieldDefinitionId.php new file mode 100644 index 0000000000..73f3ea3256 --- /dev/null +++ b/src/lib/Persistence/Legacy/Content/Type/Gateway/CriterionHandler/ContainsFieldDefinitionId.php @@ -0,0 +1,49 @@ +joinFieldDefinitions($qb); + + $value = $criterion->getValue(); + if (is_array($value)) { + return $qb->expr()->in( + 'a.id', + $qb->createNamedParameter($criterion->getValue(), Connection::PARAM_INT_ARRAY) + ); + } + + return $qb->expr()->eq( + 'a.id', + $qb->createNamedParameter($criterion->getValue(), ParameterType::INTEGER) + ); + } +} diff --git a/src/lib/Persistence/Legacy/Content/Type/Gateway/CriterionHandler/ContentTypeGroupId.php b/src/lib/Persistence/Legacy/Content/Type/Gateway/CriterionHandler/ContentTypeGroupId.php new file mode 100644 index 0000000000..c05862620d --- /dev/null +++ b/src/lib/Persistence/Legacy/Content/Type/Gateway/CriterionHandler/ContentTypeGroupId.php @@ -0,0 +1,49 @@ +joinContentTypeGroupAssignmentTable($qb); + + $value = $criterion->getValue(); + if (is_array($value)) { + return $qb->expr()->in( + 'g.group_id', + $qb->createNamedParameter($criterion->getValue(), Connection::PARAM_INT_ARRAY) + ); + } + + return $qb->expr()->eq( + 'g.group_id', + $qb->createNamedParameter($criterion->getValue(), ParameterType::INTEGER) + ); + } +} diff --git a/src/lib/Persistence/Legacy/Content/Type/Gateway/CriterionHandler/ContentTypeId.php b/src/lib/Persistence/Legacy/Content/Type/Gateway/CriterionHandler/ContentTypeId.php new file mode 100644 index 0000000000..45ab537acd --- /dev/null +++ b/src/lib/Persistence/Legacy/Content/Type/Gateway/CriterionHandler/ContentTypeId.php @@ -0,0 +1,47 @@ +getValue(); + if (is_array($value)) { + return $qb->expr()->in( + 'c.id', + $qb->createNamedParameter($criterion->getValue(), Connection::PARAM_INT_ARRAY) + ); + } + + return $qb->expr()->eq( + 'c.id', + $qb->createNamedParameter($criterion->getValue(), ParameterType::INTEGER) + ); + } +} diff --git a/src/lib/Persistence/Legacy/Content/Type/Gateway/CriterionHandler/ContentTypeIdentifier.php b/src/lib/Persistence/Legacy/Content/Type/Gateway/CriterionHandler/ContentTypeIdentifier.php new file mode 100644 index 0000000000..71fbb17b6d --- /dev/null +++ b/src/lib/Persistence/Legacy/Content/Type/Gateway/CriterionHandler/ContentTypeIdentifier.php @@ -0,0 +1,47 @@ +getValue(); + if (is_array($value)) { + return $qb->expr()->in( + 'c.identifier', + $qb->createNamedParameter($criterion->getValue(), Connection::PARAM_STR_ARRAY) + ); + } + + return $qb->expr()->eq( + 'c.identifier', + $qb->createNamedParameter($criterion->getValue(), ParameterType::STRING) + ); + } +} diff --git a/src/lib/Persistence/Legacy/Content/Type/Gateway/CriterionHandler/IsSystem.php b/src/lib/Persistence/Legacy/Content/Type/Gateway/CriterionHandler/IsSystem.php new file mode 100644 index 0000000000..6fe2f31408 --- /dev/null +++ b/src/lib/Persistence/Legacy/Content/Type/Gateway/CriterionHandler/IsSystem.php @@ -0,0 +1,41 @@ +joinContentTypeGroupAssignmentTable($qb); + $this->joinContentTypeGroup($qb); + + return $qb->expr()->eq( + 'ctg.is_system', + $qb->createNamedParameter($criterion->getValue(), ParameterType::BOOLEAN) + ); + } +} diff --git a/src/lib/Persistence/Legacy/Content/Type/Gateway/CriterionHandler/LogicalAnd.php b/src/lib/Persistence/Legacy/Content/Type/Gateway/CriterionHandler/LogicalAnd.php new file mode 100644 index 0000000000..fbfd70bc4a --- /dev/null +++ b/src/lib/Persistence/Legacy/Content/Type/Gateway/CriterionHandler/LogicalAnd.php @@ -0,0 +1,42 @@ +getCriteria() as $subCriterion) { + $subexpressions[] = $criterionVisitor->visitCriteria($qb, $subCriterion); + } + + return $qb->expr()->and(...$subexpressions); + } +} diff --git a/src/lib/Persistence/Legacy/Content/Type/Gateway/CriterionHandler/LogicalNot.php b/src/lib/Persistence/Legacy/Content/Type/Gateway/CriterionHandler/LogicalNot.php new file mode 100644 index 0000000000..0ba8587270 --- /dev/null +++ b/src/lib/Persistence/Legacy/Content/Type/Gateway/CriterionHandler/LogicalNot.php @@ -0,0 +1,43 @@ +getCriteria())) { + return ''; + } + + return sprintf( + 'NOT (%s)', + $criterionVisitor->visitCriteria($qb, $criterion->getCriteria()[0]), + ); + } +} diff --git a/src/lib/Persistence/Legacy/Content/Type/Gateway/CriterionHandler/LogicalOr.php b/src/lib/Persistence/Legacy/Content/Type/Gateway/CriterionHandler/LogicalOr.php new file mode 100644 index 0000000000..314f5d2e2e --- /dev/null +++ b/src/lib/Persistence/Legacy/Content/Type/Gateway/CriterionHandler/LogicalOr.php @@ -0,0 +1,42 @@ +getCriteria() as $subCriterion) { + $subexpressions[] = $criterionVisitor->visitCriteria($qb, $subCriterion); + } + + return $qb->expr()->or(...$subexpressions); + } +} diff --git a/src/lib/Persistence/Legacy/Content/Type/Gateway/CriterionVisitor/CriterionVisitor.php b/src/lib/Persistence/Legacy/Content/Type/Gateway/CriterionVisitor/CriterionVisitor.php new file mode 100644 index 0000000000..c1ccf84213 --- /dev/null +++ b/src/lib/Persistence/Legacy/Content/Type/Gateway/CriterionVisitor/CriterionVisitor.php @@ -0,0 +1,57 @@ +> + */ + private array $criterionHandlers; + + /** + * @param iterable<\Ibexa\Contracts\Core\Persistence\Content\Type\CriterionHandlerInterface<\Ibexa\Contracts\Core\Repository\Values\ContentType\Query\CriterionInterface>> $criterionHandlers + */ + public function __construct(iterable $criterionHandlers) + { + $this->criterionHandlers = iterator_to_array($criterionHandlers); + } + + /** + * @return \Doctrine\DBAL\Query\Expression\CompositeExpression|string + * + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotImplementedException if there's no builder for a criterion + */ + public function visitCriteria( + QueryBuilder $queryBuilder, + CriterionInterface $criterion + ) { + foreach ($this->criterionHandlers as $criterionHandler) { + if ($criterionHandler->supports($criterion)) { + return $criterionHandler->apply( + $this, + $queryBuilder, + $criterion + ); + } + } + + throw new NotImplementedException( + sprintf( + 'There is no Criterion Handler for %s Criterion', + get_class($criterion) + ) + ); + } +} diff --git a/src/lib/Persistence/Legacy/Content/Type/Gateway/DoctrineDatabase.php b/src/lib/Persistence/Legacy/Content/Type/Gateway/DoctrineDatabase.php index 83b40160f6..564681873b 100644 --- a/src/lib/Persistence/Legacy/Content/Type/Gateway/DoctrineDatabase.php +++ b/src/lib/Persistence/Legacy/Content/Type/Gateway/DoctrineDatabase.php @@ -17,6 +17,9 @@ use Ibexa\Contracts\Core\Persistence\Content\Type\FieldDefinition; use Ibexa\Contracts\Core\Persistence\Content\Type\Group; use Ibexa\Contracts\Core\Persistence\Content\Type\Group\UpdateStruct as GroupUpdateStruct; +use Ibexa\Contracts\Core\Repository\Values\ContentType\Query\ContentTypeQuery; +use Ibexa\Contracts\Core\Repository\Values\URL\Query\SortClause; +use Ibexa\Core\Base\Exceptions\InvalidArgumentException; use Ibexa\Core\Base\Exceptions\NotFoundException; use Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator; use Ibexa\Core\Persistence\Legacy\Content\MultilingualStorageFieldDefinition; @@ -34,6 +37,11 @@ */ final class DoctrineDatabase extends Gateway { + private const SORT_DIRECTION_MAP = [ + SortClause::SORT_ASC => 'ASC', + SortClause::SORT_DESC => 'DESC', + ]; + /** * Columns of database tables. * @@ -113,18 +121,22 @@ final class DoctrineDatabase extends Gateway */ private $languageMaskGenerator; + private Gateway\CriterionVisitor\CriterionVisitor $criterionVisitor; + /** * @throws \Doctrine\DBAL\DBALException */ public function __construct( Connection $connection, SharedGateway $sharedGateway, - MaskGenerator $languageMaskGenerator + MaskGenerator $languageMaskGenerator, + Gateway\CriterionVisitor\CriterionVisitor $criterionVisitor ) { $this->connection = $connection; $this->dbPlatform = $connection->getDatabasePlatform(); $this->sharedGateway = $sharedGateway; $this->languageMaskGenerator = $languageMaskGenerator; + $this->criterionVisitor = $criterionVisitor; } public function insertGroup(Group $group): int @@ -195,6 +207,16 @@ public function updateGroup(GroupUpdateStruct $group): void $query->execute(); } + public function countTypes(): int + { + $query = $this->connection->createQueryBuilder(); + $query + ->select($this->dbPlatform->getCountExpression('id')) + ->from(self::CONTENT_TYPE_TABLE); + + return (int)$query->execute()->fetchOne(); + } + public function countTypesInGroup(int $groupId): int { $query = $this->connection->createQueryBuilder(); @@ -1405,6 +1427,66 @@ public function removeByUserAndVersion(int $userId, int $version): void } } + public function findContentTypes(?ContentTypeQuery $query = null): array + { + $totalCount = $this->countTypes(); + if ($totalCount === 0) { + return [ + 'count' => $totalCount, + 'items' => [], + ]; + } + + $queryBuilder = $this->getLoadTypeQueryBuilder(); + + if ($query === null) { + $queryBuilder->setMaxResults(ContentTypeQuery::DEFAULT_LIMIT); + + return [ + 'count' => $totalCount, + 'items' => $queryBuilder->execute()->fetchAllAssociative(), + ]; + } + + if (!empty($query->getCriterion())) { + $queryBuilder->andWhere($this->criterionVisitor->visitCriteria($queryBuilder, $query->getCriterion())); + } + + if ($query->getOffset() > 0) { + $queryBuilder->setFirstResult($query->getOffset()); + } + + $queryBuilder->setMaxResults($query->getLimit() > 0 ? $query->getLimit() : null); + + foreach ($query->getSortClauses() as $sortClause) { + $column = sprintf('c.%s', $sortClause->target); + $queryBuilder->addOrderBy($column, $this->getQuerySortingDirection($sortClause->direction)); + } + + return [ + 'count' => $totalCount, + 'items' => $queryBuilder->execute()->fetchAllAssociative(), + ]; + } + + /** + * @throws \Ibexa\Core\Base\Exceptions\InvalidArgumentException + */ + private function getQuerySortingDirection(string $direction): string + { + if (!isset(self::SORT_DIRECTION_MAP[$direction])) { + throw new InvalidArgumentException( + '$sortClause->direction', + sprintf( + 'Unsupported "%s" sorting directions, use one of the SortClause::SORT_* constants instead', + $direction + ) + ); + } + + return self::SORT_DIRECTION_MAP[$direction]; + } + /** * @throws \Doctrine\DBAL\DBALException */ diff --git a/src/lib/Persistence/Legacy/Content/Type/Gateway/ExceptionConversion.php b/src/lib/Persistence/Legacy/Content/Type/Gateway/ExceptionConversion.php index fd6229651d..75de6188a1 100644 --- a/src/lib/Persistence/Legacy/Content/Type/Gateway/ExceptionConversion.php +++ b/src/lib/Persistence/Legacy/Content/Type/Gateway/ExceptionConversion.php @@ -13,6 +13,7 @@ use Ibexa\Contracts\Core\Persistence\Content\Type\FieldDefinition; use Ibexa\Contracts\Core\Persistence\Content\Type\Group; use Ibexa\Contracts\Core\Persistence\Content\Type\Group\UpdateStruct as GroupUpdateStruct; +use Ibexa\Contracts\Core\Repository\Values\ContentType\Query\ContentTypeQuery; use Ibexa\Core\Base\Exceptions\DatabaseException; use Ibexa\Core\Persistence\Legacy\Content\StorageFieldDefinition; use Ibexa\Core\Persistence\Legacy\Content\Type\Gateway; @@ -58,6 +59,15 @@ public function updateGroup(GroupUpdateStruct $group): void } } + public function countTypes(): int + { + try { + return $this->innerGateway->countTypes(); + } catch (DBALException | PDOException $e) { + throw DatabaseException::wrap($e); + } + } + public function countTypesInGroup(int $groupId): int { try { @@ -341,6 +351,15 @@ public function removeByUserAndVersion(int $userId, int $version): void throw DatabaseException::wrap($e); } } + + public function findContentTypes(?ContentTypeQuery $query = null): array + { + try { + return $this->innerGateway->findContentTypes($query); + } catch (DBALException | PDOException $e) { + throw DatabaseException::wrap($e); + } + } } class_alias(ExceptionConversion::class, 'eZ\Publish\Core\Persistence\Legacy\Content\Type\Gateway\ExceptionConversion'); diff --git a/src/lib/Persistence/Legacy/Content/Type/Handler.php b/src/lib/Persistence/Legacy/Content/Type/Handler.php index 88abfb59ef..09ee1bbd5b 100644 --- a/src/lib/Persistence/Legacy/Content/Type/Handler.php +++ b/src/lib/Persistence/Legacy/Content/Type/Handler.php @@ -14,6 +14,7 @@ use Ibexa\Contracts\Core\Persistence\Content\Type\Group\UpdateStruct as GroupUpdateStruct; use Ibexa\Contracts\Core\Persistence\Content\Type\Handler as BaseContentTypeHandler; use Ibexa\Contracts\Core\Persistence\Content\Type\UpdateStruct; +use Ibexa\Contracts\Core\Repository\Values\ContentType\Query\ContentTypeQuery; use Ibexa\Core\Base\Exceptions\BadStateException; use Ibexa\Core\Base\Exceptions\InvalidArgumentException; use Ibexa\Core\Base\Exceptions\NotFoundException; @@ -195,6 +196,20 @@ public function loadContentTypeList(array $contentTypeIds): array ); } + public function findContentTypes(?ContentTypeQuery $query = null): array + { + $rows = $this->contentTypeGateway->findContentTypes($query); + $items = $this->mapper->extractTypesFromRows( + $rows['items'], + true + ); + + return [ + 'count' => $rows['count'], + 'items' => $items, + ]; + } + /** * @return \Ibexa\Contracts\Core\Persistence\Content\Type[] */ diff --git a/src/lib/Persistence/Legacy/Content/Type/MemoryCachingHandler.php b/src/lib/Persistence/Legacy/Content/Type/MemoryCachingHandler.php index 2d9ce28969..20be44ac1b 100644 --- a/src/lib/Persistence/Legacy/Content/Type/MemoryCachingHandler.php +++ b/src/lib/Persistence/Legacy/Content/Type/MemoryCachingHandler.php @@ -14,6 +14,7 @@ use Ibexa\Contracts\Core\Persistence\Content\Type\Group\UpdateStruct as GroupUpdateStruct; use Ibexa\Contracts\Core\Persistence\Content\Type\Handler as BaseContentTypeHandler; use Ibexa\Contracts\Core\Persistence\Content\Type\UpdateStruct; +use Ibexa\Contracts\Core\Repository\Values\ContentType\Query\ContentTypeQuery; use Ibexa\Core\Persistence\Cache\Identifier\CacheIdentifierGeneratorInterface; use Ibexa\Core\Persistence\Cache\InMemory\InMemoryCache; @@ -171,6 +172,11 @@ public function loadContentTypes($groupId, $status = Type::STATUS_DEFINED): arra return $types; } + public function findContentTypes(?ContentTypeQuery $query = null): array + { + return $this->innerHandler->findContentTypes($query); + } + /** * @return \Ibexa\Contracts\Core\Persistence\Content\Type[] */ diff --git a/src/lib/Repository/ContentTypeService.php b/src/lib/Repository/ContentTypeService.php index 9b032df83b..e917d3aa6f 100644 --- a/src/lib/Repository/ContentTypeService.php +++ b/src/lib/Repository/ContentTypeService.php @@ -34,6 +34,8 @@ use Ibexa\Contracts\Core\Repository\Values\ContentType\FieldDefinition as APIFieldDefinition; use Ibexa\Contracts\Core\Repository\Values\ContentType\FieldDefinitionCreateStruct; use Ibexa\Contracts\Core\Repository\Values\ContentType\FieldDefinitionUpdateStruct; +use Ibexa\Contracts\Core\Repository\Values\ContentType\Query\ContentTypeQuery; +use Ibexa\Contracts\Core\Repository\Values\ContentType\SearchResult; use Ibexa\Contracts\Core\Repository\Values\User\User; use Ibexa\Core\Base\Exceptions\BadStateException; use Ibexa\Core\Base\Exceptions\ContentTypeFieldDefinitionValidationException; @@ -917,14 +919,10 @@ public function loadContentTypeDraft(int $contentTypeId, bool $ignoreOwnership = return $this->contentTypeDomainMapper->buildContentTypeDraftDomainObject($spiContentType); } - /** - * {@inheritdoc} - */ public function loadContentTypeList(array $contentTypeIds, array $prioritizedLanguages = []): iterable { $spiContentTypes = $this->contentTypeHandler->loadContentTypeList($contentTypeIds); $contentTypes = []; - // @todo We could bulk load content type group proxies involved in the future & pass those relevant per type to mapper foreach ($spiContentTypes as $spiContentType) { $contentTypes[$spiContentType->id] = $this->contentTypeDomainMapper->buildContentTypeDomainObject( @@ -936,6 +934,24 @@ public function loadContentTypeList(array $contentTypeIds, array $prioritizedLan return $contentTypes; } + public function findContentTypes(?ContentTypeQuery $query = null, array $prioritizedLanguages = []): SearchResult + { + $results = $this->contentTypeHandler->findContentTypes($query); + + $items = []; + foreach ($results['items'] as $persistenceContentType) { + $items[] = $this->contentTypeDomainMapper->buildContentTypeDomainObject( + $persistenceContentType, + $prioritizedLanguages + ); + } + + return new SearchResult([ + 'totalCount' => $results['count'], + 'items' => $items, + ]); + } + /** * {@inheritdoc} */ diff --git a/src/lib/Repository/SiteAccessAware/ContentTypeService.php b/src/lib/Repository/SiteAccessAware/ContentTypeService.php index 1c3c99225e..c2348f5032 100644 --- a/src/lib/Repository/SiteAccessAware/ContentTypeService.php +++ b/src/lib/Repository/SiteAccessAware/ContentTypeService.php @@ -18,6 +18,8 @@ use Ibexa\Contracts\Core\Repository\Values\ContentType\FieldDefinition; use Ibexa\Contracts\Core\Repository\Values\ContentType\FieldDefinitionCreateStruct; use Ibexa\Contracts\Core\Repository\Values\ContentType\FieldDefinitionUpdateStruct; +use Ibexa\Contracts\Core\Repository\Values\ContentType\Query\ContentTypeQuery; +use Ibexa\Contracts\Core\Repository\Values\ContentType\SearchResult; use Ibexa\Contracts\Core\Repository\Values\User\User; /** @@ -119,6 +121,11 @@ public function loadContentTypeList(array $contentTypeIds, ?array $prioritizedLa return $this->service->loadContentTypeList($contentTypeIds, $prioritizedLanguages); } + public function findContentTypes(?ContentTypeQuery $query = null, array $prioritizedLanguages = []): SearchResult + { + return $this->service->findContentTypes($query, $prioritizedLanguages); + } + public function loadContentTypes(ContentTypeGroup $contentTypeGroup, ?array $prioritizedLanguages = null): iterable { $prioritizedLanguages = $this->languageResolver->getPrioritizedLanguages($prioritizedLanguages); diff --git a/src/lib/Repository/Values/ContentType/Query/Base.php b/src/lib/Repository/Values/ContentType/Query/Base.php new file mode 100644 index 0000000000..4d395aab86 --- /dev/null +++ b/src/lib/Repository/Values/ContentType/Query/Base.php @@ -0,0 +1,89 @@ + + */ +abstract class Base implements CriterionHandlerInterface +{ + /** + * Inner join the `ezcontentclassgroup` table if not joined yet. + */ + protected function joinContentTypeGroup(QueryBuilder $query): void + { + if (!$this->hasJoinedTable($query, Gateway::CONTENT_TYPE_GROUP_TABLE)) { + $query->innerJoin( + 'g', + Gateway::CONTENT_TYPE_GROUP_TABLE, + 'ctg', + 'g.contentclass_id = ctg.id' + ); + } + } + + /** + * Inner join the `ezcontentclass_attribute` table if not joined yet. + */ + protected function joinFieldDefinitions(QueryBuilder $query): void + { + if (!$this->hasJoinedTable($query, Gateway::FIELD_DEFINITION_TABLE)) { + $expr = $query->expr(); + + $query->leftJoin( + 'c', + Gateway::FIELD_DEFINITION_TABLE, + 'a', + (string)$expr->and( + 'c.id = a.contentclass_id', + 'c.version = a.version' + ) + ); + } + } + + /** + * Inner join the `ezcontentclass_classgroup` table if not joined yet. + */ + protected function joinContentTypeGroupAssignmentTable(QueryBuilder $query): void + { + if (!$this->hasJoinedTable($query, Gateway::CONTENT_TYPE_TO_GROUP_ASSIGNMENT_TABLE)) { + $expr = $query->expr(); + + $query->leftJoin( + 'c', + Gateway::CONTENT_TYPE_TO_GROUP_ASSIGNMENT_TABLE, + 'g', + (string)$expr->and( + 'c.id = g.contentclass_id', + 'c.version = g.contentclass_version', + ) + ); + } + } + + protected function hasJoinedTable(QueryBuilder $queryBuilder, string $tableName): bool + { + // find table name in a structure: ['fromAlias' => [['joinTable' => ''], ...]] + $joinedParts = $queryBuilder->getQueryPart('join'); + foreach ($joinedParts as $joinedTables) { + foreach ($joinedTables as $join) { + if ($join['joinTable'] === $tableName) { + return true; + } + } + } + + return false; + } +} diff --git a/src/lib/Resources/settings/storage_engines/legacy/content_type.yml b/src/lib/Resources/settings/storage_engines/legacy/content_type.yml index a4c2d40a77..b98b1ed5bc 100644 --- a/src/lib/Resources/settings/storage_engines/legacy/content_type.yml +++ b/src/lib/Resources/settings/storage_engines/legacy/content_type.yml @@ -1,10 +1,22 @@ services: + _instanceof: + Ibexa\Contracts\Core\Persistence\Content\Type\CriterionHandlerInterface: + tags: [ 'ibexa.content_type.criterion_handler' ] + + Ibexa\Core\Persistence\Legacy\Content\Type\Gateway\CriterionVisitor\CriterionVisitor: + arguments: + $criterionHandlers: !tagged_iterator 'ibexa.content_type.criterion_handler' + + Ibexa\Core\Persistence\Legacy\Content\Type\Gateway\CriterionHandler\: + resource: '../../../../Persistence/Legacy/Content/Type/Gateway/CriterionHandler/*' + Ibexa\Core\Persistence\Legacy\Content\Type\Gateway\DoctrineDatabase.inner: class: Ibexa\Core\Persistence\Legacy\Content\Type\Gateway\DoctrineDatabase arguments: - - '@ibexa.api.storage_engine.legacy.connection' - - '@Ibexa\Core\Persistence\Legacy\SharedGateway\Gateway' - - '@Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator' + $connection: '@ibexa.api.storage_engine.legacy.connection' + $sharedGateway: '@Ibexa\Core\Persistence\Legacy\SharedGateway\Gateway' + $languageMaskGenerator: '@Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator' + $criterionVisitor: '@Ibexa\Core\Persistence\Legacy\Content\Type\Gateway\CriterionVisitor\CriterionVisitor' Ibexa\Core\Persistence\Legacy\Content\Type\Gateway\ExceptionConversion: class: Ibexa\Core\Persistence\Legacy\Content\Type\Gateway\ExceptionConversion diff --git a/tests/integration/Core/Repository/ContentTypeService/FindContentTypesTest.php b/tests/integration/Core/Repository/ContentTypeService/FindContentTypesTest.php new file mode 100644 index 0000000000..fdc849928d --- /dev/null +++ b/tests/integration/Core/Repository/ContentTypeService/FindContentTypesTest.php @@ -0,0 +1,252 @@ +findContentTypes(); + + self::assertCount(25, $contentTypes); + } + + /** + * @param list $expectedIdentifiers + * + * @dataProvider dataProviderForTestFindContentTypes + */ + public function testFindContentTypes(ContentTypeQuery $query, array $expectedIdentifiers): void + { + $contentTypeService = self::getContentTypeService(); + + $expectedCount = $contentTypeService->findContentTypes( + new ContentTypeQuery( + null, + [], + 0, + 0, + ) + ); + $expectedCount = count($expectedCount->getContentTypes()); + + $contentTypes = $contentTypeService->findContentTypes($query); + + $identifiers = array_map( + static fn (ContentType $contentType): string => $contentType->getIdentifier(), + $contentTypes->getContentTypes(), + ); + + self::assertCount(count($expectedIdentifiers), $identifiers); + self::assertEqualsCanonicalizing($expectedIdentifiers, $identifiers); + self::assertSame($expectedCount, $contentTypes->getTotalCount()); + } + + public function testFindContentTypesAscSortedByIdentifier(): void + { + $contentTypeService = self::getContentTypeService(); + + $contentTypes = $contentTypeService->findContentTypes( + new ContentTypeQuery( + new ContentTypeIdentifier(['folder', 'article', 'user', 'file']), + [new Identifier()] + ), + ); + $identifiers = array_map( + static fn (ContentType $contentType): string => $contentType->getIdentifier(), + $contentTypes->getContentTypes() + ); + + self::assertCount(4, $identifiers); + self::assertSame(['article', 'file', 'folder', 'user'], $identifiers); + } + + public function testPagination(): void + { + $contentTypeService = self::getContentTypeService(); + + $collectedContentTypeIDs = []; + $pageSize = 10; + $noOfPages = 3; + + for ($page = 1; $page <= $noOfPages; ++$page) { + $offset = ($page - 1) * $pageSize; + $searchResult = $contentTypeService->findContentTypes( + new ContentTypeQuery(null, [new Identifier()], $offset, $pageSize), + ); + + // an actual number of items on a current page + self::assertCount($pageSize, $searchResult); + + // check if results are not duplicated across multiple pages + foreach ($searchResult->getContentTypes() as $contentType) { + self::assertNotContains( + $contentType->getIdentifier(), + $collectedContentTypeIDs, + "Content type '{$contentType->getIdentifier()}' exists on multiple pages" + ); + $collectedContentTypeIDs[] = $contentType->getIdentifier(); + } + } + } + + public function testFindContentTypesContainingFieldDefinitions(): void + { + $contentTypeService = self::getContentTypeService(); + $folderContentType = $contentTypeService->loadContentTypeByIdentifier('folder'); + + $fieldDefinitionToInclude = null; + foreach ($folderContentType->getFieldDefinitions() as $fieldDefinition) { + if ($fieldDefinition->getIdentifier() === 'short_name') { + $fieldDefinitionToInclude = $fieldDefinition; + } + } + + assert($fieldDefinitionToInclude !== null); + + $contentTypes = $contentTypeService->findContentTypes( + new ContentTypeQuery( + new ContainsFieldDefinitionId([$fieldDefinitionToInclude->getId()]), + ) + ); + + self::assertCount(1, $contentTypes); + self::assertSame('folder', $contentTypes->getContentTypes()[0]->getIdentifier()); + } + + /** + * @return iterable}> + */ + public function dataProviderForTestFindContentTypes(): iterable + { + yield 'identifiers' => [ + new ContentTypeQuery( + new ContentTypeIdentifier(['folder', 'article']), + ), + ['article', 'folder'], + ]; + + yield 'single identifier' => [ + new ContentTypeQuery( + new ContentTypeIdentifier('folder'), + ), + ['folder'], + ]; + + yield 'user group' => [ + new ContentTypeQuery( + new ContentTypeGroupId([2]), + ), + ['user', 'user_group'], + ]; + + yield 'single user group' => [ + new ContentTypeQuery( + new ContentTypeGroupId(2), + ), + ['user', 'user_group'], + ]; + + yield 'ids' => [ + new ContentTypeQuery( + new ContentTypeId([1]), + ), + ['folder'], + ]; + + yield 'single id' => [ + new ContentTypeQuery( + new ContentTypeId(1), + ), + ['folder'], + ]; + + yield 'system group' => [ + new ContentTypeQuery( + new IsSystem(false), + ), + ['folder', 'user', 'user_group'], + ]; + + yield 'logical and' => [ + new ContentTypeQuery( + new LogicalAnd([ + new ContentTypeIdentifier(['folder', 'article']), + new ContentTypeGroupId([1]), + ]), + ), + ['folder', 'article'], + ]; + + yield 'logical or' => [ + new ContentTypeQuery( + new LogicalOr([ + new ContentTypeIdentifier(['folder', 'article']), + new ContentTypeGroupId([2]), + ]), + ), + ['folder', 'article', 'user', 'user_group'], + ]; + + yield 'logical not resulting in empty set' => [ + new ContentTypeQuery( + new LogicalAnd([ + new LogicalNot([ + new ContentTypeIdentifier(['user', 'user_group']), + ]), + new ContentTypeGroupId([2]), + ]), + ), + [], + ]; + + yield 'logical not' => [ + new ContentTypeQuery( + new LogicalAnd([ + new LogicalNot([ + new ContentTypeIdentifier(['user']), + ]), + new ContentTypeGroupId([2]), + ]), + ), + ['user_group'], + ]; + + yield 'logical or outside with logical and inside' => [ + new ContentTypeQuery( + new LogicalOr([ + new LogicalAnd([ + new ContentTypeIdentifier(['folder', 'article']), + new ContentTypeGroupId([1]), + ]), + new ContentTypeIdentifier(['user']), + ]), + ), + ['folder', 'article', 'user'], + ]; + } +} diff --git a/tests/lib/Persistence/Legacy/Content/LanguageAwareTestCase.php b/tests/lib/Persistence/Legacy/Content/LanguageAwareTestCase.php index 02d95b6425..8ed932e875 100644 --- a/tests/lib/Persistence/Legacy/Content/LanguageAwareTestCase.php +++ b/tests/lib/Persistence/Legacy/Content/LanguageAwareTestCase.php @@ -8,6 +8,7 @@ use Ibexa\Core\Persistence; use Ibexa\Core\Persistence\Legacy\Content\Language\MaskGenerator as LanguageMaskGenerator; +use Ibexa\Core\Persistence\Legacy\Content\Type\Gateway\CriterionVisitor\CriterionVisitor; use Ibexa\Core\Search\Common\FieldNameGenerator; use Ibexa\Core\Search\Common\FieldRegistry; use Ibexa\Core\Search\Legacy\Content\Mapper\FullTextMapper; @@ -34,6 +35,8 @@ abstract class LanguageAwareTestCase extends TestCase */ protected $languageMaskGenerator; + protected CriterionVisitor $criterionVisitor; + /** * Returns a language handler mock. * @@ -64,6 +67,18 @@ protected function getLanguageMaskGenerator() return $this->languageMaskGenerator; } + /** + * Returns the criterion visitor. + */ + protected function getCriterionVisitor(): CriterionVisitor + { + if (!isset($this->criterionVisitor)) { + $this->criterionVisitor = new CriterionVisitor([]); + } + + return $this->criterionVisitor; + } + /** * Return definition-based transformation processor instance. * diff --git a/tests/lib/Persistence/Legacy/Content/Type/Gateway/DoctrineDatabaseTest.php b/tests/lib/Persistence/Legacy/Content/Type/Gateway/DoctrineDatabaseTest.php index f06999a73e..8a9a807711 100644 --- a/tests/lib/Persistence/Legacy/Content/Type/Gateway/DoctrineDatabaseTest.php +++ b/tests/lib/Persistence/Legacy/Content/Type/Gateway/DoctrineDatabaseTest.php @@ -1153,7 +1153,8 @@ protected function getGateway(): DoctrineDatabase $this->gateway = new DoctrineDatabase( $this->getDatabaseConnection(), $this->getSharedGateway(), - $this->getLanguageMaskGenerator() + $this->getLanguageMaskGenerator(), + $this->getCriterionVisitor() ); } diff --git a/tests/lib/Repository/SiteAccessAware/ContentTypeServiceTest.php b/tests/lib/Repository/SiteAccessAware/ContentTypeServiceTest.php index 5701ffb0db..0ceeeafef1 100644 --- a/tests/lib/Repository/SiteAccessAware/ContentTypeServiceTest.php +++ b/tests/lib/Repository/SiteAccessAware/ContentTypeServiceTest.php @@ -12,6 +12,8 @@ use Ibexa\Contracts\Core\Repository\Values\ContentType\ContentTypeUpdateStruct; use Ibexa\Contracts\Core\Repository\Values\ContentType\FieldDefinitionCreateStruct; use Ibexa\Contracts\Core\Repository\Values\ContentType\FieldDefinitionUpdateStruct; +use Ibexa\Contracts\Core\Repository\Values\ContentType\Query\ContentTypeQuery; +use Ibexa\Contracts\Core\Repository\Values\ContentType\SearchResult; use Ibexa\Core\Repository\SiteAccessAware\ContentTypeService; use Ibexa\Core\Repository\Values\ContentType\ContentType; use Ibexa\Core\Repository\Values\ContentType\ContentTypeCreateStruct; @@ -99,6 +101,8 @@ public function providerForPassTroughMethods() ['removeContentTypeTranslation', [$contentTypeDraft, 'ger-DE'], $contentTypeDraft], ['deleteUserDrafts', [14], null], + + ['findContentTypes', [new ContentTypeQuery()], new SearchResult()], ]; } diff --git a/tests/lib/Search/Legacy/Content/AbstractTestCase.php b/tests/lib/Search/Legacy/Content/AbstractTestCase.php index 0ae8a3bf37..6ce356fa7b 100644 --- a/tests/lib/Search/Legacy/Content/AbstractTestCase.php +++ b/tests/lib/Search/Legacy/Content/AbstractTestCase.php @@ -89,6 +89,7 @@ protected function getContentTypeHandler(): SPIContentTypeHandler $this->getDatabaseConnection(), $this->getSharedGateway(), $this->getLanguageMaskGenerator(), + $this->getCriterionVisitor() ), new ContentTypeMapper( $this->getConverterRegistry(),