ew_base/Classes/DataProcessing/DatabaseQueryProcessor.php
2024-12-26 21:44:19 +01:00

617 lines
26 KiB
PHP

<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace Evoweb\EwBase\DataProcessing;
use Doctrine\DBAL\Exception as DBALException;
use Doctrine\DBAL\Result;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Context\Exception\AspectNotFoundException;
use TYPO3\CMS\Core\Context\LanguageAspect;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Expression\CompositeExpression;
use TYPO3\CMS\Core\Database\Query\Expression\ExpressionBuilder;
use TYPO3\CMS\Core\Database\Query\QueryHelper;
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
use TYPO3\CMS\Core\TimeTracker\TimeTracker;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
use TYPO3\CMS\Core\Versioning\VersionState;
use TYPO3\CMS\Frontend\ContentObject\ContentDataProcessor;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
use TYPO3\CMS\Frontend\ContentObject\DataProcessorInterface;
use TYPO3\CMS\Frontend\ContentObject\Exception\ContentRenderingException;
/**
* Fetch records from the database, using the default .select syntax from TypoScript.
*
* This way, e.g. a FLUIDTEMPLATE cObject can iterate over the array of records.
*
* Example TypoScript configuration:
*
* 10 = TYPO3\CMS\Frontend\DataProcessing\DatabaseQueryProcessor
* 10 {
* table = tt_address
* pidInList = 123
* where = company="Acme" AND first_name="Ralph"
* orderBy = sorting DESC
* as = addresses
* dataProcessing {
* 10 = TYPO3\CMS\Frontend\DataProcessing\FilesProcessor
* 10 {
* references.fieldName = image
* }
* }
* }
*
* where "as" means the variable to be containing the result-set from the DB query.
*/
class DatabaseQueryProcessor implements DataProcessorInterface
{
protected ContentObjectRenderer $cObj;
protected ?ServerRequestInterface $request = null;
protected string $tableName = '';
public function __construct(protected readonly ContentDataProcessor $contentDataProcessor) {}
/**
* Fetches records from the database as an array
*
* @param ContentObjectRenderer $cObj The data of the content element or page
* @param array $contentObjectConfiguration The configuration of Content Object
* @param array $processorConfiguration The configuration of this processor
* @param array $processedData Key/value store of processed data (e.g. to be passed to a Fluid View)
*
* @return array the processed data as key/value store
* @throws ContentRenderingException
*/
public function process(
ContentObjectRenderer $cObj,
array $contentObjectConfiguration,
array $processorConfiguration,
array $processedData
): array {
if (isset($processorConfiguration['if.']) && !$cObj->checkIf($processorConfiguration['if.'])) {
return $processedData;
}
// the table to query, if none given, exit
$tableName = $cObj->stdWrapValue('table', $processorConfiguration);
if (empty($tableName)) {
return $processedData;
}
if (isset($processorConfiguration['table.'])) {
unset($processorConfiguration['table.']);
}
if (isset($processorConfiguration['table'])) {
unset($processorConfiguration['table']);
}
$this->request = $cObj->getRequest();
$this->tableName = $tableName;
$this->cObj = clone $cObj;
// @extensionScannerIgnoreLine
$this->cObj->start($cObj->data, $tableName);
$this->cObj->setRequest($this->request);
// The variable to be used within the result
$targetVariableName = $cObj->stdWrapValue('as', $processorConfiguration, 'records');
// Execute a SQL statement to fetch the records
$records = $this->getRecords($tableName, $processorConfiguration);
$request = $cObj->getRequest();
$processedRecordVariables = [];
foreach ($records as $key => $record) {
/** @var ContentObjectRenderer $recordContentObjectRenderer */
$recordContentObjectRenderer = GeneralUtility::makeInstance(ContentObjectRenderer::class);
$recordContentObjectRenderer->setRequest($request);
$recordContentObjectRenderer->start($record, $tableName);
$processedRecordVariables[$key] = ['data' => $record];
$processedRecordVariables[$key] = $this->contentDataProcessor->process(
$recordContentObjectRenderer,
$processorConfiguration,
$processedRecordVariables[$key]
);
}
$processedData[$targetVariableName] = $processedRecordVariables;
return $processedData;
}
protected function getRecords(string $tableName, array $queryConfiguration): array
{
$records = [];
$statement = $this->exec_getQuery($tableName, $queryConfiguration);
$pageRepository = $this->getPageRepository();
while ($row = $statement->fetchAssociative()) {
// Versioning preview:
$pageRepository->versionOL($tableName, $row, true);
// Language overlay:
if (is_array($row)) {
$row = $pageRepository->getLanguageOverlay($tableName, $row);
}
// Might be unset in the language overlay
if (is_array($row)) {
$records[] = $row;
}
}
return $records;
}
protected function exec_getQuery(string $table, array $conf): Result
{
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
$statement = $this->getQuery($connection, $table, $conf);
return $connection->executeQuery($statement);
}
public function getQuery(Connection $connection, $table, $conf): string
{
// Resolve stdWrap in these properties first
$properties = [
'pidInList',
'uidInList',
'languageField',
'selectFields',
'max',
'begin',
'groupBy',
'orderBy',
'join',
'leftjoin',
'rightjoin',
'recursive',
'where',
];
foreach ($properties as $property) {
$conf[$property] = trim(
isset($conf[$property . '.'])
? (string)$this->cObj->stdWrap($conf[$property] ?? '', $conf[$property . '.'] ?? [])
: (string)($conf[$property] ?? '')
);
if ($conf[$property] === '') {
unset($conf[$property]);
} elseif (in_array($property, ['languageField', 'selectFields', 'join', 'leftjoin', 'rightjoin', 'where'], true)) {
$conf[$property] = QueryHelper::quoteDatabaseIdentifiers($connection, $conf[$property]);
}
if (isset($conf[$property . '.'])) {
// stdWrapping already done, so remove the sub-array
unset($conf[$property . '.']);
}
}
// Handle PDO-style named parameter markers first
$queryMarkers = $this->cObj->getQueryMarkers($connection, $conf);
// Replace the markers in the non-stdWrap properties
foreach ($queryMarkers as $marker => $markerValue) {
$properties = [
'uidInList',
'selectFields',
'where',
'max',
'begin',
'groupBy',
'orderBy',
'join',
'leftjoin',
'rightjoin',
];
foreach ($properties as $property) {
if ($conf[$property] ?? false) {
$conf[$property] = str_replace('###' . $marker . '###', $markerValue, $conf[$property]);
}
}
}
// Construct WHERE clause:
// Handle recursive function for the pidInList
if (isset($conf['recursive'])) {
$conf['recursive'] = (int)$conf['recursive'];
if ($conf['recursive'] > 0) {
$pidList = GeneralUtility::trimExplode(',', $conf['pidInList'], true);
array_walk($pidList, function (&$storagePid) {
if ($storagePid === 'this') {
$storagePid = $this->getRequest()->getAttribute('frontend.page.information')->getId();
}
});
$pageRepository = $this->getPageRepository();
$expandedPidList = $pageRepository->getPageIdsRecursive($pidList, $conf['recursive']);
$conf['pidInList'] = implode(',', $expandedPidList);
}
}
if ((string)($conf['pidInList'] ?? '') === '') {
$conf['pidInList'] = 'this';
}
$queryParts = $this->getQueryConstraints($connection, $table, $conf);
$queryBuilder = $connection->createQueryBuilder();
// @todo Check against getQueryConstraints, can probably use FrontendRestrictions
// @todo here and remove enableFields there.
$queryBuilder->getRestrictions()->removeAll();
$queryBuilder->select('*')->from($table);
if ($queryParts['where'] ?? false) {
$queryBuilder->where($queryParts['where']);
}
if (($queryParts['groupBy'] ?? false) && is_array($queryParts['groupBy'])) {
$queryBuilder->groupBy(...$queryParts['groupBy']);
}
if (is_array($queryParts['orderBy'] ?? false)) {
foreach ($queryParts['orderBy'] as $orderBy) {
$queryBuilder->addOrderBy(...$orderBy);
}
}
// Fields:
if ($conf['selectFields'] ?? false) {
$queryBuilder->selectLiteral($this->sanitizeSelectPart($connection, $conf['selectFields'], $table));
}
// Setting LIMIT:
if (($conf['max'] ?? false) || ($conf['begin'] ?? false)) {
// Finding the total number of records, if used:
if (str_contains(strtolower(($conf['begin'] ?? '') . ($conf['max'] ?? '')), 'total')) {
$countQueryBuilder = $connection->createQueryBuilder();
$countQueryBuilder->getRestrictions()->removeAll();
$countQueryBuilder->count('*')
->from($table)
->where($queryParts['where']);
if ($queryParts['groupBy']) {
$countQueryBuilder->groupBy(...$queryParts['groupBy']);
}
try {
$count = $countQueryBuilder->executeQuery()->fetchOne();
if (isset($conf['max'])) {
$conf['max'] = str_ireplace('total', $count, (string)$conf['max']);
}
if (isset($conf['begin'])) {
$conf['begin'] = str_ireplace('total', $count, (string)$conf['begin']);
}
} catch (DBALException $e) {
$this->getTimeTracker()->setTSlogMessage($e->getPrevious()->getMessage());
return '';
}
}
if (isset($conf['begin']) && $conf['begin'] > 0) {
$conf['begin'] = MathUtility::forceIntegerInRange((int)ceil($this->cObj->calc($conf['begin'])), 0);
$queryBuilder->setFirstResult($conf['begin']);
}
if (isset($conf['max'])) {
$conf['max'] = MathUtility::forceIntegerInRange((int)ceil($this->cObj->calc($conf['max'])), 0);
$queryBuilder->setMaxResults($conf['max'] ?: 100000);
}
}
// Setting up tablejoins:
if ($conf['join'] ?? false) {
$joinParts = QueryHelper::parseJoin($conf['join']);
$queryBuilder->join(
$table,
$joinParts['tableName'],
$joinParts['tableAlias'],
$joinParts['joinCondition']
);
} elseif ($conf['leftjoin'] ?? false) {
$joinParts = QueryHelper::parseJoin($conf['leftjoin']);
$queryBuilder->leftJoin(
$table,
$joinParts['tableName'],
$joinParts['tableAlias'],
$joinParts['joinCondition']
);
} elseif ($conf['rightjoin'] ?? false) {
$joinParts = QueryHelper::parseJoin($conf['rightjoin']);
$queryBuilder->rightJoin(
$table,
$joinParts['tableName'],
$joinParts['tableAlias'],
$joinParts['joinCondition']
);
}
// Convert the QueryBuilder object into a SQL statement.
$query = $queryBuilder->getSQL();
// Replace the markers in the queryParts to handle stdWrap enabled properties
foreach ($queryMarkers as $marker => $markerValue) {
// @todo Ugly hack that needs to be cleaned up, with the current architecture
// @todo for exec_Query / getQuery it's the best we can do.
$query = str_replace('###' . $marker . '###', (string)$markerValue, $query);
}
return $query;
}
/**
* Helper function for getQuery(), creating the WHERE clause of the SELECT query
*
* @param Connection $connection
* @param string $table The table name
* @param array $conf The TypoScript configuration properties
* @return array Associative array containing the prepared data for WHERE, ORDER BY and GROUP BY fragments
* @throws AspectNotFoundException
* @throws ContentRenderingException
* @see getQuery()
*/
protected function getQueryConstraints(Connection $connection, string $table, array $conf): array
{
$queryBuilder = $connection->createQueryBuilder();
$expressionBuilder = $queryBuilder->expr();
$request = $this->getRequest();
$contentPid = $request->getAttribute('frontend.page.information')->getContentFromPid();
$constraints = [];
$pid_uid_flag = 0;
$enableFieldsIgnore = [];
$queryParts = [
'where' => null,
'groupBy' => null,
'orderBy' => null,
];
$isInWorkspace = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('workspace', 'isOffline');
$considerMovePointers = (
$isInWorkspace && $table !== 'pages'
&& !empty($GLOBALS['TCA'][$table]['ctrl']['versioningWS'])
);
if (trim($conf['uidInList'] ?? '')) {
$listArr = GeneralUtility::intExplode(',', str_replace('this', (string)$contentPid, $conf['uidInList']));
// If moved records shall be considered, select via t3ver_oid
if ($considerMovePointers) {
$constraints[] = (string)$expressionBuilder->or(
$expressionBuilder->in($table . '.uid', $listArr),
$expressionBuilder->and(
$expressionBuilder->eq(
$table . '.t3ver_state',
VersionState::MOVE_POINTER->value
),
$expressionBuilder->in($table . '.t3ver_oid', $listArr)
)
);
} else {
$constraints[] = (string)$expressionBuilder->in($table . '.uid', $listArr);
}
$pid_uid_flag++;
}
// Static_* tables are allowed to be fetched from root page
if (str_starts_with($table, 'static_')) {
$pid_uid_flag++;
}
if (trim($conf['pidInList'])) {
$listArr = GeneralUtility::intExplode(',', str_replace('this', (string)$contentPid, $conf['pidInList']));
// Removes all pages which are not visible for the user!
$listArr = $this->cObj->checkPidArray($listArr);
if (GeneralUtility::inList($conf['pidInList'], 'root')) {
$listArr[] = 0;
}
if (GeneralUtility::inList($conf['pidInList'], '-1')) {
$listArr[] = -1;
$enableFieldsIgnore['pid'] = true;
}
if (!empty($listArr)) {
$constraints[] = $expressionBuilder->in($table . '.pid', array_map('intval', $listArr));
$pid_uid_flag++;
} else {
// If not uid and not pid then uid is set to 0 - which results in nothing!!
$pid_uid_flag = 0;
}
}
// If not uid and not pid then uid is set to 0 - which results in nothing!!
if (!$pid_uid_flag && trim($conf['pidInList'] ?? '') !== 'ignore') {
$constraints[] = $expressionBuilder->eq($table . '.uid', 0);
}
$where = trim((string)$this->cObj->stdWrapValue('where', $conf));
if ($where) {
$constraints[] = QueryHelper::stripLogicalOperatorPrefix($where);
}
// Check if the default language should be fetched (= doing overlays), or if only the records of a language should be fetched
// but only do this for TCA tables that have languages enabled
$languageConstraint = $this->getLanguageRestriction($expressionBuilder, $table, $conf, GeneralUtility::makeInstance(Context::class));
if ($languageConstraint !== null) {
$constraints[] = $languageConstraint;
}
// default constraints from TCA
$pageRepository = $this->getPageRepository();
$constraints = array_merge(
$constraints,
array_values($pageRepository->getDefaultConstraints($table, $enableFieldsIgnore))
);
// MAKE WHERE:
if ($constraints !== []) {
$queryParts['where'] = $expressionBuilder->and(...$constraints);
}
// GROUP BY
$groupBy = trim((string)$this->cObj->stdWrapValue('groupBy', $conf));
if ($groupBy) {
$queryParts['groupBy'] = QueryHelper::parseGroupBy($groupBy);
}
// ORDER BY
$orderByString = trim((string)$this->cObj->stdWrapValue('orderBy', $conf));
if ($orderByString) {
$queryParts['orderBy'] = QueryHelper::parseOrderBy($orderByString);
}
// Return result:
return $queryParts;
}
/**
* Adds parts to the WHERE clause that are related to language.
* This only works on TCA tables which have the [ctrl][languageField] field set or if they
* have select.languageField = my_language_field set explicitly.
*
* It is also possible to disable the language restriction for a query by using select.languageField = 0,
* if select.languageField is not explicitly set, the TCA default values are taken.
*
* If the table is "localizeable" (= any of the criteria above is met), then the DB query is restricted:
*
* If the current language aspect has overlays enabled, then the only records with language "0" or "-1" are
* fetched (the overlays are taken care of later-on).
* if the current language has overlays but also records without localization-parent (free mode) available,
* then these are fetched as well. This can explicitly set via select.includeRecordsWithoutDefaultTranslation = 1
* which overrules the overlayType within the language aspect.
*
* If the language aspect has NO overlays enabled, it behaves as in "free mode" (= only fetch the records
* for the current language.
*
* @throws AspectNotFoundException
*/
protected function getLanguageRestriction(
ExpressionBuilder $expressionBuilder,
string $table,
array $conf,
Context $context
): string|CompositeExpression|null {
$languageField = '';
$localizationParentField = $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'] ?? null;
// Check if the table is translatable, and set the language field by default from the TCA information
if (!empty($conf['languageField']) || !isset($conf['languageField'])) {
if (isset($conf['languageField']) && !empty($GLOBALS['TCA'][$table]['columns'][$conf['languageField']])) {
$languageField = $conf['languageField'];
} elseif (!empty($GLOBALS['TCA'][$table]['ctrl']['languageField']) && !empty($localizationParentField)) {
$languageField = $table . '.' . $GLOBALS['TCA'][$table]['ctrl']['languageField'];
}
}
// No language restriction enabled explicitly or available via TCA
if (empty($languageField)) {
return null;
}
/** @var LanguageAspect $languageAspect */
$languageAspect = $context->getAspect('language');
if ($languageAspect->doOverlays() && !empty($localizationParentField)) {
// Sys language content is set to zero/-1 - and it is expected that whatever routine processes the output will
// OVERLAY the records with localized versions!
$languageQuery = $expressionBuilder->in($languageField, [0, -1]);
// Use this option to include records that don't have a default language counterpart ("free mode")
// (originalpointerfield is 0 and the language field contains the requested language)
if (isset($conf['includeRecordsWithoutDefaultTranslation']) || !empty($conf['includeRecordsWithoutDefaultTranslation.'])) {
$includeRecordsWithoutDefaultTranslation = isset($conf['includeRecordsWithoutDefaultTranslation.'])
? $this->cObj->stdWrap($conf['includeRecordsWithoutDefaultTranslation'], $conf['includeRecordsWithoutDefaultTranslation.'])
: $conf['includeRecordsWithoutDefaultTranslation'];
$includeRecordsWithoutDefaultTranslation = trim((string)$includeRecordsWithoutDefaultTranslation);
$includeRecordsWithoutDefaultTranslation = $includeRecordsWithoutDefaultTranslation !== '' && $includeRecordsWithoutDefaultTranslation !== '0';
} else {
// Option was not explicitly set, check what's in for the language overlay type.
$includeRecordsWithoutDefaultTranslation = $languageAspect->getOverlayType() === $languageAspect::OVERLAYS_ON_WITH_FLOATING;
}
if ($includeRecordsWithoutDefaultTranslation) {
$languageQuery = $expressionBuilder->or(
$languageQuery,
$expressionBuilder->and(
$expressionBuilder->eq($table . '.' . $localizationParentField, 0),
$expressionBuilder->eq($languageField, $languageAspect->getContentId())
)
);
}
return $languageQuery;
}
// No overlays = only fetch records given for the requested language and "all languages"
return (string)$expressionBuilder->in($languageField, [$languageAspect->getContentId(), -1]);
}
/**
* Helper function for getQuery, sanitizing the select part
*
* This functions checks if the necessary fields are part of the select
* and adds them if necessary.
*
* @return string Sanitized select part
* @internal
* @see getQuery
*/
protected function sanitizeSelectPart(Connection $connection, string $selectPart, string $table): string
{
// Pattern matching parts
$matchStart = '/(^\\s*|,\\s*|' . $table . '\\.)';
$matchEnd = '(\\s*,|\\s*$)/';
$necessaryFields = ['uid', 'pid'];
$wsFields = ['t3ver_state'];
$languageField = $GLOBALS['TCA'][$table]['ctrl']['languageField'] ?? false;
if (isset($GLOBALS['TCA'][$table]) && !preg_match($matchStart . '\\*' . $matchEnd, $selectPart) && !preg_match('/(count|max|min|avg|sum)\\([^\\)]+\\)|distinct/i', $selectPart)) {
foreach ($necessaryFields as $field) {
$match = $matchStart . $field . $matchEnd;
if (!preg_match($match, $selectPart)) {
$selectPart .= ', ' . $connection->quoteIdentifier($table . '.' . $field) . ' AS ' . $connection->quoteIdentifier($field);
}
}
if (is_string($languageField)) {
$match = $matchStart . $languageField . $matchEnd;
if (!preg_match($match, $selectPart)) {
$selectPart .= ', ' . $connection->quoteIdentifier($table . '.' . $languageField) . ' AS ' . $connection->quoteIdentifier($languageField);
}
}
if ($GLOBALS['TCA'][$table]['ctrl']['versioningWS'] ?? false) {
foreach ($wsFields as $field) {
$match = $matchStart . $field . $matchEnd;
if (!preg_match($match, $selectPart)) {
$selectPart .= ', ' . $connection->quoteIdentifier($table . '.' . $field) . ' AS ' . $connection->quoteIdentifier($field);
}
}
}
}
return $selectPart;
}
protected function getPageRepository(): PageRepository
{
return GeneralUtility::makeInstance(PageRepository::class);
}
protected function getTimeTracker(): TimeTracker
{
return GeneralUtility::makeInstance(TimeTracker::class);
}
public function getRequest(): ServerRequestInterface
{
if ($this->request instanceof ServerRequestInterface) {
return $this->request;
}
throw new ContentRenderingException(
'PSR-7 request is missing in ContentObjectRenderer. Inject with start(), setRequest() or provide via $GLOBALS[\'TYPO3_REQUEST\'].',
1607172972
);
}
}