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
16namespace TYPO3\CMS\Extbase\Persistence\Generic\Storage;
17
18use TYPO3\CMS\Backend\Utility\BackendUtility;
19use TYPO3\CMS\Core\Context\Context;
20use TYPO3\CMS\Core\Database\Connection;
21use TYPO3\CMS\Core\Database\ConnectionPool;
22use TYPO3\CMS\Core\Database\Query\Expression\CompositeExpression;
23use TYPO3\CMS\Core\Database\Query\Expression\ExpressionBuilder;
24use TYPO3\CMS\Core\Database\Query\QueryBuilder;
25use TYPO3\CMS\Core\Domain\Repository\PageRepository;
26use TYPO3\CMS\Core\Utility\GeneralUtility;
27use TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface;
28use TYPO3\CMS\Extbase\DomainObject\AbstractDomainObject;
29use TYPO3\CMS\Extbase\Object\ObjectManagerInterface;
30use TYPO3\CMS\Extbase\Persistence\Generic\Exception;
31use TYPO3\CMS\Extbase\Persistence\Generic\Exception\InconsistentQuerySettingsException;
32use TYPO3\CMS\Extbase\Persistence\Generic\Exception\InvalidRelationConfigurationException;
33use TYPO3\CMS\Extbase\Persistence\Generic\Exception\MissingColumnMapException;
34use TYPO3\CMS\Extbase\Persistence\Generic\Exception\RepositoryException;
35use TYPO3\CMS\Extbase\Persistence\Generic\Exception\UnsupportedOrderException;
36use TYPO3\CMS\Extbase\Persistence\Generic\Mapper\ColumnMap;
37use TYPO3\CMS\Extbase\Persistence\Generic\Mapper\DataMapper;
38use TYPO3\CMS\Extbase\Persistence\Generic\Qom;
39use TYPO3\CMS\Extbase\Persistence\Generic\Qom\AndInterface;
40use TYPO3\CMS\Extbase\Persistence\Generic\Qom\ComparisonInterface;
41use TYPO3\CMS\Extbase\Persistence\Generic\Qom\ConstraintInterface;
42use TYPO3\CMS\Extbase\Persistence\Generic\Qom\DynamicOperandInterface;
43use TYPO3\CMS\Extbase\Persistence\Generic\Qom\EquiJoinCondition;
44use TYPO3\CMS\Extbase\Persistence\Generic\Qom\JoinInterface;
45use TYPO3\CMS\Extbase\Persistence\Generic\Qom\LowerCaseInterface;
46use TYPO3\CMS\Extbase\Persistence\Generic\Qom\NotInterface;
47use TYPO3\CMS\Extbase\Persistence\Generic\Qom\OrInterface;
48use TYPO3\CMS\Extbase\Persistence\Generic\Qom\PropertyValueInterface;
49use TYPO3\CMS\Extbase\Persistence\Generic\Qom\SelectorInterface;
50use TYPO3\CMS\Extbase\Persistence\Generic\Qom\SourceInterface;
51use TYPO3\CMS\Extbase\Persistence\Generic\Qom\UpperCaseInterface;
52use TYPO3\CMS\Extbase\Persistence\Generic\QuerySettingsInterface;
53use TYPO3\CMS\Extbase\Persistence\Generic\Storage\Exception\BadConstraintException;
54use TYPO3\CMS\Extbase\Persistence\QueryInterface;
55use TYPO3\CMS\Extbase\Service\EnvironmentService;
56
57/**
58 * QueryParser, converting the qom to string representation
59 * @internal only to be used within Extbase, not part of TYPO3 Core API.
60 */
61class Typo3DbQueryParser
62{
63    /**
64     * @var DataMapper
65     */
66    protected $dataMapper;
67
68    /**
69     * The TYPO3 page repository. Used for language and workspace overlay
70     *
71     * @var PageRepository
72     */
73    protected $pageRepository;
74
75    /**
76     * @var EnvironmentService
77     */
78    protected $environmentService;
79
80    /**
81     * @var ConfigurationManagerInterface
82     */
83    protected $configurationManager;
84
85    /**
86     * Instance of the Doctrine query builder
87     *
88     * @var QueryBuilder
89     */
90    protected $queryBuilder;
91
92    /**
93     * Maps domain model properties to their corresponding table aliases that are used in the query, e.g.:
94     *
95     * 'property1' => 'tableName',
96     * 'property1.property2' => 'tableName1',
97     *
98     * @var array
99     */
100    protected $tablePropertyMap = [];
101
102    /**
103     * Maps tablenames to their aliases to be used in where clauses etc.
104     * Mainly used for joins on the same table etc.
105     *
106     * @var array<string, string>
107     */
108    protected $tableAliasMap = [];
109
110    /**
111     * Stores all tables used in for SQL joins
112     *
113     * @var array
114     */
115    protected $unionTableAliasCache = [];
116
117    /**
118     * @var string
119     */
120    protected $tableName = '';
121
122    /**
123     * @var bool
124     */
125    protected $suggestDistinctQuery = false;
126
127    /**
128     * @var ObjectManagerInterface
129     */
130    protected $objectManager;
131
132    /**
133     * @param ObjectManagerInterface $objectManager
134     */
135    public function injectObjectManager(ObjectManagerInterface $objectManager)
136    {
137        $this->objectManager = $objectManager;
138    }
139
140    /**
141     * Object initialization called when object is created with ObjectManager, after constructor
142     */
143    public function initializeObject()
144    {
145        $this->dataMapper = $this->objectManager->get(DataMapper::class);
146    }
147
148    /**
149     * @param EnvironmentService $environmentService
150     */
151    public function injectEnvironmentService(EnvironmentService $environmentService)
152    {
153        $this->environmentService = $environmentService;
154    }
155
156    /**
157     * @param ConfigurationManagerInterface $configurationManager
158     */
159    public function injectConfigurationManager(ConfigurationManagerInterface $configurationManager)
160    {
161        $this->configurationManager = $configurationManager;
162    }
163
164    /**
165     * Whether using a distinct query is suggested.
166     * This information is defined during parsing of the current query
167     * for RELATION_HAS_MANY & RELATION_HAS_AND_BELONGS_TO_MANY relations.
168     *
169     * @return bool
170     */
171    public function isDistinctQuerySuggested(): bool
172    {
173        return $this->suggestDistinctQuery;
174    }
175
176    /**
177     * Returns a ready to be executed QueryBuilder object, based on the query
178     *
179     * @param QueryInterface $query
180     * @return QueryBuilder
181     */
182    public function convertQueryToDoctrineQueryBuilder(QueryInterface $query)
183    {
184        // Reset all properties
185        $this->tablePropertyMap = [];
186        $this->tableAliasMap = [];
187        $this->unionTableAliasCache = [];
188        $this->tableName = '';
189
190        if ($query->getStatement() && $query->getStatement()->getStatement() instanceof QueryBuilder) {
191            $this->queryBuilder = clone $query->getStatement()->getStatement();
192            return $this->queryBuilder;
193        }
194        // Find the right table name
195        $source = $query->getSource();
196        $this->initializeQueryBuilder($source);
197
198        $constraint = $query->getConstraint();
199        if ($constraint instanceof ConstraintInterface) {
200            $wherePredicates = $this->parseConstraint($constraint, $source);
201            if (!empty($wherePredicates)) {
202                $this->queryBuilder->andWhere($wherePredicates);
203            }
204        }
205
206        $this->parseOrderings($query->getOrderings(), $source);
207        $this->addTypo3Constraints($query);
208
209        return $this->queryBuilder;
210    }
211
212    /**
213     * Creates the queryBuilder object whether it is a regular select or a JOIN
214     *
215     * @param Qom\SourceInterface $source The source
216     */
217    protected function initializeQueryBuilder(SourceInterface $source)
218    {
219        if ($source instanceof SelectorInterface) {
220            $className = $source->getNodeTypeName();
221            $tableName = $this->dataMapper->getDataMap($className)->getTableName();
222            $this->tableName = $tableName;
223
224            $this->queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
225                ->getQueryBuilderForTable($tableName);
226
227            $this->queryBuilder
228                ->getRestrictions()
229                ->removeAll();
230
231            $tableAlias = $this->getUniqueAlias($tableName);
232
233            $this->queryBuilder
234                ->select($tableAlias . '.*')
235                ->from($tableName, $tableAlias);
236
237            $this->addRecordTypeConstraint($className);
238        } elseif ($source instanceof JoinInterface) {
239            $leftSource = $source->getLeft();
240            $leftTableName = $leftSource->getSelectorName();
241
242            $this->queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
243                ->getQueryBuilderForTable($leftTableName);
244            $leftTableAlias = $this->getUniqueAlias($leftTableName);
245            $this->queryBuilder
246                ->select($leftTableAlias . '.*')
247                ->from($leftTableName, $leftTableAlias);
248            $this->parseJoin($source, $leftTableAlias);
249        }
250    }
251
252    /**
253     * Transforms a constraint into SQL and parameter arrays
254     *
255     * @param Qom\ConstraintInterface $constraint The constraint
256     * @param Qom\SourceInterface $source The source
257     * @return CompositeExpression|string
258     * @throws \RuntimeException
259     */
260    protected function parseConstraint(ConstraintInterface $constraint, SourceInterface $source)
261    {
262        if ($constraint instanceof AndInterface) {
263            return $this->queryBuilder->expr()->andX(
264                $this->parseConstraint($constraint->getConstraint1(), $source),
265                $this->parseConstraint($constraint->getConstraint2(), $source)
266            );
267        }
268        if ($constraint instanceof OrInterface) {
269            return $this->queryBuilder->expr()->orX(
270                $this->parseConstraint($constraint->getConstraint1(), $source),
271                $this->parseConstraint($constraint->getConstraint2(), $source)
272            );
273        }
274        if ($constraint instanceof NotInterface) {
275            return ' NOT(' . $this->parseConstraint($constraint->getConstraint(), $source) . ')';
276        }
277        if ($constraint instanceof ComparisonInterface) {
278            return $this->parseComparison($constraint, $source);
279        }
280        throw new \RuntimeException('not implemented', 1476199898);
281    }
282
283    /**
284     * Transforms orderings into SQL.
285     *
286     * @param array $orderings An array of orderings (Qom\Ordering)
287     * @param Qom\SourceInterface $source The source
288     * @throws UnsupportedOrderException
289     */
290    protected function parseOrderings(array $orderings, SourceInterface $source)
291    {
292        foreach ($orderings as $propertyName => $order) {
293            if ($order !== QueryInterface::ORDER_ASCENDING && $order !== QueryInterface::ORDER_DESCENDING) {
294                throw new UnsupportedOrderException('Unsupported order encountered.', 1242816074);
295            }
296            $className = null;
297            $tableName = '';
298            if ($source instanceof SelectorInterface) {
299                $className = $source->getNodeTypeName();
300                $tableName = $this->dataMapper->convertClassNameToTableName($className);
301                $fullPropertyPath = '';
302                while (strpos($propertyName, '.') !== false) {
303                    $this->addUnionStatement($className, $tableName, $propertyName, $fullPropertyPath);
304                }
305            } elseif ($source instanceof JoinInterface) {
306                $tableName = $source->getLeft()->getSelectorName();
307            }
308            $columnName = $this->dataMapper->convertPropertyNameToColumnName($propertyName, $className);
309            if ($tableName !== '') {
310                $this->queryBuilder->addOrderBy($tableName . '.' . $columnName, $order);
311            } else {
312                $this->queryBuilder->addOrderBy($columnName, $order);
313            }
314        }
315    }
316
317    /**
318     * add TYPO3 Constraints for all tables to the queryBuilder
319     *
320     * @param QueryInterface $query
321     */
322    protected function addTypo3Constraints(QueryInterface $query)
323    {
324        $index = 0;
325        foreach ($this->tableAliasMap as $tableAlias => $tableName) {
326            if ($index === 0) {
327                // We only add the pid and language check for the first table (aggregate root).
328                // We know the first table is always the main table for the current query run.
329                $additionalWhereClauses = $this->getAdditionalWhereClause($query->getQuerySettings(), $tableName, $tableAlias);
330            } else {
331                $additionalWhereClauses = [];
332            }
333            $index++;
334            $statement = $this->getVisibilityConstraintStatement($query->getQuerySettings(), $tableName, $tableAlias);
335            if ($statement !== '') {
336                $additionalWhereClauses[] = $statement;
337            }
338            if (!empty($additionalWhereClauses)) {
339                if (in_array($tableAlias, $this->unionTableAliasCache, true)) {
340                    $this->queryBuilder->andWhere(
341                        $this->queryBuilder->expr()->orX(
342                            $this->queryBuilder->expr()->andX(...$additionalWhereClauses),
343                            $this->queryBuilder->expr()->isNull($tableAlias . '.uid')
344                        )
345                    );
346                } else {
347                    $this->queryBuilder->andWhere(...$additionalWhereClauses);
348                }
349            }
350        }
351    }
352
353    /**
354     * Parse a Comparison into SQL and parameter arrays.
355     *
356     * @param Qom\ComparisonInterface $comparison The comparison to parse
357     * @param Qom\SourceInterface $source The source
358     * @return string
359     * @throws \RuntimeException
360     * @throws RepositoryException
361     * @throws BadConstraintException
362     */
363    protected function parseComparison(ComparisonInterface $comparison, SourceInterface $source)
364    {
365        if ($comparison->getOperator() === QueryInterface::OPERATOR_CONTAINS) {
366            if ($comparison->getOperand2() === null) {
367                throw new BadConstraintException('The value for the CONTAINS operator must not be null.', 1484828468);
368            }
369            $value = $this->dataMapper->getPlainValue($comparison->getOperand2());
370            if (!$source instanceof SelectorInterface) {
371                throw new \RuntimeException('Source is not of type "SelectorInterface"', 1395362539);
372            }
373            $className = $source->getNodeTypeName();
374            $tableName = $this->dataMapper->convertClassNameToTableName($className);
375            $operand1 = $comparison->getOperand1();
376            $propertyName = $operand1->getPropertyName();
377            $fullPropertyPath = '';
378            while (strpos($propertyName, '.') !== false) {
379                $this->addUnionStatement($className, $tableName, $propertyName, $fullPropertyPath);
380            }
381            $columnName = $this->dataMapper->convertPropertyNameToColumnName($propertyName, $className);
382            $dataMap = $this->dataMapper->getDataMap($className);
383            $columnMap = $dataMap->getColumnMap($propertyName);
384            $typeOfRelation = $columnMap instanceof ColumnMap ? $columnMap->getTypeOfRelation() : null;
385            if ($typeOfRelation === ColumnMap::RELATION_HAS_AND_BELONGS_TO_MANY) {
386                /** @var ColumnMap $columnMap */
387                $relationTableName = (string)$columnMap->getRelationTableName();
388                $queryBuilderForSubselect = $this->queryBuilder->getConnection()->createQueryBuilder();
389                $queryBuilderForSubselect
390                        ->select($columnMap->getParentKeyFieldName())
391                        ->from($relationTableName)
392                        ->where(
393                            $queryBuilderForSubselect->expr()->eq(
394                                $columnMap->getChildKeyFieldName(),
395                                $this->queryBuilder->createNamedParameter($value)
396                            )
397                        );
398                $additionalWhereForMatchFields = $this->getAdditionalMatchFieldsStatement($queryBuilderForSubselect->expr(), $columnMap, $relationTableName, $relationTableName);
399                if ($additionalWhereForMatchFields) {
400                    $queryBuilderForSubselect->andWhere($additionalWhereForMatchFields);
401                }
402
403                return $this->queryBuilder->expr()->comparison(
404                    $this->queryBuilder->quoteIdentifier($tableName . '.uid'),
405                    'IN',
406                    '(' . $queryBuilderForSubselect->getSQL() . ')'
407                );
408            }
409            if ($typeOfRelation === ColumnMap::RELATION_HAS_MANY) {
410                $parentKeyFieldName = $columnMap->getParentKeyFieldName();
411                if (isset($parentKeyFieldName)) {
412                    $childTableName = $columnMap->getChildTableName();
413
414                    // Build the SQL statement of the subselect
415                    $queryBuilderForSubselect = $this->queryBuilder->getConnection()->createQueryBuilder();
416                    $queryBuilderForSubselect
417                            ->select($parentKeyFieldName)
418                            ->from($childTableName)
419                            ->where(
420                                $queryBuilderForSubselect->expr()->eq(
421                                    'uid',
422                                    (int)$value
423                                )
424                            );
425
426                    // Add it to the main query
427                    return $this->queryBuilder->expr()->eq(
428                        $tableName . '.uid',
429                        '(' . $queryBuilderForSubselect->getSQL() . ')'
430                    );
431                }
432                return $this->queryBuilder->expr()->inSet(
433                    $tableName . '.' . $columnName,
434                    $this->queryBuilder->quote($value)
435                );
436            }
437            throw new RepositoryException('Unsupported or non-existing property name "' . $propertyName . '" used in relation matching.', 1327065745);
438        }
439        return $this->parseDynamicOperand($comparison, $source);
440    }
441
442    /**
443     * Parse a DynamicOperand into SQL and parameter arrays.
444     *
445     * @param Qom\ComparisonInterface $comparison
446     * @param Qom\SourceInterface $source The source
447     * @return string
448     * @throws Exception
449     * @throws BadConstraintException
450     */
451    protected function parseDynamicOperand(ComparisonInterface $comparison, SourceInterface $source)
452    {
453        $value = $comparison->getOperand2();
454        $fieldName = $this->parseOperand($comparison->getOperand1(), $source);
455        $expr = null;
456        $exprBuilder = $this->queryBuilder->expr();
457        switch ($comparison->getOperator()) {
458            case QueryInterface::OPERATOR_IN:
459                $hasValue = false;
460                $plainValues = [];
461                foreach ($value as $singleValue) {
462                    $plainValue = $this->dataMapper->getPlainValue($singleValue);
463                    if ($plainValue !== null) {
464                        $hasValue = true;
465                        $plainValues[] = $this->createTypedNamedParameter($singleValue);
466                    }
467                }
468                if (!$hasValue) {
469                    throw new BadConstraintException(
470                        'The IN operator needs a non-empty value list to compare against. ' .
471                        'The given value list is empty.',
472                        1484828466
473                    );
474                }
475                $expr = $exprBuilder->comparison($fieldName, 'IN', '(' . implode(', ', $plainValues) . ')');
476                break;
477            case QueryInterface::OPERATOR_EQUAL_TO:
478                if ($value === null) {
479                    $expr = $fieldName . ' IS NULL';
480                } else {
481                    $placeHolder = $this->createTypedNamedParameter($value);
482                    $expr = $exprBuilder->comparison($fieldName, $exprBuilder::EQ, $placeHolder);
483                }
484                break;
485            case QueryInterface::OPERATOR_EQUAL_TO_NULL:
486                $expr = $fieldName . ' IS NULL';
487                break;
488            case QueryInterface::OPERATOR_NOT_EQUAL_TO:
489                if ($value === null) {
490                    $expr = $fieldName . ' IS NOT NULL';
491                } else {
492                    $placeHolder = $this->createTypedNamedParameter($value);
493                    $expr = $exprBuilder->comparison($fieldName, $exprBuilder::NEQ, $placeHolder);
494                }
495                break;
496            case QueryInterface::OPERATOR_NOT_EQUAL_TO_NULL:
497                $expr = $fieldName . ' IS NOT NULL';
498                break;
499            case QueryInterface::OPERATOR_LESS_THAN:
500                $placeHolder = $this->createTypedNamedParameter($value);
501                $expr = $exprBuilder->comparison($fieldName, $exprBuilder::LT, $placeHolder);
502                break;
503            case QueryInterface::OPERATOR_LESS_THAN_OR_EQUAL_TO:
504                $placeHolder = $this->createTypedNamedParameter($value);
505                $expr = $exprBuilder->comparison($fieldName, $exprBuilder::LTE, $placeHolder);
506                break;
507            case QueryInterface::OPERATOR_GREATER_THAN:
508                $placeHolder = $this->createTypedNamedParameter($value);
509                $expr = $exprBuilder->comparison($fieldName, $exprBuilder::GT, $placeHolder);
510                break;
511            case QueryInterface::OPERATOR_GREATER_THAN_OR_EQUAL_TO:
512                $placeHolder = $this->createTypedNamedParameter($value);
513                $expr = $exprBuilder->comparison($fieldName, $exprBuilder::GTE, $placeHolder);
514                break;
515            case QueryInterface::OPERATOR_LIKE:
516                $placeHolder = $this->createTypedNamedParameter($value, \PDO::PARAM_STR);
517                $expr = $exprBuilder->comparison($fieldName, 'LIKE', $placeHolder);
518                break;
519            default:
520                throw new Exception(
521                    'Unsupported operator encountered.',
522                    1242816073
523                );
524        }
525        return $expr;
526    }
527
528    /**
529     * Maps plain value of operand to PDO types to help Doctrine and/or the database driver process the value
530     * correctly when building the query.
531     *
532     * @param mixed $value The parameter value
533     * @return int
534     * @throws \InvalidArgumentException
535     */
536    protected function getParameterType($value): int
537    {
538        $parameterType = gettype($value);
539        switch ($parameterType) {
540            case 'integer':
541                return \PDO::PARAM_INT;
542            case 'string':
543                return \PDO::PARAM_STR;
544            default:
545                throw new \InvalidArgumentException(
546                    'Unsupported parameter type encountered. Expected integer or string, ' . $parameterType . ' given.',
547                    1494878863
548                );
549        }
550    }
551
552    /**
553     * Create a named parameter for the QueryBuilder and guess the parameter type based on the
554     * output of DataMapper::getPlainValue(). The type of the named parameter can be forced to
555     * one of the \PDO::PARAM_* types by specifying the $forceType argument.
556     *
557     * @param mixed $value The input value that should be sent to the database
558     * @param int|null $forceType The \PDO::PARAM_* type that should be forced
559     * @return string The placeholder string to be used in the query
560     * @see \TYPO3\CMS\Extbase\Persistence\Generic\Mapper\DataMapper::getPlainValue()
561     */
562    protected function createTypedNamedParameter($value, int $forceType = null): string
563    {
564        if ($value instanceof AbstractDomainObject
565            && $value->_hasProperty('_localizedUid')
566            && $value->_getProperty('_localizedUid') > 0
567        ) {
568            $plainValue = (int)$value->_getProperty('_localizedUid');
569        } else {
570            $plainValue = $this->dataMapper->getPlainValue($value);
571        }
572        $parameterType = $forceType ?? $this->getParameterType($plainValue);
573        $placeholder = $this->queryBuilder->createNamedParameter($plainValue, $parameterType);
574
575        return $placeholder;
576    }
577
578    /**
579     * @param Qom\DynamicOperandInterface $operand
580     * @param Qom\SourceInterface $source The source
581     * @return string
582     * @throws \InvalidArgumentException
583     */
584    protected function parseOperand(DynamicOperandInterface $operand, SourceInterface $source)
585    {
586        $tableName = null;
587        if ($operand instanceof LowerCaseInterface) {
588            $constraintSQL = 'LOWER(' . $this->parseOperand($operand->getOperand(), $source) . ')';
589        } elseif ($operand instanceof UpperCaseInterface) {
590            $constraintSQL = 'UPPER(' . $this->parseOperand($operand->getOperand(), $source) . ')';
591        } elseif ($operand instanceof PropertyValueInterface) {
592            $propertyName = $operand->getPropertyName();
593            $className = '';
594            if ($source instanceof SelectorInterface) {
595                $className = $source->getNodeTypeName();
596                $tableName = $this->dataMapper->convertClassNameToTableName($className);
597                $fullPropertyPath = '';
598                while (strpos($propertyName, '.') !== false) {
599                    $this->addUnionStatement($className, $tableName, $propertyName, $fullPropertyPath);
600                }
601            } elseif ($source instanceof JoinInterface) {
602                $tableName = $source->getJoinCondition()->getSelector1Name();
603            }
604            $columnName = $this->dataMapper->convertPropertyNameToColumnName($propertyName, $className);
605            $constraintSQL = (!empty($tableName) ? $tableName . '.' : '') . $columnName;
606            $constraintSQL = $this->queryBuilder->getConnection()->quoteIdentifier($constraintSQL);
607        } else {
608            throw new \InvalidArgumentException('Given operand has invalid type "' . get_class($operand) . '".', 1395710211);
609        }
610        return $constraintSQL;
611    }
612
613    /**
614     * Add a constraint to ensure that the record type of the returned tuples is matching the data type of the repository.
615     *
616     * @param string $className The class name
617     */
618    protected function addRecordTypeConstraint($className)
619    {
620        if ($className !== null) {
621            $dataMap = $this->dataMapper->getDataMap($className);
622            if ($dataMap->getRecordTypeColumnName() !== null) {
623                $recordTypes = [];
624                if ($dataMap->getRecordType() !== null) {
625                    $recordTypes[] = $dataMap->getRecordType();
626                }
627                foreach ($dataMap->getSubclasses() as $subclassName) {
628                    $subclassDataMap = $this->dataMapper->getDataMap($subclassName);
629                    if ($subclassDataMap->getRecordType() !== null) {
630                        $recordTypes[] = $subclassDataMap->getRecordType();
631                    }
632                }
633                if (!empty($recordTypes)) {
634                    $recordTypeStatements = [];
635                    foreach ($recordTypes as $recordType) {
636                        $tableName = $dataMap->getTableName();
637                        $recordTypeStatements[] = $this->queryBuilder->expr()->eq(
638                            $tableName . '.' . $dataMap->getRecordTypeColumnName(),
639                            $this->queryBuilder->createNamedParameter($recordType)
640                        );
641                    }
642                    $this->queryBuilder->andWhere(
643                        $this->queryBuilder->expr()->orX(...$recordTypeStatements)
644                    );
645                }
646            }
647        }
648    }
649
650    /**
651     * Builds a condition for filtering records by the configured match field,
652     * e.g. MM_match_fields, foreign_match_fields or foreign_table_field.
653     *
654     * @param ExpressionBuilder $exprBuilder
655     * @param ColumnMap $columnMap The column man for which the condition should be build.
656     * @param string $childTableAlias The alias of the child record table used in the query.
657     * @param string $parentTable The real name of the parent table (used for building the foreign_table_field condition).
658     * @return string The match field conditions or an empty string.
659     */
660    protected function getAdditionalMatchFieldsStatement($exprBuilder, $columnMap, $childTableAlias, $parentTable = null)
661    {
662        $additionalWhereForMatchFields = [];
663        $relationTableMatchFields = $columnMap->getRelationTableMatchFields();
664        if (is_array($relationTableMatchFields) && !empty($relationTableMatchFields)) {
665            foreach ($relationTableMatchFields as $fieldName => $value) {
666                $additionalWhereForMatchFields[] = $exprBuilder->eq($childTableAlias . '.' . $fieldName, $this->queryBuilder->createNamedParameter($value));
667            }
668        }
669
670        if (isset($parentTable)) {
671            $parentTableFieldName = $columnMap->getParentTableFieldName();
672            if (!empty($parentTableFieldName)) {
673                $additionalWhereForMatchFields[] = $exprBuilder->eq($childTableAlias . '.' . $parentTableFieldName, $this->queryBuilder->createNamedParameter($parentTable));
674            }
675        }
676
677        if (!empty($additionalWhereForMatchFields)) {
678            return $exprBuilder->andX(...$additionalWhereForMatchFields);
679        }
680        return '';
681    }
682
683    /**
684     * Adds additional WHERE statements according to the query settings.
685     *
686     * @param QuerySettingsInterface $querySettings The TYPO3 CMS specific query settings
687     * @param string $tableName The table name to add the additional where clause for
688     * @param string $tableAlias The table alias used in the query.
689     * @return array
690     */
691    protected function getAdditionalWhereClause(QuerySettingsInterface $querySettings, $tableName, $tableAlias = null)
692    {
693        $tableAlias = (string)$tableAlias;
694        // todo: $tableAlias must not be null
695
696        $whereClause = [];
697        if ($querySettings->getRespectSysLanguage()) {
698            $systemLanguageStatement = $this->getLanguageStatement($tableName, $tableAlias, $querySettings);
699            if (!empty($systemLanguageStatement)) {
700                $whereClause[] = $systemLanguageStatement;
701            }
702        }
703
704        if ($querySettings->getRespectStoragePage()) {
705            $pageIdStatement = $this->getPageIdStatement($tableName, $tableAlias, $querySettings->getStoragePageIds());
706            if (!empty($pageIdStatement)) {
707                $whereClause[] = $pageIdStatement;
708            }
709        } elseif (!empty($GLOBALS['TCA'][$tableName]['ctrl']['versioningWS'])) {
710            // Always prevent workspace records from being returned
711            $whereClause[] = $this->queryBuilder->expr()->eq($tableAlias . '.t3ver_oid', 0);
712        }
713
714        return $whereClause;
715    }
716
717    /**
718     * Adds enableFields and deletedClause to the query if necessary
719     *
720     * @param QuerySettingsInterface $querySettings
721     * @param string $tableName The database table name
722     * @param string $tableAlias
723     * @return string
724     */
725    protected function getVisibilityConstraintStatement(QuerySettingsInterface $querySettings, $tableName, $tableAlias)
726    {
727        $statement = '';
728        if (is_array($GLOBALS['TCA'][$tableName]['ctrl'] ?? null)) {
729            $ignoreEnableFields = $querySettings->getIgnoreEnableFields();
730            $enableFieldsToBeIgnored = $querySettings->getEnableFieldsToBeIgnored();
731            $includeDeleted = $querySettings->getIncludeDeleted();
732            if ($this->environmentService->isEnvironmentInFrontendMode()) {
733                $statement .= $this->getFrontendConstraintStatement($tableName, $ignoreEnableFields, $enableFieldsToBeIgnored, $includeDeleted);
734            } else {
735                // TYPO3_MODE === 'BE'
736                $statement .= $this->getBackendConstraintStatement($tableName, $ignoreEnableFields, $includeDeleted);
737            }
738            if (!empty($statement)) {
739                $statement = $this->replaceTableNameWithAlias($statement, $tableName, $tableAlias);
740                $statement = strtolower(substr($statement, 1, 3)) === 'and' ? substr($statement, 5) : $statement;
741            }
742        }
743        return $statement;
744    }
745
746    /**
747     * Returns constraint statement for frontend context
748     *
749     * @param string $tableName
750     * @param bool $ignoreEnableFields A flag indicating whether the enable fields should be ignored
751     * @param array $enableFieldsToBeIgnored If $ignoreEnableFields is true, this array specifies enable fields to be ignored. If it is NULL or an empty array (default) all enable fields are ignored.
752     * @param bool $includeDeleted A flag indicating whether deleted records should be included
753     * @return string
754     * @throws InconsistentQuerySettingsException
755     */
756    protected function getFrontendConstraintStatement($tableName, $ignoreEnableFields, array $enableFieldsToBeIgnored = [], $includeDeleted)
757    {
758        $statement = '';
759        if ($ignoreEnableFields && !$includeDeleted) {
760            if (!empty($enableFieldsToBeIgnored)) {
761                // array_combine() is necessary because of the way \TYPO3\CMS\Core\Domain\Repository\PageRepository::enableFields() is implemented
762                $statement .= $this->getPageRepository()->enableFields($tableName, -1, array_combine($enableFieldsToBeIgnored, $enableFieldsToBeIgnored), true);
763            } elseif (!empty($GLOBALS['TCA'][$tableName]['ctrl']['delete'])) {
764                $statement .= ' AND ' . $tableName . '.' . $GLOBALS['TCA'][$tableName]['ctrl']['delete'] . '=0';
765            }
766        } elseif (!$ignoreEnableFields && !$includeDeleted) {
767            $statement .= $this->getPageRepository()->enableFields($tableName);
768        } elseif (!$ignoreEnableFields && $includeDeleted) {
769            throw new InconsistentQuerySettingsException('Query setting "ignoreEnableFields=FALSE" can not be used together with "includeDeleted=TRUE" in frontend context.', 1460975922);
770        }
771        return $statement;
772    }
773
774    /**
775     * Returns constraint statement for backend context
776     *
777     * @param string $tableName
778     * @param bool $ignoreEnableFields A flag indicating whether the enable fields should be ignored
779     * @param bool $includeDeleted A flag indicating whether deleted records should be included
780     * @return string
781     */
782    protected function getBackendConstraintStatement($tableName, $ignoreEnableFields, $includeDeleted)
783    {
784        $statement = '';
785        // In case of versioning-preview, enableFields are ignored (checked in Typo3DbBackend::doLanguageAndWorkspaceOverlay)
786        $isUserInWorkspace = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('workspace', 'isOffline');
787        if (!$ignoreEnableFields && !$isUserInWorkspace) {
788            $statement .= BackendUtility::BEenableFields($tableName);
789        }
790        if (!$includeDeleted && !empty($GLOBALS['TCA'][$tableName]['ctrl']['delete'])) {
791            $statement .= ' AND ' . $tableName . '.' . $GLOBALS['TCA'][$tableName]['ctrl']['delete'] . '=0';
792        }
793        return $statement;
794    }
795
796    /**
797     * Builds the language field statement
798     *
799     * @param string $tableName The database table name
800     * @param string $tableAlias The table alias used in the query.
801     * @param QuerySettingsInterface $querySettings The TYPO3 CMS specific query settings
802     * @return string
803     */
804    protected function getLanguageStatement($tableName, $tableAlias, QuerySettingsInterface $querySettings)
805    {
806        if (empty($GLOBALS['TCA'][$tableName]['ctrl']['languageField'])) {
807            return '';
808        }
809
810        // Select all entries for the current language
811        // If any language is set -> get those entries which are not translated yet
812        // They will be removed by \TYPO3\CMS\Core\Domain\Repository\PageRepository::getRecordOverlay if not matching overlay mode
813        $languageField = $GLOBALS['TCA'][$tableName]['ctrl']['languageField'];
814
815        $transOrigPointerField = $GLOBALS['TCA'][$tableName]['ctrl']['transOrigPointerField'] ?? '';
816        if (!$transOrigPointerField || !$querySettings->getLanguageUid()) {
817            return $this->queryBuilder->expr()->in(
818                $tableAlias . '.' . $languageField,
819                [(int)$querySettings->getLanguageUid(), -1]
820            );
821        }
822
823        $mode = $querySettings->getLanguageOverlayMode();
824        if (!$mode) {
825            return $this->queryBuilder->expr()->in(
826                $tableAlias . '.' . $languageField,
827                [(int)$querySettings->getLanguageUid(), -1]
828            );
829        }
830
831        $defLangTableAlias = $tableAlias . '_dl';
832        $defaultLanguageRecordsSubSelect = $this->queryBuilder->getConnection()->createQueryBuilder();
833        $defaultLanguageRecordsSubSelect
834            ->select($defLangTableAlias . '.uid')
835            ->from($tableName, $defLangTableAlias)
836            ->where(
837                $defaultLanguageRecordsSubSelect->expr()->andX(
838                    $defaultLanguageRecordsSubSelect->expr()->eq($defLangTableAlias . '.' . $transOrigPointerField, 0),
839                    $defaultLanguageRecordsSubSelect->expr()->eq($defLangTableAlias . '.' . $languageField, 0)
840                )
841            );
842
843        $andConditions = [];
844        // records in language 'all'
845        $andConditions[] = $this->queryBuilder->expr()->eq($tableAlias . '.' . $languageField, -1);
846        // translated records where a default language exists
847        $andConditions[] = $this->queryBuilder->expr()->andX(
848            $this->queryBuilder->expr()->eq($tableAlias . '.' . $languageField, (int)$querySettings->getLanguageUid()),
849            $this->queryBuilder->expr()->in(
850                $tableAlias . '.' . $transOrigPointerField,
851                $defaultLanguageRecordsSubSelect->getSQL()
852            )
853        );
854        if ($mode !== 'hideNonTranslated') {
855            // $mode = TRUE
856            // returns records from current language which have default language
857            // together with not translated default language records
858            $translatedOnlyTableAlias = $tableAlias . '_to';
859            $queryBuilderForSubselect = $this->queryBuilder->getConnection()->createQueryBuilder();
860            $queryBuilderForSubselect
861                ->select($translatedOnlyTableAlias . '.' . $transOrigPointerField)
862                ->from($tableName, $translatedOnlyTableAlias)
863                ->where(
864                    $queryBuilderForSubselect->expr()->andX(
865                        $queryBuilderForSubselect->expr()->gt($translatedOnlyTableAlias . '.' . $transOrigPointerField, 0),
866                        $queryBuilderForSubselect->expr()->eq($translatedOnlyTableAlias . '.' . $languageField, (int)$querySettings->getLanguageUid())
867                    )
868                );
869            // records in default language, which do not have a translation
870            $andConditions[] = $this->queryBuilder->expr()->andX(
871                $this->queryBuilder->expr()->eq($tableAlias . '.' . $languageField, 0),
872                $this->queryBuilder->expr()->notIn(
873                    $tableAlias . '.uid',
874                    $queryBuilderForSubselect->getSQL()
875                )
876            );
877        }
878
879        return $this->queryBuilder->expr()->orX(...$andConditions);
880    }
881
882    /**
883     * Builds the page ID checking statement
884     *
885     * @param string $tableName The database table name
886     * @param string $tableAlias The table alias used in the query.
887     * @param array $storagePageIds list of storage page ids
888     * @return string
889     * @throws InconsistentQuerySettingsException
890     */
891    protected function getPageIdStatement($tableName, $tableAlias, array $storagePageIds)
892    {
893        if (!is_array($GLOBALS['TCA'][$tableName]['ctrl'])) {
894            return '';
895        }
896
897        $rootLevel = (int)$GLOBALS['TCA'][$tableName]['ctrl']['rootLevel'];
898        switch ($rootLevel) {
899            // Only in pid 0
900            case 1:
901                $storagePageIds = [0];
902                break;
903            // Pid 0 and pagetree
904            case -1:
905                if (empty($storagePageIds)) {
906                    $storagePageIds = [0];
907                } else {
908                    $storagePageIds[] = 0;
909                }
910                break;
911            // Only pagetree or not set
912            case 0:
913                if (empty($storagePageIds)) {
914                    throw new InconsistentQuerySettingsException('Missing storage page ids.', 1365779762);
915                }
916                break;
917            // Invalid configuration
918            default:
919                return '';
920        }
921        $storagePageIds = array_map('intval', $storagePageIds);
922        if (count($storagePageIds) === 1) {
923            return $this->queryBuilder->expr()->eq($tableAlias . '.pid', reset($storagePageIds));
924        }
925        return $this->queryBuilder->expr()->in($tableAlias . '.pid', $storagePageIds);
926    }
927
928    /**
929     * Transforms a Join into SQL and parameter arrays
930     *
931     * @param Qom\JoinInterface $join The join
932     * @param string $leftTableAlias The alias from the table to main
933     */
934    protected function parseJoin(JoinInterface $join, $leftTableAlias)
935    {
936        $leftSource = $join->getLeft();
937        $leftClassName = $leftSource->getNodeTypeName();
938        $this->addRecordTypeConstraint($leftClassName);
939        $rightSource = $join->getRight();
940        if ($rightSource instanceof JoinInterface) {
941            $left = $rightSource->getLeft();
942            $rightClassName = $left->getNodeTypeName();
943            $rightTableName = $left->getSelectorName();
944        } else {
945            $rightClassName = $rightSource->getNodeTypeName();
946            $rightTableName = $rightSource->getSelectorName();
947            $this->queryBuilder->addSelect($rightTableName . '.*');
948        }
949        $this->addRecordTypeConstraint($rightClassName);
950        $rightTableAlias = $this->getUniqueAlias($rightTableName);
951        $joinCondition = $join->getJoinCondition();
952        $joinConditionExpression = null;
953        if ($joinCondition instanceof EquiJoinCondition) {
954            $column1Name = $this->dataMapper->convertPropertyNameToColumnName($joinCondition->getProperty1Name(), $leftClassName);
955            $column2Name = $this->dataMapper->convertPropertyNameToColumnName($joinCondition->getProperty2Name(), $rightClassName);
956
957            $joinConditionExpression = $this->queryBuilder->expr()->eq(
958                $leftTableAlias . '.' . $column1Name,
959                $this->queryBuilder->quoteIdentifier($rightTableAlias . '.' . $column2Name)
960            );
961        }
962        $this->queryBuilder->leftJoin($leftTableAlias, $rightTableName, $rightTableAlias, $joinConditionExpression);
963        if ($rightSource instanceof JoinInterface) {
964            $this->parseJoin($rightSource, $rightTableAlias);
965        }
966    }
967
968    /**
969     * Generates a unique alias for the given table and the given property path.
970     * The property path will be mapped to the generated alias in the tablePropertyMap.
971     *
972     * @param string $tableName The name of the table for which the alias should be generated.
973     * @param string $fullPropertyPath The full property path that is related to the given table.
974     * @return string The generated table alias.
975     */
976    protected function getUniqueAlias($tableName, $fullPropertyPath = null)
977    {
978        if (isset($fullPropertyPath) && isset($this->tablePropertyMap[$fullPropertyPath])) {
979            return $this->tablePropertyMap[$fullPropertyPath];
980        }
981
982        $alias = $tableName;
983        $i = 0;
984        while (isset($this->tableAliasMap[$alias])) {
985            $alias = $tableName . $i;
986            $i++;
987        }
988
989        $this->tableAliasMap[$alias] = $tableName;
990
991        if (isset($fullPropertyPath)) {
992            $this->tablePropertyMap[$fullPropertyPath] = $alias;
993        }
994
995        return $alias;
996    }
997
998    /**
999     * adds a union statement to the query, mostly for tables referenced in the where condition.
1000     * The property for which the union statement is generated will be appended.
1001     *
1002     * @param string $className The name of the parent class, will be set to the child class after processing.
1003     * @param string $tableName The name of the parent table, will be set to the table alias that is used in the union statement.
1004     * @param string $propertyPath The remaining property path, will be cut of by one part during the process.
1005     * @param string $fullPropertyPath The full path the the current property, will be used to make table names unique.
1006     * @throws Exception
1007     * @throws InvalidRelationConfigurationException
1008     * @throws MissingColumnMapException
1009     */
1010    protected function addUnionStatement(&$className, &$tableName, &$propertyPath, &$fullPropertyPath)
1011    {
1012        $explodedPropertyPath = explode('.', $propertyPath, 2);
1013        $propertyName = $explodedPropertyPath[0];
1014        $columnName = $this->dataMapper->convertPropertyNameToColumnName($propertyName, $className);
1015        $realTableName = $this->dataMapper->convertClassNameToTableName($className);
1016        $tableName = $this->tablePropertyMap[$fullPropertyPath] ?? $realTableName;
1017        $columnMap = $this->dataMapper->getDataMap($className)->getColumnMap($propertyName);
1018
1019        if ($columnMap === null) {
1020            throw new MissingColumnMapException('The ColumnMap for property "' . $propertyName . '" of class "' . $className . '" is missing.', 1355142232);
1021        }
1022
1023        $parentKeyFieldName = $columnMap->getParentKeyFieldName();
1024        $childTableName = $columnMap->getChildTableName();
1025
1026        if ($childTableName === null) {
1027            throw new InvalidRelationConfigurationException('The relation information for property "' . $propertyName . '" of class "' . $className . '" is missing.', 1353170925);
1028        }
1029
1030        $fullPropertyPath .= ($fullPropertyPath === '') ? $propertyName : '.' . $propertyName;
1031        $childTableAlias = $this->getUniqueAlias($childTableName, $fullPropertyPath);
1032
1033        // If there is already a union with the current identifier we do not need to build it again and exit early.
1034        if (in_array($childTableAlias, $this->unionTableAliasCache, true)) {
1035            $propertyPath = $explodedPropertyPath[1];
1036            $tableName = $childTableAlias;
1037            $className = $this->dataMapper->getType($className, $propertyName);
1038            return;
1039        }
1040
1041        if ($columnMap->getTypeOfRelation() === ColumnMap::RELATION_HAS_ONE) {
1042            if (isset($parentKeyFieldName)) {
1043                // @todo: no test for this part yet
1044                $joinConditionExpression = $this->queryBuilder->expr()->eq(
1045                    $tableName . '.uid',
1046                    $this->queryBuilder->quoteIdentifier($childTableAlias . '.' . $parentKeyFieldName)
1047                );
1048            } else {
1049                $joinConditionExpression = $this->queryBuilder->expr()->eq(
1050                    $tableName . '.' . $columnName,
1051                    $this->queryBuilder->quoteIdentifier($childTableAlias . '.uid')
1052                );
1053            }
1054            $this->queryBuilder->leftJoin($tableName, $childTableName, $childTableAlias, $joinConditionExpression);
1055            $this->unionTableAliasCache[] = $childTableAlias;
1056            $this->queryBuilder->andWhere(
1057                $this->getAdditionalMatchFieldsStatement($this->queryBuilder->expr(), $columnMap, $childTableAlias, $realTableName)
1058            );
1059        } elseif ($columnMap->getTypeOfRelation() === ColumnMap::RELATION_HAS_MANY) {
1060            // @todo: no tests for this part yet
1061            if (isset($parentKeyFieldName)) {
1062                $joinConditionExpression = $this->queryBuilder->expr()->eq(
1063                    $tableName . '.uid',
1064                    $this->queryBuilder->quoteIdentifier($childTableAlias . '.' . $parentKeyFieldName)
1065                );
1066            } else {
1067                $joinConditionExpression = $this->queryBuilder->expr()->inSet(
1068                    $tableName . '.' . $columnName,
1069                    $this->queryBuilder->quoteIdentifier($childTableAlias . '.uid'),
1070                    true
1071                );
1072            }
1073            $this->queryBuilder->leftJoin($tableName, $childTableName, $childTableAlias, $joinConditionExpression);
1074            $this->unionTableAliasCache[] = $childTableAlias;
1075            $this->queryBuilder->andWhere(
1076                $this->getAdditionalMatchFieldsStatement($this->queryBuilder->expr(), $columnMap, $childTableAlias, $realTableName)
1077            );
1078            $this->suggestDistinctQuery = true;
1079        } elseif ($columnMap->getTypeOfRelation() === ColumnMap::RELATION_HAS_AND_BELONGS_TO_MANY) {
1080            $relationTableName = (string)$columnMap->getRelationTableName();
1081            $relationTableAlias = $this->getUniqueAlias($relationTableName, $fullPropertyPath . '_mm');
1082
1083            $joinConditionExpression = $this->queryBuilder->expr()->andX(
1084                $this->queryBuilder->expr()->eq(
1085                    $tableName . '.uid',
1086                    $this->queryBuilder->quoteIdentifier(
1087                        $relationTableAlias . '.' . $columnMap->getParentKeyFieldName()
1088                    )
1089                ),
1090                $this->getAdditionalMatchFieldsStatement($this->queryBuilder->expr(), $columnMap, $relationTableAlias, $realTableName)
1091            );
1092            $this->queryBuilder->leftJoin($tableName, $relationTableName, $relationTableAlias, $joinConditionExpression);
1093            $joinConditionExpression = $this->queryBuilder->expr()->eq(
1094                $relationTableAlias . '.' . $columnMap->getChildKeyFieldName(),
1095                $this->queryBuilder->quoteIdentifier($childTableAlias . '.uid')
1096            );
1097            $this->queryBuilder->leftJoin($relationTableAlias, $childTableName, $childTableAlias, $joinConditionExpression);
1098            $this->unionTableAliasCache[] = $childTableAlias;
1099            $this->suggestDistinctQuery = true;
1100        } else {
1101            throw new Exception('Could not determine type of relation.', 1252502725);
1102        }
1103        $propertyPath = $explodedPropertyPath[1];
1104        $tableName = $childTableAlias;
1105        $className = $this->dataMapper->getType($className, $propertyName);
1106    }
1107
1108    /**
1109     * If the table name does not match the table alias all occurrences of
1110     * "tableName." are replaced with "tableAlias." in the given SQL statement.
1111     *
1112     * @param string $statement The SQL statement in which the values are replaced.
1113     * @param string $tableName The table name that is replaced.
1114     * @param string $tableAlias The table alias that replaced the table name.
1115     * @return string The modified SQL statement.
1116     */
1117    protected function replaceTableNameWithAlias($statement, $tableName, $tableAlias)
1118    {
1119        if ($tableAlias !== $tableName) {
1120            /** @var Connection $connection */
1121            $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($tableName);
1122            $quotedTableName = $connection->quoteIdentifier($tableName);
1123            $quotedTableAlias = $connection->quoteIdentifier($tableAlias);
1124            $statement = str_replace(
1125                [$tableName . '.', $quotedTableName . '.'],
1126                [$tableAlias . '.', $quotedTableAlias . '.'],
1127                $statement
1128            );
1129        }
1130
1131        return $statement;
1132    }
1133
1134    /**
1135     * @return PageRepository
1136     */
1137    protected function getPageRepository()
1138    {
1139        if (!$this->pageRepository instanceof PageRepository) {
1140            $this->pageRepository = GeneralUtility::makeInstance(PageRepository::class);
1141        }
1142        return $this->pageRepository;
1143    }
1144}
1145