Completed
Push — master ( 694e35...3c8029 )
by
unknown
20:41
created

RootlineUtility::createQueryBuilder()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 1
dl 0
loc 4
rs 10
c 0
b 0
f 0
1
<?php
2
3
/*
4
 * This file is part of the TYPO3 CMS project.
5
 *
6
 * It is free software; you can redistribute it and/or modify it under
7
 * the terms of the GNU General Public License, either version 2
8
 * of the License, or any later version.
9
 *
10
 * For the full copyright and license information, please read the
11
 * LICENSE.txt file that was distributed with this source code.
12
 *
13
 * The TYPO3 project - inspiring people to share!
14
 */
15
16
namespace TYPO3\CMS\Core\Utility;
17
18
use Doctrine\DBAL\DBALException;
19
use Doctrine\DBAL\FetchMode;
20
use TYPO3\CMS\Core\Cache\CacheManager;
21
use TYPO3\CMS\Core\Context\Context;
22
use TYPO3\CMS\Core\Database\ConnectionPool;
23
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
24
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
25
use TYPO3\CMS\Core\Database\Query\Restriction\HiddenRestriction;
26
use TYPO3\CMS\Core\Database\RelationHandler;
27
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
28
use TYPO3\CMS\Core\Exception\Page\BrokenRootLineException;
29
use TYPO3\CMS\Core\Exception\Page\CircularRootLineException;
30
use TYPO3\CMS\Core\Exception\Page\MountPointsDisabledException;
31
use TYPO3\CMS\Core\Exception\Page\PageNotFoundException;
32
use TYPO3\CMS\Core\Exception\Page\PagePropertyRelationNotFoundException;
33
use TYPO3\CMS\Core\Versioning\VersionState;
34
35
/**
36
 * A utility resolving and Caching the Rootline generation
37
 */
