diff --git a/eZ/Publish/API/Repository/LocationService.php b/eZ/Publish/API/Repository/LocationService.php index 9d032d6f235..e9035782bb8 100644 --- a/eZ/Publish/API/Repository/LocationService.php +++ b/eZ/Publish/API/Repository/LocationService.php @@ -44,10 +44,11 @@ public function copySubtree(Location $subtree, Location $targetParentLocation); * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException If the specified location is not found * * @param mixed $locationId + * @param string[]|null $prioritizedLanguages Used as prioritized language code on translated properties of returned object. * * @return \eZ\Publish\API\Repository\Values\Content\Location */ - public function loadLocation($locationId); + public function loadLocation($locationId, array $prioritizedLanguages = null); /** * Loads a location object from its $remoteId. @@ -56,10 +57,11 @@ public function loadLocation($locationId); * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException If the specified location is not found * * @param string $remoteId + * @param string[]|null $prioritizedLanguages Used as prioritized language code on translated properties of returned object. * * @return \eZ\Publish\API\Repository\Values\Content\Location */ - public function loadLocationByRemoteId($remoteId); + public function loadLocationByRemoteId($remoteId, array $prioritizedLanguages = null); /** * Loads the locations for the given content object. @@ -71,10 +73,11 @@ public function loadLocationByRemoteId($remoteId); * * @param \eZ\Publish\API\Repository\Values\Content\ContentInfo $contentInfo * @param \eZ\Publish\API\Repository\Values\Content\Location $rootLocation + * @param string[]|null $prioritizedLanguages Used as prioritized language code on translated properties of returned object. * * @return \eZ\Publish\API\Repository\Values\Content\Location[] An array of {@link Location} */ - public function loadLocations(ContentInfo $contentInfo, Location $rootLocation = null); + public function loadLocations(ContentInfo $contentInfo, Location $rootLocation = null, array $prioritizedLanguages = null); /** * Loads children which are readable by the current user of a location object sorted by sortField and sortOrder. @@ -82,19 +85,21 @@ public function loadLocations(ContentInfo $contentInfo, Location $rootLocation = * @param \eZ\Publish\API\Repository\Values\Content\Location $location * @param int $offset the start offset for paging * @param int $limit the number of locations returned + * @param string[]|null $prioritizedLanguages Used as prioritized language code on translated properties of returned object. * * @return \eZ\Publish\API\Repository\Values\Content\LocationList */ - public function loadLocationChildren(Location $location, $offset = 0, $limit = 25); + public function loadLocationChildren(Location $location, $offset = 0, $limit = 25, array $prioritizedLanguages = null); /** * Load parent Locations for Content Draft. * * @param \eZ\Publish\API\Repository\Values\Content\VersionInfo $versionInfo + * @param string[]|null $prioritizedLanguages Used as prioritized language code on translated properties of returned object. * * @return \eZ\Publish\API\Repository\Values\Content\Location[] List of parent Locations */ - public function loadParentLocationsForDraftContent(VersionInfo $versionInfo); + public function loadParentLocationsForDraftContent(VersionInfo $versionInfo, array $prioritizedLanguages = null); /** * Returns the number of children which are readable by the current user of a location object. diff --git a/eZ/Publish/API/Repository/Tests/LocationServiceTest.php b/eZ/Publish/API/Repository/Tests/LocationServiceTest.php index a7c70b01483..3095337924c 100644 --- a/eZ/Publish/API/Repository/Tests/LocationServiceTest.php +++ b/eZ/Publish/API/Repository/Tests/LocationServiceTest.php @@ -464,6 +464,53 @@ public function testLoadLocationStructValues(Location $location) $location->contentInfo ); $this->assertEquals($this->generateId('object', 4), $location->contentInfo->id); + + // Check lazy loaded proxy on ->content + $this->assertInstanceOf( + Content::class, + $content = $location->getContent() + ); + $this->assertEquals(4, $content->contentInfo->id); + } + + public function testLoadLocationPrioritizedLanguagesFallback() + { + $repository = $this->getRepository(); + + // Add a language + $languageService = $repository->getContentLanguageService(); + $languageStruct = $languageService->newLanguageCreateStruct(); + $languageStruct->name = 'Norsk'; + $languageStruct->languageCode = 'nor-NO'; + $languageService->createLanguage($languageStruct); + + $locationService = $repository->getLocationService(); + $contentService = $repository->getContentService(); + $location = $locationService->loadLocation(5); + + // Translate "Users" + $draft = $contentService->createContentDraft($location->contentInfo); + $struct = $contentService->newContentUpdateStruct(); + $struct->setField('name', 'Brukere', 'nor-NO'); + $draft = $contentService->updateContent($draft->getVersionInfo(), $struct); + $contentService->publishVersion($draft->getVersionInfo()); + + // Load with prioritc language (fallback will be the old one) + $location = $locationService->loadLocation(5, ['nor-NO']); + + $this->assertInstanceOf( + Location::class, + $location + ); + self::assertEquals(5, $location->id); + $this->assertInstanceOf( + Content::class, + $content = $location->getContent() + ); + $this->assertEquals(4, $content->contentInfo->id); + + $this->assertEquals($content->getVersionInfo()->getName(), 'Brukere'); + $this->assertEquals($content->getVersionInfo()->getName('eng-US'), 'Users'); } /** diff --git a/eZ/Publish/API/Repository/Tests/TrashServiceTest.php b/eZ/Publish/API/Repository/Tests/TrashServiceTest.php index 5fb7df02c8d..c4bdeb70111 100644 --- a/eZ/Publish/API/Repository/Tests/TrashServiceTest.php +++ b/eZ/Publish/API/Repository/Tests/TrashServiceTest.php @@ -10,6 +10,7 @@ use eZ\Publish\API\Repository\Repository; use eZ\Publish\API\Repository\URLAliasService; +use eZ\Publish\API\Repository\Values\Content\Content; use eZ\Publish\API\Repository\Values\Content\Location as APILocation; use eZ\Publish\API\Repository\Values\Content\LocationCreateStruct; use eZ\Publish\API\Repository\Values\Content\Query; @@ -254,6 +255,12 @@ public function testLoadTrashItem() $trashItem, $trashItemReloaded ); + + $this->assertInstanceOf( + Content::class, + $content = $trashItemReloaded->getContent() + ); + $this->assertEquals($trashItem->contentId, $content->contentInfo->id); } /** diff --git a/eZ/Publish/API/Repository/Values/Content/Location.php b/eZ/Publish/API/Repository/Values/Content/Location.php index 56c355ea550..d48b44c1066 100644 --- a/eZ/Publish/API/Repository/Values/Content/Location.php +++ b/eZ/Publish/API/Repository/Values/Content/Location.php @@ -193,6 +193,19 @@ public function isDraft() */ protected $sortOrder; + /** + * @var \eZ\Publish\API\Repository\Values\Content\Content + */ + protected $content; + + /** + * @return \eZ\Publish\API\Repository\Values\Content\Content + */ + public function getContent(): Content + { + return $this->content; + } + /** * Get SortClause objects built from Locations's sort options. * diff --git a/eZ/Publish/Core/MVC/Symfony/View/Builder/ContentViewBuilder.php b/eZ/Publish/Core/MVC/Symfony/View/Builder/ContentViewBuilder.php index 63a179ed5de..9249aaa76d1 100644 --- a/eZ/Publish/Core/MVC/Symfony/View/Builder/ContentViewBuilder.php +++ b/eZ/Publish/Core/MVC/Symfony/View/Builder/ContentViewBuilder.php @@ -97,6 +97,9 @@ public function buildView(array $parameters) if (isset($parameters['content'])) { $content = $parameters['content']; + } elseif ($location instanceof Location) { + // if we already have location load content true it so we avoid dual loading in case user does that in view + $content = $location->getContent(); } else { if (isset($parameters['contentId'])) { $contentId = $parameters['contentId']; @@ -115,6 +118,13 @@ public function buildView(array $parameters) if ($location->contentId !== $content->id) { throw new InvalidArgumentException('Location', 'Provided location does not belong to selected content'); } + + if (isset($parameters['contentId']) && $location->contentId !== $parameters['contentId']) { + throw new InvalidArgumentException( + 'Location', + 'Provided location does not belong to selected content as requested via contentId parameter' + ); + } } elseif (isset($this->locationLoader)) { try { $location = $this->locationLoader->loadLocation($content->contentInfo); diff --git a/eZ/Publish/Core/Persistence/Cache/AbstractHandler.php b/eZ/Publish/Core/Persistence/Cache/AbstractHandler.php index 9be8a84f8ff..2244ef5f2ee 100644 --- a/eZ/Publish/Core/Persistence/Cache/AbstractHandler.php +++ b/eZ/Publish/Core/Persistence/Cache/AbstractHandler.php @@ -56,11 +56,15 @@ public function __construct( * Cache items must be stored with a key in the following format "${keyPrefix}${id}", like "ez-content-info-${id}", * in order for this method to be able to prefix key on id's and also extract key prefix afterwards. * + * It also optionally supports a key suffixs, for use on a variable argument that affects all lookups, + * like translations, i.e. "ez-content-${id}-${translationKey}" where $keySuffixes = [$id => "-${translationKey}"]. + * * @param array $ids - * @param string $keyPrefix + * @param string $keyPrefix E.g "ez-content-" * @param callable $missingLoader Function for loading missing objects, gets array with missing id's as argument, * expects return value to be array with id as key. Missing items should be missing. * @param callable $loadedTagger Function for tagging loaded object, gets object as argument, return array of tags. + * @param array $keySuffixes Optional, key is id as provided in $ids, and value is a key suffix e.g. "-eng-Gb" * * @return array */ @@ -68,7 +72,8 @@ final protected function getMultipleCacheItems( array $ids, string $keyPrefix, callable $missingLoader, - callable $loadedTagger + callable $loadedTagger, + array $keySuffixes = [] ): array { if (empty($ids)) { return []; @@ -77,7 +82,7 @@ final protected function getMultipleCacheItems( // Generate unique cache keys $cacheKeys = []; foreach (array_unique($ids) as $id) { - $cacheKeys[] = $keyPrefix . $id; + $cacheKeys[] = $keyPrefix . $id . ($keySuffixes[$id] ?? ''); } // Load cache items by cache keys (will contain hits and misses) @@ -86,6 +91,10 @@ final protected function getMultipleCacheItems( $keyPrefixLength = strlen($keyPrefix); foreach ($this->cache->getItems($cacheKeys) as $key => $cacheItem) { $id = substr($key, $keyPrefixLength); + if (!empty($keySuffixes)) { + $id = explode('-', $id, 2)[0]; + } + if ($cacheItem->isHit()) { $list[$id] = $cacheItem->get(); } else { diff --git a/eZ/Publish/Core/Persistence/Cache/ContentHandler.php b/eZ/Publish/Core/Persistence/Cache/ContentHandler.php index 61e9fbbea93..d8409ed94cd 100644 --- a/eZ/Publish/Core/Persistence/Cache/ContentHandler.php +++ b/eZ/Publish/Core/Persistence/Cache/ContentHandler.php @@ -10,6 +10,7 @@ use eZ\Publish\API\Repository\Values\Content\Relation as APIRelation; use eZ\Publish\SPI\Persistence\Content\Handler as ContentHandlerInterface; +use eZ\Publish\SPI\Persistence\Content; use eZ\Publish\SPI\Persistence\Content\VersionInfo; use eZ\Publish\SPI\Persistence\Content\ContentInfo; use eZ\Publish\SPI\Persistence\Content\CreateStruct; @@ -77,6 +78,40 @@ public function load($contentId, $versionNo, array $translations = null) return $content; } + public function loadContentList(array $contentLoadStructs): array + { + // Extract id's and make key suffix for each one (handling undefined versionNo and languages) + $contentIds = []; + $keySuffixes = []; + foreach ($contentLoadStructs as $struct) { + $contentIds[] = $struct->id; + $keySuffixes[$struct->id] = ($struct->versionNo ? "-{$struct->versionNo}-" : '-') . + (empty($struct->languages) ? self::ALL_TRANSLATIONS_KEY : implode('|', $struct->languages)); + } + + return $this->getMultipleCacheItems( + $contentIds, + 'ez-content-', + function (array $cacheMissIds) use ($contentLoadStructs) { + $this->logger->logCall(__CLASS__ . '::loadContentList', ['content' => $cacheMissIds]); + + $filteredStructs = []; + /* @var $contentLoadStructs \eZ\Publish\SPI\Persistence\Content\LoadStruct[] */ + foreach ($contentLoadStructs as $struct) { + if (in_array($struct->id, $cacheMissIds)) { + $filteredStructs[] = $struct; + } + } + + return $this->persistenceHandler->contentHandler()->loadContentList($filteredStructs); + }, + function (Content $content) { + return $this->getCacheTags($content->versionInfo->contentInfo, true); + }, + $keySuffixes + ); + } + /** * {@inheritdoc} */ diff --git a/eZ/Publish/Core/Persistence/Cache/Tests/ContentHandlerTest.php b/eZ/Publish/Core/Persistence/Cache/Tests/ContentHandlerTest.php index 4d71960e378..597f49d42ae 100644 --- a/eZ/Publish/Core/Persistence/Cache/Tests/ContentHandlerTest.php +++ b/eZ/Publish/Core/Persistence/Cache/Tests/ContentHandlerTest.php @@ -69,10 +69,12 @@ public function providerForCachedLoadMethods(): array $version = new VersionInfo(['versionNo' => 1, 'contentInfo' => $info]); $content = new Content(['fields' => [], 'versionInfo' => $version]); - // string $method, array $arguments, string $key, mixed? $data + // string $method, array $arguments, string $key, mixed? $data, bool $multi = false return [ ['load', [2, 1], 'ez-content-2-1-' . ContentHandler::ALL_TRANSLATIONS_KEY, $content], ['load', [2, 1, ['eng-GB', 'eng-US']], 'ez-content-2-1-eng-GB|eng-US', $content], + ['loadContentList', [[new Content\LoadStruct(['id' => 2, 'versionNo' => 3])]], 'ez-content-2-3-' . ContentHandler::ALL_TRANSLATIONS_KEY, [2 => $content], true], + ['loadContentList', [[new Content\LoadStruct(['id' => 5, 'languages' => ['eng-GB', 'eng-US']])]], 'ez-content-5-eng-GB|eng-US', [5 => $content], true], ['loadContentInfo', [2], 'ez-content-info-2', $info], ['loadContentInfoList', [[2]], 'ez-content-info-2', [2 => $info], true], ['loadContentInfoByRemoteId', ['3d8jrj'], 'ez-content-info-byRemoteId-3d8jrj', $info], diff --git a/eZ/Publish/Core/Persistence/Legacy/Content/Gateway.php b/eZ/Publish/Core/Persistence/Legacy/Content/Gateway.php index b2c4807aba9..858771e7044 100644 --- a/eZ/Publish/Core/Persistence/Legacy/Content/Gateway.php +++ b/eZ/Publish/Core/Persistence/Legacy/Content/Gateway.php @@ -151,6 +151,16 @@ abstract public function updateNonTranslatableField( */ abstract public function load($contentId, $version, array $translations = null); + /** + * Loads current version for a list of content objects. + * + * @param array[] $IdVersionTranslationPairs Hashes with 'id', optionally 'version', & optionally 'languages' + * If version is not set current version will be loaded, if languages is not set ALL will be loaded. + * + * @return array[] + */ + abstract public function loadContentList(array $IdVersionTranslationPairs): array; + /** * Loads info for a content object identified by its remote ID. * diff --git a/eZ/Publish/Core/Persistence/Legacy/Content/Gateway/DoctrineDatabase.php b/eZ/Publish/Core/Persistence/Legacy/Content/Gateway/DoctrineDatabase.php index 8248ca995b9..d9df08e021e 100644 --- a/eZ/Publish/Core/Persistence/Legacy/Content/Gateway/DoctrineDatabase.php +++ b/eZ/Publish/Core/Persistence/Legacy/Content/Gateway/DoctrineDatabase.php @@ -827,29 +827,122 @@ public function updateNonTranslatableField( * * @param mixed $contentId * @param mixed $version - * @param string[] $translations + * @param string[]|null $translations * * @return array */ public function load($contentId, $version, array $translations = null) { - $query = $this->queryBuilder->createFindQuery($translations); - $query->where( - $query->expr->lAnd( - $query->expr->eq( - $this->dbHandler->quoteColumn('id', 'ezcontentobject'), - $query->bindValue($contentId) - ), - $query->expr->eq( - $this->dbHandler->quoteColumn('version', 'ezcontentobject_version'), - $query->bindValue($version) - ) + $results = $this->internalLoadContent([ + ['id' => $contentId, 'version' => $version, 'languages' => $translations], + ]); + + return $results; + } + + /** + * Loads current version for a list of content objects. + * + * @param array[] $IdVersionTranslationPairs Hashes with 'id', optionally 'version', & optionally 'languages' + * If version is not set current version will be loaded, if languages is not set ALL will be loaded. + * + * @return array[] + */ + public function loadContentList(array $IdVersionTranslationPairs): array + { + return $this->internalLoadContent($IdVersionTranslationPairs); + } + + /** + * @see load(), loadContentList() + * + * @param array[] $IdVersionTranslationPairs Hashes with 'id', optionally 'version', & optionally 'languages' + * If version is not set current version will be loaded, if languages is not set ALL will be loaded. + * + * @return array + */ + private function internalLoadContent(array $IdVersionTranslationPairs): array + { + $q = $this->connection->createQueryBuilder(); + $q + ->select( + 'c.id AS ezcontentobject_id', + 'c.contentclass_id AS ezcontentobject_contentclass_id', + 'c.section_id AS ezcontentobject_section_id', + 'c.owner_id AS ezcontentobject_owner_id', + 'c.remote_id AS ezcontentobject_remote_id', + 'c.current_version AS ezcontentobject_current_version', + 'c.initial_language_id AS ezcontentobject_initial_language_id', + 'c.modified AS ezcontentobject_modified', + 'c.published AS ezcontentobject_published', + 'c.status AS ezcontentobject_status', + 'c.name AS ezcontentobject_name', + 'c.language_mask AS ezcontentobject_language_mask', + 'v.id AS ezcontentobject_version_id', + 'v.version AS ezcontentobject_version_version', + 'v.modified AS ezcontentobject_version_modified', + 'v.creator_id AS ezcontentobject_version_creator_id', + 'v.created AS ezcontentobject_version_created', + 'v.status AS ezcontentobject_version_status', + 'v.language_mask AS ezcontentobject_version_language_mask', + 'v.initial_language_id AS ezcontentobject_version_initial_language_id', + 'a.id AS ezcontentobject_attribute_id', + 'a.contentclassattribute_id AS ezcontentobject_attribute_contentclassattribute_id', + 'a.data_type_string AS ezcontentobject_attribute_data_type_string', + 'a.language_code AS ezcontentobject_attribute_language_code', + 'a.language_id AS ezcontentobject_attribute_language_id', + 'a.data_float AS ezcontentobject_attribute_data_float', + 'a.data_int AS ezcontentobject_attribute_data_int', + 'a.data_text AS ezcontentobject_attribute_data_text', + 'a.sort_key_int AS ezcontentobject_attribute_sort_key_int', + 'a.sort_key_string AS ezcontentobject_attribute_sort_key_string', + 't.main_node_id AS ezcontentobject_tree_main_node_id' + ) + ->from('ezcontentobject', 'c') + ->innerJoin( + 'c', + 'ezcontentobject_version', + 'v', + 'c.id = v.contentobject_id' ) + ->innerJoin( + 'v', + 'ezcontentobject_attribute', + 'a', + 'v.contentobject_id = a.contentobject_id AND v.version = a.version' + ) + ->leftJoin( + 'c', + 'ezcontentobject_tree', + 't', + 'c.id = t.contentobject_id AND t.node_id = t.main_node_id' + ); + + $where = []; + $expr = $q->expr(); + foreach ($IdVersionTranslationPairs as $IdVersionTranslation) { + $clauses = [ + $expr->eq('c.id', $q->createNamedParameter($IdVersionTranslation['id'], PDO::PARAM_INT)), + empty($IdVersionTranslation['version']) ? + $expr->eq('v.version', 'c.current_version') : + $expr->eq('v.version', $q->createNamedParameter($IdVersionTranslation['version'], PDO::PARAM_INT)), + ]; + + if (!empty($IdVersionTranslation['languages'])) { + $clauses[] = $expr->in( + 'a.language_code', + $q->createNamedParameter($IdVersionTranslation['languages'], Connection::PARAM_STR_ARRAY) + ); + } + + $where[] = $expr->andX(...$clauses); + } + + $q->where( + $expr->orX(...$where) ); - $statement = $query->prepare(); - $statement->execute(); - return $statement->fetchAll(\PDO::FETCH_ASSOC); + return $q->execute()->fetchAll(); } /** diff --git a/eZ/Publish/Core/Persistence/Legacy/Content/Gateway/ExceptionConversion.php b/eZ/Publish/Core/Persistence/Legacy/Content/Gateway/ExceptionConversion.php index 3c31aca82a1..33e6dee473c 100644 --- a/eZ/Publish/Core/Persistence/Legacy/Content/Gateway/ExceptionConversion.php +++ b/eZ/Publish/Core/Persistence/Legacy/Content/Gateway/ExceptionConversion.php @@ -276,6 +276,18 @@ public function load($contentId, $version, array $translations = null) } } + /** + * {@inheritdoc} + */ + public function loadContentList(array $IdVersionTranslationPairs): array + { + try { + return $this->innerGateway->loadContentList($IdVersionTranslationPairs); + } catch (DBALException | PDOException $e) { + throw new RuntimeException('Database error', 0, $e); + } + } + /** * Loads data for a content object identified by its remote ID. * diff --git a/eZ/Publish/Core/Persistence/Legacy/Content/Handler.php b/eZ/Publish/Core/Persistence/Legacy/Content/Handler.php index d7fad25ce97..4f38af31e4e 100644 --- a/eZ/Publish/Core/Persistence/Legacy/Content/Handler.php +++ b/eZ/Publish/Core/Persistence/Legacy/Content/Handler.php @@ -321,12 +321,55 @@ public function load($id, $version, array $translations = null) $this->contentGateway->loadVersionedNameData(array(array('id' => $id, 'version' => $version))) ); $content = $contentObjects[0]; + unset($rows, $contentObjects); $this->fieldHandler->loadExternalFieldData($content); return $content; } + /** + * {@inheritdoc} + */ + public function loadContentList(array $contentLoadStructs): array + { + $idVersionTranslationPairs = []; + foreach ($contentLoadStructs as $struct) { + $idVersionTranslationPairs[] = [ + 'id' => $struct->id, + 'version' => $struct->versionNo, + 'languages' => $struct->languages, + ]; + } + + $rawList = $this->contentGateway->loadContentList($idVersionTranslationPairs); + if (empty($rawList)) { + return []; + } + + $idVersionPairs = []; + foreach ($rawList as $row) { + $idVersionPairs[] = [ + 'id' => $row['ezcontentobject_id'], + 'version' => $row['ezcontentobject_version_version'], + ]; + } + + $contentObjects = $this->mapper->extractContentFromRows( + $rawList, + $this->contentGateway->loadVersionedNameData($idVersionPairs) + ); + unset($rawList, $idVersionPairs, $idVersionTranslationPairs); + + $result = []; + foreach ($contentObjects as $content) { + $this->fieldHandler->loadExternalFieldData($content); + $result[$content->versionInfo->contentInfo->id] = $content; + } + + return $result; + } + /** * Returns the metadata object for a content identified by $contentId. * diff --git a/eZ/Publish/Core/Persistence/Legacy/Content/Mapper.php b/eZ/Publish/Core/Persistence/Legacy/Content/Mapper.php index a21a94f41b6..400ead35c15 100644 --- a/eZ/Publish/Core/Persistence/Legacy/Content/Mapper.php +++ b/eZ/Publish/Core/Persistence/Legacy/Content/Mapper.php @@ -178,7 +178,7 @@ public function convertToStorageValue(Field $field) * * @return \eZ\Publish\SPI\Persistence\Content[] */ - public function extractContentFromRows(array $rows, array $nameRows) + public function extractContentFromRows(array $rows, array $nameRows, $prefix = 'ezcontentobject_') { $versionedNameData = array(); foreach ($nameRows as $row) { @@ -192,9 +192,9 @@ public function extractContentFromRows(array $rows, array $nameRows) $fields = array(); foreach ($rows as $row) { - $contentId = (int)$row['ezcontentobject_id']; + $contentId = (int)$row["{$prefix}id"]; if (!isset($contentInfos[$contentId])) { - $contentInfos[$contentId] = $this->extractContentInfoFromRow($row, 'ezcontentobject_'); + $contentInfos[$contentId] = $this->extractContentInfoFromRow($row, $prefix); } if (!isset($versionInfos[$contentId])) { $versionInfos[$contentId] = array(); @@ -387,7 +387,9 @@ protected function extractFieldFromRow(array $row) $field->type = $row['ezcontentobject_attribute_data_type_string']; $field->value = $this->extractFieldValueFromRow($row, $field->type); $field->languageCode = $row['ezcontentobject_attribute_language_code']; - $field->versionNo = (int)$row['ezcontentobject_attribute_version']; + $field->versionNo = isset($row['ezcontentobject_version_version']) ? + (int)$row['ezcontentobject_version_version'] : + (int)$row['ezcontentobject_attribute_version']; return $field; } diff --git a/eZ/Publish/Core/Persistence/Legacy/Tests/Content/ContentHandlerTest.php b/eZ/Publish/Core/Persistence/Legacy/Tests/Content/ContentHandlerTest.php index a5eb899c59c..7ff03ab64a1 100644 --- a/eZ/Publish/Core/Persistence/Legacy/Tests/Content/ContentHandlerTest.php +++ b/eZ/Publish/Core/Persistence/Legacy/Tests/Content/ContentHandlerTest.php @@ -519,6 +519,61 @@ public function testLoad() ); } + /** + * @covers \eZ\Publish\Core\Persistence\Legacy\Content\Handler::loadContentList + */ + public function testLoadContentList() + { + $handler = $this->getContentHandler(); + + $gatewayMock = $this->getGatewayMock(); + $mapperMock = $this->getMapperMock(); + $fieldHandlerMock = $this->getFieldHandlerMock(); + + $idVersionTranslationPairs = [ + ['id' => 2, 'version' => 2, 'languages' => ['eng-GB']], + ['id' => 3, 'version' => null, 'languages' => ['eng-GB', 'eng-US']], + ]; + $contentRows = [ + ['ezcontentobject_id' => 2, 'ezcontentobject_version_version' => 2], + ['ezcontentobject_id' => 3, 'ezcontentobject_version_version' => 1], + ]; + $gatewayMock->expects($this->once()) + ->method('loadContentList') + ->with($this->equalTo($idVersionTranslationPairs)) + ->willReturn($contentRows); + + $gatewayMock->expects($this->once()) + ->method('loadVersionedNameData') + ->with($this->equalTo([['id' => 2, 'version' => 2], ['id' => 3, 'version' => 1]])) + ->willReturn([22]); + + $expected = [ + 2 => $this->getContentFixtureForDraft(2, 2), + 3 => $this->getContentFixtureForDraft(3, 1), + ]; + $mapperMock->expects($this->once()) + ->method('extractContentFromRows') + ->with($this->equalTo($contentRows), $this->equalTo([22])) + ->willReturn($expected); + + $fieldHandlerMock->expects($this->exactly(2)) + ->method('loadExternalFieldData') + ->with($this->isInstanceOf(Content::class)); + + $loadStructList = [ + new Content\LoadStruct(['id' => 2, 'versionNo' => 2, 'languages' => ['eng-GB']]), + new Content\LoadStruct(['id' => 3, 'languages' => ['eng-GB', 'eng-US']]), + ]; + + $result = $handler->loadContentList($loadStructList); + + $this->assertEquals( + $expected, + $result + ); + } + /** * @covers \eZ\Publish\Core\Persistence\Legacy\Content\Handler::loadContentInfoByRemoteId */ @@ -566,20 +621,23 @@ public function testLoadErrorNotFound() /** * Returns a Content for {@link testCreateDraftFromVersion()}. * + * @param int $id Optional id + * @param int $versionNo Optional version number + * * @return \eZ\Publish\SPI\Persistence\Content */ - protected function getContentFixtureForDraft() + protected function getContentFixtureForDraft(int $id = 23, int $versionNo = 2) { $content = new Content(); $content->versionInfo = new VersionInfo(); - $content->versionInfo->versionNo = 2; + $content->versionInfo->versionNo = $versionNo; - $content->versionInfo->contentInfo = new ContentInfo(); + $content->versionInfo->contentInfo = new ContentInfo(['id' => $id]); $field = new Field(); - $field->versionNo = 2; + $field->versionNo = $versionNo; - $content->fields = array($field); + $content->fields = [$field]; return $content; } diff --git a/eZ/Publish/Core/Persistence/Legacy/Tests/Content/_fixtures/extract_content_from_rows.php b/eZ/Publish/Core/Persistence/Legacy/Tests/Content/_fixtures/extract_content_from_rows.php index f807c0896f0..0ceae5cd7c9 100644 --- a/eZ/Publish/Core/Persistence/Legacy/Tests/Content/_fixtures/extract_content_from_rows.php +++ b/eZ/Publish/Core/Persistence/Legacy/Tests/Content/_fixtures/extract_content_from_rows.php @@ -21,7 +21,6 @@ 'ezcontentobject_version_creator_id' => '14', 'ezcontentobject_version_created' => '1313061317', 'ezcontentobject_version_status' => '1', - 'ezcontentobject_version_contentobject_id' => '226', 'ezcontentobject_version_language_mask' => '3', 'ezcontentobject_version_initial_language_id' => '2', 'ezcontentobject_attribute_id' => '4000', @@ -29,7 +28,6 @@ 'ezcontentobject_attribute_data_type_string' => 'ezsrrating', 'ezcontentobject_attribute_language_code' => 'eng-GB', 'ezcontentobject_attribute_language_id' => '4', - 'ezcontentobject_attribute_version' => '2', 'ezcontentobject_attribute_data_float' => '0.0', 'ezcontentobject_attribute_data_int' => null, 'ezcontentobject_attribute_data_text' => '', @@ -57,7 +55,6 @@ 'ezcontentobject_version_creator_id' => '14', 'ezcontentobject_version_created' => '1313061317', 'ezcontentobject_version_status' => '1', - 'ezcontentobject_version_contentobject_id' => '226', 'ezcontentobject_version_language_mask' => '3', 'ezcontentobject_version_initial_language_id' => '2', 'ezcontentobject_attribute_id' => '1332', @@ -65,7 +62,6 @@ 'ezcontentobject_attribute_data_type_string' => 'ezstring', 'ezcontentobject_attribute_language_code' => 'eng-US', 'ezcontentobject_attribute_language_id' => '2', - 'ezcontentobject_attribute_version' => '2', 'ezcontentobject_attribute_data_float' => '0.0', 'ezcontentobject_attribute_data_int' => null, 'ezcontentobject_attribute_data_text' => 'New test article (2)', @@ -93,7 +89,6 @@ 'ezcontentobject_version_creator_id' => '14', 'ezcontentobject_version_created' => '1313061317', 'ezcontentobject_version_status' => '1', - 'ezcontentobject_version_contentobject_id' => '226', 'ezcontentobject_version_language_mask' => '3', 'ezcontentobject_version_initial_language_id' => '2', 'ezcontentobject_attribute_id' => '1333', @@ -101,7 +96,6 @@ 'ezcontentobject_attribute_data_type_string' => 'ezstring', 'ezcontentobject_attribute_language_code' => 'eng-US', 'ezcontentobject_attribute_language_id' => '2', - 'ezcontentobject_attribute_version' => '2', 'ezcontentobject_attribute_data_float' => '0.0', 'ezcontentobject_attribute_data_int' => null, 'ezcontentobject_attribute_data_text' => 'Something', @@ -129,7 +123,6 @@ 'ezcontentobject_version_creator_id' => '14', 'ezcontentobject_version_created' => '1313061317', 'ezcontentobject_version_status' => '1', - 'ezcontentobject_version_contentobject_id' => '226', 'ezcontentobject_version_language_mask' => '3', 'ezcontentobject_version_initial_language_id' => '2', 'ezcontentobject_attribute_id' => '1334', @@ -137,7 +130,6 @@ 'ezcontentobject_attribute_data_type_string' => 'ezauthor', 'ezcontentobject_attribute_language_code' => 'eng-US', 'ezcontentobject_attribute_language_id' => '2', - 'ezcontentobject_attribute_version' => '2', 'ezcontentobject_attribute_data_float' => '0.0', 'ezcontentobject_attribute_data_int' => null, 'ezcontentobject_attribute_data_text' => ' @@ -167,7 +159,6 @@ 'ezcontentobject_version_creator_id' => '14', 'ezcontentobject_version_created' => '1313061317', 'ezcontentobject_version_status' => '1', - 'ezcontentobject_version_contentobject_id' => '226', 'ezcontentobject_version_language_mask' => '3', 'ezcontentobject_version_initial_language_id' => '2', 'ezcontentobject_attribute_id' => '1335', @@ -175,7 +166,6 @@ 'ezcontentobject_attribute_data_type_string' => 'ezrichtext', 'ezcontentobject_attribute_language_code' => 'eng-US', 'ezcontentobject_attribute_language_id' => '2', - 'ezcontentobject_attribute_version' => '2', 'ezcontentobject_attribute_data_float' => '0.0', 'ezcontentobject_attribute_data_int' => '1045487555', 'ezcontentobject_attribute_data_text' => ' @@ -205,7 +195,6 @@ 'ezcontentobject_version_creator_id' => '14', 'ezcontentobject_version_created' => '1313061317', 'ezcontentobject_version_status' => '1', - 'ezcontentobject_version_contentobject_id' => '226', 'ezcontentobject_version_language_mask' => '3', 'ezcontentobject_version_initial_language_id' => '2', 'ezcontentobject_attribute_id' => '1336', @@ -213,7 +202,6 @@ 'ezcontentobject_attribute_data_type_string' => 'ezrichtext', 'ezcontentobject_attribute_language_code' => 'eng-US', 'ezcontentobject_attribute_language_id' => '2', - 'ezcontentobject_attribute_version' => '2', 'ezcontentobject_attribute_data_float' => '0.0', 'ezcontentobject_attribute_data_int' => '1045487555', 'ezcontentobject_attribute_data_text' => ' @@ -243,7 +231,6 @@ 'ezcontentobject_version_creator_id' => '14', 'ezcontentobject_version_created' => '1313061317', 'ezcontentobject_version_status' => '1', - 'ezcontentobject_version_contentobject_id' => '226', 'ezcontentobject_version_language_mask' => '3', 'ezcontentobject_version_initial_language_id' => '2', 'ezcontentobject_attribute_id' => '1337', @@ -251,7 +238,6 @@ 'ezcontentobject_attribute_data_type_string' => 'ezboolean', 'ezcontentobject_attribute_language_code' => 'eng-US', 'ezcontentobject_attribute_language_id' => '2', - 'ezcontentobject_attribute_version' => '2', 'ezcontentobject_attribute_data_float' => '0.0', 'ezcontentobject_attribute_data_int' => '1', 'ezcontentobject_attribute_data_text' => '', @@ -279,7 +265,6 @@ 'ezcontentobject_version_creator_id' => '14', 'ezcontentobject_version_created' => '1313061317', 'ezcontentobject_version_status' => '1', - 'ezcontentobject_version_contentobject_id' => '226', 'ezcontentobject_version_language_mask' => '3', 'ezcontentobject_version_initial_language_id' => '2', 'ezcontentobject_attribute_id' => '1338', @@ -287,7 +272,6 @@ 'ezcontentobject_attribute_data_type_string' => 'ezimage', 'ezcontentobject_attribute_language_code' => 'eng-US', 'ezcontentobject_attribute_language_id' => '2', - 'ezcontentobject_attribute_version' => '2', 'ezcontentobject_attribute_data_float' => '0.0', 'ezcontentobject_attribute_data_int' => null, 'ezcontentobject_attribute_data_text' => ' @@ -317,7 +301,6 @@ 'ezcontentobject_version_creator_id' => '14', 'ezcontentobject_version_created' => '1313061317', 'ezcontentobject_version_status' => '1', - 'ezcontentobject_version_contentobject_id' => '226', 'ezcontentobject_version_language_mask' => '3', 'ezcontentobject_version_initial_language_id' => '2', 'ezcontentobject_attribute_id' => '1339', @@ -325,7 +308,6 @@ 'ezcontentobject_attribute_data_type_string' => 'ezrichtext', 'ezcontentobject_attribute_language_code' => 'eng-US', 'ezcontentobject_attribute_language_id' => '2', - 'ezcontentobject_attribute_version' => '2', 'ezcontentobject_attribute_data_float' => '0.0', 'ezcontentobject_attribute_data_int' => '1045487555', 'ezcontentobject_attribute_data_text' => ' @@ -355,7 +337,6 @@ 'ezcontentobject_version_creator_id' => '14', 'ezcontentobject_version_created' => '1313061317', 'ezcontentobject_version_status' => '1', - 'ezcontentobject_version_contentobject_id' => '226', 'ezcontentobject_version_language_mask' => '3', 'ezcontentobject_version_initial_language_id' => '2', 'ezcontentobject_attribute_id' => '1340', @@ -363,7 +344,6 @@ 'ezcontentobject_attribute_data_type_string' => 'ezdatetime', 'ezcontentobject_attribute_language_code' => 'eng-US', 'ezcontentobject_attribute_language_id' => '2', - 'ezcontentobject_attribute_version' => '2', 'ezcontentobject_attribute_data_float' => '0.0', 'ezcontentobject_attribute_data_int' => '0', 'ezcontentobject_attribute_data_text' => '', @@ -391,7 +371,6 @@ 'ezcontentobject_version_creator_id' => '14', 'ezcontentobject_version_created' => '1313061317', 'ezcontentobject_version_status' => '1', - 'ezcontentobject_version_contentobject_id' => '226', 'ezcontentobject_version_language_mask' => '3', 'ezcontentobject_version_initial_language_id' => '2', 'ezcontentobject_attribute_id' => '1341', @@ -399,7 +378,6 @@ 'ezcontentobject_attribute_data_type_string' => 'ezdatetime', 'ezcontentobject_attribute_language_code' => 'eng-US', 'ezcontentobject_attribute_language_id' => '2', - 'ezcontentobject_attribute_version' => '2', 'ezcontentobject_attribute_data_float' => '0.0', 'ezcontentobject_attribute_data_int' => '0', 'ezcontentobject_attribute_data_text' => '', @@ -427,7 +405,6 @@ 'ezcontentobject_version_creator_id' => '14', 'ezcontentobject_version_created' => '1313061317', 'ezcontentobject_version_status' => '1', - 'ezcontentobject_version_contentobject_id' => '226', 'ezcontentobject_version_language_mask' => '3', 'ezcontentobject_version_initial_language_id' => '2', 'ezcontentobject_attribute_id' => '1342', @@ -435,7 +412,6 @@ 'ezcontentobject_attribute_data_type_string' => 'ezkeyword', 'ezcontentobject_attribute_language_code' => 'eng-US', 'ezcontentobject_attribute_language_id' => '2', - 'ezcontentobject_attribute_version' => '2', 'ezcontentobject_attribute_data_float' => '0.0', 'ezcontentobject_attribute_data_int' => null, 'ezcontentobject_attribute_data_text' => '', @@ -463,7 +439,6 @@ 'ezcontentobject_version_creator_id' => '14', 'ezcontentobject_version_created' => '1313061317', 'ezcontentobject_version_status' => '1', - 'ezcontentobject_version_contentobject_id' => '226', 'ezcontentobject_version_language_mask' => '3', 'ezcontentobject_version_initial_language_id' => '2', 'ezcontentobject_attribute_id' => '1343', @@ -471,7 +446,6 @@ 'ezcontentobject_attribute_data_type_string' => 'ezsrrating', 'ezcontentobject_attribute_language_code' => 'eng-US', 'ezcontentobject_attribute_language_id' => '2', - 'ezcontentobject_attribute_version' => '2', 'ezcontentobject_attribute_data_float' => '0.0', 'ezcontentobject_attribute_data_int' => null, 'ezcontentobject_attribute_data_text' => '', diff --git a/eZ/Publish/Core/Persistence/Legacy/Tests/Content/_fixtures/extract_content_from_rows_multiple_versions.php b/eZ/Publish/Core/Persistence/Legacy/Tests/Content/_fixtures/extract_content_from_rows_multiple_versions.php index 4c16fa3f470..12cd2218796 100644 --- a/eZ/Publish/Core/Persistence/Legacy/Tests/Content/_fixtures/extract_content_from_rows_multiple_versions.php +++ b/eZ/Publish/Core/Persistence/Legacy/Tests/Content/_fixtures/extract_content_from_rows_multiple_versions.php @@ -21,7 +21,6 @@ 'ezcontentobject_version_creator_id' => '14', 'ezcontentobject_version_created' => '1033920737', 'ezcontentobject_version_status' => '3', - 'ezcontentobject_version_contentobject_id' => '11', 'ezcontentobject_version_language_mask' => '3', 'ezcontentobject_version_initial_language_id' => '2', 'ezcontentobject_attribute_id' => '22', @@ -29,7 +28,6 @@ 'ezcontentobject_attribute_data_type_string' => 'ezstring', 'ezcontentobject_attribute_language_code' => 'eng-US', 'ezcontentobject_attribute_language_id' => '3', - 'ezcontentobject_attribute_version' => '1', 'ezcontentobject_attribute_data_float' => '0.0', 'ezcontentobject_attribute_data_int' => '0', 'ezcontentobject_attribute_data_text' => 'Guest accounts', @@ -57,7 +55,6 @@ 'ezcontentobject_version_creator_id' => '14', 'ezcontentobject_version_created' => '1033920737', 'ezcontentobject_version_status' => '3', - 'ezcontentobject_version_contentobject_id' => '11', 'ezcontentobject_version_language_mask' => '3', 'ezcontentobject_version_initial_language_id' => '2', 'ezcontentobject_attribute_id' => '23', @@ -65,7 +62,6 @@ 'ezcontentobject_attribute_data_type_string' => 'ezstring', 'ezcontentobject_attribute_language_code' => 'eng-US', 'ezcontentobject_attribute_language_id' => '3', - 'ezcontentobject_attribute_version' => '1', 'ezcontentobject_attribute_data_float' => '0.0', 'ezcontentobject_attribute_data_int' => '0', 'ezcontentobject_attribute_data_text' => '', @@ -93,7 +89,6 @@ 'ezcontentobject_version_creator_id' => '14', 'ezcontentobject_version_created' => '1311154215', 'ezcontentobject_version_status' => '1', - 'ezcontentobject_version_contentobject_id' => '11', 'ezcontentobject_version_language_mask' => '3', 'ezcontentobject_version_initial_language_id' => '2', 'ezcontentobject_attribute_id' => '22', @@ -101,7 +96,6 @@ 'ezcontentobject_attribute_data_type_string' => 'ezstring', 'ezcontentobject_attribute_language_code' => 'eng-US', 'ezcontentobject_attribute_language_id' => '3', - 'ezcontentobject_attribute_version' => '2', 'ezcontentobject_attribute_data_float' => '0.0', 'ezcontentobject_attribute_data_int' => '0', 'ezcontentobject_attribute_data_text' => 'Members', @@ -129,7 +123,6 @@ 'ezcontentobject_version_creator_id' => '14', 'ezcontentobject_version_created' => '1311154215', 'ezcontentobject_version_status' => '1', - 'ezcontentobject_version_contentobject_id' => '11', 'ezcontentobject_version_language_mask' => '3', 'ezcontentobject_version_initial_language_id' => '2', 'ezcontentobject_attribute_id' => '23', @@ -137,7 +130,6 @@ 'ezcontentobject_attribute_data_type_string' => 'ezstring', 'ezcontentobject_attribute_language_code' => 'eng-US', 'ezcontentobject_attribute_language_id' => '3', - 'ezcontentobject_attribute_version' => '2', 'ezcontentobject_attribute_data_float' => '0.0', 'ezcontentobject_attribute_data_int' => '0', 'ezcontentobject_attribute_data_text' => '', diff --git a/eZ/Publish/Core/Repository/ContentService.php b/eZ/Publish/Core/Repository/ContentService.php index 990e58c67c9..8b342dcb4cb 100644 --- a/eZ/Publish/Core/Repository/ContentService.php +++ b/eZ/Publish/Core/Repository/ContentService.php @@ -390,7 +390,7 @@ public function internalLoadContent($id, array $languages = null, $versionNo = n return $this->domainMapper->buildContentDomainObject( $spiContent, null, - empty($languages) ? null : $languages, + $languages ?? [], $alwaysAvailableLanguageCode ); } diff --git a/eZ/Publish/Core/Repository/Helper/DomainMapper.php b/eZ/Publish/Core/Repository/Helper/DomainMapper.php index f561fd54382..b26d4fd32af 100644 --- a/eZ/Publish/Core/Repository/Helper/DomainMapper.php +++ b/eZ/Publish/Core/Repository/Helper/DomainMapper.php @@ -9,11 +9,13 @@ namespace eZ\Publish\Core\Repository\Helper; use eZ\Publish\API\Repository\Values\Content\Search\SearchResult; +use eZ\Publish\API\Repository\Values\Content\Content as APIContent; use eZ\Publish\SPI\Persistence\Content\Handler as ContentHandler; use eZ\Publish\SPI\Persistence\Content\Location\Handler as LocationHandler; use eZ\Publish\SPI\Persistence\Content\Language\Handler as LanguageHandler; use eZ\Publish\SPI\Persistence\Content\Type\Handler as TypeHandler; use eZ\Publish\Core\Repository\Values\Content\Content; +use eZ\Publish\Core\Repository\Values\Content\ContentProxy; use eZ\Publish\API\Repository\Values\Content\VersionInfo as APIVersionInfo; use eZ\Publish\Core\Repository\Values\Content\VersionInfo; use eZ\Publish\API\Repository\Values\Content\ContentInfo; @@ -98,13 +100,17 @@ public function __construct( * * @param \eZ\Publish\SPI\Persistence\Content $spiContent * @param ContentType|SPIType $contentType - * @param array|null $fieldLanguages Language codes to filter fields on - * @param string|null $fieldAlwaysAvailableLanguage Language code fallback if a given field is not found in $fieldLanguages + * @param array $prioritizedLanguages Prioritized language codes to filter fields on + * @param string|null $fieldAlwaysAvailableLanguage Language code fallback if a given field is not found in $prioritizedLanguages * * @return \eZ\Publish\Core\Repository\Values\Content\Content */ - public function buildContentDomainObject(SPIContent $spiContent, $contentType = null, array $fieldLanguages = null, $fieldAlwaysAvailableLanguage = null) - { + public function buildContentDomainObject( + SPIContent $spiContent, + $contentType = null, + array $prioritizedLanguages = [], + string $fieldAlwaysAvailableLanguage = null + ) { if ($contentType === null) { $contentType = $this->contentTypeHandler->load( $spiContent->versionInfo->contentInfo->contentTypeId @@ -112,7 +118,6 @@ public function buildContentDomainObject(SPIContent $spiContent, $contentType = } $prioritizedFieldLanguageCode = null; - $prioritizedLanguages = $fieldLanguages ?: []; if (!empty($prioritizedLanguages)) { $availableFieldLanguageMap = array_fill_keys($spiContent->versionInfo->languageCodes, true); foreach ($prioritizedLanguages as $prioritizedLanguage) { @@ -125,13 +130,95 @@ public function buildContentDomainObject(SPIContent $spiContent, $contentType = return new Content( array( - 'internalFields' => $this->buildDomainFields($spiContent->fields, $contentType, $fieldLanguages, $fieldAlwaysAvailableLanguage), + 'internalFields' => $this->buildDomainFields($spiContent->fields, $contentType, $prioritizedLanguages, $fieldAlwaysAvailableLanguage), 'versionInfo' => $this->buildVersionInfoDomainObject($spiContent->versionInfo, $prioritizedLanguages), 'prioritizedFieldLanguageCode' => $prioritizedFieldLanguageCode, ) ); } + /** + * Builds a Content proxy object (lazy loaded, loads as soon as used). + */ + public function buildContentProxy( + SPIContent\ContentInfo $info, + array $prioritizedLanguages = [], + bool $useAlwaysAvailable = true + ): APIContent { + $generator = $this->generatorForContentList([$info], $prioritizedLanguages, $useAlwaysAvailable); + + return new ContentProxy($generator, $info->id); + } + + /** + * Builds a list of Content proxy objects (lazy loaded, loads all as soon as one of them loads). + * + * @param \eZ\Publish\SPI\Persistence\Content\ContentInfo[] $infoList + * @param string[] $prioritizedLanguages + * @param bool $useAlwaysAvailable + * + * @return \eZ\Publish\API\Repository\Values\Content\Content[] + */ + public function buildContentProxyList( + array $infoList, + array $prioritizedLanguages = [], + bool $useAlwaysAvailable = true + ): array { + $list = []; + $generator = $this->generatorForContentList($infoList, $prioritizedLanguages, $useAlwaysAvailable); + foreach ($infoList as $info) { + $list[$info->id] = new ContentProxy($generator, $info->id); + } + + return $list; + } + + /** + * @param \eZ\Publish\SPI\Persistence\Content\ContentInfo[] $infoList + * @param string[] $prioritizedLanguages + * @param bool $useAlwaysAvailable + * + * @return \Generator + */ + private function generatorForContentList( + array $infoList, + array $prioritizedLanguages = [], + bool $useAlwaysAvailable = true + ): \Generator { + // Create list of LoadStruct, take into account main language as fallback language if alwaysAvailable + // And skip setting versionNo to make sure we always get the current version when proxy is eventually loaded + $loadStructList = []; + foreach ($infoList as $info) { + if ($useAlwaysAvailable && $info->alwaysAvailable) { + $languages = $prioritizedLanguages; + $languages[] = $info->mainLanguageCode; + $loadStructList[] = new SPIContent\LoadStruct(['id' => $info->id, 'languages' => $languages]); + } else { + $loadStructList[] = new SPIContent\LoadStruct([ + 'id' => $info->id, + 'languages' => $prioritizedLanguages, + ]); + } + } + + $list = $this->contentHandler->loadContentList($loadStructList); + unset($loadStructList); + + while (!empty($list)) { + $id = yield; + $info = $list[$id]->versionInfo->contentInfo; + yield $this->buildContentDomainObject( + $list[$id], + null, + //@todo bulk load content type, AND(~/OR~) add in-memory cache for it which will also benefit all cases + $prioritizedLanguages, + $info->alwaysAvailable ? $info->mainLanguageCode : null + ); + + unset($list[$id]); + } + } + /** * Returns an array of domain fields created from given array of SPI fields. * @@ -139,14 +226,18 @@ public function buildContentDomainObject(SPIContent $spiContent, $contentType = * * @param \eZ\Publish\SPI\Persistence\Content\Field[] $spiFields * @param ContentType|SPIType $contentType - * @param array $languages A language priority, filters returned fields and is used as prioritized language code on + * @param array $prioritizedLanguages A language priority, filters returned fields and is used as prioritized language code on * returned value object. If not given all languages are returned. - * @param string|null $alwaysAvailableLanguage Language code fallback if a given field is not found in $languages + * @param string|null $alwaysAvailableLanguage Language code fallback if a given field is not found in $prioritizedLanguages * * @return array */ - public function buildDomainFields(array $spiFields, $contentType, array $languages = null, $alwaysAvailableLanguage = null) - { + public function buildDomainFields( + array $spiFields, + $contentType, + array $prioritizedLanguages = [], + string $alwaysAvailableLanguage = null + ) { if (!$contentType instanceof SPIType && !$contentType instanceof ContentType) { throw new InvalidArgumentType('$contentType', 'SPI ContentType | API ContentType'); } @@ -157,9 +248,9 @@ public function buildDomainFields(array $spiFields, $contentType, array $languag } $fieldInFilterLanguagesMap = array(); - if ($languages !== null && $alwaysAvailableLanguage !== null) { + if (!empty($prioritizedLanguages) && $alwaysAvailableLanguage !== null) { foreach ($spiFields as $spiField) { - if (in_array($spiField->languageCode, $languages)) { + if (in_array($spiField->languageCode, $prioritizedLanguages)) { $fieldInFilterLanguagesMap[$spiField->fieldDefinitionId] = true; } } @@ -172,8 +263,8 @@ public function buildDomainFields(array $spiFields, $contentType, array $languag continue; } - if ($languages !== null && !in_array($spiField->languageCode, $languages)) { - // If filtering is enabled we ignore fields in other languages then $fieldLanguages, if: + if (!empty($prioritizedLanguages) && !in_array($spiField->languageCode, $prioritizedLanguages)) { + // If filtering is enabled we ignore fields in other languages then $prioritizedLanguages, if: if ($alwaysAvailableLanguage === null) { // Ignore field if we don't have $alwaysAvailableLanguageCode fallback continue; @@ -339,20 +430,34 @@ public function buildRelationDomainObject( } /** - * Builds domain location object from provided persistence location. - * - * @param \eZ\Publish\SPI\Persistence\Content\Location $spiLocation - * @param \eZ\Publish\SPI\Persistence\Content\ContentInfo|null $contentInfo - * - * @return \eZ\Publish\API\Repository\Values\Content\Location + * @deprecated Since 7.2, use buildLocationWithContent(), buildLocation() or (private) mapLocation() instead. */ - public function buildLocationDomainObject(SPILocation $spiLocation, SPIContentInfo $contentInfo = null) - { - // TODO: this is hardcoded workaround for missing ContentInfo on root location + public function buildLocationDomainObject( + SPILocation $spiLocation, + SPIContentInfo $contentInfo = null + ) { + if ($contentInfo === null) { + return $this->buildLocation($spiLocation); + } + + return $this->mapLocation( + $spiLocation, + $this->buildContentInfoDomainObject($contentInfo), + $this->buildContentProxy($contentInfo) + ); + } + + public function buildLocation( + SPILocation $spiLocation, + array $prioritizedLanguages = [], + bool $useAlwaysAvailable = true + ): APILocation { if ($spiLocation->id == 1) { $legacyDateTime = $this->getDateTime(1030968000); // first known commit of eZ Publish 3.x - $contentInfo = new ContentInfo( - array( + // NOTE: this is hardcoded workaround for missing ContentInfo on root location + return $this->mapLocation( + $spiLocation, + new ContentInfo([ 'id' => 0, 'name' => 'Top Level Nodes', 'sectionId' => 1, @@ -366,16 +471,39 @@ public function buildLocationDomainObject(SPILocation $spiLocation, SPIContentIn 'alwaysAvailable' => 1, 'remoteId' => null, 'mainLanguageCode' => 'eng-GB', - ) + ]), + new Content([]) ); + } + + $spiContentInfo = $this->contentHandler->loadContentInfo($spiLocation->contentId); + + return $this->mapLocation( + $spiLocation, + $this->buildContentInfoDomainObject($spiContentInfo), + $this->buildContentProxy($spiContentInfo, $prioritizedLanguages, $useAlwaysAvailable) + ); + } + + public function buildLocationWithContent( + SPILocation $spiLocation, + APIContent $content, + SPIContentInfo $spiContentInfo = null + ): APILocation { + if ($spiContentInfo !== null) { + $contentInfo = $this->buildContentInfoDomainObject($spiContentInfo); } else { - $contentInfo = $this->buildContentInfoDomainObject( - $contentInfo ?: $this->contentHandler->loadContentInfo($spiLocation->contentId) - ); + $contentInfo = $content->contentInfo; } + return $this->mapLocation($spiLocation, $contentInfo, $content); + } + + private function mapLocation(SPILocation $spiLocation, ContentInfo $contentInfo, APIContent $content): APILocation + { return new Location( array( + 'content' => $content, 'contentInfo' => $contentInfo, 'id' => $spiLocation->id, 'priority' => $spiLocation->priority, @@ -391,17 +519,78 @@ public function buildLocationDomainObject(SPILocation $spiLocation, SPIContentIn ); } + /** + * Build API Content domain objects in bulk and apply to ContentSearchResult. + * + * Loading of Content objects are done in bulk. + * + * @param \eZ\Publish\API\Repository\Values\Content\Search\SearchResult $result SPI search result with SPI ContentInfo items as hits + * @param array $languageFilter + * + * @return \eZ\Publish\SPI\Persistence\Content\ContentInfo[] ContentInfo we did not find content for is returned. + */ + public function buildContentDomainObjectsOnSearchResult(SearchResult $result, array $languageFilter) + { + if (empty($result->searchHits)) { + return []; + } + + $loadStructList = []; + $prioritizedLanguages = $languageFilter['languages'] ?? []; + $useAlwaysAvailable = $languageFilter['useAlwaysAvailable'] ?? true; + foreach ($result->searchHits as $hit) { + if ($useAlwaysAvailable && $hit->valueObject->alwaysAvailable) { + $languages = $prioritizedLanguages; + $languages[] = $hit->valueObject->mainLanguageCode; + $loadStructList[] = new SPIContent\LoadStruct([ + 'id' => $hit->valueObject->id, + 'languages' => $languages, + ]); + } else { + $loadStructList[] = new SPIContent\LoadStruct([ + 'id' => $hit->valueObject->id, + 'languages' => $prioritizedLanguages, + ]); + } + } + + $missingContentList = []; + $contentList = $this->contentHandler->loadContentList($loadStructList); + foreach ($result->searchHits as $key => $hit) { + if (isset($contentList[$hit->valueObject->id])) { + $hit->valueObject = $this->buildContentDomainObject( + $contentList[$hit->valueObject->id], + null,//@todo bulk load content type, AND(~/OR~) add in-memory cache for it which will also benefit all cases + $languageFilter['languages'] ?? [], + $useAlwaysAvailable ? $hit->valueObject->mainLanguageCode : null + ); + } else { + $missingContentList[] = $hit->valueObject; + unset($result->searchHits[$key]); + --$result->totalCount; + } + } + + return $missingContentList; + } + /** * Build API Location and corresponding ContentInfo domain objects and apply to LocationSearchResult. * - * Loading of ContentInfo objects are done in one operation. + * This is done in order to be able to: + * Load ContentInfo objects in bulk, generate proxy objects for Content that will loaded in bulk on-demand (on use). * * @param \eZ\Publish\API\Repository\Values\Content\Search\SearchResult $result SPI search result with SPI Location items as hits + * @param array $languageFilter * - * @return \eZ\Publish\SPI\Persistence\Content\Location[] Locations we did not find content info for is retunred as an array. + * @return \eZ\Publish\SPI\Persistence\Content\Location[] Locations we did not find content info for is returned. */ - public function buildLocationDomainObjectsOnSearchResult(SearchResult $result) + public function buildLocationDomainObjectsOnSearchResult(SearchResult $result, array $languageFilter) { + if (empty($result->searchHits)) { + return []; + } + $contentIds = []; foreach ($result->searchHits as $hit) { $contentIds[] = $hit->valueObject->contentId; @@ -409,10 +598,15 @@ public function buildLocationDomainObjectsOnSearchResult(SearchResult $result) $missingLocations = []; $contentInfoList = $this->contentHandler->loadContentInfoList($contentIds); + $contentList = $this->buildContentProxyList( + $contentInfoList, + !empty($languageFilter['languages']) ? $languageFilter['languages'] : [] + ); foreach ($result->searchHits as $key => $hit) { if (isset($contentInfoList[$hit->valueObject->contentId])) { - $hit->valueObject = $this->buildLocationDomainObject( + $hit->valueObject = $this->buildLocationWithContent( $hit->valueObject, + $contentList[$hit->valueObject->contentId], $contentInfoList[$hit->valueObject->contentId] ); } else { diff --git a/eZ/Publish/Core/Repository/LocationService.php b/eZ/Publish/Core/Repository/LocationService.php index c61e5d9f5a5..a0a52dbb628 100644 --- a/eZ/Publish/Core/Repository/LocationService.php +++ b/eZ/Publish/Core/Repository/LocationService.php @@ -182,23 +182,16 @@ public function copySubtree(APILocation $subtree, APILocation $targetParentLocat throw $e; } - return $this->domainMapper->buildLocationDomainObject($newLocation); + return $this->domainMapper->buildLocationWithContent($newLocation, $content); } /** - * Loads a location object from its $locationId. - * - * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException If the current user user is not allowed to read this location - * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException If the specified location is not found - * - * @param mixed $locationId - * - * @return \eZ\Publish\API\Repository\Values\Content\Location + * {@inheritdoc} */ - public function loadLocation($locationId) + public function loadLocation($locationId, array $prioritizedLanguages = null) { $spiLocation = $this->persistenceHandler->locationHandler()->load($locationId); - $location = $this->domainMapper->buildLocationDomainObject($spiLocation); + $location = $this->domainMapper->buildLocation($spiLocation, $prioritizedLanguages ?: []); if (!$this->repository->canUser('content', 'read', $location->getContentInfo(), $location)) { throw new UnauthorizedException('content', 'read'); } @@ -207,24 +200,16 @@ public function loadLocation($locationId) } /** - * Loads a location object from its $remoteId. - * - * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException If the current user user is not allowed to read this location - * @throws \eZ\Publish\API\Repository\Exceptions\BadStateException If more than one location with same remote ID was found - * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException If the specified location is not found - * - * @param string $remoteId - * - * @return \eZ\Publish\API\Repository\Values\Content\Location + * {@inheritdoc} */ - public function loadLocationByRemoteId($remoteId) + public function loadLocationByRemoteId($remoteId, array $prioritizedLanguages = null) { if (!is_string($remoteId)) { throw new InvalidArgumentValue('remoteId', $remoteId); } $spiLocation = $this->persistenceHandler->locationHandler()->loadByRemoteId($remoteId); - $location = $this->domainMapper->buildLocationDomainObject($spiLocation); + $location = $this->domainMapper->buildLocation($spiLocation, $prioritizedLanguages ?: []); if (!$this->repository->canUser('content', 'read', $location->getContentInfo(), $location)) { throw new UnauthorizedException('content', 'read'); } @@ -235,7 +220,7 @@ public function loadLocationByRemoteId($remoteId) /** * {@inheritdoc} */ - public function loadLocations(ContentInfo $contentInfo, APILocation $rootLocation = null) + public function loadLocations(ContentInfo $contentInfo, APILocation $rootLocation = null, array $prioritizedLanguages = null) { if (!$contentInfo->published) { throw new BadStateException('$contentInfo', 'ContentInfo has no published versions'); @@ -247,8 +232,10 @@ public function loadLocations(ContentInfo $contentInfo, APILocation $rootLocatio ); $locations = []; + $spiInfo = $this->persistenceHandler->contentHandler()->loadContentInfo($contentInfo->id); + $content = $this->domainMapper->buildContentProxy($spiInfo, $prioritizedLanguages ?: []); foreach ($spiLocations as $spiLocation) { - $location = $this->domainMapper->buildLocationDomainObject($spiLocation); + $location = $this->domainMapper->buildLocationWithContent($spiLocation, $content, $spiInfo); if ($this->repository->canUser('content', 'read', $location->getContentInfo(), $location)) { $locations[] = $location; } @@ -258,15 +245,9 @@ public function loadLocations(ContentInfo $contentInfo, APILocation $rootLocatio } /** - * Loads children which are readable by the current user of a location object sorted by sortField and sortOrder. - * - * @param \eZ\Publish\API\Repository\Values\Content\Location $location - * @param int $offset the start offset for paging - * @param int $limit the number of locations returned - * - * @return \eZ\Publish\API\Repository\Values\Content\LocationList + * {@inheritdoc} */ - public function loadLocationChildren(APILocation $location, $offset = 0, $limit = 25) + public function loadLocationChildren(APILocation $location, $offset = 0, $limit = 25, array $prioritizedLanguages = null) { if (!$this->domainMapper->isValidLocationSortField($location->sortField)) { throw new InvalidArgumentValue('sortField', $location->sortField, 'Location'); @@ -285,7 +266,7 @@ public function loadLocationChildren(APILocation $location, $offset = 0, $limit } $childLocations = array(); - $searchResult = $this->searchChildrenLocations($location, $offset, $limit); + $searchResult = $this->searchChildrenLocations($location, $offset, $limit, $prioritizedLanguages ?: []); foreach ($searchResult->searchHits as $searchHit) { $childLocations[] = $searchHit->valueObject; } @@ -301,7 +282,7 @@ public function loadLocationChildren(APILocation $location, $offset = 0, $limit /** * {@inheritdoc} */ - public function loadParentLocationsForDraftContent(VersionInfo $versionInfo) + public function loadParentLocationsForDraftContent(VersionInfo $versionInfo, array $prioritizedLanguages = null) { if (!$versionInfo->isDraft()) { throw new BadStateException( @@ -313,14 +294,27 @@ public function loadParentLocationsForDraftContent(VersionInfo $versionInfo) ) ); } + $spiLocations = $this->persistenceHandler ->locationHandler() ->loadParentLocationsForDraftContent($versionInfo->contentInfo->id); + $contentIds = []; + foreach ($spiLocations as $spiLocation) { + $contentIds[] = $spiLocation->contentId; + } + $locations = []; $permissionResolver = $this->repository->getPermissionResolver(); + $spiContentInfoList = $this->persistenceHandler->contentHandler()->loadContentInfoList($contentIds); + $contentList = $this->domainMapper->buildContentProxyList($spiContentInfoList, $prioritizedLanguages ?: []); foreach ($spiLocations as $spiLocation) { - $location = $this->domainMapper->buildLocationDomainObject($spiLocation); + $location = $this->domainMapper->buildLocationWithContent( + $spiLocation, + $contentList[$spiLocation->contentId], + $spiContentInfoList[$spiLocation->contentId] + ); + if ($permissionResolver->canUser('content', 'read', $location->getContentInfo(), [$location])) { $locations[] = $location; } @@ -352,7 +346,7 @@ public function getLocationChildCount(APILocation $location) * * @return \eZ\Publish\API\Repository\Values\Content\Search\SearchResult */ - protected function searchChildrenLocations(APILocation $location, $offset = 0, $limit = -1) + protected function searchChildrenLocations(APILocation $location, $offset = 0, $limit = -1, array $prioritizedLanguages = null) { $query = new LocationQuery([ 'filter' => new Criterion\ParentLocationId($location->id), @@ -361,7 +355,7 @@ protected function searchChildrenLocations(APILocation $location, $offset = 0, $ 'sortClauses' => $location->getSortClauses(), ]); - return $this->repository->getSearchService()->findLocations($query); + return $this->repository->getSearchService()->findLocations($query, ['languages' => $prioritizedLanguages]); } /** @@ -442,7 +436,7 @@ public function createLocation(ContentInfo $contentInfo, LocationCreateStruct $l throw $e; } - return $this->domainMapper->buildLocationDomainObject($newLocation); + return $this->domainMapper->buildLocationWithContent($newLocation, $content); } /** diff --git a/eZ/Publish/Core/Repository/SearchService.php b/eZ/Publish/Core/Repository/SearchService.php index eea62ae259d..524d0bcbe59 100644 --- a/eZ/Publish/Core/Repository/SearchService.php +++ b/eZ/Publish/Core/Repository/SearchService.php @@ -19,8 +19,6 @@ use eZ\Publish\API\Repository\Values\Content\LocationQuery; use eZ\Publish\API\Repository\Repository as RepositoryInterface; use eZ\Publish\API\Repository\Values\Content\Search\SearchResult; -use eZ\Publish\API\Repository\Exceptions\NotFoundException as APINotFoundException; -use eZ\Publish\API\Repository\Exceptions\UnauthorizedException as APIUnauthorizedException; use eZ\Publish\Core\Base\Exceptions\NotFoundException; use eZ\Publish\Core\Base\Exceptions\InvalidArgumentException; use eZ\Publish\Core\Base\Exceptions\InvalidArgumentType; @@ -107,28 +105,10 @@ public function __construct( */ public function findContent(Query $query, array $languageFilter = array(), $filterOnUserPermissions = true) { - $contentService = $this->repository->getContentService(); $result = $this->internalFindContentInfo($query, $languageFilter, $filterOnUserPermissions); - foreach ($result->searchHits as $key => $hit) { - try { - // As we get ContentInfo from SPI, we need to load full content (avoids getting stale content data) - $hit->valueObject = $contentService->internalLoadContent( - $hit->valueObject->id, - (!empty($languageFilter['languages']) ? $languageFilter['languages'] : null), - null, - false, - (isset($languageFilter['useAlwaysAvailable']) ? $languageFilter['useAlwaysAvailable'] : true) - ); - } catch (APINotFoundException $e) { - // Most likely stale data, so we register content for background re-indexing. - $this->backgroundIndexer->registerContent($hit->valueObject); - unset($result->searchHits[$key]); - --$result->totalCount; - } catch (APIUnauthorizedException $e) { - // Most likely stale cached permission criterion, as ttl is only a few seconds we don't react to this - unset($result->searchHits[$key]); - --$result->totalCount; - } + $missingContentList = $this->domainMapper->buildContentDomainObjectsOnSearchResult($result, $languageFilter); + foreach ($missingContentList as $missingContent) { + $this->backgroundIndexer->registerContent($missingContent); } return $result; @@ -334,7 +314,7 @@ public function findLocations(LocationQuery $query, array $languageFilter = arra $result = $this->searchHandler->findLocations($query, $languageFilter); - $missingLocations = $this->domainMapper->buildLocationDomainObjectsOnSearchResult($result); + $missingLocations = $this->domainMapper->buildLocationDomainObjectsOnSearchResult($result, $languageFilter); foreach ($missingLocations as $missingLocation) { $this->backgroundIndexer->registerLocation($missingLocation); } diff --git a/eZ/Publish/Core/Repository/Tests/Service/Mock/DomainMapperTest.php b/eZ/Publish/Core/Repository/Tests/Service/Mock/DomainMapperTest.php index 5f1a4436932..37e083ad6d2 100644 --- a/eZ/Publish/Core/Repository/Tests/Service/Mock/DomainMapperTest.php +++ b/eZ/Publish/Core/Repository/Tests/Service/Mock/DomainMapperTest.php @@ -112,9 +112,9 @@ public function providerForBuildLocationDomainObjectsOnSearchResult() ]; return [ - [$locationHits, [32, 33], [32 => new ContentInfo(['id' => 32]), 33 => new ContentInfo(['id' => 33])], 0], - [$locationHits, [32, 33], [32 => new ContentInfo(['id' => 32])], 1], - [$locationHits, [32, 33], [], 2], + [$locationHits, [32, 33], [], [32 => new ContentInfo(['id' => 32]), 33 => new ContentInfo(['id' => 33])], 0], + [$locationHits, [32, 33], ['languages' => ['eng-GB']], [32 => new ContentInfo(['id' => 32])], 1], + [$locationHits, [32, 33], ['languages' => ['eng-GB']], [], 2], ]; } @@ -124,12 +124,14 @@ public function providerForBuildLocationDomainObjectsOnSearchResult() * * @param array $locationHits * @param array $contentIds + * @param array $languageFilter * @param array $contentInfoList * @param int $missing */ public function testBuildLocationDomainObjectsOnSearchResult( array $locationHits, array $contentIds, + array $languageFilter, array $contentInfoList, int $missing ) { @@ -146,7 +148,7 @@ public function testBuildLocationDomainObjectsOnSearchResult( } $spiResult = clone $result; - $missingLocations = $this->getDomainMapper()->buildLocationDomainObjectsOnSearchResult($result); + $missingLocations = $this->getDomainMapper()->buildLocationDomainObjectsOnSearchResult($result, $languageFilter); $this->assertInternalType('array', $missingLocations); if (!$missing) { diff --git a/eZ/Publish/Core/Repository/Tests/Service/Mock/SearchTest.php b/eZ/Publish/Core/Repository/Tests/Service/Mock/SearchTest.php index 38f53b3c9f4..1dd66f0fa8f 100644 --- a/eZ/Publish/Core/Repository/Tests/Service/Mock/SearchTest.php +++ b/eZ/Publish/Core/Repository/Tests/Service/Mock/SearchTest.php @@ -8,8 +8,6 @@ */ namespace eZ\Publish\Core\Repository\Tests\Service\Mock; -use eZ\Publish\Core\Base\Exceptions\NotFoundException; -use eZ\Publish\Core\Base\Exceptions\UnauthorizedException; use eZ\Publish\Core\Repository\ContentService; use eZ\Publish\Core\Repository\Helper\DomainMapper; use eZ\Publish\Core\Repository\Tests\Service\Mock\Base as BaseServiceMockTest; @@ -241,42 +239,23 @@ public function testFindContentThrowsHandlerException() $service->findContent($query, array(), true); } - public function providerForFindContentWhenContentLoadThrowsException() - { - return [ - [ - new NotFoundException('content', 'id = 33'), - true, - ], - [ - new UnauthorizedException('content', 'read', ['id' => 33]), - false, - ], - ]; - } - /** * Test for the findContent() method when search is out of sync with persistence. * - * @dataProvider providerForFindContentWhenContentLoadThrowsException * @covers \eZ\Publish\Core\Repository\SearchService::findContent */ - public function testFindContentWhenContentLoadThrowsException($e, $index = true) + public function testFindContentWhenDomainMapperThrowsException() { $indexer = $this->createMock(BackgroundIndexer::class); - if ($index) { - $indexer->expects($this->once()) - ->method('registerContent') - ->with($this->isInstanceOf(SPIContentInfo::class)); - } else { - $indexer->expects($this->never())->method($this->anything()); - } + $indexer->expects($this->once()) + ->method('registerContent') + ->with($this->isInstanceOf(SPIContentInfo::class)); $service = $this->getMockBuilder(SearchService::class) ->setConstructorArgs([ - $repo = $this->getRepositoryMock(), + $this->getRepositoryMock(), $this->getSPIMockHandler('Search\\Handler'), - $this->getDomainMapperMock(), + $mapper = $this->getDomainMapperMock(), $this->getPermissionCriterionResolverMock(), $indexer, ])->setMethods(['internalFindContentInfo']) @@ -289,15 +268,15 @@ public function testFindContentWhenContentLoadThrowsException($e, $index = true) ->with($this->isInstanceOf(Query::class)) ->willReturn($result); - $contentService = $this->createMock(ContentService::class); - $contentService->expects($this->once()) - ->method('internalLoadContent') - ->with(33) - ->willThrowException($e); + $mapper->expects($this->once()) + ->method('buildContentDomainObjectsOnSearchResult') + ->with($this->equalTo($result), $this->equalTo([])) + ->willReturnCallback(function (SearchResult $spiResult) use ($info) { + unset($spiResult->searchHits[0]); + --$spiResult->totalCount; - $repo->expects($this->once()) - ->method('getContentService') - ->willReturn($contentService); + return [$info]; + }); $finalResult = $service->findContent(new Query()); @@ -313,34 +292,20 @@ public function testFindContentWhenContentLoadThrowsException($e, $index = true) */ public function testFindContentNoPermissionsFilter() { - $repositoryMock = $this->getRepositoryMock(); /** @var \eZ\Publish\SPI\Search\Handler $searchHandlerMock */ $searchHandlerMock = $this->getSPIMockHandler('Search\\Handler'); - $domainMapperMock = $this->getDomainMapperMock(); - $permissionsCriterionResolverMock = $this->getPermissionCriterionResolverMock(); + $repositoryMock = $this->getRepositoryMock(); $service = new SearchService( $repositoryMock, $searchHandlerMock, - $domainMapperMock, - $permissionsCriterionResolverMock, + $mapper = $this->getDomainMapperMock(), + $permissionsCriterionResolverMock = $this->getPermissionCriterionResolverMock(), new NullIndexer(), array() ); $repositoryMock->expects($this->never())->method('hasAccess'); - $repositoryMock - ->expects($this->once()) - ->method('getContentService') - ->will( - $this->returnValue( - $contentServiceMock = $this - ->getMockBuilder(ContentService::class) - ->disableOriginalConstructor() - ->getMock() - ) - ); - $serviceQuery = new Query(); $handlerQuery = new Query(array('filter' => new Criterion\MatchAll(), 'limit' => 25)); $languageFilter = array(); @@ -362,10 +327,14 @@ public function testFindContentNoPermissionsFilter() ) ); - $contentServiceMock - ->expects($this->once()) - ->method('internalLoadContent') - ->will($this->returnValue($contentMock)); + $mapper->expects($this->once()) + ->method('buildContentDomainObjectsOnSearchResult') + ->with($this->isInstanceOf(SearchResult::class), $this->equalTo([])) + ->willReturnCallback(function (SearchResult $spiResult) use ($contentMock) { + $spiResult->searchHits[0]->valueObject = $contentMock; + + return []; + }); $result = $service->findContent($serviceQuery, $languageFilter, false); @@ -388,13 +357,12 @@ public function testFindContentNoPermissionsFilter() */ public function testFindContentWithPermission() { - $repositoryMock = $this->getRepositoryMock(); /** @var \eZ\Publish\SPI\Search\Handler $searchHandlerMock */ $searchHandlerMock = $this->getSPIMockHandler('Search\\Handler'); $domainMapperMock = $this->getDomainMapperMock(); $permissionsCriterionResolverMock = $this->getPermissionCriterionResolverMock(); $service = new SearchService( - $repositoryMock, + $this->getRepositoryMock(), $searchHandlerMock, $domainMapperMock, $permissionsCriterionResolverMock, @@ -402,18 +370,6 @@ public function testFindContentWithPermission() array() ); - $repositoryMock - ->expects($this->once()) - ->method('getContentService') - ->will( - $this->returnValue( - $contentServiceMock = $this - ->getMockBuilder(ContentService::class) - ->disableOriginalConstructor() - ->getMock() - ) - ); - $criterionMock = $this ->getMockBuilder(Criterion::class) ->disableOriginalConstructor() @@ -423,6 +379,11 @@ public function testFindContentWithPermission() $spiContentInfo = new SPIContentInfo(); $contentMock = $this->getMockForAbstractClass(Content::class); + $permissionsCriterionResolverMock->expects($this->once()) + ->method('getPermissionsCriterion') + ->with('content', 'read') + ->will($this->returnValue(true)); + /* @var \PHPUnit\Framework\MockObject\MockObject $searchHandlerMock */ $searchHandlerMock->expects($this->once()) ->method('findContent') @@ -438,18 +399,15 @@ public function testFindContentWithPermission() ) ); - $domainMapperMock->expects($this->never()) - ->method($this->anything()); - - $contentServiceMock + $domainMapperMock ->expects($this->once()) - ->method('internalLoadContent') - ->will($this->returnValue($contentMock)); + ->method('buildContentDomainObjectsOnSearchResult') + ->with($this->isInstanceOf(SearchResult::class), $this->equalTo([])) + ->willReturnCallback(function (SearchResult $spiResult) use ($contentMock) { + $spiResult->searchHits[0]->valueObject = $contentMock; - $permissionsCriterionResolverMock->expects($this->once()) - ->method('getPermissionsCriterion') - ->with('content', 'read') - ->will($this->returnValue(true)); + return []; + }); $result = $service->findContent($query, $languageFilter, true); @@ -472,14 +430,13 @@ public function testFindContentWithPermission() */ public function testFindContentWithNoPermission() { - $repositoryMock = $this->getRepositoryMock(); /** @var \eZ\Publish\SPI\Search\Handler $searchHandlerMock */ $searchHandlerMock = $this->getSPIMockHandler('Search\\Handler'); $permissionsCriterionResolverMock = $this->getPermissionCriterionResolverMock(); $service = new SearchService( - $repositoryMock, + $this->getRepositoryMock(), $searchHandlerMock, - $this->getDomainMapperMock(), + $mapper = $this->getDomainMapperMock(), $permissionsCriterionResolverMock, new NullIndexer(), array() @@ -499,6 +456,11 @@ public function testFindContentWithNoPermission() ->with('content', 'read') ->will($this->returnValue(false)); + $mapper->expects($this->once()) + ->method('buildContentDomainObjectsOnSearchResult') + ->with($this->isInstanceOf(SearchResult::class), $this->equalTo([])) + ->willReturn([]); + $result = $service->findContent($query, array(), true); $this->assertEquals( @@ -512,12 +474,11 @@ public function testFindContentWithNoPermission() */ public function testFindContentWithDefaultQueryValues() { - $repositoryMock = $this->getRepositoryMock(); /** @var \eZ\Publish\SPI\Search\Handler $searchHandlerMock */ $searchHandlerMock = $this->getSPIMockHandler('Search\\Handler'); $domainMapperMock = $this->getDomainMapperMock(); $service = new SearchService( - $repositoryMock, + $this->getRepositoryMock(), $searchHandlerMock, $domainMapperMock, $this->getPermissionCriterionResolverMock(), @@ -525,28 +486,9 @@ public function testFindContentWithDefaultQueryValues() array() ); - $repositoryMock - ->expects($this->once()) - ->method('getContentService') - ->will( - $this->returnValue( - $contentServiceMock = $this - ->getMockBuilder(ContentService::class) - ->disableOriginalConstructor() - ->getMock() - ) - ); - $languageFilter = array(); $spiContentInfo = new SPIContentInfo(); $contentMock = $this->getMockForAbstractClass(Content::class); - $domainMapperMock->expects($this->never()) - ->method($this->anything()); - - $contentServiceMock - ->expects($this->once()) - ->method('internalLoadContent') - ->will($this->returnValue($contentMock)); /* @var \PHPUnit\Framework\MockObject\MockObject $searchHandlerMock */ $searchHandlerMock @@ -572,6 +514,16 @@ public function testFindContentWithDefaultQueryValues() ) ); + $domainMapperMock + ->expects($this->once()) + ->method('buildContentDomainObjectsOnSearchResult') + ->with($this->isInstanceOf(SearchResult::class), $this->equalTo([])) + ->willReturnCallback(function (SearchResult $spiResult) use ($contentMock) { + $spiResult->searchHits[0]->valueObject = $contentMock; + + return []; + }); + $result = $service->findContent(new Query(), $languageFilter, false); $this->assertEquals( diff --git a/eZ/Publish/Core/Repository/TrashService.php b/eZ/Publish/Core/Repository/TrashService.php index 16136a97fa2..5166dcf48eb 100644 --- a/eZ/Publish/Core/Repository/TrashService.php +++ b/eZ/Publish/Core/Repository/TrashService.php @@ -10,6 +10,8 @@ use eZ\Publish\API\Repository\TrashService as TrashServiceInterface; use eZ\Publish\API\Repository\Repository as RepositoryInterface; +use eZ\Publish\API\Repository\Values\Content\Content; +use eZ\Publish\API\Repository\Exceptions\UnauthorizedException as APIUnauthorizedException; use eZ\Publish\SPI\Persistence\Handler; use eZ\Publish\API\Repository\Values\Content\Location; use eZ\Publish\Core\Repository\Values\Content\TrashItem; @@ -91,7 +93,10 @@ public function loadTrashItem($trashItemId) } $spiTrashItem = $this->persistenceHandler->trashHandler()->loadTrashItem($trashItemId); - $trash = $this->buildDomainTrashItemObject($spiTrashItem); + $trash = $this->buildDomainTrashItemObject( + $spiTrashItem, + $this->repository->getContentService()->internalLoadContent($spiTrashItem->contentId) + ); if (!$this->repository->canUser('content', 'read', $trash->getContentInfo())) { throw new UnauthorizedException('content', 'read'); } @@ -131,13 +136,12 @@ public function trash(Location $location) throw $e; } - // Use sudo as we want a trash item regardless of user access to the trash. + // Use internalLoadContent() as we want a trash item regardless of user access to the trash or not. try { return isset($spiTrashItem) - ? $this->repository->sudo( - function () use ($spiTrashItem) { - return $this->buildDomainTrashItemObject($spiTrashItem); - } + ? $this->buildDomainTrashItemObject( + $spiTrashItem, + $this->repository->getContentService()->internalLoadContent($spiTrashItem->contentId) ) : null; } catch (Exception $e) { @@ -302,34 +306,38 @@ public function findTrashItems(Query $query) $query->sortClauses ); + $trashItems = $this->buildDomainTrashItems($spiTrashItems); + $searchResult = new SearchResult(); + $searchResult->totalCount = $searchResult->count = count($trashItems); + $searchResult->items = $trashItems; + + return $searchResult; + } + + protected function buildDomainTrashItems(array $spiTrashItems): array + { $trashItems = array(); + // TODO: load content in bulk once API allows for it foreach ($spiTrashItems as $spiTrashItem) { try { - $trashItems[] = $this->buildDomainTrashItemObject($spiTrashItem); - } catch (UnauthorizedException $e) { + $trashItems[] = $this->buildDomainTrashItemObject( + $spiTrashItem, + $this->repository->getContentService()->loadContent($spiTrashItem->contentId) + ); + } catch (APIUnauthorizedException $e) { // Do nothing, thus exclude items the current user doesn't have read access to. } } - $searchResult = new SearchResult(); - $searchResult->totalCount = $searchResult->count = count($trashItems); - $searchResult->items = $trashItems; - - return $searchResult; + return $trashItems; } - /** - * Builds the domain TrashItem object from provided persistence trash item. - * - * @param \eZ\Publish\SPI\Persistence\Content\Location\Trashed $spiTrashItem - * - * @return \eZ\Publish\API\Repository\Values\Content\TrashItem - */ - protected function buildDomainTrashItemObject(Trashed $spiTrashItem) + protected function buildDomainTrashItemObject(Trashed $spiTrashItem, Content $content): APITrashItem { return new TrashItem( array( - 'contentInfo' => $this->repository->getContentService()->loadContentInfo($spiTrashItem->contentId), + 'content' => $content, + 'contentInfo' => $content->contentInfo, 'id' => $spiTrashItem->id, 'priority' => $spiTrashItem->priority, 'hidden' => $spiTrashItem->hidden, diff --git a/eZ/Publish/Core/Repository/Values/Content/ContentContentInfoProxy.php b/eZ/Publish/Core/Repository/Values/Content/ContentContentInfoProxy.php new file mode 100644 index 00000000000..7fd2ccaea25 --- /dev/null +++ b/eZ/Publish/Core/Repository/Values/Content/ContentContentInfoProxy.php @@ -0,0 +1,53 @@ +proxy = $proxy; + $this->id = $id; + + // See warning on class doc. + parent::__construct(['status' => $status]); + } + + /** + * Get the inner content Info value object from ContentProxy. + */ + protected function loadObject() + { + $this->object = $this->proxy->getVersionInfo()->getContentInfo(); + $this->proxy = null; + } +} diff --git a/eZ/Publish/Core/Repository/Values/Content/ContentProxy.php b/eZ/Publish/Core/Repository/Values/Content/ContentProxy.php new file mode 100644 index 00000000000..0d4fcb2db83 --- /dev/null +++ b/eZ/Publish/Core/Repository/Values/Content/ContentProxy.php @@ -0,0 +1,128 @@ +id; + } + + if ($name === 'contentInfo') { + return $this->getContentInfo(); + } + + if ($this->object === null) { + $this->loadObject(); + } + + return $this->object->$name; + } + + public function __isset($name) + { + if ($name === 'id' || $name === 'contentInfo') { + return true; + } + + if ($this->object === null) { + $this->loadObject(); + } + + return isset($this->object->$name); + } + + /** + * Return content info, in proxy form if we have not loaded object yet. + * + * For usage in among others DomainMapper->buildLocation() to make sure we can lazy load content info retrieval. + * + * @return \eZ\Publish\API\Repository\Values\Content\ContentInfo + */ + protected function getContentInfo() + { + if ($this->object === null) { + if ($this->contentInfoProxy === null) { + $this->contentInfoProxy = new ContentContentInfoProxy($this, $this->id); + } + + return $this->contentInfoProxy; + } elseif ($this->contentInfoProxy !== null) { + // Remove ref when we no longer need the proxy + $this->contentInfoProxy = null; + } + + return $this->object->getVersionInfo()->getContentInfo(); + } + + public function getVersionInfo() + { + if ($this->object === null) { + $this->loadObject(); + } + + return $this->object->getVersionInfo(); + } + + public function getFieldValue($fieldDefIdentifier, $languageCode = null) + { + if ($this->object === null) { + $this->loadObject(); + } + + return $this->object->getFieldValue($fieldDefIdentifier, $languageCode); + } + + public function getFields() + { + if ($this->object === null) { + $this->loadObject(); + } + + return $this->object->getFields(); + } + + public function getFieldsByLanguage($languageCode = null) + { + if ($this->object === null) { + $this->loadObject(); + } + + return $this->object->getFieldsByLanguage($languageCode); + } + + public function getField($fieldDefIdentifier, $languageCode = null) + { + if ($this->object === null) { + $this->loadObject(); + } + + return $this->object->getField($fieldDefIdentifier, $languageCode); + } +} diff --git a/eZ/Publish/Core/SignalSlot/LocationService.php b/eZ/Publish/Core/SignalSlot/LocationService.php index 2dbaad4f6c7..ce346845ca6 100644 --- a/eZ/Publish/Core/SignalSlot/LocationService.php +++ b/eZ/Publish/Core/SignalSlot/LocationService.php @@ -88,73 +88,43 @@ public function copySubtree(Location $subtree, Location $targetParentLocation) } /** - * Loads a location object from its $locationId. - * - * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException If the current user user is not allowed to read this location - * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException If the specified location is not found - * - * @param mixed $locationId - * - * @return \eZ\Publish\API\Repository\Values\Content\Location + * {@inheritdoc} */ - public function loadLocation($locationId) + public function loadLocation($locationId, array $prioritizedLanguages = null) { - return $this->service->loadLocation($locationId); + return $this->service->loadLocation($locationId, $prioritizedLanguages); } /** - * Loads a location object from its $remoteId. - * - * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException If the current user user is not allowed to read this location - * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException If the specified location is not found - * - * @param string $remoteId - * - * @return \eZ\Publish\API\Repository\Values\Content\Location + * {@inheritdoc} */ - public function loadLocationByRemoteId($remoteId) + public function loadLocationByRemoteId($remoteId, array $prioritizedLanguages = null) { - return $this->service->loadLocationByRemoteId($remoteId); + return $this->service->loadLocationByRemoteId($remoteId, $prioritizedLanguages); } /** - * Loads the locations for the given content object. - * - * If a $rootLocation is given, only locations that belong to this location are returned. - * The location list is also filtered by permissions on reading locations. - * - * @throws \eZ\Publish\API\Repository\Exceptions\BadStateException if there is no published version yet - * - * @param \eZ\Publish\API\Repository\Values\Content\ContentInfo $contentInfo - * @param \eZ\Publish\API\Repository\Values\Content\Location $rootLocation - * - * @return \eZ\Publish\API\Repository\Values\Content\Location[] An array of {@link Location} + * {@inheritdoc} */ - public function loadLocations(ContentInfo $contentInfo, Location $rootLocation = null) + public function loadLocations(ContentInfo $contentInfo, Location $rootLocation = null, array $prioritizedLanguages = null) { - return $this->service->loadLocations($contentInfo, $rootLocation); + return $this->service->loadLocations($contentInfo, $rootLocation, $prioritizedLanguages); } /** - * Loads children which are readable by the current user of a location object sorted by sortField and sortOrder. - * - * @param \eZ\Publish\API\Repository\Values\Content\Location $location - * @param int $offset the start offset for paging - * @param int $limit the number of locations returned - * - * @return \eZ\Publish\API\Repository\Values\Content\LocationList + * {@inheritdoc} */ - public function loadLocationChildren(Location $location, $offset = 0, $limit = 25) + public function loadLocationChildren(Location $location, $offset = 0, $limit = 25, array $prioritizedLanguages = null) { - return $this->service->loadLocationChildren($location, $offset, $limit); + return $this->service->loadLocationChildren($location, $offset, $limit, $prioritizedLanguages); } /** * {@inheritdoc} */ - public function loadParentLocationsForDraftContent(VersionInfo $versionInfo) + public function loadParentLocationsForDraftContent(VersionInfo $versionInfo, array $prioritizedLanguages = null) { - return $this->service->loadParentLocationsForDraftContent($versionInfo); + return $this->service->loadParentLocationsForDraftContent($versionInfo, $prioritizedLanguages); } /** diff --git a/eZ/Publish/Core/SignalSlot/Tests/LocationServiceTest.php b/eZ/Publish/Core/SignalSlot/Tests/LocationServiceTest.php index 9f718bb405f..8a37a50280d 100644 --- a/eZ/Publish/Core/SignalSlot/Tests/LocationServiceTest.php +++ b/eZ/Publish/Core/SignalSlot/Tests/LocationServiceTest.php @@ -87,28 +87,34 @@ public function serviceProvider() ), array( 'loadLocation', - array($rootId), + array($rootId, []), $root, 0, ), array( 'loadLocationByRemoteId', - array($rootRemoteId), + array($rootRemoteId, []), $root, 0, ), array( 'loadLocations', - array($locationContentInfo, $root), + array($locationContentInfo, $root, []), array($location), 0, ), array( 'loadLocationChildren', - array($root, 0, 1), + array($root, 0, 1, []), $rootChildren, 0, ), + /*array( + 'loadParentLocationsForDraftContent', + array($root, 0, 1, []), + $rootChildren, + 0, + ),*/ array( 'getLocationChildCount', array($root), diff --git a/eZ/Publish/SPI/Persistence/Content/Handler.php b/eZ/Publish/SPI/Persistence/Content/Handler.php index 5b57c5c4d44..00ae1fb90a7 100644 --- a/eZ/Publish/SPI/Persistence/Content/Handler.php +++ b/eZ/Publish/SPI/Persistence/Content/Handler.php @@ -67,6 +67,21 @@ public function createDraftFromVersion($contentId, $srcVersion, $userId); */ public function load($id, $version, array $translations = null); + /** + * Return list of unique Content, with content id as key. + * + * Missing items (NotFound) will be missing from the array and not cause an exception, it's up + * to calling logic to determine if this should cause exception or not. + * + * NOTE: Even if LoadStruct technically allows to load several versions of same content, this method does not allow + * this by design as content is returned as Map with key being content id. + * + * @param \eZ\Publish\SPI\Persistence\Content\LoadStruct[] $contentLoadStructs + * + * @return \eZ\Publish\SPI\Persistence\Content[] + */ + public function loadContentList(array $contentLoadStructs): array; + /** * Returns the metadata object for a content identified by $contentId. * diff --git a/eZ/Publish/SPI/Persistence/Content/LoadStruct.php b/eZ/Publish/SPI/Persistence/Content/LoadStruct.php new file mode 100644 index 00000000000..c89c281a421 --- /dev/null +++ b/eZ/Publish/SPI/Persistence/Content/LoadStruct.php @@ -0,0 +1,48 @@ +