mountPointParameter = $this->sanitizeMountPointParameter($mountPointParameter); $this->context = $context ?? GeneralUtility::makeInstance(Context::class); $this->pageRepository = GeneralUtility::makeInstance(PageRepository::class, $this->context); $this->languageUid = $this->context->getPropertyFromAspect('language', 'id', 0); $this->workspaceUid = (int)$this->context->getPropertyFromAspect('workspace', 'id', 0); if ($this->mountPointParameter !== '') { if (!($GLOBALS['TYPO3_CONF_VARS']['FE']['enable_mount_pids'] ?? false)) { throw new MountPointsDisabledException('Mount-Point Pages are disabled for this installation. Cannot resolve a Rootline for a page with Mount-Points', 1343462896); } $this->parseMountPointParameter(); } $this->pageUid = $this->resolvePageId($uid); $this->cache = GeneralUtility::makeInstance(CacheManager::class)->getCache('rootline'); $this->runtimeCache = GeneralUtility::makeInstance(CacheManager::class)->getCache('runtime'); $this->cacheIdentifier = $this->getCacheIdentifier(); } /** * Returns the actual rootline without the tree root (uid=0), including the page with $this->pageUid * * @throws BrokenRootLineException * @throws CircularRootLineException * @throws PageNotFoundException * @throws DoctrineException */ public function get(): array { if ($this->pageUid === 0) { // pageUid 0 has no root line, return empty array right away return []; } if (!$this->runtimeCache->has('rootline-localcache-' . $this->cacheIdentifier)) { $entry = $this->cache->get($this->cacheIdentifier); if (!$entry) { $this->generateRootlineCache(); } else { $this->runtimeCache->set('rootline-localcache-' . $this->cacheIdentifier, $entry, [self::RUNTIME_CACHE_TAG]); $depth = count($entry); // Populate the root-lines for parent pages as well // since they are part of the current root-line while ($depth > 1) { --$depth; $parentCacheIdentifier = $this->getCacheIdentifier($entry[$depth - 1]['uid']); // Abort if the root-line of the parent page is // already in the local cache data if ($this->runtimeCache->has('rootline-localcache-' . $parentCacheIdentifier)) { break; } // Behaves similar to array_shift(), but preserves // the array keys - which contain the page ids here $entry = array_slice($entry, 1, null, true); $this->runtimeCache->set('rootline-localcache-' . $parentCacheIdentifier, $entry, [self::RUNTIME_CACHE_TAG]); } } } return $this->runtimeCache->get('rootline-localcache-' . $this->cacheIdentifier); } protected function getCacheIdentifier(?int $otherUid = null): string { $mountPointParameter = $this->mountPointParameter; if ($mountPointParameter !== '' && str_contains($mountPointParameter, ',')) { $mountPointParameter = str_replace(',', '__', $mountPointParameter); } return implode('_', [ $otherUid ?? $this->pageUid, $mountPointParameter, $this->languageUid, $this->workspaceUid, $this->context->getAspect('visibility')->includeHiddenContent() ? '1' : '0', $this->context->getAspect('visibility')->includeHiddenPages() ? '1' : '0', ]); } /** * Queries the database for the page record and returns it. * * @param int $uid Page id * @throws PageNotFoundException * @return array */ protected function getRecordArray(int $uid): array { $currentCacheIdentifier = $this->getCacheIdentifier($uid); if (!$this->runtimeCache->has('rootline-recordcache-' . $currentCacheIdentifier)) { $row = $this->getWorkspaceResolvedPageRecord($uid, $this->workspaceUid); if (is_array($row)) { $row = $this->enrichPageRecordArray($row, $uid); $this->runtimeCache->set('rootline-recordcache-' . $currentCacheIdentifier, $row, [self::RUNTIME_CACHE_TAG]); } } if (!is_array($this->runtimeCache->get('rootline-recordcache-' . $currentCacheIdentifier) ?? false)) { throw new PageNotFoundException('Broken rootline. Could not resolve page with uid ' . $uid . '.', 1343464101); } return $this->runtimeCache->get('rootline-recordcache-' . $currentCacheIdentifier); } /** * Resolve relations as defined in TCA and add them to the provided $pageRecord array. * * @param int $uid page ID * @param array $pageRecord Page record (possibly overlaid) to be extended with relations * @throws PagePropertyRelationNotFoundException * @return array $pageRecord with additional relations */ protected function enrichWithRelationFields(int $uid, array $pageRecord): array { $resultFieldUidArray = []; $localRelationColumns = []; $foreignRelationColumns = []; $foreignRelationColumnTableFieldMapping = []; $schema = GeneralUtility::makeInstance(TcaSchemaFactory::class)->get('pages'); foreach ($schema->getFields() as $column => $fieldType) { $configuration = $fieldType->getConfiguration(); if ($this->columnHasRelationToResolve($configuration)) { $resultFieldUidArray[$column] = []; if (!empty($configuration['MM']) && !empty($configuration['MM_opposite_field']) && !empty($configuration['foreign_table'])) { $foreignRelationColumns[] = $column; // This is a solution when multiple fields are on the foreign side in an MM relation to the same local side. // For instance, when there are two category fields in pages. $foreignRelationColumnTableFieldMapping[$configuration['foreign_table']][$configuration['MM_opposite_field']][$column] = 1; } else { $localRelationColumns[] = $column; } } } if (empty($localRelationColumns) && empty($foreignRelationColumns)) { // Early return if there are no relations to resolve at all. Typically, this does not kick in with pages, though. return $pageRecord; } // @todo: There is a general issue with starttime & endtime restrictions: The date aspect of course always changes. // Since the result of this operation is cached into rootline cache, resolving restrictions based on time // may result in invalid caches. We cannot add the time restriction to the cache identifier since that // would not match cache rows constantly. That cache may also kick in when admin panel "simulate time" // is used and no-cache is not forced in admin panel, also leading to invalid results. // This is currently not a *huge* issue, since timed records attached to pages are "relatively" seldom, and // FE instances that use it most likely do something like "clearCacheAtMidnight" anyways. // To ultimately solve the issue, we should either drop the persisted rootline_cache altogether (which should // be do-able when the main rootline query switches to a CTE and does not need the cache anymore), OR we // remove the starttime/endtime handling here again, and let consumers sort out timed records on their own, // which would be a pity. // @todo: We could potentially handle ['enablecolumns']['fe_group'] here as well. This however is more work // since we then need two further fields in refindex to track it. Also fe_group is one of those CSV // fields that has "virtual" db connections "-2" and "-1" that don't point to true records. refindex // already behaves funny with those (and has no good test coverage). Also, having (negative) int uids // is a violation for select, those should at least use non-int strings and set "allowNonIdValues". // At best, we'd find some other way for -2 and -1 to get rid of those virtual values entirely. Everything // in this area is probably breaking and needs upgrade wizards. // @todo: The queries below may benefit from being prepared and then fired with values, since they are potentially // executed often. This requires storing the prepared query in runtime cache, and requires switching from // named parameters to positional parameters. // @todo: Note the entire thing currently handles only non-CSV relations (there must be a TCA foreign_field or MM), // CSV values are not "filtered" and processed regarding hidden, starttime and similar at all. This could be // added later, but needs a careful implementation, for instance because of "allowNonIdValues", and combined // "table_uid" in type=group, and fe_group "virtual" -2 fields. // @todo: This operation always returns already workspace uids if they exist. It however does *not* return localization // uids in most cases, this still needs to be done manually, when working with localized pages. Also, // hidden, starttime and endtime of the default language record kicks in, not of the localization overlay // row. We could potentially model this in refindex, by de-normalizing localization overlays in refindex // as well, but this needs work and some decisions since language overlays may need to consider fallback chains. $visibilityAspect = $this->context->getAspect('visibility'); $includeHiddenContent = $visibilityAspect->includeHiddenContent(); $includeScheduledRecords = $visibilityAspect->includeScheduledRecords(); $dateTimestamp = (int)$this->context->getAspect('date')->get('timestamp'); if (!empty($localRelationColumns) && empty($foreignRelationColumns)) { // We only have local side relations. Run a simple refindex query. Typically, this does not kick in with pages since it has categories MM. // @todo: Add at least one test that manipulates TCA to verify this code branch works. $queryBuilder = $this->createQueryBuilder('sys_refindex'); $result = $queryBuilder->select('tablename', 'field', 'ref_uid') ->from('sys_refindex') ->orderBy('sorting') ->where( $queryBuilder->expr()->eq('tablename', $queryBuilder->createNamedParameter('pages')), $queryBuilder->expr()->eq('recuid', $queryBuilder->createNamedParameter($pageRecord['_ORIG_uid'] ?? $uid, Connection::PARAM_INT)), $queryBuilder->expr()->in('field', $queryBuilder->createNamedParameter($localRelationColumns, Connection::PARAM_STR_ARRAY)), $queryBuilder->expr()->eq('workspace', $this->workspaceUid), $queryBuilder->expr()->neq('ref_t3ver_state', VersionState::DELETE_PLACEHOLDER->value), $includeHiddenContent ? $queryBuilder->expr()->in('ref_hidden', [0, 1]) // Dummy restriction to not break combined index. : $queryBuilder->expr()->eq('ref_hidden', 0), $includeScheduledRecords ? $queryBuilder->expr()->lte('ref_starttime', 2147483647) // Dummy restriction to not break combined index. : $queryBuilder->expr()->lt('ref_starttime', $dateTimestamp), $includeScheduledRecords ? $queryBuilder->expr()->gte('ref_endtime', 0) // Dummy restriction to not break combined index. : $queryBuilder->expr()->gt('ref_endtime', $dateTimestamp) ) ->executeQuery(); while ($row = $result->fetchAssociative()) { $resultFieldUidArray[$row['field']][] = (int)$row['ref_uid']; } foreach ($resultFieldUidArray as $column => $connectedUids) { if (empty($connectedUids)) { $pageRecord[$column] = ''; } else { $pageRecord[$column] = implode(',', $connectedUids); } } return $pageRecord; } if (empty($localRelationColumns)) { // We only have foreign side relations. Run a simple refindex query. Typically, this does not kick in with pages since it has inline media. // @todo: Add at least one test that manipulates TCA to verify this code branch works. $queryBuilder = $this->createQueryBuilder('sys_refindex'); $result = $queryBuilder->select('tablename', 'field', 'recuid', 'ref_field') ->from('sys_refindex') ->orderBy('ref_sorting') ->where( $queryBuilder->expr()->eq('ref_table', $queryBuilder->createNamedParameter('pages')), // Use workspace-uid if the record is an overlay. $queryBuilder->expr()->eq('ref_uid', $queryBuilder->createNamedParameter($pageRecord['_ORIG_uid'] ?? $uid, Connection::PARAM_INT)), $queryBuilder->expr()->in('tablename', $queryBuilder->createNamedParameter(array_keys($foreignRelationColumnTableFieldMapping), Connection::PARAM_STR_ARRAY)), $queryBuilder->expr()->eq('workspace', $queryBuilder->createNamedParameter($this->workspaceUid, Connection::PARAM_INT)), $queryBuilder->expr()->neq('t3ver_state', VersionState::DELETE_PLACEHOLDER->value), $includeHiddenContent ? $queryBuilder->expr()->in('hidden', [0, 1]) // Dummy restriction to not break combined index. : $queryBuilder->expr()->eq('hidden', 0), $includeScheduledRecords ? $queryBuilder->expr()->lte('starttime', 2147483647) : $queryBuilder->expr()->lt('starttime', $dateTimestamp), $includeScheduledRecords ? $queryBuilder->expr()->gte('endtime', 0) : $queryBuilder->expr()->gt('endtime', $dateTimestamp) ) ->executeQuery(); while ($row = $result->fetchAssociative()) { if (isset($foreignRelationColumnTableFieldMapping[$row['tablename']][$row['field']][$row['ref_field']])) { $resultFieldUidArray[$row['ref_field']][] = (int)$row['recuid']; } } foreach ($resultFieldUidArray as $column => $connectedUids) { if (empty($connectedUids)) { $pageRecord[$column] = ''; } else { $pageRecord[$column] = implode(',', $connectedUids); } } return $pageRecord; } // We need rows from refindex by looking at both local side and foreign side. // This is done using a UNION of two distinct queries. This is pretty useful // since it saves a round trip and can use distinct indexes per "sub" query. // Named arguments however are global for both queries, so we use a dummy query // builder to gather them all. // Also, postgres and sqlite don't support sorting on single queries with UNION, // so we sort the final result set just before imploding to the final CSV per field. $namedArgumentsQB = $this->createQueryBuilder('sys_refindex'); $localQB = $this->createQueryBuilder('sys_refindex'); $localQB->select('tablename', 'field', 'sorting', 'recuid', 'ref_uid', 'ref_field', 'ref_sorting') ->from('sys_refindex') ->where( $localQB->expr()->eq('tablename', $namedArgumentsQB->createNamedParameter('pages')), $localQB->expr()->eq('recuid', $namedArgumentsQB->createNamedParameter($pageRecord['_ORIG_uid'] ?? $uid, Connection::PARAM_INT)), $localQB->expr()->in('field', $namedArgumentsQB->createNamedParameter($localRelationColumns, Connection::PARAM_STR_ARRAY)), $localQB->expr()->eq('workspace', $this->workspaceUid), $localQB->expr()->neq('ref_t3ver_state', VersionState::DELETE_PLACEHOLDER->value), $includeHiddenContent ? $localQB->expr()->in('ref_hidden', [0, 1]) // Dummy restriction to not break combined index. : $localQB->expr()->eq('ref_hidden', 0), $includeScheduledRecords ? $localQB->expr()->lte('ref_starttime', 2147483647) // Dummy restriction to not break combined index. : $localQB->expr()->lt('ref_starttime', $dateTimestamp), $includeScheduledRecords ? $localQB->expr()->gte('ref_endtime', 0) // Dummy restriction to not break combined index. : $localQB->expr()->gt('ref_endtime', $dateTimestamp) ); $foreignQB = $this->createQueryBuilder('sys_refindex'); $foreignQB->select('tablename', 'field', 'sorting', 'recuid', 'ref_uid', 'ref_field', 'ref_sorting') ->from('sys_refindex') ->where( $foreignQB->expr()->eq('ref_table', $namedArgumentsQB->createNamedParameter('pages')), // Use workspace-uid if the record is an overlay. $foreignQB->expr()->eq('ref_uid', $namedArgumentsQB->createNamedParameter($pageRecord['_ORIG_uid'] ?? $uid, Connection::PARAM_INT)), $foreignQB->expr()->in('tablename', $namedArgumentsQB->createNamedParameter(array_keys($foreignRelationColumnTableFieldMapping), Connection::PARAM_STR_ARRAY)), $foreignQB->expr()->eq('workspace', $namedArgumentsQB->createNamedParameter($this->workspaceUid, Connection::PARAM_INT)), $foreignQB->expr()->neq('t3ver_state', VersionState::DELETE_PLACEHOLDER->value), $includeHiddenContent ? $foreignQB->expr()->in('hidden', [0, 1]) // Dummy restriction to not break combined index. : $foreignQB->expr()->eq('hidden', 0), $includeScheduledRecords ? $foreignQB->expr()->lte('starttime', 2147483647) : $foreignQB->expr()->lt('starttime', $dateTimestamp), $includeScheduledRecords ? $foreignQB->expr()->gte('endtime', 0) : $foreignQB->expr()->gt('endtime', $dateTimestamp) ); $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_refindex'); $result = $connection->executeQuery( $localQB->getSQL() . ' UNION ALL ' . $foreignQB->getSQL(), $namedArgumentsQB->getParameters(), $namedArgumentsQB->getParameterTypes() ); while ($row = $result->fetchAssociative()) { if ($row['tablename'] === 'pages') { $resultFieldUidArray[$row['field']][(int)$row['sorting']] = (int)$row['ref_uid']; } elseif (isset($foreignRelationColumnTableFieldMapping[$row['tablename']][$row['field']][$row['ref_field']])) { $resultFieldUidArray[$row['ref_field']][(int)$row['ref_sorting']] = (int)$row['recuid']; } } foreach ($resultFieldUidArray as $column => $connectedUids) { if (empty($connectedUids)) { $pageRecord[$column] = ''; } else { ksort($connectedUids, SORT_NUMERIC); $pageRecord[$column] = implode(',', $connectedUids); } } return $pageRecord; } /** * Checks whether the TCA Configuration array of a column * describes a relation which is not stored as CSV in the record * * @param array $configuration TCA configuration to check * @return bool TRUE, if it describes a non-CSV relation */ protected function columnHasRelationToResolve(array $configuration): bool { if (!empty($configuration['MM']) && !empty($configuration['type']) && in_array($configuration['type'], ['select', 'inline', 'group'])) { return true; } if (!empty($configuration['foreign_field']) && !empty($configuration['type']) && in_array($configuration['type'], ['inline', 'file'])) { return true; } if (($configuration['type'] ?? '') === 'category' && ($configuration['relationship'] ?? '') === 'manyToMany') { return true; } return false; } /** * Actual function to generate the rootline and cache it * * @throws BrokenRootLineException * @throws CircularRootLineException * @throws PageNotFoundException * @throws DoctrineException */ protected function generateRootlineCache(): void { $pageId = $this->pageUid; $page = $this->getRecordArray($pageId); $parentPageId = $page['pid']; $workspaceId = $this->workspaceUid; if ($this->isMountedPage($pageId)) { // If the current page is a mounted (according to the MP parameter) handle the mount-point $page = $this->getRecordArray($pageId); $mountPoint = $this->getRecordArray($this->parsedMountPointParameters[$pageId]); $page = $this->processMountedPage($page, $mountPoint); $parentPageId = $mountPoint['pid']; // Anyhow after reaching the mount-point, we have to go up that rootline unset($this->parsedMountPointParameters[$this->pageUid]); } $rootline = $this->getRootlineFromRuntimeCache($parentPageId); if (!is_array($rootline)) { $rootline = $this->getRootlineRecords($parentPageId, $workspaceId); } $rootline[] = $page; $firstEntry = reset($rootline); // ensure valid rootline down to virtual tree root if (is_array($firstEntry) && $firstEntry['pid'] !== 0) { throw new PageNotFoundException('Broken rootline. Could not resolve full rootline for uid ' . $pageId . '.', 1721913589); } $cacheTags = []; foreach ($rootline as $entry) { $cacheTags[] = 'pageId_' . $entry['uid']; } krsort($rootline); $this->cache->set($this->cacheIdentifier, $rootline, $cacheTags); $this->runtimeCache->set('rootline-localcache-' . $this->cacheIdentifier, $rootline, [self::RUNTIME_CACHE_TAG]); // Reduce rootline page by page and set to runtime cache to eliminate the need to fetch rootline for a parent // page as a performance optimization when a children already generated the rootline. while ($rootline !== []) { // Behaves similar to array_shift(), but preserves the array keys. $rootline = array_slice($rootline, 1, null, true); if ($rootline !== []) { $cacheIdentifier = $this->getCacheIdentifier(array_first($rootline)['uid']); $this->runtimeCache->set('rootline-localcache-' . $cacheIdentifier, $rootline, [self::RUNTIME_CACHE_TAG]); } } } /** * Checks whether the current Page is a Mounted Page * (according to the MP-URL-Parameter) */ protected function isMountedPage(int $pageId): bool { return array_key_exists($pageId, $this->parsedMountPointParameters); } /** * Enhances with mount point information or replaces the node if needed * * @param array $mountedPageData page record array of mounted page * @param array $mountPointPageData page record array of mount point page * @throws BrokenRootLineException * @return array */ protected function processMountedPage(array $mountedPageData, array $mountPointPageData): array { $mountPid = $mountPointPageData['mount_pid'] ?? null; $uid = $mountedPageData['uid'] ?? null; if ((int)$mountPid !== (int)$uid) { throw new BrokenRootLineException('Broken rootline. Mountpoint parameter does not match the actual rootline. mount_pid (' . $mountPid . ') does not match page uid (' . $uid . ').', 1343464100); } // Current page replaces the original mount-page $mountUid = $mountPointPageData['uid'] ?? null; if (!empty($mountPointPageData['mount_pid_ol'])) { $mountedPageData['_MOUNT_OL'] = true; $mountedPageData['_MOUNT_PAGE'] = [ 'uid' => $mountUid, 'pid' => $mountPointPageData['pid'] ?? null, 'title' => $mountPointPageData['title'] ?? null, ]; } else { // The mount-page is not replaced, the mount-page itself has to be used $mountedPageData = $mountPointPageData; } $mountedPageData['_MOUNTED_FROM'] = $this->pageUid; $mountedPageData['_MP_PARAM'] = $this->pageUid . '-' . $mountUid; return $mountedPageData; } /** * Sanitize the MountPoint Parameter * Splits the MP-Param via "," and removes mountpoints * that don't have the format \d+-\d+ */ protected function sanitizeMountPointParameter(string $mountPointParameter): string { $mountPointParameter = trim($mountPointParameter); if ($mountPointParameter === '') { return ''; } $mountPoints = GeneralUtility::trimExplode(',', $mountPointParameter); foreach ($mountPoints as $key => $mP) { // If MP has incorrect format, discard it if (!preg_match('/^\d+-\d+$/', $mP)) { unset($mountPoints[$key]); } } return implode(',', $mountPoints); } /** * Parse the MountPoint Parameters * Splits the MP-Param via "," for several nested mountpoints * and afterwords registers the mountpoint configurations */ protected function parseMountPointParameter(): void { $mountPoints = GeneralUtility::trimExplode(',', $this->mountPointParameter); foreach ($mountPoints as $mP) { [$mountedPageUid, $mountPageUid] = GeneralUtility::intExplode('-', $mP); $this->parsedMountPointParameters[$mountedPageUid] = $mountPageUid; } } /** * Fetches the UID of the page. * * If the page was moved in a workspace, actually returns the UID * of the moved version in the workspace. */ protected function resolvePageId(int $pageId): int { if ($pageId === 0 || $this->workspaceUid === 0) { return $pageId; } $page = $this->resolvePageRecord($pageId); if (!isset($page['t3ver_state']) || VersionState::tryFrom($page['t3ver_state']) !== VersionState::MOVE_POINTER) { return $pageId; } $movePointerId = $this->resolveMovePointerId((int)$page['t3ver_oid']); return $movePointerId ?: $pageId; } protected function resolvePageRecord(int $pageId): ?array { $queryBuilder = $this->createQueryBuilder('pages'); $queryBuilder->getRestrictions()->removeAll() ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); $statement = $queryBuilder ->from('pages') ->select('uid', 't3ver_oid', 't3ver_state') ->where( $queryBuilder->expr()->eq( 'uid', $queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT) ) ) ->executeQuery(); $record = $statement->fetchAssociative(); return $record ?: null; } /** * Fetched the UID of the versioned record if the live record has been moved in a workspace. */ protected function resolveMovePointerId(int $liveId): ?int { $queryBuilder = $this->createQueryBuilder('pages'); $queryBuilder->getRestrictions()->removeAll() ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); $statement = $queryBuilder ->from('pages') ->select('uid') ->setMaxResults(1) ->where( $queryBuilder->expr()->eq( 't3ver_wsid', $queryBuilder->createNamedParameter($this->workspaceUid, Connection::PARAM_INT) ), $queryBuilder->expr()->eq( 't3ver_state', $queryBuilder->createNamedParameter(VersionState::MOVE_POINTER->value, Connection::PARAM_INT) ), $queryBuilder->expr()->eq( 't3ver_oid', $queryBuilder->createNamedParameter($liveId, Connection::PARAM_INT) ) ) ->executeQuery(); $movePointerId = $statement->fetchOne(); return $movePointerId ? (int)$movePointerId : null; } /** * Get enriched RootLine page records. * * This method uses a recursive common-table-expression (CTE) doing the first workspace overlay handling withing the * RootLine traversal. Language overlay and record enrichment (references data) are applied on retrieved records. * * In case a received record is a mounted page, a new instance of `RootlineUtility` is created to retrieve the * mounted page rootline and result spliced together. * * In case only live-workspace rootline is required, for example in normal frontend visitors without backend login * and selected workspaces, the created CTE is simplified avoiding irrelevant joins and further improve performance * in that case. * * Note that the created recursive CTE contains two guards against cycling rootline issues: * * Guard 1 - ancestor path guard: * ------------------------------ * * During the recursing a uid path is created (`__CTE_PATH__`) and in case that a page is already contained in the * parent record `__CTE_PATH__` the flag field `__CTE_IS_CYCLE__` is set to 1, otherwise it is 0. * * If the parent record `__CTE_IS_CYLCE__` is `1` no records are retrieved which retrieves a duplicate page record * except of this flag. During the record retrieval {@see CircularRootLineException} is thrown to abort and state * this crucial data corruption. * * This guard is designed to abort cycling recursion on the database side as early as possible while still transport * the cycling issue information to this method. * * Guard 2 - max recursion level guard: * ------------------------------------ * * During the recursive CTE handling recursion level info is created (`__CTE_LEVEL__`) and used as a hard recursion * limit fence. That means, if the level reaches {@see self::MAX_CTE_TRAVERSAL_LEVELS} no more page records are * received. In the case that neither GUARD 1, nor PID=0 abort criteria is reached and max level hit a meaningful * {@see BrokenRootLineException} exception is thrown. * * Note that further improvements are possible, for example adding language overlay handling directly to the CTE * when strategy how to deal with the three dispatched PSR-14 events in PageRepository has been made up. * * @todo As already mentioned above, mountpoint pages are resolved by calling a new `RootlineUtility` instance * which prevents the detection of circular mountpoint configuration like in the prior implementation. A * way to add mountpoint page replacements within the CTE needs to be evaluated and implemented to make a * circular mountpoint configuration finally detectable. At least first cycling rootline revealing database * data corruption are now included. * * @throws BrokenRootLineException * @throws CircularRootLineException * @throws DoctrineException */ protected function getRootlineRecords(int $pageId, int $workspaceId): array { if ($pageId === 0) { return []; } $cte = $this->createQueryBuilder('pages'); $cte->getRestrictions()->removeAll(); $expr = $cte->expr(); $pagesFields = $this->getPagesFields(); $resolvedPagesFields = array_filter($pagesFields, static fn($value) => $value !== 'uid'); array_walk( $resolvedPagesFields, static function (string &$value, int|string $key, string $prefixAlias) { $value = sprintf('%s.%s', $prefixAlias, $value); }, 'finalpages', ); $cte ->typo3_withRecursive( 'cte', // Unique rows is omitted to avoid superfluous distinct row determination by the database query executor // due to having level based data in the traversal part they would be distinct anyway. Duplicates are // sorted out by the implemented ancestor guard - see self::createTraversalQueryBuilder(). false, $this->createInitialQueryBuilder($cte, $pageId, $workspaceId), $this->createTraversalQueryBuilder($cte, $workspaceId), [ // data fields $cte->quoteIdentifier('uid'), $cte->quoteIdentifier('pid'), // workspace handling values $cte->quoteIdentifier('_ORIG_pid'), $cte->quoteIdentifier('_ORIG_uid'), // recursive handling fields $cte->quoteIdentifier('__CTE_JOIN_UID__'), $cte->quoteIdentifier('__CTE_LEVEL__'), $cte->quoteIdentifier('__CTE_PATH__'), $cte->quoteIdentifier('__CTE_IS_CYCLE__'), ], ) ->select(...[ 'cte.uid', ...$resolvedPagesFields, 'cte._ORIG_uid', 'cte._ORIG_pid', // Cycle detection guard implemented manually due to the fact that CTE cycle is not implemented by // all supported Database vendors at all or not following the SQL standard. The guard stops cycling // rootline early to avoid use-less cycle retrieval until __CTE_LEVEL__ reaches maximal hard level. // Note that these values are removed from result rows by `self::cleanWorkspaceResolvedPageRecord()` 'cte.__CTE_PATH__', 'cte.__CTE_IS_CYCLE__', 'cte.__CTE_LEVEL__', ]) ->from('cte') // It's important to traverse determined rootline records in the correct order, which means from the page // record down to the rootpage. The recursive CTE builds up the CTE level starting from current record as // level 1 and incrementing the level for each parent record, which means that we need to order by the // level in ascending order (1, 2, 3, 4). ->orderBy('cte.__CTE_LEVEL__', 'ASC') ->addOrderBy('cte.uid', 'ASC') ->innerJoin( 'cte', 'pages', 'finalpages', $expr->eq('finalpages.uid', $cte->quoteIdentifier('cte.__CTE_JOIN_UID__')) ); $records = []; $result = $cte->executeQuery(); while ($record = $result->fetchAssociative()) { $cyclingDetected = (bool)($record['__CTE_IS_CYCLE__'] ?? false); $recordLevel = (int)($record['__CTE_LEVEL__'] ?? 0); $recordId = (int)$record['uid']; $recordPid = (int)$record['pid']; $recordCacheIdentifier = $this->getCacheIdentifier($recordId); $record = $this->cleanWorkspaceResolvedPageRecord($record); if ($cyclingDetected) { // Cycling page records found by CTE path guard. // @todo CTE does not handle mountpoint page resolving yet and calling RootlineUtility in recursive // manner below not detecting cycling mountpoint configurations yet. CTE **must* be improved to // handle mountpoint replacements directly and ensure cycling detection finally works - which has // never been the case. throw new CircularRootLineException( 'Circular connection in rootline for page with uid ' . $this->pageUid . ' found. ' . 'Check your mountpoint configuration and page data with pid value pointing to a sub page.', 1343464103 ); } // Throw a concrete BrokenRootLineException with explaining message in case maximal CTE traversal limit has // been reached without ending on PID 0 - the virtual tree root node. // @todo Find a way to test this case which is not that easy because of the MAX_CTE_TRAVERSAL_LEVEL. if ($recordLevel >= self::MAX_CTE_TRAVERSAL_LEVELS && $recordPid !== 0) { throw new BrokenRootLineException( sprintf( 'Broken rootline. Could not resolve full rootline for uid %s. ' . 'Reached max traversal level %s without ending on pid 0.', $pageId, self::MAX_CTE_TRAVERSAL_LEVELS, ), 1722118090, ); } if ($this->isMountedPage($recordId)) { // free result, because we will not iterator further through it and instead invoke RootlineUtility. $result->free(); // @todo Find a way to implement mountpoint resolving directly in the CTE to remove calling and splicing // recursive RootlineUtility call results and handle everything in one database query. // Get rootline of (and including) parent page $mountPointParameter = !empty($this->parsedMountPointParameters) ? $this->mountPointParameter : ''; $rootlineUtility = GeneralUtility::makeInstance(self::class, $recordId, $mountPointParameter, $this->context); $rootline = $rootlineUtility->get(); foreach ($rootline as $rootlineRecord) { $records[] = $rootlineRecord; } break; } if (!$this->runtimeCache->has('rootline-recordcache-' . $recordCacheIdentifier)) { $record = $this->enrichPageRecordArray($record, $recordId); $this->runtimeCache->set('rootline-recordcache-' . $recordCacheIdentifier, $record, [self::RUNTIME_CACHE_TAG]); } $record = $this->runtimeCache->get('rootline-recordcache-' . $recordCacheIdentifier); if (!is_array($record)) { throw new PageNotFoundException('Broken rootline. Could not resolve page with uid ' . $recordId . '.', 1721982337); } $records[] = $record; } // `$records` are build having the current record as first item. We need to revers it here to ensure correct // rootline completion and indexing within `RootlineUtility::generateRootlineCache()`, which expects to have // a record with `pid=0` as first item in the returned records. Note, that keys are not preserved on purpose. return array_reverse($records); } /** * Creates the QueryBuilder for the recursive CTE initial part within {@see self::getRootlineRecords()}. * * Not to be used standalone. {@see self::getRootlineRecords()} method docblock for overall CTE information. */ protected function createInitialQueryBuilder(QueryBuilder $cte, int $pageId, int $workspaceId): QueryBuilder { $expr = $cte->expr(); $initial = $this->createQueryBuilder('pages'); $initial->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class)); if ($workspaceId === 0) { // Return simplified initial expression for live workspace resolving only. return $initial ->selectLiteral(...[ // data fields $cte->quoteIdentifier('uid'), $cte->quoteIdentifier('pid'), // adding fake columns to be compatible with workspace aware columns $expr->as('null', '_ORIG_pid'), $expr->as('null', '_ORIG_uid'), // recursive handling fields $expr->as($cte->quoteIdentifier('uid'), '__CTE_JOIN_UID__'), $expr->castInt('1', '__CTE_LEVEL__'), // Cycle detection guard implemented manually due to the fact that CTE cycle is not implemented by // all supported Database vendors at all or not following the SQL standard. The guard stops cycling // rootline early to avoid use-less cycle retrieval until __CTE_LEVEL__ reaches maximal hard level. $expr->castText($cte->quoteIdentifier('uid'), '__CTE_PATH__'), // Because of Postgres we need to have this cte colum boolean type and thus needing a comparison here $expr->castInt('0 <> 0', '__CTE_IS_CYCLE__'), ]) ->from('pages') ->where(...[ $expr->eq('uid', $cte->createNamedParameter($pageId, Connection::PARAM_INT)), // only select live workspace $expr->eq('t3ver_wsid', $cte->createNamedParameter(0, Connection::PARAM_INT)), ]); } $initial ->selectLiteral(...[ // data fields $cte->quoteIdentifier('live.uid'), $cte->quoteIdentifier('workspace_resolved.pid'), // workspace handling // For move pointers, store the actual live PID in the _ORIG_pid // The only place where PID is actually different in a workspace $expr->if( $expr->and( $expr->isNotNull('workspace.t3ver_state'), $expr->eq( 'workspace.t3ver_state', $cte->createNamedParameter(VersionState::MOVE_POINTER->value, Connection::PARAM_INT), ), ), $cte->quoteIdentifier('live.pid'), 'null', '_ORIG_pid' ), // For versions of single elements or page+content, preserve online UID // (this will produce true "overlay" of element _content_, not any references) // For new versions there is no online counterpart $expr->if( $expr->and( $expr->isNotNull('workspace.t3ver_state'), $expr->neq( 'workspace.t3ver_state', $cte->createNamedParameter(VersionState::NEW_PLACEHOLDER->value, Connection::PARAM_INT), ), ), $cte->quoteIdentifier('workspace.uid'), 'null', '_ORIG_uid', ), // recursive handling fields $expr->if( $expr->and( $expr->isNotNull('workspace.t3ver_state'), $expr->neq( 'workspace.t3ver_state', $cte->createNamedParameter(VersionState::NEW_PLACEHOLDER->value, Connection::PARAM_INT), ), ), $cte->quoteIdentifier('workspace_resolved.uid'), $cte->quoteIdentifier('live.uid'), '__CTE_JOIN_UID__', ), $expr->castInt('1', '__CTE_LEVEL__'), // Cycle detection guard implemented manually due to the fact that CTE cycle is not implemented by // all supported Database vendors at all or not following the SQL standard. The guard stops cycling // rootline early to avoid use-less cycle retrieval until __CTE_LEVEL__ reaches maximal hard level. $expr->castText($cte->quoteIdentifier('live.uid'), '__CTE_PATH__'), // Because of Postgres we need to have this cte colum boolean type and thus needing a comparison here $expr->castInt('0 <> 0', '__CTE_IS_CYCLE__'), ]) ->from('pages', 'source') ->innerJoin( 'source', 'pages', 'live', $expr->eq( 'live.uid', $expr->if( $expr->and( $expr->gt('source.t3ver_oid', $cte->createNamedParameter(0, Connection::PARAM_INT)), $expr->eq('source.t3ver_state', $cte->createNamedParameter(VersionState::MOVE_POINTER->value, Connection::PARAM_INT)), ), $cte->quoteIdentifier('source.t3ver_oid'), $cte->quoteIdentifier('source.uid'), ) ), ) ->leftJoin( 'live', 'pages', 'workspace', $expr->and( $expr->eq( 'workspace.t3ver_wsid', $cte->createNamedParameter($workspaceId, Connection::PARAM_INT) ), $expr->or( // t3ver_state=1 does not contain a t3ver_oid, and returns itself $expr->and( $expr->eq( 'workspace.uid', $cte->quoteIdentifier('live.uid'), ), $expr->eq( 'workspace.t3ver_state', $cte->createNamedParameter(VersionState::NEW_PLACEHOLDER->value, Connection::PARAM_INT), ), ), $expr->eq( 'workspace.t3ver_oid', $cte->quoteIdentifier('live.uid'), ) ) ), ) ->innerJoin( 'workspace', 'pages', 'workspace_resolved', (string)$expr->and( $expr->eq( 'workspace_resolved.uid', $expr->if( $expr->isNotNull('workspace.uid'), $cte->quoteIdentifier('workspace.uid'), $cte->quoteIdentifier('live.uid'), ), ), ), ) ->where(...[ $expr->eq('source.uid', $cte->createNamedParameter($pageId, Connection::PARAM_INT)), $expr->in('source.t3ver_wsid', $cte->createNamedParameter([0, $workspaceId], Connection::PARAM_INT_ARRAY)), $expr->or( // retrieve live workspace if no overlays exists $expr->isNull('workspace.uid'), // discard(omit) record if it turned out to be deleted in workspace $expr->and( $expr->isNotNull('workspace.uid'), $expr->neq( 'workspace.t3ver_state', $cte->createNamedParameter(VersionState::DELETE_PLACEHOLDER->value, Connection::PARAM_INT), ), ), ), ]); return $initial; } /** * Creates the QueryBuilder for the recursive CTE traversal part within {@see self::getRootlineRecords()}. * * Not to be used standalone. {@see self::getRootlineRecords()} method docblock for overall CTE information. */ protected function createTraversalQueryBuilder(QueryBuilder $cte, int $workspaceId): QueryBuilder { $expr = $cte->expr(); $traversal = $this->createQueryBuilder('pages'); $traversal->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class)); if ($workspaceId === 0) { $traversal ->selectLiteral(...[ // data fields $cte->quoteIdentifier('p.uid'), $cte->quoteIdentifier('p.pid'), // adding fake columns to be compatible with workspace aware columns $expr->as('null', '_ORIG_pid'), $expr->as('null', '_ORIG_uid'), // recursive handling fields $expr->as($cte->quoteIdentifier('p.uid'), '__CTE_JOIN_UID__'), $expr->castInt(sprintf('(%s + 1)', $expr->castInt($cte->quoteIdentifier('c.__CTE_LEVEL__'))), '__CTE_LEVEL__'), // Cycle detection guard implemented manually due to the fact that CTE cycle is not implemented by // all supported Database vendors at all or not following the SQL standard. The guard stops cycling // rootline early to avoid use-less cycle retrieval until __CTE_LEVEL__ reaches maximal hard level. $expr->castText( $expr->concat( $expr->trim('c.__CTE_PATH__', TrimMode::TRAILING, ' '), $cte->quote(','), $cte->quoteIdentifier('p.uid') ), '__CTE_PATH__' ), $expr->as( sprintf( '%s <> 0', $expr->castInt(sprintf( // Nesting is needed because inSet() creates a comparision expression return a boolean value '(%s)', $expr->inSet( 'c.__CTE_PATH__', $cte->quoteIdentifier('p.uid'), true, ), )), ), '__CTE_IS_CYCLE__' ), ]) ->from('cte', 'c') ->innerJoin( 'c', 'pages', 'p', (string)$expr->and( $expr->eq('p.uid', $cte->quoteIdentifier('c.pid')), // only select live workspace $expr->eq('p.t3ver_wsid', $cte->createNamedParameter(0, Connection::PARAM_INT)), // ensure that resolve page is not the child page $expr->neq('c.uid', $cte->quoteIdentifier('p.uid')), ) ) ->where(...[ // If last parent has been detected as start of a recursive cycle, stop here. Note that this is done to // keep the cycle detection value in the result set to allow proper handling later on retrieved rows. $expr->eq('c.__CTE_IS_CYCLE__', $cte->createNamedParameter(0, Connection::PARAM_INT)), // do not try to fetch page with uid 0, which is the virtual tree root point $expr->neq('c.pid', $cte->createNamedParameter(0, Connection::PARAM_INT)), // place a maximal traversal level guard against invalid cycling rootlines to mitigate endless recursion $expr->lt('c.__CTE_LEVEL__', $cte->createNamedParameter(self::MAX_CTE_TRAVERSAL_LEVELS, Connection::PARAM_INT)), ]); return $traversal; } $traversal ->selectLiteral(...[ // data fields $cte->quoteIdentifier('traversal_live.uid'), $cte->quoteIdentifier('traversal_workspace_resolved.pid'), // workspace handling // For move pointers, store the actual live PID in the _ORIG_pid // The only place where PID is actually different in a workspace $expr->if( $expr->and( $expr->isNotNull('traversal_workspace.t3ver_state'), $expr->eq( 'traversal_workspace.t3ver_state', $cte->createNamedParameter(VersionState::MOVE_POINTER->value, Connection::PARAM_INT), ), ), $cte->quoteIdentifier('traversal_live.pid'), 'null', '_ORIG_pid' ), // For versions of single elements or page+content, preserve online UID // (this will produce true "overlay" of element _content_, not any references) // For new versions there is no online counterpart $expr->if( $expr->and( $expr->isNotNull('traversal_workspace.t3ver_state'), $expr->neq( 'traversal_workspace.t3ver_state', $cte->createNamedParameter(VersionState::NEW_PLACEHOLDER->value, Connection::PARAM_INT), ), ), $cte->quoteIdentifier('traversal_workspace.uid'), 'null', '_ORIG_uid', ), // recursive handling fields $expr->if( $expr->and( $expr->isNotNull('traversal_workspace.t3ver_state'), $expr->neq( 'traversal_workspace.t3ver_state', $cte->createNamedParameter(VersionState::NEW_PLACEHOLDER->value, Connection::PARAM_INT), ), ), $cte->quoteIdentifier('traversal_workspace_resolved.uid'), $cte->quoteIdentifier('traversal_live.uid'), '__CTE_JOIN_UID__', ), $expr->castInt(sprintf('(%s + 1)', $expr->castInt($cte->quoteIdentifier('traversal_c.__CTE_LEVEL__'))), '__CTE_LEVEL__'), // Cycle detection guard implemented manually due to the fact that CTE cycle is not implemented by // all supported Database vendors at all or not following the SQL standard. The guard stops cycling // rootline early to avoid use-less cycle retrieval until __CTE_LEVEL__ reaches maximal hard level. $expr->castText( $expr->concat( $expr->trim('traversal_c.__CTE_PATH__', TrimMode::TRAILING, ' '), $cte->quote(','), $cte->quoteIdentifier('traversal_live.uid') ), '__CTE_PATH__' ), $expr->as( sprintf( '%s <> 0', $expr->castInt(sprintf( // Nesting is needed because inSet() creates a comparision expression return a boolean value '(%s)', $expr->inSet( 'traversal_c.__CTE_PATH__', $cte->quoteIdentifier('traversal_live.uid'), true, ) )), ), '__CTE_IS_CYCLE__' ), ]) ->from('cte', 'traversal_c') ->innerJoin( 'traversal_c', 'pages', 'traversal_source', (string)$expr->and( $expr->eq( 'traversal_source.uid', $cte->quoteIdentifier('traversal_c.pid') ), $expr->in('traversal_source.t3ver_wsid', $cte->createNamedParameter([0, $workspaceId], Connection::PARAM_INT_ARRAY)), ), ) ->innerJoin( 'traversal_source', 'pages', 'traversal_live', $expr->eq( 'traversal_live.uid', $expr->if( $expr->and( $expr->gt('traversal_source.t3ver_oid', $cte->createNamedParameter(0, Connection::PARAM_INT)), $expr->eq('traversal_source.t3ver_state', $cte->createNamedParameter(VersionState::MOVE_POINTER->value, Connection::PARAM_INT)), ), $cte->quoteIdentifier('traversal_source.t3ver_oid'), $cte->quoteIdentifier('traversal_source.uid'), ) ), ) ->leftJoin( 'traversal_live', 'pages', 'traversal_workspace', (string)$expr->and( $expr->eq( 'traversal_workspace.t3ver_wsid', $cte->createNamedParameter($workspaceId, Connection::PARAM_INT) ), $expr->or( // t3ver_state=1 does not contain a t3ver_oid, and returns itself $expr->and( $expr->eq( 'traversal_workspace.uid', $cte->quoteIdentifier('traversal_live.uid'), ), $expr->eq( 'traversal_workspace.t3ver_state', $cte->createNamedParameter(VersionState::NEW_PLACEHOLDER->value, Connection::PARAM_INT), ), ), $expr->eq( 'traversal_workspace.t3ver_oid', $cte->quoteIdentifier('traversal_live.uid'), ) ) ), ) ->innerJoin( 'traversal_workspace', 'pages', 'traversal_workspace_resolved', (string)$expr->and( $expr->eq( 'traversal_workspace_resolved.uid', $expr->if( $expr->isNotNull('traversal_workspace.uid'), $cte->quoteIdentifier('traversal_workspace.uid'), $cte->quoteIdentifier('traversal_live.uid'), ), ), ), ) ->where(...[ // If last parent has been detected as start of a recursive cycle, stop here. Note that this is done to // keep the cycle detection value in the result set to allow proper handling later on retrieved rows. $expr->eq('traversal_c.__CTE_IS_CYCLE__', $cte->createNamedParameter(0, Connection::PARAM_INT)), // do not try to fetch page with uid 0, which is the virtual tree root point $expr->neq('traversal_c.pid', $cte->createNamedParameter(0, Connection::PARAM_INT)), // place a maximal traversal level guard against invalid cycling rootlines to mitigate endless recursion $expr->lt('traversal_c.__CTE_LEVEL__', $cte->createNamedParameter(self::MAX_CTE_TRAVERSAL_LEVELS, Connection::PARAM_INT)), // workspace handling $expr->or( // retrieve live workspace if no overlays exists $expr->isNull('traversal_workspace.uid'), // discard(omit) record if it turned out to be deleted in workspace $expr->and( $expr->isNotNull('traversal_workspace.uid'), $expr->neq( 'traversal_workspace.t3ver_state', $cte->createNamedParameter(VersionState::DELETE_PLACEHOLDER->value, Connection::PARAM_INT), ), ), ), ]); return $traversal; } /** * {@see self::getRecordArray()} used {@see PageRepository::versionOL()} to compose workspace overlayed records in * the based, bypassing access checks (aka enableFields) resulting in multiple queries. This constellation of method * and database query chains can be condensed down to a single database query, which this method uses to retrieve * the equal workspace overlay database page record in one and thus reducing overall database query count. */ protected function getWorkspaceResolvedPageRecord(int $pageId, int $workspaceId): ?array { $queryBuilder = $this->createQueryBuilder('pages'); $queryBuilder->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class)); if ($workspaceId === 0) { // For live workspace only we can simplify this even more $queryBuilder ->select('*') ->from('pages') ->where( $queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT)), $queryBuilder->expr()->eq('t3ver_wsid', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)), ); return $queryBuilder->executeQuery()->fetchAssociative() ?: null; } $fields = $this->getPagesFields(); $prefixedFields = array_filter($fields, static fn($value) => $value !== 'uid'); array_walk( $prefixedFields, static function (string &$value, int|string $key, QueryBuilder $queryBuilder) { $value = $queryBuilder->quoteIdentifier(sprintf('%s.%s', 'workspace_resolved', $value)); }, $queryBuilder, ); $queryBuilder ->selectLiteral( $queryBuilder->quoteIdentifier('live.uid'), ...$prefixedFields, ...[ // For move pointers, store the actual live PID in the _ORIG_pid // The only place where PID is actually different in a workspace $queryBuilder->expr()->if( $queryBuilder->expr()->and( $queryBuilder->expr()->isNotNull('workspace.t3ver_state'), $queryBuilder->expr()->eq('workspace.t3ver_state', $queryBuilder->createNamedParameter(VersionState::MOVE_POINTER->value, Connection::PARAM_INT)), ), $queryBuilder->quoteIdentifier('live.pid'), 'null', '_ORIG_pid' ), // For versions of single elements or page+content, preserve online UID // (this will produce true "overlay" of element _content_, not any references) // For new versions there is no online counterpart $queryBuilder->expr()->if( $queryBuilder->expr()->and( $queryBuilder->expr()->isNotNull('workspace.t3ver_state'), $queryBuilder->expr()->neq('workspace.t3ver_state', $queryBuilder->createNamedParameter(VersionState::NEW_PLACEHOLDER->value, Connection::PARAM_INT)), ), $queryBuilder->quoteIdentifier('workspace.uid'), 'null', '_ORIG_uid', ), ] ) ->from('pages', 'source') ->innerJoin( 'source', 'pages', 'live', $queryBuilder->expr()->eq( 'live.uid', $queryBuilder->expr()->if( (string)$queryBuilder->expr()->and( $queryBuilder->expr()->gt('source.t3ver_oid', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT)), $queryBuilder->expr()->eq('source.t3ver_state', $queryBuilder->createNamedParameter(VersionState::MOVE_POINTER->value, Connection::PARAM_INT)), ), $queryBuilder->quoteIdentifier('source.t3ver_oid'), $queryBuilder->quoteIdentifier('source.uid'), ) ), ) ->leftJoin( 'live', 'pages', 'workspace', (string)$queryBuilder->expr()->and( $queryBuilder->expr()->eq( 'workspace.t3ver_wsid', $queryBuilder->createNamedParameter($workspaceId, Connection::PARAM_INT) ), $queryBuilder->expr()->or( // t3ver_state=1 does not contain a t3ver_oid, and returns itself $queryBuilder->expr()->and( $queryBuilder->expr()->eq( 'workspace.uid', $queryBuilder->quoteIdentifier('live.uid'), ), $queryBuilder->expr()->eq( 'workspace.t3ver_state', $queryBuilder->createNamedParameter(VersionState::NEW_PLACEHOLDER->value, Connection::PARAM_INT), ), ), $queryBuilder->expr()->eq( 'workspace.t3ver_oid', $queryBuilder->quoteIdentifier('live.uid'), ) ) ), ) ->innerJoin( 'workspace', 'pages', 'workspace_resolved', (string)$queryBuilder->expr()->and( $queryBuilder->expr()->eq( 'workspace_resolved.uid', $queryBuilder->expr()->if( $queryBuilder->expr()->isNotNull('workspace.uid'), $queryBuilder->quoteIdentifier('workspace.uid'), $queryBuilder->quoteIdentifier('live.uid'), ), ), ), ) ->where( $queryBuilder->expr()->eq('source.uid', $queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT)), $queryBuilder->expr()->in('source.t3ver_wsid', $queryBuilder->createNamedParameter([0, $workspaceId], Connection::PARAM_INT_ARRAY)), $queryBuilder->expr()->or( // retrieve live workspace if no overlays exists $queryBuilder->expr()->isNull('workspace.uid'), // discard(omit) record if it turned out to be deleted in workspace $queryBuilder->expr()->and( $queryBuilder->expr()->isNotNull('workspace.uid'), $queryBuilder->expr()->neq( 'workspace.t3ver_state', $queryBuilder->createNamedParameter(VersionState::DELETE_PLACEHOLDER->value, Connection::PARAM_INT), ), ), ), ) ->setMaxResults(1); $row = $queryBuilder->executeQuery()->fetchAssociative() ?: null; return $this->cleanWorkspaceResolvedPageRecord($row); } protected function cleanWorkspaceResolvedPageRecord(?array $row = null): ?array { if ($row === null) { return $row; } // Remove cycle detection fields from result row unset( $row['__CTE_PATH__'], $row['__CTE_IS_CYCLE__'], $row['__CTE_LEVEL__'], ); // Remove helper fields if null, keeping them only if they contain valid data to mimic the way PHP methods // throughout the TYPO3 core added these fields. $removeNullableFields = [ '_ORIG_uid', '_ORIG_pid', ]; foreach ($removeNullableFields as $removeNullableField) { if (array_key_exists($removeNullableField, $row) && $row[$removeNullableField] === null) { unset($row[$removeNullableField]); } } return $row; } protected function enrichPageRecordArray(array $row, int $pageId): array { $row = $this->pageRepository->getLanguageOverlay('pages', $row, $this->context->getAspect('language')); $row = $this->enrichWithRelationFields($row['_LOCALIZED_UID'] ?? $pageId, $row); return $row; } /** * Uses a two-layer cache to ensure that this check is really called VERY VERY SELDOM. */ protected function getPagesFields(): array { // SchemaInformation provides a 2-level cache (runtime and persisted), no need to cache this here in the class. return GeneralUtility::makeInstance(ConnectionPool::class) ->getConnectionForTable('pages') ->getSchemaInformation()->listTableColumnNames('pages'); } protected function createQueryBuilder(string $tableName): QueryBuilder { return GeneralUtility::makeInstance(ConnectionPool::class) ->getQueryBuilderForTable($tableName); } private function getRootlineFromRuntimeCache(int $pageId): ?array { $cacheIdentifier = $this->getCacheIdentifier($pageId); if ($this->runtimeCache->has('rootline-localcache-' . $cacheIdentifier)) { $rootline = $this->runtimeCache->get('rootline-localcache-' . $cacheIdentifier); if (is_array($rootline)) { return array_reverse($rootline); } } return null; } }