38
class RootlineUtility
39
{
40
    /**
41
     * @var int
42
     */
43
    protected $pageUid;
44
45
    /**
46
     * @var string
47
     */
48
    protected $mountPointParameter;
49
50
    /**
51
     * @var array
52
     */
53
    protected $parsedMountPointParameters = [];
54
55
    /**
56
     * @var int
57
     */
58
    protected $languageUid = 0;
59
60
    /**
61
     * @var int
62
     */
63
    protected $workspaceUid = 0;
64
65
    /**
66
     * @var \TYPO3\CMS\Core\Cache\Frontend\FrontendInterface
67
     */
68
    protected static $cache;
69
70
    /**
71
     * @var array
72
     */
73
    protected static $localCache = [];
74
75
    /**
76
     * Fields to fetch when populating rootline data
77
     *
78
     * @var array
79
     */
80
    protected static $rootlineFields = [
81
        'pid',
82
        'uid',
83
        't3ver_oid',
84
        't3ver_wsid',
85
        't3ver_state',
86
        'title',
87
        'nav_title',
88
        'media',
89
        'layout',
90
        'hidden',
91
        'starttime',
92
        'endtime',
93
        'fe_group',
94
        'extendToSubpages',
95
        'doktype',
96
        'TSconfig',
97
        'tsconfig_includes',
98
        'is_siteroot',
99
        'mount_pid',
100
        'mount_pid_ol',
101
        'fe_login_mode',
102
        'backend_layout_next_level'
103
    ];
104
105
    /**
106
     * Database Query Object
107
     *
108
     * @var PageRepository
109
     */
110
    protected $pageRepository;
111
112
    /**
113
     * Query context
114
     *
115
     * @var Context
116
     */
117
    protected $context;
118
119
    /**
120
     * @var string
121
     */
122
    protected $cacheIdentifier;
123
124
    /**
125
     * @var array
126
     */
127
    protected static $pageRecordCache = [];
128
129
    /**
130
     * @param int $uid
131
     * @param string $mountPointParameter
132
     * @param Context $context
133
     * @throws MountPointsDisabledException
134
     */
135
    public function __construct($uid, $mountPointParameter = '', $context = null)
136
    {
137
        $this->mountPointParameter = trim($mountPointParameter);
138
        if (!($context instanceof Context)) {
139
            $context = GeneralUtility::makeInstance(Context::class);
140
        }
141
        $this->context = $context;
142
        $this->pageRepository = GeneralUtility::makeInstance(PageRepository::class, $context);
143
144
        $this->languageUid = $this->context->getPropertyFromAspect('language', 'id', 0);
145
        $this->workspaceUid = (int)$this->context->getPropertyFromAspect('workspace', 'id', 0);
146
        if ($this->mountPointParameter !== '') {
147
            if (!$GLOBALS['TYPO3_CONF_VARS']['FE']['enable_mount_pids']) {
148
                throw new MountPointsDisabledException('Mount-Point Pages are disabled for this installation. Cannot resolve a Rootline for a page with Mount-Points', 1343462896);
149
            }
150
            $this->parseMountPointParameter();
151
        }
152
153
        $this->pageUid = $this->resolvePageId((int)$uid);
154
        if (self::$cache === null) {
155
            self::$cache = GeneralUtility::makeInstance(CacheManager::class)->getCache('rootline');
156
        }
157
        self::$rootlineFields = array_merge(self::$rootlineFields, GeneralUtility::trimExplode(',', $GLOBALS['TYPO3_CONF_VARS']['FE']['addRootLineFields'], true));
158
        self::$rootlineFields = array_unique(self::$rootlineFields);
159
160
        $this->cacheIdentifier = $this->getCacheIdentifier();
161
    }
162
163
    /**
164
     * Purges all rootline caches.
165
     *
166
     * @internal only used in EXT:core, no public API
167
     */
168
    public static function purgeCaches()
169
    {
170
        self::$localCache = [];
171
        self::$pageRecordCache = [];
172
    }
173
174
    /**
175
     * Constructs the cache Identifier
176
     *
177
     * @param int $otherUid
178
     * @return string
179
     */
180
    public function getCacheIdentifier($otherUid = null)
181
    {
182
        $mountPointParameter = (string)$this->mountPointParameter;
183
        if ($mountPointParameter !== '' && strpos($mountPointParameter, ',') !== false) {
184
            $mountPointParameter = str_replace(',', '__', $mountPointParameter);
185
        }
186
187
        return implode('_', [
188
            $otherUid !== null ? (int)$otherUid : $this->pageUid,
189
            $mountPointParameter,
190
            $this->languageUid,
191
            $this->workspaceUid
192
        ]);
193
    }
194
195
    /**
196
     * Returns the actual rootline without the tree root (uid=0), including the page with $this->pageUid
197
     *
198
     * @return array
199
     */
200
    public function get()
201
    {
202
        if ($this->pageUid === 0) {
203
            // pageUid 0 has no root line, return empty array right away
204
            return [];
205
        }
206
        if (!isset(static::$localCache[$this->cacheIdentifier])) {
207
            $entry = static::$cache->get($this->cacheIdentifier);
208
            if (!$entry) {
209
                $this->generateRootlineCache();
210
            } else {
211
                static::$localCache[$this->cacheIdentifier] = $entry;
212
                $depth = count($entry);
213
                // Populate the root-lines for parent pages as well
214
                // since they are part of the current root-line
215
                while ($depth > 1) {
216
                    --$depth;
217
                    $parentCacheIdentifier = $this->getCacheIdentifier($entry[$depth - 1]['uid']);
218
                    // Abort if the root-line of the parent page is
219
                    // already in the local cache data
220
                    if (isset(static::$localCache[$parentCacheIdentifier])) {
221
                        break;
222
                    }
223
                    // Behaves similar to array_shift(), but preserves
224
                    // the array keys - which contain the page ids here
225
                    $entry = array_slice($entry, 1, null, true);
226
                    static::$localCache[$parentCacheIdentifier] = $entry;
227
                }
228
            }
229
        }
230
        return static::$localCache[$this->cacheIdentifier];
231
    }
232
233
    /**
234
     * Queries the database for the page record and returns it.
235
     *
236
     * @param int $uid Page id
237
     * @throws PageNotFoundException
238
     * @return array
239
     */
240
    protected function getRecordArray($uid)
241
    {
242
        $currentCacheIdentifier = $this->getCacheIdentifier($uid);
243
        if (!isset(self::$pageRecordCache[$currentCacheIdentifier])) {
244
            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
245
            $queryBuilder->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class));
246
            $row = $queryBuilder->select(...self::$rootlineFields)
247
                ->from('pages')
248
                ->where(
249
                    $queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT))
250
                )
251
                ->execute()
252
                ->fetch();
253
            if (empty($row)) {
254
                throw new PageNotFoundException('Could not fetch page data for uid ' . $uid . '.', 1343589451);
255
            }
256
            $this->pageRepository->versionOL('pages', $row, false, true);
