iconFactory = $iconFactory; } public function injectFileRepository(FileRepository $fileRepository): void { $this->fileRepository = $fileRepository; } public function injectFlashMessageService(FlashMessageService $flashMessageService): void { $this->flashMessageService = $flashMessageService; } public function injectConnectionPool(ConnectionPool $connectionPool): void { $this->connectionPool = $connectionPool; } public function injectItemProcessingService(ItemProcessingService $itemProcessingService): void { $this->itemProcessingService = $itemProcessingService; } public function injectEnvPlaceholderProcessor(EnvPlaceholderProcessor $envPlaceholderProcessor): void { $this->envPlaceholderProcessor = $envPlaceholderProcessor; } /** * Resolve "itemsProcFunc" of elements. * * @param array $result Main result array * @param string $fieldName Field name to handle item list for * @param array $items Existing items array * @return array New list of item elements */ protected function resolveItemsProcessorFunction(array $result, $fieldName, array $items) { $table = $result['tableName']; $config = $result['processedTca']['columns'][$fieldName]['config']; // Pass along only itemsProcFunc or itemsProcessors TSconfig $pageTsProcessorParameters = []; if (!empty($result['pageTsConfig']['TCEFORM.'][$table . '.'][$fieldName . '.']['itemsProcFunc.'])) { $pageTsProcessorParameters['itemsProcFunc.'] = $result['pageTsConfig']['TCEFORM.'][$table . '.'][$fieldName . '.']['itemsProcFunc.']; } if (!empty($result['pageTsConfig']['TCEFORM.'][$table . '.'][$fieldName . '.']['itemsProcessors.'])) { $pageTsProcessorParameters['itemsProcessors.'] = $result['pageTsConfig']['TCEFORM.'][$table . '.'][$fieldName . '.']['itemsProcessors.']; } $additionalParameters = [ // IMPORTANT: Below fields are only available in FormEngine context. // They are not used by the DataHandler when processing itemsProcFunc // for checking if a submitted value is valid. This means, in case // an item is added based on one of these fields, it won't be persisted // by the DataHandler. This currently(!) only concerns columns of type "check" // and type "radio", see checkValueForCheck() and checkValueForRadio(). // Therefore, no limitations when using those fields with other types // like "select", but this may change in the future. 'inlineParentUid' => $result['inlineParentUid'], 'inlineParentTableName' => $result['inlineParentTableName'], 'inlineParentFieldName' => $result['inlineParentFieldName'], 'inlineParentConfig' => $result['inlineParentConfig'], 'inlineTopMostParentUid' => $result['inlineTopMostParentUid'], 'inlineTopMostParentTableName' => $result['inlineTopMostParentTableName'], 'inlineTopMostParentFieldName' => $result['inlineTopMostParentFieldName'], ]; if (!empty($result['flexParentDatabaseRow'])) { $additionalParameters['flexParentDatabaseRow'] = $result['flexParentDatabaseRow']; } try { $itemsCollection = SelectItemCollection::createFromArray($items, $config['type']); $context = new ItemsProcessorContext( table: $result['tableName'], field: $fieldName, row: $result['databaseRow'], fieldConfiguration: $config, processorParameters: [], realPid: $result['effectivePid'], site: $result['site'] ?? $this->itemProcessingService->resolveSite($result['effectivePid']), fieldTSconfig: $pageTsProcessorParameters, additionalParameters: $additionalParameters ); $items = $this->itemProcessingService->processItems($itemsCollection, $context)->toArray(); } catch (\Exception $exception) { // The itemsProcFunc method may throw an exception, create a flash message if so $languageService = $this->getLanguageService(); $fieldLabel = $fieldName; if (!empty($result['processedTca']['columns'][$fieldName]['label'])) { $fieldLabel = $languageService->sL($result['processedTca']['columns'][$fieldName]['label']); } $message = sprintf( $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:error.items_proc_func_error'), $fieldLabel, $exception->getMessage() ); $flashMessage = new FlashMessage( $message, '', ContextualFeedbackSeverity::ERROR, true ); $defaultFlashMessageQueue = $this->flashMessageService->getMessageQueueByIdentifier(); $defaultFlashMessageQueue->enqueue($flashMessage); } return $items; } /** * Page TSconfig addItems: * * TCEFORMS.aTable.aField[.types][.aType].addItems.aValue = aLabel, * with type specific options merged by pageTsConfig already * * Used by TcaSelectItems and TcaSelectTreeItems data providers * * @param array $result result array * @param string $fieldName Current handle field name * @param array $items Incoming items * @return array Modified item array */ protected function addItemsFromPageTsConfig(array $result, $fieldName, array $items) { $table = $result['tableName']; if (!empty($result['pageTsConfig']['TCEFORM.'][$table . '.'][$fieldName . '.']['addItems.']) && is_array($result['pageTsConfig']['TCEFORM.'][$table . '.'][$fieldName . '.']['addItems.']) ) { $addItemsArray = $result['pageTsConfig']['TCEFORM.'][$table . '.'][$fieldName . '.']['addItems.']; foreach ($addItemsArray as $value => $label) { // If the value ends with a dot, it is a subelement like "34.icon = mylabel.png", skip it if (str_ends_with($value, '.')) { continue; } // Check if value "34 = mylabel" also has a "34.icon = my-icon-identifier" // or "34.group = my-group-identifier" $iconIdentifier = null; $group = null; if (is_array($addItemsArray[$value . '.'] ?? null)) { if (!empty($addItemsArray[$value . '.']['icon'])) { $iconIdentifier = $addItemsArray[$value . '.']['icon']; } if (!empty($addItemsArray[$value . '.']['group'])) { $group = $addItemsArray[$value . '.']['group']; } } $items[] = [ 'label' => $label, 'value' => $value, 'icon' => $iconIdentifier, 'group' => $group, ]; } } return $items; } /** * TCA config "fileFolder" evaluation. Add them to $items * * Used by TcaSelectItems and TcaSelectTreeItems data providers * * @param array $result Result array * @param string $fieldName Current handle field name * @param array $items Incoming items * @return array Modified item array * @throws \RuntimeException */ protected function addItemsFromFolder(array $result, $fieldName, array $items) { if (empty($result['processedTca']['columns'][$fieldName]['config']['fileFolderConfig']['folder']) || !is_string($result['processedTca']['columns'][$fieldName]['config']['fileFolderConfig']['folder']) ) { return $items; } $tableName = $result['tableName']; $fileFolderConfig = $result['processedTca']['columns'][$fieldName]['config']['fileFolderConfig']; $fileFolderTSconfig = $result['pageTsConfig']['TCEFORM.'][$tableName . '.'][$fieldName . '.']['config.']['fileFolderConfig.'] ?? []; if (is_array($fileFolderTSconfig) && $fileFolderTSconfig !== []) { if ($fileFolderTSconfig['folder'] ?? false) { $fileFolderConfig['folder'] = $fileFolderTSconfig['folder']; } if (isset($fileFolderTSconfig['allowedExtensions'])) { $fileFolderConfig['allowedExtensions'] = $fileFolderTSconfig['allowedExtensions']; } if (isset($fileFolderTSconfig['depth'])) { $fileFolderConfig['depth'] = (int)$fileFolderTSconfig['depth']; } } $folderResource = rtrim($fileFolderConfig['folder'], '/') . '/'; // @todo this needs to be replaced with usage of the yet to be created // System Resource API that can handle folders // This will then remove the need to use internal SystemResourceIdentifierFactory here $packageManager = GeneralUtility::makeInstance(PackageManager::class); $identifierFactory = new SystemResourceIdentifierFactory($packageManager); try { $resourceIdentifier = $identifierFactory->create($folderResource); if (!$resourceIdentifier instanceof PackageResourceIdentifier) { throw new InvalidSystemResourceIdentifierException('Only package identifiers are allowed', 1764503770); } $folder = $resourceIdentifier->getPackage()->getPackagePath() . $resourceIdentifier->getRelativePath(); } catch (InvalidSystemResourceIdentifierException $e) { throw new \RuntimeException( 'Invalid folder given for item processing: ' . $folderResource . ' for table ' . $tableName . ', field ' . $fieldName, 1479399227, $e, ); } if (@is_dir($folder)) { $allowedExtensions = ''; if (!empty($fileFolderConfig['allowedExtensions']) && is_string($fileFolderConfig['allowedExtensions'])) { $allowedExtensions = $fileFolderConfig['allowedExtensions']; } $depth = isset($fileFolderConfig['depth']) ? MathUtility::forceIntegerInRange($fileFolderConfig['depth'], 0, 99) : 99; $fileArray = GeneralUtility::getAllFilesAndFoldersInPath([], $folder, $allowedExtensions, false, $depth); $fileArray = GeneralUtility::removePrefixPathFromList($fileArray, $folder); foreach ($fileArray as $fileReference) { $fileInformation = pathinfo($fileReference); $icon = GeneralUtility::inList($GLOBALS['TYPO3_CONF_VARS']['GFX']['imagefile_ext'], strtolower($fileInformation['extension'])) ? $folderResource . $fileReference : ''; $items[] = [ 'label' => $fileReference, 'value' => $fileReference, 'icon' => $icon, ]; } } return $items; } /** * TCA config "foreign_table" evaluation. Add them to $items * * Used by TcaSelectItems and TcaSelectTreeItems data providers * * @param array $result Result array * @param string $fieldName Current handled field name * @param array $items Incoming items * @return array Modified item array * @throws \UnexpectedValueException */ protected function addItemsFromForeignTable(array $result, string $fieldName, array $items = []): array { if (empty($result['processedTca']['columns'][$fieldName]['config']['foreign_table']) || !is_string($result['processedTca']['columns'][$fieldName]['config']['foreign_table']) ) { return $items; } $languageService = $this->getLanguageService(); $foreignTable = $result['processedTca']['columns'][$fieldName]['config']['foreign_table']; if (!$result['tcaSchemata']->has($foreignTable)) { throw new \UnexpectedValueException( 'Field ' . $fieldName . ' of table ' . $result['tableName'] . ' reference to foreign table ' . $foreignTable . ', but this table is not defined in TCA', 1439569743 ); } $queryBuilder = $this->buildForeignTableQueryBuilder($result, $fieldName); try { $queryResult = $queryBuilder->executeQuery(); } catch (DBALException $e) { // Early return on error with flash message $msg = $e->getMessage() . '. ' . $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:error.database_schema_mismatch'); $msgTitle = $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:error.database_schema_mismatch_title'); $flashMessage = new FlashMessage($msg, $msgTitle, ContextualFeedbackSeverity::ERROR, true); $defaultFlashMessageQueue = $this->flashMessageService->getMessageQueueByIdentifier(); $defaultFlashMessageQueue->enqueue($flashMessage); return $items; } $labelPrefix = ''; if (!empty($result['processedTca']['columns'][$fieldName]['config']['foreign_table_prefix'])) { $labelPrefix = $result['processedTca']['columns'][$fieldName]['config']['foreign_table_prefix']; $labelPrefix = $languageService->sL($labelPrefix); } $allForeignRows = $queryResult->fetchAllAssociative(); // Find all possible versioned records of the current IDs, so we do not need to overlay each record // This way, workspaceOL() does not need to be called for each record. $workspaceId = $this->getBackendUser()->workspace; $doOverlaysForRecords = BackendUtility::getPossibleWorkspaceVersionIdsOfLiveRecordIds($foreignTable, array_column($allForeignRows, 'uid'), $workspaceId); $itemGroupField = $result['processedTca']['columns'][$fieldName]['config']['foreign_table_item_group'] ?? ''; foreach ($allForeignRows as $foreignRow) { // Only do workspace overlays when a versioned record exists. if (isset($foreignRow['uid']) && isset($doOverlaysForRecords[(int)$foreignRow['uid']])) { BackendUtility::workspaceOL($foreignTable, $foreignRow, $workspaceId); } // Only proceed in case the row was not unset and we don't deal with a delete placeholder if (is_array($foreignRow) && VersionState::tryFrom($foreignRow['t3ver_state'] ?? 0) !== VersionState::DELETE_PLACEHOLDER ) { // If the foreign table sets selicon_field, this field can contain an image // that represents this specific row. $iconFieldName = ''; $isFileReference = false; $foreignTableSchema = $result['tcaSchemata']->get($foreignTable); if (!empty($foreignTableSchema->getRawConfiguration()['selicon_field'] ?? null)) { $iconFieldName = $foreignTableSchema->getRawConfiguration()['selicon_field']; if ($foreignTableSchema->getField($iconFieldName)->getType() === 'file') { $isFileReference = true; } } $icon = ''; if ($isFileReference) { $references = $this->fileRepository->findByRelation($foreignTable, $iconFieldName, $foreignRow['uid']); if (!empty($references)) { $icon = reset($references); $icon = $icon->getPublicUrl(); } } else { // Else, determine icon based on record type, or a generic fallback $icon = $this->iconFactory->mapRecordTypeToIconIdentifier($foreignTable, $foreignRow, $result['tcaSchemata']->get($foreignTable)); } $item = [ 'label' => $labelPrefix . BackendUtility::getRecordTitle($foreignTable, $foreignRow), 'value' => $foreignRow['uid'], 'icon' => $icon, 'group' => $foreignRow[$itemGroupField] ?? null, // This line is part of the category tree performance hack, which should be used everywhere '_row' => $foreignRow, ]; $items[] = $item; } } return $items; } /** * Remove items using "keepItems" pageTsConfig * * Used by TcaSelectItems and TcaSelectTreeItems data providers * * @param array $result Result array * @param string $fieldName Current handle field name * @param array $items Incoming items * @return array Modified item array */ protected function removeItemsByKeepItemsPageTsConfig(array $result, $fieldName, array $items) { $table = $result['tableName']; if (!isset($result['pageTsConfig']['TCEFORM.'][$table . '.'][$fieldName . '.']['keepItems']) || !is_string($result['pageTsConfig']['TCEFORM.'][$table . '.'][$fieldName . '.']['keepItems']) ) { return $items; } // If keepItems is set but is an empty list all current items get removed if ($result['pageTsConfig']['TCEFORM.'][$table . '.'][$fieldName . '.']['keepItems'] === '') { return []; } return ArrayUtility::keepItemsInArray( $items, $result['pageTsConfig']['TCEFORM.'][$table . '.'][$fieldName . '.']['keepItems'], static function ($value) { return $value['value']; } ); } /** * Remove items using "removeItems" pageTsConfig * * Used by TcaSelectItems and TcaSelectTreeItems data providers * * @param array $result Result array * @param string $fieldName Current handle field name * @param array $items Incoming items * @return array Modified item array */ protected function removeItemsByRemoveItemsPageTsConfig(array $result, $fieldName, array $items) { $table = $result['tableName']; if (!isset($result['pageTsConfig']['TCEFORM.'][$table . '.'][$fieldName . '.']['removeItems']) || !is_string($result['pageTsConfig']['TCEFORM.'][$table . '.'][$fieldName . '.']['removeItems']) || $result['pageTsConfig']['TCEFORM.'][$table . '.'][$fieldName . '.']['removeItems'] === '' ) { return $items; } $removeItems = array_flip(GeneralUtility::trimExplode( ',', $result['pageTsConfig']['TCEFORM.'][$table . '.'][$fieldName . '.']['removeItems'], true )); foreach ($items as $key => $itemValues) { if (isset($removeItems[$itemValues['value']])) { unset($items[$key]); } } return $items; } /** * Remove items user restriction on language field * * Used by TcaSelectItems and TcaSelectTreeItems data providers * * @param array $result Result array * @param string $fieldName Current handle field name * @param array $items Incoming items * @return array Modified item array */ protected function removeItemsByUserLanguageFieldRestriction(array $result, $fieldName, array $items) { // Guard clause returns if not a language field is handled if (empty($result['processedTca']['ctrl']['languageField']) || $result['processedTca']['ctrl']['languageField'] !== $fieldName ) { return $items; } $backendUser = $this->getBackendUser(); foreach ($items as $key => $itemValues) { if (!$backendUser->checkLanguageAccess($itemValues['value'])) { unset($items[$key]); } } return $items; } /** * Remove items by user restriction on authMode items * * Used by TcaSelectItems and TcaSelectTreeItems data providers * * @param array $result Result array * @param string $fieldName Current handle field name * @param array $items Incoming items * @return array Modified item array */ protected function removeItemsByUserAuthMode(array $result, $fieldName, array $items) { // Guard clause returns early if no authMode field is configured if (!isset($result['processedTca']['columns'][$fieldName]['config']['authMode']) || !is_string($result['processedTca']['columns'][$fieldName]['config']['authMode']) ) { return $items; } $backendUser = $this->getBackendUser(); foreach ($items as $key => $itemValues) { if (!$backendUser->checkAuthMode($result['tableName'], $fieldName, $itemValues['value'])) { unset($items[$key]); } } return $items; } /** * Remove items if doktype is handled for non admin users * * Used by TcaSelectItems and TcaSelectTreeItems data providers * * @param array $result Result array * @param string $fieldName Current handle field name * @param array $items Incoming items * @return array Modified item array */ protected function removeItemsByDoktypeUserRestriction(array $result, $fieldName, array $items) { $table = $result['tableName']; $backendUser = $this->getBackendUser(); // Guard clause returns if not correct table and field or if user is admin if ($table !== 'pages' || $fieldName !== 'doktype' || $backendUser->isAdmin() ) { return $items; } $allowedPageTypes = $backendUser->groupData['pagetypes_select']; foreach ($items as $key => $itemValues) { if (!GeneralUtility::inList($allowedPageTypes, $itemValues['value'])) { unset($items[$key]); } } return $items; } /** * Remove items if sys_file_storage is not allowed for non-admin users. * * Used by TcaSelectItems data providers * * @param array $result Result array * @param string $fieldName Current handle field name * @param array $items Incoming items * @return array Modified item array */ protected function removeItemsByUserStorageRestriction(array $result, $fieldName, array $items) { $referencedTableName = $result['processedTca']['columns'][$fieldName]['config']['foreign_table'] ?? null; if ($referencedTableName !== 'sys_file_storage') { return $items; } $allowedStorageIds = array_map( static function (ResourceStorage $storage): int { return $storage->getUid(); }, $this->getBackendUser()->getFileStorages() ); return array_filter( $items, static function (array $item) use ($allowedStorageIds): bool { $itemValue = $item['value'] ?? null; return empty($itemValue) || in_array((int)$itemValue, $allowedStorageIds, true); } ); } /** * Build wrapped QueryBuilder to fetch full foreign records. Helper method of * {@see self::addItemsFromForeignTable()}, do not call otherwise. * * @param array $result Result array * @param string $localFieldName Current handle field name */ protected function buildForeignTableQueryBuilder(array $result, string $localFieldName): QueryBuilder { $backendUser = $this->getBackendUser(); $foreignTableName = $result['processedTca']['columns'][$localFieldName]['config']['foreign_table']; $foreignTableClauseArray = $this->processForeignTableClause($result, $foreignTableName, $localFieldName); $connection = $this->connectionPool->getConnectionForTable($foreignTableName); $wrapQueryBuilder = $connection->createQueryBuilder(); $queryBuilder = $connection->createQueryBuilder(); // Full foreign table row is wanted for the result, which requires to have `GROUP BY` columns listed // within the `SELECT ` list for some database systems and vice versa. Second requirement is, // that all fields used for `GROUP BY` and listed as select fields needs to be aggregated with a proper // function, for example (MIN(), MAX(), ANY_VALUES(), ...). // // MariaDB is even stricter than MySQL with default and recommend `sql_mode = 'ONLY_FULL_GROUP_BY', // which is also a long time questioned fact why MariaDB differs here from MySQL and also PostgresSQL // without an explicit mode setting. // // To sum up all requirements for all database systems and expectable modes, we ... // // * can't simply select all foreign table fields, for example with `$foreignTableName . '.*'` as select() // for the QueryBuilder below. // * need to ensure that we have grouped fields aggregated. // * need to use a wrapped SQL query (QueryBuilder) to retrieve full rows of foreign table because we cannot // use full-table columns information to replace a `*` wildcard here. // // The first step respecting all requirements is, to determine commonly used select fields for the table // and using `ANY_VALUES()` aggregation for the `uid` field. $hasGroupBy = is_array($foreignTableClauseArray['GROUPBY']) && $foreignTableClauseArray['GROUPBY'] !== []; $selectFieldList = []; $schema = $result['tcaSchemata']->get($foreignTableName); $commonFieldList = $this->getCommonSelectFields($foreignTableName, $schema); foreach ($commonFieldList as $fieldName) { if ($hasGroupBy && in_array($fieldName, $foreignTableClauseArray['GROUPBY'], true)) { $selectFieldList[] = sprintf('ANY_VALUE(%s)', $queryBuilder->quoteIdentifier($fieldName)); continue; } $selectFieldList[] = $queryBuilder->quoteIdentifier($fieldName); } $wrapQueryBuilder->getRestrictions()->removeAll(); $queryBuilder->getRestrictions() ->removeAll() ->add(GeneralUtility::makeInstance(DeletedRestriction::class)) ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->getBackendUser()->workspace)); $queryBuilder ->selectLiteral(...$selectFieldList) ->from($foreignTableName) ->where($foreignTableClauseArray['WHERE']); if ($hasGroupBy) { $queryBuilder->groupBy(...$foreignTableClauseArray['GROUPBY']); } // Sorting is applied to the inner query to ensure proper sorting and in combination with LIMIT to retrieve // the expected/correct dataset and not cutting away data. Sorting is applied a second time below on the // outer joined table (wrapping QueryBuilder) to keep the sorting between inner and outer query for vendors // applying deterministic sorting criterias and returning other results than other vendors to ensure the same // behavior across all supported database vendors. if (!empty($foreignTableClauseArray['ORDERBY'])) { foreach ($foreignTableClauseArray['ORDERBY'] as $orderPair) { [$fieldName, $order] = $orderPair; $queryBuilder->addOrderBy($fieldName, $order); } } elseif ($schema->hasCapability(TcaSchemaCapability::DefaultSorting)) { $orderByClauses = QueryHelper::parseOrderBy($schema->getCapability(TcaSchemaCapability::DefaultSorting)->getValue()); foreach ($orderByClauses as $orderByClause) { if (!empty($orderByClause[0])) { $queryBuilder->addOrderBy($foreignTableName . '.' . $orderByClause[0], $orderByClause[1]); } } } if (!empty($foreignTableClauseArray['LIMIT'])) { if (!empty($foreignTableClauseArray['LIMIT'][1])) { $queryBuilder->setMaxResults($foreignTableClauseArray['LIMIT'][1]); $queryBuilder->setFirstResult($foreignTableClauseArray['LIMIT'][0]); } elseif (!empty($foreignTableClauseArray['LIMIT'][0])) { $queryBuilder->setMaxResults($foreignTableClauseArray['LIMIT'][0]); } } // rootLevel = -1 means that elements can be on the rootlevel OR on any page (pid!=-1) // rootLevel = 0 means that elements are not allowed on root level // rootLevel = 1 means that elements are only on the root level (pid=0) /** @var RootLevelCapability $rootLevelCapability */ $rootLevelCapability = $schema->getCapability(TcaSchemaCapability::RestrictionRootLevel); if ($rootLevelCapability->getRootLevelType() === -1) { $queryBuilder->andWhere( $queryBuilder->expr()->neq( $foreignTableName . '.pid', $wrapQueryBuilder->createNamedParameter(-1, Connection::PARAM_INT) ) ); } elseif ($rootLevelCapability->getRootLevelType() === 1) { $queryBuilder->andWhere( $queryBuilder->expr()->eq( $foreignTableName . '.pid', $wrapQueryBuilder->createNamedParameter(0, Connection::PARAM_INT) ) ); } else { $queryBuilder->andWhere($backendUser->getPagePermsClause(Permission::PAGE_SHOW)); if ($foreignTableName !== 'pages') { $queryBuilder ->from('pages') ->andWhere( $queryBuilder->expr()->eq( 'pages.uid', $queryBuilder->quoteIdentifier($foreignTableName . '.pid') ) ); } } // @todo what about PID restriction? if ($this->getBackendUser()->workspace !== 0 && $schema->hasCapability(TcaSchemaCapability::Workspace)) { $queryBuilder ->andWhere( $queryBuilder->expr()->neq( $foreignTableName . '.t3ver_state', $wrapQueryBuilder->createNamedParameter(VersionState::MOVE_POINTER->value, Connection::PARAM_INT) ) ); } // Second step to respect all database requirements regarding `GROUP BY` and still returning full foreign table // records is using the QueryBuilder (query) as a sub-query, join the table and retrieve the full records using // column wildcard. That ensures that really the full records are retrieved including not TCA managed columns. // @todo Look in this again and consider using a non-recursive CTE here instead of a sub-query table when // upstream doctrine/dbal CTE support is completely available and working. $wrapQueryBuilder->select('joined_table.*'); $wrapQueryBuilder->getConcreteQueryBuilder()->from( '(' . $queryBuilder->getSQL() . ')', $wrapQueryBuilder->quoteIdentifier('inner_table_alias') ); $wrapQueryBuilder->innerJoin( 'inner_table_alias', $foreignTableName, 'joined_table', $wrapQueryBuilder->expr()->and( $wrapQueryBuilder->expr()->eq('joined_table.uid', $wrapQueryBuilder->quoteIdentifier('inner_table_alias.uid')) ) ); // Adding orderings to the re-joined table result is required to ensure that a non-joined table PK sorting // leads to the same result. For example MariaDB/MySQL applies a deterministic joined table PK sorting and // other platforms like PostgresSQL does this not. That leads to a re-sorting on the outer result based on // the automatic sorting override for MySQL/MariaDB. INNER sorting above is still required to have proper // result in case limiting the resultset (LIMIT) is applied to the foreign table and would otherwise lead // to retrieving unexpected datasets. $orderByClauses = []; if (!empty($foreignTableClauseArray['ORDERBY'])) { $orderByClauses = $foreignTableClauseArray['ORDERBY']; } elseif ($schema->hasCapability(TcaSchemaCapability::DefaultSorting)) { $orderByClauses = QueryHelper::parseOrderBy($schema->getCapability(TcaSchemaCapability::DefaultSorting)->getValue()); } if ($orderByClauses !== []) { foreach ($orderByClauses as $orderPair) { [$fieldName, $order] = $orderPair; // Remove table name if (str_contains($fieldName, '.')) { $fieldName = substr($fieldName, strpos($fieldName, '.') + 1); } $fieldName = 'joined_table.' . $fieldName; $wrapQueryBuilder->addOrderBy($fieldName, $order); } } return $wrapQueryBuilder; } /** * Replace markers in a where clause from TCA foreign_table_where * * ###REC_FIELD_[field name]### * ###THIS_UID### - is current element uid (zero if new). * ###CURRENT_PID### - is the current page id (pid of the record). * ###SITEROOT### * ###PAGE_TSCONFIG_ID### - a value you can set from page TSconfig dynamically. * ###PAGE_TSCONFIG_IDLIST### - a value you can set from page TSconfig dynamically. * ###PAGE_TSCONFIG_STR### - a value you can set from page TSconfig dynamically. * * @param array $result Result array * @param string $foreignTableName Name of foreign table * @param string $localFieldName Current handle field name * @return array Query parts with keys WHERE, ORDERBY, GROUPBY, LIMIT */ protected function processForeignTableClause(array $result, $foreignTableName, $localFieldName) { $connection = $this->connectionPool->getConnectionForTable($foreignTableName); $localTable = $result['tableName']; $effectivePid = $result['effectivePid']; $foreignTableClause = ''; if (!empty($result['processedTca']['columns'][$localFieldName]['config']['foreign_table_where']) && is_string($result['processedTca']['columns'][$localFieldName]['config']['foreign_table_where']) ) { $foreignTableClause = QueryHelper::quoteDatabaseIdentifiers($connection, $result['processedTca']['columns'][$localFieldName]['config']['foreign_table_where']); // Replace possible markers in query if (str_contains($foreignTableClause, '###REC_FIELD_')) { // " AND table.field='###REC_FIELD_field1###' AND ..." -> array(" AND table.field='", "field1###' AND ...") $whereClauseParts = explode('###REC_FIELD_', $foreignTableClause); foreach ($whereClauseParts as $key => $value) { if ($key !== 0) { // "field1###' AND ..." -> array("field1", "' AND ...") $whereClauseSubParts = explode('###', $value, 2); // @todo: Throw exception if there is no value? What happens for NEW records? $databaseRowKey = empty($result['flexParentDatabaseRow']) ? 'databaseRow' : 'flexParentDatabaseRow'; $rowFieldValue = $result[$databaseRowKey][$whereClauseSubParts[0]] ?? ''; if (is_array($rowFieldValue)) { // If a select or group field is used here, it may have been processed already and // is now an array containing uid + table + title + row. // See TcaGroup data provider for details. // Pick the first one (always on 0), and use uid only. $rowFieldValue = $rowFieldValue[0]['uid'] ?? $rowFieldValue[0] ?? ''; } if (str_ends_with($whereClauseParts[0], '\'') && $whereClauseSubParts[1][0] === '\'') { $whereClauseParts[0] = substr($whereClauseParts[0], 0, -1); $whereClauseSubParts[1] = substr($whereClauseSubParts[1], 1); } $whereClauseParts[$key] = $connection->quote($rowFieldValue) . $whereClauseSubParts[1]; } } $foreignTableClause = implode('', $whereClauseParts); } if (str_contains($foreignTableClause, '###CURRENT_PID###')) { // Use pid from parent page clause if in flex form context if (!empty($result['flexParentDatabaseRow']['pid'])) { $effectivePid = $result['flexParentDatabaseRow']['pid']; } elseif (!$effectivePid && !empty($result['databaseRow']['pid'])) { // Use pid from database row if in inline context $effectivePid = $result['databaseRow']['pid']; } } $siteRootUid = 0; foreach ($result['rootline'] as $rootlinePage) { if (!empty($rootlinePage['is_siteroot'])) { $siteRootUid = (int)$rootlinePage['uid']; break; } } $pageTsConfigId = 0; if (isset($result['pageTsConfig']['TCEFORM.'][$localTable . '.'][$localFieldName . '.']['PAGE_TSCONFIG_ID']) && $result['pageTsConfig']['TCEFORM.'][$localTable . '.'][$localFieldName . '.']['PAGE_TSCONFIG_ID'] ) { $pageTsConfigId = (int)$result['pageTsConfig']['TCEFORM.'][$localTable . '.'][$localFieldName . '.']['PAGE_TSCONFIG_ID']; } $pageTsConfigIdList = 0; if (isset($result['pageTsConfig']['TCEFORM.'][$localTable . '.'][$localFieldName . '.']['PAGE_TSCONFIG_IDLIST']) && $result['pageTsConfig']['TCEFORM.'][$localTable . '.'][$localFieldName . '.']['PAGE_TSCONFIG_IDLIST'] ) { $pageTsConfigIdList = $result['pageTsConfig']['TCEFORM.'][$localTable . '.'][$localFieldName . '.']['PAGE_TSCONFIG_IDLIST']; } $pageTsConfigIdListArray = GeneralUtility::trimExplode(',', $pageTsConfigIdList, true); $pageTsConfigIdList = []; foreach ($pageTsConfigIdListArray as $pageTsConfigIdListElement) { if (MathUtility::canBeInterpretedAsInteger($pageTsConfigIdListElement)) { $pageTsConfigIdList[] = (int)$pageTsConfigIdListElement; } } $pageTsConfigIdList = implode(',', $pageTsConfigIdList); $pageTsConfigString = ''; if (isset($result['pageTsConfig']['TCEFORM.'][$localTable . '.'][$localFieldName . '.']['PAGE_TSCONFIG_STR']) && $result['pageTsConfig']['TCEFORM.'][$localTable . '.'][$localFieldName . '.']['PAGE_TSCONFIG_STR'] ) { $pageTsConfigString = $result['pageTsConfig']['TCEFORM.'][$localTable . '.'][$localFieldName . '.']['PAGE_TSCONFIG_STR']; $pageTsConfigString = $connection->quote($pageTsConfigString); } $foreignTableClause = str_replace( [ '###CURRENT_PID###', '###THIS_UID###', '###SITEROOT###', '###PAGE_TSCONFIG_ID###', '###PAGE_TSCONFIG_IDLIST###', '\'###PAGE_TSCONFIG_STR###\'', '###PAGE_TSCONFIG_STR###', ], [ (string)(int)$effectivePid, (string)(int)$result['databaseRow']['uid'], (string)$siteRootUid, (string)$pageTsConfigId, $pageTsConfigIdList, $pageTsConfigString, $pageTsConfigString, ], $foreignTableClause ); $parsedSiteConfiguration = $this->parseSiteConfiguration($result['site'], $foreignTableClause); if ($parsedSiteConfiguration !== []) { $parsedSiteConfiguration = $this->quoteParsedSiteConfiguration($connection, $parsedSiteConfiguration); $foreignTableClause = $this->replaceParsedSiteConfiguration($foreignTableClause, $parsedSiteConfiguration); } } // Split the clause into an array with keys WHERE, GROUPBY, ORDERBY, LIMIT // Prepend a space to make sure "[[:space:]]+" will find a space there for the first element. $foreignTableClause = ' ' . $foreignTableClause; $foreignTableClauseArray = [ 'WHERE' => '', 'GROUPBY' => '', 'ORDERBY' => '', 'LIMIT' => '', ]; // Find LIMIT $reg = []; if (preg_match('/^(.*)[[:space:]]+LIMIT[[:space:]]+([[:alnum:][:space:],._]+)$/is', $foreignTableClause, $reg)) { $foreignTableClauseArray['LIMIT'] = GeneralUtility::intExplode(',', trim($reg[2]), true); $foreignTableClause = $reg[1]; } // Find ORDER BY $reg = []; if (preg_match('/^(.*)[[:space:]]+ORDER[[:space:]]+BY[[:space:]]+([[:alnum:][:space:],._()"]+)$/is', $foreignTableClause, $reg)) { $foreignTableClauseArray['ORDERBY'] = QueryHelper::parseOrderBy(trim($reg[2])); $foreignTableClause = $reg[1]; } // Find GROUP BY $reg = []; if (preg_match('/^(.*)[[:space:]]+GROUP[[:space:]]+BY[[:space:]]+([[:alnum:][:space:],._()"]+)$/is', $foreignTableClause, $reg)) { $foreignTableClauseArray['GROUPBY'] = QueryHelper::parseGroupBy(trim($reg[2])); $foreignTableClause = $reg[1]; } // Rest is assumed to be "WHERE" clause $foreignTableClauseArray['WHERE'] = QueryHelper::stripLogicalOperatorPrefix($foreignTableClause); return $foreignTableClauseArray; } /** * Parse ###SITE:### placeholders in the input string and return the replacements array for later use in * $this->replaceParsedSiteConfiguration(). * * IMPORTANT: If the values are used within raw SQL statements (e.g. foreign_table_where), consider using * $this->quoteParsedSiteConfiguration() *before* replacement. */ protected function parseSiteConfiguration(?SiteInterface $site, string $input): array { // Since we need to access the configuration, early return in case // we don't deal with an instance of Site (e.g. null or NullSite). if (!$site instanceof Site) { return []; } $siteClausesRegEx = '/###SITE:([^#]+)###/m'; preg_match_all($siteClausesRegEx, $input, $matches, PREG_SET_ORDER); if (empty($matches)) { return []; } $replacements = []; $configuration = $site->getConfiguration(); array_walk($matches, static function (array $match) use (&$replacements, &$configuration): void { $key = $match[1]; try { $value = ArrayUtility::getValueByPath($configuration, $key, '.'); } catch (MissingArrayPathException $exception) { $value = ''; } $replacements[$match[0]] = $value; }); return $replacements; } protected function quoteParsedSiteConfiguration(Connection $connection, array $parsedSiteConfiguration): array { foreach ($parsedSiteConfiguration as $key => $value) { if (is_int($value)) { // int values are safe, nothing to do here continue; } if (is_string($value)) { $parsedSiteConfiguration[$key] = $connection->quote($value); continue; } if (is_array($value)) { $parsedSiteConfiguration[$key] = implode(',', $this->quoteParsedSiteConfiguration($connection, $value)); continue; } if (is_bool($value)) { $parsedSiteConfiguration[$key] = (int)$value; continue; } throw new \InvalidArgumentException( sprintf('Cannot quote site configuration setting "%s" of type "%s", only "int", "bool", "string" and "array" are supported', $key, gettype($value)), 1630324435 ); } return $parsedSiteConfiguration; } protected function replaceParsedSiteConfiguration(string $input, array $parsedSiteConfiguration): string { return str_replace( array_keys($parsedSiteConfiguration), array_values($parsedSiteConfiguration), $input ); } /** * A field's [treeConfig][startingPoints] can be set via site config, parse possibly set values */ protected function parseStartingPointsFromSiteConfiguration(array $result, array $fieldConfig): array { if (!isset($fieldConfig['config']['treeConfig']['startingPoints'])) { return $fieldConfig; } $parsedSiteConfiguration = $this->parseSiteConfiguration($result['site'], $fieldConfig['config']['treeConfig']['startingPoints']); if ($parsedSiteConfiguration !== []) { // $this->quoteParsedSiteConfiguration() is omitted on purpose, all values are cast to integers $parsedSiteConfiguration = array_unique(array_map(static function (array|string|int $value): string { if (is_array($value)) { return implode(',', array_map(intval(...), $value)); } return implode(',', GeneralUtility::intExplode(',', (string)$value, true)); }, $parsedSiteConfiguration)); $resolvedStartingPoints = $this->replaceParsedSiteConfiguration($fieldConfig['config']['treeConfig']['startingPoints'], $parsedSiteConfiguration); // Add the resolved starting points while removing empty values $fieldConfig['config']['treeConfig']['startingPoints'] = implode( ',', GeneralUtility::trimExplode(',', $resolvedStartingPoints, true) ); } return $fieldConfig; } /** * Convert the current database values into an array * * @param array $row database row * @param string $fieldName fieldname to process * @return array */ protected function processDatabaseFieldValue(array $row, $fieldName) { $currentDatabaseValues = array_key_exists($fieldName, $row) ? $row[$fieldName] : ''; if ($currentDatabaseValues === null) { return []; } if (!is_array($currentDatabaseValues)) { $currentDatabaseValues = GeneralUtility::trimExplode(',', $currentDatabaseValues, true); } return $currentDatabaseValues; } /** * Validate and sanitize database row values of the select field with the given name. * Creates an array out of databaseRow[selectField] values. * * Used by TcaSelectItems and TcaSelectTreeItems data providers * * @param array $result The current result array. * @param string $fieldName Name of the current select field. * @param array $staticValues Array with statically defined items, item value is used as array key. * @return array */ protected function processSelectFieldValue(array $result, $fieldName, array $staticValues) { $fieldConfig = $result['processedTca']['columns'][$fieldName]; $currentDatabaseValueArray = array_key_exists($fieldName, $result['databaseRow']) ? $result['databaseRow'][$fieldName] : []; $isSiteAction = $result['tableName'] === 'site'; if ($isSiteAction && count($currentDatabaseValueArray) === 1 && $this->envPlaceholderProcessor->canProcess($currentDatabaseValueArray[0])) { return $currentDatabaseValueArray; } $newDatabaseValueArray = []; // Add all values that were defined by static methods and do not come from the relation // e.g. TCA, TSconfig, itemsProcFunc etc. foreach ($currentDatabaseValueArray as $value) { if (isset($staticValues[$value])) { $newDatabaseValueArray[] = $value; } } if (isset($fieldConfig['config']['foreign_table']) && !empty($fieldConfig['config']['foreign_table'])) { $relationHandler = GeneralUtility::makeInstance(RelationHandler::class); $relationHandler->registerNonTableValues = !empty($fieldConfig['config']['allowNonIdValues']); if (!empty($fieldConfig['config']['MM']) && $result['command'] !== 'new') { // MM relation $relationHandler->start( implode(',', $currentDatabaseValueArray), $fieldConfig['config']['foreign_table'], $fieldConfig['config']['MM'], $result['databaseRow']['uid'], $result['tableName'], $fieldConfig['config'] ); $relationHandler->processDeletePlaceholder(); $newDatabaseValueArray = array_merge($newDatabaseValueArray, $relationHandler->getValueArray()); } else { // Non MM relation // If not dealing with MM relations, use default live uid, not versioned uid for record relations $relationHandler->start( implode(',', $currentDatabaseValueArray), $fieldConfig['config']['foreign_table'], '', $this->getLiveUid($result), $result['tableName'], $fieldConfig['config'] ); $relationHandler->processDeletePlaceholder(); $databaseIds = array_merge($newDatabaseValueArray, $relationHandler->getValueArray()); // remove all items from the current DB values if not available as relation or static value anymore $newDatabaseValueArray = array_values(array_intersect($currentDatabaseValueArray, $databaseIds)); } } if ($fieldConfig['config']['multiple'] ?? false) { return $newDatabaseValueArray; } return array_unique($newDatabaseValueArray); } /** * Translate the item labels * * Used by TcaSelectItems and TcaSelectTreeItems data providers * * @param array $result Result array * @param array $itemArray Items * @param string $table * @param string $fieldName * @return array */ public function translateLabels(array $result, array $itemArray, $table, $fieldName) { $languageService = $this->getLanguageService(); foreach ($itemArray as $key => $item) { $labelIndex = $item['value'] ?? ''; if ($labelIndex === '' && isset($result['pageTsConfig']['TCEFORM.'][$table . '.'][$fieldName . '.']['altLabels']) && !empty($result['pageTsConfig']['TCEFORM.'][$table . '.'][$fieldName . '.']['altLabels']) ) { $label = $languageService->sL($result['pageTsConfig']['TCEFORM.'][$table . '.'][$fieldName . '.']['altLabels']); } elseif (isset($result['pageTsConfig']['TCEFORM.'][$table . '.'][$fieldName . '.']['altLabels.'][$labelIndex]) && !empty($result['pageTsConfig']['TCEFORM.'][$table . '.'][$fieldName . '.']['altLabels.'][$labelIndex]) ) { $label = $languageService->sL($result['pageTsConfig']['TCEFORM.'][$table . '.'][$fieldName . '.']['altLabels.'][$labelIndex]); } else { $label = $languageService->sL(trim($item['label'] ?? '')); } $value = strlen((string)($item['value'] ?? '')) > 0 ? $item['value'] : ''; $icon = !empty($item['icon']) ? $item['icon'] : null; $groupId = $item['group'] ?? null; $helpText = null; if (!empty($item['description'])) { if (is_string($item['description'])) { $helpText = $languageService->sL($item['description']); } else { $helpText = $item['description']; } } // @todo This removes `_row` full item row and does not have it in processedTCA later, at lest for // TcaSelectItems. Consider to keep that information here if available or if dropping is good. $itemArray[$key] = [ 'label' => $label, 'value' => $value, 'icon' => $icon, 'group' => $groupId, 'description' => $helpText, ]; } return $itemArray; } /** * Add alternative icon using "altIcons" TSconfig */ public function addIconFromAltIcons(array $result, array $items, string $table, string $fieldName): array { foreach ($items as &$item) { if (isset($result['pageTsConfig']['TCEFORM.'][$table . '.'][$fieldName . '.']['altIcons.'][$item['value']]) && !empty($result['pageTsConfig']['TCEFORM.'][$table . '.'][$fieldName . '.']['altIcons.'][$item['value']]) ) { $item['icon'] = $result['pageTsConfig']['TCEFORM.'][$table . '.'][$fieldName . '.']['altIcons.'][$item['value']]; } } return $items; } /** * Sanitize incoming item array * * Used by TcaSelectItems and TcaSelectTreeItems data providers * * @param mixed $itemArray * @param string $tableName * @param string $fieldName * @throws \UnexpectedValueException * @return array */ public function sanitizeItemArray($itemArray, $tableName, $fieldName) { if (!is_array($itemArray)) { $itemArray = []; } foreach ($itemArray as $item) { if (!is_array($item)) { throw new \UnexpectedValueException( 'An item in field ' . $fieldName . ' of table ' . $tableName . ' is not an array as expected', 1439288036 ); } } return $itemArray; } /** * Gets the record uid of the live default record. If already * pointing to the live record, the submitted record uid is returned. * * @param array $result Result array * @return int|string If the record is new, uid will be a string beginning with "NEW". Otherwise an int. * @throws \UnexpectedValueException */ protected function getLiveUid(array $result) { $table = $result['tableName']; $row = $result['databaseRow']; $uid = $row['uid'] ?? 0; if ($result['tcaSchemata']->has($table) && $result['tcaSchemata']->get($table)->hasCapability(TcaSchemaCapability::Workspace) && (int)($row['t3ver_oid'] ?? 0) > 0) { $uid = $row['t3ver_oid']; } return $uid; } /** * @internal private on purpose */ private function getCommonSelectFields(string $table, TcaSchema $schema): array { $fields = ['uid', 'pid']; if ($schema->hasCapability(TcaSchemaCapability::Label)) { $fields = array_merge($fields, $schema->getCapability(TcaSchemaCapability::Label)->getAllLabelFieldNames()); } if ($schema->isWorkspaceAware()) { $fields[] = 't3ver_state'; $fields[] = 't3ver_wsid'; } if ($schema->getRawConfiguration()['selicon_field'] ?? '') { $fields[] = $schema->getRawConfiguration()['selicon_field']; } if ($schema->getRawConfiguration()['typeicon_column'] ?? '') { $fields[] = $schema->getRawConfiguration()['typeicon_column']; } $capabilities = [ TcaSchemaCapability::SoftDelete, TcaSchemaCapability::RestrictionDisabledField, TcaSchemaCapability::RestrictionStartTime, TcaSchemaCapability::RestrictionEndTime, TcaSchemaCapability::RestrictionUserGroup, ]; foreach ($capabilities as $capability) { if ($schema->hasCapability($capability)) { $fields[] = $schema->getCapability($capability)->getFieldName(); } } $fields = array_unique($fields); return array_map(static fn(string $value): string => $table . '.' . $value, $fields); } protected function getLanguageService(): LanguageService { return $GLOBALS['LANG']; } protected function getBackendUser(): BackendUserAuthentication { return $GLOBALS['BE_USER']; } }