257
            $this->pageRepository->fixVersioningPid('pages', $row);
258
            if (is_array($row)) {
259
                if ($this->languageUid > 0) {
260
                    $row = $this->pageRepository->getPageOverlay($row, $this->languageUid);
261
                }
262
                $row = $this->enrichWithRelationFields($row['_PAGES_OVERLAY_UID'] ??  $uid, $row);
263
                self::$pageRecordCache[$currentCacheIdentifier] = $row;
264
            }
265
        }
266
        if (!is_array(self::$pageRecordCache[$currentCacheIdentifier])) {
267
            throw new PageNotFoundException('Broken rootline. Could not resolve page with uid ' . $uid . '.', 1343464101);
268
        }
269
        return self::$pageRecordCache[$currentCacheIdentifier];
270
    }
271
272
    /**
273
     * Resolve relations as defined in TCA and add them to the provided $pageRecord array.
274
     *
275
     * @param int $uid page ID
276
     * @param array $pageRecord Page record (possibly overlaid) to be extended with relations
277
     * @throws PagePropertyRelationNotFoundException
278
     * @return array $pageRecord with additional relations
279
     */
280
    protected function enrichWithRelationFields($uid, array $pageRecord)
281
    {
282
        $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
283
284
        // @todo Remove this special interpretation of relations by consequently using RelationHandler
285
        foreach ($GLOBALS['TCA']['pages']['columns'] as $column => $configuration) {
286
            // Ensure that only fields defined in $rootlineFields (and "addRootLineFields") are actually evaluated
287
            if (array_key_exists($column, $pageRecord) && $this->columnHasRelationToResolve($configuration)) {
288
                $configuration = $configuration['config'];
289
                if ($configuration['MM']) {
290
                    /** @var \TYPO3\CMS\Core\Database\RelationHandler $loadDBGroup */
291
                    $loadDBGroup = GeneralUtility::makeInstance(RelationHandler::class);
292
                    $loadDBGroup->start(
293
                        $pageRecord[$column],
294
                        // @todo That depends on the type (group, select, inline)
295
                        $configuration['allowed'] ?? $configuration['foreign_table'],
296
                        $configuration['MM'],
297
                        $uid,
298
                        'pages',
299
                        $configuration
300
                    );
301
                    $relatedUids = $loadDBGroup->tableArray[$configuration['foreign_table']] ?? [];
302
                } else {
303
                    // @todo The assumption is wrong, since group can be used without "MM", but having "allowed"
304
                    $table = $configuration['foreign_table'];
305
306
                    $queryBuilder = $connectionPool->getQueryBuilderForTable($table);
307
                    $queryBuilder->getRestrictions()->removeAll()
308
                        ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
309
                        ->add(GeneralUtility::makeInstance(HiddenRestriction::class));
310
                    $queryBuilder->select('uid')
311
                        ->from($table)
312
                        ->where(
313
                            $queryBuilder->expr()->eq(
314
                                $configuration['foreign_field'],
315
                                $queryBuilder->createNamedParameter(
316
                                    $uid,
317
                                    \PDO::PARAM_INT
318
                                )
319
                            )
320
                        );
321
322
                    if (isset($configuration['foreign_match_fields']) && is_array($configuration['foreign_match_fields'])) {
323
                        foreach ($configuration['foreign_match_fields'] as $field => $value) {
324
                            $queryBuilder->andWhere(
325
                                $queryBuilder->expr()->eq(
326
                                    $field,
327
                                    $queryBuilder->createNamedParameter($value, \PDO::PARAM_STR)
328
                                )
329
                            );
330
                        }
331
                    }
332
                    if (isset($configuration['foreign_table_field'])) {
333
                        $queryBuilder->andWhere(
334
                            $queryBuilder->expr()->eq(
335
                                trim($configuration['foreign_table_field']),
336
                                $queryBuilder->createNamedParameter(
337
                                    'pages',
338
                                    \PDO::PARAM_STR
339
                                )
340
                            )
341
                        );
342
                    }
343
                    if (isset($configuration['foreign_sortby'])) {
344
                        $queryBuilder->orderBy($configuration['foreign_sortby']);
345
                    }
346
                    try {
347
                        $statement = $queryBuilder->execute();
348
                    } catch (DBALException $e) {
349
                        throw new PagePropertyRelationNotFoundException('Could to resolve related records for page ' . $uid . ' and foreign_table ' . htmlspecialchars($table), 1343589452);
350
                    }
351
                    $relatedUids = [];
352
                    while ($row = $statement->fetch()) {
353
                        $relatedUids[] = $row['uid'];
354
                    }
355
                }
356
                $pageRecord[$column] = implode(',', $relatedUids);
357
            }
358
        }
359
        return $pageRecord;
360
    }
361
362
    /**
363
     * Checks whether the TCA Configuration array of a column
364
     * describes a relation which is not stored as CSV in the record
365
     *
366
     * @param array $configuration TCA configuration to check
367
     * @return bool TRUE, if it describes a non-CSV relation
368
     */
369
    protected function columnHasRelationToResolve(array $configuration)
370
    {
371
        $configuration = $configuration['config'] ?? null;
372
        if (!empty($configuration['MM']) && !empty($configuration['type']) && in_array($configuration['type'], ['select', 'inline', 'group'])) {
373
            return true;
374
        }
375
        if (!empty($configuration['foreign_field']) && !empty($configuration['type']) && in_array($configuration['type'], ['select', 'inline'])) {
376
            return true;
377
        }
378
        return false;
379
    }
380
381
    /**
382
     * Actual function to generate the rootline and cache it
383
     *
384
     * @throws CircularRootLineException
385
     */
386
    protected function generateRootlineCache()
387
    {
388
        $page = $this->getRecordArray($this->pageUid);
389
        // If the current page is a mounted (according to the MP parameter) handle the mount-point
390
        if ($this->isMountedPage()) {
391
            $mountPoint = $this->getRecordArray($this->parsedMountPointParameters[$this->pageUid]);
392
            $page = $this->processMountedPage($page, $mountPoint);
393
            $parentUid = $mountPoint['pid'];
394
            // Anyhow after reaching the mount-point, we have to go up that rootline
395
            unset($this->parsedMountPointParameters[$this->pageUid]);
396
        } else {
397
            $parentUid = $page['pid'];
398
        }
399
        $cacheTags = ['pageId_' . $page['uid']];
400
        if ($parentUid > 0) {
401
            // Get rootline of (and including) parent page
402
            $mountPointParameter = !empty($this->parsedMountPointParameters) ? $this->mountPointParameter : '';
403
            $rootline = GeneralUtility::makeInstance(self::class, $parentUid, $mountPointParameter, $this->context)->get();
404
            // retrieve cache tags of parent rootline
405
            foreach ($rootline as $entry) {
406
                $cacheTags[] = 'pageId_' . $entry['uid'];
407
                if ($entry['uid'] == $this->pageUid) {
408
                    throw new CircularRootLineException('Circular connection in rootline for page with uid ' . $this->pageUid . ' found. Check your mountpoint configuration.', 1343464103);
409
                }
410
            }
411
        } else {
412
            $rootline = [];
413
        }
414
        $rootline[] = $page;
415
        krsort($rootline);
416
        static::$cache->set($this->cacheIdentifier, $rootline, $cacheTags);
417
        static::$localCache[$this->cacheIdentifier] = $rootline;
418
    }
419
420
    /**
421
     * Checks whether the current Page is a Mounted Page
422
     * (according to the MP-URL-Parameter)
423
     *
424
     * @return bool
425
     */
426
    public function isMountedPage()
427
    {
428
        return array_key_exists($this->pageUid, $this->parsedMountPointParameters);
429
    }
430
431
    /**
432
     * Enhances with mount point information or replaces the node if needed
433
     *
434
     * @param array $mountedPageData page record array of mounted page
435
     * @param array $mountPointPageData page record array of mount point page
436
     * @throws BrokenRootLineException
437
     * @return array
438
     */
439
    protected function processMountedPage(array $mountedPageData, array $mountPointPageData)
440
    {
441
        $mountPid = $mountPointPageData['mount_pid'] ?? null;
442
        $uid = $mountedPageData['uid'] ?? null;
443
        if ($mountPid != $uid) {
444
            throw new BrokenRootLineException('Broken rootline. Mountpoint parameter does not match the actual rootline. mount_pid (' . $mountPid . ') does not match page uid (' . $uid . ').', 1343464100);
445
        }
446
        // Current page replaces the original mount-page
447
        $mountUid = $mountPointPageData['uid'] ?? null;
448
        if (!empty($mountPointPageData['mount_pid_ol'])) {
449
            $mountedPageData['_MOUNT_OL'] = true;
450
            $mountedPageData['_MOUNT_PAGE'] = [
451
                'uid' => $mountUid,
452
                'pid' => $mountPointPageData['pid'] ?? null,
453
                'title' => $mountPointPageData['title'] ?? null
454
            ];
455
        } else {
456
            // The mount-page is not replaced, the mount-page itself has to be used
457
            $mountedPageData = $mountPointPageData;
458
        }
459
        $mountedPageData['_MOUNTED_FROM'] = $this->pageUid;
460
        $mountedPageData['_MP_PARAM'] = $this->pageUid . '-' . $mountUid;
461
        return $mountedPageData;
462
    }
463
464
    /**
465
     * Parse the MountPoint Parameters
466
     * Splits the MP-Param via "," for several nested mountpoints
467
     * and afterwords registers the mountpoint configurations
468
     */
469
    protected function parseMountPointParameter()
470
    {
471
        $mountPoints = GeneralUtility::trimExplode(',', $this->mountPointParameter);
472
        foreach ($mountPoints as $mP) {
473
            [$mountedPageUid, $mountPageUid] = GeneralUtility::intExplode('-', $mP);
474
            $this->parsedMountPointParameters[$mountedPageUid] = $mountPageUid;
475
        }
476
    }
477
478
    /**
479
     * Fetches the UID of the page, but if the page was moved in a workspace, actually returns the UID
480
     * of the moved version in the workspace.
481
     *
482
     * @param int $pageId
483
     * @return int
484
     */
485
    protected function resolvePageId(int $pageId): int
486
    {
487
        if ($pageId === 0 || $this->workspaceUid === 0) {
488
            return $pageId;
489
        }
490
491
        $page = $this->resolvePageRecord($pageId);
492
        if (!VersionState::cast($page['t3ver_state'])->equals(VersionState::MOVE_POINTER)) {
493
            return $pageId;
494
        }
495
496
        $movePointerId = $this->resolveMovePointerId((int)$page['t3ver_oid']);
497
        return $movePointerId ?: $pageId;
498
    }
499
500
    /**
501
     * @param int $pageId
502
     * @return array|null
503
     */
504
    protected function resolvePageRecord(int $pageId): ?array
505
    {
506
        $queryBuilder = $this->createQueryBuilder('pages');
507
        $queryBuilder->getRestrictions()->removeAll()
508
            ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
509
510
        $statement = $queryBuilder
511
            ->from('pages')
512
            ->select('uid', 't3ver_oid', 't3ver_state')
513
            ->where(
514
                $queryBuilder->expr()->eq(
515
                    'uid',
516
                    $queryBuilder->createNamedParameter($pageId, \PDO::PARAM_INT)
517
                )
518
            )
519
            ->setMaxResults(1)
520
            ->execute();
521
522
        $record = $statement->fetch(FetchMode::ASSOCIATIVE);
523
        return $record ?: null;
524
    }
525
526
    /**
527
     * Fetched the UID of the versioned record if the live record has been moved in a workspace.
528
     *
529
     * @param int $liveId
530
     * @return int|null
531
     */
532
    protected function resolveMovePointerId(int $liveId): ?int
533
    {
534
        $queryBuilder = $this->createQueryBuilder('pages');
535
        $queryBuilder->getRestrictions()->removeAll()
536
            ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
537
538
        $statement = $queryBuilder
539
            ->from('pages')
540
            ->select('uid')
541
            ->setMaxResults(1)
542
            ->where(
543
                $queryBuilder->expr()->eq(
544
                    't3ver_wsid',
545
                    $queryBuilder->createNamedParameter($this->workspaceUid, \PDO::PARAM_INT)
546
                ),
547
                $queryBuilder->expr()->eq(
548
                    't3ver_state',
549
                    $queryBuilder->createNamedParameter(VersionState::MOVE_POINTER, \PDO::PARAM_INT)
550
                ),
551
                $queryBuilder->expr()->eq(
552
                    't3ver_oid',
553
                    $queryBuilder->createNamedParameter($liveId, \PDO::PARAM_INT)
554
                )
555
            )
556
            ->execute();
557
558
        $movePointerId = $statement->fetchColumn();
559
        return $movePointerId ? (int)$movePointerId : null;
560
    }
561
562
    /**
563
     * @param string $tableName
564
     * @return QueryBuilder
565
     */
566
    protected function createQueryBuilder(string $tableName): QueryBuilder
567
    {
568
        return GeneralUtility::makeInstance(ConnectionPool::class)
569
            ->getQueryBuilderForTable($tableName);
570
    }
571
}
572