commit 6830982e7d7dff193c53555aa183e1dcff638030 Author: Sven Wappler Date: Mon Aug 10 22:31:15 2026 +0200 TYPO3 v15 dev-main snapshot () diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..57872d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/vendor/ diff --git a/Classes/Attribute/Authorize.php b/Classes/Attribute/Authorize.php new file mode 100644 index 0000000..d8805e4 --- /dev/null +++ b/Classes/Attribute/Authorize.php @@ -0,0 +1,35 @@ +limit < 1) { + throw new \RuntimeException('Invalid "limit" property for rate limit. Ensure, that the value is greater than 0.', 1771074438); + } + if ($this->interval === '') { + throw new \RuntimeException('Invalid "interval" property for rate limit.', 1771074439); + } + if ($this->policy === '') { + throw new \RuntimeException('Invalid "policy" property for rate limit.', 1771074440); + } + } + + public function getConfiguration(string $identifier): array + { + return [ + 'id' => 'extbase-' . $identifier, + 'policy' => $this->policy, + 'limit' => $this->limit, + 'interval' => $this->interval, + ]; + } +} diff --git a/Classes/Attribute/Validate.php b/Classes/Attribute/Validate.php new file mode 100644 index 0000000..111d715 --- /dev/null +++ b/Classes/Attribute/Validate.php @@ -0,0 +1,47 @@ + $options + */ + public function __construct( + public readonly string $validator, + public readonly array $options = [] + ) {} + + public function __toString(): string + { + $strings = []; + $strings[] = $this->validator; + + if (count($this->options) > 0) { + $validatorOptionsStrings = []; + foreach ($this->options as $optionKey => $optionValue) { + $validatorOptionsStrings[] = $optionKey . '=' . $optionValue; + } + + $strings[] = '(' . implode(', ', $validatorOptionsStrings) . ')'; + } + + return trim(implode(' ', $strings)); + } +} diff --git a/Classes/Authorization/AuthorizationFailureReason.php b/Classes/Authorization/AuthorizationFailureReason.php new file mode 100644 index 0000000..9242478 --- /dev/null +++ b/Classes/Authorization/AuthorizationFailureReason.php @@ -0,0 +1,25 @@ +authorized; + } + + public function isDenied(): bool + { + return !$this->authorized; + } +} diff --git a/Classes/Configuration/BackendConfigurationManager.php b/Classes/Configuration/BackendConfigurationManager.php new file mode 100644 index 0000000..449a900 --- /dev/null +++ b/Classes/Configuration/BackendConfigurationManager.php @@ -0,0 +1,331 @@ +typoScriptService->convertTypoScriptArrayToPlainArray($configuration); + + $typoscriptSetup = $this->getTypoScriptSetup($request); + $frameworkConfiguration = []; + if (isset($typoscriptSetup['config.']['tx_extbase.'])) { + $frameworkConfiguration = $this->typoScriptService->convertTypoScriptArrayToPlainArray($typoscriptSetup['config.']['tx_extbase.']); + } + + if (!isset($frameworkConfiguration['persistence']['storagePid'])) { + $currentPageId = $this->getCurrentPageId($request); + $frameworkConfiguration['persistence']['storagePid'] = $currentPageId; + } + // only merge $configuration and override controller configuration when retrieving configuration of the current plugin + if ($extensionName === null || $extensionName === $extensionNameFromConfig && $pluginName === $pluginNameFromConfig) { + $pluginConfiguration = $this->getPluginConfiguration($request, (string)$extensionNameFromConfig, (string)$pluginNameFromConfig); + ArrayUtility::mergeRecursiveWithOverrule($pluginConfiguration, $configuration); + $pluginConfiguration['controllerConfiguration'] = []; + } else { + $pluginConfiguration = $this->getPluginConfiguration($request, $extensionName, (string)$pluginName); + $pluginConfiguration['controllerConfiguration'] = []; + } + ArrayUtility::mergeRecursiveWithOverrule($frameworkConfiguration, $pluginConfiguration); + + if (!empty($frameworkConfiguration['persistence']['storagePid'])) { + if (is_array($frameworkConfiguration['persistence']['storagePid'])) { + // We simulate the frontend to enable the use of cObjects in + // stdWrap. We then convert the configuration to normal TypoScript + // and apply the stdWrap to the storagePid + // Use makeInstance here since extbase Bootstrap always setContentObject(null) in Backend, no need to call getContentObject(). + $conf = $this->typoScriptService->convertPlainArrayToTypoScriptArray($frameworkConfiguration['persistence']); + $frameworkConfiguration['persistence']['storagePid'] = GeneralUtility::makeInstance(ContentObjectRenderer::class)->stdWrapValue('storagePid', $conf); + } + + if (!empty($frameworkConfiguration['persistence']['recursive'])) { + $storagePids = $this->getRecursiveStoragePids( + GeneralUtility::intExplode(',', (string)($frameworkConfiguration['persistence']['storagePid'] ?? '')), + (int)$frameworkConfiguration['persistence']['recursive'] + ); + $frameworkConfiguration['persistence']['storagePid'] = implode(',', $storagePids); + } + } + return $frameworkConfiguration; + } + + /** + * Returns TypoScript Setup array from current Environment. + * + * @return array the raw TypoScript setup + */ + public function getTypoScriptSetup(ServerRequestInterface $request): array + { + $currentPageId = $this->getCurrentPageId($request); + + $cacheIdentifier = 'extbase-backend-typoscript-pageId-' . $currentPageId; + $setupArray = $this->runtimeCache->get($cacheIdentifier); + if (is_array($setupArray)) { + return $setupArray; + } + + $site = $request->getAttribute('site'); + if (($site === null || $site instanceof NullSite) && $currentPageId > 0) { + // Due to the weird magic of getting the pid of the first root template when + // not having a pageId (extbase BE modules without page tree / no page selected), + // we also have no proper site in this case. + // So we try to get the site for this pageId. This way, site settings for this + // first TS page are turned into constants and can be used in setup and setup + // conditions. + try { + $site = $this->siteFinder->getSiteByPageId($currentPageId); + } catch (SiteNotFoundException) { + // Keep null / NullSite when no site could be determined for whatever reason. + } + } + if ($site === null) { + // If still no site object, have NullSite (usually pid 0). + $site = new NullSite(); + } + + $rootLine = []; + $sysTemplateRows = []; + if ($currentPageId > 0) { + $rootLine = GeneralUtility::makeInstance(RootlineUtility::class, $currentPageId)->get(); + // When the site acts as a TypoScript root, limit sys_template lookup to + // pages within this site by truncating the rootline at the site root page. + // This mirrors the frontend behavior and prevents sys_template records from + // parent sites from leaking into the backend TypoScript evaluation. + // @see \TYPO3\CMS\Frontend\Page\PageInformationFactory::setSysTemplateRows() + $rootLineForSysTemplates = $rootLine; + if ($site instanceof Site && $site->isTypoScriptRoot()) { + $rootLineForSysTemplates = []; + foreach ($rootLine as $index => $rootlinePage) { + $rootLineForSysTemplates[$index] = $rootlinePage; + if ((int)($rootlinePage['uid'] ?? 0) === $site->getRootPageId()) { + break; + } + } + } + $sysTemplateRows = $this->sysTemplateRepository->getSysTemplateRowsByRootline($rootLineForSysTemplates, $request); + ksort($rootLine); + } + $sets = $site instanceof Site ? $this->setRegistry->getSets(...$site->getSets()) : []; + if (empty($sysTemplateRows) && $sets === []) { + // If no page with sys_template rows or site sets could be derived, we + // "fake" a row to trigger inclusion of 'global' TypoScript only. + $sysTemplateFakeRow = [ + 'uid' => 0, + 'pid' => 0, + 'title' => 'Fake sys_template row to force global TypoScript loading', + 'root' => 1, + 'clear' => 3, + 'include_static_file' => '', + 'basedOn' => '', + 'includeStaticAfterBasedOn' => 0, + 'static_file_mode' => false, + 'constants' => '', + 'config' => '', + 'deleted' => 0, + 'hidden' => 0, + 'starttime' => 0, + 'endtime' => 0, + 'sorting' => 0, + ]; + $sysTemplateRows[] = $sysTemplateFakeRow; + } + + $expressionMatcherVariables = [ + 'request' => $request, + 'pageId' => $currentPageId, + 'page' => !empty($rootLine) ? array_first($rootLine) : [], + 'fullRootLine' => $rootLine, + 'site' => $site, + ]; + + $typoScript = $this->frontendTypoScriptFactory->createSettingsAndSetupConditions($site, $sysTemplateRows, $expressionMatcherVariables, $this->typoScriptCache); + $typoScript = $this->frontendTypoScriptFactory->createSetupConfigOrFullSetup(true, $typoScript, $site, $sysTemplateRows, $expressionMatcherVariables, '0', $this->typoScriptCache, null); + $setupArray = $typoScript->getSetupArray(); + $this->runtimeCache->set($cacheIdentifier, $setupArray); + return $setupArray; + } + + /** + * Returns the TypoScript configuration found in module.tx_yourextension_yourmodule + * merged with the global configuration of your extension from module.tx_yourextension + * + * @param string|null $pluginName in BE mode this is actually the module signature. But we're using it just like the plugin name in FE + */ + private function getPluginConfiguration(ServerRequestInterface $request, string $extensionName, ?string $pluginName = null): array + { + $setup = $this->getTypoScriptSetup($request); + $pluginConfiguration = []; + if (is_array($setup['module.']['tx_' . strtolower($extensionName) . '.'] ?? false)) { + $pluginConfiguration = $this->typoScriptService->convertTypoScriptArrayToPlainArray($setup['module.']['tx_' . strtolower($extensionName) . '.']); + } + if ($pluginName !== null) { + $pluginSignature = strtolower($extensionName . '_' . $pluginName); + if (is_array($setup['module.']['tx_' . $pluginSignature . '.'] ?? false)) { + $overruleConfiguration = $this->typoScriptService->convertTypoScriptArrayToPlainArray($setup['module.']['tx_' . $pluginSignature . '.']); + ArrayUtility::mergeRecursiveWithOverrule($pluginConfiguration, $overruleConfiguration); + } + } + return $pluginConfiguration; + } + + /** + * Get page id from the request, accessing POST / GET 'id' + */ + private function getCurrentPageId(ServerRequestInterface $request): int + { + // @todo: This misuses 'id' as a broken convention for pages-uid. The filelist module for instance + // uses 'id' as "storage-uid:path", which is only mitigated here by testing the argument + // with MU:canBeInterpretedAsInteger(). + // This is in-line with a similar misuse in BackendModuleValidator. + $id = 0; + $potentialId = $request->getParsedBody()['id'] ?? $request->getQueryParams()['id'] ?? 0; + if (MathUtility::canBeInterpretedAsInteger($potentialId) && $potentialId > 0) { + $id = (int)$potentialId; + } + return $id; + } + + /** + * Returns an array of storagePIDs that are below a list of storage pids. + * + * @param int[] $storagePids Storage PIDs to start at; multiple PIDs possible as comma-separated list + * @param int $recursionDepth Maximum number of levels to search, 0 to disable recursive lookup + * @return int[] Uid list including the start $storagePids + */ + private function getRecursiveStoragePids(array $storagePids, int $recursionDepth = 0): array + { + if ($recursionDepth <= 0) { + return $storagePids; + } + $permsClause = QueryHelper::stripLogicalOperatorPrefix( + $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW) + ); + $recursiveStoragePids = []; + foreach ($storagePids as $startPid) { + $startPid = abs($startPid); + $recursiveStoragePids = array_merge( + $recursiveStoragePids, + [ $startPid ], + $this->getPageChildrenRecursive($startPid, $recursionDepth, 0, $permsClause) + ); + } + return array_unique($recursiveStoragePids); + } + + /** + * Recursively fetch all children of a given page + * + * @return int[] List of child row $uid's + */ + private function getPageChildrenRecursive(int $pid, int $depth, int $begin, string $permsClause): array + { + $children = []; + if ($pid && $depth > 0) { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages'); + $queryBuilder->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class)); + $statement = $queryBuilder->select('uid') + ->from('pages') + ->where( + $queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pid, Connection::PARAM_INT)), + $queryBuilder->expr()->eq('language_tag', 0), + $permsClause + ) + ->orderBy('uid') + ->executeQuery(); + while ($row = $statement->fetchAssociative()) { + if ($begin <= 0) { + $children[] = (int)$row['uid']; + } + if ($depth > 1) { + $theSubList = $this->getPageChildrenRecursive((int)$row['uid'], $depth - 1, $begin - 1, $permsClause); + $children = array_merge($children, $theSubList); + } + } + } + return $children; + } + + private function getBackendUser(): BackendUserAuthentication + { + return $GLOBALS['BE_USER']; + } +} diff --git a/Classes/Configuration/ConfigurationManager.php b/Classes/Configuration/ConfigurationManager.php new file mode 100644 index 0000000..d71f900 --- /dev/null +++ b/Classes/Configuration/ConfigurationManager.php @@ -0,0 +1,125 @@ +request = $request; + } + + public function setConfiguration(array $configuration = []): void + { + $this->configuration = $configuration; + $this->feConfigCache = []; + } + + /** + * Returns the specified configuration. + * The actual configuration will be merged from different sources in a defined order. + * + * You can get the following types of configuration invoking: + * CONFIGURATION_TYPE_SETTINGS: Extbase settings + * CONFIGURATION_TYPE_FRAMEWORK: the current module/plugin settings + * CONFIGURATION_TYPE_FULL_TYPOSCRIPT: a raw TS array + * + * Note that this is a low level method and only makes sense to be used by Extbase internally. + * + * @param string $configurationType The kind of configuration to fetch - must be one of the CONFIGURATION_TYPE_* constants + * @param string|null $extensionName if specified, the configuration for the given extension will be returned. + * @param string|null $pluginName if specified, the configuration for the given plugin will be returned. + * @return array The configuration + */ + public function getConfiguration(string $configurationType, ?string $extensionName = null, ?string $pluginName = null): array + { + $request = $this->request; + $configuration = $this->configuration; + if ($request === null && ($GLOBALS['TYPO3_REQUEST'] ?? null) instanceof ServerRequestInterface) { + // @todo: deprecate + $request = $GLOBALS['TYPO3_REQUEST']; + } + if ($request === null) { + // This is a *specific* exception (as opposed to a global one) to allow consumers to opt-out + // of a Request dependency. The extbase persistence layer is an example: It can be useful to + // only have a loose Request / TypoScript dependency in it, and the TS config values / toggles + // used within the persistence layer are not crucially important and can fall back to hard + // coded defaults. + // Note custom extensions should typically not catch this exception. The dependency to + // the current request is still an important dependency in most extbase places, e.g. in + // controller and view related code. + throw new NoServerRequestGivenException('No request given. ConfigurationManager has not been initialized properly.', 1721920500); + } + if (ApplicationType::fromRequest($request)->isFrontend()) { + if ($configurationType === self::CONFIGURATION_TYPE_FULL_TYPOSCRIPT) { + return $this->feConfigManager->getTypoScriptSetup($request); + } + // @todo Throw if empty to not end up with '_': Invalid setup/call! + $feConfigCacheKey = strtolower( + ($extensionName ?? $configuration['extensionName'] ?? null) + . '_' + . ($pluginName ?? $configuration['pluginName'] ?? null) + ); + if ($configurationType === self::CONFIGURATION_TYPE_SETTINGS) { + if (isset($this->feConfigCache[$feConfigCacheKey])) { + return $this->feConfigCache[$feConfigCacheKey]['settings'] ?? []; + } + $this->feConfigCache[$feConfigCacheKey] = $this->feConfigManager->getConfiguration($request, $this->configuration, $extensionName, $pluginName); + return $this->feConfigCache[$feConfigCacheKey]['settings'] ?? []; + } + if ($configurationType === self::CONFIGURATION_TYPE_FRAMEWORK) { + if (isset($this->feConfigCache[$feConfigCacheKey])) { + return $this->feConfigCache[$feConfigCacheKey]; + } + $this->feConfigCache[$feConfigCacheKey] = $this->feConfigManager->getConfiguration($request, $this->configuration, $extensionName, $pluginName); + return $this->feConfigCache[$feConfigCacheKey]; + } + throw new \RuntimeException('Invalid configuration type "' . $configurationType . '"', 1206031879); + } else { + return match ($configurationType) { + self::CONFIGURATION_TYPE_SETTINGS => $this->beConfigManager->getConfiguration($request, $this->configuration, $extensionName, $pluginName)['settings'] ?? [], + self::CONFIGURATION_TYPE_FRAMEWORK => $this->beConfigManager->getConfiguration($request, $this->configuration, $extensionName, $pluginName), + self::CONFIGURATION_TYPE_FULL_TYPOSCRIPT => $this->beConfigManager->getTypoScriptSetup($request), + default => throw new \RuntimeException('Invalid configuration type "' . $configurationType . '"', 1721928055), + }; + } + } +} diff --git a/Classes/Configuration/ConfigurationManagerInterface.php b/Classes/Configuration/ConfigurationManagerInterface.php new file mode 100644 index 0000000..2d6a022 --- /dev/null +++ b/Classes/Configuration/ConfigurationManagerInterface.php @@ -0,0 +1,75 @@ +typoScriptService->convertTypoScriptArrayToPlainArray($configuration); + + $frameworkConfiguration = $this->getExtbaseConfiguration($request); + if (!isset($frameworkConfiguration['persistence']['storagePid'])) { + $frameworkConfiguration['persistence']['storagePid'] = 0; + } + // only merge $configuration and override controller configuration when retrieving configuration of the current plugin + if ($extensionName === null || $extensionName === $extensionNameFromConfig && $pluginName === $pluginNameFromConfig) { + $pluginConfiguration = $this->getPluginConfiguration($request, (string)$extensionNameFromConfig, (string)$pluginNameFromConfig); + ArrayUtility::mergeRecursiveWithOverrule($pluginConfiguration, $configuration); + $pluginConfiguration['controllerConfiguration'] = $this->getControllerConfiguration((string)$extensionNameFromConfig, (string)$pluginNameFromConfig); + } else { + $pluginConfiguration = $this->getPluginConfiguration($request, $extensionName, (string)$pluginName); + $pluginConfiguration['controllerConfiguration'] = $this->getControllerConfiguration($extensionName, (string)$pluginName); + } + ArrayUtility::mergeRecursiveWithOverrule($frameworkConfiguration, $pluginConfiguration); + // only load context specific configuration when retrieving configuration of the current plugin + if ($extensionName === null || $extensionName === $extensionNameFromConfig && $pluginName === $pluginNameFromConfig) { + $frameworkConfiguration = $this->getContextSpecificFrameworkConfiguration($request, $frameworkConfiguration); + } + + if (!empty($frameworkConfiguration['persistence']['storagePid'])) { + if (is_array($frameworkConfiguration['persistence']['storagePid'])) { + $conf = $this->typoScriptService->convertPlainArrayToTypoScriptArray($frameworkConfiguration['persistence']); + $contentObjectRenderer = GeneralUtility::makeInstance(ContentObjectRenderer::class); + $contentObjectRenderer->setRequest($request); + $contentObjectRenderer->start($request->getAttribute('frontend.page.information')->getPageRecord(), 'pages'); + $frameworkConfiguration['persistence']['storagePid'] = $contentObjectRenderer->stdWrapValue('storagePid', $conf); + } + if (!empty($frameworkConfiguration['persistence']['recursive'])) { + $storagePids = $this->getRecursiveStoragePids( + GeneralUtility::intExplode(',', (string)($frameworkConfiguration['persistence']['storagePid'] ?? '')), + (int)$frameworkConfiguration['persistence']['recursive'] + ); + $frameworkConfiguration['persistence']['storagePid'] = implode(',', $storagePids); + } + } + return $frameworkConfiguration; + } + + /** + * Returns full Frontend TypoScript setup array calculated by FE middlewares. + */ + public function getTypoScriptSetup(ServerRequestInterface $request): array + { + $frontendTypoScript = $request->getAttribute('frontend.typoscript'); + if (!($frontendTypoScript instanceof FrontendTypoScript)) { + throw new \RuntimeException( + 'Setup array has not been initialized. This happens in cached Frontend scope where full TypoScript' + . ' is not needed by the system.', + 1700841298 + ); + } + return $frontendTypoScript->getSetupArray(); + } + + /** + * Returns the TypoScript configuration found in config.tx_extbase + */ + private function getExtbaseConfiguration(ServerRequestInterface $request): array + { + $setup = $this->getTypoScriptSetup($request); + $extbaseConfiguration = []; + if (isset($setup['config.']['tx_extbase.'])) { + $extbaseConfiguration = $this->typoScriptService->convertTypoScriptArrayToPlainArray($setup['config.']['tx_extbase.']); + } + return $extbaseConfiguration; + } + + /** + * Returns the TypoScript configuration found in plugin.tx_yourextension_yourplugin + * merged with the global configuration of your extension from plugin.tx_yourextension + * + * @param string|null $pluginName in FE mode this is the specified plugin name + */ + private function getPluginConfiguration(ServerRequestInterface $request, string $extensionName, ?string $pluginName = null): array + { + $setup = $this->getTypoScriptSetup($request); + $pluginConfiguration = []; + if (isset($setup['plugin.']['tx_' . strtolower($extensionName) . '.']) && is_array($setup['plugin.']['tx_' . strtolower($extensionName) . '.'])) { + $pluginConfiguration = $this->typoScriptService->convertTypoScriptArrayToPlainArray($setup['plugin.']['tx_' . strtolower($extensionName) . '.']); + } + if ($pluginName !== null) { + $pluginSignature = strtolower($extensionName . '_' . $pluginName); + if (isset($setup['plugin.']['tx_' . $pluginSignature . '.']) && is_array($setup['plugin.']['tx_' . $pluginSignature . '.'])) { + ArrayUtility::mergeRecursiveWithOverrule( + $pluginConfiguration, + $this->typoScriptService->convertTypoScriptArrayToPlainArray($setup['plugin.']['tx_' . $pluginSignature . '.']) + ); + } + } + return $pluginConfiguration; + } + + /** + * Returns the configured controller/action configuration of the specified plugin in the format + * array( + * 'Controller1' => array('action1', 'action2'), + * 'Controller2' => array('action3', 'action4') + * ) + * + * @param string $pluginName in FE mode this is the specified plugin name + */ + private function getControllerConfiguration(string $extensionName, string $pluginName): array + { + $controllerConfiguration = $GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['extbase']['extensions'][$extensionName]['plugins'][$pluginName]['controllers'] ?? []; + if (!is_array($controllerConfiguration)) { + $controllerConfiguration = []; + } + return $controllerConfiguration; + } + + /** + * Get context specific framework configuration. + * - Overrides storage PID with setting "Startingpoint" + * - merge flexForm configuration, if needed + * + * @param array $frameworkConfiguration The framework configuration to modify + * @return array the modified framework configuration + */ + private function getContextSpecificFrameworkConfiguration(ServerRequestInterface $request, array $frameworkConfiguration): array + { + $frameworkConfiguration = $this->overrideStoragePidIfStartingPointIsSet($request, $frameworkConfiguration); + $frameworkConfiguration = $this->overrideConfigurationFromPlugin($request, $frameworkConfiguration); + return $this->overrideConfigurationFromFlexForm($request, $frameworkConfiguration); + } + + /** + * Overrides the storage PID settings, in case the "Startingpoint" settings + * is set in the plugin configuration. + * + * @param array $frameworkConfiguration the framework configurations + * @return array the framework configuration with overridden storagePid + */ + private function overrideStoragePidIfStartingPointIsSet(ServerRequestInterface $request, array $frameworkConfiguration): array + { + $contentObject = $request->getAttribute('currentContentObject'); + $pages = (string)($contentObject?->data['pages'] ?? ''); + if ($pages !== '') { + $storagePids = GeneralUtility::intExplode(',', $pages, true); + $recursionDepth = (int)($contentObject?->data['recursive'] ?? 0); + $recursiveStoragePids = $this->pageRepository->getPageIdsRecursive($storagePids, $recursionDepth); + $pages = implode(',', $recursiveStoragePids); + ArrayUtility::mergeRecursiveWithOverrule($frameworkConfiguration, [ + 'persistence' => [ + 'storagePid' => $pages, + ], + ]); + } + return $frameworkConfiguration; + } + + /** + * Overrides configuration settings from the plugin typoscript (plugin.tx_myext_pi1.) + * + * @param array $frameworkConfiguration the framework configuration + * @return array the framework configuration with overridden data from typoscript + */ + private function overrideConfigurationFromPlugin(ServerRequestInterface $request, array $frameworkConfiguration): array + { + if (!isset($frameworkConfiguration['extensionName']) || !isset($frameworkConfiguration['pluginName'])) { + return $frameworkConfiguration; + } + + $setup = $this->getTypoScriptSetup($request); + $pluginSignature = strtolower($frameworkConfiguration['extensionName'] . '_' . $frameworkConfiguration['pluginName']); + $pluginConfiguration = $setup['plugin.']['tx_' . $pluginSignature . '.'] ?? null; + if (is_array($pluginConfiguration)) { + $pluginConfiguration = $this->typoScriptService->convertTypoScriptArrayToPlainArray($pluginConfiguration); + $frameworkConfiguration = $this->mergeConfigurationIntoFrameworkConfiguration($frameworkConfiguration, $pluginConfiguration, 'settings'); + $frameworkConfiguration = $this->mergeConfigurationIntoFrameworkConfiguration($frameworkConfiguration, $pluginConfiguration, 'persistence'); + $frameworkConfiguration = $this->mergeConfigurationIntoFrameworkConfiguration($frameworkConfiguration, $pluginConfiguration, 'view'); + } + return $frameworkConfiguration; + } + + /** + * Overrides configuration settings from flexForms. This merges the whole flexForm data. + * + * @param array $frameworkConfiguration the framework configuration + * @return array the framework configuration with overridden data from flexForm + */ + private function overrideConfigurationFromFlexForm(ServerRequestInterface $request, array $frameworkConfiguration): array + { + $contentObject = $request->getAttribute('currentContentObject'); + $flexFormConfiguration = $contentObject?->data['pi_flexform'] ?? []; + if (is_string($flexFormConfiguration)) { + if ($flexFormConfiguration !== '') { + $flexFormConfiguration = $this->flexFormTools->convertFlexFormContentToArray($flexFormConfiguration); + } else { + $flexFormConfiguration = []; + } + } + + // Early return, if flexForm configuration is empty + if (!is_array($flexFormConfiguration) || empty($flexFormConfiguration)) { + return $frameworkConfiguration; + } + + // Remove flexForm settings if empty for fields defined in `ignoreFlexFormSettingsIfEmpty` + $originalFlexFormConfiguration = $flexFormConfiguration; + $ignoredSettingsConfig = (string)($frameworkConfiguration['ignoreFlexFormSettingsIfEmpty'] ?? ''); + if ($ignoredSettingsConfig !== '') { + $ignoredSettings = GeneralUtility::trimExplode(',', $ignoredSettingsConfig, true); + $flexFormConfiguration = $this->removeIgnoredFlexFormSettingsIfEmpty($flexFormConfiguration, $ignoredSettings); + } + + // PSR-14 event for extension authors to modify flexForm configuration before the merge process + $event = new BeforeFlexFormConfigurationOverrideEvent($frameworkConfiguration, $originalFlexFormConfiguration, $flexFormConfiguration); + $this->eventDispatcher->dispatch($event); + $flexFormConfiguration = $event->getFlexFormConfiguration(); + + $frameworkConfiguration = $this->mergeConfigurationIntoFrameworkConfiguration($frameworkConfiguration, $flexFormConfiguration, 'settings'); + $frameworkConfiguration = $this->mergeConfigurationIntoFrameworkConfiguration($frameworkConfiguration, $flexFormConfiguration, 'persistence'); + return $this->mergeConfigurationIntoFrameworkConfiguration($frameworkConfiguration, $flexFormConfiguration, 'view'); + } + + /** + * Merge a configuration into the framework configuration. + * + * @param array $frameworkConfiguration the framework configuration to merge the data on + * @param array $configuration The configuration + * @param string $configurationPartName The name of the configuration part which should be merged. + * @return array the processed framework configuration + */ + private function mergeConfigurationIntoFrameworkConfiguration(array $frameworkConfiguration, array $configuration, string $configurationPartName): array + { + if (isset($configuration[$configurationPartName]) && is_array($configuration[$configurationPartName])) { + if (isset($frameworkConfiguration[$configurationPartName]) && is_array($frameworkConfiguration[$configurationPartName])) { + ArrayUtility::mergeRecursiveWithOverrule($frameworkConfiguration[$configurationPartName], $configuration[$configurationPartName]); + } else { + $frameworkConfiguration[$configurationPartName] = $configuration[$configurationPartName]; + } + } + return $frameworkConfiguration; + } + + /** + * Returns a comma separated list of storagePid that are below a certain storage pid. + * + * @param array|int[] $storagePids Storage PIDs to start at; multiple PIDs possible as comma-separated list + * @param int $recursionDepth Maximum number of levels to search, 0 to disable recursive lookup + * @return int[] storage PIDs + */ + private function getRecursiveStoragePids(array $storagePids, int $recursionDepth = 0): array + { + return $this->pageRepository->getPageIdsRecursive($storagePids, $recursionDepth); + } + + private function removeIgnoredFlexFormSettingsIfEmpty(array $flexFormConfiguration, array $ignoredSettings): array + { + foreach ($ignoredSettings as $ignoredSetting) { + $ignoredSettingName = 'settings.' . $ignoredSetting; + if (!ArrayUtility::isValidPath($flexFormConfiguration, $ignoredSettingName, '.')) { + continue; + } + + $fieldValue = ArrayUtility::getValueByPath($flexFormConfiguration, $ignoredSettingName, '.'); + if ($fieldValue === '' || $fieldValue === '0') { + $flexFormConfiguration = ArrayUtility::removeByPath($flexFormConfiguration, $ignoredSettingName, '.'); + } + } + + return $flexFormConfiguration; + } +} diff --git a/Classes/ConfigurationModuleProvider/ClassConfigurationProvider.php b/Classes/ConfigurationModuleProvider/ClassConfigurationProvider.php new file mode 100644 index 0000000..0340ccf --- /dev/null +++ b/Classes/ConfigurationModuleProvider/ClassConfigurationProvider.php @@ -0,0 +1,31 @@ +classesConfiguration->getConfiguration(); + } +} diff --git a/Classes/ContentObject/ExtbasePluginContentObject.php b/Classes/ContentObject/ExtbasePluginContentObject.php new file mode 100644 index 0000000..6b1f1ed --- /dev/null +++ b/Classes/ContentObject/ExtbasePluginContentObject.php @@ -0,0 +1,70 @@ +setContentObjectRenderer($this->getContentObjectRenderer()); + if ($this->cObj->getUserObjectType() === false) { + // Come here only if we are not called as non-cached element + $this->cObj->setUserObjectType(ContentObjectRenderer::OBJECTTYPE_USER); + } + $request = $extbaseBootstrap->initialize($conf, $this->request); + $content = $extbaseBootstrap->handleFrontendRequest($request); + // Rendering is deferred, as the action should not be cached. Register as non cached element. + if ($this->cObj->doConvertToUserIntObject) { + $this->cObj->doConvertToUserIntObject = false; + // @todo: this should be removed in the future when FE chains allows more "uncacheables" than USER_INTs + // also, the handleFrontendRequest() should return the full response in the future + $conf['userFunc'] = Bootstrap::class . '->run'; + $this->cObj->setUserObjectType(ContentObjectRenderer::OBJECTTYPE_USER_INT); + $pageParts = $request->getAttribute('frontend.page.parts'); + $substKey = 'INT_SCRIPT.' . md5(StringUtility::getUniqueId()); + $content = ''; + $pageParts->addNotCachedContentElement([ + 'substKey' => $substKey, + 'conf' => $conf, + 'cObjData' => serialize($this->cObj->getState()), + 'type' => 'FUNC', + ]); + } elseif (isset($conf['stdWrap.'])) { + // Only executed when the element is not converted to USER_INT + $content = $this->cObj->stdWrap($content, $conf['stdWrap.']); + } + $this->cObj->setUserObjectType(false); + return $content; + } +} diff --git a/Classes/Core/Bootstrap.php b/Classes/Core/Bootstrap.php new file mode 100644 index 0000000..3cc129c --- /dev/null +++ b/Classes/Core/Bootstrap.php @@ -0,0 +1,253 @@ +callUserFunction(). + */ + public function setContentObjectRenderer(ContentObjectRenderer $cObj): void + { + $this->cObj = $cObj; + } + + /** + * Explicitly initializes all necessary Extbase objects by invoking the various initialize* methods. + * + * Usually this method is only called from unit tests or other applications which need a more fine-grained control over + * the initialization and request handling process. Most other applications just call the run() method. + * + * @param array $configuration The TS configuration array + * @throws \RuntimeException + * @see run() + */ + public function initialize(array $configuration, ServerRequestInterface $request): ServerRequestInterface + { + if (!Environment::isCli()) { + if (!isset($configuration['extensionName']) || $configuration['extensionName'] === '') { + throw new \RuntimeException('Invalid configuration: "extensionName" is not set', 1290623020); + } + if (!isset($configuration['pluginName']) || $configuration['pluginName'] === '') { + throw new \RuntimeException('Invalid configuration: "pluginName" is not set', 1290623027); + } + } + return $this->initializeConfiguration($configuration, $request); + } + + /** + * Initializes the Object framework. + * + * @see initialize() + * @internal + */ + public function initializeConfiguration(array $configuration, ServerRequestInterface $request): ServerRequestInterface + { + if ($this->cObj === null) { + // @todo: While the frontend sets the current cObj, a backend extbase request does not. + // It is currently not clear if the backend should have a dummy cObj as well. + // For now, extbase initializes one. + $this->cObj = $this->container->get(ContentObjectRenderer::class); + $this->cObj->setRequest($request); + } + $this->configurationManager->setRequest($request); + $this->configurationManager->setConfiguration($configuration); + return $request; + // todo: Outdated todo, recheck in v13. + // Shouldn't the configuration manager object – which is a singleton – be stateless? + // At this point we give the configuration manager a state, while we could directly pass the + // configuration (i.e. controllerName, actionName and such), directly to the request + // handler, which then creates stateful request objects. + // Once this has changed, \TYPO3\CMS\Extbase\Mvc\Web\RequestBuilder::loadDefaultValues does not need + // to fetch this configuration from the configuration manager. + } + + /** + * Runs the Extbase Framework by resolving an appropriate Request Handler and passing control to it. + * If the Framework is not initialized yet, it will be initialized. + * + * This is usually used in Frontend plugins. + * This method will be marked as internal in the future, use EXTBASEPLUGIN in TypoScript to execute an Extbase plugin + * instead. + * + * @param string $content The content. Not used + * @param array $configuration The TS configuration array + * @param ServerRequestInterface $request the incoming server request + * @return string $content The processed content + */ + #[AsAllowedCallable] + public function run(string $content, array $configuration, ServerRequestInterface $request): string + { + $request = $this->initialize($configuration, $request); + return $this->handleFrontendRequest($request); + } + + /** + * Used for any Extbase Plugin in the Frontend, be sure to run $this->initialize() before. + * + * @internal + */ + public function handleFrontendRequest(ServerRequestInterface $request): string + { + $extbaseRequest = $this->extbaseRequestBuilder->build($request); + if (!$this->isExtbaseRequestCacheable($extbaseRequest)) { + if ($this->cObj->getUserObjectType() === ContentObjectRenderer::OBJECTTYPE_USER) { + // ContentObjectRenderer::convertToUserIntObject() will recreate the object, + // so we have to stop the request here before the action is actually called + $this->cObj->convertToUserIntObject(); + return ''; + } + } + + // Dispatch the extbase request + $response = $this->dispatcher->dispatch($extbaseRequest); + if ($response->getStatusCode() >= 300) { + // Avoid caching the plugin when we issue a redirect or error response + // This means that even when an action is configured as cachable + // we avoid the plugin to be cached, but keep the page cache untouched + if ($this->cObj->getUserObjectType() === ContentObjectRenderer::OBJECTTYPE_USER) { + $this->cObj->convertToUserIntObject(); + } + } + // Usually coming from an error action, ensure all caches are cleared + if ($response->getStatusCode() === 400) { + $this->clearCacheOnError($request); + } + + if ($response->hasHeader('Content-Type')) { + // Typically used when extbase for instance created a json response. + $request->getAttribute('frontend.page.parts')->setHttpContentType($response->getHeaderLine('Content-Type')); + // Do not send the header directly (see below) + $response = $response->withoutHeader('Content-Type'); + } + + $responseData = $request->getAttribute('frontend.response.data'); + if ($responseData instanceof ResponseData) { + foreach ($response->getHeaders() as $name => $values) { + $responseData->setHeader($name, $values); + } + // @todo: Get rid of this in TYPO3 v15. See todos in ResponseData. + if ($response->getStatusCode() >= 300) { + $responseData->setProtocolVersion($response->getProtocolVersion()); + $responseData->setStatusCode($response->getStatusCode()); + $responseData->setReasonPhrase($response->getReasonPhrase()); + } + } + + $body = $response->getBody(); + $body->rewind(); + $content = $body->getContents(); + $this->resetSingletons(); + $this->cacheService->clearCachesOfRegisteredPageIds(); + return $content; + } + + /** + * Entrypoint for backend modules, handling PSR-7 requests/responses. + * + * Creates an Extbase Request, dispatches it and then returns the Response + * + * @internal + */ + public function handleBackendRequest(ServerRequestInterface $request): ResponseInterface + { + // build the configuration from the module, included in the current request + $module = $request->getAttribute('module'); + $configuration = [ + 'extensionName' => $module?->getExtensionName(), + 'pluginName' => $module?->getIdentifier(), + ]; + + $request = $this->initialize($configuration, $request); + $extbaseRequest = $this->extbaseRequestBuilder->build($request); + $response = $this->dispatcher->dispatch($extbaseRequest); + $this->resetSingletons(); + $this->cacheService->clearCachesOfRegisteredPageIds(); + return $response; + } + + /** + * Clear cache of current page on error. Needed because we want a re-evaluation of the data. + */ + protected function clearCacheOnError(ServerRequestInterface $request): void + { + $extbaseSettings = $this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FRAMEWORK); + if (isset($extbaseSettings['persistence']['enableAutomaticCacheClearing']) && $extbaseSettings['persistence']['enableAutomaticCacheClearing'] === '1') { + $pageId = $request->getAttribute('frontend.page.information')?->getId(); + if ($pageId !== null) { + $this->cacheService->clearPageCache([$pageId]); + } + } + } + + /** + * Resets global singletons for the next plugin + */ + protected function resetSingletons(): void + { + $this->persistenceManager->persistAll(); + } + + protected function isExtbaseRequestCacheable(RequestInterface $extbaseRequest): bool + { + $controllerClassName = $extbaseRequest->getControllerObjectName(); + $actionName = $extbaseRequest->getControllerActionName(); + $frameworkConfiguration = $this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FRAMEWORK); + $nonCacheableActions = $frameworkConfiguration['controllerConfiguration'][$controllerClassName]['nonCacheableActions'] ?? null; + if (!is_array($nonCacheableActions)) { + return true; + } + return !in_array($actionName, $nonCacheableActions, true); + } +} diff --git a/Classes/DependencyInjection/AuthorizePass.php b/Classes/DependencyInjection/AuthorizePass.php new file mode 100644 index 0000000..1507094 --- /dev/null +++ b/Classes/DependencyInjection/AuthorizePass.php @@ -0,0 +1,78 @@ +hasDefinition(AuthorizeRegistry::class)) { + return; + } + + $registryDefinition = $container->findDefinition(AuthorizeRegistry::class); + + foreach ($container->findTaggedServiceIds($this->tagName) as $serviceName => $tags) { + $definition = $container->findDefinition($serviceName); + if ($definition->isAbstract()) { + continue; + } + + $className = $definition->getClass() ?? $serviceName; + $reflectionClass = $container->getReflectionClass($className); + if ($reflectionClass === null) { + continue; + } + + foreach ($reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + if (!str_ends_with($method->getName(), 'Action')) { + continue; + } + + $attributes = $method->getAttributes(Authorize::class); + if ($attributes === []) { + continue; + } + + foreach ($attributes as $attribute) { + $authorize = $attribute->newInstance(); + $registryDefinition->addMethodCall('add', [ + $className, + $method->getName(), + $authorize->callback, + $authorize->requireLogin, + $authorize->requireGroups, + ]); + } + } + } + } +} diff --git a/Classes/DependencyInjection/RateLimitPass.php b/Classes/DependencyInjection/RateLimitPass.php new file mode 100644 index 0000000..e6b732e --- /dev/null +++ b/Classes/DependencyInjection/RateLimitPass.php @@ -0,0 +1,77 @@ +hasDefinition(RateLimitRegistry::class)) { + return; + } + + $registryDefinition = $container->findDefinition(RateLimitRegistry::class); + + foreach ($container->findTaggedServiceIds($this->tagName) as $serviceName => $tags) { + $definition = $container->findDefinition($serviceName); + if ($definition->isAbstract()) { + continue; + } + + $className = $definition->getClass() ?? $serviceName; + $reflectionClass = $container->getReflectionClass($className); + if ($reflectionClass === null) { + continue; + } + + foreach ($reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + if (!str_ends_with($method->getName(), 'Action')) { + continue; + } + + $attributes = $method->getAttributes(RateLimit::class); + if ($attributes === []) { + continue; + } + + $rateLimit = $attributes[0]->newInstance(); + $registryDefinition->addMethodCall('add', [ + $className, + $method->getName(), + $rateLimit->limit, + $rateLimit->interval, + $rateLimit->policy, + $rateLimit->message, + ]); + } + } + } +} diff --git a/Classes/DependencyInjection/TypeConverterPass.php b/Classes/DependencyInjection/TypeConverterPass.php new file mode 100644 index 0000000..2f16b65 --- /dev/null +++ b/Classes/DependencyInjection/TypeConverterPass.php @@ -0,0 +1,97 @@ +tagName = $tagName; + } + + /** + * @throws InvalidTypeConverterConfigurationException + */ + public function process(ContainerBuilder $container): void + { + $typeConverterRegistryDefinition = $container->findDefinition(TypeConverterRegistry::class); + + foreach ($container->findTaggedServiceIds($this->tagName) as $serviceName => $tags) { + $definition = $container->findDefinition($serviceName); + if (!$definition->isAutoconfigured() || $definition->isAbstract()) { + continue; + } + + $definition->setPublic(true); + + foreach ($tags as $attributes) { + if (!isset($attributes['sources'])) { + throw new InvalidTypeConverterConfigurationException( + sprintf( + 'Configuration for TypeConverter "%s" misses the "sources" attribute.', + $serviceName + ), + 1638376684 + ); + } + + $sources = GeneralUtility::trimExplode(',', (string)$attributes['sources'], true); + + if ($sources === []) { + throw new InvalidTypeConverterConfigurationException( + sprintf( + 'The sources attribute of the configuration of TypeConverter "%s" contains an empty list.', + $serviceName + ), + 1638376687 + ); + } + + if (!($attributes['target'] ?? false)) { + throw new InvalidTypeConverterConfigurationException( + sprintf( + 'Configuration for TypeConverter "%s" misses a valid "target" attribute.', + $serviceName + ), + 1638376689 + ); + } + + $typeConverterRegistryDefinition->addMethodCall('add', [ + $definition, + (int)($attributes['priority'] ?? 10), + $sources, + $attributes['target'], + ]); + } + } + } +} diff --git a/Classes/Domain/Model/Category.php b/Classes/Domain/Model/Category.php new file mode 100644 index 0000000..5d99291 --- /dev/null +++ b/Classes/Domain/Model/Category.php @@ -0,0 +1,69 @@ +title; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } + + public function getDescription(): string + { + return $this->description; + } + + public function setDescription(string $description): void + { + $this->description = $description; + } + + public function getParent(): ?Category + { + if ($this->parent instanceof LazyLoadingProxy) { + $this->parent->_loadRealInstance(); + } + return $this->parent; + } + + public function setParent(Category $parent): void + { + $this->parent = $parent; + } +} diff --git a/Classes/Domain/Model/File.php b/Classes/Domain/Model/File.php new file mode 100644 index 0000000..ef8ce62 --- /dev/null +++ b/Classes/Domain/Model/File.php @@ -0,0 +1,44 @@ +originalResource === null) { + $this->originalResource = GeneralUtility::makeInstance(ResourceFactory::class)->getFileObject($this->getUid()); + } + + return $this->originalResource; + } + + public function setOriginalResource(\TYPO3\CMS\Core\Resource\File $originalResource): void + { + $this->originalResource = $originalResource; + } +} diff --git a/Classes/Domain/Model/FileReference.php b/Classes/Domain/Model/FileReference.php new file mode 100644 index 0000000..d308252 --- /dev/null +++ b/Classes/Domain/Model/FileReference.php @@ -0,0 +1,52 @@ +originalResource = $originalResource; + $this->uidLocal = $originalResource->getOriginalFile()->getUid(); + } + + public function getOriginalResource(): \TYPO3\CMS\Core\Resource\FileReference + { + if ($this->originalResource === null) { + $uid = $this->_localizedUid; + $this->originalResource = GeneralUtility::makeInstance(ResourceFactory::class)->getFileReferenceObject($uid); + } + + return $this->originalResource; + } +} diff --git a/Classes/Domain/Model/Folder.php b/Classes/Domain/Model/Folder.php new file mode 100644 index 0000000..e26655d --- /dev/null +++ b/Classes/Domain/Model/Folder.php @@ -0,0 +1,38 @@ +originalResource = $originalResource; + } + + public function getOriginalResource(): ?\TYPO3\CMS\Core\Resource\Folder + { + return $this->originalResource; + } +} diff --git a/Classes/DomainObject/AbstractDomainObject.php b/Classes/DomainObject/AbstractDomainObject.php new file mode 100644 index 0000000..5938561 --- /dev/null +++ b/Classes/DomainObject/AbstractDomainObject.php @@ -0,0 +1,339 @@ +|null The uid of the record. The uid is only unique in the context of the database table. + */ + protected ?int $uid = null; + + /** + * @var int<0, max>|null The uid of the localized record. Holds the uid of the record in default language (the translationOrigin). + * + * @internal + * @todo make private in 13.0 and expose value via getter + */ + protected ?int $_localizedUid = null; + + /** + * @var int<-1, max>|null The uid of the language of the object. This is the id of the corresponding sing language. + * + * @internal + * @todo make private in 13.0 and expose value via getter + */ + protected ?int $_languageUid = null; + + /** + * The uid of the versioned record. + * + * @internal + * @todo make private in 13.0 and expose value via getter + */ + protected ?int $_versionedUid = null; + + /** + * @var int<0, max>|null The id of the page the record is "stored". + */ + protected ?int $pid = null; + + /** + * TRUE if the object is a clone + * + * @internal + */ + private bool $_isClone = false; + + /** + * @var array + * + * @internal + */ + private array $_cleanProperties = []; + + /** + * @return int<1, max>|null + */ + public function getUid(): ?int + { + if ($this->uid !== null) { + return (int)$this->uid; + } + return null; + } + + /** + * @param int<0, max> $pid + */ + public function setPid(int $pid): void + { + $this->pid = $pid; + } + + /** + * @return int<0, max>|null + */ + public function getPid(): ?int + { + if ($this->pid === null) { + return null; + } + return (int)$this->pid; + } + + /** + * @internal + */ + public function _setProperty(string $propertyName, mixed $value): bool + { + if ($this->_hasProperty($propertyName)) { + $this->{$propertyName} = $value; + return true; + } + return false; + } + + /** + * @internal + */ + public function _getProperty(string $propertyName): mixed + { + return $this->_hasProperty($propertyName) && isset($this->{$propertyName}) + ? $this->{$propertyName} + : null; + } + + /** + * @return array a hash map of property names and property values. + * + * @internal + */ + public function _getProperties(): array + { + $properties = get_object_vars($this); + foreach ($properties as $propertyName => $propertyValue) { + if (str_starts_with($propertyName, '_')) { + unset($properties[$propertyName]); + } + } + return $properties; + } + + /** + * @param non-empty-string $propertyName + * + * @internal + */ + public function _hasProperty(string $propertyName): bool + { + return property_exists($this, $propertyName); + } + + /** + * Returns TRUE if the object is new (the uid was not set, yet) + * + * @internal + */ + public function _isNew(): bool + { + return $this->uid === null; + } + + /** + * Register an object's clean state, e.g. after it has been reconstituted + * from the database. + * + * @param non-empty-string|null $propertyName The name of the property to be memorized. If omitted all persistable properties are memorized. + */ + public function _memorizeCleanState(?string $propertyName = null): void + { + if ($propertyName !== null) { + $this->_memorizePropertyCleanState($propertyName); + } else { + $this->_cleanProperties = []; + foreach ($this->_getProperties() as $propertyName => $propertyValue) { + $this->_memorizePropertyCleanState($propertyName); + } + } + } + + /** + * Register a property's clean state, e.g. after it has been reconstituted + * from the database. + * + * @param non-empty-string $propertyName The name of the property to be memorized. If omitted all persistable properties are memorized. + */ + public function _memorizePropertyCleanState(string $propertyName): void + { + $propertyValue = $this->_getProperty($propertyName); + if (is_object($propertyValue) && !($propertyValue instanceof \UnitEnum)) { + $propertyValueClone = clone $propertyValue; + // We need to make sure the clone and the original object + // are identical when compared with == (see _isDirty()). + // After the cloning, the Domain Object will have the property + // "isClone" set to TRUE, so we manually have to set it to FALSE + // again. Possible fix: Somehow get rid of the "isClone" property, + // which is currently needed in Fluid. + if ($propertyValueClone instanceof AbstractDomainObject) { + $propertyValueClone->_setClone(false); + } + + $this->_cleanProperties[$propertyName] = $propertyValueClone; + } else { + $this->_cleanProperties[$propertyName] = $propertyValue; + } + } + + /** + * Returns a hash map of clean properties and $values. + * + * @return array + */ + public function _getCleanProperties(): array + { + return $this->_cleanProperties; + } + + /** + * Returns the clean value of the given property. The returned value will be NULL if the clean state was not memorized before, or + * if the clean value is NULL. + * + * @param non-empty-string $propertyName The name of the property to be memorized. + * + * @internal + */ + public function _getCleanProperty(string $propertyName): mixed + { + return $this->_cleanProperties[$propertyName] ?? null; + } + + /** + * Returns TRUE if the properties were modified after reconstitution + * + * @param non-empty-string|null $propertyName An optional name of a property to be checked if its value is dirty + * + * @throws TooDirtyException + */ + public function _isDirty(?string $propertyName = null): bool + { + if ($this->uid !== null && $this->_getCleanProperty(self::PROPERTY_UID) !== null && $this->uid != $this->_getCleanProperty(self::PROPERTY_UID)) { + throw new TooDirtyException('The ' . self::PROPERTY_UID . ' "' . $this->uid . '" has been modified, that is simply too much.', 1222871239); + } + + if ($propertyName === null) { + foreach ($this->_getCleanProperties() as $propertyName => $cleanPropertyValue) { + if ($this->isPropertyDirty($cleanPropertyValue, $this->_getProperty($propertyName)) === true) { + return true; + } + } + return false; + } + + if ($this->isPropertyDirty($this->_getCleanProperty($propertyName), $this->_getProperty($propertyName)) === true) { + return true; + } + + return false; + } + + /** + * Checks the $value against the $cleanState. + */ + protected function isPropertyDirty(mixed $previousValue, mixed $currentValue): bool + { + // In case it is an object and it implements the ObjectMonitoringInterface, we call _isDirty() instead of a simple comparison of objects. + // We do this, because if the object itself contains a lazy loaded property, the comparison of the objects might fail even if the object didn't change + if (is_object($currentValue)) { + $currentTypeString = null; + if ($currentValue instanceof LazyLoadingProxy) { + $currentTypeString = $currentValue->_getTypeAndUidString(); + } elseif ($currentValue instanceof DomainObjectInterface) { + $currentTypeString = $currentValue::class . ':' . $currentValue->getUid(); + } + + if ($currentTypeString !== null) { + $previousTypeString = null; + if ($previousValue instanceof LazyLoadingProxy) { + $previousTypeString = $previousValue->_getTypeAndUidString(); + } elseif ($previousValue instanceof DomainObjectInterface) { + $previousTypeString = $previousValue::class . ':' . $previousValue->getUid(); + } + + $result = $currentTypeString !== $previousTypeString; + } elseif ($currentValue instanceof ObjectMonitoringInterface) { + $result = !is_object($previousValue) || $currentValue->_isDirty() || $previousValue::class !== $currentValue::class; + } else { + // For all other objects we do only a simple comparison (!=) as we want cloned objects to return the same values. + $result = $previousValue != $currentValue; + } + } else { + $result = $previousValue !== $currentValue; + } + return $result; + } + + public function _isClone(): bool + { + return $this->_isClone; + } + + /** + * Setter whether this Domain Object is a clone of another one. + * NEVER SET THIS PROPERTY DIRECTLY. We currently need it to make the + * _isDirty check inside AbstractEntity work, but it is just a work- + * around right now. + * + * @internal + */ + public function _setClone(bool $clone) + { + $this->_isClone = $clone; + } + + public function __clone(): void + { + $this->_isClone = true; + } + + /** + * @return non-empty-string + */ + public function __toString(): string + { + return static::class . ':' . $this->uid; + } +} diff --git a/Classes/DomainObject/AbstractEntity.php b/Classes/DomainObject/AbstractEntity.php new file mode 100644 index 0000000..264ce87 --- /dev/null +++ b/Classes/DomainObject/AbstractEntity.php @@ -0,0 +1,22 @@ +__toString(); + } +} diff --git a/Classes/DomainObject/DomainObjectInterface.php b/Classes/DomainObject/DomainObjectInterface.php new file mode 100644 index 0000000..ce4910f --- /dev/null +++ b/Classes/DomainObject/DomainObjectInterface.php @@ -0,0 +1,84 @@ + + * + * @internal + */ + public function _getProperties(): array; + + /** + * Returns the clean value of the given property. The returned value will be NULL if the clean state was not memorized before, or + * if the clean value is NULL. + * + * @param non-empty-string $propertyName + * @return mixed The clean property value or NULL + * + * @internal + */ + public function _getCleanProperty(string $propertyName): mixed; +} diff --git a/Classes/Error/Error.php b/Classes/Error/Error.php new file mode 100644 index 0000000..767202e --- /dev/null +++ b/Classes/Error/Error.php @@ -0,0 +1,30 @@ +message = $message; + $this->code = $code; + $this->arguments = $arguments; + $this->title = $title; + } + + /** + * Returns the error message + * + * @return string The error message + */ + public function getMessage(): string + { + return $this->message; + } + + /** + * Returns the error code + * + * @return int The error code + */ + public function getCode(): int + { + return $this->code; + } + + /** + * Get arguments + */ + public function getArguments(): array + { + return $this->arguments; + } + + /** + * Get title + */ + public function getTitle(): string + { + return $this->title; + } + + /** + * Return the rendered message + */ + public function render(): string + { + if (count($this->arguments) > 0) { + return vsprintf($this->message, $this->arguments); + } + return $this->message; + } + + /** + * Converts this error into a string + * + * @return string + */ + public function __toString() + { + return $this->render(); + } +} diff --git a/Classes/Error/Notice.php b/Classes/Error/Notice.php new file mode 100644 index 0000000..fb2fe9d --- /dev/null +++ b/Classes/Error/Notice.php @@ -0,0 +1,30 @@ +parent !== $parent) { + $this->parent = $parent; + if ($this->hasErrors()) { + $parent->setErrorsExist(); + } + if ($this->hasWarnings()) { + $parent->setWarningsExist(); + } + if ($this->hasNotices()) { + $parent->setNoticesExist(); + } + } + } + + /** + * Add an error to the current Result object + */ + public function addError(Error $error): void + { + $this->errors[] = $error; + $this->setErrorsExist(); + } + + /** + * Add a warning to the current Result object + */ + public function addWarning(Warning $warning): void + { + $this->warnings[] = $warning; + $this->setWarningsExist(); + } + + /** + * Add a notice to the current Result object + */ + public function addNotice(Notice $notice): void + { + $this->notices[] = $notice; + $this->setNoticesExist(); + } + + /** + * Get all errors in the current Result object (non-recursive) + * + * @return Error[] + */ + public function getErrors(): array + { + return $this->errors; + } + + /** + * Get all warnings in the current Result object (non-recursive) + * + * @return Warning[] + */ + public function getWarnings(): array + { + return $this->warnings; + } + + /** + * Get all notices in the current Result object (non-recursive) + * + * @return Notice[] + */ + public function getNotices(): array + { + return $this->notices; + } + + /** + * Get the first error object of the current Result object (non-recursive) + * + * @return bool|Error + */ + public function getFirstError() + { + reset($this->errors); + return current($this->errors); + } + + /** + * Get the first warning object of the current Result object (non-recursive) + * + * @return bool|Warning + */ + public function getFirstWarning() + { + reset($this->warnings); + return current($this->warnings); + } + + /** + * Get the first notice object of the current Result object (non-recursive) + * + * @return bool|Notice + */ + public function getFirstNotice() + { + reset($this->notices); + return current($this->notices); + } + + /** + * Return a Result object for the given property path. This is + * a fluent interface, so you will probably use it like: + * $result->forProperty('foo.bar')->getErrors() -- to get all errors + * for property "foo.bar" + */ + public function forProperty(?string $propertyPath): Result + { + if ($propertyPath === '' || $propertyPath === null) { + return $this; + } + if (str_contains($propertyPath, '.')) { + return $this->recurseThroughResult(explode('.', $propertyPath)); + } + if (!isset($this->propertyResults[$propertyPath])) { + $this->propertyResults[$propertyPath] = new self(); + $this->propertyResults[$propertyPath]->setParent($this); + } + return $this->propertyResults[$propertyPath]; + } + + /** + * @todo: consider making this method protected as it will and should not be called from an outside scope + * + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ + public function recurseThroughResult(array $pathSegments): Result + { + if (count($pathSegments) === 0) { + return $this; + } + + $propertyName = array_shift($pathSegments); + + if (!isset($this->propertyResults[$propertyName])) { + $this->propertyResults[$propertyName] = new self(); + $this->propertyResults[$propertyName]->setParent($this); + } + + return $this->propertyResults[$propertyName]->recurseThroughResult($pathSegments); + } + + /** + * Sets the error cache to TRUE and propagates the information + * upwards the Result-Object Tree + */ + protected function setErrorsExist(): void + { + $this->errorsExist = true; + if ($this->parent !== null) { + $this->parent->setErrorsExist(); + } + } + + /** + * Sets the warning cache to TRUE and propagates the information + * upwards the Result-Object Tree + */ + protected function setWarningsExist(): void + { + $this->warningsExist = true; + if ($this->parent !== null) { + $this->parent->setWarningsExist(); + } + } + + /** + * Sets the notices cache to TRUE and propagates the information + * upwards the Result-Object Tree + */ + protected function setNoticesExist(): void + { + $this->noticesExist = true; + if ($this->parent !== null) { + $this->parent->setNoticesExist(); + } + } + + /** + * Does the current Result object have Notices, Errors or Warnings? (Recursively) + */ + public function hasMessages(): bool + { + return $this->errorsExist || $this->noticesExist || $this->warningsExist; + } + + /** + * Clears the result + */ + public function clear(): void + { + $this->errors = []; + $this->notices = []; + $this->warnings = []; + + $this->warningsExist = false; + $this->noticesExist = false; + $this->errorsExist = false; + + $this->propertyResults = []; + } + + /** + * Does the current Result object have Errors? (Recursively) + */ + public function hasErrors(): bool + { + if (count($this->errors) > 0) { + return true; + } + + foreach ($this->propertyResults as $subResult) { + if ($subResult->hasErrors()) { + return true; + } + } + + return false; + } + + /** + * Does the current Result object have Warnings? (Recursively) + */ + public function hasWarnings(): bool + { + if (count($this->warnings) > 0) { + return true; + } + + foreach ($this->propertyResults as $subResult) { + if ($subResult->hasWarnings()) { + return true; + } + } + + return false; + } + + /** + * Does the current Result object have Notices? (Recursively) + */ + public function hasNotices(): bool + { + if (count($this->notices) > 0) { + return true; + } + + foreach ($this->propertyResults as $subResult) { + if ($subResult->hasNotices()) { + return true; + } + } + + return false; + } + + /** + * Get a list of all Error objects recursively. The result is an array, + * where the key is the property path where the error occurred, and the + * value is a list of all errors (stored as array) + * + * @return array> + */ + public function getFlattenedErrors(): array + { + $result = []; + $this->flattenErrorTree($result, []); + return $result; + } + + /** + * Get a list of all Warning objects recursively. The result is an array, + * where the key is the property path where the warning occurred, and the + * value is a list of all warnings (stored as array) + * + * @return array> + */ + public function getFlattenedWarnings(): array + { + $result = []; + $this->flattenWarningsTree($result, []); + return $result; + } + + /** + * Get a list of all Notice objects recursively. The result is an array, + * where the key is the property path where the notice occurred, and the + * value is a list of all notices (stored as array) + * + * @return array> + */ + public function getFlattenedNotices(): array + { + $result = []; + $this->flattenNoticesTree($result, []); + return $result; + } + + protected function flattenErrorTree(array &$result, array $level): void + { + if (count($this->errors) > 0) { + $result[implode('.', $level)] = $this->errors; + } + foreach ($this->propertyResults as $subPropertyName => $subResult) { + $level[] = $subPropertyName; + $subResult->flattenErrorTree($result, $level); + array_pop($level); + } + } + + protected function flattenWarningsTree(array &$result, array $level): void + { + if (count($this->warnings) > 0) { + $result[implode('.', $level)] = $this->warnings; + } + foreach ($this->propertyResults as $subPropertyName => $subResult) { + $level[] = $subPropertyName; + $subResult->flattenWarningsTree($result, $level); + array_pop($level); + } + } + + protected function flattenNoticesTree(array &$result, array $level): void + { + if (count($this->notices) > 0) { + $result[implode('.', $level)] = $this->notices; + } + foreach ($this->propertyResults as $subPropertyName => $subResult) { + $level[] = $subPropertyName; + $subResult->flattenNoticesTree($result, $level); + array_pop($level); + } + } + + /** + * Merge the given Result object into this one. + */ + public function merge(Result $otherResult): void + { + if ($otherResult->errorsExist) { + $this->mergeProperty($otherResult, 'getErrors', 'addError'); + } + if ($otherResult->warningsExist) { + $this->mergeProperty($otherResult, 'getWarnings', 'addWarning'); + } + if ($otherResult->noticesExist) { + $this->mergeProperty($otherResult, 'getNotices', 'addNotice'); + } + + foreach ($otherResult->getSubResults() as $subPropertyName => $subResult) { + /** @var Result $subResult */ + if (array_key_exists($subPropertyName, $this->propertyResults) && $this->propertyResults[$subPropertyName]->hasMessages()) { + $this->forProperty((string)$subPropertyName)->merge($subResult); + } else { + $this->propertyResults[$subPropertyName] = $subResult; + $subResult->setParent($this); + } + } + } + + /** + * Merge a single property from the other result object. + */ + protected function mergeProperty(Result $otherResult, string $getterName, string $adderName): void + { + $getter = [$otherResult, $getterName]; + $adder = [$this, $adderName]; + + if (!is_callable($getter) || !is_callable($adder)) { + return; + } + + foreach ($getter() as $messageInOtherResult) { + $adder($messageInOtherResult); + } + } + + /** + * Get a list of all sub Result objects available. + * + * @return Result[] + */ + public function getSubResults(): array + { + return $this->propertyResults; + } +} diff --git a/Classes/Error/Warning.php b/Classes/Error/Warning.php new file mode 100644 index 0000000..c663793 --- /dev/null +++ b/Classes/Error/Warning.php @@ -0,0 +1,30 @@ +frameworkConfiguration; + } + + public function getOriginalFlexFormConfiguration(): array + { + return $this->originalFlexFormConfiguration; + } + + public function getFlexFormConfiguration(): array + { + return $this->flexFormConfiguration; + } + + public function setFlexFormConfiguration(array $flexFormConfiguration): void + { + $this->flexFormConfiguration = $flexFormConfiguration; + } +} diff --git a/Classes/Event/Mvc/AfterRequestDispatchedEvent.php b/Classes/Event/Mvc/AfterRequestDispatchedEvent.php new file mode 100644 index 0000000..ea63506 --- /dev/null +++ b/Classes/Event/Mvc/AfterRequestDispatchedEvent.php @@ -0,0 +1,42 @@ +request; + } + + public function getResponse(): ResponseInterface + { + return $this->response; + } +} diff --git a/Classes/Event/Mvc/BeforeActionAuthorizationDeniedEvent.php b/Classes/Event/Mvc/BeforeActionAuthorizationDeniedEvent.php new file mode 100644 index 0000000..7af997f --- /dev/null +++ b/Classes/Event/Mvc/BeforeActionAuthorizationDeniedEvent.php @@ -0,0 +1,80 @@ +request; + } + + public function getControllerClassName(): string + { + return $this->controllerClassName; + } + + public function getActionMethodName(): string + { + return $this->actionMethodName; + } + + public function getAuthorize(): Authorize + { + return $this->authorize; + } + + public function getFailureReason(): AuthorizationFailureReason + { + return $this->failureReason; + } + + public function getResponse(): ?ResponseInterface + { + return $this->response; + } + + public function setResponse(ResponseInterface $response): void + { + $this->response = $response; + } +} diff --git a/Classes/Event/Mvc/BeforeActionCallEvent.php b/Classes/Event/Mvc/BeforeActionCallEvent.php new file mode 100644 index 0000000..0c92e35 --- /dev/null +++ b/Classes/Event/Mvc/BeforeActionCallEvent.php @@ -0,0 +1,54 @@ +controllerClassName; + } + + public function getActionMethodName(): string + { + return $this->actionMethodName; + } + + public function getPreparedArguments(): array + { + return $this->preparedArguments; + } + + public function getRequest(): ServerRequestInterface + { + return $this->request; + } +} diff --git a/Classes/Event/Mvc/BeforeActionRateLimitResponseEvent.php b/Classes/Event/Mvc/BeforeActionRateLimitResponseEvent.php new file mode 100644 index 0000000..fe731d8 --- /dev/null +++ b/Classes/Event/Mvc/BeforeActionRateLimitResponseEvent.php @@ -0,0 +1,67 @@ +request; + } + + public function getControllerClassName(): string + { + return $this->controllerClassName; + } + + public function getActionMethodName(): string + { + return $this->actionMethodName; + } + + public function getRateLimit(): RateLimit + { + return $this->rateLimit; + } + + public function getResponse(): ResponseInterface + { + return $this->response; + } + + public function setResponse(ResponseInterface $response): void + { + $this->response = $response; + } +} diff --git a/Classes/Event/Persistence/AfterObjectThawedEvent.php b/Classes/Event/Persistence/AfterObjectThawedEvent.php new file mode 100644 index 0000000..c55bb25 --- /dev/null +++ b/Classes/Event/Persistence/AfterObjectThawedEvent.php @@ -0,0 +1,38 @@ +mappedObject; + } + + public function getRecord(): array + { + return $this->record; + } +} diff --git a/Classes/Event/Persistence/EntityAddedToPersistenceEvent.php b/Classes/Event/Persistence/EntityAddedToPersistenceEvent.php new file mode 100644 index 0000000..024a587 --- /dev/null +++ b/Classes/Event/Persistence/EntityAddedToPersistenceEvent.php @@ -0,0 +1,34 @@ +persistedObject; + } +} diff --git a/Classes/Event/Persistence/EntityFinalizedAfterPersistenceEvent.php b/Classes/Event/Persistence/EntityFinalizedAfterPersistenceEvent.php new file mode 100644 index 0000000..fb3603d --- /dev/null +++ b/Classes/Event/Persistence/EntityFinalizedAfterPersistenceEvent.php @@ -0,0 +1,34 @@ +persistedObject; + } +} diff --git a/Classes/Event/Persistence/EntityPersistedEvent.php b/Classes/Event/Persistence/EntityPersistedEvent.php new file mode 100644 index 0000000..e5c210e --- /dev/null +++ b/Classes/Event/Persistence/EntityPersistedEvent.php @@ -0,0 +1,33 @@ +persistedObject; + } +} diff --git a/Classes/Event/Persistence/EntityRemovedFromPersistenceEvent.php b/Classes/Event/Persistence/EntityRemovedFromPersistenceEvent.php new file mode 100644 index 0000000..bfb29b9 --- /dev/null +++ b/Classes/Event/Persistence/EntityRemovedFromPersistenceEvent.php @@ -0,0 +1,33 @@ +persistedObject; + } +} diff --git a/Classes/Event/Persistence/EntityUpdatedInPersistenceEvent.php b/Classes/Event/Persistence/EntityUpdatedInPersistenceEvent.php new file mode 100644 index 0000000..bd27764 --- /dev/null +++ b/Classes/Event/Persistence/EntityUpdatedInPersistenceEvent.php @@ -0,0 +1,33 @@ +persistedObject; + } +} diff --git a/Classes/Event/Persistence/ModifyQueryBeforeFetchingObjectCountEvent.php b/Classes/Event/Persistence/ModifyQueryBeforeFetchingObjectCountEvent.php new file mode 100644 index 0000000..8302394 --- /dev/null +++ b/Classes/Event/Persistence/ModifyQueryBeforeFetchingObjectCountEvent.php @@ -0,0 +1,38 @@ +query; + } + + public function setQuery(QueryInterface $query): void + { + $this->query = $query; + } +} diff --git a/Classes/Event/Persistence/ModifyQueryBeforeFetchingObjectDataEvent.php b/Classes/Event/Persistence/ModifyQueryBeforeFetchingObjectDataEvent.php new file mode 100644 index 0000000..85ca88f --- /dev/null +++ b/Classes/Event/Persistence/ModifyQueryBeforeFetchingObjectDataEvent.php @@ -0,0 +1,38 @@ +query; + } + + public function setQuery(QueryInterface $query): void + { + $this->query = $query; + } +} diff --git a/Classes/Event/Persistence/ModifyResultAfterFetchingObjectCountEvent.php b/Classes/Event/Persistence/ModifyResultAfterFetchingObjectCountEvent.php new file mode 100644 index 0000000..13d005f --- /dev/null +++ b/Classes/Event/Persistence/ModifyResultAfterFetchingObjectCountEvent.php @@ -0,0 +1,43 @@ +query; + } + + public function getResult(): int + { + return $this->result; + } + + public function setResult(int $result): void + { + $this->result = $result; + } +} diff --git a/Classes/Event/Persistence/ModifyResultAfterFetchingObjectDataEvent.php b/Classes/Event/Persistence/ModifyResultAfterFetchingObjectDataEvent.php new file mode 100644 index 0000000..9e608ce --- /dev/null +++ b/Classes/Event/Persistence/ModifyResultAfterFetchingObjectDataEvent.php @@ -0,0 +1,43 @@ +query; + } + + public function getResult(): array + { + return $this->result; + } + + public function setResult(array $result): void + { + $this->result = $result; + } +} diff --git a/Classes/Event/Service/ModifyUploadedFileTargetFilenameEvent.php b/Classes/Event/Service/ModifyUploadedFileTargetFilenameEvent.php new file mode 100644 index 0000000..f3515bc --- /dev/null +++ b/Classes/Event/Service/ModifyUploadedFileTargetFilenameEvent.php @@ -0,0 +1,47 @@ +targetFilename; + } + + public function setTargetFilename(string $targetFilename): void + { + $this->targetFilename = $targetFilename; + } + + public function getConfiguration(): FileUploadConfiguration + { + return $this->configuration; + } +} diff --git a/Classes/EventListener/AddDefaultModuleIcon.php b/Classes/EventListener/AddDefaultModuleIcon.php new file mode 100644 index 0000000..e905d3a --- /dev/null +++ b/Classes/EventListener/AddDefaultModuleIcon.php @@ -0,0 +1,41 @@ +hasConfigurationValue('controllerActions') + || $event->getConfigurationValue('icon') + || $event->getConfigurationValue('iconIdentifier') + ) { + // Either no extbase module or icon / iconIdentifier is already set + return; + } + + $event->setConfigurationValue('icon', 'EXT:extbase/Resources/Public/Icons/Extension.svg'); + } +} diff --git a/Classes/EventListener/ExtbaseHistoryTracker.php b/Classes/EventListener/ExtbaseHistoryTracker.php new file mode 100644 index 0000000..5ce15c9 --- /dev/null +++ b/Classes/EventListener/ExtbaseHistoryTracker.php @@ -0,0 +1,208 @@ +trackEntityHistory($event, RecordHistoryStore::ACTION_ADD); + } + + #[AsEventListener('extbase-history-tracker-updated')] + public function onEntityUpdated(EntityUpdatedInPersistenceEvent $event): void + { + $this->trackEntityHistory($event, RecordHistoryStore::ACTION_MODIFY); + } + + #[AsEventListener('extbase-history-tracker-removed')] + public function onEntityRemoved(EntityRemovedFromPersistenceEvent $event): void + { + $this->trackEntityHistory($event, RecordHistoryStore::ACTION_DELETE); + } + + private function trackEntityHistory( + EntityAddedToPersistenceEvent|EntityUpdatedInPersistenceEvent|EntityRemovedFromPersistenceEvent $event, + int $action + ): void { + // Skip history tracking if feature flag is disabled. TCA does not matter in this case. + if (!$this->features->isFeatureEnabled('extbase.enableHistoryTracking')) { + return; + } + + $object = $event->getObject(); + + // Skip if object doesn't have a UID (not persisted yet) + if ($object->getUid() === null) { + return; + } + + $dataMap = $this->dataMapFactory->buildDataMap($object::class); + $tableName = $dataMap->getTableName(); + + // Skip if table doesn't exist in TCA schema + try { + $schema = $this->tcaSchemaFactory->get($tableName); + } catch (UndefinedSchemaException) { + // Table not found in TCA schema, skip history tracking + return; + } + + // Skip history tracking if TCA ctrl setting is disabled (defaults to "enabled") + if (!$schema->hasCapability(TcaSchemaCapability::ExtbaseHistoryTracking)) { + return; + } + + $historyStore = $this->createHistoryStore(); + + match ($action) { + RecordHistoryStore::ACTION_ADD => $historyStore->addRecord( + $tableName, + $object->getUid(), + $this->extractObjectData($object, $dataMap) + ), + RecordHistoryStore::ACTION_MODIFY => $historyStore->modifyRecord( + $tableName, + $object->getUid(), + [ + 'oldRecord' => $this->extractObjectData($object, $dataMap, false, true), + 'newRecord' => $this->extractObjectData($object, $dataMap, false), + '_pid' => $object->getPid(), + '_extbase_class' => $object::class, + ], + ), + RecordHistoryStore::ACTION_DELETE => $historyStore->deleteRecord( + $tableName, + $object->getUid() + ), + default => throw new \InvalidArgumentException( + sprintf('Unsupported history action: %d', $action), + 1774123762 + ), + }; + } + + private function createHistoryStore(): RecordHistoryStore + { + /** @var DateTimeAspect $dateTimeAspect */ + $dateTimeAspect = $this->context->getAspect('date'); + $currentTimestamp = $dateTimeAspect->get('timestamp'); + + $userAspect = $this->context->getAspect('frontend.user'); + + if ($userAspect->isLoggedIn()) { + return new RecordHistoryStore( + RecordHistoryStore::USER_FRONTEND, + $userAspect->get('id'), + null, + $currentTimestamp + ); + } + + // Check for backend user context + $backendUserAspect = $this->context->getAspect('backend.user'); + if ($backendUserAspect->isLoggedIn()) { + return new RecordHistoryStore( + RecordHistoryStore::USER_BACKEND, + $backendUserAspect->get('id'), + null, + $currentTimestamp + ); + } + + // Anonymous user + return new RecordHistoryStore( + RecordHistoryStore::USER_ANONYMOUS, + null, + null, + $currentTimestamp + ); + } + + private function extractObjectData(DomainObjectInterface $object, DataMap $dataMap, bool $appendMetadata = true, bool $fetchPropertiesBeforePersistence = false): array + { + $data = []; + if ($fetchPropertiesBeforePersistence && $object instanceof AbstractDomainObject) { + $properties = $object->_getCleanProperties(); + } else { + $properties = $object->_getProperties(); + } + + foreach ($properties as $propertyName => $propertyValue) { + // Get actual database column name: + $columnMap = $dataMap->getColumnMap($propertyName); + if ($columnMap !== null) { + $propertyName = $columnMap->columnName; + } else { + $propertyName = GeneralUtility::camelCaseToLowerCaseUnderscored($propertyName); + } + + // Convert objects and complex types to string representation + if (is_object($propertyValue)) { + if ($propertyValue instanceof DomainObjectInterface) { + $data[$propertyName] = $propertyValue->getUid(); + } elseif (method_exists($propertyValue, '__toString')) { + $data[$propertyName] = (string)$propertyValue; + } else { + $data[$propertyName] = get_class($propertyValue); + } + } elseif (is_array($propertyValue)) { + $data[$propertyName] = json_encode($propertyValue); + } else { + $data[$propertyName] = $propertyValue; + } + } + + // Add metadata + if ($appendMetadata) { + $data['_extbase_class'] = $object::class; + $data['_pid'] = $object->getPid(); + } + + return $data; + } +} diff --git a/Classes/Exception.php b/Classes/Exception.php new file mode 100644 index 0000000..ed386b9 --- /dev/null +++ b/Classes/Exception.php @@ -0,0 +1,25 @@ + + */ + private array $flashMessages = []; + + public function __construct(private readonly string $actionName) + { + $this->argumentsValidationResult = new Result(); + parent::__construct('php://temp', 204); + } + + public function withControllerName(string $controllerName): self + { + $clone = clone $this; + $clone->controllerName = $controllerName; + return $clone; + } + + public function withoutControllerName(): self + { + $clone = clone $this; + $clone->controllerName = null; + return $clone; + } + + public function withExtensionName(string $extensionName): self + { + $clone = clone $this; + $clone->extensionName = $extensionName; + return $clone; + } + + public function withoutExtensionName(): self + { + $clone = clone $this; + $this->extensionName = null; + return $clone; + } + + public function withArguments(array $arguments): self + { + $clone = clone $this; + $clone->arguments = $arguments; + return $clone; + } + + public function withoutArguments(): self + { + $clone = clone $this; + $this->arguments = null; + return $clone; + } + + public function withArgumentsValidationResult(Result $argumentsValidationResult): self + { + $clone = clone $this; + $clone->argumentsValidationResult = $argumentsValidationResult; + return $clone; + } + + public function withFlashMessages(FlashMessage ...$flashMessages): self + { + if ($flashMessages === []) { + return $this; + } + $clone = clone $this; + $clone->flashMessages = array_merge($this->flashMessages, $flashMessages); + return $clone; + } + + public function getActionName(): string + { + return $this->actionName; + } + + public function getControllerName(): ?string + { + return $this->controllerName; + } + + public function getExtensionName(): ?string + { + return $this->extensionName; + } + + public function getArguments(): ?array + { + return $this->arguments; + } + + public function getArgumentsValidationResult(): Result + { + return $this->argumentsValidationResult; + } + + /** + * @return list + */ + public function getFlashMessages(): array + { + return $this->flashMessages; + } +} diff --git a/Classes/Mvc/Controller/ActionController.php b/Classes/Mvc/Controller/ActionController.php new file mode 100644 index 0000000..fa1cce4 --- /dev/null +++ b/Classes/Mvc/Controller/ActionController.php @@ -0,0 +1,967 @@ +responseFactory = $responseFactory; + } + + final public function injectStreamFactory(StreamFactoryInterface $streamFactory): void + { + $this->streamFactory = $streamFactory; + } + + /** + * @internal + */ + public function injectConfigurationManager(ConfigurationManagerInterface $configurationManager): void + { + $this->configurationManager = $configurationManager; + $this->settings = $this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_SETTINGS); + $this->arguments = GeneralUtility::makeInstance(Arguments::class); + } + + /** + * @internal + */ + public function injectValidatorResolver(ValidatorResolver $validatorResolver): void + { + $this->validatorResolver = $validatorResolver; + } + + final public function injectViewFactory(ViewFactoryInterface $viewFactory): void + { + $this->viewFactory = $viewFactory; + } + + /** + * @internal + */ + public function injectReflectionService(ReflectionService $reflectionService): void + { + $this->reflectionService = $reflectionService; + } + + /** + * @internal + */ + public function injectHashService(HashService $hashService): void + { + $this->hashService = $hashService; + } + + public function injectMvcPropertyMappingConfigurationService(MvcPropertyMappingConfigurationService $mvcPropertyMappingConfigurationService): void + { + $this->mvcPropertyMappingConfigurationService = $mvcPropertyMappingConfigurationService; + } + + public function injectEventDispatcher(EventDispatcherInterface $eventDispatcher): void + { + $this->eventDispatcher = $eventDispatcher; + } + + public function injectFileHandlingService(FileHandlingService $fileHandlingService): void + { + $this->fileHandlingService = $fileHandlingService; + } + + public function injectRateLimitRegistry(RateLimitRegistry $rateLimitRegistry): void + { + $this->rateLimitRegistry = $rateLimitRegistry; + } + + public function injectAuthorizeRegistry(AuthorizeRegistry $authorizeRegistry): void + { + $this->authorizeRegistry = $authorizeRegistry; + } + + /** + * @internal + */ + public function injectPropertyMapper(PropertyMapper $propertyMapper): void + { + $this->propertyMapper = $propertyMapper; + } + + /** + * @internal + */ + final public function injectInternalFlashMessageService(FlashMessageService $flashMessageService): void + { + $this->internalFlashMessageService = $flashMessageService; + } + + /** + * @internal + */ + final public function injectInternalExtensionService(ExtensionService $extensionService): void + { + $this->internalExtensionService = $extensionService; + } + + /** + * Initializes the controller before invoking an action method. + * + * Override this method to solve tasks which all actions have in + * common. + */ + protected function initializeAction(): void {} + + /** + * Implementation of the arguments initialization in the action controller: + * Automatically registers arguments of the current action + * + * Don't override this method - use initializeAction() instead. + * + * @throws InvalidArgumentTypeException + * @see initializeArguments() + * + * @internal + */ + protected function initializeActionMethodArguments(): void + { + $methodParameters = $this->reflectionService + ->getClassSchema(static::class) + ->getMethod($this->actionMethodName)->getParameters(); + + foreach ($methodParameters as $parameterName => $parameter) { + $dataType = null; + if ($parameter->getType() !== null) { + $dataType = $parameter->getType(); + } elseif ($parameter->isArray()) { + $dataType = 'array'; + } + if ($dataType === null) { + throw new InvalidArgumentTypeException('The argument type for parameter $' . $parameterName . ' of method ' . static::class . '->' . $this->actionMethodName . '() could not be detected.', 1253175643); + } + $defaultValue = $parameter->hasDefaultValue() ? $parameter->getDefaultValue() : null; + $this->arguments->addNewArgument($parameterName, $dataType, !$parameter->isOptional(), $defaultValue); + } + } + + /** + * Adds the needed validators to the Arguments: + * + * - Validators checking the data type from the param annotation + * - Custom validators specified with #[Validate] attributes. + * - Model-based validators (#[Validate] attributes in the model) + * - Custom model validator classes + * + * @internal + */ + protected function initializeActionMethodValidators(): void + { + if ($this->arguments->count() === 0) { + return; + } + + $classSchemaMethod = $this->reflectionService->getClassSchema(static::class)->getMethod($this->actionMethodName); + + /** @var Argument $argument */ + foreach ($this->arguments as $argument) { + $classSchemaMethodParameter = $classSchemaMethod->getParameter($argument->getName()); + // At this point validation is skipped if there is an #[IgnoreValidation] attribute. + // @todo: IgnoreValidation attributes could be evaluated in the ClassSchema and result in + // no validators being applied to the method parameter. + if ($classSchemaMethodParameter->ignoreValidation()) { + continue; + } + /** @var ConjunctionValidator $validator */ + $validator = $this->validatorResolver->createValidator(ConjunctionValidator::class); + foreach ($classSchemaMethodParameter->getValidators() as $validatorDefinition) { + if (isset($validatorDefinition['constraint'])) { + $validatorInstance = $validatorDefinition['constraint']; + } else { + $validatorInstance = $this->validatorResolver->createValidator( + $validatorDefinition['className'], + $validatorDefinition['options'], + $this->request, + ); + } + if ($validatorInstance !== null) { + $validator->addValidator($validatorInstance); + } + } + $baseValidatorConjunction = $this->validatorResolver->getBaseValidatorConjunction( + $argument->getDataType(), + $this->request + ); + if ($baseValidatorConjunction->count() > 0) { + $validator->addValidator($baseValidatorConjunction); + } + $argument->setValidator($validator); + } + } + + protected function initializeStateFromExtbaseRequestParameters(): void + { + $extbaseRequestParameters = $this->request->getAttribute('extbase'); + if (!$extbaseRequestParameters instanceof ExtbaseRequestParameters) { + return; + } + $flashMessageQueue = $this->getFlashMessageQueue(); + foreach ($extbaseRequestParameters->getOriginalFlashMessages() as $flashMessage) { + $flashMessage->setStoreInSession(false); + $flashMessageQueue->enqueue($flashMessage); + } + } + + /** + * Handles an incoming request and returns a response object + * + * @internal + */ + public function processRequest(RequestInterface $request): ResponseInterface + { + /** @var Request $request */ + $this->request = $request; + $this->uriBuilder = GeneralUtility::makeInstance(UriBuilder::class); + $this->uriBuilder->setRequest($request); + $this->actionMethodName = $this->resolveActionMethodName(); + $this->initializeActionMethodArguments(); + $this->initializeActionMethodValidators(); + $this->initializeStateFromExtbaseRequestParameters(); + $this->mvcPropertyMappingConfigurationService->initializePropertyMappingConfigurationFromRequest($request, $this->arguments); + $this->fileHandlingService->initializeFileUploadConfigurationsFromRequest($request, $this->arguments); + $this->initializeAction(); + $actionInitializationMethodName = 'initialize' . ucfirst($this->actionMethodName); + /** @var callable|null $callable */ + $callable = [$this, $actionInitializationMethodName]; + if (is_callable($callable)) { + $callable(); + } + $this->mapRequestArgumentsToControllerArguments(); + $this->view = $this->resolveView(); + if (method_exists($this, 'initializeView')) { + // @todo: We may want to get rid of this and declare actions should actively create own + // views using ViewFactoryInterface instead. See comment on resolveView() below. + // Currently, this method is pretty much only helpful in 'xclass' scenarios, + // since actions can already do whatever happens here within their action body. + $this->initializeView($this->view); + } + $response = $this->callActionMethod($request); + return $response; + } + + /** + * Resolves and checks the current action method name + * + * @throws NoSuchActionException if the action specified in the request object does not exist (and if there's no default action either). + * + * @internal + */ + protected function resolveActionMethodName(): string + { + $actionMethodName = $this->request->getControllerActionName() . 'Action'; + if (!method_exists($this, $actionMethodName)) { + throw new NoSuchActionException('An action "' . $actionMethodName . '" does not exist in controller "' . static::class . '".', 1186669086); + } + return $actionMethodName; + } + + /** + * Calls the specified action method and passes the arguments. + * + * If the action returns a string, it is appended to the content in the + * response object. If the action doesn't return anything and a valid + * view exists, the view is rendered automatically. + * + * @internal + */ + protected function callActionMethod(RequestInterface $request): ResponseInterface + { + // incoming request is not needed yet but can be passed into the action in the future like in symfony + // todo: support this via method-reflection + + $this->fileHandlingService->initializeFileUploadDeletionConfigurationsFromRequest($request, $this->arguments); + $validationResult = $this->arguments->validate(); + if (!$validationResult->hasErrors()) { + $preparedArguments = []; + /** @var Argument $argument */ + foreach ($this->arguments as $argument) { + $this->fileHandlingService->applyDeletionsToArgument($argument); + $this->fileHandlingService->mapUploadedFilesToArgument($argument); + $preparedArguments[] = $argument->getValue(); + } + + if (($authorizeResponse = $this->performAuthorizationChecks($request, $preparedArguments)) !== null) { + return $authorizeResponse; + } + + if (($rateLimitResponse = $this->handleRateLimit($request)) !== null) { + return $rateLimitResponse; + } + + $this->eventDispatcher->dispatch(new BeforeActionCallEvent(static::class, $this->actionMethodName, $preparedArguments, $this->request)); + $actionResult = $this->{$this->actionMethodName}(...$preparedArguments); + } else { + $actionResult = $this->{$this->errorMethodName}(); + } + + if ($actionResult instanceof ResponseInterface) { + return $actionResult; + } + throw new \RuntimeException( + sprintf( + 'Controller action %s did not return an instance of %s.', + static::class . '::' . $this->actionMethodName, + ResponseInterface::class + ), + 1638554283 + ); + } + + /** + * Prepares a view for the current action. + * + * @internal + * @todo We may want to decide in extbase to go away from the automatic view preparation via + * processRequest() and this method for actions. We could very well postulate actions + * should take care of creating "their" view on their own using a ViewFactoryInterface + * implementation, similar to what is done with request creation already (which needs + * further work, too), and have a helper in this class to easily create a standard view. + * This would dissolve the ugly $this->defaultViewObjectName property, which is more + * a burden than helpful since controllers then need to have an initializeFooAction() + * just to set this property when different actions want different views. Also, it does + * not allow actions to have no view prepared at all, for instance when they just want to + * create a json response by json_encode()'ing stuff. We should look at this in v14, which + * renders property defaultViewObjectName even more useless. + */ + protected function resolveView(): ViewInterface + { + if ($this->defaultViewObjectName !== null && is_a($this->defaultViewObjectName, JsonView::class, true)) { + // @todo: JsonView is a very extbase specific thing. It comes with setVariablesToRender() and + // setConfiguration(). We don't let it run through a factory here, since consumers need + // to deal with these specialities anyways. Often, one would rather want to either have + // an own view prepared in a controller (or action), or have a custom factory that deals + // with stuff and returns a ViewInterface, or directly json_encode() data in an action. + // This is related to the comment above, too. + $view = new JsonView(); + $view->assign('settings', $this->settings); + return $view; + } + $configuration = $this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FRAMEWORK); + $extensionKey = $this->request->getControllerExtensionKey(); + $templateRootPaths = $this->addDefaultPathToPaths($configuration['view']['templateRootPaths'] ?? [], 'EXT:' . $extensionKey . '/Resources/Private/Templates/'); + $layoutRootPaths = $this->addDefaultPathToPaths($configuration['view']['layoutRootPaths'] ?? [], 'EXT:' . $extensionKey . '/Resources/Private/Layouts/'); + $partialRootPaths = $this->addDefaultPathToPaths($configuration['view']['partialRootPaths'] ?? [], 'EXT:' . $extensionKey . '/Resources/Private/Partials/'); + if ($this->defaultViewObjectName === null) { + $viewFactoryData = new ViewFactoryData( + templateRootPaths: $templateRootPaths, + partialRootPaths: $partialRootPaths, + layoutRootPaths: $layoutRootPaths, + request: $this->request, + format: $this->request->getFormat(), + ); + $view = $this->viewFactory->create($viewFactoryData); + if ($view instanceof FluidViewAdapter) { + // This specific magic is tailored to Fluid. Ignore if we're not dealing with a fluid view here. + $renderingContext = $view->getRenderingContext(); + $renderingContext->setControllerName($this->request->getControllerName()); + $renderingContext->setControllerAction($this->request->getControllerActionName()); + } + $view->assign('settings', $this->settings); + return $view; + } + throw new \RuntimeException( + 'The only allowed values for $this->defaultViewObjectName are null or extbase JsonView::class.' + . ' Please create an own view in your action if that is not sufficient, or inject a different' + . ' ViewFactoryInterface', + 1729780151 + ); + } + + /** + * Adds extbase's default template path to the configured list of + * template paths. The default path is usually used as a fallback if + * no paths are specified or if the template cannot be found in any + * of the configured paths. However, if the default path is already + * present in the configured paths, the specified position takes + * precedence. This allows the default path to be "moved" within + * the list of paths via configuration. + * + * @return string[] + * @internal + */ + protected function addDefaultPathToPaths(mixed $paths, string $defaultPath): array + { + if (!is_array($paths) || empty($paths)) { + $paths = [$defaultPath]; + } else { + $paths = ArrayUtility::sortArrayWithIntegerKeys($paths); + if (!in_array($defaultPath, $paths)) { + $paths = array_merge([$defaultPath], $paths); + } + } + + return $paths; + } + + /** + * A special action which is called if the originally intended action could + * not be called, for example if the arguments were not valid. + * + * The default implementation sets a flash message, request errors and forwards back + * to the originating action. This is suitable for most actions dealing with form input. + */ + protected function errorAction(): ResponseInterface + { + if (($response = $this->forwardToReferringRequest()) !== null) { + if ($response instanceof ForwardResponse) { + // Add flash messages to queue + $this->addErrorFlashMessage(); + // Extract all pending flash messages out of th queue and ensure they + // are passed along the response but without invoking the session. + $flashMessages = $this->getFlashMessageQueue()->getAllMessagesAndFlush(); + $response = $response->withFlashMessages(...$flashMessages); + } + return $response->withStatus(400); + } + $response = $this->htmlResponse($this->getFlattenedValidationErrorMessage()); + return $response->withStatus(400); + } + + /** + * If an error occurred during this request, this adds a flash message describing the error to the flash + * message container. + * + * @internal + */ + protected function addErrorFlashMessage(): void + { + $errorFlashMessage = $this->getErrorFlashMessage(); + if (is_string($errorFlashMessage)) { + $this->addFlashMessage($errorFlashMessage, '', ContextualFeedbackSeverity::ERROR, false); + } + } + + /** + * A template method for displaying custom error flash messages, or to + * display no flash message at all on errors. Override this to customize + * the flash message in your action controller. + * + * Returns either the flash message or "false" if no flash message should be set + */ + protected function getErrorFlashMessage(): bool|string + { + return 'An error occurred while trying to call ' . static::class . '->' . $this->actionMethodName . '()'; + } + + /** + * If information on the request before the current request was sent, this method forwards back + * to the originating request. This effectively ends processing of the current request, so do not + * call this method before you have finished the necessary business logic! + * + * @internal + */ + protected function forwardToReferringRequest(): ?ResponseInterface + { + /** @var ExtbaseRequestParameters $extbaseRequestParameters */ + $extbaseRequestParameters = $this->request->getAttribute('extbase'); + $referringRequestArguments = $extbaseRequestParameters->getInternalArgument('__referrer') ?? null; + if (is_string($referringRequestArguments['@request'] ?? null)) { + $referrerArray = json_decode( + $this->hashService->validateAndStripHmac($referringRequestArguments['@request'], HashScope::ReferringRequest->prefix(), HashAlgo::SHA3_256), + true + ); + $arguments = []; + if (is_string($referringRequestArguments['arguments'] ?? null)) { + /* @phpstan-ignore unserialize.allowedClasses.insecure (Integrity check already happens via HMAC validation) */ + $arguments = unserialize( + base64_decode($this->hashService->validateAndStripHmac( + $referringRequestArguments['arguments'], + HashScope::ReferringArguments->prefix(), + HashAlgo::SHA3_256 + )), + ['allowed_classes' => true] + ); + } + $replacedArguments = array_replace_recursive($arguments, $referrerArray); + $nonExtbaseBaseArguments = []; + foreach ($replacedArguments as $argumentName => $argumentValue) { + if (!is_string($argumentName) || $argumentName === '') { + throw new InvalidArgumentNameException('Invalid argument name.', 1623940985); + } + if (str_starts_with($argumentName, '__') + || in_array($argumentName, ['@extension', '@subpackage', '@controller', '@action', '@format'], true) + ) { + // Don't handle internalArguments here, not needed for forwardResponse() + continue; + } + $nonExtbaseBaseArguments[$argumentName] = $argumentValue; + } + return (new ForwardResponse((string)($replacedArguments['@action'] ?? 'index'))) + ->withControllerName((string)($replacedArguments['@controller'] ?? 'Standard')) + ->withExtensionName((string)($replacedArguments['@extension'] ?? '')) + ->withArguments($nonExtbaseBaseArguments) + ->withArgumentsValidationResult($this->arguments->validate()); + } + + return null; + } + + /** + * Returns a string with a basic error message about validation failure. + * We may add all validation error messages to a log file in the future, + * but for security reasons (@see #54074) we do not return these here. + * + * @internal + */ + protected function getFlattenedValidationErrorMessage(): string + { + return 'Validation failed while trying to call ' . static::class . '->' . $this->actionMethodName . '().' . PHP_EOL; + } + + /** + * Creates a Message object and adds it to the FlashMessageQueue. + * + * @throws \InvalidArgumentException if the message body is no string + * @see FlashMessage + */ + public function addFlashMessage( + string $messageBody, + string $messageTitle = '', + ContextualFeedbackSeverity $severity = ContextualFeedbackSeverity::OK, + bool $storeInSession = true + ): void { + $flashMessage = new FlashMessage( + $messageBody, + $messageTitle, + $severity, + $storeInSession + ); + + $this->getFlashMessageQueue()->enqueue($flashMessage); + } + + /** + * todo: As soon as the incoming request contains the compiled plugin namespace, extbase will offer a trait to + * create a flash message identifier from the current request. Users then should inject the flash message + * service themselves if needed. + * + * @internal + */ + protected function getFlashMessageQueue(?string $identifier = null): FlashMessageQueue + { + if ($identifier === null) { + $pluginNamespace = $this->internalExtensionService->getPluginNamespace( + $this->request->getControllerExtensionName(), + $this->request->getPluginName() + ); + $identifier = 'extbase.flashmessages.' . $pluginNamespace; + } + + return $this->internalFlashMessageService->getMessageQueueByIdentifier($identifier); + } + + /** + * Redirects the request to another action and / or controller. + * + * Redirect will be sent to the client which then performs another request to the new URI. + * + * @param string|null $actionName Name of the action to forward to + * @param string|null $controllerName Unqualified object name of the controller to forward to. If not specified, the current controller is used. + * @param string|null $extensionName Name of the extension containing the controller to forward to. If not specified, the current extension is assumed. + * @param array|null $arguments Arguments to pass to the target action + * @param int|null $pageUid Target page uid. If NULL, the current page uid is used + * @param null $_ (optional) Unused + * @param int $statusCode (optional) The HTTP status code for the redirect. Default is "303 See Other + */ + protected function redirect( + ?string $actionName, + ?string $controllerName = null, + ?string $extensionName = null, + ?array $arguments = null, + ?int $pageUid = null, + $_ = null, + int $statusCode = 303 + ): ResponseInterface { + if ($controllerName === null) { + $controllerName = $this->request->getControllerName(); + } + $this->uriBuilder->reset()->setCreateAbsoluteUri(true); + if (MathUtility::canBeInterpretedAsInteger($pageUid)) { + $this->uriBuilder->setTargetPageUid((int)$pageUid); + } + if ($this->request->getAttribute('normalizedParams')->isHttps()) { + $this->uriBuilder->setAbsoluteUriScheme('https'); + } + $uri = $this->uriBuilder->uriFor($actionName, $arguments, $controllerName, $extensionName); + return $this->redirectToUri($uri, null, $statusCode); + } + + /** + * Redirects the web request to another uri. + * + * @param string|UriInterface $uri A string representation of a URI + * @param null $_ (optional) Unused + * @param int $statusCode (optional) The HTTP status code for the redirect. Default is "303 See Other" + */ + protected function redirectToUri(string|UriInterface $uri, $_ = null, int $statusCode = 303): ResponseInterface + { + $uri = $this->addBaseUriIfNecessary((string)$uri); + return new RedirectResponse($uri, $statusCode); + } + + /** + * Adds the base uri if not already in place. + * + * @internal + */ + protected function addBaseUriIfNecessary(string $uri): string + { + return GeneralUtility::locationHeaderUrl($uri, $this->request); + } + + /** + * Sends the specified HTTP status immediately and only stops to run back through the middleware stack. + * Note: If any other plugin or content or hook is used within a frontend request, this is skipped by design. + * + * @param int $statusCode The HTTP status code + * @param string $statusMessage A custom HTTP status message + * @param string|null $content Body content which further explains the status + * @throws PropagateResponseException + */ + public function throwStatus(int $statusCode, string $statusMessage = '', ?string $content = null): never + { + if ($content === null) { + $content = $statusCode . ' ' . $statusMessage; + } + $response = $this->responseFactory + ->createResponse($statusCode, $statusMessage) + ->withBody($this->streamFactory->createStream((string)$content)); + throw new PropagateResponseException($response, 1476045871); + } + + /** + * This method processes exceptions that occur due to missing or not found targets or arguments during argument + * mapping. Based on configuration settings, either a "page not found" response is triggered or the original + * exception is propagated. + * + * Extension authors can override this function to implement additional/custom argument mapping exception handling + */ + protected function handleArgumentMappingExceptions(\Exception $exception): void + { + $configuration = $this->configurationManager->getConfiguration( + ConfigurationManagerInterface::CONFIGURATION_TYPE_FRAMEWORK + ); + + $handleTargetNotFoundException = $exception instanceof TargetNotFoundException + && (bool)($configuration['mvc']['showPageNotFoundIfTargetNotFoundException'] ?? false); + $handleRequiredArgumentMissingException = $exception instanceof RequiredArgumentMissingException + && (bool)($configuration['mvc']['showPageNotFoundIfRequiredArgumentIsMissingException'] ?? false); + + if ($handleTargetNotFoundException || $handleRequiredArgumentMissingException) { + $response = GeneralUtility::makeInstance(ErrorController::class)->pageNotFoundAction( + $this->request, + $exception->getMessage() + ); + throw new PropagateResponseException($response, 1720242346); + } + + throw $exception; + } + + /** + * Maps arguments delivered by the request object to the local controller arguments. + * + * @internal + */ + protected function mapRequestArgumentsToControllerArguments(): void + { + try { + /** @var Argument $argument */ + foreach ($this->arguments as $argument) { + $argumentName = $argument->getName(); + if ($this->request->hasArgument($argumentName)) { + $this->setArgumentValue($argument, $this->request->getArgument($argumentName)); + } elseif ($argument->isRequired()) { + throw new RequiredArgumentMissingException('Required argument "' . $argumentName . '" is not set for ' . $this->request->getControllerObjectName() . '->' . $this->request->getControllerActionName() . '.', 1298012500); + } + + if ($this->request->getMethod() === 'POST') { + $uploadedFiles = $this->request->getUploadedFiles()[$argumentName] ?? []; + $argument->setUploadedFiles($uploadedFiles); + } + } + } catch (\Exception $exception) { + $this->handleArgumentMappingExceptions($exception); + } + } + + private function setArgumentValue(Argument $argument, mixed $rawValue): void + { + if ($rawValue === null) { + $argument->setValue(null); + return; + } + $dataType = $argument->getDataType(); + if ($rawValue instanceof $dataType) { + $argument->setValue($rawValue); + return; + } + $this->propertyMapper->resetMessages(); + try { + $argument->setValue( + $this->propertyMapper->convert( + $rawValue, + $dataType, + $argument->getPropertyMappingConfiguration() + ) + ); + } catch (TargetNotFoundException $e) { + // for optional arguments no exception is thrown. + if ($argument->isRequired()) { + throw $e; + } + } + $argument->getValidationResults()->merge($this->propertyMapper->getMessages()); + } + + /** + * Returns a response object with either the given html string or the current rendered view as content. + */ + protected function htmlResponse(?string $html = null): ResponseInterface + { + return $this->responseFactory->createResponse() + ->withHeader('Content-Type', 'text/html; charset=utf-8') + ->withBody($this->streamFactory->createStream(($html ?? $this->view->render()))); + } + + /** + * Returns a response object with either the given json string or the current rendered + * view as content. Mainly to be used for actions / controllers using the JsonView. + */ + protected function jsonResponse(?string $json = null): ResponseInterface + { + return $this->responseFactory->createResponse() + ->withHeader('Content-Type', 'application/json; charset=utf-8') + ->withBody($this->streamFactory->createStream(($json ?? $this->view->render()))); + } + + /** + * Handles rate-limiting for the given action request. Checks if the current request exceeds + * a possible defined rate limit for the action method and generates an appropriate response + * if the limit is reached. + * + * @internal + * @return ResponseInterface|null The rate-limited response if the limit is exceeded, or null if no rate-limiting applies. + */ + protected function handleRateLimit(RequestInterface $request): ?ResponseInterface + { + $rateLimiter = $this->rateLimitRegistry->createLimiter(static::class, $this->actionMethodName, $this->request); + if ($rateLimiter === null) { + return null; + } + + $rateLimit = $this->rateLimitRegistry->getRateLimit(static::class, $this->actionMethodName); + $limit = $rateLimiter->consume(); + if ($limit->isAccepted()) { + return null; + } + + $customMessage = null; + if ($rateLimit->message !== '') { + $customMessage = LocalizationUtility::translate($rateLimit->message, $this->request->getControllerExtensionName()); + } + $message = $customMessage ?? LocalizationUtility::translate('ratelimit.action.defaultmessage', 'extbase'); + + $response = $this->responseFactory->createResponse() + ->withHeader('Content-Type', 'text/html; charset=utf-8') + ->withStatus(429) + ->withBody($this->streamFactory->createStream($message)); + + $event = $this->eventDispatcher->dispatch( + new BeforeActionRateLimitResponseEvent($request, static::class, $this->actionMethodName, $rateLimit, $response) + ); + + return $event->getResponse(); + } + + /** + * Performs authorization checks for actions with the #[Authorize] attribute. If access is denied, a HTTP 403 + * response is propagated. This behavior can be customized by implementing a event listener for the + * {@see BeforeActionAuthorizationDeniedEvent}. + * + * @internal + */ + protected function performAuthorizationChecks(RequestInterface $request, array $preparedArguments): ?ResponseInterface + { + $result = $this->authorizeRegistry->checkAuthorization($this, $this->actionMethodName, $preparedArguments); + + if ($result === null || $result->isAllowed()) { + return null; + } + + $message = match ($result->failureReason) { + AuthorizationFailureReason::NOT_LOGGED_IN => 'Access denied: Login required', + AuthorizationFailureReason::MISSING_GROUP => 'Access denied: Insufficient permissions', + AuthorizationFailureReason::CALLBACK_DENIED, null => 'Access denied', + }; + + $event = $this->eventDispatcher->dispatch( + new BeforeActionAuthorizationDeniedEvent( + $request, + static::class, + $this->actionMethodName, + $result->failedAttribute, + $result->failureReason, + ) + ); + + if (!$event->getResponse()) { + $response = GeneralUtility::makeInstance(ErrorController::class)->accessDeniedAction( + $this->request, + $message, + [ + 'code' => PageAccessFailureReasons::ACCESS_DENIED_GENERAL, + ] + ); + throw new PropagateResponseException($response, 1761287264); + } + + return $event->getResponse(); + } +} diff --git a/Classes/Mvc/Controller/Argument.php b/Classes/Mvc/Controller/Argument.php new file mode 100644 index 0000000..6af8456 --- /dev/null +++ b/Classes/Mvc/Controller/Argument.php @@ -0,0 +1,253 @@ +> + */ + protected array $uploadedFiles = []; + + /** + * Default value. Used if argument is optional. + */ + protected mixed $defaultValue = null; + + /** + * A custom validator, used supplementary to the base validation + */ + protected ?ValidatorInterface $validator = null; + + /** + * The validation results. This can be asked if the argument has errors. + */ + protected Result $validationResults; + + /** + * Constructs this controller argument + * + * @throws \InvalidArgumentException if $name is empty string + */ + public function __construct(string $name, string $dataType) + { + if ($name === '') { + throw new \InvalidArgumentException('$name must be a non-empty string.', 1232551853); + } + $this->name = $name; + $this->dataType = TypeHandlingUtility::normalizeType($dataType); + + $this->validationResults = new Result(); + $this->propertyMappingConfiguration = GeneralUtility::makeInstance(MvcPropertyMappingConfiguration::class); + $this->fileHandlingServiceConfiguration = GeneralUtility::makeInstance(FileHandlingServiceConfiguration::class); + } + + public function getName(): string + { + return $this->name; + } + + /** + * @throws \InvalidArgumentException if $shortName is not a character + */ + public function setShortName(string $shortName): Argument + { + if (strlen($shortName) !== 1) { + throw new \InvalidArgumentException('$shortName must be a single character or NULL', 1195824959); + } + $this->shortName = $shortName; + return $this; + } + + public function getShortName(): string + { + return $this->shortName; + } + + public function getDataType(): string + { + return $this->dataType; + } + + public function setRequired(bool $required): Argument + { + $this->isRequired = $required; + return $this; + } + + public function isRequired(): bool + { + return $this->isRequired; + } + + public function setDefaultValue(mixed $defaultValue): Argument + { + $this->defaultValue = $defaultValue; + return $this; + } + + public function getDefaultValue(): mixed + { + return $this->defaultValue; + } + + /** + * Sets a custom validator which is used supplementary to the base validation + */ + public function setValidator(ValidatorInterface $validator): Argument + { + $this->validator = $validator; + return $this; + } + + public function getValidator(): ?ValidatorInterface + { + return $this->validator; + } + + public function setValue(mixed $rawValue): Argument + { + $this->value = $rawValue; + return $this; + } + + public function getValue(): mixed + { + if ($this->value === null) { + return $this->defaultValue; + } + return $this->value; + } + + /** + * Return the Property Mapping Configuration used for this argument; can be used by the initialize*action to modify the Property Mapping. + */ + public function getPropertyMappingConfiguration(): MvcPropertyMappingConfiguration + { + return $this->propertyMappingConfiguration; + } + + /** + * Return the FileHandlingServiceConfiguration used for this argument; can be used by the + * initialize*action to modify the file upload configuration for properties. + */ + public function getFileHandlingServiceConfiguration(): FileHandlingServiceConfiguration + { + return $this->fileHandlingServiceConfiguration; + } + + public function getUploadedFiles(): array + { + return $this->uploadedFiles; + } + + public function setUploadedFiles(array $uploadedFiles): void + { + $this->uploadedFiles = $uploadedFiles; + } + + /** + * @return bool TRUE if the argument is valid, FALSE otherwise + */ + public function isValid(): bool + { + return !$this->validate()->hasErrors(); + } + + /** + * Returns a string representation of this argument's value + */ + public function __toString(): string + { + return (string)$this->value; + } + + public function validate(): Result + { + if ($this->hasBeenValidated) { + return $this->validationResults; + } + + if ($this->validator !== null) { + $validationMessages = $this->validator->validate($this->value); + $this->validationResults->merge($validationMessages); + } + + if ($this->fileHandlingServiceConfiguration->hasfileUploadConfigurations()) { + $fileOperationValidationResults = $this->fileHandlingServiceConfiguration->validateFileOperations($this); + $this->validationResults->merge($fileOperationValidationResults); + } + + $this->hasBeenValidated = true; + return $this->validationResults; + } + + /** + * Returns an array of possible UploadedFile objects for the given property + * @return list + */ + public function getUploadedFilesForProperty(string $propertyName): array + { + $result = []; + + try { + $uploadedFiles = ArrayUtility::getValueByPath($this->uploadedFiles, $propertyName, '.'); + if ($uploadedFiles instanceof UploadedFile) { + $result = [$uploadedFiles]; + } elseif (is_iterable($uploadedFiles)) { + foreach ($uploadedFiles as $uploadedFile) { + $result[] = $uploadedFile; + } + } + } catch (MissingArrayPathException) { + // Do nothing, empty array will be returned + } + + return $result; + } + + /** + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ + public function getValidationResults(): Result + { + return $this->validationResults; + } +} diff --git a/Classes/Mvc/Controller/Arguments.php b/Classes/Mvc/Controller/Arguments.php new file mode 100644 index 0000000..a5536a5 --- /dev/null +++ b/Classes/Mvc/Controller/Arguments.php @@ -0,0 +1,235 @@ +getName(); + parent::offsetSet($argumentName, $value); + $this->argumentNames[$argumentName] = true; + } + + /** + * Sets an argument, aliased to offsetSet() + * + * @throws \InvalidArgumentException if the argument is not a valid Controller Argument object + */ + public function append(mixed $value): void + { + if (!$value instanceof Argument) { + throw new \InvalidArgumentException('Controller arguments must be valid TYPO3\\CMS\\Extbase\\Mvc\\Controller\\Argument objects.', 1187953787); + } + $this->offsetSet(null, $value); + } + + public function offsetUnset(mixed $offset): void + { + $translatedOffset = $this->translateToLongArgumentName($offset); + parent::offsetUnset($translatedOffset); + unset($this->argumentNames[$translatedOffset]); + if ($offset != $translatedOffset) { + unset($this->argumentShortNames[$offset]); + } + } + + public function offsetExists(mixed $offset): bool + { + $translatedOffset = $this->translateToLongArgumentName($offset); + return parent::offsetExists($translatedOffset); + } + + /** + * Returns the value at the specified index + * + * @throws NoSuchArgumentException if the argument does not exist + */ + public function offsetGet(mixed $offset): Argument + { + $translatedOffset = $this->translateToLongArgumentName($offset); + if ($translatedOffset === '') { + throw new NoSuchArgumentException('The argument "' . $offset . '" does not exist.', 1216909923); + } + return parent::offsetGet($translatedOffset); + } + + /** + * Creates, adds and returns a new controller argument to this composite object. + * If an argument with the same name exists already, it will be replaced by the + * new argument object. + */ + public function addNewArgument(string $name, string $dataType = 'Text', bool $isRequired = false, mixed $defaultValue = null): Argument + { + $argument = GeneralUtility::makeInstance(Argument::class, $name, $dataType); + $argument->setRequired($isRequired); + $argument->setDefaultValue($defaultValue); + $this->addArgument($argument); + return $argument; + } + + /** + * Adds the specified controller argument to this composite object. + * If an argument with the same name exists already, it will be replaced by the + * new argument object. + * + * Note that the argument will be cloned, not referenced. + */ + public function addArgument(Argument $argument): void + { + $this->offsetSet(null, $argument); + } + + /** + * Returns an argument specified by name + * + * @throws NoSuchArgumentException + */ + public function getArgument(string $argumentName): Argument + { + if (!$this->offsetExists($argumentName)) { + throw new NoSuchArgumentException('An argument "' . $argumentName . '" does not exist.', 1195815178); + } + return $this->offsetGet($argumentName); + } + + /** + * Checks if an argument with the specified name exists + * + * @see offsetExists() + */ + public function hasArgument(string $argumentName): bool + { + return $this->offsetExists($argumentName); + } + + /** + * Returns the names of all arguments contained in this object + */ + public function getArgumentNames(): array + { + return array_keys($this->argumentNames); + } + + /** + * Returns the short names of all arguments contained in this object that have one. + */ + public function getArgumentShortNames(): array + { + $argumentShortNames = []; + /** @var Argument $argument */ + foreach ($this as $argument) { + $argumentShortNames[$argument->getShortName()] = true; + } + return array_keys($argumentShortNames); + } + + /** + * Magic setter method for the argument values. Each argument + * value can be set by just calling the setArgumentName() method. + * + * @throws \LogicException + */ + public function __call(string $methodName, array $arguments): void + { + if (!str_starts_with($methodName, 'set')) { + throw new \LogicException('Unknown method "' . $methodName . '".', 1210858451); + } + $firstLowerCaseArgumentName = $this->translateToLongArgumentName(strtolower($methodName[3]) . substr($methodName, 4)); + $firstUpperCaseArgumentName = $this->translateToLongArgumentName(ucfirst(substr($methodName, 3))); + if (in_array($firstLowerCaseArgumentName, $this->getArgumentNames())) { + $argument = parent::offsetGet($firstLowerCaseArgumentName); + $argument->setValue($arguments[0]); + } elseif (in_array($firstUpperCaseArgumentName, $this->getArgumentNames())) { + $argument = parent::offsetGet($firstUpperCaseArgumentName); + $argument->setValue($arguments[0]); + } + } + + /** + * Translates a short argument name to its corresponding long name. If the + * specified argument name is a real argument name already, it will be returned again. + * + * If an argument with the specified name or short name does not exist, an empty + * string is returned. + */ + protected function translateToLongArgumentName(string $argumentName): string + { + if (in_array($argumentName, $this->getArgumentNames())) { + return $argumentName; + } + /** @var Argument $argument */ + foreach ($this as $argument) { + if ($argumentName === $argument->getShortName()) { + return $argument->getName(); + } + } + return ''; + } + + /** + * Remove all arguments and resets this object + */ + public function removeAll(): void + { + foreach ($this->argumentNames as $argumentName => $booleanValue) { + parent::offsetUnset($argumentName); + } + $this->argumentNames = []; + } + + public function validate(): Result + { + $results = new Result(); + /** @var Argument $argument */ + foreach ($this as $argument) { + $argumentValidationResults = $argument->validate(); + $results->forProperty($argument->getName())->merge($argumentValidationResults); + } + return $results; + } +} diff --git a/Classes/Mvc/Controller/AuthorizeRegistry.php b/Classes/Mvc/Controller/AuthorizeRegistry.php new file mode 100644 index 0000000..206651e --- /dev/null +++ b/Classes/Mvc/Controller/AuthorizeRegistry.php @@ -0,0 +1,61 @@ +>> */ + private array $authorizations = []; + + public function __construct( + private readonly ActionAuthorizationService $authorizationService, + ) {} + + public function add(string $controllerClass, string $actionMethod, string|array|null $callback, bool $requireLogin, array $requireGroups): void + { + $this->authorizations[$controllerClass][$actionMethod][] = new Authorize($callback, $requireLogin, $requireGroups); + } + + /** + * @return list + */ + public function getAuthorizeAttributes(string $controllerClass, string $actionMethod): array + { + return $this->authorizations[$controllerClass][$actionMethod] ?? []; + } + + public function checkAuthorization(ActionController $controller, string $actionMethod, array $preparedArguments): ?AuthorizationResult + { + $authorizeAttributes = $this->getAuthorizeAttributes($controller::class, $actionMethod); + if ($authorizeAttributes === []) { + return null; + } + + return $this->authorizationService->checkAuthorization($controller, $authorizeAttributes, $preparedArguments); + } +} diff --git a/Classes/Mvc/Controller/ControllerInterface.php b/Classes/Mvc/Controller/ControllerInterface.php new file mode 100644 index 0000000..c1ae752 --- /dev/null +++ b/Classes/Mvc/Controller/ControllerInterface.php @@ -0,0 +1,32 @@ + + */ + protected ObjectStorage $fileUploadConfigurations; + + /** + * @var ObjectStorage + */ + protected ObjectStorage $fileUploadDeletionConfigurations; + + public function __construct() + { + $this->fileUploadConfigurations = new ObjectStorage(); + $this->fileUploadDeletionConfigurations = new ObjectStorage(); + } + + public function addFileUploadConfiguration(FileUploadConfiguration $configuration): void + { + $this->fileUploadConfigurations->attach($configuration); + } + + public function getFileUploadConfigurations(): ObjectStorage + { + return $this->fileUploadConfigurations; + } + + public function hasfileUploadConfigurations(): bool + { + return $this->fileUploadConfigurations->count() > 0; + } + + /** + * Returns the FileUploadConfiguration for the given propertyName + */ + public function getFileUploadConfigurationForProperty(string $propertyName): ?FileUploadConfiguration + { + foreach ($this->fileUploadConfigurations as $configuration) { + if ($configuration->getPropertyName() === $propertyName) { + return $configuration; + } + } + + return null; + } + + /** + * Registers a file deletion for the given property and file reference uid + */ + public function registerFileDeletion(string $property, int $fileReferenceUid): void + { + $fileUploadDeletionConfiguration = $this->getFileUploadDeletionConfigurationForProperty($property); + if (!$fileUploadDeletionConfiguration) { + $fileUploadDeletionConfiguration = GeneralUtility::makeInstance(FileUploadDeletionConfiguration::class, $property); + $this->fileUploadDeletionConfigurations->attach($fileUploadDeletionConfiguration); + } + $fileUploadDeletionConfiguration->addFileReferenceUid($fileReferenceUid); + } + + /** + * Returns all file deletion configurations + */ + public function getFileUploadDeletionConfigurations(): ObjectStorage + { + return $this->fileUploadDeletionConfigurations; + } + + /** + * Returns the FileUploadDeletionConfiguration for the given propertyName + */ + public function getFileUploadDeletionConfigurationForProperty(string $propertyName): ?FileUploadDeletionConfiguration + { + foreach ($this->fileUploadDeletionConfigurations as $configuration) { + if ($configuration->getPropertyName() === $propertyName) { + return $configuration; + } + } + + return null; + } + + /** + * Returns the amount of configured file deletions for the given property + */ + private function getFileUploadDeletionCountForProperty(string $propertyName): int + { + $fileUploadDeletionConfiguration = $this->getFileUploadDeletionConfigurationForProperty($propertyName); + if ($fileUploadDeletionConfiguration) { + return count($fileUploadDeletionConfiguration->getFileReferenceUids()); + } + + return 0; + } + + /** + * Validates file operations for the given argument by checking file upload and file deletion configurations and + * returning the validation result. + */ + public function validateFileOperations(Argument $argument): Result + { + $validationResults = new Result(); + $value = $argument->getValue(); + + foreach ($this->fileUploadConfigurations as $configuration) { + $uploadedFilesForProperty = $argument->getUploadedFilesForProperty( + $configuration->getPropertyName() + ); + $fileDeletionCount = $this->getFileUploadDeletionCountForProperty($configuration->getPropertyName()); + $currentPropertyValue = null; + if ($value) { + $currentPropertyValue = ObjectAccess::getPropertyPath($value, $configuration->getPropertyName()); + } + $validationResult = $this->getValidationResultsForProperty( + $configuration, + $configuration->getPropertyName(), + $currentPropertyValue, + $uploadedFilesForProperty, + $fileDeletionCount + ); + $validationResults->merge($validationResult); + } + + return $validationResults; + } + + /** + * Validates file uploads and file deletions for the given propertyPath and currentPropertyValue and returns + * the validation result. + */ + private function getValidationResultsForProperty( + FileUploadConfiguration $configuration, + string $propertyPath, + mixed $currentPropertyValue, + array $uploadedFiles, + int $fileDeletionCount + ): Result { + $validationResults = new Result(); + + if ($currentPropertyValue instanceof FileReference) { + $currentAmount = 1; + } elseif ($currentPropertyValue instanceof ObjectStorage) { + $currentAmount = $currentPropertyValue->count(); + } else { + $currentAmount = 0; + } + + // Validate, that minimum files requirement is valid after file deletion(s) + if ($fileDeletionCount > 0 + && ($currentPropertyValue instanceof FileReference || $currentPropertyValue instanceof ObjectStorage) + ) { + $newAmount = $currentAmount - $fileDeletionCount + count($uploadedFiles); + if ($newAmount < $configuration->getMinFiles()) { + $minFilesError = new Error( + $this->translateErrorMessage( + 'filehandlingserviceconfiguration.minfiles.delete.notvalid', + 'extbase', + ), + 1714557062 + ); + $validationResults->forProperty($propertyPath) + ->addError($minFilesError); + } + } + + // If the given $currentPropertyValue (which is the target property for file upload) is either a FileReference + // or a non empty ObjectStorage and no uploaded files are available, the rest of the validation can be skipped. + if ($uploadedFiles === [] + && ($currentPropertyValue instanceof FileReference + || ($currentPropertyValue instanceof ObjectStorage && $currentPropertyValue->count() > 0)) + ) { + return $validationResults; + } + + if (count($uploadedFiles) < $configuration->getMinFiles()) { + $minFilesError = new Error( + $this->translateErrorMessage( + 'filehandlingserviceconfiguration.minfiles.notvalid', + 'extbase', + [$configuration->getMinFiles()] + ), + 1708596527 + ); + $validationResults->forProperty($propertyPath) + ->addError($minFilesError); + } + + if ((count($uploadedFiles) + $currentAmount - $fileDeletionCount) > $configuration->getMaxFiles()) { + $minFilesError = new Error( + $this->translateErrorMessage( + 'filehandlingserviceconfiguration.maxfiles.notvalid', + 'extbase', + [$configuration->getMaxFiles()] + ), + 1708596528 + ); + $validationResults->forProperty($propertyPath) + ->addError($minFilesError); + } + + $validators = $this->enforceDefaultValidators( + ...$configuration->getValidators() + ); + foreach ($validators as $validator) { + foreach ($uploadedFiles as $uploadedFile) { + $validatorResult = $validator->validate($uploadedFile); + if ($validatorResult->hasErrors()) { + $validationResults->forProperty($propertyPath)->merge($validatorResult); + } + } + } + + return $validationResults; + } + + /** + * @return list + */ + private function enforceDefaultValidators(ValidatorInterface ...$validators): array + { + $enforceValidators = [ + FileNameValidator::class, + FileExtensionMimeTypeConsistencyValidator::class, + ]; + $existingValidators = array_map(get_class(...), $validators); + $missingValidators = array_diff($enforceValidators, $existingValidators); + foreach ($missingValidators as $missingValidator) { + $validators[] = GeneralUtility::makeInstance($missingValidator); + } + return $validators; + } + + /** + * Wrapper to translate error messages + */ + private function translateErrorMessage(string $translateKey, string $extensionName, array $arguments = []): string + { + return LocalizationUtility::translate( + $translateKey, + $extensionName, + $arguments + ) ?? ''; + } +} diff --git a/Classes/Mvc/Controller/FileUploadConfiguration.php b/Classes/Mvc/Controller/FileUploadConfiguration.php new file mode 100644 index 0000000..e045fa4 --- /dev/null +++ b/Classes/Mvc/Controller/FileUploadConfiguration.php @@ -0,0 +1,266 @@ + + */ + protected array $validators = []; + + public function __construct(protected readonly string $propertyName) {} + + /** + * Initializes the object with the given configuration array. Typically used with configuration from + * #[FileUpload] attribute. + */ + public function initializeWithConfiguration(array $configuration): self + { + if (!isset($configuration['validation']) || $configuration['validation'] === []) { + throw new \RuntimeException('Extbase file upload must at least define one validation rule.', 1711947120); + } + + $this->initializeUploadValidation($configuration['validation']); + + if (isset($configuration['uploadFolder']) && $configuration['uploadFolder'] !== '') { + $this->uploadFolder = $configuration['uploadFolder']; + } + + if (isset($configuration['addRandomSuffix'])) { + $this->addRandomSuffix = (bool)$configuration['addRandomSuffix']; + } + + if (isset($configuration['duplicationBehavior'])) { + $this->duplicationBehavior = $configuration['duplicationBehavior']; + } + + if (isset($configuration['createUploadFolderIfNotExist'])) { + $this->createUploadFolderIfNotExist = $configuration['createUploadFolderIfNotExist']; + } + + return $this; + } + + public function addValidator(ValidatorInterface $validator): self + { + $this->validators[] = $validator; + return $this; + } + + public function getValidators(): array + { + return $this->validators; + } + + public function resetValidators(): self + { + $this->validators = []; + return $this; + } + + public function getPropertyName(): string + { + return $this->propertyName; + } + + public function setRequired(): self + { + $this->minFiles = 1; + return $this; + } + + public function getMinFiles(): int + { + return $this->minFiles; + } + + public function setMinFiles(int $minFiles): self + { + $this->minFiles = $minFiles; + return $this; + } + + public function getMaxFiles(): int + { + return $this->maxFiles; + } + + public function setMaxFiles(int $maxFiles): self + { + $this->maxFiles = $maxFiles; + return $this; + } + + public function getUploadFolder(): string + { + return $this->uploadFolder; + } + + public function setUploadFolder(string $uploadFolder): self + { + $this->uploadFolder = $uploadFolder; + return $this; + } + + public function isAddRandomSuffix(): bool + { + return $this->addRandomSuffix; + } + + public function setAddRandomSuffix(bool $addRandomSuffix): self + { + $this->addRandomSuffix = $addRandomSuffix; + return $this; + } + + public function isCreateUploadFolderIfNotExist(): bool + { + return $this->createUploadFolderIfNotExist; + } + + public function setCreateUploadFolderIfNotExist(bool $createUploadFolderIfNotExist): self + { + $this->createUploadFolderIfNotExist = $createUploadFolderIfNotExist; + return $this; + } + + public function getDuplicationBehavior(): DuplicationBehavior + { + return $this->duplicationBehavior; + } + + public function setDuplicationBehavior(DuplicationBehavior $duplicationBehavior): void + { + $this->duplicationBehavior = $duplicationBehavior; + } + + /** + * Checks if the current configuration is considered valid for the given target type and throws + * an exception, if the configuration is invalid. + */ + public function ensureValidConfiguration(string $targetType): void + { + if ($targetType !== FileReference::class) { + throw new \RuntimeException('The FileUploadConfiguration can only be used for properties of type FileReference.', 1721623184); + } + + if (str_contains($this->getPropertyName(), '.')) { + throw new \RuntimeException('The property name for the FileUploadConfiguration must not contain any dot.', 1724585391); + } + + if ($this->getUploadFolder() === '') { + throw new \RuntimeException('An upload folder must be defined for the FileUploadConfiguration.', 1711799735); + } + + if (!$this->isCombinedStoragePathIdentifier($this->getUploadFolder())) { + throw new \RuntimeException('The upload folder must be a combined identifier - e.g. 1:/user_upload/', 1711801071); + } + + if ($this->getMaxFiles() < $this->getMinFiles()) { + throw new \RuntimeException('Maximum number of files cannot be less than minimum number of files.', 1711799765); + } + } + + private function isCombinedStoragePathIdentifier(string $identifier): bool + { + return str_contains($identifier, ':') + && !str_starts_with($identifier, ':') + && !str_ends_with($identifier, ':') + && MathUtility::canBeInterpretedAsInteger(substr($identifier, 0, strpos($identifier, ':'))); + } + + /** + * Initializes validators based on the given array of validation configuration + */ + private function initializeUploadValidation(array $validationConfiguration): void + { + if ($validationConfiguration['required'] ?? false) { + $this->minFiles = 1; + } + + if ((int)($validationConfiguration['minFiles'] ?? PHP_INT_MAX) < PHP_INT_MAX) { + $this->minFiles = (int)($validationConfiguration['minFiles']); + } + + if ((int)($validationConfiguration['maxFiles'] ?? PHP_INT_MAX) < PHP_INT_MAX) { + $this->maxFiles = (int)($validationConfiguration['maxFiles']); + } + + // Migrate allowedMimeTypes to mimeType configuration, if mimeType configuration is not defined + if (($validationConfiguration['allowedMimeTypes'] ?? false) + && is_array($validationConfiguration['allowedMimeTypes']) + && !isset($validationConfiguration['mimeType']) + ) { + $validationConfiguration['mimeType'] = ['allowedMimeTypes' => $validationConfiguration['allowedMimeTypes']]; + unset($validationConfiguration['allowedMimeTypes']); + } + + if (($validationConfiguration['mimeType'] ?? false) + && is_array($validationConfiguration['mimeType']) + ) { + $mimeTypeValidator = GeneralUtility::makeInstance(MimeTypeValidator::class); + $mimeTypeValidator->setOptions($validationConfiguration['mimeType']); + $this->addValidator($mimeTypeValidator); + } + + if (($validationConfiguration['fileExtension'] ?? false) + && is_array($validationConfiguration['fileExtension']) + ) { + $fileExtensionValidator = GeneralUtility::makeInstance(FileExtensionValidator::class); + $fileExtensionValidator->setOptions($validationConfiguration['fileExtension']); + $this->addValidator($fileExtensionValidator); + } + + if (($validationConfiguration['fileSize'] ?? false) + && is_array($validationConfiguration['fileSize']) + ) { + $fileSizeValidator = GeneralUtility::makeInstance(FileSizeValidator::class); + $fileSizeValidator->setOptions($validationConfiguration['fileSize']); + $this->addValidator($fileSizeValidator); + } + + if (($validationConfiguration['imageDimensions'] ?? false) + && is_array($validationConfiguration['imageDimensions']) + ) { + $imageDimensionsValidator = GeneralUtility::makeInstance(ImageDimensionsValidator::class); + $imageDimensionsValidator->setOptions($validationConfiguration['imageDimensions']); + $this->addValidator($imageDimensionsValidator); + } + } +} diff --git a/Classes/Mvc/Controller/FileUploadDeletionConfiguration.php b/Classes/Mvc/Controller/FileUploadDeletionConfiguration.php new file mode 100644 index 0000000..b17f0f7 --- /dev/null +++ b/Classes/Mvc/Controller/FileUploadDeletionConfiguration.php @@ -0,0 +1,41 @@ +propertyName; + } + + public function addFileReferenceUid(int $fileReferenceUid): void + { + $this->fileReferenceUids[] = $fileReferenceUid; + } + + public function getFileReferenceUids(): array + { + return $this->fileReferenceUids; + } +} diff --git a/Classes/Mvc/Controller/MvcPropertyMappingConfiguration.php b/Classes/Mvc/Controller/MvcPropertyMappingConfiguration.php new file mode 100644 index 0000000..a1e0763 --- /dev/null +++ b/Classes/Mvc/Controller/MvcPropertyMappingConfiguration.php @@ -0,0 +1,59 @@ +forProperty($propertyPath)->setTypeConverterOption(PersistentObjectConverter::class, PersistentObjectConverter::CONFIGURATION_CREATION_ALLOWED, true); + } + + /** + * Allow modification for a given property path + * + * @param string $propertyPath + */ + public function allowModificationForSubProperty($propertyPath) + { + $this->forProperty($propertyPath)->setTypeConverterOption(PersistentObjectConverter::class, PersistentObjectConverter::CONFIGURATION_MODIFICATION_ALLOWED, true); + } + + /** + * Set the target type for a certain property. Especially useful + * if there is an object which has a nested object which is abstract, + * and you want to instantiate a concrete object instead. + * + * @param string $propertyPath + * @param string $targetType + */ + public function setTargetTypeForSubProperty($propertyPath, $targetType) + { + $this->forProperty($propertyPath)->setTypeConverterOption(PersistentObjectConverter::class, PersistentObjectConverter::CONFIGURATION_TARGET_TYPE, $targetType); + } +} diff --git a/Classes/Mvc/Controller/MvcPropertyMappingConfigurationService.php b/Classes/Mvc/Controller/MvcPropertyMappingConfigurationService.php new file mode 100644 index 0000000..94125c0 --- /dev/null +++ b/Classes/Mvc/Controller/MvcPropertyMappingConfigurationService.php @@ -0,0 +1,177 @@ +hashService = $hashService; + } + + /** + * Generate a request hash for a list of form fields + */ + public function generateTrustedPropertiesToken(array $formFieldNames, string $fieldNamePrefix = ''): string + { + $formFieldArray = []; + foreach ($formFieldNames as $formField) { + $formFieldParts = explode('[', $formField); + $currentPosition = &$formFieldArray; + $formFieldPartsCount = count($formFieldParts); + for ($i = 0; $i < $formFieldPartsCount; $i++) { + $formFieldPart = $formFieldParts[$i]; + $formFieldPart = rtrim($formFieldPart, ']'); + if (!is_array($currentPosition)) { + throw new InvalidArgumentForHashGenerationException('The form field "' . $formField . '" is declared as array, but it collides with a previous form field of the same name which declared the field as string. This is an inconsistency you need to fix inside your Fluid form. (String overridden by Array)', 1255072196); + } + if ($i === $formFieldPartsCount - 1) { + if (isset($currentPosition[$formFieldPart]) && is_array($currentPosition[$formFieldPart])) { + throw new InvalidArgumentForHashGenerationException('The form field "' . $formField . '" is declared as string, but it collides with a previous form field of the same name which declared the field as array. This is an inconsistency you need to fix inside your Fluid form. (Array overridden by String)', 1255072587); + } + // Last iteration - add a string + if ($formFieldPart === '') { + $currentPosition[] = 1; + } else { + $currentPosition[$formFieldPart] = 1; + } + } else { + if ($formFieldPart === '') { + throw new InvalidArgumentForHashGenerationException('The form field "' . $formField . '" is invalid. Reason: "[]" used not as last argument, but somewhere in the middle (like foo[][bar]).', 1255072832); + } + if (!isset($currentPosition[$formFieldPart])) { + $currentPosition[$formFieldPart] = []; + } + $currentPosition = &$currentPosition[$formFieldPart]; + } + } + } + if ($fieldNamePrefix !== '') { + $formFieldArray = ($formFieldArray[$fieldNamePrefix] ?? []); + } + return $this->encodeAndHashFormFieldArray($formFieldArray); + } + + /** + * Encode and hash the form field array + */ + protected function encodeAndHashFormFieldArray(array $formFieldArray): string + { + $encodedFormFieldArray = json_encode($formFieldArray); + return $this->hashService->appendHmac($encodedFormFieldArray, HashScope::TrustedProperties->prefix(), HashAlgo::SHA3_256); + } + + /** + * Initialize the property mapping configuration in $controllerArguments if + * the trusted properties are set inside the request. + * + * @throws BadRequestException + */ + public function initializePropertyMappingConfigurationFromRequest(Request $request, Arguments $controllerArguments): void + { + /** @var ExtbaseRequestParameters $extbaseRequestParameters */ + $extbaseRequestParameters = $request->getAttribute('extbase'); + $trustedPropertiesToken = $extbaseRequestParameters->getInternalArgument('__trustedProperties'); + if (!is_string($trustedPropertiesToken)) { + return; + } + + try { + $encodedTrustedProperties = $this->hashService->validateAndStripHmac($trustedPropertiesToken, HashScope::TrustedProperties->prefix(), HashAlgo::SHA3_256); + } catch (InvalidHashStringException $e) { + throw new BadRequestException('The HMAC of the form could not be validated.', 1581862822); + } + $trustedProperties = json_decode($encodedTrustedProperties, true); + if (!is_array($trustedProperties)) { + if (str_starts_with($encodedTrustedProperties, 'a:')) { + throw new BadRequestException('Trusted properties used outdated serialization format instead json.', 1699604555); + } + throw new BadRequestException('The HMAC of the form could not be utilized.', 1691267306); + } + + foreach ($trustedProperties as $propertyName => $propertyConfiguration) { + $propertyName = (string)$propertyName; + if (!$controllerArguments->hasArgument($propertyName) || !is_array($propertyConfiguration)) { + continue; + } + $propertyMappingConfiguration = $controllerArguments->getArgument($propertyName)->getPropertyMappingConfiguration(); + $this->modifyPropertyMappingConfiguration($propertyConfiguration, $propertyMappingConfiguration); + } + } + + /** + * Modify the passed $propertyMappingConfiguration according to the $propertyConfiguration which + * has been generated by Fluid. In detail, if the $propertyConfiguration contains + * an __identity field, we allow modification of objects; else we allow creation. + * + * All other properties are specified as allowed properties. + */ + protected function modifyPropertyMappingConfiguration( + array $propertyConfiguration, + PropertyMappingConfigurationInterface $propertyMappingConfiguration + ): void { + if (isset($propertyConfiguration['__identity'])) { + $propertyMappingConfiguration->setTypeConverterOption(PersistentObjectConverter::class, PersistentObjectConverter::CONFIGURATION_MODIFICATION_ALLOWED, true); + unset($propertyConfiguration['__identity']); + } else { + $propertyMappingConfiguration->setTypeConverterOption(PersistentObjectConverter::class, PersistentObjectConverter::CONFIGURATION_CREATION_ALLOWED, true); + } + + foreach ($propertyConfiguration as $innerKey => $innerValue) { + if (is_array($innerValue)) { + $this->modifyPropertyMappingConfiguration( + $innerValue, + $propertyMappingConfiguration->forProperty((string)$innerKey) + ); + } + $propertyMappingConfiguration->allowProperties($innerKey); + } + } +} diff --git a/Classes/Mvc/Controller/RateLimitRegistry.php b/Classes/Mvc/Controller/RateLimitRegistry.php new file mode 100644 index 0000000..ca8e44c --- /dev/null +++ b/Classes/Mvc/Controller/RateLimitRegistry.php @@ -0,0 +1,60 @@ +> */ + private array $rateLimits = []; + + public function __construct( + private readonly RateLimiterFactoryInterface $rateLimiterFactory, + ) {} + + public function add(string $controllerClass, string $actionMethod, int $limit, string $interval, string $policy, string $message): void + { + $this->rateLimits[$controllerClass][$actionMethod] = new RateLimit($limit, $interval, $policy, $message); + } + + public function getRateLimit(string $controllerClass, string $actionMethod): ?RateLimit + { + return $this->rateLimits[$controllerClass][$actionMethod] ?? null; + } + + public function createLimiter(string $controllerClass, string $actionMethod, ServerRequestInterface $request): ?LimiterInterface + { + $rateLimit = $this->getRateLimit($controllerClass, $actionMethod); + if ($rateLimit === null) { + return null; + } + + $identifier = strtolower(str_replace('\\', '-', $controllerClass) . '-' . $actionMethod); + return $this->rateLimiterFactory->createRequestBasedLimiter($request, $rateLimit->getConfiguration($identifier)); + } +} diff --git a/Classes/Mvc/Dispatcher.php b/Classes/Mvc/Dispatcher.php new file mode 100644 index 0000000..b38eeb5 --- /dev/null +++ b/Classes/Mvc/Dispatcher.php @@ -0,0 +1,127 @@ +container = $container; + $this->eventDispatcher = $eventDispatcher; + } + + /** + * Dispatches a request to a controller and initializes the security framework. + * + * @param RequestInterface $request The request to dispatch + * @throws Exception\InfiniteLoopException + */ + public function dispatch(RequestInterface $request): ResponseInterface + { + $dispatchLoopCount = 0; + $isDispatched = false; + while (!$isDispatched) { + if ($dispatchLoopCount++ > 99) { + throw new InfiniteLoopException( + 'Could not ultimately dispatch the request after ' . $dispatchLoopCount + . ' iterations. Most probably, an #[' . IgnoreValidation::class . ']' + . ' attribute is missing on re-displaying a form with validation errors.', + 1217839467 + ); + } + $controller = $this->resolveController($request); + $response = $controller->processRequest($request); + if ($response instanceof ForwardResponse) { + // The controller action returned an extbase internal Forward response: + // Another action should be dispatched. + $request = static::buildRequestFromCurrentRequestAndForwardResponse($request, $response); + } else { + // The controller action returned a casual or a HTTP redirect response. + // Dispatching ends here and response is sent to client. + $isDispatched = true; + } + } + $this->eventDispatcher->dispatch(new AfterRequestDispatchedEvent($request, $response)); + return $response; + } + + /** + * Finds and instantiates a controller that matches the current request. + * If no controller can be found, an instance of NotFoundControllerInterface is returned. + * + * @param RequestInterface $request The request to dispatch + * @return Controller\ControllerInterface + * @throws Exception\InvalidControllerException + */ + protected function resolveController(RequestInterface $request) + { + $controllerObjectName = $request->getControllerObjectName(); + $controller = $this->container->get($controllerObjectName); + if (!$controller instanceof ControllerInterface) { + throw new InvalidControllerException( + 'Invalid controller "' . $request->getControllerObjectName() . '". The controller must implement the TYPO3\\CMS\\Extbase\\Mvc\\Controller\\ControllerInterface.', + 1476109646 + ); + } + return $controller; + } + + /** + * @internal only to be used within Extbase, not part of TYPO3 Core API. + * @todo: make this a private method again as soon as the tests, that fake the dispatching of requests, are refactored. + */ + public static function buildRequestFromCurrentRequestAndForwardResponse(RequestInterface $currentRequest, ForwardResponse $forwardResponse): RequestInterface + { + $request = $currentRequest->withControllerActionName($forwardResponse->getActionName()); + if ($forwardResponse->getControllerName() !== null) { + $request = $request->withControllerName($forwardResponse->getControllerName()); + } + if ($forwardResponse->getExtensionName() !== null) { + $request = $request->withControllerExtensionName($forwardResponse->getExtensionName()); + } + if ($forwardResponse->getArguments() !== null) { + $request = $request->withArguments($forwardResponse->getArguments()); + } + /** @var ExtbaseRequestParameters $extbaseRequestParameters */ + $extbaseRequestParameters = clone $request->getAttribute('extbase'); + $extbaseRequestParameters->setOriginalRequest($currentRequest); + $extbaseRequestParameters->setOriginalRequestMappingResults($forwardResponse->getArgumentsValidationResult()); + $extbaseRequestParameters->setOriginalFlashMessages(...$forwardResponse->getFlashMessages()); + return $request->withAttribute('extbase', $extbaseRequestParameters); + } +} diff --git a/Classes/Mvc/Exception.php b/Classes/Mvc/Exception.php new file mode 100644 index 0000000..fb18218 --- /dev/null +++ b/Classes/Mvc/Exception.php @@ -0,0 +1,34 @@ +getMessage(), $e->getCode(), $e); + } +} diff --git a/Classes/Mvc/Exception/InfiniteLoopException.php b/Classes/Mvc/Exception/InfiniteLoopException.php new file mode 100644 index 0000000..a3135b1 --- /dev/null +++ b/Classes/Mvc/Exception/InfiniteLoopException.php @@ -0,0 +1,25 @@ + $controllerObjectName + */ + protected array $controllerAliasToClassNameMapping = []; + + /** + * Name of the action the controller is supposed to execute. For example "create" with the + * controller method name being "createAction()". + * Action name must start with a lower case letter and is case-sensitive. + */ + protected string $controllerActionName = 'index'; + + /** + * The arguments for this request. This receives only those arguments relevant and + * prefixed for this extension/controller/plugin combination. + */ + protected array $arguments = []; + + /** + * Framework-internal arguments for this request, such as __referrer. + * All framework-internal arguments start with double underscore (__), + * and are only used from within the framework. Not for user consumption. + * Internal Arguments can be objects, in contrast to public arguments + */ + protected array $internalArguments = []; + + /** + * The requested representation format, "html", "xml", "png", "json" or the like. + * Can even be something like "rss.xml". + */ + protected string $format = 'html'; + + /** + * If this request is a forward because of an error, the original request gets filled. + */ + protected ?RequestInterface $originalRequest = null; + + /** + * If the request is a forward because of an error, these mapping results get filled here. + */ + protected ?Result $originalRequestMappingResults = null; + + /** + * @var list + */ + protected array $originalFlashMessages = []; + + /** + * If files were uploaded, this array holds the files + * prefixed for this extension/controller/plugin combination. + */ + protected array $uploadedFiles = []; + + public function __construct(string $controllerClassName = '') + { + $this->controllerObjectName = $controllerClassName; + } + + public function getControllerObjectName(): string + { + return $this->controllerObjectName; + } + + public function setControllerObjectName(string $controllerObjectName): self + { + $nameParts = ClassNamingUtility::explodeObjectControllerName($controllerObjectName); + $this->controllerExtensionName = $nameParts['extensionName']; + $this->controllerName = $nameParts['controllerName']; + return $this; + } + + public function setPluginName(string $pluginName): self + { + $this->pluginName = $pluginName; + return $this; + } + + public function getPluginName(): string + { + return $this->pluginName; + } + + public function setControllerExtensionName(string $controllerExtensionName): self + { + $this->controllerExtensionName = $controllerExtensionName; + return $this; + } + + public function getControllerExtensionName(): string + { + return $this->controllerExtensionName; + } + + public function getControllerExtensionKey(): string + { + return GeneralUtility::camelCaseToLowerCaseUnderscored($this->controllerExtensionName); + } + + public function setControllerAliasToClassNameMapping(array $controllerAliasToClassNameMapping): self + { + // this is only needed as long as forwarded requests are altered and unless there + // is no new request object created by the request builder. + $this->controllerAliasToClassNameMapping = $controllerAliasToClassNameMapping; + return $this; + } + + public function setControllerName(string $controllerName): self + { + $this->controllerName = $controllerName; + // There might be no Controller Class, for example for Fluid Templates. + $this->controllerObjectName = $this->controllerAliasToClassNameMapping[$controllerName] ?? ''; + return $this; + } + + public function getControllerName(): string + { + return $this->controllerName; + } + + public function setControllerActionName(string $actionName): self + { + $this->controllerActionName = $actionName; + return $this; + } + + public function getControllerActionName(): string + { + return $this->controllerActionName; + } + + /** + * @param mixed $value The new value + * @throws InvalidArgumentNameException + */ + public function setArgument(string $argumentName, mixed $value): self + { + if ($argumentName === '') { + throw new InvalidArgumentNameException('Invalid argument name.', 1210858767); + } + if (str_starts_with($argumentName, '__')) { + $this->internalArguments[$argumentName] = $value; + return $this; + } + if (!in_array($argumentName, ['@extension', '@subpackage', '@controller', '@action', '@format'], true)) { + $this->arguments[$argumentName] = $value; + } + return $this; + } + + /** + * Sets the whole arguments array and therefore replaces any arguments which existed before. + * + * @param array $arguments + * @throws InvalidArgumentNameException + */ + public function setArguments(array $arguments): self + { + $this->arguments = []; + foreach ($arguments as $argumentName => $argumentValue) { + $this->setArgument($argumentName, $argumentValue); + } + return $this; + } + + public function getArguments(): array + { + return $this->arguments; + } + + /** + * Returns the value of the specified argument. + * + * @return mixed Value of the argument + * @throws NoSuchArgumentException if such an argument does not exist + */ + public function getArgument(string $argumentName): mixed + { + if (!isset($this->arguments[$argumentName])) { + throw new NoSuchArgumentException('An argument "' . $argumentName . '" does not exist for this request.', 1176558158); + } + return $this->arguments[$argumentName]; + } + + /** + * Checks if an argument of the given name exists (is set) + */ + public function hasArgument(string $argumentName = ''): bool + { + return isset($this->arguments[$argumentName]); + } + + public function setFormat(string $format): self + { + $this->format = $format; + return $this; + } + + public function getFormat(): string + { + return $this->format; + } + + /** + * Returns the original request. Filled only if a property mapping error occurred. + */ + public function getOriginalRequest(): ?RequestInterface + { + return $this->originalRequest; + } + + public function setOriginalRequest(RequestInterface $originalRequest): self + { + $this->originalRequest = $originalRequest; + return $this; + } + + public function getOriginalRequestMappingResults(): Result + { + if ($this->originalRequestMappingResults === null) { + return new Result(); + } + return $this->originalRequestMappingResults; + } + + public function setOriginalRequestMappingResults(Result $originalRequestMappingResults): self + { + $this->originalRequestMappingResults = $originalRequestMappingResults; + return $this; + } + + /** + * @return list + */ + public function getOriginalFlashMessages(): array + { + return $this->originalFlashMessages; + } + + public function setOriginalFlashMessages(FlashMessage ...$originalFlashMessages): self + { + $this->originalFlashMessages = $originalFlashMessages; + return $this; + } + + /** + * Returns the value of the specified argument + * + * @return mixed Value of the argument, or NULL if not set. + */ + public function getInternalArgument($argumentName): mixed + { + if (!isset($this->internalArguments[$argumentName])) { + return null; + } + return $this->internalArguments[$argumentName]; + } + + public function getUploadedFiles(): array + { + return $this->uploadedFiles; + } + + public function setUploadedFiles(array $files): self + { + $this->validateUploadedFiles($files); + $this->uploadedFiles = $files; + return $this; + } + + /** + * Recursively validate the structure in an uploaded files array. + * + * @throws \InvalidArgumentException if any leaf is not an UploadedFileInterface instance. + */ + protected function validateUploadedFiles(array $uploadedFiles): void + { + foreach ($uploadedFiles as $file) { + if (is_array($file)) { + $this->validateUploadedFiles($file); + continue; + } + if (!$file instanceof UploadedFileInterface) { + throw new \InvalidArgumentException('Invalid file in uploaded files structure.', 1647338470); + } + } + } +} diff --git a/Classes/Mvc/Request.php b/Classes/Mvc/Request.php new file mode 100644 index 0000000..dff45b6 --- /dev/null +++ b/Classes/Mvc/Request.php @@ -0,0 +1,391 @@ +getAttribute('extbase') instanceof ExtbaseRequestParameters) { + throw new \InvalidArgumentException( + 'Given request must have an attribute "extbase" of type ExtbaseAttribute', + 1624452070 + ); + } + $this->request = $request; + } + + /** + * ExtbaseAttribute attached as attribute 'extbase' to $request carries extbase + * specific request values. This helper method type hints this attribute. + */ + protected function getExtbaseAttribute(): ExtbaseRequestParameters + { + return $this->request->getAttribute('extbase'); + } + + public function getControllerObjectName(): string + { + return $this->getExtbaseAttribute()->getControllerObjectName(); + } + + /** + * Return an instance with the specified controller object name set. + */ + public function withControllerObjectName(string $controllerObjectName): static + { + $attribute = clone $this->getExtbaseAttribute(); + $attribute->setControllerObjectName($controllerObjectName); + return $this->withAttribute('extbase', $attribute); + } + + /** + * Returns the plugin key. + */ + public function getPluginName(): string + { + return $this->getExtbaseAttribute()->getPluginName(); + } + + /** + * Return an instance with the specified plugin name set. + */ + public function withPluginName(string $pluginName): static + { + $attribute = clone $this->getExtbaseAttribute(); + $attribute->setPluginName($pluginName); + return $this->withAttribute('extbase', $attribute); + } + + /** + * Returns the extension name of the specified controller. + */ + public function getControllerExtensionName(): string + { + return $this->getExtbaseAttribute()->getControllerExtensionName(); + } + + /** + * Return an instance with the specified controller extension name set. + */ + public function withControllerExtensionName(string $controllerExtensionName): static + { + $attribute = clone $this->getExtbaseAttribute(); + $attribute->setControllerExtensionName($controllerExtensionName); + return $this->withAttribute('extbase', $attribute); + } + + /** + * Returns the extension key of the specified controller. + */ + public function getControllerExtensionKey(): string + { + return $this->getExtbaseAttribute()->getControllerExtensionKey(); + } + + /** + * Returns the controller name supposed to handle this request, if one + * was set already (if not, the name of the default controller is returned) + */ + public function getControllerName(): string + { + return $this->getExtbaseAttribute()->getControllerName(); + } + + /** + * Return an instance with the specified controller name set. + */ + public function withControllerName(string $controllerName): static + { + $attribute = clone $this->getExtbaseAttribute(); + $attribute->setControllerName($controllerName); + return $this->withAttribute('extbase', $attribute); + } + + /** + * Returns the name of the action the controller is supposed to execute. + */ + public function getControllerActionName(): string + { + return $this->getExtbaseAttribute()->getControllerActionName(); + } + + /** + * Return an instance with the specified controller action name set. + */ + public function withControllerActionName(string $actionName): static + { + $attribute = clone $this->getExtbaseAttribute(); + $attribute->setControllerActionName($actionName); + return $this->withAttribute('extbase', $attribute); + } + + public function getArguments(): array + { + return $this->getExtbaseAttribute()->getArguments(); + } + + /** + * Return an instance with the specified extbase arguments, replacing + * any arguments which existed before. + */ + public function withArguments(array $arguments): static + { + $attribute = clone $this->getExtbaseAttribute(); + $attribute->setArguments($arguments); + return $this->withAttribute('extbase', $attribute); + } + + public function getArgument(string $argumentName): mixed + { + return $this->getExtbaseAttribute()->getArgument($argumentName); + } + + public function hasArgument(string $argumentName): bool + { + return $this->getExtbaseAttribute()->hasArgument($argumentName); + } + + /** + * Return an instance with the specified argument set. + */ + public function withArgument(string $argumentName, mixed $value): static + { + $attribute = clone $this->getExtbaseAttribute(); + $attribute->setArgument($argumentName, $value); + return $this->withAttribute('extbase', $attribute); + } + + /** + * Returns the requested representation format, something + * like "html", "xml", "png", "json" or the like. + */ + public function getFormat(): string + { + return $this->getExtbaseAttribute()->getFormat(); + } + + /** + * Return an instance with the specified derived request attribute. + * + * This method allows setting a single derived request attribute as + * described in getFormat(). + */ + public function withFormat(string $format): static + { + $attribute = clone $this->getExtbaseAttribute(); + $attribute->setFormat($format); + return $this->withAttribute('extbase', $attribute); + } + + /** + * Methods implementing ServerRequestInterface + */ + public function getServerParams(): array + { + return $this->request->getServerParams(); + } + + public function getCookieParams(): array + { + return $this->request->getCookieParams(); + } + + public function withCookieParams(array $cookies): static + { + $request = $this->request->withCookieParams($cookies); + return new static($request); + } + + public function getQueryParams(): array + { + return $this->request->getQueryParams(); + } + + public function withQueryParams(array $query): static + { + $request = $this->request->withQueryParams($query); + return new static($request); + } + + public function getUploadedFiles(): array + { + return $this->getExtbaseAttribute()->getUploadedFiles(); + } + + public function withUploadedFiles(array $uploadedFiles): static + { + $attribute = clone $this->getExtbaseAttribute(); + $attribute->setUploadedFiles($uploadedFiles); + return $this->withAttribute('extbase', $attribute); + } + + public function getParsedBody() + { + return $this->request->getParsedBody(); + } + + public function withParsedBody($data): static + { + $request = $this->request->withParsedBody($data); + return new static($request); + } + + public function getAttributes(): array + { + return $this->request->getAttributes(); + } + + public function getAttribute($name, $default = null) + { + return $this->request->getAttribute($name, $default); + } + + public function withAttribute($name, $value): static + { + $request = $this->request->withAttribute($name, $value); + return new static($request); + } + + /** + * @return ($name is 'extbase' ? ServerRequestInterface : static) + */ + public function withoutAttribute($name): ServerRequestInterface|static + { + $request = $this->request->withoutAttribute($name); + if ($name === 'extbase') { + return $request; + } + return new static($request); + } + + /** + * Methods implementing RequestInterface + */ + public function getRequestTarget(): string + { + return $this->request->getRequestTarget(); + } + + public function withRequestTarget($requestTarget): static + { + $request = $this->request->withRequestTarget($requestTarget); + return new static($request); + } + + public function getMethod(): string + { + return $this->request->getMethod(); + } + + public function withMethod($method): static + { + $request = $this->request->withMethod($method); + return new static($request); + } + + public function getUri(): UriInterface + { + return $this->request->getUri(); + } + + public function withUri(UriInterface $uri, $preserveHost = false): static + { + $request = $this->request->withUri($uri, $preserveHost); + return new static($request); + } + + /** + * Methods implementing MessageInterface + */ + public function getProtocolVersion(): string + { + return $this->request->getProtocolVersion(); + } + + public function withProtocolVersion($version): static + { + $request = $this->request->withProtocolVersion($version); + return new static($request); + } + + public function getHeaders(): array + { + return $this->request->getHeaders(); + } + + public function hasHeader($name): bool + { + return $this->request->hasHeader($name); + } + + public function getHeader($name): array + { + return $this->request->getHeader($name); + } + + public function getHeaderLine($name): string + { + return $this->request->getHeaderLine($name); + } + + public function withHeader($name, $value): static + { + $request = $this->request->withHeader($name, $value); + return new static($request); + } + + public function withAddedHeader($name, $value): static + { + $request = $this->request->withAddedHeader($name, $value); + return new static($request); + } + + public function withoutHeader($name): static + { + $request = $this->request->withoutHeader($name); + return new static($request); + } + + public function getBody(): StreamInterface + { + return $this->request->getBody(); + } + + public function withBody(StreamInterface $body): static + { + $request = $this->request->withBody($body); + return new static($request); + } +} diff --git a/Classes/Mvc/RequestInterface.php b/Classes/Mvc/RequestInterface.php new file mode 100644 index 0000000..55c3160 --- /dev/null +++ b/Classes/Mvc/RequestInterface.php @@ -0,0 +1,121 @@ + [ + * '_only' => ['property1', 'property2', ...] + * ], + * 'variable2' => [ + * '_exclude' => ['property3', 'property4, ...] + * ], + * 'variable3' => [ + * '_exclude' => ['secretTitle'], + * '_descend' => [ + * 'customer' => [ + * '_only' => ['firstName', 'lastName'] + * ] + * ] + * ], + * 'somearrayvalue' => [ + * '_descendAll' => [ + * '_only' => ['property1'] + * ] + * ] + * ] + * + * Of variable1 only property1 and property2 will be included. + * Of variable2 all properties except property3 and property4 + * are used. + * Of variable3 all properties except secretTitle are included. + * + * If a property value is an array or object, it is not included + * by default. If, however, such a property is listed in a "_descend" + * section, the renderer will descend into this sub structure and + * include all its properties (of the next level). + * + * The configuration of each property in "_descend" has the same syntax + * as the top level. Therefore - theoretically - infinitely nested + * structures can be configured. + * + * To export indexed arrays the "_descendAll" section can be used to + * include all array keys for the output. The configuration inside a + * "_descendAll" will be applied to each array element. + * + * + * Example 2: exposing object identifier + * + * [ + * 'variableFoo' => [ + * '_exclude' => ['secretTitle'], + * '_descend' => [ + * 'customer' => [ // consider 'customer' being a persisted entity + * '_only' => ['firstName'], + * '_exposeObjectIdentifier' => TRUE, + * '_exposedObjectIdentifierKey' => 'guid' + * ] + * ] + * ] + * ] + * + * Note for entity objects you are able to expose the object's identifier + * also, just add an "_exposeObjectIdentifier" directive set to TRUE and + * an additional property '__identity' will appear keeping the persistence + * identifier. Renaming that property name instead of '__identity' is also + * possible with the directive "_exposedObjectIdentifierKey". + * Example 2 above would output (summarized): + * {"customer":{"firstName":"John","guid":"892693e4-b570-46fe-af71-1ad32918fb64"}} + * + * + * Example 3: exposing object's class name + * + * [ + * 'variableFoo' => [ + * '_exclude' => ['secretTitle'], + * '_descend' => [ + * 'customer' => [ // consider 'customer' being an object + * '_only' => ['firstName'], + * '_exposeClassName' => \TYPO3\CMS\Extbase\Mvc\View\JsonView::EXPOSE_CLASSNAME_FULLY_QUALIFIED + * ] + * ] + * ] + * ] + * + * The ``_exposeClassName`` is similar to the objectIdentifier one, but the class name is added to the + * JSON object output, for example (summarized): + * {"customer":{"firstName":"John","__class":"Acme\Foo\Domain\Model\Customer"}} + * + * The other option is EXPOSE_CLASSNAME_UNQUALIFIED which only will give the last part of the class + * without the namespace, for example (summarized): + * {"customer":{"firstName":"John","__class":"Customer"}} + * This might be of interest to not provide information about the package or domain structure behind. + */ + protected array $configuration = []; + + protected PersistenceManagerInterface $persistenceManager; + + /** + * View variables and their values + */ + protected array $variables = []; + + /** + * @internal + */ + public function injectPersistenceManager(PersistenceManagerInterface $persistenceManager): void + { + $this->persistenceManager = $persistenceManager; + } + + /** + * Add a variable to $this->viewData. + * Can be chained, so $this->view->assign(..., ...)->assign(..., ...); is possible + * + * @param string $key Key of variable + * @param mixed $value Value of object + * @return self an instance of $this, to enable chaining + */ + public function assign(string $key, mixed $value): ViewInterface + { + $this->variables[$key] = $value; + return $this; + } + + /** + * Add multiple variables to $this->viewData. + * + * @param array $values array in the format array(key1 => value1, key2 => value2). + * @return self an instance of $this, to enable chaining + */ + public function assignMultiple(array $values): ViewInterface + { + foreach ($values as $key => $value) { + $this->assign($key, $value); + } + return $this; + } + + /** + * Specifies which variables this JsonView should render + * By default only the variable 'value' will be rendered + * + * @param string[] $variablesToRender + */ + public function setVariablesToRender(array $variablesToRender): void + { + $this->variablesToRender = $variablesToRender; + } + + /** + * @param array $configuration The rendering configuration for this JSON view + */ + public function setConfiguration(array $configuration): void + { + $this->configuration = $configuration; + } + + /** + * Transforms the value view variable to a serializable + * array representation using a YAML view configuration and JSON encodes + * the result. + * + * @return string The JSON encoded variables + */ + public function render(string $templateFileName = ''): string + { + $propertiesToRender = $this->renderArray(); + return json_encode($propertiesToRender, JSON_UNESCAPED_UNICODE); + } + + /** + * Loads the configuration and transforms the value to a serializable array. + */ + protected function renderArray(): mixed + { + if (count($this->variablesToRender) === 1) { + $firstLevel = false; + $variableName = current($this->variablesToRender); + $this->currentVariable = $variableName; + $valueToRender = $this->variables[$variableName] ?? null; + $configuration = $this->configuration[$variableName] ?? []; + } else { + $firstLevel = true; + $valueToRender = []; + foreach ($this->variablesToRender as $variableName) { + $valueToRender[$variableName] = $this->variables[$variableName] ?? null; + } + $configuration = $this->configuration; + } + return $this->transformValue($valueToRender, $configuration, $firstLevel); + } + + /** + * Transforms a value depending on type recursively using the + * supplied configuration. + * + * @param mixed $value The value to transform + * @param array $configuration Configuration for transforming the value + * @return mixed The transformed value + */ + protected function transformValue(mixed $value, array $configuration, bool $firstLevel = false): mixed + { + // ObjectStorage returns $key as string, which causes the resulting JSON to be an object instead of the expected array + if ($value instanceof ObjectStorage) { + $value = $value->toArray(); + } + if (is_array($value) || $value instanceof \ArrayAccess) { + $array = []; + foreach ($value as $key => $element) { + if ($firstLevel) { + $this->currentVariable = $key; + } + if (isset($configuration['_descendAll']) && is_array($configuration['_descendAll'])) { + $array[$key] = $this->transformValue($element, $configuration['_descendAll']); + } else { + if (isset($configuration['_only']) && is_array($configuration['_only']) && !in_array($key, $configuration['_only'], true)) { + continue; + } + if (isset($configuration['_exclude']) && is_array($configuration['_exclude']) && in_array($key, $configuration['_exclude'], true)) { + continue; + } + $array[$key] = $this->transformValue($element, $configuration[$key] ?? []); + } + } + return $array; + } + if (is_object($value)) { + return $this->transformObject($value, $configuration); + } + return $value; + } + + /** + * Traverses the given object structure in order to transform it into an array structure. + * + * @param object $object Object to traverse + * @param array $configuration Configuration for transforming the given object or NULL + * @return array|string Object structure as an array or as a rendered string (for a DateTime instance) + */ + protected function transformObject(object $object, array $configuration): array|string + { + if ($object instanceof \DateTimeInterface) { + return $object->format(\DateTimeInterface::ATOM); + } + $propertyNames = ObjectAccess::getGettablePropertyNames($object); + $propertiesToRender = []; + foreach ($propertyNames as $propertyName) { + if (isset($configuration['_only']) && is_array($configuration['_only']) && !in_array($propertyName, $configuration['_only'], true)) { + continue; + } + if (isset($configuration['_exclude']) && is_array($configuration['_exclude']) && in_array($propertyName, $configuration['_exclude'], true)) { + continue; + } + $propertyValue = ObjectAccess::getProperty($object, $propertyName); + if (!is_array($propertyValue) && !is_object($propertyValue)) { + $propertiesToRender[$propertyName] = $propertyValue; + } elseif (isset($configuration['_descend']) && array_key_exists($propertyName, $configuration['_descend'])) { + $propertiesToRender[$propertyName] = $this->transformValue($propertyValue, $configuration['_descend'][$propertyName]); + } elseif (isset($configuration['_recursive']) && in_array($propertyName, $configuration['_recursive'])) { + $propertiesToRender[$propertyName] = $this->transformValue($propertyValue, $this->configuration[$this->currentVariable]); + } + } + if (isset($configuration['_exposeObjectIdentifier']) && $configuration['_exposeObjectIdentifier'] === true) { + if (isset($configuration['_exposedObjectIdentifierKey']) && strlen($configuration['_exposedObjectIdentifierKey']) > 0) { + $identityKey = $configuration['_exposedObjectIdentifierKey']; + } else { + $identityKey = '__identity'; + } + $propertiesToRender[$identityKey] = $this->persistenceManager->getIdentifierByObject($object); + } + if (isset($configuration['_exposeClassName']) && ($configuration['_exposeClassName'] === self::EXPOSE_CLASSNAME_FULLY_QUALIFIED || $configuration['_exposeClassName'] === self::EXPOSE_CLASSNAME_UNQUALIFIED)) { + $className = get_class($object); + $classNameParts = explode('\\', $className); + $propertiesToRender['__class'] = ($configuration['_exposeClassName'] === self::EXPOSE_CLASSNAME_FULLY_QUALIFIED ? $className : array_pop($classNameParts)); + } + return $propertiesToRender; + } +} diff --git a/Classes/Mvc/Web/RequestBuilder.php b/Classes/Mvc/Web/RequestBuilder.php new file mode 100644 index 0000000..26a5e3d --- /dev/null +++ b/Classes/Mvc/Web/RequestBuilder.php @@ -0,0 +1,222 @@ +getAttribute('module'); + if ($module instanceof ExtbaseModule) { + $configuration = [ + 'controllerConfiguration' => $module->getControllerActions(), + ]; + $useArgumentsWithoutNamespace = true; + // Ensure the "controller" and "action" information are added as fallback parameters. + if ($routeOptions = $mainRequest->getAttribute('route')?->getOptions()) { + $fallbackParameters['controller'] = $routeOptions['controller'] ?? null; + $fallbackParameters['action'] = $routeOptions['action']; + } + } + $defaultValues = $this->loadDefaultValues($configuration); + $pluginNamespace = $this->extensionService->getPluginNamespace( + $defaultValues->getExtensionName(), + $defaultValues->getPluginName() + ); + $queryArguments = $mainRequest->getAttribute('routing'); + if ($useArgumentsWithoutNamespace) { + $parameters = $mainRequest->getQueryParams(); + } elseif ($queryArguments instanceof PageArguments) { + $parameters = $queryArguments->get($pluginNamespace) ?? []; + } else { + $parameters = $mainRequest->getQueryParams()[$pluginNamespace] ?? []; + } + $parameters = is_array($parameters) ? $parameters : []; + if ($fallbackParameters !== []) { + // Enhance with fallback parameters, such as "controller" and "action" + $parameters = array_replace_recursive($fallbackParameters, $parameters); + } + if ($mainRequest->getMethod() === 'POST') { + if ($useArgumentsWithoutNamespace) { + $postParameters = $mainRequest->getParsedBody(); + } else { + $postParameters = $mainRequest->getParsedBody()[$pluginNamespace] ?? []; + } + $postParameters = is_array($postParameters) ? $postParameters : []; + $parameters = array_replace_recursive($parameters, $postParameters); + } + + $files = $mainRequest->getUploadedFiles(); + if (!$useArgumentsWithoutNamespace) { + $files = $files[$pluginNamespace] ?? []; + if ($files instanceof UploadedFile) { + throw new InvalidArgumentNameException( + 'Using only the plugin namespace as argument name is not allowed for uploaded files. Please use plugin_namespace[argument_name] instead.', + 1722542546 + ); + } + } + + // Merge UploadedFiles into request parameters, so that they are available as arguments + // for property mapping (e.g. in ext:form or a custom file upload TypeConverter). + $parameters = array_replace_recursive($parameters, $files); + + $controllerClassName = $this->resolveControllerClassName($defaultValues, $parameters); + $actionName = $this->resolveActionName($defaultValues, $controllerClassName, $parameters); + + $extbaseAttribute = new ExtbaseRequestParameters(); + $extbaseAttribute->setPluginName($defaultValues->getPluginName()); + $extbaseAttribute->setControllerExtensionName($defaultValues->getExtensionName()); + $extbaseAttribute->setControllerAliasToClassNameMapping($defaultValues->getControllerAliasToClassMapping()); + $extbaseAttribute->setControllerName($defaultValues->getControllerAliasForControllerClassName($controllerClassName)); + $extbaseAttribute->setControllerActionName($actionName); + $extbaseAttribute->setUploadedFiles($files); + + if (isset($parameters['format']) && is_string($parameters['format']) && $parameters['format'] !== '') { + $extbaseAttribute->setFormat(preg_replace('/[^a-zA-Z0-9]+/', '', $parameters['format'])); + } else { + $extbaseAttribute->setFormat($defaultValues->getDefaultFormat()); + } + foreach ($parameters as $argumentName => $argumentValue) { + $extbaseAttribute->setArgument($argumentName, $argumentValue); + } + return new Request($mainRequest->withAttribute('extbase', $extbaseAttribute)); + } + + /** + * @throws MvcException + */ + protected function loadDefaultValues(array $configuration = []): RequestBuilderDefaultValues + { + // todo: See comment in \TYPO3\CMS\Extbase\Core\Bootstrap::initializeConfiguration for further explanation + // todo: on why we shouldn't use the configuration manager here. + $configuration = array_replace_recursive($this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FRAMEWORK), $configuration); + try { + return RequestBuilderDefaultValues::fromConfiguration($configuration); + } catch (\InvalidArgumentException $e) { + throw MvcException::fromPrevious($e); + } + } + + /** + * Returns the current ControllerName extracted from given $parameters. + * If no controller is specified, the defaultControllerName will be returned. + * If that's not available, an exception is thrown. + * + * @throws InvalidControllerNameException + * @throws MvcException if the controller could not be resolved + * @throws PageNotFoundException + * @return class-string + */ + protected function resolveControllerClassName(RequestBuilderDefaultValues $defaultValues, array $parameters): string + { + if (!isset($parameters['controller']) || $parameters['controller'] === '') { + return $defaultValues->getDefaultControllerClassName(); + } + $controllerClassName = $defaultValues->getControllerClassNameForAlias($parameters['controller']) ?? ''; + if ($defaultValues->getAllowedControllerActionsOfController($controllerClassName) === []) { + $configuration = $this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FRAMEWORK); + if (isset($configuration['mvc']['throwPageNotFoundExceptionIfActionCantBeResolved']) && (bool)$configuration['mvc']['throwPageNotFoundExceptionIfActionCantBeResolved']) { + throw new PageNotFoundException('The requested resource was not found', 1313857897); + } + if (isset($configuration['mvc']['callDefaultActionIfActionCantBeResolved']) && (bool)$configuration['mvc']['callDefaultActionIfActionCantBeResolved']) { + return $defaultValues->getDefaultControllerClassName(); + } + throw new InvalidControllerNameException( + 'The controller "' . $parameters['controller'] . '" is not allowed by plugin "' . $defaultValues->getPluginName() . '". Please check for TYPO3\\CMS\\Extbase\\Utility\\ExtensionUtility::configurePlugin() in your ext_localconf.php.', + 1313855173 + ); + } + return preg_replace('/[^a-zA-Z0-9\\\\]+/', '', $controllerClassName); + } + + /** + * Returns the current actionName extracted from given $parameters. + * If no action is specified, the defaultActionName will be returned. + * If that's not available or the specified action is not defined in the current plugin, an exception is thrown. + * + * @param class-string $controllerClassName + * @throws InvalidActionNameException + * @throws MvcException + * @throws PageNotFoundException + * @return non-empty-string + */ + protected function resolveActionName(RequestBuilderDefaultValues $defaultValues, string $controllerClassName, array $parameters): string + { + $defaultActionName = $defaultValues->getDefaultActionName($controllerClassName); + if (!isset($parameters['action']) || $parameters['action'] === '') { + if ($defaultActionName === '') { + throw new MvcException('The default action can not be determined for controller "' . $controllerClassName . '". Please check TYPO3\\CMS\\Extbase\\Utility\\ExtensionUtility::configurePlugin() in your ext_localconf.php.', 1295479651); + } + return $defaultActionName; + } + $actionName = $parameters['action']; + $allowedActionNames = $defaultValues->getAllowedControllerActionsOfController($controllerClassName); + if (!in_array($actionName, $allowedActionNames)) { + $configuration = $this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FRAMEWORK); + if (isset($configuration['mvc']['throwPageNotFoundExceptionIfActionCantBeResolved']) && (bool)$configuration['mvc']['throwPageNotFoundExceptionIfActionCantBeResolved']) { + throw new PageNotFoundException('The requested resource was not found', 1313857898); + } + if (isset($configuration['mvc']['callDefaultActionIfActionCantBeResolved']) && (bool)$configuration['mvc']['callDefaultActionIfActionCantBeResolved']) { + if ($defaultActionName === '') { + throw new MvcException('The default action can not be determined for controller "' . $controllerClassName . '". Please check TYPO3\\CMS\\Extbase\\Utility\\ExtensionUtility::configurePlugin() in your ext_localconf.php.', 1679048627); + } + return $defaultActionName; + } + throw new InvalidActionNameException('The action "' . $actionName . '" (controller "' . $controllerClassName . '") is not allowed by this plugin / module. Please check TYPO3\\CMS\\Extbase\\Utility\\ExtensionUtility::configurePlugin() in your ext_localconf.php / array key "controllerActions" defined in your Configuration/Backend/Modules.php.', 1313855175); + } + return preg_replace('/[^a-zA-Z0-9]+/', '', $actionName); + } +} diff --git a/Classes/Mvc/Web/RequestBuilderDefaultValues.php b/Classes/Mvc/Web/RequestBuilderDefaultValues.php new file mode 100644 index 0000000..0a98981 --- /dev/null +++ b/Classes/Mvc/Web/RequestBuilderDefaultValues.php @@ -0,0 +1,247 @@ + $controllerConfiguration) { + if (!is_string($controllerClassName) || $controllerClassName === '') { + continue; + } + + if (!is_array($controllerConfiguration)) { + continue; + } + + $actions = $controllerConfiguration['actions'] ?? []; + $actions = is_array($actions) ? $actions : []; + + if ($actions === []) { + continue; + } + + $controllerClassName = $controllerConfiguration['className'] ?? null; + $controllerClassName = is_string($controllerClassName) && $controllerClassName !== '' ? $controllerClassName : null; + + if ($controllerClassName === null) { + continue; + } + + $controllerAlias = $controllerConfiguration['alias'] ?? null; + $controllerAlias = is_string($controllerAlias) && $controllerAlias !== '' ? $controllerAlias : null; + + if ($controllerAlias === null) { + continue; + } + + $allowedControllerActions[$controllerClassName] = $actions; + $controllerClassToAliasMapping[$controllerClassName] = $controllerAlias; + $controllerAliasToClassMapping[$controllerAlias] = $controllerClassName; + + if ($firstItem) { + $defaultControllerClassName = $controllerClassName; + $defaultControllerAlias = $controllerAlias; + } + + $firstItem = false; + } + + if ($defaultControllerClassName === null || $defaultControllerAlias === null) { + throw new \LogicException( + 'Either $defaultControllerClassName or $defaultControllerAlias are unexpectedly null', + 1679051921 + ); + } + + if ($allowedControllerActions === []) { + throw new \LengthException( + '$allowedControllerActions is expected to not be empty', + 1679051891 + ); + } + + return new self( + $extensionName, + $pluginName, + $defaultControllerClassName, + $defaultControllerAlias, + $defaultFormat, + $allowedControllerActions, + $controllerAliasToClassMapping, + $controllerClassToAliasMapping, + ); + } + + /** + * @return non-empty-string + */ + public function getExtensionName(): string + { + return $this->extensionName; + } + + /** + * @return non-empty-string + */ + public function getPluginName(): string + { + return $this->pluginName; + } + + /** + * @return class-string + */ + public function getDefaultControllerClassName(): string + { + return $this->defaultControllerClassName; + } + + /** + * @return non-empty-string + */ + public function getDefaultControllerAlias(): string + { + return $this->defaultControllerAlias; + } + + /** + * @return non-empty-string + */ + public function getDefaultFormat(): string + { + return $this->defaultFormat; + } + + /** + * @return array> + */ + public function getAllowedControllerActions(): array + { + return $this->allowedControllerActions; + } + + /** + * @return list + */ + public function getAllowedControllerActionsOfController(string $controllerClassName): array + { + return $this->allowedControllerActions[$controllerClassName] ?? []; + } + + /** + * @return array + */ + public function getControllerAliasToClassMapping(): array + { + return $this->controllerAliasToClassMapping; + } + + /** + * @return array + */ + public function getControllerClassToAliasMapping(): array + { + return $this->controllerClassToAliasMapping; + } + + /** + * @param non-empty-string $controllerAlias + * @return class-string|null + */ + public function getControllerClassNameForAlias(string $controllerAlias): ?string + { + return $this->controllerAliasToClassMapping[$controllerAlias] ?? null; + } + + /** + * @param class-string $controllerClassName + * @return non-empty-string|null + */ + public function getControllerAliasForControllerClassName(string $controllerClassName): ?string + { + return $this->controllerClassToAliasMapping[$controllerClassName] ?? null; + } + + public function getDefaultActionName(string $controllerClassName): ?string + { + $actions = $this->allowedControllerActions[$controllerClassName] ?? []; + return $actions[0] ?? null; + } +} diff --git a/Classes/Mvc/Web/Routing/UriBuilder.php b/Classes/Mvc/Web/Routing/UriBuilder.php new file mode 100644 index 0000000..1fd295f --- /dev/null +++ b/Classes/Mvc/Web/Routing/UriBuilder.php @@ -0,0 +1,647 @@ +request = $request; + return $this; + } + + /** + * Additional query parameters. + * If you want to "prefix" arguments, you can pass in multidimensional arrays: + * array('prefix1' => array('foo' => 'bar')) gets "&prefix1[foo]=bar" + * + * @return static the current UriBuilder to allow method chaining + */ + public function setArguments(array $arguments): UriBuilder + { + $this->arguments = $arguments; + return $this; + } + + /** + * @internal + */ + public function getArguments(): array + { + return $this->arguments; + } + + /** + * If specified, adds a given HTML anchor to the URI (#...) + * + * @return static the current UriBuilder to allow method chaining + */ + public function setSection(string $section): UriBuilder + { + $this->section = $section; + return $this; + } + + /** + * @internal + */ + public function getSection(): string + { + return $this->section; + } + + /** + * Specifies the format of the target (e.g. "html" or "xml") + * + * @return static the current UriBuilder to allow method chaining + */ + public function setFormat(string $format): UriBuilder + { + $this->format = $format; + return $this; + } + + /** + * @internal + */ + public function getFormat(): string + { + return $this->format; + } + + /** + * If set, the URI is prepended with the current base URI. Defaults to FALSE. + * + * @return static the current UriBuilder to allow method chaining + */ + public function setCreateAbsoluteUri(bool $createAbsoluteUri): UriBuilder + { + $this->createAbsoluteUri = $createAbsoluteUri; + return $this; + } + + /** + * @internal + */ + public function getCreateAbsoluteUri(): bool + { + return $this->createAbsoluteUri; + } + + /** + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ + public function getAbsoluteUriScheme(): ?string + { + return $this->absoluteUriScheme; + } + + /** + * Sets the scheme that should be used for absolute URIs in FE mode + * + * @param string $absoluteUriScheme the scheme to be used for absolute URIs + * @return static the current UriBuilder to allow method chaining + */ + public function setAbsoluteUriScheme(string $absoluteUriScheme): UriBuilder + { + $this->absoluteUriScheme = $absoluteUriScheme; + return $this; + } + + /** + * Enforces a URI / link to a page to a specific language (or use "current") + */ + public function setLanguage(?string $language): UriBuilder + { + $this->language = $language; + return $this; + } + + /** + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ + public function getLanguage(): ?string + { + return $this->language; + } + + /** + * If set, the current query parameters will be merged with $this->arguments in backend context. + * In frontend context, setting this property will only include mapped query arguments from the + * Page Routing. To include any - possible "unsafe" - GET parameters, the property has to be set + * to "untrusted". Defaults to FALSE. + * + * @param bool|string|int $addQueryString is set to "1", "true", "0", "false" or "untrusted" + * @return static the current UriBuilder to allow method chaining + * @see https://docs.typo3.org/m/typo3/reference-typoscript/main/en-us/Functions/Typolink.html#addquerystring + */ + public function setAddQueryString(bool|string|int $addQueryString): UriBuilder + { + $this->addQueryString = $addQueryString; + return $this; + } + + /** + * @internal + */ + public function getAddQueryString(): bool|string|int + { + return $this->addQueryString; + } + + /** + * A list of arguments to be excluded from the query parameters + * Only active if addQueryString is set + * + * @return static the current UriBuilder to allow method chaining + * @see https://docs.typo3.org/m/typo3/reference-typoscript/main/en-us/Functions/Typolink.html#addquerystring + * @see setAddQueryString() + */ + public function setArgumentsToBeExcludedFromQueryString(array $argumentsToBeExcludedFromQueryString): UriBuilder + { + $this->argumentsToBeExcludedFromQueryString = $argumentsToBeExcludedFromQueryString; + return $this; + } + + /** + * @internal + */ + public function getArgumentsToBeExcludedFromQueryString(): array + { + return $this->argumentsToBeExcludedFromQueryString; + } + + /** + * Specifies the prefix to be used for all arguments. + * + * @return static the current UriBuilder to allow method chaining + */ + public function setArgumentPrefix(string $argumentPrefix): UriBuilder + { + $this->argumentPrefix = $argumentPrefix; + return $this; + } + + /** + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ + public function getArgumentPrefix(): ?string + { + return $this->argumentPrefix; + } + + /** + * If set, URIs for pages without access permissions will be created + * + * @return static the current UriBuilder to allow method chaining + */ + public function setLinkAccessRestrictedPages(bool $linkAccessRestrictedPages): UriBuilder + { + $this->linkAccessRestrictedPages = $linkAccessRestrictedPages; + return $this; + } + + /** + * @internal + */ + public function getLinkAccessRestrictedPages(): bool + { + return $this->linkAccessRestrictedPages; + } + + /** + * Uid of the target page + * + * @return static the current UriBuilder to allow method chaining + */ + public function setTargetPageUid(int $targetPageUid): UriBuilder + { + $this->targetPageUid = $targetPageUid; + return $this; + } + + /** + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ + public function getTargetPageUid(): ?int + { + return $this->targetPageUid; + } + + /** + * Sets the page type of the target URI. Defaults to 0 + * + * @return static the current UriBuilder to allow method chaining + */ + public function setTargetPageType(int $targetPageType): UriBuilder + { + $this->targetPageType = $targetPageType; + return $this; + } + + /** + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ + public function getTargetPageType(): int + { + return $this->targetPageType; + } + + /** + * by default FALSE; if TRUE, &no_cache=1 will be appended to the URI + * + * @return static the current UriBuilder to allow method chaining + */ + public function setNoCache(bool $noCache): UriBuilder + { + $this->noCache = $noCache; + return $this; + } + + /** + * @internal + */ + public function getNoCache(): bool + { + return $this->noCache; + } + + /** + * Returns the arguments being used for the last URI being built. + * This is only set after build() / uriFor() has been called. + * + * @return array The last arguments + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ + public function getLastArguments(): array + { + return $this->lastArguments; + } + + /** + * Resets all UriBuilder options to their default value + * + * @return static the current UriBuilder to allow method chaining + */ + public function reset(): UriBuilder + { + $this->arguments = []; + $this->section = ''; + $this->format = ''; + $this->language = null; + $this->createAbsoluteUri = false; + $this->addQueryString = false; + $this->argumentsToBeExcludedFromQueryString = []; + $this->linkAccessRestrictedPages = false; + $this->targetPageUid = null; + $this->targetPageType = 0; + $this->noCache = false; + $this->argumentPrefix = null; + $this->absoluteUriScheme = null; + // $this->request MUST NOT be reset here because the request is actually a hard dependency + // and not part of the internal state of this object. + return $this; + } + + /** + * Creates a URI used for linking to an Extbase action. + * Works in Frontend and Backend mode of TYPO3. + * + * @param string|null $actionName Name of the action to be called + * @param array|null $controllerArguments Additional query parameters. Will be "namespaced" and merged with $this->arguments. + * @param string|null $controllerName Name of the target controller. If not set, current ControllerName is used. + * @param string|null $extensionName Name of the target extension, without underscores. If not set, current ExtensionName is used. + * @param string|null $pluginName Name of the target plugin. If not set, current PluginName is used. + * @return string the rendered URI + * @see build() + */ + public function uriFor( + ?string $actionName = null, + ?array $controllerArguments = null, + ?string $controllerName = null, + ?string $extensionName = null, + ?string $pluginName = null + ): string { + $controllerArguments = $controllerArguments ?? []; + + if ($actionName !== null) { + $controllerArguments['action'] = $actionName; + } + if ($controllerName !== null) { + $controllerArguments['controller'] = $controllerName; + } else { + $controllerArguments['controller'] = $this->request->getControllerName(); + } + if ($extensionName === null) { + $extensionName = $this->request->getControllerExtensionName(); + } + $isFrontend = ApplicationType::fromRequest($this->request)->isFrontend(); + if ($pluginName === null && $isFrontend) { + $pluginName = $this->extensionService->getPluginNameByAction($extensionName, $controllerArguments['controller'], $controllerArguments['action'] ?? null); + } + if ($pluginName === null) { + $pluginName = $this->request->getPluginName(); + } + if ($this->targetPageUid === null && $isFrontend) { + $this->targetPageUid = $this->extensionService->getTargetPidByPlugin($extensionName, $pluginName); + } + if ($this->format !== '') { + $controllerArguments['format'] = $this->format; + } + if ($this->argumentPrefix !== null) { + $prefixedControllerArguments = [$this->argumentPrefix => $controllerArguments]; + } elseif (!$isFrontend) { + $prefixedControllerArguments = $controllerArguments; + // Backend UriBuilder needs the route, which usually maps to the "route" parameter, which can be + // found in "Configuration/Backend/Modules.php" as the main key - that is the actual base route + // for the backend module, which in Extbase-speak is called a "pluginName" + $prefixedControllerArguments['route'] = $pluginName; + } else { + $pluginNamespace = $this->extensionService->getPluginNamespace($extensionName, $pluginName); + $prefixedControllerArguments = [$pluginNamespace => $controllerArguments]; + } + ArrayUtility::mergeRecursiveWithOverrule($this->arguments, $prefixedControllerArguments); + return $this->build(); + } + + /** + * Builds the URI + * Depending on the current context this calls buildBackendUri() or buildFrontendUri() + * + * @return string The URI + * @see buildBackendUri() + * @see buildFrontendUri() + */ + public function build(): string + { + if (ApplicationType::fromRequest($this->request)->isBackend()) { + return $this->buildBackendUri(); + } + return $this->buildFrontendUri(); + } + + /** + * Builds the URI, backend flavour + * The settings pageUid, pageType, noCache & linkAccessRestrictedPages + * will be ignored in the backend. + * + * @return string The URI + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ + public function buildBackendUri(): string + { + $arguments = []; + if ($this->addQueryString && $this->addQueryString !== 'false') { + $arguments = $this->request->getQueryParams(); + foreach ($this->argumentsToBeExcludedFromQueryString as $argumentToBeExcluded) { + $argumentArrayToBeExcluded = []; + parse_str($argumentToBeExcluded, $argumentArrayToBeExcluded); + $arguments = ArrayUtility::arrayDiffKeyRecursive($arguments, $argumentArrayToBeExcluded); + } + } else { + $id = $this->request->getParsedBody()['id'] ?? $this->request->getQueryParams()['id'] ?? null; + if ($id !== null) { + $arguments['id'] = $id; + } + } + if (($route = $this->request->getAttribute('route')) instanceof Route) { + /** @var Route $route */ + $arguments['route'] = $route->getOption('_identifier'); + } + $arguments = array_replace_recursive($arguments, $this->arguments); + $arguments = $this->convertDomainObjectsToIdentityArrays($arguments); + $this->lastArguments = $arguments; + $routeIdentifier = $arguments['route'] ?? null; + unset($arguments['route'], $arguments['token']); + + // In case the current route identifier is an identifier of a sub route, remove the sub route + // part to be able to add the actually requested sub route based on the current arguments. + if ($routeIdentifier && str_contains($routeIdentifier, '.')) { + [$routeIdentifier] = explode('.', $routeIdentifier); + } + // Build route identifier to the actually requested sub route (controller / action pair) - if any - + // and unset corresponding arguments. + if ($routeIdentifier && isset($arguments['controller'], $arguments['action'])) { + $routeIdentifier .= '.' . $arguments['controller'] . '_' . $arguments['action']; + unset($arguments['controller'], $arguments['action']); + } + $uri = ''; + if ($routeIdentifier) { + $backendUriBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Backend\Routing\UriBuilder::class); + try { + if ($this->createAbsoluteUri) { + $uri = (string)$backendUriBuilder->buildUriFromRoute($routeIdentifier, $arguments, \TYPO3\CMS\Backend\Routing\UriBuilder::ABSOLUTE_URL); + } else { + $uri = (string)$backendUriBuilder->buildUriFromRoute($routeIdentifier, $arguments); + } + } catch (RouteNotFoundException) { + // empty URL + } + } + if ($this->section !== '') { + $uri .= '#' . $this->section; + } + return $uri; + } + + /** + * Builds the URI, frontend flavour + * + * @return string The URI + * @see buildTypolinkConfiguration() + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ + public function buildFrontendUri(): string + { + $typolinkConfiguration = $this->buildTypolinkConfiguration(); + if ($this->createAbsoluteUri === true) { + $typolinkConfiguration['forceAbsoluteUrl'] = true; + if ($this->absoluteUriScheme !== null) { + $typolinkConfiguration['forceAbsoluteUrl.']['scheme'] = $this->absoluteUriScheme; + } + } + /** @var ?ContentObjectRenderer $currentContentObject */ + $currentContentObject = $this->request->getAttribute('currentContentObject'); + return $currentContentObject?->createUrl($typolinkConfiguration) ?? ''; + } + + /** + * Builds a TypoLink configuration array from the current settings + * + * @return array typolink configuration array + * @see https://docs.typo3.org/m/typo3/reference-typoscript/main/en-us/Functions/Typolink.html + */ + protected function buildTypolinkConfiguration(): array + { + $typolinkConfiguration = []; + $typolinkConfiguration['parameter'] = $this->targetPageUid ?? $this->request->getAttribute('frontend.page.information')?->getId() ?? ''; + if ($this->targetPageType !== 0) { + $typolinkConfiguration['parameter'] .= ',' . $this->targetPageType; + } elseif ($this->format !== '') { + $targetPageType = $this->extensionService->getTargetPageTypeByFormat($this->request->getControllerExtensionName(), $this->format); + $typolinkConfiguration['parameter'] .= ',' . $targetPageType; + } + if (!empty($this->arguments)) { + $arguments = $this->convertDomainObjectsToIdentityArrays($this->arguments); + $this->lastArguments = $arguments; + $typolinkConfiguration['queryParameters'] = $arguments; + } + if ($this->addQueryString && $this->addQueryString !== 'false') { + $typolinkConfiguration['addQueryString'] = $this->addQueryString; + if (!empty($this->argumentsToBeExcludedFromQueryString)) { + $typolinkConfiguration['addQueryString.'] = [ + 'exclude' => implode(',', $this->argumentsToBeExcludedFromQueryString), + ]; + } + } + if ($this->language !== null) { + $typolinkConfiguration['language'] = $this->language; + } + if ($this->noCache === true) { + $typolinkConfiguration['no_cache'] = 1; + } + if ($this->section !== '') { + $typolinkConfiguration['section'] = $this->section; + } + if ($this->linkAccessRestrictedPages === true) { + $typolinkConfiguration['linkAccessRestrictedPages'] = 1; + } + return $typolinkConfiguration; + } + + /** + * Recursively iterates through the specified arguments and turns instances of type \TYPO3\CMS\Extbase\DomainObject\AbstractEntity + * into an arrays containing the uid of the domain object. + * + * @param array $arguments The arguments to be iterated + * @throws InvalidArgumentValueException + * @return array The modified arguments array + */ + protected function convertDomainObjectsToIdentityArrays(array $arguments): array + { + foreach ($arguments as $argumentKey => $argumentValue) { + // if we have a LazyLoadingProxy here, make sure to get the real instance for further processing + if ($argumentValue instanceof LazyLoadingProxy) { + $argumentValue = $argumentValue->_loadRealInstance(); + // also update the value in the arguments array, because the lazyLoaded object could be + // hidden and thus the $argumentValue would be NULL. + $arguments[$argumentKey] = $argumentValue; + } + if ($argumentValue instanceof \Iterator) { + $argumentValue = $this->convertIteratorToArray($argumentValue); + } + if ($argumentValue instanceof DomainObjectInterface) { + if ($argumentValue->getUid() !== null) { + $arguments[$argumentKey] = $argumentValue->getUid(); + } elseif ($argumentValue instanceof AbstractValueObject) { + $arguments[$argumentKey] = $this->convertTransientObjectToArray($argumentValue); + } else { + throw new InvalidArgumentValueException('Could not serialize Domain Object ' . get_class($argumentValue) . '. It is neither an Entity with identity properties set, nor a Value Object.', 1260881688); + } + } elseif (is_array($argumentValue)) { + $arguments[$argumentKey] = $this->convertDomainObjectsToIdentityArrays($argumentValue); + } elseif ($argumentValue instanceof \UnitEnum) { + $arguments[$argumentKey] = $argumentValue->value ?? $argumentValue->name; + } elseif ($argumentValue instanceof \Stringable) { + $arguments[$argumentKey] = (string)$argumentValue; + } + } + return $arguments; + } + + protected function convertIteratorToArray(\Iterator $iterator): array + { + if (method_exists($iterator, 'toArray')) { + $array = $iterator->toArray(); + } else { + $array = iterator_to_array($iterator); + } + return $array; + } + + /** + * Converts a given object recursively into an array. + * + * @todo Refactor this into convertDomainObjectsToIdentityArrays() + */ + protected function convertTransientObjectToArray(DomainObjectInterface $object): array + { + $result = []; + foreach ($object->_getProperties() as $propertyName => $propertyValue) { + if ($propertyValue instanceof \Iterator) { + $propertyValue = $this->convertIteratorToArray($propertyValue); + } + if ($propertyValue instanceof DomainObjectInterface) { + if ($propertyValue->getUid() !== null) { + $result[$propertyName] = $propertyValue->getUid(); + } else { + $result[$propertyName] = $this->convertTransientObjectToArray($propertyValue); + } + } elseif (is_array($propertyValue)) { + $result[$propertyName] = $this->convertDomainObjectsToIdentityArrays($propertyValue); + } else { + $result[$propertyName] = $propertyValue; + } + } + return $result; + } +} diff --git a/Classes/Pagination/QueryResultPaginator.php b/Classes/Pagination/QueryResultPaginator.php new file mode 100644 index 0000000..c2a619c --- /dev/null +++ b/Classes/Pagination/QueryResultPaginator.php @@ -0,0 +1,61 @@ +setCurrentPageNumber($currentPageNumber); + $this->setItemsPerPage($itemsPerPage); + + $this->updateInternalState(); + } + + public function getPaginatedItems(): iterable + { + return $this->paginatedQueryResult; + } + + protected function updatePaginatedItems(int $limit, int $offset): void + { + $this->paginatedQueryResult = $this->queryResult + ->getQuery() + ->setLimit($limit) + ->setOffset($offset) + ->execute(); + } + + protected function getTotalAmountOfItems(): int + { + return count($this->queryResult); + } + + protected function getAmountOfItemsOnCurrentPage(): int + { + return count($this->paginatedQueryResult); + } +} diff --git a/Classes/Persistence/ClassesConfiguration.php b/Classes/Persistence/ClassesConfiguration.php new file mode 100644 index 0000000..49ff83d --- /dev/null +++ b/Classes/Persistence/ClassesConfiguration.php @@ -0,0 +1,71 @@ +configuration = $configuration; + } + + public function hasClass(string $className): bool + { + return array_key_exists($className, $this->configuration); + } + + public function getConfigurationFor(string $className): ?array + { + return $this->configuration[$className] ?? null; + } + + /** + * Resolves all subclasses for the given set of (sub-)classes. + * The whole classes configuration is used to determine all subclasses recursively. + * + * @return array A numeric array that contains all available subclasses-strings as values. + */ + public function getSubClasses(string $className): array + { + return $this->resolveSubClassesRecursive($className); + } + + private function resolveSubClassesRecursive(string $className, array $subClasses = []): array + { + foreach ($this->configuration[$className]['subclasses'] ?? [] as $subclass) { + if (in_array($subclass, $subClasses, true)) { + continue; + } + + $subClasses[] = $subclass; + $subClasses = $this->resolveSubClassesRecursive($subclass, $subClasses); + } + + return $subClasses; + } + + public function getConfiguration(): array + { + return $this->configuration; + } +} diff --git a/Classes/Persistence/ClassesConfigurationFactory.php b/Classes/Persistence/ClassesConfigurationFactory.php new file mode 100644 index 0000000..ceeb646 --- /dev/null +++ b/Classes/Persistence/ClassesConfigurationFactory.php @@ -0,0 +1,111 @@ +cache->get($this->cacheIdentifier); + if ($classesConfigurationCache !== false) { + return new ClassesConfiguration($classesConfigurationCache); + } + + $classes = []; + foreach ($this->packageManager->getActivePackages() as $activePackage) { + $persistenceClassesFile = $activePackage->getPackagePath() . 'Configuration/Extbase/Persistence/Classes.php'; + if (file_exists($persistenceClassesFile)) { + $definedClasses = require $persistenceClassesFile; + if (is_array($definedClasses)) { + ArrayUtility::mergeRecursiveWithOverrule( + $classes, + $definedClasses, + true, + false + ); + } + } + } + + $classes = $this->inheritPropertiesFromParentClasses($classes); + + $this->cache->set($this->cacheIdentifier, $classes); + + return new ClassesConfiguration($classes); + } + + /** + * todo: this method is flawed, see https://forge.typo3.org/issues/87566 + */ + private function inheritPropertiesFromParentClasses(array $classes): array + { + foreach (array_keys($classes) as $className) { + if (!isset($classes[$className]['properties'])) { + $classes[$className]['properties'] = []; + } + + /* + * At first we need to clean the list of parent classes. + * This methods is expected to be called for models that either inherit + * AbstractEntity or AbstractValueObject, therefore we want to know all + * parents of $className until one of these parents. + */ + $relevantParentClasses = []; + $parentClasses = class_parents($className) ?: []; + while (null !== $parentClass = array_shift($parentClasses)) { + if (in_array($parentClass, [AbstractEntity::class, AbstractValueObject::class], true)) { + break; + } + + $relevantParentClasses[] = $parentClass; + } + + /* + * Once we found all relevant parent classes of $class, we can check their + * property configuration and merge theirs with the current one. This is necessary + * to get the property configuration of parent classes in the current one to not + * miss data in the model later on. + */ + foreach ($relevantParentClasses as $currentClassName) { + if (null === $properties = $classes[$currentClassName]['properties'] ?? null) { + continue; + } + + // Merge new properties over existing ones. + $classes[$className]['properties'] = array_replace_recursive($properties, $classes[$className]['properties'] ?? []); + } + } + + return $classes; + } +} diff --git a/Classes/Persistence/Exception.php b/Classes/Persistence/Exception.php new file mode 100644 index 0000000..2c16575 --- /dev/null +++ b/Classes/Persistence/Exception.php @@ -0,0 +1,25 @@ +aggregateRootObjects = new ObjectStorage(); + $this->deletedEntities = new ObjectStorage(); + $this->changedEntities = new ObjectStorage(); + } + + public function setPersistenceManager(PersistenceManagerInterface $persistenceManager): void + { + $this->persistenceManager = $persistenceManager; + } + + /** + * Returns the number of records matching the query. + * + * @return int + */ + public function getObjectCountByQuery(QueryInterface $query) + { + $event = new ModifyQueryBeforeFetchingObjectCountEvent($query); + $this->eventDispatcher->dispatch($event); + $query = $event->getQuery(); + $result = $this->storageBackend->getObjectCountByQuery($query); + $event = new ModifyResultAfterFetchingObjectCountEvent($query, $result); + $this->eventDispatcher->dispatch($event); + return $event->getResult(); + } + + /** + * Returns the object data matching the $query. + * + * @return list> + */ + public function getObjectDataByQuery(QueryInterface $query) + { + $event = new ModifyQueryBeforeFetchingObjectDataEvent($query); + $this->eventDispatcher->dispatch($event); + $query = $event->getQuery(); + $result = $this->storageBackend->getObjectDataByQuery($query); + $event = new ModifyResultAfterFetchingObjectDataEvent($query, $result); + $this->eventDispatcher->dispatch($event); + return $event->getResult(); + } + + /** + * Returns the (internal) identifier for the object, if it is known to the + * backend. Otherwise NULL is returned. + * + * The returned identifier is the base identifier (UID or UID_localizedUID) + * without the language content identifier suffix, suitable for use as an external identifier. + * + * @param object $object + * @return string|null The identifier for the object if it is known, or NULL + */ + public function getIdentifierByObject($object) + { + if ($object instanceof LazyLoadingProxy) { + $object = $object->_loadRealInstance(); + } + + if (!is_object($object)) { + return null; + } + + $identifier = $this->session->getIdentifierByObject($object); + if ($identifier === null) { + return null; + } + + return $this->session->getBaseIdentifier($identifier); + } + + /** + * Returns the object with the (internal) identifier, if it is known to the + * backend. Otherwise NULL is returned. + * + * @param string $identifier + * @param string $className + * @return object|null The object for the identifier if it is known, or NULL + */ + public function getObjectByIdentifier($identifier, $className) + { + $query = $this->persistenceManager->createQueryForType($className); + // This allows to fetch IDs for languages for default language AND language IDs + // This is especially important when using the PropertyMapper of the Extbase MVC part to get + // an object of the translated version of the incoming ID of a record. + // "Free" mode (OVERLAYS_OFF) is mapped to OVERLAYS_MIXED - overlays need to be enabled for the + // identity lookup, but hiding untranslated records is not a configured intent in free mode. + // This is consistent with the same handling for related objects in DataMapper->getPreparedQuery(). + $languageAspect = $query->getQuerySettings()->getLanguageAspect(); + $languageAspect = new LanguageAspect( + $languageAspect->getId(), + $languageAspect->getContentId(), + $languageAspect->getOverlayType() === LanguageAspect::OVERLAYS_OFF ? LanguageAspect::OVERLAYS_MIXED : $languageAspect->getOverlayType(), + $languageAspect->getFallbackChain() + ); + + // Build language-aware session identifier + $sessionIdentifier = $this->session->buildIdentifier($identifier, $languageAspect); + if ($this->session->hasIdentifier($sessionIdentifier, $className)) { + return $this->session->getObjectByIdentifier($sessionIdentifier, $className); + } + + $query->getQuerySettings()->setLanguageAspect($languageAspect); + $query->getQuerySettings()->setRespectStoragePage(false); + $query->getQuerySettings()->setRespectSysLanguage(false); + return $query->matching($query->equals('uid', $identifier))->execute()->getFirst(); + } + + /** + * Checks if the given object has ever been persisted. + * + * @param object $object The object to check + * @return bool TRUE if the object is new, FALSE if the object exists in the repository + */ + public function isNewObject($object) + { + return $this->getIdentifierByObject($object) === null; + } + + /** + * Sets the aggregate root objects + */ + public function setAggregateRootObjects(ObjectStorage $objects) + { + $this->aggregateRootObjects = $objects; + } + + /** + * Sets the changed objects + */ + public function setChangedEntities(ObjectStorage $entities) + { + $this->changedEntities = $entities; + } + + /** + * Sets the deleted objects + */ + public function setDeletedEntities(ObjectStorage $entities) + { + $this->deletedEntities = $entities; + } + + /** + * Commits the current persistence session. + */ + public function commit() + { + $this->persistObjects(); + $this->processDeletedObjects(); + } + + /** + * Traverse and persist all aggregate roots and their object graph. + */ + protected function persistObjects(): void + { + $this->visitedDuringPersistence = new ObjectStorage(); + foreach ($this->aggregateRootObjects as $object) { + /** @var DomainObjectInterface $object */ + if ($object->_isNew()) { + $this->insertObject($object); + } + $this->persistObject($object); + } + foreach ($this->changedEntities as $object) { + $this->persistObject($object); + } + } + + /** + * Persists the given object. + */ + protected function persistObject(DomainObjectInterface $object): void + { + if (isset($this->visitedDuringPersistence[$object])) { + return; + } + $row = []; + $queue = []; + $className = get_class($object); + $dataMap = $this->dataMapFactory->buildDataMap($className); + $classSchema = $this->reflectionService->getClassSchema($className); + foreach ($classSchema->getDomainObjectProperties() as $property) { + $propertyName = $property->getName(); + if (!$dataMap->isPersistableProperty($propertyName)) { + continue; + } + $propertyValue = $object->_getProperty($propertyName); + if ($this->propertyValueIsLazyLoaded($propertyValue)) { + continue; + } + $columnMap = $dataMap->getColumnMap($propertyName); + if ($propertyValue instanceof ObjectStorage) { + $cleanProperty = $object->_getCleanProperty($propertyName); + // objectstorage needs to be persisted if the object is new, the objectstorage is dirty, meaning it has + // been changed after initial build, or an empty objectstorage is present and the cleanstate objectstorage + // has childelements, meaning all elements should been removed from the objectstorage + if ($object->_isNew() || $propertyValue->_isDirty() || ($propertyValue->count() === 0 && $cleanProperty && $cleanProperty->count() > 0)) { + $this->persistObjectStorage($propertyValue, $object, $propertyName, $row); + $propertyValue->_memorizeCleanState(); + } + foreach ($propertyValue as $containedObject) { + if ($containedObject instanceof DomainObjectInterface) { + $queue[] = $containedObject; + } + } + } elseif ($propertyValue instanceof DomainObjectInterface) { + if ($object->_isDirty($propertyName)) { + if ($propertyValue->_isNew()) { + $this->insertObject($propertyValue, $object, $propertyName); + } + $row[$columnMap->columnName] = $this->getPlainValue($propertyValue, null, $property); + } + $queue[] = $propertyValue; + } elseif ($object->_isNew() || $object->_isDirty($propertyName)) { + $row[$columnMap->columnName] = $this->getPlainValue($propertyValue, $columnMap, $property); + } + } + if (!empty($row)) { + $this->updateObject($object, $row); + $object->_memorizeCleanState(); + } + $this->visitedDuringPersistence[$object] = $object->getUid(); + foreach ($queue as $queuedObject) { + $this->persistObject($queuedObject); + } + $this->eventDispatcher->dispatch(new EntityPersistedEvent($object)); + } + + /** + * Checks, if the property value is lazy loaded and was not initialized + */ + protected function propertyValueIsLazyLoaded(mixed $propertyValue): bool + { + if ($propertyValue instanceof LazyLoadingProxy) { + return true; + } + if (($propertyValue instanceof LazyObjectStorage) && $propertyValue->isInitialized() === false) { + return true; + } + return false; + } + + /** + * Persists an object storage. Objects of a 1:n or m:n relation are queued and processed with the parent object. + * A 1:1 relation gets persisted immediately. Objects which were removed from the property were detached from + * the parent object. They will not be deleted by default. You have to add the attribute + * #[\TYPO3\CMS\Extbase\Attribute\ORM\Cascade(['value' => 'remove'])] to the property if you want them to + * be deleted as well. + * + * @param \TYPO3\CMS\Extbase\Persistence\ObjectStorage $objectStorage The object storage to be persisted. + * @param DomainObjectInterface $parentObject The parent object. One of the properties holds the object storage. + * @param string $propertyName The name of the property holding the object storage. + * @param array $row The row array of the parent object to be persisted. It's passed by reference and gets filled with either a comma separated list of uids (csv) or the number of contained objects. + */ + protected function persistObjectStorage( + ObjectStorage $objectStorage, + DomainObjectInterface $parentObject, + string $propertyName, + array &$row + ): void { + $className = get_class($parentObject); + $dataMapper = GeneralUtility::makeInstance(DataMapper::class); + $columnMap = $this->dataMapFactory->buildDataMap($className)->getColumnMap($propertyName); + $property = $this->reflectionService->getClassSchema($className)->getProperty($propertyName); + foreach ($this->getRemovedChildObjects($parentObject, $propertyName) as $removedObject) { + $this->detachObjectFromParentObject($removedObject, $parentObject, $propertyName); + if ($columnMap->typeOfRelation === Relation::HAS_MANY && $property->getCascadeValue() === 'remove') { + $this->removeEntity($removedObject); + } + } + + $currentUids = []; + $sortingPosition = 1; + $updateSortingOfFollowing = false; + + foreach ($objectStorage as $object) { + /** @var DomainObjectInterface $object */ + if (empty($currentUids)) { + $sortingPosition = 1; + } else { + $sortingPosition++; + } + $cleanProperty = $parentObject->_getCleanProperty($propertyName); + if ($object->_isNew()) { + $this->insertObject($object, $parentObject); + $this->attachObjectToParentObject($object, $parentObject, $propertyName, $sortingPosition); + // if a new object is inserted, all objects after this need to have their sorting updated + $updateSortingOfFollowing = true; + } elseif ($cleanProperty === null || $cleanProperty->getPosition($object) === null) { + // if parent object is new then it doesn't have cleanProperty yet; before attaching object it's clean position is null + $this->attachObjectToParentObject($object, $parentObject, $propertyName, $sortingPosition); + // if a relation is dirty (speaking the same object is removed and added again at a different position), all objects after this needs to be updated the sorting + $updateSortingOfFollowing = true; + } elseif ($objectStorage->isRelationDirty($object) || $cleanProperty->getPosition($object) !== $objectStorage->getPosition($object)) { + $this->updateRelationOfObjectToParentObject($object, $parentObject, $propertyName, $sortingPosition); + $updateSortingOfFollowing = true; + } elseif ($updateSortingOfFollowing) { + if ($sortingPosition > $objectStorage->getPosition($object)) { + $this->updateRelationOfObjectToParentObject($object, $parentObject, $propertyName, $sortingPosition); + } else { + $sortingPosition = $objectStorage->getPosition($object); + } + } + $currentUids[] = $object->getUid(); + } + + if ($columnMap->parentKeyFieldName === null) { + $row[$columnMap->columnName] = implode(',', $currentUids); + } else { + $row[$columnMap->columnName] = $dataMapper->countRelated($parentObject, $propertyName); + } + } + + /** + * Returns the removed objects determined by a comparison of the clean property value + * with the actual property value. + */ + protected function getRemovedChildObjects(DomainObjectInterface $object, string $propertyName): array + { + $removedObjects = []; + $cleanPropertyValue = $object->_getCleanProperty($propertyName); + if (is_array($cleanPropertyValue) || $cleanPropertyValue instanceof \Iterator) { + $propertyValue = $object->_getProperty($propertyName); + foreach ($cleanPropertyValue as $containedObject) { + if (!$propertyValue->contains($containedObject)) { + $removedObjects[] = $containedObject; + } + } + } + return $removedObjects; + } + + /** + * Updates the fields defining the relation between the object and the parent object. + */ + protected function attachObjectToParentObject( + DomainObjectInterface $object, + DomainObjectInterface $parentObject, + string $parentPropertyName, + int $sortingPosition = 0 + ): void { + $parentDataMap = $this->dataMapFactory->buildDataMap(get_class($parentObject)); + $parentColumnMap = $parentDataMap->getColumnMap($parentPropertyName); + if ($parentColumnMap->typeOfRelation === Relation::HAS_MANY) { + $this->attachObjectToParentObjectRelationHasMany($object, $parentObject, $parentPropertyName, $sortingPosition); + } elseif ($parentColumnMap->typeOfRelation === Relation::HAS_AND_BELONGS_TO_MANY) { + $this->insertRelationInRelationtable($object, $parentObject, $parentPropertyName, $sortingPosition); + } + } + + /** + * Updates the fields defining the relation between the object and the parent object. + */ + protected function updateRelationOfObjectToParentObject( + DomainObjectInterface $object, + DomainObjectInterface $parentObject, + string $parentPropertyName, + int $sortingPosition = 0 + ): void { + $parentDataMap = $this->dataMapFactory->buildDataMap(get_class($parentObject)); + $parentColumnMap = $parentDataMap->getColumnMap($parentPropertyName); + if ($parentColumnMap->typeOfRelation === Relation::HAS_MANY) { + $this->attachObjectToParentObjectRelationHasMany($object, $parentObject, $parentPropertyName, $sortingPosition); + } elseif ($parentColumnMap->typeOfRelation === Relation::HAS_AND_BELONGS_TO_MANY) { + $this->updateRelationInRelationTable($object, $parentObject, $parentPropertyName, $sortingPosition); + } + } + + /** + * Updates fields defining the relation between the object and the parent object in relation has-many. + * + * @throws IllegalRelationTypeException + */ + protected function attachObjectToParentObjectRelationHasMany( + DomainObjectInterface $object, + DomainObjectInterface $parentObject, + string $parentPropertyName, + int $sortingPosition = 0 + ): void { + $parentDataMap = $this->dataMapFactory->buildDataMap(get_class($parentObject)); + $parentColumnMap = $parentDataMap->getColumnMap($parentPropertyName); + if ($parentColumnMap->typeOfRelation !== Relation::HAS_MANY) { + throw new IllegalRelationTypeException( + 'Parent column relation type is ' . Relation::class . '::' . $parentColumnMap->typeOfRelation->name + . ' but should be ' . Relation::class . '::' . Relation::HAS_MANY->name, + 1345368105 + ); + } + $row = []; + if ($parentColumnMap->parentKeyFieldName !== null) { + $row[$parentColumnMap->parentKeyFieldName] = $parentObject->_getProperty(AbstractDomainObject::PROPERTY_LOCALIZED_UID) ?: $parentObject->getUid(); + if ($parentColumnMap->parentTableFieldName !== null) { + $row[$parentColumnMap->parentTableFieldName] = $parentDataMap->tableName; + } + $row = array_merge($parentColumnMap->relationTableMatchFields, $row); + } + $childSortByFieldName = $parentColumnMap->childSortByFieldName; + if (!empty($childSortByFieldName)) { + $row[$childSortByFieldName] = $sortingPosition; + } + if (!empty($row)) { + $this->updateObject($object, $row); + } + } + + /** + * Updates the fields defining the relation between the object and the parent object. + */ + protected function detachObjectFromParentObject( + DomainObjectInterface $object, + DomainObjectInterface $parentObject, + string $parentPropertyName + ): void { + $parentDataMap = $this->dataMapFactory->buildDataMap(get_class($parentObject)); + $parentColumnMap = $parentDataMap->getColumnMap($parentPropertyName); + if ($parentColumnMap->typeOfRelation === Relation::HAS_MANY) { + $row = []; + if ($parentColumnMap->parentKeyFieldName !== null) { + $row[$parentColumnMap->parentKeyFieldName] = 0; + if ($parentColumnMap->parentTableFieldName !== null) { + $row[$parentColumnMap->parentTableFieldName] = ''; + } + if (!empty($parentColumnMap->relationTableMatchFields)) { + $row = array_merge(array_fill_keys(array_keys($parentColumnMap->relationTableMatchFields), ''), $row); + } + } + if (!empty($parentColumnMap->childSortByFieldName)) { + $row[$parentColumnMap->childSortByFieldName] = 0; + } + if (!empty($row)) { + $this->updateObject($object, $row); + } + } elseif ($parentColumnMap->typeOfRelation === Relation::HAS_AND_BELONGS_TO_MANY) { + $this->deleteRelationFromRelationtable($object, $parentObject, $parentPropertyName); + } + } + + /** + * Inserts an object in the storage backend + */ + protected function insertObject( + DomainObjectInterface $object, + ?DomainObjectInterface $parentObject = null, + string $parentPropertyName = '' + ): void { + if ($object instanceof AbstractValueObject) { + $result = $this->getUidOfAlreadyPersistedValueObject($object); + if ($result !== null) { + $object->_setProperty(AbstractDomainObject::PROPERTY_UID, $result); + return; + } + } + $className = get_class($object); + $dataMap = $this->dataMapFactory->buildDataMap($className); + $row = []; + $classSchema = $this->reflectionService->getClassSchema($className); + foreach ($classSchema->getDomainObjectProperties() as $property) { + $propertyName = $property->getName(); + if (!$dataMap->isPersistableProperty($propertyName)) { + continue; + } + $propertyValue = $object->_getProperty($propertyName); + if ($this->propertyValueIsLazyLoaded($propertyValue)) { + continue; + } + $columnMap = $dataMap->getColumnMap($propertyName); + if ($columnMap->typeOfRelation === Relation::HAS_ONE) { + $row[$columnMap->columnName] = 0; + } elseif ($columnMap->typeOfRelation !== Relation::NONE) { + if ($columnMap->parentKeyFieldName === null) { + // CSV type relation + $row[$columnMap->columnName] = ''; + } else { + // MM type relation + $row[$columnMap->columnName] = 0; + } + } elseif ($propertyValue !== null) { + $row[$columnMap->columnName] = $this->getPlainValue($propertyValue, $columnMap, $property); + } + } + $this->addCommonFieldsToRow($object, $row); + if ($dataMap->languageIdColumnName !== null && $object->_getProperty(AbstractDomainObject::PROPERTY_LANGUAGE_UID) === null) { + $row[$dataMap->languageIdColumnName] = 0; + $object->_setProperty(AbstractDomainObject::PROPERTY_LANGUAGE_UID, 0); + } + if ($dataMap->translationOriginColumnName !== null) { + $row[$dataMap->translationOriginColumnName] = 0; + } + if ($dataMap->translationOriginDiffSourceName !== null) { + $row[$dataMap->translationOriginDiffSourceName] = ''; + } + if ($parentObject !== null && $parentPropertyName) { + $parentColumnDataMap = $this->dataMapFactory->buildDataMap(get_class($parentObject))->getColumnMap($parentPropertyName); + $row = array_merge($parentColumnDataMap->relationTableMatchFields, $row); + if ($parentColumnDataMap->parentKeyFieldName !== null) { + $row[$parentColumnDataMap->parentKeyFieldName] = (int)$parentObject->getUid(); + } + } + + if ($parentObject) { + // Ensure a nested object respects the storage PID for new records or inherits the storage PID from + // the parent object. + $storagePidForObject = $this->determineStoragePageIdForNewRecord($object); + if ($storagePidForObject === 0) { + $storagePidForObject = $parentObject->getPid() ?? 0; + } + $row['pid'] = $storagePidForObject; + } + + $uid = $this->storageBackend->addRow($dataMap->tableName, $row); + $localizedUid = $object->_getProperty(AbstractDomainObject::PROPERTY_LOCALIZED_UID); + $identifier = $this->session->buildIdentifier(['uid' => $uid, '_LOCALIZED_UID' => $localizedUid]); + $object->_setProperty(AbstractDomainObject::PROPERTY_UID, $uid); + $object->setPid((int)$row['pid']); + if ($uid >= 1) { + $this->eventDispatcher->dispatch(new EntityAddedToPersistenceEvent($object)); + } + + $this->referenceIndex->updateRefIndexTable($dataMap->tableName, $uid); + $this->session->registerObject($object, $identifier); + if ($uid >= 1) { + $this->eventDispatcher->dispatch(new EntityFinalizedAfterPersistenceEvent($object)); + } + } + + /** + * Tests, if the given Value Object already exists in the storage backend and if so, it returns the uid. + * + * @return int|null The matching uid if an object was found, else null + */ + protected function getUidOfAlreadyPersistedValueObject(AbstractValueObject $object): ?int + { + return $this->storageBackend->getUidOfAlreadyPersistedValueObject($object); + } + + /** + * Inserts mm-relation into a relation table + * + * @return int The uid of the inserted row + */ + protected function insertRelationInRelationtable( + DomainObjectInterface $object, + DomainObjectInterface $parentObject, + string $propertyName, + ?int $sortingPosition = null + ): int { + $dataMap = $this->dataMapFactory->buildDataMap(get_class($parentObject)); + $columnMap = $dataMap->getColumnMap($propertyName); + $parentUid = $parentObject->getUid(); + if ($parentObject->_getProperty(AbstractDomainObject::PROPERTY_LOCALIZED_UID) !== null) { + $parentUid = $parentObject->_getProperty(AbstractDomainObject::PROPERTY_LOCALIZED_UID); + } + $row = []; + if ($columnMap->parentKeyFieldName !== null) { + $row[$columnMap->parentKeyFieldName] = (int)$parentUid; + } + if ($columnMap->childKeyFieldName !== null) { + $row[$columnMap->childKeyFieldName] = (int)$object->getUid(); + } + if ($columnMap->childSortByFieldName !== null) { + $row[$columnMap->childSortByFieldName] = $sortingPosition ?? 0; + } + $relationTableName = $columnMap->relationTableName; + if ($this->tcaSchemaFactory->has($relationTableName)) { + $row[AbstractDomainObject::PROPERTY_PID] = $this->determineStoragePageIdForNewRecord(); + } + $row = array_merge($columnMap->relationTableMatchFields, $row); + return $this->storageBackend->addRow($relationTableName, $row, true); + } + + /** + * Updates mm-relation in a relation table + * + * @return bool TRUE if update was successfully + */ + protected function updateRelationInRelationTable( + DomainObjectInterface $object, + DomainObjectInterface $parentObject, + string $propertyName, + int $sortingPosition = 0 + ): bool { + $dataMap = $this->dataMapFactory->buildDataMap(get_class($parentObject)); + $columnMap = $dataMap->getColumnMap($propertyName); + $row = []; + if ($columnMap->parentKeyFieldName !== null) { + $row[$columnMap->parentKeyFieldName] = (int)$parentObject->getUid(); + } + if ($columnMap->childKeyFieldName !== null) { + $row[$columnMap->childKeyFieldName] = (int)$object->getUid(); + } + if ($columnMap->childSortByFieldName !== null) { + $row[$columnMap->childSortByFieldName] = $sortingPosition; + } + $relationTableName = $columnMap->relationTableName; + $row = array_merge($columnMap->relationTableMatchFields, $row); + $this->storageBackend->updateRelationTableRow($relationTableName, $row); + return true; + } + + /** + * Delete all mm-relations of a parent from a relation table + * + * @return bool TRUE if delete was successfully + */ + protected function deleteAllRelationsFromRelationtable( + DomainObjectInterface $parentObject, + string $parentPropertyName + ): bool { + $dataMap = $this->dataMapFactory->buildDataMap(get_class($parentObject)); + $columnMap = $dataMap->getColumnMap($parentPropertyName); + $relationTableName = $columnMap->relationTableName; + $relationMatchFields = []; + if ($columnMap->parentKeyFieldName !== null) { + $relationMatchFields[$columnMap->parentKeyFieldName] = (int)$parentObject->getUid(); + } + $relationMatchFields = array_merge($columnMap->relationTableMatchFields, $relationMatchFields); + $this->storageBackend->removeRow($relationTableName, $relationMatchFields); + return true; + } + + /** + * Delete an mm-relation from a relation table + */ + protected function deleteRelationFromRelationtable( + DomainObjectInterface $relatedObject, + DomainObjectInterface $parentObject, + string $parentPropertyName + ): bool { + $dataMap = $this->dataMapFactory->buildDataMap(get_class($parentObject)); + $columnMap = $dataMap->getColumnMap($parentPropertyName); + $relationTableName = $columnMap->relationTableName; + $relationMatchFields = []; + if ($columnMap->parentKeyFieldName !== null) { + $relationMatchFields[$columnMap->parentKeyFieldName] = (int)$parentObject->getUid(); + } + if ($columnMap->childKeyFieldName !== null) { + $relationMatchFields[$columnMap->childKeyFieldName] = (int)$relatedObject->getUid(); + } + $relationMatchFields = array_merge($columnMap->relationTableMatchFields, $relationMatchFields); + $this->storageBackend->removeRow($relationTableName, $relationMatchFields); + return true; + } + + /** + * Updates a given object in the storage + */ + protected function updateObject(DomainObjectInterface $object, array $row): void + { + $dataMap = $this->dataMapFactory->buildDataMap(get_class($object)); + $this->addCommonFieldsToRow($object, $row); + $row['uid'] = $object->getUid(); + if ($dataMap->languageIdColumnName !== null) { + $row[$dataMap->languageIdColumnName] = (int)$object->_getProperty(AbstractDomainObject::PROPERTY_LANGUAGE_UID); + if ($object->_getProperty(AbstractDomainObject::PROPERTY_LOCALIZED_UID) !== null) { + $row['uid'] = $object->_getProperty(AbstractDomainObject::PROPERTY_LOCALIZED_UID); + } + } + $this->storageBackend->updateRow($dataMap->tableName, $row); + $this->eventDispatcher->dispatch(new EntityUpdatedInPersistenceEvent($object)); + $this->referenceIndex->updateRefIndexTable($dataMap->tableName, (int)$row['uid']); + } + + /** + * Adds common database fields to a row + */ + protected function addCommonFieldsToRow(DomainObjectInterface $object, array &$row): void + { + $dataMap = $this->dataMapFactory->buildDataMap(get_class($object)); + $this->addCommonDateFieldsToRow($object, $row); + if ($dataMap->recordTypeColumnName !== null && $dataMap->recordType !== null) { + $row[$dataMap->recordTypeColumnName] = $dataMap->recordType; + } + if ($object->_isNew() && !isset($row['pid'])) { + $row['pid'] = $this->determineStoragePageIdForNewRecord($object); + } + } + + /** + * Adjusts the common date fields of the given row to the current time + */ + protected function addCommonDateFieldsToRow(DomainObjectInterface $object, array &$row): void + { + $dataMap = $this->dataMapFactory->buildDataMap(get_class($object)); + if ($object->_isNew() && $dataMap->creationDateColumnName !== null) { + $row[$dataMap->creationDateColumnName] = $GLOBALS['EXEC_TIME']; + } + if ($dataMap->modificationDateColumnName !== null) { + $row[$dataMap->modificationDateColumnName] = $GLOBALS['EXEC_TIME']; + } + } + + /** + * Iterate over deleted aggregate root objects and process them + */ + protected function processDeletedObjects(): void + { + foreach ($this->deletedEntities as $entity) { + if ($this->session->hasObject($entity)) { + $this->removeEntity($entity); + $this->session->unregisterReconstitutedEntity($entity); + $this->session->unregisterObject($entity); + } + } + $this->deletedEntities = new ObjectStorage(); + } + + /** + * Deletes an object + */ + protected function removeEntity(DomainObjectInterface $object, bool $markAsDeleted = true): void + { + $dataMap = $this->dataMapFactory->buildDataMap(get_class($object)); + if ($markAsDeleted === true && $dataMap->deletedFlagColumnName !== null) { + $deletedColumnName = $dataMap->deletedFlagColumnName; + $row = [ + 'uid' => $object->getUid(), + $deletedColumnName => 1, + ]; + $this->addCommonDateFieldsToRow($object, $row); + $this->storageBackend->updateRow($dataMap->tableName, $row); + } else { + $this->storageBackend->removeRow($dataMap->tableName, ['uid' => $object->getUid()]); + } + $this->eventDispatcher->dispatch(new EntityRemovedFromPersistenceEvent($object)); + + $this->removeRelatedObjects($object); + $this->referenceIndex->updateRefIndexTable($dataMap->tableName, $object->getUid()); + } + + /** + * Remove related objects + */ + protected function removeRelatedObjects(DomainObjectInterface $object): void + { + $className = get_class($object); + $dataMap = $this->dataMapFactory->buildDataMap($className); + $classSchema = $this->reflectionService->getClassSchema($className); + foreach ($classSchema->getDomainObjectProperties() as $property) { + $propertyName = $property->getName(); + $columnMap = $dataMap->getColumnMap($propertyName); + if ($columnMap === null) { + continue; + } + $propertyValue = $object->_getProperty($propertyName); + if ($property->getCascadeValue() === 'remove') { + if ($columnMap->typeOfRelation === Relation::HAS_MANY) { + foreach ($propertyValue as $containedObject) { + $this->removeEntity($containedObject); + } + } elseif ($propertyValue instanceof DomainObjectInterface) { + $this->removeEntity($propertyValue); + } + } elseif ($dataMap->deletedFlagColumnName === null + && $columnMap->typeOfRelation === Relation::HAS_AND_BELONGS_TO_MANY + ) { + $this->deleteAllRelationsFromRelationtable($object, $propertyName); + } + } + } + + /** + * Determine the storage page ID for a given NEW record + * + * This does the following: + * - If the domain object has an accessible property 'pid' (i.e. through a getPid() method), that is used to store the record. + * - If there is a TypoScript configuration "classes.CLASSNAME.newRecordStoragePid", that is used to store new records. + * - If there is no such TypoScript configuration, it uses the first value of The "storagePid" taken for reading records. + * + * @return int the storage Page ID where the object should be stored + */ + protected function determineStoragePageIdForNewRecord(?DomainObjectInterface $object = null): int + { + $frameworkConfiguration = []; + try { + $frameworkConfiguration = $this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FRAMEWORK); + } catch (NoServerRequestGivenException) { + // Fallback to empty array if ConfigurationManager has not been initialized with a Request. + // This implies storagePid 0. This is a measure to specifically allow running the extbase + // persistence layer without a Request, which may be useful in some CLI scenarios (and can + // be convenient in tests) when no other code branches of extbase that have a hard dependency + // to the Request (e.g. controllers / view) are used. + } + + if ($object !== null) { + if (ObjectAccess::isPropertyGettable($object, AbstractDomainObject::PROPERTY_PID)) { + $pid = ObjectAccess::getProperty($object, AbstractDomainObject::PROPERTY_PID); + if (isset($pid)) { + return (int)$pid; + } + } + $className = get_class($object); + if (isset($frameworkConfiguration['persistence']['classes'][$className]) && !empty($frameworkConfiguration['persistence']['classes'][$className]['newRecordStoragePid'])) { + return (int)$frameworkConfiguration['persistence']['classes'][$className]['newRecordStoragePid']; + } + } + $storagePidList = GeneralUtility::intExplode(',', (string)($frameworkConfiguration['persistence']['storagePid'] ?? '0')); + return $storagePidList[0]; + } + + /** + * Returns a plain value, i.e. objects are flattened out if possible. + * Checks explicitly for null values as DataMapper's getPlainValue would convert this to 'NULL'. + * For null values, the expected DB null value will be considered. + * + * @param mixed $input The value that will be converted + * @param ColumnMap|null $columnMap Optional column map for retrieving the date storage format + * @param Property|null $property The current property + * @return int|string|null + */ + protected function getPlainValue(mixed $input, ?ColumnMap $columnMap = null, ?Property $property = null) + { + if ($input !== null) { + return GeneralUtility::makeInstance(DataMapper::class)->getPlainValue($input, $columnMap); + } + + if ($columnMap?->type === TableColumnType::DATETIME) { + return QueryHelper::transformDateTimeToDatabaseValue( + null, + $columnMap->isNullable, + $columnMap->dateTimeFormat ?? 'datetime', + $columnMap->dateTimeStorageFormat + ); + } + + if ($property === null) { + return null; + } + + $className = $property->getPrimaryType()->getClassName() ?? null; + + if ($className === null) { + return null; + } + + // Nullable domain model property + if (is_subclass_of($className, DomainObjectInterface::class)) { + return 0; + } + return null; + } +} diff --git a/Classes/Persistence/Generic/BackendInterface.php b/Classes/Persistence/Generic/BackendInterface.php new file mode 100644 index 0000000..9de3a33 --- /dev/null +++ b/Classes/Persistence/Generic/BackendInterface.php @@ -0,0 +1,92 @@ +> + */ + public function getObjectDataByQuery(QueryInterface $query); +} diff --git a/Classes/Persistence/Generic/Exception.php b/Classes/Persistence/Generic/Exception.php new file mode 100644 index 0000000..a9afc58 --- /dev/null +++ b/Classes/Persistence/Generic/Exception.php @@ -0,0 +1,23 @@ +parentObject = $parentObject; + $this->propertyName = $propertyName; + $this->fieldValue = $fieldValue; + if ($dataMapper === null) { + $dataMapper = GeneralUtility::makeInstance(DataMapper::class); + } + $this->dataMapper = $dataMapper; + } + + /** + * Populate this proxy by asking the $population closure. + * + * @return object|null The instance (hopefully) returned + */ + public function _loadRealInstance() + { + // this check safeguards against a proxy being activated multiple times + // usually that does not happen, but if the proxy is held from outside + // its parent ... the result would be weird. + if ($this->parentObject->_getProperty($this->propertyName) instanceof LazyLoadingProxy && $this->dataMapper) { + $objects = $this->dataMapper->fetchRelated($this->parentObject, $this->propertyName, $this->fieldValue, false); + $propertyValue = $this->dataMapper->mapResultToPropertyValue($this->parentObject, $this->propertyName, $objects); + $this->parentObject->_setProperty($this->propertyName, $propertyValue); + $this->parentObject->_memorizeCleanState($this->propertyName); + return $propertyValue; + } + return $this->parentObject->_getProperty($this->propertyName); + } + + /** + * @return string + */ + public function _getTypeAndUidString() + { + $type = $this->dataMapper->getType(get_class($this->parentObject), $this->propertyName); + return $type . ':' . $this->fieldValue; + } + + public function getUid(): int + { + return (int)$this->fieldValue; + } + + /** + * Magic method call implementation. + * + * @param string $methodName The name of the property to get + * @param array $arguments The arguments given to the call + * @return mixed + */ + public function __call($methodName, $arguments) + { + $realInstance = $this->_loadRealInstance(); + if (!is_object($realInstance)) { + return null; + } + /** @var callable $callable */ + $callable = [$realInstance, $methodName]; + return $callable(...$arguments); + } + + /** + * Magic get call implementation. + * + * @param string $propertyName The name of the property to get + * @return mixed + */ + public function __get($propertyName) + { + $realInstance = $this->_loadRealInstance(); + + if ($realInstance instanceof DomainObjectInterface) { + return $realInstance->_getProperty($propertyName); + } + return $realInstance?->{$propertyName}; + } + + /** + * Magic set call implementation. + * + * @param string $propertyName The name of the property to set + * @param mixed $value The value for the property to set + */ + public function __set($propertyName, $value) + { + $realInstance = $this->_loadRealInstance(); + $realInstance->{$propertyName} = $value; + } + + /** + * Magic isset call implementation. + * + * @param string $propertyName The name of the property to check + * @return bool + */ + public function __isset($propertyName) + { + $realInstance = $this->_loadRealInstance(); + return isset($realInstance->{$propertyName}); + } + + /** + * Magic unset call implementation. + * + * @param string $propertyName The name of the property to unset + */ + public function __unset($propertyName) + { + $realInstance = $this->_loadRealInstance(); + unset($realInstance->{$propertyName}); + } + + /** + * Magic toString call implementation. + * + * @return string + */ + public function __toString() + { + $realInstance = $this->_loadRealInstance(); + return $realInstance->__toString(); + } + + /** + * Returns the current value of the storage array + */ + public function current(): mixed + { + // todo: make sure current() can be performed on $realInstance + $realInstance = $this->_loadRealInstance(); + return current($realInstance); + } + + /** + * Returns the current key storage array + * @return int|string|null + */ + public function key(): mixed + { + // todo: make sure key() can be performed on $realInstance + $realInstance = $this->_loadRealInstance(); + return key($realInstance); + } + + /** + * Returns the next position of the storage array + */ + public function next(): void + { + // todo: make sure next() can be performed on $realInstance + $realInstance = $this->_loadRealInstance(); + next($realInstance); + } + + /** + * Resets the array pointer of the storage + */ + public function rewind(): void + { + // todo: make sure reset() can be performed on $realInstance + $realInstance = $this->_loadRealInstance(); + reset($realInstance); + } + + /** + * Checks if the array pointer of the storage points to a valid position + */ + public function valid(): bool + { + return $this->current() !== false; + } + + public function __serialize(): array + { + $properties = get_object_vars($this); + unset($properties['dataMapper']); + return $properties; + } + + public function __unserialize(array $data): void + { + foreach ($data as $propertyName => $propertyValue) { + $this->{$propertyName} = $propertyValue; + } + + $this->dataMapper = GeneralUtility::getContainer()->get(DataMapper::class); + } +} diff --git a/Classes/Persistence/Generic/LazyObjectStorage.php b/Classes/Persistence/Generic/LazyObjectStorage.php new file mode 100644 index 0000000..3f0e29e --- /dev/null +++ b/Classes/Persistence/Generic/LazyObjectStorage.php @@ -0,0 +1,317 @@ + + */ +class LazyObjectStorage extends ObjectStorage implements LoadingStrategyInterface +{ + /** + * This field is only needed to make debugging easier: + * + * If you call current() on a class that implements Iterator, PHP will return the first field of the object + * instead of calling the current() method of the interface. + * + * We use this unusual behavior of PHP to return the warning below in this case. + */ + private string $warning = 'You should never see this warning. If you do, you probably used PHP array functions like current() on the TYPO3\\CMS\\Extbase\\Persistence\\Generic\\LazyObjectStorage. To retrieve the first result, you can use the rewind() and current() methods.'; + + protected DataMapper $dataMapper; + + /** + * The object this property is contained in. + */ + protected DomainObjectInterface $parentObject; + + /** + * The name of the property represented by this proxy. + */ + protected string $propertyName; + + /** + * The raw field value. + */ + protected mixed $fieldValue; + + protected bool $isInitialized = false; + + public function isInitialized(): bool + { + return $this->isInitialized; + } + + /** + * @param TEntity $parentObject The object instance this proxy is part of + * @param string $propertyName The name of the proxied property in its parent + * @param mixed $fieldValue The raw field value. + */ + public function __construct(object $parentObject, string $propertyName, mixed $fieldValue, ?DataMapper $dataMapper = null) + { + $this->parentObject = $parentObject; + $this->propertyName = $propertyName; + $this->fieldValue = $fieldValue; + reset($this->storage); + if ($dataMapper === null) { + $dataMapper = GeneralUtility::makeInstance(DataMapper::class); + } + $this->dataMapper = $dataMapper; + } + + /** + * Lazily initializes the object storage. + */ + protected function initialize(): void + { + if ($this->isInitialized) { + return; + } + + $this->isInitialized = true; + $objects = $this->dataMapper->fetchRelated($this->parentObject, $this->propertyName, $this->fieldValue, false); + foreach ($objects as $object) { + parent::attach($object); + } + $this->_memorizeCleanState(); + if (!$this->isStorageAlreadyMemorizedInParentCleanState()) { + $this->parentObject->_memorizeCleanState($this->propertyName); + } + } + + protected function isStorageAlreadyMemorizedInParentCleanState(): bool + { + return $this->parentObject->_getCleanProperty($this->propertyName) === $this; + } + + // Delegation to the ObjectStorage methods below + + /** + * @see `ObjectStorage::addAll` + */ + public function addAll(ObjectStorage $storage): void + { + $this->initialize(); + parent::addAll($storage); + } + + /** + * @param TEntity $object The object to add. + * @param mixed $information The information to associate with the object. + * + * @see `ObjectStorage::attach` + */ + public function attach(object $object, mixed $information = null): void + { + $this->initialize(); + parent::attach($object, $information); + } + + /** + * @param TEntity $object The object to look for. + * + * @see `ObjectStorage::contains` + */ + public function contains(object $object): bool + { + $this->initialize(); + return parent::contains($object); + } + + /** + * Counts the elements in the storage array + * + * @throws Exception + * @return 0|positive-int The number of objects in the storage. + */ + public function count(): int + { + $columnMap = $this->dataMapper->getDataMap(get_class($this->parentObject))->getColumnMap($this->propertyName); + if (!$this->isInitialized && $columnMap->typeOfRelation === Relation::HAS_MANY) { + $numberOfElements = $this->dataMapper->countRelated($this->parentObject, $this->propertyName, $this->fieldValue); + } else { + $this->initialize(); + $numberOfElements = count($this->storage); + } + return $numberOfElements; + } + + /** + * @return TEntity|null The object at the current iterator position. + * @see `ObjectStorage::current` + */ + public function current(): ?object + { + $this->initialize(); + return parent::current(); + } + + /** + * @param TEntity $object The object to remove. + * + * @see `ObjectStorage::detach` + */ + public function detach(object $object): void + { + $this->initialize(); + parent::detach($object); + } + + /** + * @return string The index corresponding to the position of the iterator. + * + * @see `ObjectStorage::key` + */ + public function key(): string + { + $this->initialize(); + return parent::key(); + } + + /** + * @see `ObjectStorage::next` + */ + public function next(): void + { + $this->initialize(); + parent::next(); + } + + /** + * @param TEntity|int|string $value The object to look for, or the key in the storage. + * + * @see `ObjectStorage::offsetExists` + */ + public function offsetExists(mixed $value): bool + { + $this->initialize(); + return parent::offsetExists($value); + } + + /** + * @param TEntity|int|string $value The object to look for, or its key in the storage. + * + * @see `ObjectStorage::offsetGet` + */ + public function offsetGet(mixed $value): mixed + { + $this->initialize(); + return parent::offsetGet($value); + } + + /** + * @param TEntity|string|null $object The object to add. + * @param mixed $information The information to associate with the object. + * + * @see `ObjectStorage::offsetSet` + */ + public function offsetSet(mixed $object, mixed $information): void + { + $this->initialize(); + parent::offsetSet($object, $information); + } + + /** + * @param TEntity|int|string $value The object to remove, or its key in the storage. + * + * @see `ObjectStorage::offsetUnset` + */ + public function offsetUnset(mixed $value): void + { + $this->initialize(); + parent::offsetUnset($value); + } + + /** + * @param ObjectStorage $storage The storage containing the elements to remove. + * + * @see `ObjectStorage::removeAll` + */ + public function removeAll(ObjectStorage $storage): void + { + $this->initialize(); + parent::removeAll($storage); + } + + /** + * @see `ObjectStorage::rewind` + */ + public function rewind(): void + { + $this->initialize(); + parent::rewind(); + } + + /** + * @see `ObjectStorage::valid` + */ + public function valid(): bool + { + $this->initialize(); + return parent::valid(); + } + + /** + * @see `ObjectStorage::toArray` + */ + public function toArray(): array + { + $this->initialize(); + return parent::toArray(); + } + + /** + * @param mixed $object + */ + public function getPosition($object): ?int + { + $this->initialize(); + return parent::getPosition($object); + } + + public function __serialize(): array + { + $properties = get_object_vars($this); + unset( + $properties['warning'], + $properties['dataMapper'] + ); + return $properties; + } + + public function __unserialize(array $data): void + { + foreach ($data as $propertyName => $propertyValue) { + if (property_exists($this, $propertyName)) { + $this->{$propertyName} = $propertyValue; + } + } + $this->dataMapper = GeneralUtility::getContainer()->get(DataMapper::class); + } +} diff --git a/Classes/Persistence/Generic/LoadingStrategyInterface.php b/Classes/Persistence/Generic/LoadingStrategyInterface.php new file mode 100644 index 0000000..edb9039 --- /dev/null +++ b/Classes/Persistence/Generic/LoadingStrategyInterface.php @@ -0,0 +1,21 @@ +typeOfRelation; + } + + public function getColumnName(): string + { + return $this->columnName; + } + + public function getChildTableName(): ?string + { + return $this->childTableName; + } + + public function getChildTableDefaultSortings(): ?string + { + return $this->childTableDefaultSortings; + } + + public function getChildSortByFieldName(): ?string + { + return $this->childSortByFieldName; + } + + public function getRelationTableName(): ?string + { + return $this->relationTableName; + } + + public function getRelationTableMatchFields(): array + { + return $this->relationTableMatchFields; + } + + public function getParentKeyFieldName(): ?string + { + return $this->parentKeyFieldName; + } + + public function getParentTableFieldName(): ?string + { + return $this->parentTableFieldName; + } + + public function getChildKeyFieldName(): ?string + { + return $this->childKeyFieldName; + } + + public function getDateTimeFormat(): ?string + { + return $this->dateTimeFormat; + } + + public function getDateTimeStorageFormat(): ?string + { + return $this->dateTimeStorageFormat; + } + + public function getType(): TableColumnType + { + return $this->type; + } + + public function isNullable(): bool + { + return $this->isNullable; + } +} diff --git a/Classes/Persistence/Generic/Mapper/ColumnMap/Relation.php b/Classes/Persistence/Generic/Mapper/ColumnMap/Relation.php new file mode 100644 index 0000000..0fd8319 --- /dev/null +++ b/Classes/Persistence/Generic/Mapper/ColumnMap/Relation.php @@ -0,0 +1,30 @@ +reflectionService->getClassSchema($className)->getProperty($propertyName); + $nonProxyPropertyTypes = $property->getFilteredTypes([$property, 'filterLazyLoadingProxyAndLazyObjectStorage']); + $primaryType = $nonProxyPropertyTypes[0] ?? null; + $propertyType = $primaryType?->getClassName() ?? $primaryType?->getBuiltinType() ?? null; + if ($primaryType?->isCollection() && $primaryType->getCollectionValueTypes() !== []) { + $primaryCollectionValueType = $primaryType->getCollectionValueTypes()[0]; + $propertyCollectionValueType = $primaryCollectionValueType->getClassName() ?? $primaryCollectionValueType->getBuiltinType(); + } + } catch (NoSuchPropertyException) { + // $type and $propertyCollectionValueType kept null + } + + // @todo: The relation related handling below smells fishy at various places. Some TCA + // details are ignored, some are at least opinionated, some are wrong. The combination + // of fetching details from TCA *and* the model class makes everything quite complex. + // This should be consolidated. + // Also, the mixture of extbase internal "Relation", core TableColumnType, plus + // core TcaSchema details is complex and should be simplified to what we really need. + // Last, ColumnMap is not fully used throughout extbase, various details tend to + // still access TCA details directly. + // In the end, we may be better off removing extbase "Relation" altogether and + // add TcaSchema $columnConfiguration to ColumnMap to sort out TCA details at + // the few places where needed directly? This would be more in-line with DataHandler + // as well and raises fewer state questions in consumers, which reduces complexity. + + $columnConfiguration = $field->getConfiguration(); + $columnName = $field->getName(); + $tableColumnType = TableColumnType::tryFrom($field->getType()); + $childTableName = null; + if ($field->isType(TableColumnType::GROUP)) { + // TCA type="group" has no TCA property "foreign_table" and can only deal with single-table + // relations in extbase (no support for union types). That means `allowed` should only + // contain ONE table entry, as Extbase can only evaluate the first one, if multiple + // are defined. + $allowed = GeneralUtility::trimExplode(',', $columnConfiguration['allowed'] ?? '', true); + $childTableName = $allowed[0] ?? $columnConfiguration['foreign_table'] ?? null; + } elseif ($field instanceof RelationalFieldTypeInterface) { + $childTableName = $columnConfiguration['foreign_table'] ?? null; + } + + if ($field instanceof DateTimeFieldType) { + // TCA type="datetime" considers "dbtype" and is done. + return new ColumnMap( + columnName: $columnName, + type: $tableColumnType, + dateTimeFormat: $field->getFormat(), + dateTimeStorageFormat: $field->getPersistenceType(), + isNullable: $field->isNullable(), + ); + } + + if (($field instanceof RelationalFieldTypeInterface) && $field->getRelationshipType() === RelationshipType::ManyToMany) { + if (!isset($columnConfiguration['MM'])) { + throw new \LogicException( + 'TCA schema of column ' . $columnName . ' is "ManytoMany", but TCA config has no MM property set', + 1733560101 + ); + } + return new ColumnMap( + columnName: $columnName, + type: $tableColumnType, + typeOfRelation: Relation::HAS_AND_BELONGS_TO_MANY, + childTableName: $childTableName, + relationTableName: $columnConfiguration['MM'], + relationTableMatchFields: is_array($columnConfiguration['MM_match_fields'] ?? false) ? $columnConfiguration['MM_match_fields'] : [], + parentKeyFieldName: !empty($columnConfiguration['MM_opposite_field']) ? 'uid_foreign' : 'uid_local', + childKeyFieldName: !empty($columnConfiguration['MM_opposite_field']) ? 'uid_local' : 'uid_foreign', + childSortByFieldName: !empty($columnConfiguration['MM_opposite_field']) ? 'sorting_foreign' : 'sorting', + isNullable: $field->isNullable(), + ); + } + + if ($propertyCollectionValueType !== null) { + // The field might not be a RelationFieldType, e.g. for TCA type "passthrough" or type "select" + // without items. However, the model defines a relation and therefore overrules the TCA schema lookup. + // This also overrules any "maxitems" or "renderType" configuration! + return new ColumnMap( + columnName: $columnName, + type: $tableColumnType, + typeOfRelation: Relation::HAS_MANY, + childTableName: $childTableName, + relationTableMatchFields: is_array($columnConfiguration['foreign_match_fields'] ?? false) ? $columnConfiguration['foreign_match_fields'] : [], + parentKeyFieldName: $columnConfiguration['foreign_field'] ?? null, + parentTableFieldName: $columnConfiguration['foreign_table_field'] ?? null, + childSortByFieldName: $columnConfiguration['foreign_sortby'] ?? null, + childTableDefaultSortings: $columnConfiguration['foreign_default_sortby'] ?? null, + isNullable: $field->isNullable(), + ); + } + + if ($propertyType !== null && strpbrk($propertyType, '_\\') !== false) { + // @todo: Check this. Seems to be a check for Tx_Foo_Bar style class names?! + return new ColumnMap( + columnName: $columnName, + type: $tableColumnType, + typeOfRelation: Relation::HAS_ONE, + childTableName: $childTableName, + relationTableMatchFields: is_array($columnConfiguration['foreign_match_fields'] ?? false) ? $columnConfiguration['foreign_match_fields'] : [], + parentKeyFieldName: $columnConfiguration['foreign_field'] ?? null, + parentTableFieldName: $columnConfiguration['foreign_table_field'] ?? null, + childSortByFieldName: $columnConfiguration['foreign_sortby'] ?? null, + isNullable: $field->isNullable(), + ); + } + + if ($field instanceof FolderFieldType) { + // Folder is a special case which always has a relation to one or many "folders". + // In case "maxitems" is set to > 1 and relationship is not explicitly set to "*toOne" + // it's HAS_MANY, in all other cases it's HAS_ONE. It can never belong to many. + // @todo: Get rid of the "maxitems" and rely purely on the evaluated relationship type + // @todo: TCA type="folder" has no TCA property "relationship"! + $relation = Relation::HAS_ONE; + if (!in_array((string)($columnConfiguration['relationship'] ?? ''), ['oneToOne', 'manyToOne'], true) + && (!isset($columnConfiguration['maxitems']) || $columnConfiguration['maxitems'] > 1) + ) { + $relation = Relation::HAS_MANY; + } + return new ColumnMap( + columnName: $columnName, + type: $tableColumnType, + typeOfRelation: $relation, + isNullable: $field->isNullable(), + ); + } + + if ($field instanceof CountryFieldType) { + $relation = Relation::HAS_ONE; + return new ColumnMap( + columnName: $columnName, + type: $tableColumnType, + typeOfRelation: $relation, + ); + } + + if ( + ( + $field instanceof RelationalFieldTypeInterface + && $field->getRelationshipType()->hasMany() + && ( + !$field->isType(TableColumnType::GROUP, TableColumnType::SELECT) + || ($field->isType(TableColumnType::GROUP) && (!isset($columnConfiguration['maxitems']) || $columnConfiguration['maxitems'] > 1)) + || ($field->isType(TableColumnType::SELECT) && (($columnConfiguration['renderType'] ?? '') !== 'selectSingle' || (int)($columnConfiguration['maxitems'] ?? 0) > 1)) + ) + ) + || ( + $field instanceof StaticSelectFieldType + && (int)($columnConfiguration['maxitems'] ?? 0) > 1 // @todo: Get rid of the "maxitems" and rely purely on the relationship type + ) + ) { + return new ColumnMap( + columnName: $columnName, + type: $tableColumnType, + typeOfRelation: Relation::HAS_MANY, + isNullable: $field->isNullable(), + ); + } + + return new ColumnMap( + columnName: $columnName, + type: $tableColumnType, + isNullable: $field->isNullable(), + ); + } +} diff --git a/Classes/Persistence/Generic/Mapper/DataMap.php b/Classes/Persistence/Generic/Mapper/DataMap.php new file mode 100644 index 0000000..f65c7cb --- /dev/null +++ b/Classes/Persistence/Generic/Mapper/DataMap.php @@ -0,0 +1,157 @@ + $columnMaps List of TCA columns with their ColumnMap representation + * @param string|null $languageIdColumnName Name of a column holding the language id of the record, often "sys_language_uid" + * @param string|null $translationOriginColumnName Name of a column holding the uid of the record this record is a translation of, often "l10n_parent" or "l18n_parent" + * @param string|null $translationOriginDiffSourceName Name of a column holding the diff data for the record this record is a translation of, often "l10n_diffsource" or "l10n_diffsource" + * @param string|null $modificationDateColumnName Name of a column holding the timestamp the record was last modified, often "tstamp" + * @param string|null $creationDateColumnName Name of a column holding the creation date timestamp, often "crdate" + * @param string|null $deletedFlagColumnName Name of a column indicating the soft deleted state of the row, often "deleted" + * @param string|null $disabledFlagColumnName Name of a column indicating the "hidden in frontend" state of the row, often "hidden" or "disabled" + * @param string|null $startTimeColumnName Name of a column holding the timestamp the record should not be displayed before, often "starttime" + * @param string|null $endTimeColumnName Name of a column holding the timestamp the record should not be displayed afterward, often "endtime" + * @param string|null $frontendUserGroupColumnName Name of a column holding the uid of the front-end user group which is allowed to edit this record + * @param string|null $recordTypeColumnName Name of a column holding the record type, example: "CType" in table "tt_content" + * @param bool $rootLevel Bool cast of TCA[$tableName]['ctrl']['rootLevel'] + */ + public function __construct( + public string $className, + public string $tableName, + public ?string $recordType = null, + public array $subclasses = [], + public array $columnMaps = [], + public ?string $languageIdColumnName = null, + public ?string $translationOriginColumnName = null, + public ?string $translationOriginDiffSourceName = null, + public ?string $modificationDateColumnName = null, + public ?string $creationDateColumnName = null, + public ?string $deletedFlagColumnName = null, + public ?string $disabledFlagColumnName = null, + public ?string $startTimeColumnName = null, + public ?string $endTimeColumnName = null, + public ?string $frontendUserGroupColumnName = null, + public ?string $recordTypeColumnName = null, + public bool $rootLevel = false, + ) {} + + public function getColumnMap(string $propertyName): ?ColumnMap + { + return $this->columnMaps[$propertyName] ?? null; + } + + public function isPersistableProperty(string $propertyName): bool + { + return isset($this->columnMaps[$propertyName]); + } + + // Getters below could be removed but don't harm much and kept as b/w compat for now. + + public function getClassName(): string + { + return $this->className; + } + + public function getTableName(): string + { + return $this->tableName; + } + + public function getRecordType(): ?string + { + return $this->recordType; + } + + public function getSubclasses(): array + { + return $this->subclasses; + } + + public function getLanguageIdColumnName(): ?string + { + return $this->languageIdColumnName; + } + + public function getTranslationOriginColumnName(): ?string + { + return $this->translationOriginColumnName; + } + + public function getTranslationOriginDiffSourceName(): ?string + { + return $this->translationOriginDiffSourceName; + } + + public function getModificationDateColumnName(): ?string + { + return $this->modificationDateColumnName; + } + + public function getCreationDateColumnName(): ?string + { + return $this->creationDateColumnName; + } + + public function getDeletedFlagColumnName(): ?string + { + return $this->deletedFlagColumnName; + } + + public function getDisabledFlagColumnName(): ?string + { + return $this->disabledFlagColumnName; + } + + public function getStartTimeColumnName(): ?string + { + return $this->startTimeColumnName; + } + + public function getEndTimeColumnName(): ?string + { + return $this->endTimeColumnName; + } + + public function getFrontEndUserGroupColumnName(): ?string + { + return $this->frontendUserGroupColumnName; + } + + public function getRecordTypeColumnName(): ?string + { + return $this->recordTypeColumnName; + } + + public function getRootLevel(): bool + { + return $this->rootLevel; + } +} diff --git a/Classes/Persistence/Generic/Mapper/DataMapFactory.php b/Classes/Persistence/Generic/Mapper/DataMapFactory.php new file mode 100644 index 0000000..2b3b14b --- /dev/null +++ b/Classes/Persistence/Generic/Mapper/DataMapFactory.php @@ -0,0 +1,178 @@ +baseCacheIdentifier; + $dataMap = $this->firstLevelCache->get($cacheIdentifier); + if ($dataMap instanceof DataMap) { + return $dataMap; + } + $dataMap = $this->secondLevelCache->get($cacheIdentifier); + if ($dataMap instanceof DataMap) { + $this->firstLevelCache->set($cacheIdentifier, $dataMap); + return $dataMap; + } + $dataMap = $this->buildDataMapInternal($className); + $this->firstLevelCache->set($cacheIdentifier, $dataMap); + $this->secondLevelCache->set($cacheIdentifier, $dataMap); + return $dataMap; + } + + /** + * Builds a data map by adding column maps for all the configured columns in the $TCA. + * It also resolves the type of values the column is holding and the typo of relation the column + * represents. + * + * @param string $className The class name you want to fetch the Data Map for + * @throws InvalidClassException + */ + protected function buildDataMapInternal(string $className): DataMap + { + if (!class_exists($className)) { + throw new InvalidClassException( + 'Could not find class definition for name "' . $className . '". This could be caused by a mis-spelling of the class name in the class definition.', + 1476045117 + ); + } + + $recordType = null; + $subclasses = []; + $tableName = $this->resolveTableName($className); + $fieldNameToPropertyNameMapping = []; + if ($this->classesConfiguration->hasClass($className)) { + $classSettings = $this->classesConfiguration->getConfigurationFor($className); + $subclasses = $this->classesConfiguration->getSubClasses($className); + if (isset($classSettings['recordType']) && $classSettings['recordType'] !== '') { + $recordType = (string)$classSettings['recordType']; + } + if (isset($classSettings['tableName']) && $classSettings['tableName'] !== '') { + $tableName = $classSettings['tableName']; + } + foreach ($classSettings['properties'] ?? [] as $propertyName => $propertyDefinition) { + $fieldNameToPropertyNameMapping[$propertyDefinition['fieldName']] = $propertyName; + } + } + + $schema = null; + $languageCapability = null; + $columnMaps = []; + if ($this->tcaSchemaFactory->has($tableName)) { + $schema = $this->tcaSchemaFactory->get($tableName); + if ($schema->hasCapability(TcaSchemaCapability::Language)) { + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + } + foreach ($schema->getFields() as $columnName => $columnDefinition) { + $propertyName = $fieldNameToPropertyNameMapping[$columnName] ?? GeneralUtility::underscoredToLowerCamelCase($columnName); + $columnMaps[$propertyName] = $this->columnMapFactory->create($columnDefinition, $propertyName, $className); + } + } + + return new DataMap( + className: $className, + tableName: $tableName, + recordType: $recordType, + subclasses: $subclasses, + columnMaps: $columnMaps, + languageIdColumnName: $languageCapability?->getLanguageField()->getName(), + translationOriginColumnName: $languageCapability?->getTranslationOriginPointerField()->getName(), + translationOriginDiffSourceName: $languageCapability?->hasDiffSourceField() + ? $languageCapability->getDiffSourceField()->getName() + : null, + modificationDateColumnName: $schema?->hasCapability(TcaSchemaCapability::UpdatedAt) + ? (string)$schema->getCapability(TcaSchemaCapability::UpdatedAt) + : null, + creationDateColumnName: $schema?->hasCapability(TcaSchemaCapability::CreatedAt) + ? (string)$schema->getCapability(TcaSchemaCapability::CreatedAt) + : null, + deletedFlagColumnName: $schema?->hasCapability(TcaSchemaCapability::SoftDelete) + ? (string)$schema->getCapability(TcaSchemaCapability::SoftDelete) + : null, + disabledFlagColumnName: $schema?->hasCapability(TcaSchemaCapability::RestrictionDisabledField) + ? (string)$schema->getCapability(TcaSchemaCapability::RestrictionDisabledField) + : null, + startTimeColumnName: $schema?->hasCapability(TcaSchemaCapability::RestrictionStartTime) + ? (string)$schema->getCapability(TcaSchemaCapability::RestrictionStartTime) + : null, + endTimeColumnName: $schema?->hasCapability(TcaSchemaCapability::RestrictionEndTime) + ? (string)$schema->getCapability(TcaSchemaCapability::RestrictionEndTime) + : null, + frontendUserGroupColumnName: $schema?->hasCapability(TcaSchemaCapability::RestrictionUserGroup) + ? (string)$schema->getCapability(TcaSchemaCapability::RestrictionUserGroup) + : null, + // @todo Check how to resolve foreign table types properly - if possible at all in this scenario + recordTypeColumnName: $schema?->supportsSubSchema() && !$schema->getSubSchemaTypeInformation()->isPointerToForeignFieldInForeignSchema() + ? $schema->getSubSchemaTypeInformation()->getFieldName() + : null, + // @todo We should remove DataMap in order to use TcaSchema directly + rootLevel: (bool)($schema?->getCapability(TcaSchemaCapability::RestrictionRootLevel)->getRootLevelType()), + ); + } + + /** + * Resolve the table name for the given class name + */ + protected function resolveTableName(string $className): string + { + $className = ltrim($className, '\\'); + $classNameParts = explode('\\', $className); + // Skip vendor and product name for core classes + if (str_starts_with($className, 'TYPO3\\CMS\\')) { + $classPartsToSkip = 2; + } else { + $classPartsToSkip = 1; + } + return 'tx_' . strtolower(implode('_', array_slice($classNameParts, $classPartsToSkip))); + } +} diff --git a/Classes/Persistence/Generic/Mapper/DataMapper.php b/Classes/Persistence/Generic/Mapper/DataMapper.php new file mode 100644 index 0000000..eebd2cd --- /dev/null +++ b/Classes/Persistence/Generic/Mapper/DataMapper.php @@ -0,0 +1,977 @@ +query = $query; + } + + /** + * Maps the given rows on objects + * + * @param string $className The name of the class + * @param array $rows An array of arrays with field_name => value pairs + * @return array An array of objects of the given class + * @template T of DomainObjectInterface + * @phpstan-param class-string $className + * @phpstan-return list + */ + public function map($className, array $rows) + { + $objects = []; + foreach ($rows as $row) { + $objects[] = $this->mapSingleRow($this->getTargetType($className, $row), $row); + } + return $objects; + } + + /** + * Returns the target type for the given row. + * + * @param string $className The name of the class + * @param array $row A single array with field_name => value pairs + * @return string The target type (a class name) + * @phpstan-param class-string $className + * @phpstan-return class-string + */ + public function getTargetType($className, array $row) + { + $dataMap = $this->getDataMap($className); + $targetType = $className; + if ($dataMap->recordTypeColumnName !== null) { + foreach ($dataMap->subclasses as $subclassName) { + $recordSubtype = $this->getDataMap($subclassName)->recordType; + if ((string)$row[$dataMap->recordTypeColumnName] === (string)$recordSubtype) { + $targetType = $subclassName; + break; + } + } + } + return $targetType; + } + + /** + * Maps a single row on an object of the given class + * + * @param string $className The name of the target class + * @param array $row A single array with field_name => value pairs + * @return object An object of the given class + * @template T of DomainObjectInterface + * @phpstan-param class-string $className + * @phpstan-return T + */ + protected function mapSingleRow($className, array $row) + { + $identifier = $this->buildIdentifier($row); + if ($this->persistenceSession->hasIdentifier($identifier, $className)) { + $object = $this->persistenceSession->getObjectByIdentifier($identifier, $className); + } else { + $object = $this->createEmptyObject($className); + $this->persistenceSession->registerObject($object, $identifier); + $this->thawProperties($object, $row); + $event = new AfterObjectThawedEvent($object, $row); + $this->eventDispatcher->dispatch($event); + $object->_memorizeCleanState(); + $this->persistenceSession->registerReconstitutedEntity($object); + } + return $object; + } + + /** + * Build a language-aware identifier for the identity map. + * + * The identifier includes the UID, localized UID (if present), and the + * language content identifier to ensure objects loaded with different + * language configurations are cached separately. + * + * @param array $row A single array with field_name => value pairs + * @return non-empty-string The identifier for the identity map + */ + protected function buildIdentifier(array $row): string + { + return $this->persistenceSession->buildIdentifier($row, $this->getEffectiveLanguageAspect()); + } + + /** + * Get the effective LanguageAspect for the current mapping context. + * + * Returns the LanguageAspect from the current query if available, + * otherwise returns a default LanguageAspect for default language. + */ + protected function getEffectiveLanguageAspect(): LanguageAspect + { + return $this->query?->getQuerySettings()->getLanguageAspect() ?? new LanguageAspect(); + } + + /** + * Creates a skeleton of the specified object. This is + * designed to *not* call class constructor when hydrating, + * but *do call* initializeObject() if exists and obey + * eventually registered implementation overrides ("xclass"). + * + * @param class-string $className Name of the class to create a skeleton for + * @throws InvalidClassException + * @template T of DomainObjectInterface + * @phpstan-param class-string $className + * @phpstan-return T + */ + protected function createEmptyObject(string $className): DomainObjectInterface + { + // Note: The class_implements() function also invokes autoload to assure that the interfaces + // and the class are loaded. Would end up with __PHP_Incomplete_Class without it. + if (!in_array(DomainObjectInterface::class, class_implements($className) ?: [])) { + throw new InvalidClassException('Cannot create empty instance of the class "' . $className + . '" because it does not implement the TYPO3\\CMS\\Extbase\\DomainObject\\DomainObjectInterface.', 1234386924); + } + // Use GU::getClassName() to obey class implementation overrides. + $object = $this->instantiator->instantiate(GeneralUtility::getClassName($className)); + if (is_callable($callable = [$object, 'initializeObject'])) { + $callable(); + } + return $object; + } + + /** + * Sets the given properties on the object. + * + * @param DomainObjectInterface $object The object to set properties on + * @throws NonExistentPropertyException + * @throws UnknownPropertyTypeException + */ + protected function thawProperties(DomainObjectInterface $object, array $row) + { + $className = get_class($object); + $classSchema = $this->reflectionService->getClassSchema($className); + $dataMap = $this->getDataMap($className); + $object->_setProperty(AbstractDomainObject::PROPERTY_UID, (int)$row['uid']); + $object->_setProperty(AbstractDomainObject::PROPERTY_PID, (int)($row['pid'] ?? 0)); + $object->_setProperty(AbstractDomainObject::PROPERTY_LOCALIZED_UID, (int)$row['uid']); + $object->_setProperty(AbstractDomainObject::PROPERTY_VERSIONED_UID, (int)$row['uid']); + if ($dataMap->languageIdColumnName !== null) { + $object->_setProperty(AbstractDomainObject::PROPERTY_LANGUAGE_UID, (int)($row[$dataMap->languageIdColumnName] ?? 0)); + if (isset($row['_LOCALIZED_UID'])) { + $object->_setProperty(AbstractDomainObject::PROPERTY_LOCALIZED_UID, (int)$row['_LOCALIZED_UID']); + } + } + if (!empty($row['_ORIG_uid']) && $this->tcaSchemaFactory->get($dataMap->tableName)->isWorkspaceAware()) { + $object->_setProperty(AbstractDomainObject::PROPERTY_VERSIONED_UID, (int)$row['_ORIG_uid']); + } + foreach ($classSchema->getDomainObjectProperties() as $property) { + $propertyName = $property->getName(); + if (!$dataMap->isPersistableProperty($propertyName)) { + continue; + } + $columnMap = $dataMap->getColumnMap($propertyName); + if ($columnMap === null) { + continue; + } + if (!isset($row[$columnMap->columnName])) { + continue; + } + $propertyValue = $row[$columnMap->columnName]; + + $nonProxyPropertyTypes = $property->getFilteredTypes([$property, 'filterLazyLoadingProxyAndLazyObjectStorage']); + if ($nonProxyPropertyTypes === []) { + throw new UnknownPropertyTypeException( + 'The type of property ' . $className . '::' . $propertyName . ' could not be identified, therefore the desired value (' + . var_export($propertyValue, true) . ') cannot be mapped onto it. The type of a class property is usually defined via property types or php doc blocks. ' + . 'Make sure the property has a property type or valid @var tag set which defines the type.', + 1579965021 + ); + } + + if (count($nonProxyPropertyTypes) > 1) { + throw new UnknownPropertyTypeException( + 'The type of property ' . $className . '::' . $propertyName . ' could not be identified because the property is defined as union or intersection type, therefore the desired value (' + . var_export($propertyValue, true) . ') cannot be mapped onto it. Make sure to use only a single type.', + 1660215701 + ); + } + + $primaryType = $nonProxyPropertyTypes[0]; + + $propertyType = $primaryType->getBuiltinType(); + $propertyClassName = $primaryType->getClassName(); + + $propertyValue = match ($propertyType) { + 'int', 'integer' => (int)$propertyValue, + 'bool', 'boolean' => (bool)$propertyValue, + 'float' => (float)$propertyValue, + 'string' => (string)$propertyValue, + 'array' => null, // $this->mapArray($propertyValue); // Not supported, yet! + 'object' => $this->thawObjectProperty($property, $columnMap, $object, $propertyName, $propertyValue, $propertyClassName), + default => null, + }; + + if ($propertyValue !== null || $property->isNullable()) { + $object->_setProperty($propertyName, $propertyValue); + } + } + } + + /** + * @param non-empty-string $propertyName + * @param class-string|null $targetClassName + */ + private function thawObjectProperty( + Property $propertySchema, + ColumnMap $columnMap, + DomainObjectInterface $parent, + string $propertyName, + mixed $propertyValue, + ?string $targetClassName + ): ?object { + if ($targetClassName === null) { + return null; + } + + if (is_subclass_of($targetClassName, \BackedEnum::class)) { + return $propertySchema->isNullable() + ? $targetClassName::tryFrom($propertyValue) + : $targetClassName::from($propertyValue); + } + + if (in_array($targetClassName, [\SplObjectStorage::class, ObjectStorage::class], true)) { + return $this->mapResultToPropertyValue( + $parent, + $propertyName, + $this->fetchRelated($parent, $propertyName, $propertyValue) + ); + } + + if (is_subclass_of($targetClassName, \DateTimeInterface::class)) { + return $this->mapDateTime( + $propertyValue, + $columnMap->dateTimeFormat, + $columnMap->dateTimeStorageFormat, + $columnMap->isNullable, + $targetClassName + ); + } + + if ($targetClassName === Country::class || is_subclass_of($targetClassName, Country::class)) { + // @todo Check if this can be abstracted in a better way (for future TCA types) + // @todo does alpha2 need to be configurable? All storage currently seems to depend on alpha2 in TCA FormEngine + return $this->countryProvider->getByAlpha2IsoCode($propertyValue); + } + + if (TypeHandlingUtility::isCoreType($targetClassName)) { + return $this->mapCoreType($targetClassName, $propertyValue); + } + + return $this->mapObjectToClassProperty( + $parent, + $propertyName, + $propertyValue + ); + } + + /** + * Map value to a core type + * + * @param string $type + * @param mixed $value + * @return \TYPO3\CMS\Core\Type\TypeInterface + */ + protected function mapCoreType($type, $value) + { + return new $type($value); + } + + /** + * Creates a DateTime from a unix timestamp or date/datetime/time value. + * If the input is empty, NULL is returned. + * + * @param int|string $value Unix timestamp or date/datetime/datetimesec value or seconds for time/timesec + * @param string|null $format Output format (date/datetime/time/timesec/datetimesec) + * @param string|null $storageFormat Storage format for native date/datetime/time/datetimesec fields + * @param string $targetType The object class name to be created + * @return \DateTimeInterface|null + */ + protected function mapDateTime( + $value, + $format = null, + $storageFormat = null, + $isNullable = true, + $targetType = \DateTime::class + ) { + $dateTime = DateTimeFactory::createFromDatabaseValueAndTCAConfig( + $value, + // Reconstruct TCA from our ColumnMap + [ + 'type' => 'datetime', + 'format' => $format, + 'dbType' => $storageFormat, + 'nullable' => $isNullable, + ] + ); + + return $dateTime === null ? null : match ($targetType) { + \DateTimeImmutable::class => $dateTime, + \DateTime::class => \DateTime::createFromImmutable($dateTime), + default => GeneralUtility::makeInstance($targetType, $dateTime->format('Y-m-d H:i:s.v e')), + }; + } + + /** + * Fetches a collection of objects related to a property of a parent object + * + * @param DomainObjectInterface $parentObject The object instance this proxy is part of + * @param string $propertyName The name of the proxied property in it's parent + * @param mixed $fieldValue The raw field value. + * @param bool $enableLazyLoading A flag indication if the related objects should be lazy loaded + * @return \TYPO3\CMS\Extbase\Persistence\Generic\LazyObjectStorage|Persistence\QueryResultInterface The result + */ + public function fetchRelated(DomainObjectInterface $parentObject, $propertyName, $fieldValue = '', $enableLazyLoading = true) + { + $property = $this->reflectionService->getClassSchema(get_class($parentObject))->getProperty($propertyName); + if ($enableLazyLoading && $property->isLazy()) { + if ($property->isObjectStorageType()) { + $result = GeneralUtility::makeInstance(LazyObjectStorage::class, $parentObject, $propertyName, $fieldValue, $this); + } elseif (empty($fieldValue)) { + $result = null; + } else { + $result = GeneralUtility::makeInstance(LazyLoadingProxy::class, $parentObject, $propertyName, $fieldValue, $this); + } + } else { + $result = $this->fetchRelatedEager($parentObject, $propertyName, $fieldValue); + } + return $result; + } + + /** + * Fetches the related objects from the storage backend. + * + * @param DomainObjectInterface $parentObject The object instance this proxy is part of + * @param string $propertyName The name of the proxied property in it's parent + * @param mixed $fieldValue The raw field value. + * @return mixed + */ + protected function fetchRelatedEager(DomainObjectInterface $parentObject, $propertyName, $fieldValue = '') + { + return $fieldValue === '' ? $this->getEmptyRelationValue($parentObject, $propertyName) : $this->getNonEmptyRelationValue($parentObject, $propertyName, $fieldValue); + } + + /** + * @param string $propertyName + * @return array|null + */ + protected function getEmptyRelationValue(DomainObjectInterface $parentObject, $propertyName) + { + $columnMap = $this->getDataMap(get_class($parentObject))->getColumnMap($propertyName); + $relatesToOne = $columnMap->typeOfRelation == Relation::HAS_ONE; + return $relatesToOne ? null : []; + } + + /** + * @param string $propertyName + * @param string $fieldValue + * @return Persistence\QueryResultInterface + */ + protected function getNonEmptyRelationValue(DomainObjectInterface $parentObject, $propertyName, $fieldValue) + { + $query = $this->getPreparedQuery($parentObject, $propertyName, $fieldValue); + return $query->execute(); + } + + /** + * Builds and returns the prepared query, ready to be executed. + * + * @param string $propertyName + * @param string $fieldValue + * @return Persistence\QueryInterface + */ + protected function getPreparedQuery(DomainObjectInterface $parentObject, $propertyName, $fieldValue = '') + { + $dataMap = $this->getDataMap(get_class($parentObject)); + $columnMap = $dataMap->getColumnMap($propertyName); + $type = $this->getType(get_class($parentObject), $propertyName); + $query = $this->queryFactory->create($type); + if ($this->query && $query instanceof Query) { + $query->setParentQuery($this->query); + } + $query->getQuerySettings()->setRespectStoragePage(false); + $query->getQuerySettings()->setRespectSysLanguage(false); + + $languageAspect = $query->getQuerySettings()->getLanguageAspect(); + $languageUid = $languageAspect->getContentId(); + if ($this->query) { + $languageAspect = $this->query->getQuerySettings()->getLanguageAspect(); + $languageUid = $languageAspect->getContentId(); + if ($dataMap->languageIdColumnName !== null && !$this->query->getQuerySettings()->getRespectSysLanguage()) { + //pass language of parent record to child objects, so they can be overlaid correctly in case + //e.g. findByUid is used. + //the languageUid is used for getRecordOverlay later on, despite RespectSysLanguage being false + $parentLanguageUid = (int)$parentObject->_getProperty(AbstractDomainObject::PROPERTY_LANGUAGE_UID); + // do not override the language when the parent language uid is set to all languages (-1) + if ($parentLanguageUid !== -1) { + $languageUid = $parentLanguageUid; + } + } + } + + // we always want to overlay relations as most of the time they are stored in db using default language uids + $languageAspect = new LanguageAspect( + $languageUid, + $languageUid, + $languageAspect->getOverlayType() === LanguageAspect::OVERLAYS_OFF ? LanguageAspect::OVERLAYS_MIXED : $languageAspect->getOverlayType(), + $languageAspect->getFallbackChain() + ); + $query->getQuerySettings()->setLanguageAspect($languageAspect); + + if ($columnMap->typeOfRelation === Relation::HAS_MANY) { + if (null !== $orderings = $this->getOrderingsForColumnMap($columnMap)) { + $query->setOrderings($orderings); + } + } elseif ($columnMap->typeOfRelation === Relation::HAS_AND_BELONGS_TO_MANY) { + $query->setSource($this->getSource($parentObject, $propertyName)); + if ($columnMap->childSortByFieldName !== null) { + $query->setOrderings([$columnMap->childSortByFieldName => QueryInterface::ORDER_ASCENDING]); + } + } + $query->matching($this->getConstraint($query, $parentObject, $propertyName, $fieldValue, $columnMap->relationTableMatchFields)); + return $query; + } + + /** + * Get orderings array for extbase query by columnMap + * + * @phpstan-return array|null + * @return array|null + */ + public function getOrderingsForColumnMap(ColumnMap $columnMap): ?array + { + if ($columnMap->childSortByFieldName !== null) { + return [$columnMap->childSortByFieldName => QueryInterface::ORDER_ASCENDING]; + } + + if ($columnMap->childTableDefaultSortings === null) { + return null; + } + + $orderings = []; + $fields = QueryHelper::parseOrderBy($columnMap->childTableDefaultSortings); + foreach ($fields as $field) { + $fieldName = $field[0] ?? null; + if ($fieldName === null) { + continue; + } + + if (($fieldOrdering = $field[1] ?? null) === null) { + $orderings[$fieldName] = QueryInterface::ORDER_ASCENDING; + continue; + } + + $fieldOrdering = strtoupper($fieldOrdering); + if (!in_array($fieldOrdering, [QueryInterface::ORDER_ASCENDING, QueryInterface::ORDER_DESCENDING], true)) { + $orderings[$fieldName] = QueryInterface::ORDER_ASCENDING; + continue; + } + + $orderings[$fieldName] = $fieldOrdering; + } + return $orderings !== [] ? $orderings : null; + } + + /** + * Builds and returns the constraint for multi value properties. + * + * @param string $propertyName + * @param string $fieldValue + * @param array $relationTableMatchFields + * @return \TYPO3\CMS\Extbase\Persistence\Generic\Qom\ConstraintInterface $constraint + */ + protected function getConstraint(QueryInterface $query, DomainObjectInterface $parentObject, $propertyName, $fieldValue = '', $relationTableMatchFields = []) + { + $dataMap = $this->getDataMap(get_class($parentObject)); + $columnMap = $dataMap->getColumnMap($propertyName); + $workspaceId = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('workspace', 'id'); + $parentId = $this->resolveParentId($parentObject, $workspaceId, $columnMap); + if ($columnMap && $workspaceId > 0) { + $resolvedRelationIds = $this->resolveRelationValuesOfField($dataMap, $columnMap, $parentId, $fieldValue, $workspaceId); + } else { + $resolvedRelationIds = []; + } + // Work with the UIDs directly in a workspace + if (!empty($resolvedRelationIds)) { + $source = $query->getSource(); + if ($source instanceof JoinInterface) { + $constraint = $query->in($source->getJoinCondition()->getProperty1Name(), $resolvedRelationIds); + // When querying MM relations directly, Typo3DbQueryParser uses enableFields and thus, filters + // out versioned records by default. However, we directly query versioned UIDs here, so we want + // to include the versioned records explicitly. + if ($columnMap->typeOfRelation === Relation::HAS_AND_BELONGS_TO_MANY) { + $query->getQuerySettings()->setEnableFieldsToBeIgnored(['pid']); + $query->getQuerySettings()->setIgnoreEnableFields(true); + } + // Also, we still need to restrict the MM on the foreign side + if ($columnMap->getParentKeyFieldName() !== null) { + $constraint = $query->logicalAnd( + $constraint, + $query->equals($columnMap->getParentKeyFieldName(), $parentId) + ); + } + } else { + $constraint = $query->in('uid', $resolvedRelationIds); + } + if ($columnMap->parentTableFieldName !== null) { + $constraint = $query->logicalAnd( + $constraint, + $query->equals($columnMap->parentTableFieldName, $dataMap->tableName) + ); + } + } elseif ($columnMap->parentKeyFieldName !== null) { + $value = $parentObject; + // If this a MM relation, and MM relations do not know about workspaces, the MM relations always point to the + // versioned record, so this must be taken into account here and the versioned record's UID must be used. + if ($columnMap->typeOfRelation === Relation::HAS_AND_BELONGS_TO_MANY) { + // The versioned UID is used ideally the version ID of a translated record, so this takes precedence over the localized UID + if ($value->_hasProperty(AbstractDomainObject::PROPERTY_VERSIONED_UID) && $value->_getProperty(AbstractDomainObject::PROPERTY_VERSIONED_UID) > 0 && $value->_getProperty(AbstractDomainObject::PROPERTY_VERSIONED_UID) !== $value->getUid()) { + $value = (int)$value->_getProperty(AbstractDomainObject::PROPERTY_VERSIONED_UID); + } + } + $constraint = $query->equals($columnMap->parentKeyFieldName, $value); + if ($columnMap->parentTableFieldName !== null) { + $constraint = $query->logicalAnd( + $constraint, + $query->equals($columnMap->parentTableFieldName, $dataMap->tableName) + ); + } + } else { + // Note: $fieldValue is annotated as a string, but this cannot be trusted as the callers do not ensure this. + $constraint = $query->in('uid', GeneralUtility::intExplode(',', (string)$fieldValue)); + } + if (!empty($relationTableMatchFields)) { + foreach ($relationTableMatchFields as $relationTableMatchFieldName => $relationTableMatchFieldValue) { + $constraint = $query->logicalAnd($constraint, $query->equals($relationTableMatchFieldName, $relationTableMatchFieldValue)); + } + } + return $constraint; + } + + /** + * Fetch the actual "uid" which we need to query to fetch relations to this UID. + */ + protected function resolveParentId(DomainObjectInterface $parentObject, int $workspaceId, ?ColumnMap $columnMap): ?int + { + $parentId = $parentObject->getUid(); + if ($columnMap && $workspaceId > 0) { + // versionedUid in a multi-language setup is the overlaid versioned AND translated ID + if ($parentObject->_hasProperty(AbstractDomainObject::PROPERTY_VERSIONED_UID) && $parentObject->_getProperty(AbstractDomainObject::PROPERTY_VERSIONED_UID) > 0 && $parentObject->_getProperty(AbstractDomainObject::PROPERTY_VERSIONED_UID) !== $parentId) { + $parentId = $parentObject->_getProperty(AbstractDomainObject::PROPERTY_VERSIONED_UID); + } elseif ($parentObject->_hasProperty(AbstractDomainObject::PROPERTY_LANGUAGE_UID) && $parentObject->_getProperty(AbstractDomainObject::PROPERTY_LANGUAGE_UID) > 0) { + $parentId = $parentObject->_getProperty(AbstractDomainObject::PROPERTY_LOCALIZED_UID); + } + } + return $parentId; + } + + /** + * This resolves relations via RelationHandler and returns their UIDs respectively, and works for MM/ForeignField/CSV in IRRE + Select + Group. + * + * Note: This only happens for resolving properties for models. When limiting a parentQuery, the Typo3DbQueryParser is taking care of it. + * + * By using the RelationHandler, the localized, deleted and moved records turn out to be properly resolved + * without having to build intermediate queries. + * + * This is currently only used in workspaces' context, as it is 1 additional DB query needed. + * + * @param DataMap $dataMap + * @param ColumnMap $columnMap + * @param int|null $parentId + * @param string $fieldValue + * @param int $workspaceId + * @return array|false|mixed + */ + protected function resolveRelationValuesOfField(DataMap $dataMap, ColumnMap $columnMap, ?int $parentId, $fieldValue, int $workspaceId) + { + $relationHandler = GeneralUtility::makeInstance(RelationHandler::class); + $relationHandler->setWorkspaceId($workspaceId); + $relationHandler->setUseLiveReferenceIds(true); + $relationHandler->setUseLiveParentIds(true); + $tableName = $dataMap->tableName; + $fieldName = $columnMap->columnName; + if (!$this->tcaSchemaFactory->get($tableName)->hasField($fieldName)) { + return []; + } + $fieldConfiguration = $this->tcaSchemaFactory->get($tableName)->getField($fieldName)->getConfiguration(); + $relationHandler->start( + $fieldValue, + $fieldConfiguration['allowed'] ?? $fieldConfiguration['foreign_table'] ?? '', + $fieldConfiguration['MM'] ?? '', + $parentId, + $tableName, + $fieldConfiguration + ); + $relationHandler->processDeletePlaceholder(); + $relatedUids = []; + if (!empty($relationHandler->tableArray)) { + $relatedUids = reset($relationHandler->tableArray); + } + return $relatedUids; + } + + /** + * Builds and returns the source to build a join for a m:n relation. + * + * @param string $propertyName + */ + protected function getSource(DomainObjectInterface $parentObject, $propertyName): SourceInterface + { + $columnMap = $this->getDataMap(get_class($parentObject))->getColumnMap($propertyName); + $left = $this->qomFactory->selector(null, $columnMap->relationTableName); + $childClassName = $this->getType(get_class($parentObject), $propertyName); + $right = $this->qomFactory->selector($childClassName, $columnMap->childTableName); + $joinCondition = $this->qomFactory->equiJoinCondition($columnMap->relationTableName, $columnMap->childKeyFieldName, $columnMap->childTableName, 'uid'); + return $this->qomFactory->join($left, $right, Query::JCR_JOIN_TYPE_INNER, $joinCondition); + } + + /** + * Returns the mapped classProperty from the identityMap or + * mapResultToPropertyValue() + * + * If the field value is empty and the column map has no parent key field name, + * the relation will be empty. If the persistence session has a registered object of + * the correct type and identity (fieldValue), this function returns that object. + * Otherwise, it proceeds with mapResultToPropertyValue(). + * + * @param mixed $fieldValue the raw field value + * @see mapResultToPropertyValue() + */ + protected function mapObjectToClassProperty(DomainObjectInterface $parentObject, string $propertyName, $fieldValue) + { + if ($this->propertyMapsByForeignKey($parentObject, $propertyName)) { + $result = $this->fetchRelated($parentObject, $propertyName, $fieldValue); + return $this->mapResultToPropertyValue($parentObject, $propertyName, $result); + } + + if (empty($fieldValue)) { + return $this->getEmptyRelationValue($parentObject, $propertyName); + } + + $primaryType = $this->reflectionService + ->getClassSchema(get_class($parentObject)) + ->getProperty($propertyName) + ->getPrimaryType(); + + if ($primaryType === null) { + throw NoPropertyTypesException::create($parentObject::class, $propertyName); + } + + $className = $primaryType->getClassName(); + if ($className === null) { + throw new \LogicException( + sprintf('Evaluated type of class property %s::%s is not a class name. Check the type declaration of the property to use a valid class name.', $parentObject::class, $propertyName), + 1660217846 + ); + } + + $identifier = $this->persistenceSession->buildIdentifier((string)$fieldValue, $this->getEffectiveLanguageAspect()); + if ($this->persistenceSession->hasIdentifier($identifier, $className)) { + return $this->persistenceSession->getObjectByIdentifier($identifier, $className); + } + + $result = $this->fetchRelated($parentObject, $propertyName, $fieldValue); + return $this->mapResultToPropertyValue($parentObject, $propertyName, $result); + } + + /** + * Checks if the relation is based on a foreign key. + * + * @param string $propertyName + * @return bool TRUE if the property is mapped + */ + protected function propertyMapsByForeignKey(DomainObjectInterface $parentObject, $propertyName) + { + $columnMap = $this->getDataMap(get_class($parentObject))->getColumnMap($propertyName); + return $columnMap->parentKeyFieldName !== null; + } + + /** + * Returns the given result as property value of the specified property type. + * + * @param string $propertyName + * @param mixed $result The result + * @return mixed + */ + public function mapResultToPropertyValue(DomainObjectInterface $parentObject, $propertyName, $result) + { + $propertyValue = null; + if ($result instanceof LoadingStrategyInterface) { + $propertyValue = $result; + } else { + $property = $this->reflectionService->getClassSchema(get_class($parentObject))->getProperty($propertyName); + $primaryType = $property->getPrimaryType(); + + if ($primaryType === null) { + throw NoPropertyTypesException::create($parentObject::class, $propertyName); + } + + if ($primaryType->getBuiltinType() === 'array' || in_array($primaryType->getClassName(), [\ArrayObject::class, \SplObjectStorage::class, ObjectStorage::class], true)) { + $objects = []; + foreach ($result as $value) { + $objects[] = $value; + } + if ($primaryType->getClassName() === \ArrayObject::class) { + $propertyValue = new \ArrayObject($objects); + } elseif ($primaryType->getClassName() === ObjectStorage::class) { + $propertyValue = new ObjectStorage(); + foreach ($objects as $object) { + $propertyValue->attach($object); + } + $propertyValue->_memorizeCleanState(); + } else { + $propertyValue = $objects; + } + } elseif (strpbrk((string)$primaryType->getClassName(), '_\\') !== false) { + // @todo: check the strpbrk function call. Seems to be a check for Tx_Foo_Bar style class names + if ($result instanceof QueryResultInterface) { + $propertyValue = $result->getFirst(); + } else { + $propertyValue = $result; + } + } + } + return $propertyValue; + } + + /** + * Counts the number of related objects assigned to a property of a parent object + * + * @param DomainObjectInterface $parentObject The object instance this proxy is part of + * @param string $propertyName The name of the proxied property in it's parent + * @param mixed $fieldValue The raw field value. + * @return int + */ + public function countRelated(DomainObjectInterface $parentObject, $propertyName, $fieldValue = '') + { + $query = $this->getPreparedQuery($parentObject, $propertyName, $fieldValue); + return $query->execute()->count(); + } + + /** + * Returns a data map for a given class name + * + * @param string $className The class name you want to fetch the Data Map for + * @throws Persistence\Generic\Exception + */ + public function getDataMap($className): DataMap + { + if (!is_string($className) || $className === '') { + throw new Exception('No class name was given to retrieve the Data Map for.', 1251315965); + } + return $this->dataMapFactory->buildDataMap($className); + } + + /** + * Returns the selector (table) name for a given class name. + * + * @param string $className + * @return string The selector name + */ + public function convertClassNameToTableName($className) + { + return $this->getDataMap($className)->tableName; + } + + /** + * Returns the column name for a given property name of the specified class. + * + * @param string $propertyName + * @param string $className + * @return string The column name + */ + public function convertPropertyNameToColumnName($propertyName, $className = null) + { + if (!empty($className)) { + $dataMap = $this->getDataMap($className); + $columnMap = $dataMap->getColumnMap($propertyName); + if ($columnMap !== null) { + return $columnMap->columnName; + } + } + return GeneralUtility::camelCaseToLowerCaseUnderscored($propertyName); + } + + /** + * Returns the type of a child object. + * + * @param string $parentClassName The class name of the object this proxy is part of + * @param string $propertyName The name of the proxied property in it's parent + * @throws UnexpectedTypeException + * @return string The class name of the child object + */ + public function getType($parentClassName, $propertyName) + { + try { + $primaryType = $this->reflectionService + ->getClassSchema($parentClassName) + ->getProperty($propertyName) + ->getPrimaryType(); + + if ($primaryType === null) { + throw NoPropertyTypesException::create($parentClassName, $propertyName); + } + + if ($primaryType->isCollection() && $primaryType->getCollectionValueTypes() !== []) { + $primaryCollectionValueType = $primaryType->getCollectionValueTypes()[0]; + return $primaryCollectionValueType->getClassName() + ?? $primaryCollectionValueType->getBuiltinType(); + } + + return $primaryType->getClassName() + ?? $primaryType->getBuiltinType(); + } catch (NoSuchPropertyException|NoPropertyTypesException $e) { + } + + throw new UnexpectedTypeException('Could not determine the child object type.', 1251315967); + } + + /** + * Returns a plain value, i.e. objects are flattened out if possible. + * Multi value objects or arrays will be converted to a comma-separated list for use in "IN" SQL queries. + * Caution: We do not return "null" values yet, if so, we need to adapt all places to handle null (see git history of this line) + * + * @param mixed $input The value that will be converted. + * @param ColumnMap|null $columnMap Optional column map for retrieving the date storage format. + */ + public function getPlainValue(mixed $input, ?ColumnMap $columnMap = null): int|string + { + if ($input instanceof \DateTimeInterface || ($input === null && $columnMap?->type === TableColumnType::DATETIME)) { + return QueryHelper::transformDateTimeToDatabaseValue( + $input, + $columnMap->isNullable ?? false, + $columnMap->dateTimeFormat ?? 'datetime', + $columnMap?->dateTimeStorageFormat + ) ?? 'NULL'; + } + + if ($input === null) { + return 'NULL'; + } + + if ($input instanceof \BackedEnum) { + return $input->value; + } + + if ($input instanceof LazyLoadingProxy) { + $input = $input->_loadRealInstance(); + } + + if (is_bool($input)) { + return (int)$input; + } + + if (is_int($input)) { + return $input; + } + + if ($input instanceof Country) { + // @todo Check if this can be abstracted in a better way (for future TCA types) + return $input->getAlpha2IsoCode(); + } + + if ($input instanceof DomainObjectInterface) { + return (int)$input->getUid(); + } + + if (TypeHandlingUtility::isValidTypeForMultiValueComparison($input)) { + $plainValueArray = []; + foreach ($input as $inputElement) { + $plainValueArray[] = $this->getPlainValue($inputElement, $columnMap); + } + return implode(',', $plainValueArray); + } + + if (is_object($input)) { + if (TypeHandlingUtility::isCoreType($input) || $input instanceof \Stringable) { + return (string)$input; + } + + throw new UnexpectedTypeException('An object of class "' . get_class($input) . '" could not be converted to a plain value.', 1274799934); + } + + return (string)$input; + } +} diff --git a/Classes/Persistence/Generic/Mapper/Exception.php b/Classes/Persistence/Generic/Mapper/Exception.php new file mode 100644 index 0000000..4ede278 --- /dev/null +++ b/Classes/Persistence/Generic/Mapper/Exception.php @@ -0,0 +1,20 @@ +queryFactory = $queryFactory; + $this->backend = $backend; + $this->persistenceSession = $persistenceSession; + + $this->addedObjects = new ObjectStorage(); + $this->removedObjects = new ObjectStorage(); + $this->changedObjects = new ObjectStorage(); + } + + /** + * Registers a repository + * + * @param string $className The class name of the repository to be registered + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ + public function registerRepositoryClassName(string $className): void {} + + /** + * Returns the number of records matching the query. + */ + public function getObjectCountByQuery(QueryInterface $query): int + { + return $this->backend->getObjectCountByQuery($query); + } + + /** + * Returns the object data matching the $query. + * @return list> + */ + public function getObjectDataByQuery(QueryInterface $query): array + { + return $this->backend->getObjectDataByQuery($query); + } + + /** + * Returns the (internal) identifier for the object, if it is known to the + * backend. Otherwise NULL is returned. + * + * Note: this returns an identifier even if the object has not been + * persisted in case of AOP-managed entities. Use isNewObject() if you need + * to distinguish those cases. + * + * @return string|null The identifier for the object if it is known, or NULL + */ + public function getIdentifierByObject(object $object): ?string + { + return $this->backend->getIdentifierByObject($object); + } + + /** + * Returns the object with the (internal) identifier, if it is known to the + * backend. Otherwise NULL is returned. + * + * @param bool $useLazyLoading Set to TRUE if you want to use lazy loading for this object + * @return object|null The object for the identifier if it is known, or NULL + */ + public function getObjectByIdentifier(string|int $identifier, ?string $objectType = null, bool $useLazyLoading = false): ?object + { + if (isset($this->newObjects[$identifier])) { + return $this->newObjects[$identifier]; + } + // Delegate to backend which handles language-aware session lookup + return $this->backend->getObjectByIdentifier((string)$identifier, $objectType); + } + + /** + * Commits new objects and changes to objects in the current persistence + * session into the backend. + */ + public function persistAll(): void + { + // hand in only aggregate roots, leaving handling of subobjects to + // the underlying storage layer + // reconstituted entities must be fetched from the session and checked + // for changes by the underlying backend as well! + $this->backend->setAggregateRootObjects($this->addedObjects); + $this->backend->setChangedEntities($this->changedObjects); + $this->backend->setDeletedEntities($this->removedObjects); + $this->backend->commit(); + + $this->addedObjects = new ObjectStorage(); + $this->removedObjects = new ObjectStorage(); + $this->changedObjects = new ObjectStorage(); + } + + /** + * Return a query object for the given type. + * + * @internal only to be used within Extbase, not part of TYPO3 Core API. + * + * @template T of object + * @param class-string $type + * @return QueryInterface + */ + public function createQueryForType(string $type): QueryInterface + { + return $this->queryFactory->create($type); + } + + /** + * Adds an object to the persistence. + * + * @param object $object The object to add + */ + public function add(object $object): void + { + $this->addedObjects->attach($object); + $this->removedObjects->detach($object); + } + + /** + * Removes an object to the persistence. + * + * @param object $object The object to remove + */ + public function remove(object $object): void + { + if ($this->addedObjects->contains($object)) { + $this->addedObjects->detach($object); + } else { + $this->removedObjects->attach($object); + } + } + + /** + * Update an object in the persistence. + * + * @param object $object The modified object + * @throws \TYPO3\CMS\Extbase\Persistence\Exception\UnknownObjectException + */ + public function update(object $object): void + { + if ($this->isNewObject($object)) { + throw new UnknownObjectException('The object of type "' . get_class($object) . '" given to update must be persisted already, but is new.', 1249479819); + } + $this->changedObjects->attach($object); + } + + /** + * Initializes the persistence manager, called by Extbase. + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ + public function initializeObject(): void + { + $this->backend->setPersistenceManager($this); + } + + /** + * Clears the in-memory state of the persistence. + * + * Managed instances become detached, any fetches will + * return data directly from the persistence "backend". + * + * @throws \TYPO3\CMS\Extbase\Persistence\Generic\Exception\NotImplementedException + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ + public function clearState(): void + { + $this->newObjects = []; + $this->addedObjects = new ObjectStorage(); + $this->removedObjects = new ObjectStorage(); + $this->changedObjects = new ObjectStorage(); + $this->persistenceSession->destroy(); + } + + /** + * Checks if the given object has ever been persisted. + * + * @param object $object The object to check + * @return bool TRUE if the object is new, FALSE if the object exists in the persistence session + */ + public function isNewObject(object $object): bool + { + return $this->persistenceSession->hasObject($object) === false; + } + + /** + * Registers an object which has been created or cloned during this request. + * + * A "new" object does not necessarily + * have to be known by any repository or be persisted in the end. + * + * Objects registered with this method must be known to the getObjectByIdentifier() + * method. + * + * @param object $object The new object to register + * + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ + public function registerNewObject(object $object): void + { + $identifier = $this->getIdentifierByObject($object); + $this->newObjects[$identifier] = $object; + } + + /** + * Tear down the persistence + * + * This method is called in functional tests to reset the storage between tests. + * The implementation is optional and depends on the underlying persistence backend. + * + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ + public function tearDown(): void + { + if (method_exists($this->backend, 'tearDown')) { + $this->backend->tearDown(); + } + } +} diff --git a/Classes/Persistence/Generic/PropertyType.php b/Classes/Persistence/Generic/PropertyType.php new file mode 100644 index 0000000..2f728c5 --- /dev/null +++ b/Classes/Persistence/Generic/PropertyType.php @@ -0,0 +1,361 @@ +variableName] = null; + } + + public function getBindVariableName(): string + { + return $this->variableName; + } +} diff --git a/Classes/Persistence/Generic/Qom/BindVariableValueInterface.php b/Classes/Persistence/Generic/Qom/BindVariableValueInterface.php new file mode 100644 index 0000000..eff7e32 --- /dev/null +++ b/Classes/Persistence/Generic/Qom/BindVariableValueInterface.php @@ -0,0 +1,26 @@ + $operands + */ + public function __construct( + private array $operands + ) {} + + public function getOperands(): array + { + return $this->operands; + } + + public function getFunctionName(): string + { + return 'COALESCE'; + } +} diff --git a/Classes/Persistence/Generic/Qom/CoalesceInterface.php b/Classes/Persistence/Generic/Qom/CoalesceInterface.php new file mode 100644 index 0000000..fea8afc --- /dev/null +++ b/Classes/Persistence/Generic/Qom/CoalesceInterface.php @@ -0,0 +1,28 @@ +orderBy($query->coalesce('nickname', 'firstName'), QueryInterface::ORDER_ASCENDING); + * + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ +interface CoalesceInterface extends FunctionExpressionInterface {} diff --git a/Classes/Persistence/Generic/Qom/Comparison.php b/Classes/Persistence/Generic/Qom/Comparison.php new file mode 100644 index 0000000..8ded48c --- /dev/null +++ b/Classes/Persistence/Generic/Qom/Comparison.php @@ -0,0 +1,110 @@ +operand1; + } + + /** + * @return QueryInterface::OPERATOR_* + */ + public function getOperator(): int + { + $operator = $this->operator; + + if ($this->getOperand2() === null) { + if ($operator === QueryInterface::OPERATOR_EQUAL_TO) { + $operator = QueryInterface::OPERATOR_EQUAL_TO_NULL; + } elseif ($operator === QueryInterface::OPERATOR_NOT_EQUAL_TO) { + $operator = QueryInterface::OPERATOR_NOT_EQUAL_TO_NULL; + } + } + + return $operator; + } + + public function getOperand2(): mixed + { + return $this->operand2; + } + + public function collectBoundVariableNames(array &$boundVariables): array + { + return []; + } +} diff --git a/Classes/Persistence/Generic/Qom/ComparisonInterface.php b/Classes/Persistence/Generic/Qom/ComparisonInterface.php new file mode 100644 index 0000000..43fe9e7 --- /dev/null +++ b/Classes/Persistence/Generic/Qom/ComparisonInterface.php @@ -0,0 +1,75 @@ + $operands + */ + public function __construct( + private array $operands + ) {} + + public function getOperands(): array + { + return $this->operands; + } + + public function getFunctionName(): string + { + return 'CONCAT'; + } +} diff --git a/Classes/Persistence/Generic/Qom/ConcatInterface.php b/Classes/Persistence/Generic/Qom/ConcatInterface.php new file mode 100644 index 0000000..9f5234d --- /dev/null +++ b/Classes/Persistence/Generic/Qom/ConcatInterface.php @@ -0,0 +1,28 @@ +orderBy($query->concat('firstName', 'lastName'), QueryInterface::ORDER_ASCENDING); + * + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ +interface ConcatInterface extends FunctionExpressionInterface {} diff --git a/Classes/Persistence/Generic/Qom/ConstraintInterface.php b/Classes/Persistence/Generic/Qom/ConstraintInterface.php new file mode 100644 index 0000000..40c2873 --- /dev/null +++ b/Classes/Persistence/Generic/Qom/ConstraintInterface.php @@ -0,0 +1,29 @@ + exception + } + + public function getSelector1Name(): string + { + return $this->selector1Name; + } + + public function getProperty1Name(): string + { + return $this->property1Name; + } + + public function getSelector2Name(): string + { + return $this->selector2Name; + } + + public function getProperty2Name(): string + { + return $this->property2Name; + } + + public function getChildSelectorName(): string + { + return ''; + } + + public function getParentSelectorName(): string + { + return ''; + } +} diff --git a/Classes/Persistence/Generic/Qom/EquiJoinConditionInterface.php b/Classes/Persistence/Generic/Qom/EquiJoinConditionInterface.php new file mode 100644 index 0000000..8669dd6 --- /dev/null +++ b/Classes/Persistence/Generic/Qom/EquiJoinConditionInterface.php @@ -0,0 +1,32 @@ + The operands + */ + public function getOperands(): array; + + /** + * Returns the SQL function name. + * + * @return string The function name (e.g., 'CONCAT', 'TRIM', 'COALESCE') + */ + public function getFunctionName(): string; +} diff --git a/Classes/Persistence/Generic/Qom/Join.php b/Classes/Persistence/Generic/Qom/Join.php new file mode 100644 index 0000000..8321f0f --- /dev/null +++ b/Classes/Persistence/Generic/Qom/Join.php @@ -0,0 +1,59 @@ +left; + } + + public function getRight(): SourceInterface&SelectorInterface + { + return $this->right; + } + + /** + * @return string one of QueryObjectModelConstants.JCR_JOIN_TYPE_* + */ + public function getJoinType(): string + { + return $this->joinType; + } + + public function getJoinCondition(): JoinConditionInterface + { + return $this->joinCondition; + } +} diff --git a/Classes/Persistence/Generic/Qom/JoinConditionInterface.php b/Classes/Persistence/Generic/Qom/JoinConditionInterface.php new file mode 100644 index 0000000..96775f2 --- /dev/null +++ b/Classes/Persistence/Generic/Qom/JoinConditionInterface.php @@ -0,0 +1,26 @@ +constraint1->collectBoundVariableNames($boundVariables); + $this->constraint2->collectBoundVariableNames($boundVariables); + } + + public function getConstraint1(): ConstraintInterface + { + return $this->constraint1; + } + + public function getConstraint2(): ConstraintInterface + { + return $this->constraint2; + } +} diff --git a/Classes/Persistence/Generic/Qom/LogicalNot.php b/Classes/Persistence/Generic/Qom/LogicalNot.php new file mode 100644 index 0000000..92299ce --- /dev/null +++ b/Classes/Persistence/Generic/Qom/LogicalNot.php @@ -0,0 +1,40 @@ +constraint->collectBoundVariableNames($boundVariables); + } + + public function getConstraint(): ConstraintInterface + { + return $this->constraint; + } +} diff --git a/Classes/Persistence/Generic/Qom/LogicalOr.php b/Classes/Persistence/Generic/Qom/LogicalOr.php new file mode 100644 index 0000000..284fba8 --- /dev/null +++ b/Classes/Persistence/Generic/Qom/LogicalOr.php @@ -0,0 +1,52 @@ +constraint1->collectBoundVariableNames($boundVariables); + $this->constraint2->collectBoundVariableNames($boundVariables); + } + + public function getConstraint1(): ConstraintInterface + { + return $this->constraint1; + } + + public function getConstraint2(): ConstraintInterface + { + return $this->constraint2; + } +} diff --git a/Classes/Persistence/Generic/Qom/LowerCase.php b/Classes/Persistence/Generic/Qom/LowerCase.php new file mode 100644 index 0000000..4b42b4c --- /dev/null +++ b/Classes/Persistence/Generic/Qom/LowerCase.php @@ -0,0 +1,49 @@ +operand; + } + + public function getSelectorName(): string + { + return $this->operand->getSelectorName(); + } + + public function getPropertyName(): string + { + return 'LOWER' . $this->operand->getPropertyName(); + } +} diff --git a/Classes/Persistence/Generic/Qom/LowerCaseInterface.php b/Classes/Persistence/Generic/Qom/LowerCaseInterface.php new file mode 100644 index 0000000..03f8048 --- /dev/null +++ b/Classes/Persistence/Generic/Qom/LowerCaseInterface.php @@ -0,0 +1,32 @@ +operand; + } + + /** + * @return string One of QueryInterface::ORDER_* + */ + public function getOrder(): string + { + return $this->order; + } +} diff --git a/Classes/Persistence/Generic/Qom/OrderingInterface.php b/Classes/Persistence/Generic/Qom/OrderingInterface.php new file mode 100644 index 0000000..20ef7a1 --- /dev/null +++ b/Classes/Persistence/Generic/Qom/OrderingInterface.php @@ -0,0 +1,32 @@ +selectorName; + } + + public function getPropertyName(): string + { + return $this->propertyName; + } +} diff --git a/Classes/Persistence/Generic/Qom/PropertyValueInterface.php b/Classes/Persistence/Generic/Qom/PropertyValueInterface.php new file mode 100644 index 0000000..10bb0f3 --- /dev/null +++ b/Classes/Persistence/Generic/Qom/PropertyValueInterface.php @@ -0,0 +1,36 @@ +nodeTypeName; + } + + public function getSelectorName(): string + { + return $this->selectorName; + } +} diff --git a/Classes/Persistence/Generic/Qom/SelectorInterface.php b/Classes/Persistence/Generic/Qom/SelectorInterface.php new file mode 100644 index 0000000..1a0111c --- /dev/null +++ b/Classes/Persistence/Generic/Qom/SelectorInterface.php @@ -0,0 +1,43 @@ +statement; + } + + public function getBoundVariables(): array + { + return $this->boundVariables; + } + + public function collectBoundVariableNames(array &$boundVariables) {} +} diff --git a/Classes/Persistence/Generic/Qom/StaticOperandInterface.php b/Classes/Persistence/Generic/Qom/StaticOperandInterface.php new file mode 100644 index 0000000..9563a5d --- /dev/null +++ b/Classes/Persistence/Generic/Qom/StaticOperandInterface.php @@ -0,0 +1,24 @@ +operand; + } + + public function getOperands(): array + { + return [$this->operand]; + } + + public function getFunctionName(): string + { + return 'TRIM'; + } +} diff --git a/Classes/Persistence/Generic/Qom/TrimInterface.php b/Classes/Persistence/Generic/Qom/TrimInterface.php new file mode 100644 index 0000000..0e6a107 --- /dev/null +++ b/Classes/Persistence/Generic/Qom/TrimInterface.php @@ -0,0 +1,34 @@ +orderBy($query->trim('title'), QueryInterface::ORDER_ASCENDING); + * + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ +interface TrimInterface extends FunctionExpressionInterface +{ + /** + * Returns the operand being trimmed. + */ + public function getOperand(): DynamicOperandInterface; +} diff --git a/Classes/Persistence/Generic/Qom/UpperCase.php b/Classes/Persistence/Generic/Qom/UpperCase.php new file mode 100644 index 0000000..a604127 --- /dev/null +++ b/Classes/Persistence/Generic/Qom/UpperCase.php @@ -0,0 +1,49 @@ +operand; + } + + public function getSelectorName(): string + { + return $this->operand->getSelectorName(); + } + + public function getPropertyName(): string + { + return 'UPPER' . $this->operand->getPropertyName(); + } +} diff --git a/Classes/Persistence/Generic/Qom/UpperCaseInterface.php b/Classes/Persistence/Generic/Qom/UpperCaseInterface.php new file mode 100644 index 0000000..abb277d --- /dev/null +++ b/Classes/Persistence/Generic/Qom/UpperCaseInterface.php @@ -0,0 +1,32 @@ + + */ +#[Autoconfigure(public: true, shared: false)] +class Query implements QueryInterface +{ + /** + * An inner join. + */ + public const JCR_JOIN_TYPE_INNER = '{http://www.jcp.org/jcr/1.0}joinTypeInner'; + + /** + * A left-outer join. + */ + public const JCR_JOIN_TYPE_LEFT_OUTER = '{http://www.jcp.org/jcr/1.0}joinTypeLeftOuter'; + + /** + * A right-outer join. + */ + public const JCR_JOIN_TYPE_RIGHT_OUTER = '{http://www.jcp.org/jcr/1.0}joinTypeRightOuter'; + + /** + * Charset of strings in QOM + */ + public const CHARSET = 'utf-8'; + + /** + * @var string + * @phpstan-var class-string + */ + protected $type; + + protected DataMapFactory $dataMapFactory; + protected PersistenceManagerInterface $persistenceManager; + protected QueryObjectModelFactory $qomFactory; + protected ContainerInterface $container; + + protected ?SourceInterface $source = null; + + /** + * @var \TYPO3\CMS\Extbase\Persistence\Generic\Qom\ConstraintInterface + */ + protected $constraint; + + /** + * @var \TYPO3\CMS\Extbase\Persistence\Generic\Qom\Statement + */ + protected $statement; + + /** + * @var array|array + */ + protected array $orderings = []; + + /** + * @var int|null + */ + protected $limit; + + /** + * @var int + */ + protected $offset; + + protected QuerySettingsInterface $querySettings; + + /** + * @var QueryInterface|null + * @internal + */ + protected $parentQuery; + + public function __construct( + DataMapFactory $dataMapFactory, + PersistenceManagerInterface $persistenceManager, + QueryObjectModelFactory $qomFactory, + ContainerInterface $container + ) { + $this->dataMapFactory = $dataMapFactory; + $this->persistenceManager = $persistenceManager; + $this->qomFactory = $qomFactory; + $this->container = $container; + } + + /** + * @phpstan-param class-string $type + */ + public function setType(string $type): void + { + $this->type = $type; + } + + /** + * @internal + */ + public function getParentQuery(): ?QueryInterface + { + return $this->parentQuery; + } + + /** + * @internal + */ + public function setParentQuery(?QueryInterface $parentQuery): void + { + $this->parentQuery = $parentQuery; + } + + /** + * Sets the Query Settings. These Query settings must match the settings expected by + * the specific Storage Backend. + */ + public function setQuerySettings(QuerySettingsInterface $querySettings) + { + $this->querySettings = $querySettings; + } + + public function getQuerySettings(): QuerySettingsInterface + { + return $this->querySettings; + } + + /** + * Returns the type this query cares for. + * + * @return string + * @phpstan-return class-string + */ + public function getType() + { + return $this->type; + } + + public function setSource(SourceInterface $source): void + { + $this->source = $source; + } + + /** + * Returns the selector's name or an empty string, if the source is not a selector + * @todo This has to be checked at another place + * + * @return string The selector name + */ + protected function getSelectorName() + { + $source = $this->getSource(); + if ($source instanceof SelectorInterface) { + return $source->getSelectorName(); + } + return ''; + } + + public function getSource(): SourceInterface + { + if ($this->source === null) { + $this->source = $this->qomFactory->selector($this->getType(), $this->dataMapFactory->buildDataMap($this->getType())->tableName); + } + return $this->source; + } + + /** + * Executes the query against the database and returns the result + * + * @param bool $returnRawQueryResult avoids the object mapping by the persistence + * @return QueryResultInterface|list> The query result object or an array if $returnRawQueryResult is TRUE + * @phpstan-return ($returnRawQueryResult is true ? list> : QueryResultInterface) + */ + public function execute($returnRawQueryResult = false) + { + if ($returnRawQueryResult) { + return $this->persistenceManager->getObjectDataByQuery($this); + } + /** @phpstan-var QueryResultInterface $queryResult */ + $queryResult = $this->container->get(QueryResultInterface::class); + $queryResult->setQuery($this); + return $queryResult; + } + + /** + * Sets the property names to order the result by. Expected like this: + * array( + * 'foo' => \TYPO3\CMS\Extbase\Persistence\QueryInterface::ORDER_ASCENDING, + * 'bar' => \TYPO3\CMS\Extbase\Persistence\QueryInterface::ORDER_DESCENDING + * ) + * where 'foo' and 'bar' are property names. + * + * @param array $orderings The property names to order by + * @return QueryInterface + * @phpstan-return QueryInterface + */ + public function setOrderings(array $orderings) + { + $this->orderings = $orderings; + return $this; + } + + /** + * Returns the property names to order the result by. Like this: + * array( + * 'foo' => \TYPO3\CMS\Extbase\Persistence\QueryInterface::ORDER_ASCENDING, + * 'bar' => \TYPO3\CMS\Extbase\Persistence\QueryInterface::ORDER_DESCENDING + * ) + * + * @return array|array + */ + public function getOrderings() + { + return $this->orderings; + } + + /** + * Sets the ordering for the result by a single operand. Replaces any existing orderings. + * + * @param string|DynamicOperandInterface $operand The property name or a dynamic operand + * @param string $order The order direction + * @return QueryInterface + * @phpstan-return QueryInterface + */ + public function orderBy(string|DynamicOperandInterface $operand, string $order = QueryInterface::ORDER_ASCENDING) + { + $this->orderings = []; + return $this->addOrderBy($operand, $order); + } + + /** + * Adds an ordering for the result. Appends to any existing orderings. + * + * @param string|DynamicOperandInterface $operand The property name or a dynamic operand + * @param string $order The order direction + * @return QueryInterface + * @phpstan-return QueryInterface + */ + public function addOrderBy(string|DynamicOperandInterface $operand, string $order = QueryInterface::ORDER_ASCENDING) + { + if (is_string($operand)) { + $operand = $this->qomFactory->propertyValue($operand, $this->getSelectorName()); + } + if ($order === QueryInterface::ORDER_ASCENDING) { + $this->orderings[] = $this->qomFactory->ascending($operand); + } else { + $this->orderings[] = $this->qomFactory->descending($operand); + } + return $this; + } + + /** + * Creates a CONCAT expression for ordering. + * + * @param string|DynamicOperandInterface ...$operands Property names or operand objects to concatenate + */ + public function concat(string|DynamicOperandInterface ...$operands): ConcatInterface + { + $resolvedOperands = []; + foreach ($operands as $operand) { + if (is_string($operand)) { + $resolvedOperands[] = $this->qomFactory->propertyValue($operand, $this->getSelectorName()); + } else { + $resolvedOperands[] = $operand; + } + } + return $this->qomFactory->concat(...$resolvedOperands); + } + + /** + * Creates a TRIM expression for ordering. + * + * @param string|DynamicOperandInterface $operand The property name or operand to trim + */ + public function trim(string|DynamicOperandInterface $operand): TrimInterface + { + if (is_string($operand)) { + $operand = $this->qomFactory->propertyValue($operand, $this->getSelectorName()); + } + return $this->qomFactory->trim($operand); + } + + /** + * Creates a COALESCE expression for ordering. + * + * @param string|DynamicOperandInterface ...$operands Property names or operand objects + */ + public function coalesce(string|DynamicOperandInterface ...$operands): CoalesceInterface + { + $resolvedOperands = []; + foreach ($operands as $operand) { + if (is_string($operand)) { + $resolvedOperands[] = $this->qomFactory->propertyValue($operand, $this->getSelectorName()); + } else { + $resolvedOperands[] = $operand; + } + } + return $this->qomFactory->coalesce(...$resolvedOperands); + } + + /** + * Sets the maximum size of the result set to limit. Returns $this to allow + * for chaining (fluid interface) + * + * @param int $limit + * @throws \InvalidArgumentException + * @return QueryInterface + * @phpstan-return QueryInterface + */ + public function setLimit($limit) + { + if (!is_int($limit) || $limit < 1) { + throw new \InvalidArgumentException('The limit must be an integer >= 1', 1245071870); + } + $this->limit = $limit; + return $this; + } + + /** + * Resets a previously set maximum size of the result set. Returns $this to allow + * for chaining (fluid interface) + * + * @return QueryInterface + */ + public function unsetLimit() + { + $this->limit = null; + return $this; + } + + /** + * Returns the maximum size of the result set to limit. + * + * @return int|null + */ + public function getLimit() + { + return $this->limit; + } + + /** + * Sets the start offset of the result set to offset. Returns $this to + * allow for chaining (fluid interface) + * + * @param int $offset + * @throws \InvalidArgumentException + * @return QueryInterface + * @phpstan-return QueryInterface + */ + public function setOffset($offset) + { + if (!is_int($offset) || $offset < 0) { + throw new \InvalidArgumentException('The offset must be a positive integer', 1245071872); + } + $this->offset = $offset; + return $this; + } + + /** + * Returns the start offset of the result set. + * + * @return int + */ + public function getOffset() + { + return $this->offset; + } + + /** + * The constraint used to limit the result set. Returns $this to allow + * for chaining (fluid interface) + * + * @param \TYPO3\CMS\Extbase\Persistence\Generic\Qom\ConstraintInterface $constraint + * @return QueryInterface + * @phpstan-return QueryInterface + */ + public function matching($constraint) + { + $this->constraint = $constraint; + return $this; + } + + /** + * Sets the statement of this query. If you use this, you will lose the abstraction from a concrete storage + * backend (database). + * + * @param string|\TYPO3\CMS\Core\Database\Query\QueryBuilder|\Doctrine\DBAL\Statement $statement The statement + * @param array $parameters An array of parameters. These will be bound to placeholders '?' in the $statement. + * @return QueryInterface + */ + public function statement($statement, array $parameters = []) + { + $this->statement = $this->qomFactory->statement($statement, $parameters); + return $this; + } + + /** + * Returns the statement of this query. + * + * @return \TYPO3\CMS\Extbase\Persistence\Generic\Qom\Statement + */ + public function getStatement() + { + return $this->statement; + } + + /** + * Gets the constraint for this query. + * + * @return \TYPO3\CMS\Extbase\Persistence\Generic\Qom\ConstraintInterface|null the constraint, or null if none + */ + public function getConstraint() + { + return $this->constraint; + } + + /** + * Performs a logical conjunction of multiple given constraints. The method + * takes an arbitrary number of constraints and concatenates them with a boolean AND. + */ + public function logicalAnd(ConstraintInterface ...$constraints): AndInterface + { + switch (count($constraints)) { + case 0: + $alwaysTrue = $this->greaterThan('uid', 0); + return $this->qomFactory->_and($alwaysTrue, $alwaysTrue); + case 1: + $alwaysTrue = $this->greaterThan('uid', 0); + return $this->qomFactory->_and(array_shift($constraints), $alwaysTrue); + default: + $resultingConstraint = $this->qomFactory->_and(array_shift($constraints), array_shift($constraints)); + foreach ($constraints as $furtherConstraint) { + $resultingConstraint = $this->qomFactory->_and($resultingConstraint, $furtherConstraint); + } + return $resultingConstraint; + } + } + + /** + * Performs a logical disjunction of multiple given constraints. The method + * takes an arbitrary number of constraints and concatenates them with a boolean OR. + */ + public function logicalOr(ConstraintInterface ...$constraints): OrInterface + { + switch (count($constraints)) { + case 0: + $alwaysFalse = $this->equals('uid', 0); + return $this->qomFactory->_or($alwaysFalse, $alwaysFalse); + case 1: + $alwaysFalse = $this->equals('uid', 0); + return $this->qomFactory->_or(array_shift($constraints), $alwaysFalse); + default: + $resultingConstraint = $this->qomFactory->_or(array_shift($constraints), array_shift($constraints)); + foreach ($constraints as $furtherConstraint) { + $resultingConstraint = $this->qomFactory->_or($resultingConstraint, $furtherConstraint); + } + return $resultingConstraint; + } + } + + /** + * Performs a logical negation of the given constraint + * + * @param \TYPO3\CMS\Extbase\Persistence\Generic\Qom\ConstraintInterface $constraint Constraint to negate + * @throws \RuntimeException + * @return \TYPO3\CMS\Extbase\Persistence\Generic\Qom\NotInterface + */ + public function logicalNot(ConstraintInterface $constraint) + { + return $this->qomFactory->not($constraint); + } + + /** + * Returns an equals criterion used for matching objects against a query + * + * @param string $propertyName The name of the property to compare against + * @param mixed $operand The value to compare with + * @param bool $caseSensitive Whether the equality test should be done case-sensitive + * @return \TYPO3\CMS\Extbase\Persistence\Generic\Qom\ComparisonInterface + */ + public function equals($propertyName, $operand, $caseSensitive = true) + { + if (is_object($operand) || $caseSensitive) { + $comparison = $this->qomFactory->comparison( + $this->qomFactory->propertyValue($propertyName, $this->getSelectorName()), + QueryInterface::OPERATOR_EQUAL_TO, + $operand + ); + } else { + $comparison = $this->qomFactory->comparison( + $this->qomFactory->lowerCase($this->qomFactory->propertyValue($propertyName, $this->getSelectorName())), + QueryInterface::OPERATOR_EQUAL_TO, + mb_strtolower($operand, \TYPO3\CMS\Extbase\Persistence\Generic\Query::CHARSET) + ); + } + return $comparison; + } + + /** + * Returns a like criterion used for matching objects against a query + * + * @param string $propertyName The name of the property to compare against + * @param mixed $operand The value to compare with + * @return \TYPO3\CMS\Extbase\Persistence\Generic\Qom\ComparisonInterface + */ + public function like($propertyName, $operand) + { + return $this->qomFactory->comparison( + $this->qomFactory->propertyValue($propertyName, $this->getSelectorName()), + QueryInterface::OPERATOR_LIKE, + $operand + ); + } + + /** + * Returns a "contains" criterion used for matching objects against a query. + * It matches if the multivalued property contains the given operand. + * + * @param string $propertyName The name of the (multivalued) property to compare against + * @param mixed $operand The value to compare with + * @return \TYPO3\CMS\Extbase\Persistence\Generic\Qom\ComparisonInterface + */ + public function contains($propertyName, $operand) + { + return $this->qomFactory->comparison($this->qomFactory->propertyValue($propertyName, $this->getSelectorName()), QueryInterface::OPERATOR_CONTAINS, $operand); + } + + /** + * Returns an "in" criterion used for matching objects against a query. It + * matches if the property's value is contained in the multivalued operand. + * + * @param string $propertyName The name of the property to compare against + * @param mixed $operand The value to compare with, multivalued + * @throws Exception\UnexpectedTypeException + * @return \TYPO3\CMS\Extbase\Persistence\Generic\Qom\ComparisonInterface + */ + public function in($propertyName, $operand) + { + if (!TypeHandlingUtility::isValidTypeForMultiValueComparison($operand)) { + throw new UnexpectedTypeException('The "in" operator must be given a multivalued operand (array, ArrayAccess, Traversable).', 1264678095); + } + return $this->qomFactory->comparison($this->qomFactory->propertyValue($propertyName, $this->getSelectorName()), QueryInterface::OPERATOR_IN, $operand); + } + + /** + * Returns a less than criterion used for matching objects against a query + * + * @param string $propertyName The name of the property to compare against + * @param mixed $operand The value to compare with + * @return \TYPO3\CMS\Extbase\Persistence\Generic\Qom\ComparisonInterface + */ + public function lessThan($propertyName, $operand) + { + return $this->qomFactory->comparison($this->qomFactory->propertyValue($propertyName, $this->getSelectorName()), QueryInterface::OPERATOR_LESS_THAN, $operand); + } + + /** + * Returns a less or equal than criterion used for matching objects against a query + * + * @param string $propertyName The name of the property to compare against + * @param mixed $operand The value to compare with + * @return \TYPO3\CMS\Extbase\Persistence\Generic\Qom\ComparisonInterface + */ + public function lessThanOrEqual($propertyName, $operand) + { + return $this->qomFactory->comparison($this->qomFactory->propertyValue($propertyName, $this->getSelectorName()), QueryInterface::OPERATOR_LESS_THAN_OR_EQUAL_TO, $operand); + } + + /** + * Returns a greater than criterion used for matching objects against a query + * + * @param string $propertyName The name of the property to compare against + * @param mixed $operand The value to compare with + * @return \TYPO3\CMS\Extbase\Persistence\Generic\Qom\ComparisonInterface + */ + public function greaterThan($propertyName, $operand) + { + return $this->qomFactory->comparison($this->qomFactory->propertyValue($propertyName, $this->getSelectorName()), QueryInterface::OPERATOR_GREATER_THAN, $operand); + } + + /** + * Returns a greater than or equal criterion used for matching objects against a query + * + * @param string $propertyName The name of the property to compare against + * @param mixed $operand The value to compare with + * @return \TYPO3\CMS\Extbase\Persistence\Generic\Qom\ComparisonInterface + */ + public function greaterThanOrEqual($propertyName, $operand) + { + return $this->qomFactory->comparison($this->qomFactory->propertyValue($propertyName, $this->getSelectorName()), QueryInterface::OPERATOR_GREATER_THAN_OR_EQUAL_TO, $operand); + } + + /** + * Returns a greater than or equal criterion used for matching objects against a query + * + * @param string $propertyName The name of the property to compare against + * @param mixed $operandLower The value of the lower boundary to compare against + * @param mixed $operandUpper The value of the upper boundary to compare against + * @return \TYPO3\CMS\Extbase\Persistence\Generic\Qom\AndInterface + */ + public function between($propertyName, $operandLower, $operandUpper) + { + return $this->logicalAnd( + $this->greaterThanOrEqual($propertyName, $operandLower), + $this->lessThanOrEqual($propertyName, $operandUpper) + ); + } + + /** + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ + public function __wakeup() + { + $this->persistenceManager = GeneralUtility::makeInstance(PersistenceManagerInterface::class); + $this->dataMapFactory = GeneralUtility::makeInstance(DataMapFactory::class); + $this->qomFactory = GeneralUtility::makeInstance(QueryObjectModelFactory::class); + } + + /** + * @return array + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ + public function __sleep() + { + return ['type', 'source', 'constraint', 'statement', 'orderings', 'limit', 'offset', 'querySettings']; + } + + /** + * Returns the query result count. + * + * @return int The query result count + */ + public function count() + { + return $this->execute()->count(); + } +} diff --git a/Classes/Persistence/Generic/QueryFactory.php b/Classes/Persistence/Generic/QueryFactory.php new file mode 100644 index 0000000..494ecc1 --- /dev/null +++ b/Classes/Persistence/Generic/QueryFactory.php @@ -0,0 +1,71 @@ + $className + * @phpstan-return QueryInterface + */ + public function create($className): QueryInterface + { + $query = GeneralUtility::makeInstance(QueryInterface::class); + $query->setType($className); + $querySettings = GeneralUtility::makeInstance(QuerySettingsInterface::class); + + $dataMap = $this->dataMapFactory->buildDataMap($className); + if ($dataMap->rootLevel) { + $querySettings->setRespectStoragePage(false); + } + + $storagePid = '0'; + try { + $frameworkConfiguration = $this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FRAMEWORK); + $storagePid = (string)($frameworkConfiguration['persistence']['storagePid'] ?? '0'); + } catch (NoServerRequestGivenException) { + // Fallback to storagePid 0 if ConfigurationManager has not been initialized with a Request. This + // is a measure to specifically allow running the extbase persistence layer without a Request, which + // may be useful in some CLI scenarios (and can be convenient in tests) when no other code branches + // of extbase that have a hard dependency to the Request (e.g. controllers / view) are used. + } + + $querySettings->setStoragePageIds(GeneralUtility::intExplode(',', $storagePid)); + $query->setQuerySettings($querySettings); + return $query; + } +} diff --git a/Classes/Persistence/Generic/QueryFactoryInterface.php b/Classes/Persistence/Generic/QueryFactoryInterface.php new file mode 100644 index 0000000..99e76ac --- /dev/null +++ b/Classes/Persistence/Generic/QueryFactoryInterface.php @@ -0,0 +1,33 @@ + $className + * @phpstan-return \TYPO3\CMS\Extbase\Persistence\QueryInterface + */ + public function create($className); +} diff --git a/Classes/Persistence/Generic/QueryResult.php b/Classes/Persistence/Generic/QueryResult.php new file mode 100644 index 0000000..67ffabf --- /dev/null +++ b/Classes/Persistence/Generic/QueryResult.php @@ -0,0 +1,260 @@ + + */ +#[Autoconfigure(public: true, shared: false)] +class QueryResult implements QueryResultInterface +{ + protected DataMapper $dataMapper; + protected PersistenceManagerInterface $persistenceManager; + + /** + * @var int|null + */ + protected $numberOfResults; + + /** + * @phpstan-var QueryInterface|null + */ + protected ?QueryInterface $query = null; + + /** + * @var array|null + * @phpstan-var list|null + */ + protected $queryResult; + + public function __construct( + DataMapper $dataMapper, + PersistenceManagerInterface $persistenceManager + ) { + $this->dataMapper = $dataMapper; + $this->persistenceManager = $persistenceManager; + } + + /** + * @phpstan-param QueryInterface $query + */ + public function setQuery(QueryInterface $query): void + { + $this->query = $query; + $this->dataMapper->setQuery($query); + } + + /** + * Loads the objects this QueryResult is supposed to hold + */ + protected function initialize() + { + if (!is_array($this->queryResult)) { + $this->queryResult = $this->dataMapper->map($this->query->getType(), $this->persistenceManager->getObjectDataByQuery($this->query)); + } + } + + /** + * Returns a clone of the query object + * + * @return QueryInterface + * @phpstan-return QueryInterface + */ + public function getQuery() + { + return clone $this->query; + } + + /** + * Returns the first object in the result set + * + * @return object + * @phpstan-return TValue|null + */ + public function getFirst() + { + if (is_array($this->queryResult)) { + $queryResult = $this->queryResult; + reset($queryResult); + } else { + $query = $this->getQuery(); + $query->setLimit(1); + $queryResult = $this->dataMapper->map($query->getType(), $this->persistenceManager->getObjectDataByQuery($query)); + } + $firstResult = current($queryResult); + if ($firstResult === false) { + $firstResult = null; + } + return $firstResult; + } + + /** + * Returns the number of objects in the result + * + * @return int The number of matching objects + */ + public function count(): int + { + if ($this->numberOfResults === null) { + if (is_array($this->queryResult)) { + $this->numberOfResults = count($this->queryResult); + } else { + $this->numberOfResults = $this->persistenceManager->getObjectCountByQuery($this->query); + } + } + return $this->numberOfResults; + } + + /** + * Returns an array with the objects in the result set + * + * @return array + * @phpstan-return list + */ + public function toArray() + { + $this->initialize(); + return iterator_to_array($this); + } + + /** + * This method is needed to implement the ArrayAccess interface, + * but it isn't very useful as the offset has to be an integer + * + * @param mixed $offset + */ + public function offsetExists($offset): bool + { + $this->initialize(); + return isset($this->queryResult[$offset]); + } + + /** + * @param mixed $offset + * @return TValue|null + */ + public function offsetGet($offset): mixed + { + $this->initialize(); + return $this->queryResult[$offset] ?? null; + } + + /** + * This method has no effect on the persisted objects but only on the result set + * + * @param mixed $offset + * @param mixed $value + * @phpstan-param TValue $value + */ + public function offsetSet($offset, $value): void + { + $this->initialize(); + $this->numberOfResults = null; + $this->queryResult[$offset] = $value; + } + + /** + * This method has no effect on the persisted objects but only on the result set + * + * @param mixed $offset + */ + public function offsetUnset($offset): void + { + $this->initialize(); + $this->numberOfResults = null; + unset($this->queryResult[$offset]); + } + + /** + * @return mixed + * @see Iterator::current() + * @return TValue|false + */ + public function current(): mixed + { + $this->initialize(); + return current($this->queryResult); + } + + /** + * @return mixed + * @see Iterator::key() + * @return int|null + */ + public function key(): mixed + { + $this->initialize(); + return key($this->queryResult); + } + + /** + * @see Iterator::next() + */ + public function next(): void + { + $this->initialize(); + next($this->queryResult); + } + + /** + * @see Iterator::rewind() + */ + public function rewind(): void + { + $this->initialize(); + reset($this->queryResult); + } + + /** + * @see Iterator::valid() + */ + public function valid(): bool + { + $this->initialize(); + return current($this->queryResult) !== false; + } + + /** + * Ensures that the persistenceManager and dataMapper are back when loading the QueryResult + * from the cache + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ + public function __wakeup() + { + $this->persistenceManager = GeneralUtility::makeInstance(PersistenceManagerInterface::class); + $this->dataMapper = GeneralUtility::makeInstance(DataMapper::class); + } + + /** + * @return array + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ + public function __sleep() + { + return ['query']; + } +} diff --git a/Classes/Persistence/Generic/QuerySettingsInterface.php b/Classes/Persistence/Generic/QuerySettingsInterface.php new file mode 100644 index 0000000..0cbc6dd --- /dev/null +++ b/Classes/Persistence/Generic/QuerySettingsInterface.php @@ -0,0 +1,134 @@ +ignoreEnableFields = TRUE. + * + * @param string[] $enableFieldsToBeIgnored + * @return $this fluent interface + * @see setIgnoreEnableFields() + */ + public function setEnableFieldsToBeIgnored(array $enableFieldsToBeIgnored): self; + + /** + * An array of column names in the enable columns array (array keys in $GLOBALS['TCA'][$table]['ctrl']['enablecolumns']), + * to be ignored while building the query statement. + * + * @return string[] + * @see getIgnoreEnableFields() + */ + public function getEnableFieldsToBeIgnored(): array; + + /** + * Sets the flag if the query should return objects that are deleted. + * + * @param bool $includeDeleted + * @return $this fluent interface + */ + public function setIncludeDeleted(bool $includeDeleted): self; + + /** + * Returns if the query should return objects that are deleted. + */ + public function getIncludeDeleted(): bool; + + public function getLanguageAspect(): LanguageAspect; + + /** + * Overrides the main language aspect, defined in the main Context API + * @return $this fluent interface + */ + public function setLanguageAspect(LanguageAspect $languageAspect): self; +} diff --git a/Classes/Persistence/Generic/Session.php b/Classes/Persistence/Generic/Session.php new file mode 100644 index 0000000..b37b430 --- /dev/null +++ b/Classes/Persistence/Generic/Session.php @@ -0,0 +1,215 @@ +> + */ + protected array $identifierMap = []; + + /** + * Constructs a new Session + */ + public function __construct() + { + $this->reconstitutedEntities = new ObjectStorage(); + $this->objectMap = new ObjectStorage(); + } + + /** + * Registers data for a reconstituted object. + * + * $entityData format is described in + * "Documentation/PersistenceFramework object data format.txt" + */ + public function registerReconstitutedEntity(object $entity): void + { + $this->reconstitutedEntities->attach($entity); + } + + /** + * Unregisters data for a reconstituted object + */ + public function unregisterReconstitutedEntity(object $entity): void + { + if ($this->reconstitutedEntities->contains($entity)) { + $this->reconstitutedEntities->detach($entity); + } + } + + /** + * Returns all objects which have been registered as reconstituted + */ + public function getReconstitutedEntities(): ObjectStorage + { + return $this->reconstitutedEntities; + } + + /** + * Checks whether the given object is known to the identity map + */ + public function hasObject(object $object): bool + { + return $this->objectMap->contains($object); + } + + /** + * Checks whether the given identifier is known to the identity map + * + * @param non-empty-string $identifier + * @param class-string $className + */ + public function hasIdentifier(string $identifier, string $className): bool + { + return isset($this->identifierMap[$this->getClassIdentifier($className)][$identifier]); + } + + /** + * Returns the object for the given identifier + * + * @param non-empty-string $identifier + * @param class-string $className + */ + public function getObjectByIdentifier(string $identifier, string $className): object + { + return $this->identifierMap[$this->getClassIdentifier($className)][$identifier]; + } + + /** + * Returns the identifier for the given object from + * the session, if the object was registered. + * + * @return non-empty-string|null + */ + public function getIdentifierByObject(object $object): ?string + { + if ($this->hasObject($object)) { + return $this->objectMap[$object]; + } + return null; + } + + /** + * Register an identifier for an object + * + * @param non-empty-string $identifier + */ + public function registerObject(object $object, string $identifier): void + { + $this->objectMap[$object] = $identifier; + $this->identifierMap[$this->getClassIdentifier(get_class($object))][$identifier] = $object; + } + + /** + * Unregister an object + */ + public function unregisterObject(object $object): void + { + unset($this->identifierMap[$this->getClassIdentifier(get_class($object))][$this->objectMap[$object]]); + $this->objectMap->detach($object); + } + + /** + * Destroy the state of the persistence session and reset + * all internal data. + */ + public function destroy(): void + { + $this->identifierMap = []; + $this->objectMap = new ObjectStorage(); + $this->reconstitutedEntities = new ObjectStorage(); + } + + /** + * Objects are stored in the cache with their implementation class name + * to allow reusing instances of different classes that point to the same implementation + * Returns a unique class identifier respecting configured implementation class names + * + * @param class-string $className + * @return non-empty-string + */ + protected function getClassIdentifier(string $className): string + { + return strtolower($className); + } + + /** + * Build a language-aware identifier for the identity map by combining + * a base identifier with a language content identifier. + */ + public function buildIdentifier(string|array $baseIdentifier, ?LanguageAspect $languageAspect = null): string + { + if (is_array($baseIdentifier)) { + $identifier = (string)$baseIdentifier['uid']; + if (isset($baseIdentifier['_LOCALIZED_UID'])) { + $identifier .= '_' . $baseIdentifier['_LOCALIZED_UID']; + } + $baseIdentifier = $identifier; + } + // Use default language context for newly inserted objects + $languageAspect ??= new LanguageAspect(0, 0, LanguageAspect::OVERLAYS_ON_WITH_FLOATING, []); + return $baseIdentifier . '@' . $this->getContentIdentifier($languageAspect); + } + + /** + * Build a unique identifier representing the content-fetching configuration + * of the given LanguageAspect. + * + * This includes contentId, overlayType, and fallbackChain — everything + * that affects which record overlay is returned. The language ID is + * intentionally excluded because it only affects menus/links, not content. + * + * @internal + */ + protected function getContentIdentifier(LanguageAspect $languageAspect): string + { + return sprintf( + '%d-%s-%s', + $languageAspect->getContentId(), + $languageAspect->getOverlayType(), + implode(',', $languageAspect->getFallbackChain()) + ); + } + + /** + * Extract the base identifier (before '@') from a full identity map identifier. + */ + public function getBaseIdentifier(string $identifier): string + { + $pos = strpos($identifier, '@'); + if ($pos !== false) { + return substr($identifier, 0, $pos); + } + return $identifier; + } +} diff --git a/Classes/Persistence/Generic/Storage/BackendInterface.php b/Classes/Persistence/Generic/Storage/BackendInterface.php new file mode 100644 index 0000000..0694675 --- /dev/null +++ b/Classes/Persistence/Generic/Storage/BackendInterface.php @@ -0,0 +1,82 @@ + value). This array will be transformed to a WHERE clause + * @param bool $isRelation TRUE if we are currently inserting into a relation table, FALSE by default + */ + public function removeRow(string $tableName, array $where, bool $isRelation = false): void; + + /** + * Returns the number of items matching the query. + */ + public function getObjectCountByQuery(QueryInterface $query): int; + + /** + * Returns the object data matching the $query. + */ + public function getObjectDataByQuery(QueryInterface $query): array; + + /** + * Checks if a Value Object equal to the given Object exists in the data base + * + * @param \TYPO3\CMS\Extbase\DomainObject\AbstractValueObject $object The Value Object + * @return int|null The matching uid if an object was found, else null + * @todo this is the last monster in this persistence series. refactor! + */ + public function getUidOfAlreadyPersistedValueObject(AbstractValueObject $object): ?int; +} diff --git a/Classes/Persistence/Generic/Storage/Exception/BadConstraintException.php b/Classes/Persistence/Generic/Storage/Exception/BadConstraintException.php new file mode 100644 index 0000000..1e55247 --- /dev/null +++ b/Classes/Persistence/Generic/Storage/Exception/BadConstraintException.php @@ -0,0 +1,25 @@ +connectionPool->getConnectionForTable($tableName); + $connection->insert($tableName, $fieldValues, $this->getTypesForDataset($tableName, $fieldValues)); + } catch (DBALException $e) { + throw new SqlErrorException($e->getMessage(), 1470230766, $e); + } + + $uid = 0; + if (!$isRelation) { + // Relation tables have no auto_increment column, so no retrieval must be tried. + $uid = (int)$connection->lastInsertId(); + $this->cacheService->clearCacheForRecord($tableName, $uid); + } + return $uid; + } + + /** + * Updates a row in the storage + * + * @param string $tableName The database table name + * @param array $fieldValues The row to be updated + * @param bool $isRelation TRUE if we are currently inserting into a relation table, FALSE by default + * @throws \InvalidArgumentException + * @throws SqlErrorException + */ + public function updateRow(string $tableName, array $fieldValues, bool $isRelation = false): void + { + if (!isset($fieldValues['uid'])) { + throw new \InvalidArgumentException('The given row must contain a value for "uid".', 1476045164); + } + + $uid = (int)$fieldValues['uid']; + unset($fieldValues['uid']); + + try { + $connection = $this->connectionPool->getConnectionForTable($tableName); + $connection->update($tableName, $fieldValues, ['uid' => $uid], $this->getTypesForDataset($tableName, $fieldValues)); + } catch (DBALException $e) { + throw new SqlErrorException($e->getMessage(), 1470230767, $e); + } + + if (!$isRelation) { + $this->cacheService->clearCacheForRecord($tableName, $uid); + } + } + + /** + * Updates a relation row in the storage. + * + * @param string $tableName The database relation table name + * @param array $fieldValues The row to be updated + * @throws SqlErrorException + * @throws \InvalidArgumentException + */ + public function updateRelationTableRow(string $tableName, array $fieldValues): void + { + if (!isset($fieldValues['uid_local']) && !isset($fieldValues['uid_foreign'])) { + throw new \InvalidArgumentException( + 'The given fieldValues must contain a value for "uid_local" and "uid_foreign".', + 1360500126 + ); + } + + $where = []; + $where['uid_local'] = (int)$fieldValues['uid_local']; + $where['uid_foreign'] = (int)$fieldValues['uid_foreign']; + unset($fieldValues['uid_local']); + unset($fieldValues['uid_foreign']); + + if (!empty($fieldValues['tablenames'])) { + $where['tablenames'] = $fieldValues['tablenames']; + unset($fieldValues['tablenames']); + } + if (!empty($fieldValues['fieldname'])) { + $where['fieldname'] = $fieldValues['fieldname']; + unset($fieldValues['fieldname']); + } + + try { + $this->connectionPool->getConnectionForTable($tableName)->update( + $tableName, + $fieldValues, + $where, + $this->getTypesForDataset($tableName, $fieldValues), + ); + } catch (DBALException $e) { + throw new SqlErrorException($e->getMessage(), 1470230768, $e); + } + } + + /** + * Deletes a row in the storage + * + * @param string $tableName The database table name + * @param array $where An array of where array('fieldname' => value). + * @param bool $isRelation TRUE if we are currently manipulating a relation table, FALSE by default + * @throws SqlErrorException + */ + public function removeRow(string $tableName, array $where, bool $isRelation = false): void + { + try { + $this->connectionPool->getConnectionForTable($tableName)->delete($tableName, $where); + } catch (DBALException $e) { + throw new SqlErrorException($e->getMessage(), 1470230769, $e); + } + + if (!$isRelation && isset($where['uid'])) { + $this->cacheService->clearCacheForRecord($tableName, (int)$where['uid']); + } + } + + /** + * Returns the object data matching the $query. + * + * @throws SqlErrorException + */ + public function getObjectDataByQuery(QueryInterface $query): array + { + $statement = $query->getStatement(); + // A custom query is needed for the language, so a custom context is cloned + /** @var Context $context */ + $context = clone GeneralUtility::makeInstance(Context::class); + $context->setAspect('language', $query->getQuerySettings()->getLanguageAspect()); + if ($statement instanceof Statement && !$statement->getStatement() instanceof QueryBuilder) { + $rows = $this->getObjectDataByRawQuery($statement); + } else { + $queryParser = GeneralUtility::makeInstance(Typo3DbQueryParser::class); + if ($statement instanceof Statement + && $statement->getStatement() instanceof QueryBuilder + ) { + $queryBuilder = $statement->getStatement(); + } else { + $queryBuilder = $queryParser->convertQueryToDoctrineQueryBuilder($query); + } + $selectParts = $queryBuilder->getSelect(); + if ($queryParser->isDistinctQuerySuggested() && !empty($selectParts)) { + $selectParts[0] = 'DISTINCT ' . $selectParts[0]; + $queryBuilder->selectLiteral(...$selectParts); + } + if ($query->getOffset()) { + $queryBuilder->setFirstResult($query->getOffset()); + } + if ($query->getLimit()) { + // Only set the "real" limit in LIVE workspace, as we do not need to make WS overlays here + // And can calculate with the direct result from the RDBMS without needing to calculate this in + // PHP (see below). + // What we do in workspace, is making a "best guess". Why do we do this? If we have content that + // is hidden in a workspace, we need to get the "next" record in line, but we cannot do this + // with overlays in SQL. So we use the "best guess" by adding twice the limit. Imagine you have + // 2000 news records, and we need to manually calculate the first 10 records, we just take 20 records + // from SQL and hope that this matches for "most" usecases (Pareto Principle). + if ($context->getAspect('workspace')->isLive()) { + $queryBuilder->setMaxResults($query->getLimit()); + } else { + $queryBuilder->setMaxResults($query->getLimit() * 2); + } + } + try { + $rows = $queryBuilder->executeQuery()->fetchAllAssociative(); + } catch (DBALException $e) { + throw new SqlErrorException($e->getMessage(), 1472074485, $e); + } + } + + if (!empty($rows)) { + $rows = $this->overlayLanguageAndWorkspace($query->getSource(), $rows, $query, $context); + if ($this->autoTagging) { + $source = $query->getSource(); + if ($source instanceof JoinInterface) { + $source = $source->getRight(); + } + if (!$source instanceof SelectorInterface) { + throw new \RuntimeException(get_class($source) . ' must implement SelectorInterface at this point.', 1726753183); + } + $tableName = $source->getSelectorName(); + $this->addCacheTagsForRows($tableName, $rows); + } + } + + return $rows; + } + + /** + * Returns the object data using a custom statement + * + * @throws SqlErrorException when the raw SQL statement fails in the database + */ + protected function getObjectDataByRawQuery(Statement $statement): array + { + $realStatement = $statement->getStatement(); + $parameters = $statement->getBoundVariables(); + + // The real statement is an instance of the Doctrine DBAL QueryBuilder, so fetching + // this directly is possible + if ($realStatement instanceof QueryBuilder) { + try { + $result = $realStatement->executeQuery(); + } catch (DBALException $e) { + throw new SqlErrorException($e->getMessage(), 1472064721, $e); + } + $rows = $result->fetchAllAssociative(); + // Prepared Doctrine DBAL statement + } elseif ($realStatement instanceof \Doctrine\DBAL\Statement) { + try { + foreach ($parameters as $parameterIdentifier => $parameterValue) { + $realStatement->bindValue($parameterIdentifier, $parameterValue); + } + $result = $realStatement->executeQuery(); + } catch (DBALException $e) { + throw new SqlErrorException($e->getMessage(), 1481281404, $e); + } + $rows = $result->fetchAllAssociative(); + } else { + // Do a real raw query. This is very stupid, as it does not allow to use DBAL's real power if + // several tables are on different databases, so this is used with caution and could be removed + // in the future + try { + $connection = $this->connectionPool->getConnectionByName(ConnectionPool::DEFAULT_CONNECTION_NAME); + $statement = $connection->executeQuery($realStatement, $parameters); + } catch (DBALException $e) { + throw new SqlErrorException($e->getMessage(), 1472064775, $e); + } + + $rows = $statement->fetchAllAssociative(); + } + + return $rows; + } + + /** + * Returns the number of tuples matching the query. + * + * @return int The number of matching tuples + * @throws BadConstraintException + * @throws SqlErrorException + */ + public function getObjectCountByQuery(QueryInterface $query): int + { + if ($query->getConstraint() instanceof Statement) { + throw new BadConstraintException('Could not execute count on queries with a constraint of type TYPO3\\CMS\\Extbase\\Persistence\\Generic\\Qom\\Statement', 1256661045); + } + + $statement = $query->getStatement(); + if ($statement instanceof Statement + && !$statement->getStatement() instanceof QueryBuilder + ) { + $rows = $this->getObjectDataByQuery($query); + $count = count($rows); + } else { + $queryParser = GeneralUtility::makeInstance(Typo3DbQueryParser::class); + $queryBuilder = $queryParser + ->convertQueryToDoctrineQueryBuilder($query) + ->resetOrderBy(); + + if ($queryParser->isDistinctQuerySuggested()) { + $source = $queryBuilder->getFrom()[0]; + // Tablename is already quoted for the DBMS, we need to treat table and field names separately + $tableName = $source->alias ?: $source->table; + $fieldName = $queryBuilder->quoteIdentifier('uid'); + $queryBuilder + ->resetGroupBy() + ->selectLiteral(sprintf('COUNT(DISTINCT %s.%s)', $tableName, $fieldName)); + } else { + $queryBuilder->count('*'); + } + // Ensure to count only records in the current workspace + $context = GeneralUtility::makeInstance(Context::class); + $workspaceUid = (int)$context->getPropertyFromAspect('workspace', 'id'); + $queryBuilder->getRestrictions()->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $workspaceUid)); + + try { + $count = $queryBuilder->executeQuery()->fetchOne(); + } catch (DBALException $e) { + throw new SqlErrorException($e->getMessage(), 1472074379, $e); + } + if ($query->getOffset()) { + $count -= $query->getOffset(); + } + if ($query->getLimit()) { + $count = min($count, $query->getLimit()); + } + } + return (int)max(0, $count); + } + + /** + * Checks if a Value Object equal to the given Object exists in the database + * + * @param AbstractValueObject $object The Value Object + * @return int|null The matching uid if an object was found, else FALSE + * @throws SqlErrorException + */ + public function getUidOfAlreadyPersistedValueObject(AbstractValueObject $object): ?int + { + $className = get_class($object); + /** @var DataMapper $dataMapper */ + $dataMapper = GeneralUtility::makeInstance(DataMapper::class); + $dataMap = $dataMapper->getDataMap($className); + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($dataMap->tableName); + if (($GLOBALS['TYPO3_REQUEST'] ?? null) instanceof ServerRequestInterface + && ApplicationType::fromRequest($GLOBALS['TYPO3_REQUEST'])->isFrontend() + ) { + $queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class)); + } + $whereClause = []; + // loop over all properties of the object to exactly set the values of each database field + $classSchema = $this->reflectionService->getClassSchema($className); + foreach ($classSchema->getDomainObjectProperties() as $property) { + $propertyName = $property->getName(); + // @todo We couple the Backend to the Entity implementation (uid, isClone); changes there breaks this method + if ($dataMap->isPersistableProperty($propertyName) && $propertyName !== AbstractDomainObject::PROPERTY_UID && $propertyName !== AbstractDomainObject::PROPERTY_PID && $propertyName !== 'isClone') { + $propertyValue = $object->_getProperty($propertyName); + $columnMap = $dataMap->getColumnMap($propertyName); + $fieldName = $columnMap->columnName; + if ($propertyValue === null) { + $whereClause[] = $queryBuilder->expr()->isNull($fieldName); + } else { + $whereClause[] = $queryBuilder->expr()->eq($fieldName, $queryBuilder->createNamedParameter($dataMapper->getPlainValue($propertyValue, $columnMap))); + } + } + } + $queryBuilder + ->select('uid') + ->from($dataMap->tableName) + ->where(...$whereClause); + + try { + $uid = (int)$queryBuilder + ->executeQuery() + ->fetchOne(); + if ($uid > 0) { + return $uid; + } + return null; + } catch (DBALException $e) { + throw new SqlErrorException($e->getMessage(), 1470231748, $e); + } + } + + /** + * Performs workspace and language overlay on the given row array. The language and workspace id is automatically + * detected (depending on FE or BE context). You can also explicitly set the language/workspace id. + */ + protected function overlayLanguageAndWorkspace(SourceInterface $source, array $rows, QueryInterface $query, Context $context): array + { + $workspaceUid = (int)$context->getPropertyFromAspect('workspace', 'id'); + + $pageRepository = GeneralUtility::makeInstance(PageRepository::class, $context); + if ($source instanceof SelectorInterface) { + $tableName = $source->getSelectorName(); + $rows = $this->resolveMovedRecordsInWorkspace($tableName, $rows, $workspaceUid); + return $this->overlayLanguageAndWorkspaceForSelect($tableName, $rows, $pageRepository, $query, $context); + } + if ($source instanceof JoinInterface) { + $tableName = $source->getRight()->getSelectorName(); + // Special handling of joined select is only needed when doing workspace overlays, which does not happen + // in live workspace + if ($workspaceUid === 0) { + return $this->overlayLanguageAndWorkspaceForSelect($tableName, $rows, $pageRepository, $query, $context); + } + return $this->overlayLanguageAndWorkspaceForJoinedSelect($tableName, $rows, $pageRepository, $query, $context); + } + // No proper source, so we do not have a table name here + // we cannot do an overlay and return the original rows instead. + return $rows; + } + + /** + * If the result is a plain SELECT (no JOIN) then the regular overlay process works for tables + * - overlay workspace + * - overlay language of versioned record again + */ + protected function overlayLanguageAndWorkspaceForSelect(string $tableName, array $rows, PageRepository $pageRepository, QueryInterface $query, Context $context): array + { + $limit = 0; + $overlaidRows = []; + $countOverlaidRows = 0; + if ($query->getLimit() && !$context->getAspect('workspace')->isLive()) { + $limit = $query->getLimit(); + } + + foreach ($rows as $row) { + $row = $this->overlayLanguageAndWorkspaceForSingleRecord($tableName, $row, $pageRepository, $query); + if (is_array($row)) { + $overlaidRows[] = $row; + $countOverlaidRows++; + // We need to calculate the number of overlaid rows manually in PHP + // (via the is_array() above), because some overlays do not exist in a Workspace + if ($limit === $countOverlaidRows) { + return $overlaidRows; + } + } + } + return $overlaidRows; + } + + /** + * If the result consists of a JOIN (usually happens if a property is a relation with a MM table) then it is necessary + * to only do overlays for the fields that are contained in the main database table, otherwise a SQL error is thrown. + * In order to make this happen, a single SQL query is made to fetch all possible field names (= array keys) of + * a record (TCA[$tableName][columns] does not contain all needed information), which is then used to compute + * a separate subset of the row which can be overlaid properly. + */ + protected function overlayLanguageAndWorkspaceForJoinedSelect(string $tableName, array $rows, PageRepository $pageRepository, QueryInterface $query, Context $context): array + { + // No valid rows, so this is skipped + if (!isset($rows[0]['uid'])) { + return $rows; + } + + $limit = 0; + $overlaidRows = []; + $countOverlaidRows = 0; + if ($query->getLimit() && !$context->getAspect('workspace')->isLive()) { + $limit = $query->getLimit(); + } + + // First, find out the fields that belong to the "main" selected table which is defined by TCA, and take the first + // record to find out all possible fields in this database table + $fieldsOfMainTable = $pageRepository->getRawRecord($tableName, (int)$rows[0]['uid']); + if (is_array($fieldsOfMainTable)) { + foreach ($rows as $row) { + $mainRow = array_intersect_key($row, $fieldsOfMainTable); + $joinRow = array_diff_key($row, $mainRow); + $mainRow = $this->overlayLanguageAndWorkspaceForSingleRecord($tableName, $mainRow, $pageRepository, $query); + if (is_array($mainRow)) { + $overlaidRows[] = array_replace($joinRow, $mainRow); + $countOverlaidRows++; + // We need to calculate the number of overlaid rows manually in PHP + // (via the is_array() above), because some overlays do not exist in a Workspace + if ($limit === $countOverlaidRows) { + return $overlaidRows; + } + } + } + } + return $overlaidRows; + } + + /** + * Takes one specific row, as defined in TCA and does all overlays. + * + * @return array|int|mixed|null the overlaid row or false or null if overlay failed. + */ + protected function overlayLanguageAndWorkspaceForSingleRecord(string $tableName, array $row, PageRepository $pageRepository, QueryInterface $query) + { + $querySettings = $query->getQuerySettings(); + $languageAspect = $querySettings->getLanguageAspect(); + $languageUid = $languageAspect->getContentId(); + $schema = $this->tcaSchemaFactory->get($tableName); + $languageOfCurrentRecord = 0; + $languageField = null; + $translationParentPointerField = null; + // If current row is a translation select its parent + if ($schema->isLanguageAware()) { + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + $languageField = $languageCapability->getLanguageField()->getName(); + $translationParentPointerField = $languageCapability->getTranslationOriginPointerField()->getName(); + } + if ($languageField && ($row[$languageField] ?? false)) { + $languageOfCurrentRecord = $row[$languageField]; + } + // Note #1: In case of ->findByUid([uid-of-translated-record]) the translated record should be fetched at all times + // Example: you've fetched a translation directly via findByUid(11) which is a translated record, but the + // request was to do overlays. In this case, the default record is loaded again, and then reapplied again. + // Note #2: We cannot use $languageAspect->doOverlays() as it also checks for ID > 0 + $fetchLocalizedRecord = $languageAspect->getOverlayType() !== LanguageAspect::OVERLAYS_OFF; + // We have a translated record from the DB, but we do overlays, so let's take the default language record + // and do overlays again later-on + if ($languageOfCurrentRecord > 0 + && $fetchLocalizedRecord + && ($row[$translationParentPointerField] ?? 0) > 0 + ) { + $row = $pageRepository->getRawRecord( + $tableName, + (int)$row[$translationParentPointerField] + ); + $languageUid = $languageOfCurrentRecord; + } + + // Handle workspace overlays + $pageRepository->versionOL($tableName, $row, true, $querySettings->getIgnoreEnableFields()); + if (is_array($row) && $fetchLocalizedRecord) { + if ($tableName === 'pages') { + $row = $pageRepository->getLanguageOverlay($tableName, $row); + } else { + if (!$querySettings->getRespectSysLanguage() + && $languageOfCurrentRecord > 0 + && (!$query instanceof Query || !$query->getParentQuery()) + ) { + // No parent query means we're processing the aggregate root. + // respectSysLanguage is false which means that records returned by the query + // might be from different languages (which is desired). + // So we must set the language used for overlay to the language of the current record + $languageUid = $languageOfCurrentRecord; + } + if ($translationParentPointerField + && ($row[$translationParentPointerField] ?? 0) > 0 + && $languageOfCurrentRecord > 0 + ) { + // Force overlay by faking default language record, as getRecordOverlay can only handle default language records + $row['uid'] = $row[$translationParentPointerField]; + $row[$languageField] = 0; + } + // The overlay type (and fallback chain) of the language aspect is respected, so translation + // behavior is consistent with the regular page / content rendering. The content language + // however may have been adjusted above to the language of the actually fetched record + // (see Note #1 and the respectSysLanguage handling), so a custom aspect is passed here. + $customLanguageAspect = new LanguageAspect( + $languageAspect->getId(), + $languageUid, + $languageAspect->getOverlayType(), + $languageAspect->getFallbackChain() + ); + $row = $pageRepository->getLanguageOverlay($tableName, $row, $customLanguageAspect); + } + } elseif (is_array($row)) { + // If an already localized record is fetched, the "uid" of the default language is used + // as the record is re-fetched in the DataMapper + if ($translationParentPointerField + && ($row[$translationParentPointerField] ?? 0) > 0 + && $languageOfCurrentRecord > 0 + ) { + $row['_LOCALIZED_UID'] = (int)$row['uid']; + $row['uid'] = $row[$translationParentPointerField]; + } + } + return $row; + } + + /** + * Fetches the moved record in case it is supported + * by the table and if there's only one row in the result set + * (applying this to all rows does not work, since the sorting + * order would be destroyed and possible limits are not met anymore) + * The move pointers are later unset (see versionOL() last argument) + */ + protected function resolveMovedRecordsInWorkspace(string $tableName, array $rows, int $workspaceUid): array + { + if ($workspaceUid === 0) { + return $rows; + } + if (!$this->tcaSchemaFactory->has($tableName) || !$this->tcaSchemaFactory->get($tableName)->hasCapability(TcaSchemaCapability::Workspace)) { + return $rows; + } + if (count($rows) !== 1) { + return $rows; + } + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($tableName); + $queryBuilder->getRestrictions()->removeAll(); + $movedRecords = $queryBuilder + ->select('*') + ->from($tableName) + ->where( + $queryBuilder->expr()->eq('t3ver_state', $queryBuilder->createNamedParameter(VersionState::MOVE_POINTER->value, Connection::PARAM_INT)), + $queryBuilder->expr()->eq('t3ver_wsid', $queryBuilder->createNamedParameter($workspaceUid, Connection::PARAM_INT)), + $queryBuilder->expr()->eq('t3ver_oid', $queryBuilder->createNamedParameter($rows[0]['uid'], Connection::PARAM_INT)) + ) + ->setMaxResults(1) + ->executeQuery() + ->fetchAllAssociative(); + if (!empty($movedRecords)) { + $rows = $movedRecords; + } + return $rows; + } + + protected function addCacheTagsForRows(string $tableName, array $rows): void + { + foreach ($rows as $row) { + $lifetime = $this->cacheLifetimeCalculator->calculateLifetimeForRow($tableName, $row); + $this->eventDispatcher->dispatch( + new AddCacheTagEvent( + new CacheTag(sprintf('%s_%s', $tableName, ($row['uid'] ?? 0)), $lifetime) + ) + ); + } + } + + /** + * @param array $fieldValues + * @return array + */ + private function getTypesForDataset(string $tableName, array $fieldValues): array + { + $connection = $this->connectionPool->getConnectionForTable($tableName); + $tableInfo = $connection->getSchemaInformation()->getTableInfo($tableName); + $types = []; + foreach ($fieldValues as $key => $value) { + if (!$tableInfo->hasColumnInfo($key)) { + // Field is not part of the database schema information, therefore no type is set here and + // Doctrine DBAL handles the value with its default binding type (ParameterType::STRING). + continue; + } + try { + // `ColumnInfo->getType()` returns the Doctrine type (e.g. JsonType), which carries the + // `PHP value <-> database value` conversion methods applied by Doctrine DBAL. Each Doctrine + // type maps to a plain binding type (e.g. ParameterType::STRING for VARCHAR/CHAR/TEXT/...), + // which binds the value as-is without applying any conversion. Extbase already performs that + // conversion itself, which is why the plain binding type is enforced here. This additionally + // prevents `Connection::ensureDatabaseValueTypes()` from adding the Doctrine type looked up + // from the database schema. + $types[$key] = $tableInfo->getColumnInfo($key)->getType()->getBindingType(); + } catch (TypesException) { + // Ignore, no type to be set + } + } + return $types; + } +} diff --git a/Classes/Persistence/Generic/Storage/Typo3DbQueryParser.php b/Classes/Persistence/Generic/Storage/Typo3DbQueryParser.php new file mode 100644 index 0000000..5f4b3c6 --- /dev/null +++ b/Classes/Persistence/Generic/Storage/Typo3DbQueryParser.php @@ -0,0 +1,1088 @@ + 'tableName', + * 'property1.property2' => 'tableName1', + */ + protected array $tablePropertyMap = []; + + /** + * Maps tablenames to their aliases to be used in where clauses etc. + * Mainly used for joins on the same table etc. + * + * @var array + */ + protected array $tableAliasMap = []; + + /** + * Stores all tables used in for SQL joins + */ + protected array $unionTableAliasCache = []; + protected bool $suggestDistinctQuery = false; + + public function __construct( + protected readonly DataMapper $dataMapper, + protected readonly TcaSchemaFactory $tcaSchemaFactory, + protected readonly ConnectionPool $connectionPool, + protected readonly PageRepository $pageRepository, + ) {} + + /** + * Whether using a distinct query is suggested. + * This information is defined during parsing of the current query + * for RELATION_HAS_MANY & RELATION_HAS_AND_BELONGS_TO_MANY relations. + */ + public function isDistinctQuerySuggested(): bool + { + return $this->suggestDistinctQuery; + } + + /** + * Returns a ready to be executed QueryBuilder object, based on the query + */ + public function convertQueryToDoctrineQueryBuilder(QueryInterface $query): QueryBuilder + { + // Reset property from previous run which is available using isDistinctQuerySuggested() + // after this method has been called. + $this->suggestDistinctQuery = false; + + if ($query->getStatement() && $query->getStatement()->getStatement() instanceof QueryBuilder) { + $this->queryBuilder = clone $query->getStatement()->getStatement(); + return $this->queryBuilder; + } + // Find the right table name + $source = $query->getSource(); + $this->initializeQueryBuilder($source); + + $constraint = $query->getConstraint(); + if ($constraint instanceof ConstraintInterface) { + $wherePredicates = $this->parseConstraint($constraint, $source); + if (!empty($wherePredicates)) { + $this->queryBuilder->andWhere($wherePredicates); + } + } + + $this->parseOrderings($query->getOrderings(), $source); + $this->addTypo3Constraints($query); + + $queryBuilder = $this->queryBuilder; + // Reset temporary properties + $this->tablePropertyMap = []; + $this->tableAliasMap = []; + $this->unionTableAliasCache = []; + $this->queryBuilder = null; + return $queryBuilder; + } + + /** + * Creates the queryBuilder object whether it is a regular select or a JOIN + */ + protected function initializeQueryBuilder(SourceInterface $source): void + { + if ($source instanceof SelectorInterface) { + $className = $source->getNodeTypeName(); + $tableName = $this->dataMapper->getDataMap($className)->tableName; + $this->queryBuilder = $this->connectionPool->getQueryBuilderForTable($tableName); + $this->queryBuilder->getRestrictions()->removeAll(); + $tableAlias = $this->getUniqueAlias($tableName); + $this->queryBuilder + ->select($tableAlias . '.*') + ->from($tableName, $tableAlias); + $this->addRecordTypeConstraint($className); + } elseif ($source instanceof JoinInterface) { + $leftSource = $source->getLeft(); + $leftTableName = $leftSource->getSelectorName(); + $this->queryBuilder = $this->connectionPool->getQueryBuilderForTable($leftTableName); + $leftTableAlias = $this->getUniqueAlias($leftTableName); + $this->queryBuilder + ->select($leftTableAlias . '.*') + ->from($leftTableName, $leftTableAlias); + $this->parseJoin($source, $leftTableAlias); + } + } + + /** + * Transforms a constraint into SQL and parameter arrays + */ + protected function parseConstraint(ConstraintInterface $constraint, SourceInterface $source): CompositeExpression|string + { + if ($constraint instanceof AndInterface) { + return $this->queryBuilder->expr()->and( + $this->parseConstraint($constraint->getConstraint1(), $source), + $this->parseConstraint($constraint->getConstraint2(), $source) + ); + } + if ($constraint instanceof OrInterface) { + return $this->queryBuilder->expr()->or( + $this->parseConstraint($constraint->getConstraint1(), $source), + $this->parseConstraint($constraint->getConstraint2(), $source) + ); + } + if ($constraint instanceof NotInterface) { + return ' NOT(' . $this->parseConstraint($constraint->getConstraint(), $source) . ')'; + } + if ($constraint instanceof ComparisonInterface) { + return $this->parseComparison($constraint, $source); + } + throw new \RuntimeException('not implemented', 1476199898); + } + + /** + * Transforms orderings into SQL. + * + * @param array $orderings An array of orderings (Qom\Ordering or legacy propertyName => direction) + * @throws UnsupportedOrderException + */ + protected function parseOrderings(array $orderings, SourceInterface $source): void + { + foreach ($orderings as $propertyName => $order) { + // New API: OrderingInterface objects + if ($order instanceof OrderingInterface) { + // parseOperand() already returns a fully quoted identifier or SQL expression + // (e.g. CONCAT("table"."column", …)), so it must be added to the underlying + // concrete query builder directly to avoid quoteIdentifier() being applied twice. + $sql = $this->parseOperand($order->getOperand(), $source); + $this->queryBuilder->getConcreteQueryBuilder()->addOrderBy($sql, $order->getOrder()); + continue; + } + + // Legacy API: propertyName => direction + if ($order !== QueryInterface::ORDER_ASCENDING && $order !== QueryInterface::ORDER_DESCENDING) { + throw new UnsupportedOrderException('Unsupported order encountered.', 1242816074); + } + $className = null; + $tableName = ''; + if ($source instanceof SelectorInterface) { + $className = $source->getNodeTypeName(); + $tableName = $this->dataMapper->convertClassNameToTableName($className); + $fullPropertyPath = ''; + while (str_contains($propertyName, '.')) { + $this->addUnionStatement($className, $tableName, $propertyName, $fullPropertyPath); + } + } elseif ($source instanceof JoinInterface) { + $tableName = $source->getLeft()->getSelectorName(); + } + $columnName = $this->dataMapper->convertPropertyNameToColumnName($propertyName, $className); + if ($tableName !== '') { + $this->queryBuilder->addOrderBy($tableName . '.' . $columnName, $order); + } else { + $this->queryBuilder->addOrderBy($columnName, $order); + } + } + } + + /** + * add TYPO3 Constraints for all tables to the queryBuilder + */ + protected function addTypo3Constraints(QueryInterface $query): void + { + $index = 0; + foreach ($this->tableAliasMap as $tableAlias => $tableName) { + if ($index === 0) { + // We only add the pid and language check for the first table (aggregate root). + // We know the first table is always the main table for the current query run. + $additionalWhereClauses = $this->getAdditionalWhereClause($query->getQuerySettings(), $tableName, $tableAlias); + } else { + $additionalWhereClauses = []; + } + $index++; + $statement = $this->getVisibilityConstraintStatement($query->getQuerySettings(), $tableName, $tableAlias); + if ($statement !== '') { + $additionalWhereClauses[] = $statement; + } + if (!empty($additionalWhereClauses)) { + if (in_array($tableAlias, $this->unionTableAliasCache, true)) { + $this->queryBuilder->andWhere( + $this->queryBuilder->expr()->or( + $this->queryBuilder->expr()->and(...$additionalWhereClauses), + $this->queryBuilder->expr()->isNull($tableAlias . '.uid') + ) + ); + } else { + $this->queryBuilder->andWhere(...$additionalWhereClauses); + } + } + } + } + + /** + * Parse a Comparison into SQL and parameter arrays. + * + * @throws RepositoryException + * @throws BadConstraintException + */ + protected function parseComparison(ComparisonInterface $comparison, SourceInterface $source): string + { + if ($comparison->getOperator() === QueryInterface::OPERATOR_CONTAINS) { + if ($comparison->getOperand2() === null) { + throw new BadConstraintException('The value for the CONTAINS operator must not be null.', 1484828468); + } + $value = $this->dataMapper->getPlainValue($comparison->getOperand2()); + if (!$source instanceof SelectorInterface) { + throw new \RuntimeException('Source is not of type "SelectorInterface"', 1395362539); + } + $className = $source->getNodeTypeName(); + $tableName = $this->dataMapper->convertClassNameToTableName($className); + $operand1 = $comparison->getOperand1(); + $propertyName = $operand1->getPropertyName(); + $fullPropertyPath = ''; + while (str_contains($propertyName, '.')) { + $this->addUnionStatement($className, $tableName, $propertyName, $fullPropertyPath); + } + $columnName = $this->dataMapper->convertPropertyNameToColumnName($propertyName, $className); + $dataMap = $this->dataMapper->getDataMap($className); + $columnMap = $dataMap->getColumnMap($propertyName); + $typeOfRelation = $columnMap->typeOfRelation ?? null; + if ($typeOfRelation === Relation::HAS_AND_BELONGS_TO_MANY) { + /** @var ColumnMap $columnMap */ + $relationTableName = (string)$columnMap->relationTableName; + $queryBuilderForSubselect = $this->queryBuilder->getConnection()->createQueryBuilder(); + $queryBuilderForSubselect->getRestrictions()->removeAll(); + $queryBuilderForSubselect + ->select($columnMap->parentKeyFieldName) + ->from($relationTableName) + ->where( + $queryBuilderForSubselect->expr()->eq( + $columnMap->childKeyFieldName, + $this->queryBuilder->createNamedParameter($value) + ) + ); + $additionalWhereForMatchFields = $this->getAdditionalMatchFieldsStatement($queryBuilderForSubselect->expr(), $columnMap, $relationTableName, $relationTableName); + if ($additionalWhereForMatchFields) { + $queryBuilderForSubselect->andWhere($additionalWhereForMatchFields); + } + + return $this->queryBuilder->expr()->comparison( + $this->queryBuilder->quoteIdentifier($tableName . '.uid'), + 'IN', + '(' . $queryBuilderForSubselect->getSQL() . ')' + ); + } + if ($typeOfRelation === Relation::HAS_MANY) { + if (isset($columnMap->parentKeyFieldName)) { + $childTableName = $columnMap->childTableName; + // Build the SQL statement of the subselect + $queryBuilderForSubselect = $this->queryBuilder->getConnection()->createQueryBuilder(); + $queryBuilderForSubselect->getRestrictions()->removeAll(); + $queryBuilderForSubselect + ->select($columnMap->parentKeyFieldName) + ->from($childTableName) + ->where( + $queryBuilderForSubselect->expr()->eq( + 'uid', + (int)$value + ) + ); + // Add it to the main query + return $this->queryBuilder->expr()->eq( + $tableName . '.uid', + '(' . $queryBuilderForSubselect->getSQL() . ')' + ); + } + return $this->queryBuilder->expr()->inSet( + $tableName . '.' . $columnName, + $this->queryBuilder->quote((string)$value) + ); + } + throw new RepositoryException('Unsupported or non-existing property name "' . $propertyName . '" used in relation matching.', 1327065745); + } + return $this->parseDynamicOperand($comparison, $source); + } + + /** + * Parse a DynamicOperand into SQL and parameter arrays. + * + * @throws Exception + * @throws BadConstraintException + */ + protected function parseDynamicOperand(ComparisonInterface $comparison, SourceInterface $source): string + { + $value = $comparison->getOperand2(); + // columnMap is filled by parseOperand + $columnMap = null; + $fieldName = $this->parseOperand($comparison->getOperand1(), $source, $columnMap); + $exprBuilder = $this->queryBuilder->expr(); + switch ($comparison->getOperator()) { + case QueryInterface::OPERATOR_IN: + $hasValue = false; + $plainValues = []; + foreach ($value as $singleValue) { + $plainValue = $this->dataMapper->getPlainValue($singleValue, $columnMap); + if ($plainValue !== null) { + $hasValue = true; + $plainValues[] = $this->createTypedNamedParameter($singleValue, null, $columnMap); + } + } + if (!$hasValue) { + throw new BadConstraintException( + 'The IN operator needs a non-empty value list to compare against. ' + . 'The given value list is empty.', + 1484828466 + ); + } + $expr = $exprBuilder->comparison($fieldName, 'IN', '(' . implode(', ', $plainValues) . ')'); + break; + case QueryInterface::OPERATOR_EQUAL_TO: + if ($value === null) { + $expr = $fieldName . ' IS NULL'; + } else { + $placeHolder = $this->createTypedNamedParameter($value, null, $columnMap); + $expr = $exprBuilder->comparison($fieldName, $exprBuilder::EQ, $placeHolder); + } + break; + case QueryInterface::OPERATOR_EQUAL_TO_NULL: + $expr = $fieldName . ' IS NULL'; + break; + case QueryInterface::OPERATOR_NOT_EQUAL_TO: + if ($value === null) { + $expr = $fieldName . ' IS NOT NULL'; + } else { + $placeHolder = $this->createTypedNamedParameter($value, null, $columnMap); + $expr = $exprBuilder->comparison($fieldName, $exprBuilder::NEQ, $placeHolder); + } + break; + case QueryInterface::OPERATOR_NOT_EQUAL_TO_NULL: + $expr = $fieldName . ' IS NOT NULL'; + break; + case QueryInterface::OPERATOR_LESS_THAN: + $placeHolder = $this->createTypedNamedParameter($value, null, $columnMap); + $expr = $exprBuilder->comparison($fieldName, $exprBuilder::LT, $placeHolder); + break; + case QueryInterface::OPERATOR_LESS_THAN_OR_EQUAL_TO: + $placeHolder = $this->createTypedNamedParameter($value, null, $columnMap); + $expr = $exprBuilder->comparison($fieldName, $exprBuilder::LTE, $placeHolder); + break; + case QueryInterface::OPERATOR_GREATER_THAN: + $placeHolder = $this->createTypedNamedParameter($value, null, $columnMap); + $expr = $exprBuilder->comparison($fieldName, $exprBuilder::GT, $placeHolder); + break; + case QueryInterface::OPERATOR_GREATER_THAN_OR_EQUAL_TO: + $placeHolder = $this->createTypedNamedParameter($value, null, $columnMap); + $expr = $exprBuilder->comparison($fieldName, $exprBuilder::GTE, $placeHolder); + break; + case QueryInterface::OPERATOR_LIKE: + $placeHolder = $this->createTypedNamedParameter($value, Connection::PARAM_STR, $columnMap); + if ($this->queryBuilder->getConnection()->getDatabasePlatform() instanceof PostgreSQLPlatform) { + $expr = $exprBuilder->comparison($fieldName, 'ILIKE', $placeHolder); + } else { + $expr = $exprBuilder->comparison($fieldName, 'LIKE', $placeHolder); + } + break; + default: + throw new Exception( + 'Unsupported operator encountered.', + 1242816073 + ); + } + return $expr; + } + + /** + * Maps plain value of operand to PDO types to help Doctrine and/or the database driver process the value + * correctly when building the query. + */ + protected function getParameterType(mixed $value): ParameterType + { + $parameterType = gettype($value); + return match ($parameterType) { + 'integer' => Connection::PARAM_INT, + 'string' => Connection::PARAM_STR, + default => throw new \InvalidArgumentException( + 'Unsupported parameter type encountered. Expected integer or string, ' . $parameterType . ' given.', + 1494878863 + ), + }; + } + + /** + * Create a named parameter for the QueryBuilder and guess the parameter type based on the + * output of DataMapper::getPlainValue(). The type of the named parameter can be forced to + * one of the \PDO::PARAM_* types by specifying the $forceType argument. + * + * @param mixed $value The input value that should be sent to the database + * @param ParameterType|Type|ArrayParameterType|null $forceType The \TYPO3\CMS\Core\Database\Connection::PARAM_* type that should be forced + * @return string The placeholder string to be used in the query + * @see \TYPO3\CMS\Extbase\Persistence\Generic\Mapper\DataMapper::getPlainValue() + */ + protected function createTypedNamedParameter( + mixed $value, + ParameterType|Type|ArrayParameterType|null $forceType = null, + ?ColumnMap $columnMap = null, + ): string { + if ($value instanceof DomainObjectInterface && $value->_getProperty(AbstractDomainObject::PROPERTY_LOCALIZED_UID) > 0) { + $plainValue = (int)$value->_getProperty(AbstractDomainObject::PROPERTY_LOCALIZED_UID); + } else { + $plainValue = $this->dataMapper->getPlainValue($value, $columnMap); + } + $parameterType = $forceType ?? $this->getParameterType($plainValue); + return $this->queryBuilder->createNamedParameter($plainValue, $parameterType); + } + + protected function parseOperand( + DynamicOperandInterface $operand, + SourceInterface $source, + ?ColumnMap &$columnMapOut = null + ): string { + $tableName = null; + if ($operand instanceof LowerCaseInterface) { + $constraintSQL = 'LOWER(' . $this->parseOperand($operand->getOperand(), $source, $columnMapOut) . ')'; + } elseif ($operand instanceof UpperCaseInterface) { + $constraintSQL = 'UPPER(' . $this->parseOperand($operand->getOperand(), $source, $columnMapOut) . ')'; + } elseif ($operand instanceof FunctionExpressionInterface) { + $constraintSQL = $this->parseFunctionExpression($operand, $source); + } elseif ($operand instanceof PropertyValueInterface) { + $propertyName = $operand->getPropertyName(); + $className = ''; + if ($source instanceof SelectorInterface) { + $className = $source->getNodeTypeName(); + $tableName = $this->dataMapper->convertClassNameToTableName($className); + $fullPropertyPath = ''; + while (str_contains($propertyName, '.')) { + $this->addUnionStatement($className, $tableName, $propertyName, $fullPropertyPath); + } + } elseif ($source instanceof JoinInterface) { + $tableName = $source->getJoinCondition()->getSelector1Name(); + } + if ($className) { + $columnMapOut = $this->dataMapper->getDataMap($className)->getColumnMap($propertyName); + } + $columnName = $this->dataMapper->convertPropertyNameToColumnName($propertyName, $className); + $constraintSQL = (!empty($tableName) ? $tableName . '.' : '') . $columnName; + $constraintSQL = $this->queryBuilder->getConnection()->quoteIdentifier($constraintSQL); + } else { + throw new \InvalidArgumentException('Given operand has invalid type "' . get_class($operand) . '".', 1395710211); + } + return $constraintSQL; + } + + /** + * Parses a function expression (CONCAT, TRIM, COALESCE) into SQL. + */ + protected function parseFunctionExpression(FunctionExpressionInterface $expression, SourceInterface $source): string + { + $functionName = $expression->getFunctionName(); + $operands = $expression->getOperands(); + $parsedOperands = []; + + foreach ($operands as $operand) { + if ($operand instanceof DynamicOperandInterface) { + $parsedOperands[] = $this->parseOperand($operand, $source); + } elseif (is_string($operand)) { + // Literal string value - use named parameter to prevent SQL injection + $parsedOperands[] = $this->queryBuilder->createNamedParameter($operand); + } + } + + return $functionName . '(' . implode(', ', $parsedOperands) . ')'; + } + + /** + * Add a constraint to ensure that the record type of the returned tuples is matching the data type of the repository. + * + * @param string|null $className The class name + */ + protected function addRecordTypeConstraint(?string $className): void + { + if ($className !== null) { + $dataMap = $this->dataMapper->getDataMap($className); + if ($dataMap->recordTypeColumnName !== null) { + $recordTypes = []; + if ($dataMap->recordType !== null) { + $recordTypes[] = $dataMap->recordType; + } + foreach ($dataMap->subclasses as $subclassName) { + $subclassDataMap = $this->dataMapper->getDataMap($subclassName); + if ($subclassDataMap->recordType !== null) { + $recordTypes[] = $subclassDataMap->recordType; + } + } + if (!empty($recordTypes)) { + $recordTypeStatements = []; + foreach ($recordTypes as $recordType) { + $recordTypeStatements[] = $this->queryBuilder->expr()->eq( + $dataMap->tableName . '.' . $dataMap->recordTypeColumnName, + $this->queryBuilder->createNamedParameter($recordType) + ); + } + $this->queryBuilder->andWhere( + $this->queryBuilder->expr()->or(...$recordTypeStatements) + ); + } + } + } + } + + /** + * Builds a condition for filtering records by the configured match field, + * e.g. MM_match_fields, foreign_match_fields or foreign_table_field. + * + * @param ExpressionBuilder $exprBuilder + * @param ColumnMap $columnMap The column man for which the condition should be build. + * @param string $childTableAlias The alias of the child record table used in the query. + * @param string $parentTable The real name of the parent table (used for building the foreign_table_field condition). + * @return CompositeExpression|string The match field conditions or an empty string. + */ + protected function getAdditionalMatchFieldsStatement($exprBuilder, $columnMap, $childTableAlias, $parentTable = null) + { + $additionalWhereForMatchFields = []; + foreach ($columnMap->relationTableMatchFields as $fieldName => $value) { + $additionalWhereForMatchFields[] = $exprBuilder->eq( + $childTableAlias . '.' . $fieldName, + $this->queryBuilder->createNamedParameter($value) + ); + } + if (isset($parentTable)) { + if (!empty($columnMap->parentTableFieldName)) { + $additionalWhereForMatchFields[] = $exprBuilder->eq( + $childTableAlias . '.' . $columnMap->parentTableFieldName, + $this->queryBuilder->createNamedParameter($parentTable) + ); + } + } + if (!empty($additionalWhereForMatchFields)) { + return $exprBuilder->and(...$additionalWhereForMatchFields); + } + return ''; + } + + /** + * Adds additional WHERE statements according to the query settings. + * + * @param QuerySettingsInterface $querySettings The TYPO3 CMS specific query settings + * @param string $tableName The table name to add the additional where clause for + * @param string $tableAlias The table alias used in the query. + */ + protected function getAdditionalWhereClause(QuerySettingsInterface $querySettings, string $tableName, string $tableAlias): array + { + $whereClause = []; + if ($querySettings->getRespectSysLanguage()) { + $systemLanguageStatement = $this->getLanguageStatement($tableName, $tableAlias, $querySettings); + if (!empty($systemLanguageStatement)) { + $whereClause[] = $systemLanguageStatement; + } + } + + if ($querySettings->getRespectStoragePage()) { + $pageIdStatement = $this->getPageIdStatement($tableName, $tableAlias, $querySettings->getStoragePageIds()); + if (!empty($pageIdStatement)) { + $whereClause[] = $pageIdStatement; + } + } + if ($this->tcaSchemaFactory->has($tableName) && $this->tcaSchemaFactory->get($tableName)->isWorkspaceAware()) { + // Always prevent workspace records from being returned (except for newly created records) + $whereClause[] = $this->queryBuilder->expr()->eq($tableAlias . '.t3ver_oid', 0); + } + + return $whereClause; + } + + /** + * Adds enableFields and deletedClause to the query if necessary + */ + protected function getVisibilityConstraintStatement(QuerySettingsInterface $querySettings, string $tableName, string $tableAlias): string + { + if (!$this->tcaSchemaFactory->has($tableName)) { + return ''; + } + + $ignoreEnableFields = $querySettings->getIgnoreEnableFields(); + $enableFieldsToBeIgnored = $querySettings->getEnableFieldsToBeIgnored(); + $includeDeleted = $querySettings->getIncludeDeleted(); + if (($GLOBALS['TYPO3_REQUEST'] ?? null) instanceof ServerRequestInterface + && ApplicationType::fromRequest($GLOBALS['TYPO3_REQUEST'])->isFrontend() + ) { + $statement = $this->getFrontendConstraintStatement($tableName, $tableAlias, $ignoreEnableFields, $enableFieldsToBeIgnored, $includeDeleted); + } else { + // applicationType backend + $statement = $this->getBackendConstraintStatement($tableName, $ignoreEnableFields, $includeDeleted); + if (!empty($statement)) { + $statement = $this->replaceTableNameWithAlias($statement, $tableName, $tableAlias); + $statement = strtolower(substr($statement, 1, 3)) === 'and' ? substr($statement, 5) : $statement; + } + } + return $statement; + } + + /** + * Returns constraint statement for frontend context + * + * @param bool $ignoreEnableFields A flag indicating whether the enable fields should be ignored + * @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. + * @param bool $includeDeleted A flag indicating whether deleted records should be included + * @throws InconsistentQuerySettingsException + */ + protected function getFrontendConstraintStatement(string $tableName, string $tableAlias, bool $ignoreEnableFields, array $enableFieldsToBeIgnored, bool $includeDeleted): string + { + $statement = ''; + if ($ignoreEnableFields && !$includeDeleted) { + if (!empty($enableFieldsToBeIgnored)) { + $constraints = $this->pageRepository->getDefaultConstraints($tableName, $enableFieldsToBeIgnored, $tableAlias); + if ($constraints !== []) { + $statement = implode(' AND ', $constraints); + } + } else { + $schema = $this->tcaSchemaFactory->has($tableName) ? $this->tcaSchemaFactory->get($tableName) : null; + if ($schema?->hasCapability(TcaSchemaCapability::SoftDelete)) { + $deleteField = $schema->getCapability(TcaSchemaCapability::SoftDelete)->getFieldName(); + $statement = $tableAlias . '.' . $deleteField . '=0'; + } + } + } elseif (!$ignoreEnableFields && !$includeDeleted) { + $constraints = $this->pageRepository->getDefaultConstraints($tableName, [], $tableAlias); + if ($constraints !== []) { + $statement = implode(' AND ', $constraints); + } + } elseif (!$ignoreEnableFields) { + throw new InconsistentQuerySettingsException('Query setting "ignoreEnableFields=FALSE" can not be used together with "includeDeleted=TRUE" in frontend context.', 1460975922); + } + return $statement; + } + + /** + * Returns constraint statement for backend context + * + * @param string $tableName + * @param bool $ignoreEnableFields A flag indicating whether the enable fields should be ignored + * @param bool $includeDeleted A flag indicating whether deleted records should be included + * @return string + */ + protected function getBackendConstraintStatement(string $tableName, bool $ignoreEnableFields, bool $includeDeleted): string + { + $statement = ''; + // In case of versioning-preview, enableFields are ignored (checked in Typo3DbBackend::doLanguageAndWorkspaceOverlay) + $isUserInWorkspace = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('workspace', 'isOffline'); + if (!$ignoreEnableFields && !$isUserInWorkspace) { + $statement .= BackendUtility::BEenableFields($tableName); + } + $schema = $this->tcaSchemaFactory->has($tableName) ? $this->tcaSchemaFactory->get($tableName) : null; + if (!$includeDeleted && $schema?->hasCapability(TcaSchemaCapability::SoftDelete)) { + $deleteField = $schema->getCapability(TcaSchemaCapability::SoftDelete)->getFieldName(); + $statement .= ' AND ' . $tableName . '.' . $deleteField . '=0'; + } + return $statement; + } + + /** + * Builds the language field statement + * + * @param string $tableName The database table name + * @param string $tableAlias The table alias used in the query. + * @param QuerySettingsInterface $querySettings The TYPO3 CMS specific query settings + * @return CompositeExpression|string + */ + protected function getLanguageStatement(string $tableName, string $tableAlias, QuerySettingsInterface $querySettings) + { + if (!$this->tcaSchemaFactory->has($tableName)) { + return ''; + } + $schema = $this->tcaSchemaFactory->get($tableName); + if (!$schema->isLanguageAware()) { + return ''; + } + $languageCapability = $schema->getCapability(TcaSchemaCapability::Language); + + // Select all entries for the current language + // If any language is set -> get those entries which are not translated yet + // They will be removed by \TYPO3\CMS\Core\Domain\Repository\PageRepository::getRecordOverlay if not matching overlay mode + $languageField = $languageCapability->getLanguageField()->getName(); + $transOrigPointerField = $languageCapability->getTranslationOriginPointerField()->getName(); + + $languageAspect = $querySettings->getLanguageAspect(); + if (!$languageAspect->getContentId()) { + return $this->queryBuilder->expr()->in( + $tableAlias . '.' . $languageField, + [$languageAspect->getContentId(), -1] + ); + } + + if (!$languageAspect->doOverlays()) { + return $this->queryBuilder->expr()->in( + $tableAlias . '.' . $languageField, + [$languageAspect->getContentId(), -1] + ); + } + + $defLangTableAlias = $tableAlias . '_dl'; + $defaultLanguageRecordsSubSelect = $this->queryBuilder->getConnection()->createQueryBuilder(); + $defaultLanguageRecordsSubSelect->getRestrictions()->removeAll(); + $defaultLanguageRecordsSubSelect + ->select($defLangTableAlias . '.uid') + ->from($tableName, $defLangTableAlias) + ->where( + $defaultLanguageRecordsSubSelect->expr()->eq($defLangTableAlias . '.' . $transOrigPointerField, 0), + $defaultLanguageRecordsSubSelect->expr()->eq($defLangTableAlias . '.' . $languageField, 0), + $this->getVisibilityConstraintStatement($querySettings, $tableName, $defLangTableAlias) + ); + + $andConditions = []; + // records in language 'all' + $andConditions[] = $this->queryBuilder->expr()->eq($tableAlias . '.' . $languageField, -1); + // translated records where a default language exists + $andConditions[] = $this->queryBuilder->expr()->and( + $this->queryBuilder->expr()->eq($tableAlias . '.' . $languageField, $languageAspect->getContentId()), + $this->queryBuilder->expr()->in( + $tableAlias . '.' . $transOrigPointerField, + $defaultLanguageRecordsSubSelect->getSQL() + ) + ); + // Records in translation with no default language + if ($languageAspect->getOverlayType() === LanguageAspect::OVERLAYS_ON_WITH_FLOATING) { + $andConditions[] = $this->queryBuilder->expr()->and( + $this->queryBuilder->expr()->eq($tableAlias . '.' . $languageField, $languageAspect->getContentId()), + $this->queryBuilder->expr()->eq($tableAlias . '.' . $transOrigPointerField, 0), + $this->queryBuilder->expr()->notIn( + $tableAlias . '.' . $transOrigPointerField, + $defaultLanguageRecordsSubSelect->getSQL() + ) + ); + } + if ($languageAspect->getOverlayType() === LanguageAspect::OVERLAYS_MIXED) { + // returns records from current language which have a default language + // together with not translated default language records + $translatedOnlyTableAlias = $tableAlias . '_to'; + $queryBuilderForSubselect = $this->queryBuilder->getConnection()->createQueryBuilder(); + $queryBuilderForSubselect->getRestrictions()->removeAll(); + $queryBuilderForSubselect + ->select($translatedOnlyTableAlias . '.' . $transOrigPointerField) + ->from($tableName, $translatedOnlyTableAlias) + ->where( + $queryBuilderForSubselect->expr()->gt($translatedOnlyTableAlias . '.' . $transOrigPointerField, 0), + $queryBuilderForSubselect->expr()->eq($translatedOnlyTableAlias . '.' . $languageField, $languageAspect->getContentId()), + // The records in default language should also respect the visibility constraints + $this->getVisibilityConstraintStatement($querySettings, $tableName, $translatedOnlyTableAlias) + ); + // records in default language, which do not have a translation + $andConditions[] = $this->queryBuilder->expr()->and( + $this->queryBuilder->expr()->eq($tableAlias . '.' . $languageField, 0), + $this->queryBuilder->expr()->notIn( + $tableAlias . '.uid', + $queryBuilderForSubselect->getSQL() + ) + ); + } + + return $this->queryBuilder->expr()->or(...$andConditions); + } + + /** + * Builds the page ID checking statement + * + * @param string $tableName The database table name + * @param string $tableAlias The table alias used in the query. + * @param array $storagePageIds list of storage page ids + * @throws InconsistentQuerySettingsException + */ + protected function getPageIdStatement(string $tableName, string $tableAlias, array $storagePageIds): string + { + if (!$this->tcaSchemaFactory->has($tableName)) { + return ''; + } + + /** @var RootLevelCapability $rootLevelCapability */ + $rootLevelCapability = $this->tcaSchemaFactory->get($tableName)->getCapability(TcaSchemaCapability::RestrictionRootLevel); + switch ($rootLevelCapability->getRootLevelType()) { + // Only in pid 0 + case RootLevelCapability::TYPE_ONLY_ON_ROOTLEVEL: + $storagePageIds = [0]; + break; + // Pid 0 and pagetree + case RootLevelCapability::TYPE_BOTH: + if ($storagePageIds === []) { + $storagePageIds = [0]; + } else { + $storagePageIds[] = 0; + } + break; + // Only pagetree or not set + case RootLevelCapability::TYPE_ONLY_ON_PAGES: + if (empty($storagePageIds)) { + throw new InconsistentQuerySettingsException('Missing storage page ids.', 1365779762); + } + break; + // Invalid configuration + default: + return ''; + } + $storagePageIds = array_map(intval(...), $storagePageIds); + if (count($storagePageIds) === 1) { + return $this->queryBuilder->expr()->eq($tableAlias . '.pid', reset($storagePageIds)); + } + return $this->queryBuilder->expr()->in($tableAlias . '.pid', $storagePageIds); + } + + /** + * Transforms a Join into SQL and parameter arrays + */ + protected function parseJoin(JoinInterface $join, string $leftTableAlias): void + { + $leftSource = $join->getLeft(); + $leftClassName = $leftSource->getNodeTypeName(); + $this->addRecordTypeConstraint($leftClassName); + $rightSource = $join->getRight(); + if ($rightSource instanceof JoinInterface) { + $left = $rightSource->getLeft(); + $rightClassName = $left->getNodeTypeName(); + $rightTableName = $left->getSelectorName(); + } else { + $rightClassName = $rightSource->getNodeTypeName(); + $rightTableName = $rightSource->getSelectorName(); + $this->queryBuilder->addSelect($rightTableName . '.*'); + } + $this->addRecordTypeConstraint($rightClassName); + $rightTableAlias = $this->getUniqueAlias($rightTableName); + $joinCondition = $join->getJoinCondition(); + $joinConditionExpression = null; + if ($joinCondition instanceof EquiJoinCondition) { + $column1Name = $this->dataMapper->convertPropertyNameToColumnName($joinCondition->getProperty1Name(), $leftClassName); + $column2Name = $this->dataMapper->convertPropertyNameToColumnName($joinCondition->getProperty2Name(), $rightClassName); + + $joinConditionExpression = $this->queryBuilder->expr()->eq( + $leftTableAlias . '.' . $column1Name, + $this->queryBuilder->quoteIdentifier($rightTableAlias . '.' . $column2Name) + ); + } + $this->queryBuilder->leftJoin($leftTableAlias, $rightTableName, $rightTableAlias, $joinConditionExpression); + if ($rightSource instanceof JoinInterface) { + $this->parseJoin($rightSource, $rightTableAlias); + } + } + + /** + * Generates a unique alias for the given table and the given property path. + * The property path will be mapped to the generated alias in the tablePropertyMap. + * + * @param string $tableName The name of the table for which the alias should be generated. + * @param string|null $fullPropertyPath The full property path that is related to the given table. + * @return string The generated table alias. + */ + protected function getUniqueAlias(string $tableName, ?string $fullPropertyPath = null): string + { + if (isset($fullPropertyPath) && isset($this->tablePropertyMap[$fullPropertyPath])) { + return $this->tablePropertyMap[$fullPropertyPath]; + } + $alias = $tableName; + $i = 0; + while (isset($this->tableAliasMap[$alias])) { + $alias = $tableName . $i; + $i++; + } + $this->tableAliasMap[$alias] = $tableName; + if (isset($fullPropertyPath)) { + $this->tablePropertyMap[$fullPropertyPath] = $alias; + } + return $alias; + } + + /** + * adds a union statement to the query, mostly for tables referenced in the where condition. + * The property for which the union statement is generated will be appended. + * + * @param string $className The name of the parent class, will be set to the child class after processing. + * @param string $tableName The name of the parent table, will be set to the table alias that is used in the union statement. + * @param string $propertyPath The remaining property path, will be cut of by one part during the process. + * @param string $fullPropertyPath The full path the current property, will be used to make table names unique. + * @throws Exception + * @throws InvalidRelationConfigurationException + * @throws MissingColumnMapException + */ + protected function addUnionStatement(&$className, &$tableName, &$propertyPath, &$fullPropertyPath) + { + $explodedPropertyPath = explode('.', $propertyPath, 2); + $propertyName = $explodedPropertyPath[0]; + $columnName = $this->dataMapper->convertPropertyNameToColumnName($propertyName, $className); + $realTableName = $this->dataMapper->convertClassNameToTableName($className); + $tableName = $this->tablePropertyMap[$fullPropertyPath] ?? $realTableName; + $columnMap = $this->dataMapper->getDataMap($className)->getColumnMap($propertyName); + + if ($columnMap === null) { + throw new MissingColumnMapException('The ColumnMap for property "' . $propertyName . '" of class "' . $className . '" is missing.', 1355142232); + } + + $parentKeyFieldName = $columnMap->parentKeyFieldName; + $childTableName = $columnMap->childTableName; + + if ($childTableName === null) { + throw new InvalidRelationConfigurationException('The relation information for property "' . $propertyName . '" of class "' . $className . '" is missing.', 1353170925); + } + + $fullPropertyPath .= ($fullPropertyPath === '') ? $propertyName : '.' . $propertyName; + $childTableAlias = $this->getUniqueAlias($childTableName, $fullPropertyPath); + + // If there is already a union with the current identifier we do not need to build it again and exit early. + if (in_array($childTableAlias, $this->unionTableAliasCache, true)) { + $propertyPath = $explodedPropertyPath[1]; + $tableName = $childTableAlias; + $className = $this->dataMapper->getType($className, $propertyName); + return; + } + + if ($columnMap->typeOfRelation === Relation::HAS_ONE) { + if (isset($parentKeyFieldName)) { + // @todo: no test for this part yet + $basicJoinCondition = $this->queryBuilder->expr()->eq( + $tableName . '.uid', + $this->queryBuilder->quoteIdentifier($childTableAlias . '.' . $parentKeyFieldName) + ); + } else { + $basicJoinCondition = $this->queryBuilder->expr()->eq( + $tableName . '.' . $columnName, + $this->queryBuilder->quoteIdentifier($childTableAlias . '.uid') + ); + } + $joinConditionExpression = $this->queryBuilder->expr()->and( + $basicJoinCondition, + $this->getAdditionalMatchFieldsStatement($this->queryBuilder->expr(), $columnMap, $childTableAlias, $realTableName) + ); + $this->queryBuilder->leftJoin($tableName, $childTableName, $childTableAlias, (string)$joinConditionExpression); + $this->unionTableAliasCache[] = $childTableAlias; + } elseif ($columnMap->typeOfRelation === Relation::HAS_MANY) { + if (isset($parentKeyFieldName)) { + $basicJoinCondition = $this->queryBuilder->expr()->eq( + $tableName . '.uid', + $this->queryBuilder->quoteIdentifier($childTableAlias . '.' . $parentKeyFieldName) + ); + } else { + $basicJoinCondition = $this->queryBuilder->expr()->inSet( + $tableName . '.' . $columnName, + $this->queryBuilder->quoteIdentifier($childTableAlias . '.uid'), + true + ); + } + $joinConditionExpression = $this->queryBuilder->expr()->and( + $basicJoinCondition, + $this->getAdditionalMatchFieldsStatement($this->queryBuilder->expr(), $columnMap, $childTableAlias, $realTableName) + ); + $this->queryBuilder->leftJoin($tableName, $childTableName, $childTableAlias, (string)$joinConditionExpression); + $this->unionTableAliasCache[] = $childTableAlias; + $this->suggestDistinctQuery = true; + } elseif ($columnMap->typeOfRelation === Relation::HAS_AND_BELONGS_TO_MANY) { + $relationTableName = (string)$columnMap->relationTableName; + $relationTableAlias = $this->getUniqueAlias($relationTableName, $fullPropertyPath . '_mm'); + + $joinConditionExpression = $this->queryBuilder->expr()->and( + $this->queryBuilder->expr()->eq( + $tableName . '.uid', + $this->queryBuilder->quoteIdentifier( + $relationTableAlias . '.' . $columnMap->parentKeyFieldName + ) + ), + $this->getAdditionalMatchFieldsStatement($this->queryBuilder->expr(), $columnMap, $relationTableAlias, $realTableName) + ); + $this->queryBuilder->leftJoin($tableName, $relationTableName, $relationTableAlias, (string)$joinConditionExpression); + $joinConditionExpression = $this->queryBuilder->expr()->eq( + $relationTableAlias . '.' . $columnMap->childKeyFieldName, + $this->queryBuilder->quoteIdentifier($childTableAlias . '.uid') + ); + $this->queryBuilder->leftJoin($relationTableAlias, $childTableName, $childTableAlias, $joinConditionExpression); + $this->unionTableAliasCache[] = $childTableAlias; + $this->suggestDistinctQuery = true; + } else { + throw new Exception('Could not determine type of relation.', 1252502725); + } + $propertyPath = $explodedPropertyPath[1]; + $tableName = $childTableAlias; + $className = $this->dataMapper->getType($className, $propertyName); + } + + /** + * If the table name does not match the table alias all occurrences of + * "tableName." are replaced with "tableAlias." in the given SQL statement. + * + * @param string $statement The SQL statement in which the values are replaced. + * @param string $tableName The table name that is replaced. + * @param string $tableAlias The table alias that replaced the table name. + * @return string The modified SQL statement. + */ + protected function replaceTableNameWithAlias($statement, $tableName, $tableAlias) + { + if ($tableAlias !== $tableName) { + $connection = $this->connectionPool->getConnectionForTable($tableName); + $quotedTableName = $connection->quoteIdentifier($tableName); + $quotedTableAlias = $connection->quoteIdentifier($tableAlias); + $statement = str_replace( + [$tableName . '.', $quotedTableName . '.'], + [$tableAlias . '.', $quotedTableAlias . '.'], + $statement + ); + } + return $statement; + } +} diff --git a/Classes/Persistence/Generic/Typo3QuerySettings.php b/Classes/Persistence/Generic/Typo3QuerySettings.php new file mode 100644 index 0000000..6a8e906 --- /dev/null +++ b/Classes/Persistence/Generic/Typo3QuerySettings.php @@ -0,0 +1,240 @@ +context = clone $context; + $this->configurationManager = $configurationManager; + $this->languageAspect = $this->context->getAspect('language'); + // see note in class' phpdoc about this condition + if (($GLOBALS['TYPO3_REQUEST'] ?? null) instanceof ServerRequestInterface + && ApplicationType::fromRequest($GLOBALS['TYPO3_REQUEST'])->isBackend() + ) { + $this->setIgnoreEnableFields(true); + } + } + + /** + * Sets the flag if the storage page should be respected for the query. + * + * @param bool $respectStoragePage If TRUE the storage page ID will be determined and the statement will be extended accordingly. + */ + public function setRespectStoragePage(bool $respectStoragePage): QuerySettingsInterface + { + $this->respectStoragePage = $respectStoragePage; + return $this; + } + + /** + * Returns the state, if the storage page should be respected for the query. + * + * @return bool TRUE, if the storage page should be respected; otherwise FALSE. + */ + public function getRespectStoragePage(): bool + { + return $this->respectStoragePage; + } + + /** + * Sets the pid(s) of the storage page(s) that should be respected for the query. + * + * @param array $storagePageIds If given the storage page IDs will be determined and the statement will be extended accordingly. + */ + public function setStoragePageIds(array $storagePageIds): self + { + $this->storagePageIds = $storagePageIds; + return $this; + } + + /** + * Returns the pid(s) of the storage page(s) that should be respected for the query. + * + * @return array list of integers that each represent a storage page id + */ + public function getStoragePageIds(): array + { + return $this->storagePageIds; + } + + /** + * @param bool $respectSysLanguage TRUE if TYPO3 language settings are to be applied + */ + public function setRespectSysLanguage(bool $respectSysLanguage): self + { + $this->respectSysLanguage = $respectSysLanguage; + return $this; + } + + /** + * @return bool TRUE if TYPO3 language settings are to be applied + */ + public function getRespectSysLanguage(): bool + { + return $this->respectSysLanguage; + } + + public function getLanguageAspect(): LanguageAspect + { + return $this->languageAspect; + } + + public function setLanguageAspect(LanguageAspect $languageAspect): self + { + $this->languageAspect = $languageAspect; + return $this; + } + + /** + * Sets a flag indicating whether all or some enable fields should be ignored. If TRUE, all enable fields are ignored. + * If--in addition to this--enableFieldsToBeIgnored is set, only fields specified there are ignored. If FALSE, all + * enable fields are taken into account, regardless of the enableFieldsToBeIgnored setting. + * + * @see setEnableFieldsToBeIgnored() + */ + public function setIgnoreEnableFields(bool $ignoreEnableFields): self + { + $this->ignoreEnableFields = $ignoreEnableFields; + return $this; + } + + /** + * The returned value indicates whether all or some enable fields should be ignored. + * + * If TRUE, all enable fields are ignored. If--in addition to this--enableFieldsToBeIgnored is set, only fields specified there are ignored. + * If FALSE, all enable fields are taken into account, regardless of the enableFieldsToBeIgnored setting. + * + * @see getEnableFieldsToBeIgnored() + */ + public function getIgnoreEnableFields(): bool + { + return $this->ignoreEnableFields; + } + + /** + * An array of column names in the enable columns array (array keys in $GLOBALS['TCA'][$table]['ctrl']['enablecolumns']), + * to be ignored while building the query statement. Adding a column name here effectively switches off filtering + * by this column. This setting is only taken into account if $this->ignoreEnableFields = TRUE. + * + * @see setIgnoreEnableFields() + */ + public function setEnableFieldsToBeIgnored(array $enableFieldsToBeIgnored): self + { + $this->enableFieldsToBeIgnored = $enableFieldsToBeIgnored; + return $this; + } + + /** + * An array of column names in the enable columns array (array keys in $GLOBALS['TCA'][$table]['ctrl']['enablecolumns']), + * to be ignored while building the query statement. + * + * @see getIgnoreEnableFields() + */ + public function getEnableFieldsToBeIgnored(): array + { + return $this->enableFieldsToBeIgnored; + } + + /** + * Sets the flag if the query should return objects that are deleted. + */ + public function setIncludeDeleted(bool $includeDeleted): self + { + $this->includeDeleted = $includeDeleted; + return $this; + } + + /** + * Returns if the query should return objects that are deleted. + */ + public function getIncludeDeleted(): bool + { + return $this->includeDeleted; + } +} diff --git a/Classes/Persistence/ObjectMonitoringInterface.php b/Classes/Persistence/ObjectMonitoringInterface.php new file mode 100644 index 0000000..f822c0b --- /dev/null +++ b/Classes/Persistence/ObjectMonitoringInterface.php @@ -0,0 +1,42 @@ + + * @implements \Iterator + */ +class ObjectStorage implements \Countable, \Iterator, \ArrayAccess, ObjectMonitoringInterface +{ + /** + * This field is only needed to make debugging easier: + * + * If you call `current()` on a class that implements `Iterator`, PHP will return the first field of the object + * instead of calling the `current()` method of the interface. + * + * We use this unusual behavior of PHP to return the warning below in this case. + */ + private string $warning = 'You should never see this warning. If you do, you probably used PHP array functions like current() on the TYPO3\\CMS\\Extbase\\Persistence\\ObjectStorage. To retrieve the first result, you can use the rewind() and current() methods.'; + + /** + * An array holding the objects and the stored information. The key of the array items ist the + * spl_object_hash of the given object. + * + * ```php + * [ + * 'spl_object_hash' => [ + * 'obj' => $object, + * 'inf' => $information, + * ], + * ] + * ``` + */ + protected array $storage = []; + + /** + * A flag indication if the object storage was modified after reconstitution (e.g., by adding a new object) + */ + protected bool $isModified = false; + + /** + * An array holding the internal position the object was added. + * + * The object entry is unset when the object gets removed from the object storage. + */ + protected array $addedObjectsPositions = []; + + /** + * An array holding the internal position the object was added before, when it would + * be removed from the object storage. + */ + protected array $removedObjectsPositions = []; + + /** + * An internal variable holding the count of added objects to be stored as position. + * + * It will be reset when all objects are be removed from the object storage. + */ + protected int $positionCounter = 0; + + /** + * Rewinds the iterator to the first storage element. + */ + public function rewind(): void + { + reset($this->storage); + } + + /** + * Checks if the array pointer of the storage points to a valid position. + */ + public function valid(): bool + { + return current($this->storage) !== false; + } + + /** + * Returns the index at which the iterator currently is. + * + * This is different from `SplObjectStorage` as the key in this implementation is the object hash (string). + * + * @return string The index corresponding to the position of the iterator. + */ + public function key(): string + { + return key($this->storage); + } + + /** + * Returns the current storage entry. + * + * @return TEntity|null The object at the current iterator position. + */ + public function current(): ?object + { + $item = current($this->storage); + return $item['obj'] ?? null; + } + + /** + * Moves to the next entry. + */ + public function next(): void + { + next($this->storage); + } + + /** + * Returns the number of objects in the storage. + * + * @return 0|positive-int The number of objects in the storage. + */ + public function count(): int + { + return count($this->storage); + } + + /** + * Associates information to an object in the storage. `offsetSet()` is an alias of `attach()`. + * + * @param TEntity|string|null $object The object to add. + * @param mixed $information The information to associate with the object. + */ + public function offsetSet(mixed $object, mixed $information): void + { + $this->isModified = true; + $this->storage[spl_object_hash($object)] = ['obj' => $object, 'inf' => $information]; + + $this->positionCounter++; + $this->addedObjectsPositions[spl_object_hash($object)] = $this->positionCounter; + } + + /** + * Checks whether an object exists in the storage. + * + * @param TEntity|int|string $value The object to look for, or the key in the storage. + */ + public function offsetExists(mixed $value): bool + { + return (is_object($value) && isset($this->storage[spl_object_hash($value)])) + || (MathUtility::canBeInterpretedAsInteger($value) && isset(array_values($this->storage)[$value])); + } + + /** + * Removes an object from the storage. `offsetUnset()` is an alias of `detach()`. + * + * @param TEntity|int|string $value The object to remove, or its key in the storage. + */ + public function offsetUnset(mixed $value): void + { + $this->isModified = true; + + $object = $value; + + if (MathUtility::canBeInterpretedAsInteger($value)) { + $object = $this->offsetGet($value); + } + + unset($this->storage[spl_object_hash($object)]); + + if (empty($this->storage)) { + $this->positionCounter = 0; + } + + $this->removedObjectsPositions[spl_object_hash($object)] = $this->addedObjectsPositions[spl_object_hash($object)] ?? null; + unset($this->addedObjectsPositions[spl_object_hash($object)]); + } + + /** + * Returns the information associated with an object, or the object itself if an integer is passed. + * + * @param TEntity|int|string $value The object to look for, or its key in the storage. + * @return mixed The information associated with an object in the storage, or the object itself if an integer is passed. + */ + public function offsetGet(mixed $value): mixed + { + if (MathUtility::canBeInterpretedAsInteger($value)) { + return array_values($this->storage)[$value]['obj'] ?? null; + } + + /** @var DomainObjectInterface $value */ + return $this->storage[spl_object_hash($value)]['inf'] ?? null; + } + + /** + * Checks if the storage contains a specific object. + * + * @param TEntity $object The object to look for. + */ + public function contains(object $object): bool + { + return $this->offsetExists($object); + } + + /** + * Adds an object in the storage, and optionally associate it to some information. + * + * @param TEntity $object The object to add. + * @param mixed $information The information to associate with the object. + */ + public function attach(object $object, mixed $information = null): void + { + $this->offsetSet($object, $information); + } + + /** + * Removes an object from the storage. + * + * @param TEntity $object The object to remove. + */ + public function detach(object $object): void + { + $this->offsetUnset($object); + } + + /** + * Returns the information associated with the object pointed by the current iterator position. + * + * @return mixed The information associated with the current iterator position. + */ + public function getInfo(): mixed + { + $item = current($this->storage); + + return $item['inf'] ?? null; + } + + /** + * Associates information with the object currently pointed to by the iterator. + */ + public function setInfo(mixed $information): void + { + $this->isModified = true; + $key = key($this->storage); + $this->storage[$key]['inf'] = $information; + } + + /** + * Adds all object-information pairs from a different storage in the current storage. + * + * @param ObjectStorage $storage + */ + public function addAll(ObjectStorage $storage): void + { + foreach ($storage as $object) { + $this->attach($object, $storage->getInfo()); + } + } + + /** + * Removes objects contained in another storage from the current storage. + * + * @param ObjectStorage $storage The storage containing the elements to remove. + */ + public function removeAll(ObjectStorage $storage): void + { + foreach ($storage as $object) { + $this->detach($object); + } + } + + /** + * Returns this object storage as an array. + * + * @return list + */ + public function toArray(): array + { + $array = []; + $storage = array_values($this->storage); + foreach ($storage as $item) { + $array[] = $item['obj']; + } + return $array; + } + + /** + * Alias of `toArray` which allows that method to be used from contexts which support + * for example dotted paths, e.g., `ObjectAccess::getPropertyPath($object, 'children.array.123')` + * to get exactly the 123rd item in the `children` property which is an `ObjectStorage`. + * + * @return list + */ + public function getArray(): array + { + return $this->toArray(); + } + + /** + * Register the storage's clean state, e.g., after it has been reconstituted from the database. + * + * @param non-empty-string|null $propertyName + */ + public function _memorizeCleanState(?string $propertyName = null): void + { + $this->isModified = false; + } + + /** + * Returns `true` if the storage was modified after reconstitution. + * + * @param non-empty-string|null $propertyName + */ + public function _isDirty(?string $propertyName = null): bool + { + return $this->isModified; + } + + /** + * Returns `true` if an object was added, then removed and added at a different position. + */ + public function isRelationDirty(object $object): bool + { + return isset($this->addedObjectsPositions[spl_object_hash($object)]) + && isset($this->removedObjectsPositions[spl_object_hash($object)]) + && ($this->addedObjectsPositions[spl_object_hash($object)] !== $this->removedObjectsPositions[spl_object_hash($object)]); + } + + public function getPosition(object $object): ?int + { + if (!isset($this->addedObjectsPositions[spl_object_hash($object)])) { + return null; + } + + return $this->addedObjectsPositions[spl_object_hash($object)]; + } +} diff --git a/Classes/Persistence/PersistenceManagerInterface.php b/Classes/Persistence/PersistenceManagerInterface.php new file mode 100644 index 0000000..183a15c --- /dev/null +++ b/Classes/Persistence/PersistenceManagerInterface.php @@ -0,0 +1,118 @@ + $type + * @return QueryInterface + */ + public function createQueryForType(string $type): QueryInterface; +} diff --git a/Classes/Persistence/QueryInterface.php b/Classes/Persistence/QueryInterface.php new file mode 100644 index 0000000..f32c819 --- /dev/null +++ b/Classes/Persistence/QueryInterface.php @@ -0,0 +1,399 @@ +' comparison operator. + */ + public const OPERATOR_GREATER_THAN = 5; + + /** + * The '>=' comparison operator. + */ + public const OPERATOR_GREATER_THAN_OR_EQUAL_TO = 6; + + /** + * The 'like' comparison operator. + */ + public const OPERATOR_LIKE = 7; + + /** + * The 'contains' comparison operator for collections. + */ + public const OPERATOR_CONTAINS = 8; + + /** + * The 'in' comparison operator. + */ + public const OPERATOR_IN = 9; + + /** + * The 'is NULL' comparison operator. + */ + public const OPERATOR_IS_NULL = 10; + + /** + * The 'is empty' comparison operator for collections. + */ + public const OPERATOR_IS_EMPTY = 11; + + /** + * Constants representing the direction when ordering result sets. + */ + public const ORDER_ASCENDING = 'ASC'; + public const ORDER_DESCENDING = 'DESC'; + + /** + * Gets the node-tuple source for this query. + * + * @return SourceInterface + * @todo: Set SourceInterface as return type. + */ + public function getSource(); + + /** + * Executes the query and returns the result. + * + * @param bool $returnRawQueryResult avoids the object mapping by the persistence + * @return QueryResultInterface|list> The query result object or an array if $returnRawQueryResult is TRUE + * @phpstan-return ($returnRawQueryResult is true ? list> : QueryResultInterface) + */ + public function execute($returnRawQueryResult = false); + + /** + * Sets the property names to order the result by. Expected like this: + * array( + * 'foo' => \TYPO3\CMS\Extbase\Persistence\QueryInterface::ORDER_ASCENDING, + * 'bar' => \TYPO3\CMS\Extbase\Persistence\QueryInterface::ORDER_DESCENDING + * ) + * + * @param array $orderings The property names to order by + * @return \TYPO3\CMS\Extbase\Persistence\QueryInterface + * @phpstan-return QueryInterface + */ + public function setOrderings(array $orderings); + + /** + * Sets the ordering for the result by a single operand. Replaces any existing orderings. + * + * @param string|DynamicOperandInterface $operand The property name or a dynamic operand (e.g., concat(), trim()) + * @param string $order The order direction (QueryInterface::ORDER_ASCENDING or ORDER_DESCENDING) + * @return \TYPO3\CMS\Extbase\Persistence\QueryInterface + * @phpstan-return QueryInterface + */ + public function orderBy(string|DynamicOperandInterface $operand, string $order = self::ORDER_ASCENDING); + + /** + * Adds an ordering for the result. Appends to any existing orderings. + * + * @param string|DynamicOperandInterface $operand The property name or a dynamic operand (e.g., concat(), trim()) + * @param string $order The order direction (QueryInterface::ORDER_ASCENDING or ORDER_DESCENDING) + * @return \TYPO3\CMS\Extbase\Persistence\QueryInterface + * @phpstan-return QueryInterface + */ + public function addOrderBy(string|DynamicOperandInterface $operand, string $order = self::ORDER_ASCENDING); + + /** + * Creates a CONCAT expression for ordering. + * + * @param string|DynamicOperandInterface ...$operands Property names or operand objects to concatenate + */ + public function concat(string|DynamicOperandInterface ...$operands): ConcatInterface; + + /** + * Creates a TRIM expression for ordering. + * + * @param string|DynamicOperandInterface $operand The property name or operand to trim + */ + public function trim(string|DynamicOperandInterface $operand): TrimInterface; + + /** + * Creates a COALESCE expression for ordering. + * + * @param string|DynamicOperandInterface ...$operands Property names or operand objects + */ + public function coalesce(string|DynamicOperandInterface ...$operands): CoalesceInterface; + + /** + * Sets the maximum size of the result set to limit. Returns $this to allow + * for chaining (fluid interface). + * + * @param int $limit + * @return \TYPO3\CMS\Extbase\Persistence\QueryInterface + * @phpstan-return QueryInterface + */ + public function setLimit($limit); + + /** + * Sets the start offset of the result set to offset. Returns $this to + * allow for chaining (fluid interface). + * + * @param int $offset + * @return \TYPO3\CMS\Extbase\Persistence\QueryInterface + * @phpstan-return QueryInterface + */ + public function setOffset($offset); + + /** + * The constraint used to limit the result set. Returns $this to allow + * for chaining (fluid interface). + * + * @param ConstraintInterface $constraint Some constraint, depending on the backend + * @return \TYPO3\CMS\Extbase\Persistence\QueryInterface + * @phpstan-return QueryInterface + */ + public function matching($constraint); + + /** + * Performs a logical conjunction of multiple given constraints. The method + * takes an arbitrary number of constraints and concatenates them with a boolean AND. + */ + public function logicalAnd(ConstraintInterface ...$constraints): AndInterface; + + /** + * Performs a logical disjunction of multiple given constraints. The method + * takes an arbitrary number of constraints and concatenates them with a boolean OR. + */ + public function logicalOr(ConstraintInterface ...$constraints): OrInterface; + + /** + * Performs a logical negation of the given constraint + * + * @param ConstraintInterface $constraint Constraint to negate + * @return \TYPO3\CMS\Extbase\Persistence\Generic\Qom\NotInterface + */ + public function logicalNot(ConstraintInterface $constraint); + + /** + * Returns an equals criterion used for matching objects against a query. + * + * It matches if the $operand equals the value of the property named + * $propertyName. If $operand is NULL a strict check for NULL is done. For + * strings the comparison can be done with or without case-sensitivity. + * + * @param string $propertyName The name of the property to compare against + * @param mixed $operand The value to compare with + * @param bool $caseSensitive Whether the equality test should be done case-sensitive for strings + * @return \TYPO3\CMS\Extbase\Persistence\Generic\Qom\ComparisonInterface + */ + public function equals($propertyName, $operand, $caseSensitive = true); + + /** + * Returns a like criterion used for matching objects against a query. + * Matches if the property named $propertyName is like the $operand, using + * standard SQL wildcards. + * + * @param string $propertyName The name of the property to compare against + * @param string $operand The value to compare with + * @return ComparisonInterface + * @throws \TYPO3\CMS\Extbase\Persistence\Exception\InvalidQueryException if used on a non-string property + */ + public function like($propertyName, $operand); + + /** + * Returns a "contains" criterion used for matching objects against a query. + * It matches if the multivalued property contains the given operand. + * + * If NULL is given as $operand, there will never be a match! + * + * @param string $propertyName The name of the multivalued property to compare against + * @param mixed $operand The value to compare with + * @return ComparisonInterface + * @throws \TYPO3\CMS\Extbase\Persistence\Exception\InvalidQueryException if used on a single-valued property + */ + public function contains($propertyName, $operand); + + /** + * Returns an "in" criterion used for matching objects against a query. It + * matches if the property's value is contained in the multivalued operand. + * + * @param string $propertyName The name of the property to compare against + * @param mixed $operand The value to compare with, multivalued + * @return ComparisonInterface + * @throws \TYPO3\CMS\Extbase\Persistence\Exception\InvalidQueryException if used on a multi-valued property + */ + public function in($propertyName, $operand); + + /** + * Returns a less than criterion used for matching objects against a query + * + * @param string $propertyName The name of the property to compare against + * @param mixed $operand The value to compare with + * @return ComparisonInterface + * @throws \TYPO3\CMS\Extbase\Persistence\Exception\InvalidQueryException if used on a multi-valued property or with a non-literal/non-DateTime operand + */ + public function lessThan($propertyName, $operand); + + /** + * Returns a less or equal than criterion used for matching objects against a query + * + * @param string $propertyName The name of the property to compare against + * @param mixed $operand The value to compare with + * @return ComparisonInterface + * @throws \TYPO3\CMS\Extbase\Persistence\Exception\InvalidQueryException if used on a multi-valued property or with a non-literal/non-DateTime operand + */ + public function lessThanOrEqual($propertyName, $operand); + + /** + * Returns a greater than criterion used for matching objects against a query + * + * @param string $propertyName The name of the property to compare against + * @param mixed $operand The value to compare with + * @return ComparisonInterface + * @throws \TYPO3\CMS\Extbase\Persistence\Exception\InvalidQueryException if used on a multi-valued property or with a non-literal/non-DateTime operand + */ + public function greaterThan($propertyName, $operand); + + /** + * Returns a greater than or equal criterion used for matching objects against a query + * + * @param string $propertyName The name of the property to compare against + * @param mixed $operand The value to compare with + * @return ComparisonInterface + * @throws \TYPO3\CMS\Extbase\Persistence\Exception\InvalidQueryException if used on a multi-valued property or with a non-literal/non-DateTime operand + */ + public function greaterThanOrEqual($propertyName, $operand); + + /** + * Set the type this query cares for. + * @phpstan-param class-string $type + */ + public function setType(string $type): void; + + /** + * Returns the type this query cares for. + * + * @return string + * @phpstan-return class-string + */ + public function getType(); + + /** + * Sets the Query Settings. These Query settings must match the settings expected by + * the specific Storage Backend. + */ + public function setQuerySettings(QuerySettingsInterface $querySettings); + + /** + * Returns the Query Settings. + * + * @return QuerySettingsInterface $querySettings The Query Settings + */ + public function getQuerySettings(); + + /** + * Returns the query result count. + * + * @return int The query result count + */ + public function count(); + + /** + * Gets the orderings for this query. + * + * When using setOrderings(), returns legacy format: + * array( + * 'foo' => \TYPO3\CMS\Extbase\Persistence\QueryInterface::ORDER_ASCENDING, + * 'bar' => \TYPO3\CMS\Extbase\Persistence\QueryInterface::ORDER_DESCENDING + * ) + * + * When using orderBy()/addOrderBy(), returns OrderingInterface objects: + * array(\TYPO3\CMS\Extbase\Persistence\Generic\Qom\OrderingInterface, ...) + * + * @return array|array<\TYPO3\CMS\Extbase\Persistence\Generic\Qom\OrderingInterface> + */ + public function getOrderings(); + + /** + * Returns the maximum size of the result set to limit. + * + * @return int + */ + public function getLimit(); + + /** + * Returns the start offset of the result set. + * + * @return int + */ + public function getOffset(); + + /** + * Gets the constraint for this query. + * + * @return ConstraintInterface|null the constraint, or null if none + */ + public function getConstraint(); + + /** + * Sets the source to fetch the result from + */ + public function setSource(SourceInterface $source); + + /** + * Returns the statement of this query. + * + * @return \TYPO3\CMS\Extbase\Persistence\Generic\Qom\Statement + */ + public function getStatement(); +} diff --git a/Classes/Persistence/QueryResultInterface.php b/Classes/Persistence/QueryResultInterface.php new file mode 100644 index 0000000..b3569d5 --- /dev/null +++ b/Classes/Persistence/QueryResultInterface.php @@ -0,0 +1,55 @@ + + * @extends \ArrayAccess + */ +interface QueryResultInterface extends \Countable, \Iterator, \ArrayAccess +{ + /** + * @phpstan-param QueryInterface $query + */ + public function setQuery(QueryInterface $query): void; + + /** + * Returns a clone of the query object + * + * @return \TYPO3\CMS\Extbase\Persistence\QueryInterface + * @phpstan-return QueryInterface + */ + public function getQuery(); + + /** + * Returns the first object in the result set + * + * @return object|null + * @phpstan-return TValue|null + */ + public function getFirst(); + + /** + * Returns an array with the objects in the result set + * + * @return array + * @phpstan-return list + */ + public function toArray(); +} diff --git a/Classes/Persistence/Repository.php b/Classes/Persistence/Repository.php new file mode 100644 index 0000000..a03f1de --- /dev/null +++ b/Classes/Persistence/Repository.php @@ -0,0 +1,345 @@ + + */ +class Repository implements RepositoryInterface, SingletonInterface +{ + protected PersistenceManagerInterface $persistenceManager; + protected EventDispatcherInterface $eventDispatcher; + protected bool $autoTagging; + + /** + * @var string + * @phpstan-var class-string + */ + protected $objectType; + + /** + * @var array + */ + protected $defaultOrderings = []; + + /** + * Override query settings created by extbase natively. + * Be careful if using this, see the comment on `setDefaultQuerySettings()` for more insights. + * + * @var QuerySettingsInterface + */ + protected $defaultQuerySettings; + + public function injectPersistenceManager(PersistenceManagerInterface $persistenceManager) + { + $this->persistenceManager = $persistenceManager; + } + + public function injectEventDispatcher(EventDispatcherInterface $eventDispatcher): void + { + $this->eventDispatcher = $eventDispatcher; + } + + public function injectFeatures(Features $features): void + { + $this->autoTagging = $features->isFeatureEnabled('frontend.cache.autoTagging'); + } + + /** + * Constructs a new Repository + */ + public function __construct() + { + $this->objectType = ClassNamingUtility::translateRepositoryNameToModelName($this->getRepositoryClassName()); + } + + /** + * Adds an object to this repository + * + * @param object $object The object to add + * @phpstan-param T $object + * @throws Exception\IllegalObjectTypeException + */ + public function add($object) + { + if (!$object instanceof $this->objectType) { + throw new IllegalObjectTypeException('The object given to add() was not of the type (' . $this->objectType . ') this repository manages.', 1248363335); + } + $this->persistenceManager->add($object); + } + + /** + * Removes an object from this repository. + * + * @param object $object The object to remove + * @phpstan-param T $object + * @throws Exception\IllegalObjectTypeException + */ + public function remove($object) + { + if (!$object instanceof $this->objectType) { + throw new IllegalObjectTypeException('The object given to remove() was not of the type (' . $this->objectType . ') this repository manages.', 1248363336); + } + $this->persistenceManager->remove($object); + } + + /** + * Replaces an existing object with the same identifier by the given object + * + * @param object $modifiedObject The modified object + * @phpstan-param T $modifiedObject + * @throws Exception\UnknownObjectException + * @throws Exception\IllegalObjectTypeException + */ + public function update($modifiedObject) + { + if (!$modifiedObject instanceof $this->objectType) { + throw new IllegalObjectTypeException('The modified object given to update() was not of the type (' . $this->objectType . ') this repository manages.', 1249479625); + } + $this->persistenceManager->update($modifiedObject); + } + + /** + * Returns all objects of this repository. + * + * @return QueryResultInterface + * @phpstan-return QueryResultInterface + */ + public function findAll() + { + $query = $this->createQuery(); + $this->addTableToCacheTags($query); + return $query->execute(); + } + + /** + * Returns the total number objects of this repository. + * + * @return int The object count + */ + public function countAll() + { + $query = $this->createQuery(); + $this->addTableToCacheTags($query); + return $query->execute()->count(); + } + + /** + * Removes all objects of this repository as if remove() was called for + * all of them. + */ + public function removeAll() + { + foreach ($this->findAll() as $object) { + $this->remove($object); + } + } + + /** + * Finds an object matching the given identifier. + * + * @param int $uid The identifier of the object to find + * @return object|null The matching object if found, otherwise NULL + * @phpstan-return T|null + */ + public function findByUid($uid) + { + return $this->findByIdentifier($uid); + } + + /** + * Finds an object matching the given identifier. + * + * @param mixed $identifier The identifier of the object to find + * @return object|null The matching object if found, otherwise NULL + * @phpstan-return T|null + */ + public function findByIdentifier($identifier) + { + return $this->persistenceManager->getObjectByIdentifier($identifier, $this->objectType); + } + + /** + * Sets the property names to order the result by per default. + * Expected like this: + * array( + * 'foo' => \TYPO3\CMS\Extbase\Persistence\QueryInterface::ORDER_ASCENDING, + * 'bar' => \TYPO3\CMS\Extbase\Persistence\QueryInterface::ORDER_DESCENDING + * ) + * + * @param array $defaultOrderings The property names to order by + */ + public function setDefaultOrderings(array $defaultOrderings) + { + $this->defaultOrderings = $defaultOrderings; + } + + /** + * Sets the default query settings to be used in this repository. + * + * A typical use case is an initializeObject() method that creates a QuerySettingsInterface + * object, configures it and sets it to be used for all queries created by the repository. + * + * Warning: Using this setter *fully overrides* native query settings created by + * QueryFactory->create(). This especially means that storagePid settings from + * configuration are not applied anymore, if not explicitly set. Make sure to apply these + * to your own QuerySettingsInterface object if needed, when using this method. + */ + public function setDefaultQuerySettings(QuerySettingsInterface $defaultQuerySettings) + { + $this->defaultQuerySettings = $defaultQuerySettings; + } + + /** + * Returns a query for objects of this repository + * + * @return QueryInterface + * @phpstan-return QueryInterface + */ + public function createQuery() + { + $query = $this->persistenceManager->createQueryForType($this->objectType); + if ($this->defaultOrderings !== []) { + $query->setOrderings($this->defaultOrderings); + } + if ($this->defaultQuerySettings !== null) { + $query->setQuerySettings(clone $this->defaultQuerySettings); + } + return $query; + } + + /** + * @phpstan-param array $criteria + * @phpstan-param array|null $orderBy + * @phpstan-param 0|positive-int|null $limit + * @phpstan-param 0|positive-int|null $offset + * @phpstan-return QueryResultInterface + */ + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): QueryResultInterface + { + $query = $this->createQuery(); + $constraints = []; + foreach ($criteria as $propertyName => $propertyValue) { + if (!is_string($propertyName)) { + throw new \RuntimeException('Repository::findBy() expects an array with string keys as first argument', 1741806517); + } + $constraints[] = $query->equals($propertyName, $propertyValue); + } + + if (($numberOfConstraints = count($constraints)) === 1) { + $query->matching(...$constraints); + } elseif ($numberOfConstraints > 1) { + $query->matching($query->logicalAnd(...$constraints)); + } + + if (is_array($orderBy)) { + $query->setOrderings($orderBy); + } + + if (is_int($limit)) { + $query->setLimit($limit); + } + + if (is_int($offset)) { + $query->setOffset($offset); + } + + $this->addStorageCacheTags($query); + return $query->execute(); + } + + /** + * @phpstan-param array $criteria + * @phpstan-param array|null $orderBy + * @phpstan-return T|null + */ + public function findOneBy(array $criteria, ?array $orderBy = null): ?object + { + return $this->findBy($criteria, $orderBy, 1)->getFirst(); + } + + /** + * @phpstan-param array $criteria + * @phpstan-return 0|positive-int + */ + public function count(array $criteria): int + { + return $this->findBy($criteria)->count(); + } + + /** + * Returns the class name of this class. + * + * @return class-string Class name of the repository. + */ + protected function getRepositoryClassName() + { + return static::class; + } + + /** + * Add the tablename to the cache tags, depending on the storage page settings. + */ + protected function addTableToCacheTags(QueryInterface $query): void + { + if (!$this->autoTagging) { + return; + } + $storagePageIds = $query->getQuerySettings()->getStoragePageIds(); + if (empty($storagePageIds) || $query->getQuerySettings()->getRespectStoragePage() === false) { + $source = $query->getSource(); + if ($source instanceof SelectorInterface) { + $this->eventDispatcher->dispatch( + new AddCacheTagEvent(new CacheTag($source->getSelectorName())) + ); + } + } else { + $this->addStorageCacheTags($query); + } + } + + /** + * Add the combination of tablename and storage pid as cache tag. + */ + protected function addStorageCacheTags(QueryInterface $query): void + { + if (!$this->autoTagging) { + return; + } + $source = $query->getSource(); + if ($source instanceof SelectorInterface) { + $tableName = $source->getSelectorName(); + $storagePageIds = $query->getQuerySettings()->getStoragePageIds(); + foreach ($storagePageIds as $storagePageId) { + $this->eventDispatcher->dispatch( + new AddCacheTagEvent(new CacheTag(sprintf('%s_pid_%s', $tableName, $storagePageId))) + ); + } + } + } +} diff --git a/Classes/Persistence/RepositoryInterface.php b/Classes/Persistence/RepositoryInterface.php new file mode 100644 index 0000000..98c33c9 --- /dev/null +++ b/Classes/Persistence/RepositoryInterface.php @@ -0,0 +1,115 @@ + + */ + public function findAll(); + + /** + * Returns the total number objects of this repository. + * + * @return int The object count + */ + public function countAll(); + + /** + * Removes all objects of this repository as if remove() was called for + * all of them. + */ + public function removeAll(); + + /** + * Finds an object matching the given identifier. + * + * @param int $uid The identifier of the object to find + * @return object The matching object if found, otherwise NULL + * @phpstan-return T|null + */ + public function findByUid($uid); + + /** + * Finds an object matching the given identifier. + * + * @param mixed $identifier The identifier of the object to find + * @return object The matching object if found, otherwise NULL + * @phpstan-return T|null + */ + public function findByIdentifier($identifier); + + /** + * Sets the property names to order the result by per default. + * Expected like this: + * array( + * 'foo' => \TYPO3\CMS\Extbase\Persistence\QueryInterface::ORDER_ASCENDING, + * 'bar' => \TYPO3\CMS\Extbase\Persistence\QueryInterface::ORDER_DESCENDING + * ) + * + * @param array $defaultOrderings The property names to order by + */ + public function setDefaultOrderings(array $defaultOrderings); + + /** + * Sets the default query settings to be used in this repository + * + * @param \TYPO3\CMS\Extbase\Persistence\Generic\QuerySettingsInterface $defaultQuerySettings The query settings to be used by default + */ + public function setDefaultQuerySettings(QuerySettingsInterface $defaultQuerySettings); + + /** + * Returns a query for objects of this repository + * + * @return \TYPO3\CMS\Extbase\Persistence\QueryInterface + * @phpstan-return QueryInterface + */ + public function createQuery(); +} diff --git a/Classes/Property/Exception.php b/Classes/Property/Exception.php new file mode 100644 index 0000000..3220e07 --- /dev/null +++ b/Classes/Property/Exception.php @@ -0,0 +1,25 @@ +resetMessages(); + } + + /** + * Map $source to $targetType, and return the result + * + * @param mixed $source the source data to map. MUST be a simple type, NO object allowed! + * @param string $targetType The type of the target; can be either a class name or a simple type. + * @param PropertyMappingConfigurationInterface|null $configuration Configuration for the property mapping. If NULL, the PropertyMappingConfigurationBuilder will create a default configuration. + * @return mixed an instance of $targetType + */ + public function convert(mixed $source, string $targetType, ?PropertyMappingConfigurationInterface $configuration = null): mixed + { + $configuration ??= $this->configurationBuilder->build(); + $currentPropertyPath = []; + try { + $result = $this->doMapping($source, $targetType, $configuration, $currentPropertyPath); + if ($result instanceof Error) { + return null; + } + + return $result; + } catch (TargetNotFoundException $e) { + throw $e; + } catch (\Exception $e) { + throw new Exception('Exception while property mapping at property path "' . implode('.', $currentPropertyPath) . '": ' . $e->getMessage(), 1297759968, $e); + } + } + + /** + * Get the messages of the last Property Mapping. + */ + public function getMessages(): Result + { + return $this->messages; + } + + /** + * Resets the messages of the last Property Mapping. + */ + public function resetMessages(): void + { + $this->messages = new Result(); + } + + /** + * Internal function which actually does the property mapping. + * + * @param mixed $source the source data to map. MUST be a simple type, NO object allowed! + * @param string $targetType The type of the target; can be either a class name or a simple type. + * @param PropertyMappingConfigurationInterface $configuration Configuration for the property mapping. + * @param array $currentPropertyPath The property path currently being mapped; used for knowing the context in case an exception is thrown. + * @return mixed an instance of $targetType + * + * @internal since TYPO3 v12.0 + */ + protected function doMapping(mixed $source, string $targetType, PropertyMappingConfigurationInterface $configuration, array &$currentPropertyPath) + { + if (is_object($source)) { + $targetType = $this->parseCompositeType($targetType); + if ($source instanceof $targetType) { + return $source; + } + } + + $source ??= ''; + + $typeConverter = $this->findTypeConverter($source, $targetType, $configuration); + $targetType = $typeConverter->getTargetTypeForSource($source, $targetType, $configuration); + + $convertedChildProperties = []; + foreach ($typeConverter->getSourceChildPropertiesToBeConverted($source) as $sourcePropertyName => $sourcePropertyValue) { + $targetPropertyName = $configuration->getTargetPropertyName($sourcePropertyName); + if ($configuration->shouldSkip($targetPropertyName)) { + continue; + } + + if (!$configuration->shouldMap($targetPropertyName)) { + if ($configuration->shouldSkipUnknownProperties()) { + continue; + } + throw new InvalidPropertyMappingConfigurationException('It is not allowed to map property "' . $targetPropertyName . '". You need to use $propertyMappingConfiguration->allowProperties(\'' . $targetPropertyName . '\') to enable mapping of this property.', 1355155913); + } + + $targetPropertyType = $typeConverter->getTypeOfChildProperty($targetType, $targetPropertyName, $configuration); + + $subConfiguration = $configuration->getConfigurationFor($targetPropertyName); + + $currentPropertyPath[] = $targetPropertyName; + $targetPropertyValue = $this->doMapping($sourcePropertyValue, $targetPropertyType, $subConfiguration, $currentPropertyPath); + array_pop($currentPropertyPath); + if (!($targetPropertyValue instanceof Error)) { + $convertedChildProperties[$targetPropertyName] = $targetPropertyValue; + } + } + $result = $typeConverter->convertFrom($source, $targetType, $convertedChildProperties, $configuration); + + if ($result instanceof Error) { + $this->messages->forProperty(implode('.', $currentPropertyPath))->addError($result); + } + + return $result; + } + + /** + * Determine the type converter to be used. If no converter has been found, an exception is raised. + * + * @return TypeConverterInterface Type Converter which should be used to convert between $source and $targetType. + * + * @throws Exception\TypeConverterException + * @throws Exception\InvalidTargetException + * @throws Exception\DuplicateTypeConverterException + * @throws Exception\InvalidSourceException + * + * @internal since TYPO3 v12.0 + */ + protected function findTypeConverter(mixed $source, string $targetType, PropertyMappingConfigurationInterface $configuration): TypeConverterInterface + { + if ($configuration->getTypeConverter() !== null) { + return $configuration->getTypeConverter(); + } + + $sourceType = $this->determineSourceType($source); + + $targetType = $this->parseCompositeType($targetType); + $targetType = TypeHandlingUtility::normalizeType($targetType); + + return $this->typeConverterRegistry->findTypeConverter($sourceType, $targetType); + } + + /** + * Determine the type of the source data, or throw an exception if source was an unsupported format. + * + * @throws Exception\InvalidSourceException + * + * @internal since TYPO3 v12.0 + */ + protected function determineSourceType(mixed $source): string + { + if (is_string($source)) { + return 'string'; + } + if (is_array($source)) { + return 'array'; + } + if (is_float($source)) { + return 'float'; + } + if (is_int($source)) { + return 'integer'; + } + if (is_bool($source)) { + return 'boolean'; + } + throw new Exception\InvalidSourceException('The source is not of type string, array, float, integer or boolean, but of type "' . gettype($source) . '"', 1297773150); + } + + /** + * Parse a composite type like \Foo\Collection<\Bar\Entity> into + * \Foo\Collection + * + * @internal since TYPO3 v12.0 + */ + protected function parseCompositeType(string $compositeType): string + { + if (str_contains($compositeType, '<')) { + $compositeType = substr($compositeType, 0, (int)strpos($compositeType, '<')); + } + return $compositeType; + } +} diff --git a/Classes/Property/PropertyMappingConfiguration.php b/Classes/Property/PropertyMappingConfiguration.php new file mode 100644 index 0000000..6d7db89 --- /dev/null +++ b/Classes/Property/PropertyMappingConfiguration.php @@ -0,0 +1,368 @@ +, non-empty-string|int> + */ + protected array $configuration = []; + + /** + * Stores the configuration for specific child properties. + * + * @var array + */ + protected array $subConfigurationForProperty = []; + + /** + * Keys which should be renamed + * + * @var array + */ + protected array $mapping = []; + + protected ?TypeConverterInterface $typeConverter = null; + + /** + * List of allowed property names to be converted + * + * @var array + */ + protected array $propertiesToBeMapped = []; + + /** + * List of property names to be skipped during property mapping + * + * @var array + */ + protected array $propertiesToSkip = []; + + /** + * List of disallowed property names which will be ignored while property mapping + * + * @var array + */ + protected array $propertiesNotToBeMapped = []; + + /** + * If TRUE, unknown properties will be skipped during property mapping + */ + protected bool $skipUnknownProperties = false; + + /** + * If TRUE, unknown properties will be mapped. + */ + protected bool $mapUnknownProperties = false; + + /** + * The behavior is as follows: + * + * - if a property has been explicitly forbidden using allowAllPropertiesExcept(...), it is directly rejected + * - if a property has been allowed using allowProperties(...), it is directly allowed. + * - if allowAllProperties* has been called, we allow unknown properties + * - else, return FALSE. + * + * @param non-empty-string $propertyName + * @return bool TRUE if the given propertyName should be mapped, FALSE otherwise. + */ + public function shouldMap(string $propertyName): bool + { + if (isset($this->propertiesNotToBeMapped[$propertyName])) { + return false; + } + + if (isset($this->propertiesToBeMapped[$propertyName])) { + return true; + } + + if (isset($this->subConfigurationForProperty[self::PROPERTY_PATH_PLACEHOLDER])) { + return true; + } + + return $this->mapUnknownProperties; + } + + /** + * Check if the given $propertyName should be skipped during mapping. + * + * @param non-empty-string $propertyName + */ + public function shouldSkip(string $propertyName): bool + { + return isset($this->propertiesToSkip[$propertyName]); + } + + /** + * Allow all properties in property mapping, even unknown ones. + * + * @return $this + */ + public function allowAllProperties(): self + { + $this->mapUnknownProperties = true; + return $this; + } + + /** + * Allow a list of specific properties. All arguments of + * allowProperties are used here (varargs). + * + * Example: allowProperties('title', 'content', 'author') + * + * @param non-empty-string ...$propertyNames + * @return $this + */ + public function allowProperties(int|string ...$propertyNames): self + { + foreach ($propertyNames as $propertyName) { + $this->propertiesToBeMapped[$propertyName] = $propertyName; + } + return $this; + } + + /** + * Skip a list of specific properties. All arguments of + * skipProperties are used here (varargs). + * + * Example: skipProperties('unused', 'dummy') + * + * @param non-empty-string ...$propertyNames + * @return $this + */ + public function skipProperties(string ...$propertyNames): self + { + foreach ($propertyNames as $propertyName) { + $this->propertiesToSkip[$propertyName] = $propertyName; + } + return $this; + } + + /** + * Allow all properties during property mapping, but reject a few + * selected ones (blacklist). + * + * Example: allowAllPropertiesExcept('password', 'userGroup') + * + * @param non-empty-string ...$propertyNames + * @return $this + */ + public function allowAllPropertiesExcept(string ...$propertyNames): self + { + $this->mapUnknownProperties = true; + + foreach ($propertyNames as $propertyName) { + $this->propertiesNotToBeMapped[$propertyName] = $propertyName; + } + return $this; + } + + /** + * When this is enabled, properties that are disallowed will be skipped + * instead of triggering an error during mapping. + * + * @return $this + */ + public function skipUnknownProperties(): self + { + $this->skipUnknownProperties = true; + return $this; + } + + /** + * Whether unknown (non configured) properties should be skipped during + * mapping, instead if causing an error. + */ + public function shouldSkipUnknownProperties(): bool + { + return $this->skipUnknownProperties; + } + + /** + * Returns the sub-configuration for the passed $propertyName. Must ALWAYS return a valid configuration object! + * + * @param non-empty-string $propertyName + * @return PropertyMappingConfigurationInterface the property mapping configuration for the given $propertyName. + */ + public function getConfigurationFor(string $propertyName): PropertyMappingConfigurationInterface + { + if (isset($this->subConfigurationForProperty[$propertyName])) { + return $this->subConfigurationForProperty[$propertyName]; + } + if (isset($this->subConfigurationForProperty[self::PROPERTY_PATH_PLACEHOLDER])) { + return $this->subConfigurationForProperty[self::PROPERTY_PATH_PLACEHOLDER]; + } + + return new self(); + } + + /** + * Maps the given $sourcePropertyName to a target property name. + * + * @param non-empty-string $sourcePropertyName + * @return non-empty-string property name of target + */ + public function getTargetPropertyName(string $sourcePropertyName): string + { + if (isset($this->mapping[$sourcePropertyName])) { + return $this->mapping[$sourcePropertyName]; + } + return $sourcePropertyName; + } + + /** + * @param class-string $typeConverterClassName + * @param non-empty-string|int $key + * @return mixed configuration value for the specific $typeConverterClassName. Can be used by Type Converters to fetch converter-specific configuration. + */ + public function getConfigurationValue(string $typeConverterClassName, string|int $key): mixed + { + if (!isset($this->configuration[$typeConverterClassName][$key])) { + return null; + } + + return $this->configuration[$typeConverterClassName][$key]; + } + + /** + * Define renaming from Source to Target property. + * + * @param non-empty-string $sourcePropertyName + * @param non-empty-string $targetPropertyName + * @return $this + */ + public function setMapping(string $sourcePropertyName, string $targetPropertyName): self + { + $this->mapping[$sourcePropertyName] = $targetPropertyName; + return $this; + } + + /** + * Set all options for the given $typeConverter. + * + * @param class-string $typeConverter class name of type converter + * @return $this + */ + public function setTypeConverterOptions(string $typeConverter, array $options): self + { + foreach ($this->getTypeConvertersWithParentClasses($typeConverter) as $typeConverter) { + $this->configuration[$typeConverter] = $options; + } + return $this; + } + + /** + * Set a single option (denoted by $optionKey) for the given $typeConverter. + * + * @param class-string $typeConverter class name of type converter + * @param non-empty-string|int $optionKey + * @param mixed $optionValue + * @return $this + */ + public function setTypeConverterOption(string $typeConverter, string|int $optionKey, mixed $optionValue): self + { + foreach ($this->getTypeConvertersWithParentClasses($typeConverter) as $typeConverter) { + $this->configuration[$typeConverter][$optionKey] = $optionValue; + } + return $this; + } + + /** + * Get type converter classes including parents for the given type converter + * + * When setting an option on a subclassed type converter, this option must also be set on + * all its parent type converters. + * + * @param class-string $typeConverter The type converter class + * @return array Class names of type converters + */ + protected function getTypeConvertersWithParentClasses(string $typeConverter): array + { + $typeConverterClasses = class_parents($typeConverter); + $typeConverterClasses = $typeConverterClasses ?: []; + $typeConverterClasses[] = $typeConverter; + return $typeConverterClasses; + } + + /** + * Returns the configuration for the specific property path, ready to be modified. Should be used + * inside a fluent interface like: + * $configuration->forProperty('foo.bar')->setTypeConverterOption(....) + * + * @param non-empty-string $propertyPath + */ + public function forProperty(string $propertyPath): PropertyMappingConfigurationInterface + { + $splitPropertyPath = explode('.', $propertyPath); + return $this->traverseProperties($splitPropertyPath); + } + + /** + * Traverse the property configuration. Only used by forProperty(). + */ + public function traverseProperties(array $splitPropertyPath): PropertyMappingConfigurationInterface + { + if (empty($splitPropertyPath)) { + return $this; + } + + $currentProperty = array_shift($splitPropertyPath); + if (!isset($this->subConfigurationForProperty[$currentProperty])) { + $type = static::class; + if (isset($this->subConfigurationForProperty[self::PROPERTY_PATH_PLACEHOLDER])) { + $this->subConfigurationForProperty[$currentProperty] = clone $this->subConfigurationForProperty[self::PROPERTY_PATH_PLACEHOLDER]; + } else { + $this->subConfigurationForProperty[$currentProperty] = new $type(); + } + } + return $this->subConfigurationForProperty[$currentProperty]->traverseProperties($splitPropertyPath); + } + + /** + * Return the type converter set for this configuration. + */ + public function getTypeConverter(): ?TypeConverterInterface + { + return $this->typeConverter; + } + + /** + * Set a type converter which should be used for this specific conversion. + * + * @return $this + */ + public function setTypeConverter(TypeConverterInterface $typeConverter) + { + $this->typeConverter = $typeConverter; + return $this; + } +} diff --git a/Classes/Property/PropertyMappingConfigurationBuilder.php b/Classes/Property/PropertyMappingConfigurationBuilder.php new file mode 100644 index 0000000..9d36507 --- /dev/null +++ b/Classes/Property/PropertyMappingConfigurationBuilder.php @@ -0,0 +1,46 @@ + $type the implementation class to instantiate + * @return PropertyMappingConfiguration + */ + public function build($type = PropertyMappingConfiguration::class) + { + $configuration = new $type(); + + $configuration->setTypeConverterOptions(PersistentObjectConverter::class, [ + PersistentObjectConverter::CONFIGURATION_CREATION_ALLOWED => true, + PersistentObjectConverter::CONFIGURATION_MODIFICATION_ALLOWED => true, + ]); + $configuration->allowAllProperties(); + + return $configuration; + } +} diff --git a/Classes/Property/PropertyMappingConfigurationInterface.php b/Classes/Property/PropertyMappingConfigurationInterface.php new file mode 100644 index 0000000..9a26608 --- /dev/null +++ b/Classes/Property/PropertyMappingConfigurationInterface.php @@ -0,0 +1,100 @@ + $typeConverterClassName + * @param non-empty-string|int $key + * @return mixed configuration value for the specific $typeConverterClassName. Can be used by Type Converters to fetch converter-specific configuration + */ + public function getConfigurationValue(string $typeConverterClassName, string|int $key): mixed; + + /** + * This method can be used to explicitly force a TypeConverter to be used for this Configuration. + * + * @return TypeConverterInterface|null The type converter to be used for this particular PropertyMappingConfiguration, or NULL if the system-wide configured type converter should be used. + */ + public function getTypeConverter(): ?TypeConverterInterface; + + public function allowAllProperties(): PropertyMappingConfigurationInterface; + + /** + * @param class-string $typeConverter + */ + public function setTypeConverterOption(string $typeConverter, string|int $optionKey, mixed $optionValue): PropertyMappingConfigurationInterface; + + /** + * @param non-empty-string $propertyName + */ + public function shouldMap(string $propertyName): bool; + + /** + * @param class-string $typeConverter + */ + public function setTypeConverterOptions(string $typeConverter, array $options): PropertyMappingConfigurationInterface; + + /** + * @param non-empty-string $propertyPath + */ + public function forProperty(string $propertyPath): PropertyMappingConfigurationInterface; + + /** + * @param non-empty-string ...$propertyNames + */ + public function allowProperties(string ...$propertyNames): PropertyMappingConfigurationInterface; + + /** + * @param array $splitPropertyPath + */ + public function traverseProperties(array $splitPropertyPath): PropertyMappingConfigurationInterface; +} diff --git a/Classes/Property/TypeConverter/AbstractFileFolderConverter.php b/Classes/Property/TypeConverter/AbstractFileFolderConverter.php new file mode 100644 index 0000000..4920db9 --- /dev/null +++ b/Classes/Property/TypeConverter/AbstractFileFolderConverter.php @@ -0,0 +1,72 @@ +fileFactory = $fileFactory; + } + + /** + * Actually convert from $source to $targetType, taking into account the fully + * built $convertedChildProperties and $configuration. + * + * @param string|int $source + * @throws Exception + */ + public function convertFrom( + $source, + string $targetType, + array $convertedChildProperties = [], + ?PropertyMappingConfigurationInterface $configuration = null + ): File|FileReference|Folder { + $object = $this->getOriginalResource($source); + if (empty($this->expectedObjectType) || !$object instanceof $this->expectedObjectType) { + throw new Exception('Expected object of type "' . $this->expectedObjectType . '" but got ' . (is_object($object) ? get_class($object) : 'null'), 1342895975); + } + /** @var File|FileReference|Folder $subject */ + $subject = GeneralUtility::makeInstance($targetType); + $subject->setOriginalResource($object); + return $subject; + } + + /** + * @param string|int $source + */ + abstract protected function getOriginalResource($source): ?ResourceInterface; +} diff --git a/Classes/Property/TypeConverter/AbstractTypeConverter.php b/Classes/Property/TypeConverter/AbstractTypeConverter.php new file mode 100644 index 0000000..9fc7e5d --- /dev/null +++ b/Classes/Property/TypeConverter/AbstractTypeConverter.php @@ -0,0 +1,71 @@ +getConfigurationValue(self::class, self::CONFIGURATION_DELIMITER); + $removeEmptyValues = $configuration->getConfigurationValue(self::class, self::CONFIGURATION_REMOVE_EMPTY_VALUES) ?? false; + $limit = $configuration->getConfigurationValue(self::class, self::CONFIGURATION_LIMIT) ?? 0; + if (!is_string($delimiter)) { + throw new TypeConverterException('No delimiter configured for ' . self::class . ' and non-empty value given.', 1582877555); + } + + return GeneralUtility::trimExplode($delimiter, $source, $removeEmptyValues, $limit); + } +} diff --git a/Classes/Property/TypeConverter/BooleanConverter.php b/Classes/Property/TypeConverter/BooleanConverter.php new file mode 100644 index 0000000..9a5c8b1 --- /dev/null +++ b/Classes/Property/TypeConverter/BooleanConverter.php @@ -0,0 +1,41 @@ +getMessage(), 1381680012); + } + } +} diff --git a/Classes/Property/TypeConverter/CountryConverter.php b/Classes/Property/TypeConverter/CountryConverter.php new file mode 100644 index 0000000..9cd3551 --- /dev/null +++ b/Classes/Property/TypeConverter/CountryConverter.php @@ -0,0 +1,61 @@ +countryProvider = $countryProvider; + } + + /** + * Actually convert from $source to $targetType, taking into account the fully + * built $convertedChildProperties and $configuration. + * + * @param string $source + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ + public function convertFrom( + $source, + string $targetType, + array $convertedChildProperties = [], + ?PropertyMappingConfigurationInterface $configuration = null + ): ?Country { + + $by = self::CONFIGURATION_FROM; + if ($configuration !== null) { + $by = $configuration->getConfigurationValue(CountryConverter::class, self::CONFIGURATION_FROM); + } + return match ($by) { + 'alpha3IsoCode' => $this->countryProvider->getByAlpha3IsoCode((string)$source), + default => $this->countryProvider->getByAlpha2IsoCode((string)$source), + }; + } +} diff --git a/Classes/Property/TypeConverter/DateTimeConverter.php b/Classes/Property/TypeConverter/DateTimeConverter.php new file mode 100644 index 0000000..c5b7db5 --- /dev/null +++ b/Classes/Property/TypeConverter/DateTimeConverter.php @@ -0,0 +1,200 @@ +arguments[''] + * ->getPropertyMappingConfiguration() + * ->forProperty('') // this line can be skipped in order to specify the format for all properties + * ->setTypeConverterOption(\TYPO3\CMS\Extbase\Property\TypeConverter\DateTimeConverter::class, \TYPO3\CMS\Extbase\Property\TypeConverter\DateTimeConverter::CONFIGURATION_DATE_FORMAT, ''); + * + * If the source is of type array, it is possible to override the format in the source:: + * + * array( + * 'date' => '', + * 'dateFormat' => '' + * ); + * + * By using an array as source you can also override time and timezone of the created DateTime object:: + * + * array( + * 'date' => '', + * 'hour' => '', // integer + * 'minute' => '', // integer + * 'seconds' => '', // integer + * 'timezone' => '', // string, see http://www.php.net/manual/timezones.php + * ); + * + * As an alternative to providing the date as string, you might supply day, month and year as array items each:: + * + * array( + * 'day' => '', // integer + * 'month' => '', // integer + * 'year' => '', // integer + * ); + */ +class DateTimeConverter extends AbstractTypeConverter +{ + /** + * @var string + */ + public const CONFIGURATION_DATE_FORMAT = 'dateFormat'; + + /** + * The default date format is "YYYY-MM-DDT##:##:##+##:##", for example "2005-08-15T15:52:01+00:00" + * according to the W3C standard @see http://www.w3.org/TR/NOTE-datetime.html + * + * @var string + */ + public const DEFAULT_DATE_FORMAT = \DateTimeInterface::W3C; + + /** + * Converts $source to a \DateTime using the configured dateFormat + * + * @param string|int|array $source the string to be converted to a \DateTime object + * @param string $targetType must be "DateTime" + * @param array $convertedChildProperties not used currently + * @throws TypeConverterException + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ + public function convertFrom( + $source, + string $targetType, + array $convertedChildProperties = [], + ?PropertyMappingConfigurationInterface $configuration = null + ): \DateTime|Error|null { + $dateFormat = $this->getDefaultDateFormat($configuration); + if (is_string($source)) { + $dateAsString = $source; + } elseif (is_int($source)) { + $dateAsString = (string)$source; + } else { + if (isset($source['date']) && is_string($source['date'])) { + $dateAsString = $source['date']; + } elseif (isset($source['date']) && is_int($source['date'])) { + $dateAsString = (string)$source['date']; + } elseif ($this->isDatePartKeysProvided($source)) { + if ($source['day'] < 1 || $source['month'] < 1 || $source['year'] < 1) { + return new Error('Could not convert the given date parts into a DateTime object because one or more parts were 0.', 1333032779); + } + $dateAsString = sprintf('%d-%d-%d', $source['year'], $source['month'], $source['day']); + } else { + throw new TypeConverterException('Could not convert the given source into a DateTime object because it was not an array with a valid date as a string', 1308003914); + } + if (isset($source['dateFormat']) && $source['dateFormat'] !== '') { + $dateFormat = $source['dateFormat']; + } + } + if ($dateAsString === '') { + return null; + } + if (ctype_digit($dateAsString) && $configuration === null && (!is_array($source) || !isset($source['dateFormat']))) { + // todo: type converters are never called without a property mapping configuration + $dateFormat = 'U'; + } + if (is_array($source) && isset($source['timezone']) && (string)$source['timezone'] !== '') { + try { + $timezone = new \DateTimeZone($source['timezone']); + } catch (\Exception $e) { + throw new TypeConverterException('The specified timezone "' . $source['timezone'] . '" is invalid.', 1308240974); + } + $date = $targetType::createFromFormat($dateFormat, $dateAsString, $timezone); + } else { + $date = $targetType::createFromFormat($dateFormat, $dateAsString); + } + if ($date === false) { + return new \TYPO3\CMS\Extbase\Validation\Error( + $this->translateErrorMessage( + 'LLL:EXT:extbase/Resources/Private/Language/locallang.xlf:converter.datetime.notrecognized', + ), + 1307719788, + [$dateAsString, $dateFormat] + ); + } + if (is_array($source)) { + $date = $this->overrideTimeIfSpecified($date, $source); + } + return $date; + } + + /** + * Wrap static call to LocalizationUtility to simplify unit testing. + */ + protected function translateErrorMessage(string $translateKey): string + { + return LocalizationUtility::translate($translateKey) ?? ''; + } + + /** + * Returns whether date information (day, month, year) are present as keys in $source. + */ + protected function isDatePartKeysProvided(array $source): bool + { + return isset($source['day'], $source['month'], $source['year']) + && ctype_digit($source['day']) && ctype_digit($source['month']) && ctype_digit($source['year']); + } + + /** + * Determines the default date format to use for the conversion. + * If no format is specified in the mapping configuration DEFAULT_DATE_FORMAT is used. + * + * @throws InvalidPropertyMappingConfigurationException + */ + protected function getDefaultDateFormat(?PropertyMappingConfigurationInterface $configuration = null): string + { + if ($configuration === null) { + // todo: type converters are never called without a property mapping configuration + return self::DEFAULT_DATE_FORMAT; + } + $dateFormat = $configuration->getConfigurationValue(DateTimeConverter::class, self::CONFIGURATION_DATE_FORMAT); + if ($dateFormat === null) { + return self::DEFAULT_DATE_FORMAT; + } + if (!is_string($dateFormat)) { + throw new InvalidPropertyMappingConfigurationException('CONFIGURATION_DATE_FORMAT must be of type string, "' . get_debug_type($dateFormat) . '" given', 1307719569); + } + return $dateFormat; + } + + /** + * Overrides hour, minute & second of the given date with the values in the $source array + */ + protected function overrideTimeIfSpecified(\DateTime $date, array $source): \DateTime + { + if (!isset($source['hour']) && !isset($source['minute']) && !isset($source['second'])) { + return $date; + } + $hour = isset($source['hour']) ? (int)$source['hour'] : 0; + $minute = isset($source['minute']) ? (int)$source['minute'] : 0; + $second = isset($source['second']) ? (int)$source['second'] : 0; + return $date->setTime($hour, $minute, $second); + } +} diff --git a/Classes/Property/TypeConverter/EnumConverter.php b/Classes/Property/TypeConverter/EnumConverter.php new file mode 100644 index 0000000..72f1ea4 --- /dev/null +++ b/Classes/Property/TypeConverter/EnumConverter.php @@ -0,0 +1,71 @@ + $targetType + * @return T|null + * @throws InvalidTargetException + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ + public function convertFrom( + mixed $source, + string $targetType, + array $convertedChildProperties = [], + ?PropertyMappingConfigurationInterface $configuration = null + ): ?\UnitEnum { + return $this->getEnumElement($source, $targetType); + } + + /** + * @template T of UnitEnum + * @param class-string $targetType + * @return T|null + * @throws InvalidTargetException + */ + protected function getEnumElement(float|int|string $source, string $targetType): ?\UnitEnum + { + if (!enum_exists($targetType)) { + throw new InvalidTargetException('TargetType "' . $targetType . '" is not an enum.', 1660834545); + } + foreach ($targetType::cases() as $enum) { + if (property_exists($enum, 'value') && $enum->value == $source) { + return $enum; + } + } + + foreach ($targetType::cases() as $enum) { + if ($enum->name == $source) { + return $enum; + } + } + return null; + } +} diff --git a/Classes/Property/TypeConverter/FileConverter.php b/Classes/Property/TypeConverter/FileConverter.php new file mode 100644 index 0000000..41394cf --- /dev/null +++ b/Classes/Property/TypeConverter/FileConverter.php @@ -0,0 +1,41 @@ +fileFactory->retrieveFileOrFolderObject($source); + } +} diff --git a/Classes/Property/TypeConverter/FileReferenceConverter.php b/Classes/Property/TypeConverter/FileReferenceConverter.php new file mode 100644 index 0000000..47d91d6 --- /dev/null +++ b/Classes/Property/TypeConverter/FileReferenceConverter.php @@ -0,0 +1,39 @@ +fileFactory->getFileReferenceObject($source); + } +} diff --git a/Classes/Property/TypeConverter/FloatConverter.php b/Classes/Property/TypeConverter/FloatConverter.php new file mode 100644 index 0000000..9f9e103 --- /dev/null +++ b/Classes/Property/TypeConverter/FloatConverter.php @@ -0,0 +1,65 @@ +getConfigurationValue(self::class, self::CONFIGURATION_THOUSANDS_SEPARATOR); + $decimalPoint = $configuration->getConfigurationValue(self::class, self::CONFIGURATION_DECIMAL_POINT); + $source = str_replace([$thousandsSeparator, $decimalPoint], ['', '.'], $source); + } + if (!is_numeric($source)) { + return new Error('"%s" cannot be converted to a float value.', 1332934124, [$source]); + } + return (float)$source; + } +} diff --git a/Classes/Property/TypeConverter/FolderConverter.php b/Classes/Property/TypeConverter/FolderConverter.php new file mode 100644 index 0000000..d73fe93 --- /dev/null +++ b/Classes/Property/TypeConverter/FolderConverter.php @@ -0,0 +1,39 @@ +fileFactory->getFolderObjectFromCombinedIdentifier($source); + } +} diff --git a/Classes/Property/TypeConverter/IntegerConverter.php b/Classes/Property/TypeConverter/IntegerConverter.php new file mode 100644 index 0000000..ec0a029 --- /dev/null +++ b/Classes/Property/TypeConverter/IntegerConverter.php @@ -0,0 +1,48 @@ +reflectionService = $reflectionService; + } + + public function injectContainer(ContainerInterface $container): void + { + $this->container = $container; + } + + /** + * Convert all properties in the source array + * + * @param mixed $source + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ + public function getSourceChildPropertiesToBeConverted($source): array + { + if (isset($source['__type'])) { + unset($source['__type']); + } + return $source; + } + + /** + * The type of a property is determined by the reflection service. + * + * @throws InvalidTargetException + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ + public function getTypeOfChildProperty( + string $targetType, + string $propertyName, + PropertyMappingConfigurationInterface $configuration + ): string { + $configuredTargetType = $configuration->getConfigurationFor($propertyName) + ->getConfigurationValue(ObjectConverter::class, self::CONFIGURATION_TARGET_TYPE); + if ($configuredTargetType !== null) { + return $configuredTargetType; + } + + $classSchema = $this->reflectionService->getClassSchema($targetType); + + // @todo: infer property type from property instead of from setter and make setter optional + // {@link https://forge.typo3.org/issues/100136} + + $methodName = 'set' . ucfirst($propertyName); + if ($classSchema->hasMethod($methodName)) { + $methodParameters = $classSchema->getMethod($methodName)->getParameters(); + $methodParameter = current($methodParameters); + if ($methodParameter->getType() === null) { + throw new InvalidTargetException('Setter for property "' . $propertyName . '" had no type hint or documentation in target object of type "' . $targetType . '".', 1303379158); + } + $property = $classSchema->getProperty($propertyName); + $primaryCollectionValueType = $property->getPrimaryCollectionValueType(); + if ($primaryCollectionValueType) { + return $methodParameter->getType() . '<' . ($primaryCollectionValueType->getClassName() ?? $primaryCollectionValueType->getBuiltinType()) . '>'; + } + return $methodParameter->getType(); + } + try { + $parameterType = $classSchema->getMethod('__construct')->getParameter($propertyName)->getType(); + } catch (NoSuchMethodException $e) { + $exceptionMessage = sprintf('Type of child property "%s" of class "%s" could not be ' + . 'derived from constructor arguments as said class does not have a constructor ' + . 'defined.', $propertyName, $targetType); + throw new InvalidTargetException($exceptionMessage, 1582385098); + } catch (NoSuchMethodParameterException $e) { + $exceptionMessage = sprintf('Type of child property "%1$s" of class "%2$s" could not be ' + . 'derived from constructor arguments as the constructor of said class does not ' + . 'have a parameter with property name "%1$s".', $propertyName, $targetType); + throw new InvalidTargetException($exceptionMessage, 1303379126); + } + + if ($parameterType === null) { + $exceptionMessage = sprintf('Type of child property "%1$s" of class "%2$s" could not be ' + . 'derived from constructor argument "%1$s". This usually happens if the argument ' + . 'misses a type hint.', $propertyName, $targetType); + throw new InvalidTargetException($exceptionMessage, 1582385619); + } + return $parameterType; + } + + /** + * Convert an object from $source to an object. + * + * @param mixed $source + * @return object|null the target type + * @throws InvalidTargetException + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ + public function convertFrom( + $source, + string $targetType, + array $convertedChildProperties = [], + ?PropertyMappingConfigurationInterface $configuration = null + ): ?object { + $object = $this->buildObject($convertedChildProperties, $targetType); + foreach ($convertedChildProperties as $propertyName => $propertyValue) { + $result = ObjectAccess::setProperty($object, $propertyName, $propertyValue); + if ($result === false) { + $exceptionMessage = sprintf( + 'Property "%s" having a value of type "%s" could not be set in target object of type "%s". Make sure that the property is accessible properly, for example via an appropriate setter method.', + $propertyName, + get_debug_type($propertyValue), + $targetType + ); + throw new InvalidTargetException($exceptionMessage, 1304538165); + } + } + + return $object; + } + + /** + * Determines the target type based on the source's (optional) __type key and by evaluating possible + * XCLASS overrides of the target type. + * + * @param mixed $source + * @throws InvalidDataTypeException + * @throws InvalidPropertyMappingConfigurationException + * @throws \InvalidArgumentException + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ + public function getTargetTypeForSource( + $source, + string $originalTargetType, + ?PropertyMappingConfigurationInterface $configuration = null + ): string { + $targetType = $originalTargetType; + + if (is_array($source) && array_key_exists('__type', $source)) { + $targetType = $source['__type']; + + if ($configuration === null) { + // todo: this is impossible to achieve since this methods is always called via (convert -> doMapping -> getTargetTypeForSource) and convert and doMapping create configuration objects if missing. + throw new \InvalidArgumentException('A property mapping configuration must be given, not NULL.', 1326277369); + } + if ($configuration->getConfigurationValue(ObjectConverter::class, self::CONFIGURATION_OVERRIDE_TARGET_TYPE_ALLOWED) !== true) { + throw new InvalidPropertyMappingConfigurationException('Override of target type not allowed. To enable this, you need to set the PropertyMappingConfiguration Value "CONFIGURATION_OVERRIDE_TARGET_TYPE_ALLOWED" to TRUE.', 1317050430); + } + + if ($targetType !== $originalTargetType && is_a($targetType, $originalTargetType, true) === false) { + throw new InvalidDataTypeException('The given type "' . $targetType . '" is not a subtype of "' . $originalTargetType . '".', 1317048056); + } + } + + // Respect XCLASSed object target type + return GeneralUtility::getClassName($targetType); + } + + /** + * Builds a new instance of $objectType with the given $possibleConstructorArgumentValues. If + * constructor argument values are missing from the given array the method looks for a default + * value in the constructor signature. Furthermore, the constructor arguments are removed from + * $possibleConstructorArgumentValues: They are considered "handled" by __construct and will + * not be mapped calling setters later. + * + * @return object The created instance + * @throws InvalidTargetException if a required constructor argument is missing + */ + protected function buildObject(array &$possibleConstructorArgumentValues, string $objectType): object + { + // The ObjectConverter typically kicks in, if request arguments are to be mapped to + // a domain model. An example is ext:belog:Domain/Model/Demand. + // Domain models are data objects and should thus be fetched via makeInstance(), should + // not be registered as service, and should thus not be DI aware. + // Additionally, all to-be-mapped arguments are hand over as "possible constructor arguments" here, + // and extbase is able to use single arguments as constructor arguments to domain models, + // if a __construct() with an argument having the same name as a to-be-mapped argument exists. + // This is the reason that &$possibleConstructorArgumentValues is hand over as reference here: + // If an argument can be hand over as constructor argument, it is considered "already mapped" and + // is not manually mapped calling setters later. + // To be as backwards compatible as possible, the following logic is applied: + // * If the class is registered as service (container->has()=true), and if there are no + // $possibleConstructorArgumentValues, instantiate the class via container->get(). Easy + // scenario - the target class is DI aware and will get dependencies injected. A different target + // class can be specified using service configuration if needed. + // * If the class is registered as service, and if there are $possibleConstructorArgumentValues, + // the class is instantiated via container->get(). $possibleConstructorArgumentValues are *not* hand + // over to the constructor. The target class can then use constructor injection and inject* methods + // for DI. A different target class can be specified using service configuration if needed. Mapping + // of arguments is done using setters by follow-up code. + // * If the class is *not* registered as service, makeInstance() is used for object retrieval. + // * If there are no $possibleConstructorArgumentValues, makeInstance() is used right away. + // * If there are $possibleConstructorArgumentValues and __construct() does not exist, makeInstance() + // is used without constructor arguments. Mapping of argument values via setters is done by follow-up code. + // * If there are $possibleConstructorArgumentValues and if __construct() exists, extbase reflection + // is used to map single arguments to constructor arguments with the same name and + // makeInstance() is used to instantiate the class. Mapping remaining arguments is done by follow-up code. + if ($this->container->has($objectType)) { + // @todo: consider dropping container->get() to prevent domain models being treated as services in >=v12. + return $this->container->get($objectType); + } + + if (empty($possibleConstructorArgumentValues) || !method_exists($objectType, '__construct')) { + return GeneralUtility::makeInstance($objectType); + } + + $classSchema = $this->reflectionService->getClassSchema($objectType); + $constructor = $classSchema->getMethod('__construct'); + $constructorArguments = []; + foreach ($constructor->getParameters() as $parameterName => $parameter) { + if (array_key_exists($parameterName, $possibleConstructorArgumentValues)) { + $constructorArguments[] = $possibleConstructorArgumentValues[$parameterName]; + unset($possibleConstructorArgumentValues[$parameterName]); + } elseif ($parameter->isOptional()) { + $constructorArguments[] = $parameter->getDefaultValue(); + } else { + throw new InvalidTargetException('Missing constructor argument "' . $parameterName . '" for object of type "' . $objectType . '".', 1268734872); + } + } + return GeneralUtility::makeInstance(...[$objectType, ...$constructorArguments]); + } +} diff --git a/Classes/Property/TypeConverter/ObjectStorageConverter.php b/Classes/Property/TypeConverter/ObjectStorageConverter.php new file mode 100644 index 0000000..aaa4bf7 --- /dev/null +++ b/Classes/Property/TypeConverter/ObjectStorageConverter.php @@ -0,0 +1,75 @@ +attach($subProperty); + } + return $objectStorage; + } + + /** + * Returns the source, if it is an array, otherwise an empty array. + * + * @param mixed $source + */ + public function getSourceChildPropertiesToBeConverted($source): array + { + if (is_array($source)) { + return $source; + } + return []; + } + + /** + * Return the type of a given sub-property inside the $targetType + * + * @param string $targetType + */ + public function getTypeOfChildProperty( + $targetType, + string $propertyName, + PropertyMappingConfigurationInterface $configuration + ): string { + $parsedTargetType = TypeHandlingUtility::parseType($targetType); + return $parsedTargetType['elementType']; + } +} diff --git a/Classes/Property/TypeConverter/PersistentObjectConverter.php b/Classes/Property/TypeConverter/PersistentObjectConverter.php new file mode 100644 index 0000000..bc2813f --- /dev/null +++ b/Classes/Property/TypeConverter/PersistentObjectConverter.php @@ -0,0 +1,226 @@ +persistenceManager = $persistenceManager; + } + + /** + * All properties in the source array except __identity are sub-properties. + * + * @param mixed $source + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ + public function getSourceChildPropertiesToBeConverted($source): array + { + if (is_string($source) || is_int($source)) { + return []; + } + if (isset($source['__identity'])) { + unset($source['__identity']); + } + return parent::getSourceChildPropertiesToBeConverted($source); + } + + /** + * The type of a property is determined by the reflection service. + * + * @param string $targetType + * @throws InvalidTargetException + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ + public function getTypeOfChildProperty( + $targetType, + string $propertyName, + PropertyMappingConfigurationInterface $configuration + ): string { + $configuredTargetType = $configuration->getConfigurationFor($propertyName) + ->getConfigurationValue(PersistentObjectConverter::class, self::CONFIGURATION_TARGET_TYPE); + if ($configuredTargetType !== null) { + return $configuredTargetType; + } + + $schema = $this->reflectionService->getClassSchema($targetType); + if (!$schema->hasProperty($propertyName)) { + throw new InvalidTargetException('Property "' . $propertyName . '" was not found in target object of type "' . $targetType . '".', 1297978366); + } + $primaryType = $schema->getProperty($propertyName)->getPrimaryType(); + if (!$primaryType) { + throw NoPropertyTypesException::create($targetType, $propertyName); + } + + $type = $primaryType->getClassName() ?? $primaryType->getBuiltinType(); + if ($primaryType->isCollection() && $primaryType->getCollectionValueTypes() !== []) { + $primaryCollectionValueType = $primaryType->getCollectionValueTypes()[0]; + $collectionValueType = $primaryCollectionValueType->getClassName() ?? $primaryCollectionValueType->getBuiltinType(); + $type .= '<' . $collectionValueType . '>'; + } + + return $type; + } + + /** + * Convert an object from $source to an entity or a value object. + * + * @param mixed $source + * @throws \InvalidArgumentException + * @throws InvalidTargetException + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ + public function convertFrom( + $source, + string $targetType, + array $convertedChildProperties = [], + ?PropertyMappingConfigurationInterface $configuration = null + ): ?object { + if (is_array($source)) { + if ( + class_exists($targetType) + && is_subclass_of($targetType, AbstractValueObject::class) + ) { + // Unset identity for valueobject to use constructor mapping, since the identity is determined from + // constructor arguments + unset($source['__identity']); + } + $object = $this->handleArrayData($source, $targetType, $convertedChildProperties, $configuration); + } elseif (is_string($source) || is_int($source)) { + if (empty($source)) { + return null; + } + $object = $this->fetchObjectFromPersistence($source, $targetType); + } else { + // todo: this case is impossible as this converter is never called with a source that is not an integer, a string or an array + throw new \InvalidArgumentException('Only integers, strings and arrays are accepted.', 1305630314); + } + foreach ($convertedChildProperties as $propertyName => $propertyValue) { + $result = ObjectAccess::setProperty($object, $propertyName, $propertyValue); + if ($result === false) { + $exceptionMessage = sprintf( + 'Property "%s" having a value of type "%s" could not be set in target object of type "%s". Make sure that the property is accessible properly, for example via an appropriate setter method.', + $propertyName, + get_debug_type($propertyValue), + $targetType + ); + throw new InvalidTargetException($exceptionMessage, 1297935345); + } + } + + return $object; + } + + /** + * Handle the case if $source is an array. + * + * @throws InvalidPropertyMappingConfigurationException + */ + protected function handleArrayData( + array $source, + string $targetType, + array &$convertedChildProperties, + ?PropertyMappingConfigurationInterface $configuration = null + ): object { + if (isset($source['__identity'])) { + $object = $this->fetchObjectFromPersistence($source['__identity'], $targetType); + + if (count($source) > 1 && ($configuration === null || $configuration->getConfigurationValue(PersistentObjectConverter::class, self::CONFIGURATION_MODIFICATION_ALLOWED) !== true)) { + throw new InvalidPropertyMappingConfigurationException('Modification of persistent objects not allowed. To enable this, you need to set the PropertyMappingConfiguration Value "CONFIGURATION_MODIFICATION_ALLOWED" to TRUE.', 1297932028); + } + } else { + if ($configuration === null || $configuration->getConfigurationValue(PersistentObjectConverter::class, self::CONFIGURATION_CREATION_ALLOWED) !== true) { + throw new InvalidPropertyMappingConfigurationException( + 'Creation of objects not allowed. To enable this, you need to set the PropertyMappingConfiguration Value "CONFIGURATION_CREATION_ALLOWED" to TRUE', + 1476044961 + ); + } + $object = $this->buildObject($convertedChildProperties, $targetType); + } + return $object; + } + + /** + * Fetch an object from persistence layer. + * + * @throws TargetNotFoundException + * @throws InvalidSourceException + */ + protected function fetchObjectFromPersistence(mixed $identity, string $targetType): object + { + // @todo - Ideally, this underscore notation should not be passed here. + // Consumers of this method should rather earlier resolve to the proper uid + // via '$value->getUid' like 'renderHiddenIdentityField' does, for example. + // @see #105319 + if (str_contains((string)$identity, '_')) { + $localizedUidParts = explode('_', (string)$identity); + $uidIdentity = $localizedUidParts[0]; + } else { + $uidIdentity = (string)$identity; + } + if (ctype_digit($uidIdentity)) { + $object = $this->persistenceManager->getObjectByIdentifier($uidIdentity, $targetType); + } else { + throw new InvalidSourceException('The identity property "' . $identity . '" is no UID.', 1297931020); + } + + if ($object === null) { + throw new TargetNotFoundException(sprintf('Object of type %s with identity "%s" not found.', $targetType, print_r($identity, true)), 1297933823); + } + + return $object; + } +} diff --git a/Classes/Property/TypeConverter/StringConverter.php b/Classes/Property/TypeConverter/StringConverter.php new file mode 100644 index 0000000..d3d401f --- /dev/null +++ b/Classes/Property/TypeConverter/StringConverter.php @@ -0,0 +1,42 @@ +>> + */ + protected array $typeConverters = []; + + /** + * Used in the TypeConverterPass only. + * + * @param array|string[] $sources + * @throws Exception\DuplicateTypeConverterException + * @internal + */ + public function add(TypeConverterInterface $converter, int $priority, array $sources, string $target): void + { + foreach ($sources as $source) { + if (isset($this->typeConverters[$source][$target][$priority])) { + throw new Exception\DuplicateTypeConverterException( + sprintf( + 'There exist at least two type converters which handle the conversion from "%s" to "%s" with priority "%d": %s and %s', + $source, + $target, + $priority, + get_class($this->typeConverters[$source][$target][$priority]), + get_class($converter) + ), + 1297951378 + ); + } + + $this->typeConverters[$source][$target][$priority] = $converter; + } + } + + /** + * @throws Exception\DuplicateTypeConverterException + * @throws Exception\InvalidTargetException + * @throws Exception\TypeConverterException + */ + public function findTypeConverter(string $sourceType, string $targetType): TypeConverterInterface + { + $converter = null; + if (TypeHandlingUtility::isSimpleType($targetType)) { + if (isset($this->typeConverters[$sourceType][$targetType])) { + $converter = $this->findEligibleConverterWithHighestPriority($this->typeConverters[$sourceType][$targetType]); + } + } else { + $converter = $this->findFirstEligibleTypeConverterInObjectHierarchy($sourceType, $targetType); + } + + if ($converter === null) { + throw new Exception\TypeConverterException( + 'No converter found which can be used to convert from "' . $sourceType . '" to "' . $targetType . '".', + 1476044883 + ); + } + + return $converter; + } + + /** + * Tries to find a suitable type converter for the given source type and target type. + * + * @param string $sourceType Type of the source to convert from + * @param class-string $targetClass Name of the target class to find a type converter for + * + * + * @throws Exception\InvalidTargetException + * @throws Exception\DuplicateTypeConverterException + */ + protected function findFirstEligibleTypeConverterInObjectHierarchy(string $sourceType, string $targetClass): ?TypeConverterInterface + { + if (!class_exists($targetClass) && !interface_exists($targetClass)) { + throw new Exception\InvalidTargetException('Could not find a suitable type converter for "' . $targetClass . '" because no such class or interface exists.', 1297948764); + } + + if (!isset($this->typeConverters[$sourceType])) { + return null; + } + + $convertersForSource = $this->typeConverters[$sourceType]; + if (isset($convertersForSource[$targetClass])) { + $converter = $this->findEligibleConverterWithHighestPriority($convertersForSource[$targetClass]); + if ($converter !== null) { + return $converter; + } + } + + foreach (class_parents($targetClass) as $parentClass) { + if (!isset($convertersForSource[$parentClass])) { + continue; + } + + $converter = $this->findEligibleConverterWithHighestPriority($convertersForSource[$parentClass]); + if ($converter !== null) { + return $converter; + } + } + + $implementedInterface = class_implements($targetClass); + /** @var array $implementedInterface */ + $implementedInterface = $implementedInterface === false ? [] : $implementedInterface; + $implementedInterface = array_keys($implementedInterface); + + $converters = $this->getConvertersForInterfaces($convertersForSource, $implementedInterface); + $converter = $this->findEligibleConverterWithHighestPriority($converters); + + if ($converter !== null) { + return $converter; + } + if (isset($convertersForSource['object'])) { + return $this->findEligibleConverterWithHighestPriority($convertersForSource['object']); + } + return null; + } + + /** + * @param array $converters + */ + protected function findEligibleConverterWithHighestPriority(array $converters): ?TypeConverterInterface + { + if ($converters === []) { + return null; + } + + krsort($converters, SORT_NUMERIC); + reset($converters); + return current($converters); + } + + /** + * @param array> $convertersForSource + * @param class-string[] $interfaceNames + * + * @return TypeConverterInterface[] + * + * @throws Exception\DuplicateTypeConverterException + */ + protected function getConvertersForInterfaces(array $convertersForSource, array $interfaceNames): array + { + $convertersForInterface = []; + foreach ($interfaceNames as $implementedInterface) { + if (isset($convertersForSource[$implementedInterface])) { + foreach ($convertersForSource[$implementedInterface] as $priority => $converter) { + if (isset($convertersForInterface[$priority])) { + throw new Exception\DuplicateTypeConverterException( + sprintf( + 'There exist at least two converters which handle the conversion to an interface with priority "%d". %s and %s', + $priority, + get_class($convertersForInterface[$priority]), + get_class($converter) + ), + 1297951338 + ); + } + $convertersForInterface[$priority] = $converter; + } + } + } + return $convertersForInterface; + } +} diff --git a/Classes/Reflection/ClassSchema.php b/Classes/Reflection/ClassSchema.php new file mode 100644 index 0000000..d94926a --- /dev/null +++ b/Classes/Reflection/ClassSchema.php @@ -0,0 +1,396 @@ + + */ + private array $properties = []; + private array $methods = []; + private static ?PropertyInfoExtractor $propertyInfoExtractor = null; + + /** + * Constructs this class schema + * + * @param class-string $className Name of the class this schema is referring to + * @throws InvalidTypeHintException + * @throws InvalidValidationConfigurationException + * @throws \ReflectionException + */ + public function __construct(private readonly string $className) + { + $this->bitSet = new BitSet(); + + $reflectionClass = new \ReflectionClass($className); + + if ($reflectionClass->implementsInterface(ControllerInterface::class)) { + $this->bitSet->set(self::BIT_CLASS_IS_CONTROLLER); + } + + if (self::$propertyInfoExtractor === null) { + $docBlockFactory = DocBlockFactory::createInstance(); + $phpDocExtractor = new PhpDocExtractor($docBlockFactory); + + $reflectionExtractor = new ReflectionExtractor(); + + self::$propertyInfoExtractor = new PropertyInfoExtractor( + [], + [$phpDocExtractor, $reflectionExtractor] + ); + } + + $this->reflectProperties($reflectionClass); + $this->reflectMethods($reflectionClass); + } + + /** + * @throws \TYPO3\CMS\Extbase\Validation\Exception\NoSuchValidatorException + */ + protected function reflectProperties(\ReflectionClass $reflectionClass): void + { + foreach ($reflectionClass->getProperties() as $reflectionProperty) { + if ($reflectionProperty->isStatic()) { + continue; + } + + $propertyName = $reflectionProperty->getName(); + + $propertyCharacteristicsBit = 0; + $propertyCharacteristicsBit += $reflectionProperty->isPrivate() ? PropertyCharacteristics::VISIBILITY_PRIVATE : 0; + $propertyCharacteristicsBit += $reflectionProperty->isProtected() ? PropertyCharacteristics::VISIBILITY_PROTECTED : 0; + $propertyCharacteristicsBit += $reflectionProperty->isPublic() ? PropertyCharacteristics::VISIBILITY_PUBLIC : 0; + + $this->properties[$propertyName] = [ + 'c' => null, // cascade + 'f' => null, // file upload + 't' => null, // type + 'v' => [], // validators + ]; + + $validateAttributes = []; + $fileUploadAttributes = []; + foreach ($reflectionProperty->getAttributes() as $attribute) { + match ($attribute->getName()) { + Attribute\Validate::class => $validateAttributes[] = $attribute, + Attribute\FileUpload::class => $fileUploadAttributes[] = $attribute, + Attribute\ORM\Lazy::class => $propertyCharacteristicsBit += PropertyCharacteristics::ANNOTATED_LAZY, + Attribute\ORM\Transient::class => $propertyCharacteristicsBit += PropertyCharacteristics::ANNOTATED_TRANSIENT, + Attribute\ORM\Cascade::class => $this->properties[$propertyName]['c'] = $attribute->newInstance()->value, + default => '' // non-extbase attributes + }; + + if (is_a($attribute->getName(), Constraint::class, true)) { + $validateAttributes[] = $attribute; + } + } + foreach ($validateAttributes as $attribute) { + $validator = $attribute->newInstance(); + + if ($validator instanceof Constraint) { + $property = [ + 'constraint' => $validator, + 'className' => $validator::class, + ]; + } else { + $property = [ + 'name' => $validator->validator, + 'options' => $validator->options, + 'className' => ValidatorClassNameResolver::resolve($validator->validator), + ]; + } + + $this->properties[$propertyName]['v'][] = $property; + } + + foreach ($fileUploadAttributes as $attribute) { + $fileUpload = $attribute->newInstance(); + + $this->properties[$propertyName]['f'] = [ + 'validation' => $fileUpload->validation, + 'uploadFolder' => $fileUpload->uploadFolder, + 'addRandomSuffix' => $fileUpload->addRandomSuffix, + 'duplicationBehavior' => $fileUpload->duplicationBehavior, + 'createUploadFolderIfNotExist' => $fileUpload->createUploadFolderIfNotExist, + ]; + } + + $this->properties[$propertyName]['propertyCharacteristicsBit'] = $propertyCharacteristicsBit; + + $type = self::$propertyInfoExtractor->getType($this->className, $propertyName, ['reflectionProperty' => $reflectionProperty]); + if ($type !== null) { + $this->properties[$propertyName]['t'] = $type; + } + } + } + + /** + * @throws InvalidTypeHintException + * @throws InvalidValidationConfigurationException + * @throws \ReflectionException + * @throws \TYPO3\CMS\Extbase\Validation\Exception\NoSuchValidatorException + */ + protected function reflectMethods(\ReflectionClass $reflectionClass): void + { + foreach ($reflectionClass->getMethods() as $reflectionMethod) { + if ($reflectionMethod->isStatic()) { + continue; + } + + $methodName = $reflectionMethod->getName(); + + $this->methods[$methodName] = []; + $this->methods[$methodName]['private'] = $reflectionMethod->isPrivate(); + $this->methods[$methodName]['protected'] = $reflectionMethod->isProtected(); + $this->methods[$methodName]['public'] = $reflectionMethod->isPublic(); + $this->methods[$methodName]['params'] = []; + $isAction = $this->bitSet->get(self::BIT_CLASS_IS_CONTROLLER) && str_ends_with($methodName, 'Action'); + + /** @var array> $validateAttributes */ + $validateAttributes = []; + /** @var array> $validateAttributes */ + $ignoreValidationAttributes = []; + + foreach ($reflectionMethod->getParameters() as $reflectionParameter) { + $parameterName = $reflectionParameter->getName(); + $parameterAttributes = $reflectionParameter->getAttributes(); + + $validateAttributes[$parameterName] ??= []; + $ignoreValidationAttributes[$parameterName] ??= []; + + if ($isAction) { + foreach ($parameterAttributes as $parameterAttribute) { + match ($parameterAttribute->getName()) { + Attribute\Validate::class => $validateAttributes[$parameterName][] = $parameterAttribute->newInstance(), + Attribute\IgnoreValidation::class => $ignoreValidationAttributes[$parameterName][] = $parameterAttribute->newInstance(), + default => '' // non-extbase attributes + }; + } + } + + $reflectionType = $reflectionParameter->getType(); + + $this->methods[$methodName]['params'][$parameterName] = []; + $this->methods[$methodName]['params'][$parameterName]['array'] = false; // compat + $this->methods[$methodName]['params'][$parameterName]['optional'] = $reflectionParameter->isOptional(); + $this->methods[$methodName]['params'][$parameterName]['allowsNull'] = $reflectionParameter->allowsNull(); + $this->methods[$methodName]['params'][$parameterName]['type'] = null; + $this->methods[$methodName]['params'][$parameterName]['hasDefaultValue'] = $reflectionParameter->isDefaultValueAvailable(); + $this->methods[$methodName]['params'][$parameterName]['defaultValue'] = null; + $this->methods[$methodName]['params'][$parameterName]['ignoreValidation'] = $ignoreValidationAttributes[$parameterName] !== []; + $this->methods[$methodName]['params'][$parameterName]['validators'] = []; + + if ($reflectionParameter->isDefaultValueAvailable()) { + $this->methods[$methodName]['params'][$parameterName]['defaultValue'] = $reflectionParameter->getDefaultValue(); + } + + // A ReflectionNamedType means "there is a type specified, and it's not a union type." + // (Union types are not handled, currently.) + if ($reflectionType instanceof \ReflectionNamedType) { + $this->methods[$methodName]['params'][$parameterName]['allowsNull'] = $reflectionType->allowsNull(); + // A built-in type effectively means "not a class". + if ($reflectionType->isBuiltin()) { + $this->methods[$methodName]['params'][$parameterName]['type'] = ltrim($reflectionType->getName(), '\\'); + } elseif ($reflectionType->getName() === 'self') { + // In addition, self cannot be resolved by "new \ReflectionClass('self')", + // so treat this as a reference to the current class + $this->methods[$methodName]['params'][$parameterName]['type'] = ltrim($reflectionClass->getName(), '\\'); + } else { + // This is mainly to confirm that the class exists. If it doesn't, a ReflectionException + // will be thrown. It's not the ideal way of doing so, but it maintains the existing API + // so that the exception can get caught and recast to a TYPO3-specific exception. + /** @var class-string $classname */ + $classname = $reflectionType->getName(); + + // Test if the class can be reflected + /** @noinspection PhpUnusedLocalVariableInspection */ + $reflection = new \ReflectionClass($classname); + // There's a single type declaration that is a class. + $this->methods[$methodName]['params'][$parameterName]['type'] = $reflectionType->getName(); + } + } + + // Extbase Validation + if ($validateAttributes[$parameterName] !== []) { + if ($this->methods[$methodName]['params'][$parameterName]['type'] === null) { + throw new InvalidTypeHintException( + 'Missing type information for parameter "$' . $parameterName . '" in ' . $this->className . '->' . $methodName . '(): Use a type hint.', + 1515075192 + ); + } + + $this->methods[$methodName]['params'][$parameterName]['validators'] = array_map( + static fn(Attribute\Validate $validator) => [ + 'name' => $validator->validator, + 'options' => $validator->options, + 'className' => ValidatorClassNameResolver::resolve($validator->validator), + ], + $validateAttributes[$parameterName], + ); + unset($validateAttributes[$parameterName]); + } + } + + // Extbase Validation + foreach ($validateAttributes as $parameterName => $validators) { + if ($validators !== []) { + $validatorNames = array_map( + static fn(Attribute\Validate $validate) => $validate->validator, + $validators, + ); + + throw new InvalidValidationConfigurationException( + 'Invalid #[Validate] attribute in ' . $this->className . '->' . $methodName . '(): The following validators have been defined for missing param "$' . $parameterName . '": ' . implode(', ', $validatorNames), + 1515073585 + ); + } + } + } + } + + /** + * @throws NoSuchPropertyException + */ + public function getProperty(string $propertyName): Property + { + $properties = $this->buildPropertyObjects(); + + if (!isset($properties[$propertyName])) { + throw NoSuchPropertyException::create($this->className, $propertyName); + } + + return $properties[$propertyName]; + } + + /** + * @return array|Property[] + */ + public function getProperties(): array + { + return $this->buildPropertyObjects(); + } + + /** + * Returns all properties that do not start with an underscore like $_localizedUid + * + * @return Property[] + * @internal + */ + public function getDomainObjectProperties(): array + { + return array_filter( + $this->getProperties(), + static fn(Property $property): bool => !str_starts_with($property->getName(), '_') + ); + } + + /** + * If the class schema has a certain property. + * + * @param string $propertyName Name of the property + */ + public function hasProperty(string $propertyName): bool + { + return array_key_exists($propertyName, $this->properties); + } + + /** + * @throws NoSuchMethodException + */ + public function getMethod(string $methodName): Method + { + $methods = $this->buildMethodObjects(); + + if (!isset($methods[$methodName])) { + throw NoSuchMethodException::create($this->className, $methodName); + } + + return $methods[$methodName]; + } + + /** + * @return array|Method[] + */ + public function getMethods(): array + { + return $this->buildMethodObjects(); + } + + public function hasMethod(string $methodName): bool + { + return isset($this->methods[$methodName]); + } + + /** + * @return array|Property[] + */ + private function buildPropertyObjects(): array + { + if (!isset(self::$propertyObjects[$this->className])) { + self::$propertyObjects[$this->className] = []; + foreach ($this->properties as $propertyName => $propertyDefinition) { + self::$propertyObjects[$this->className][$propertyName] = new Property($propertyName, $propertyDefinition); + } + } + + return self::$propertyObjects[$this->className]; + } + + /** + * @return array|Method[] + */ + private function buildMethodObjects(): array + { + if (!isset(self::$methodObjects[$this->className])) { + self::$methodObjects[$this->className] = []; + foreach ($this->methods as $methodName => $methodDefinition) { + self::$methodObjects[$this->className][$methodName] = new Method($methodName, $methodDefinition, $this->className); + } + } + + return self::$methodObjects[$this->className]; + } +} diff --git a/Classes/Reflection/ClassSchema/Exception/NoPropertyTypesException.php b/Classes/Reflection/ClassSchema/Exception/NoPropertyTypesException.php new file mode 100644 index 0000000..5b6a377 --- /dev/null +++ b/Classes/Reflection/ClassSchema/Exception/NoPropertyTypesException.php @@ -0,0 +1,32 @@ + [], + 'public' => false, + 'protected' => false, + 'private' => false, + ]; + + foreach ($defaults as $key => $defaultValue) { + if (!isset($definition[$key])) { + $definition[$key] = $defaultValue; + } + } + + $this->definition = $definition; + + foreach ($this->definition['params'] as $parameterName => $parameterDefinition) { + $this->parameters[$parameterName] = new MethodParameter($parameterName, $parameterDefinition); + } + } + + public function getName(): string + { + return $this->name; + } + + /** + * @return array|MethodParameter[] + */ + public function getParameters(): array + { + return $this->parameters; + } + + /** + * @throws NoSuchMethodParameterException + */ + public function getParameter(string $parameterName): MethodParameter + { + if (!isset($this->parameters[$parameterName])) { + throw NoSuchMethodParameterException::createForParameterName( + $this->className, + $this->name, + $parameterName + ); + } + + return $this->parameters[$parameterName]; + } + + public function isPublic(): bool + { + return $this->definition['public']; + } + + public function isProtected(): bool + { + return $this->definition['protected']; + } + + public function isPrivate(): bool + { + return $this->definition['private']; + } +} diff --git a/Classes/Reflection/ClassSchema/MethodParameter.php b/Classes/Reflection/ClassSchema/MethodParameter.php new file mode 100644 index 0000000..b2ccc12 --- /dev/null +++ b/Classes/Reflection/ClassSchema/MethodParameter.php @@ -0,0 +1,92 @@ + null, + 'array' => false, + 'optional' => false, + 'hasDefaultValue' => false, + 'defaultValue' => null, + 'ignoreValidation' => false, + 'validators' => [], + ]; + + foreach ($defaults as $key => $defaultValue) { + if (!isset($definition[$key])) { + $definition[$key] = $defaultValue; + } + } + + $this->definition = $definition; + } + + public function getName(): string + { + return $this->name; + } + + public function getType(): ?string + { + return $this->definition['type']; + } + + public function isArray(): bool + { + return $this->definition['array']; + } + + public function hasDefaultValue(): bool + { + return $this->definition['hasDefaultValue']; + } + + /** + * @return mixed + */ + public function getDefaultValue() + { + return $this->definition['defaultValue']; + } + + public function getValidators(): array + { + return $this->definition['validators']; + } + + public function ignoreValidation(): bool + { + return $this->definition['ignoreValidation']; + } + + public function isOptional(): bool + { + return $this->definition['optional']; + } +} diff --git a/Classes/Reflection/ClassSchema/Property.php b/Classes/Reflection/ClassSchema/Property.php new file mode 100644 index 0000000..6388988 --- /dev/null +++ b/Classes/Reflection/ClassSchema/Property.php @@ -0,0 +1,228 @@ +, + * 't': null|Type, + * 'v': list, + * 'propertyCharacteristicsBit'?: int + * } + * @internal only to be used within Extbase, not part of TYPO3 Core API. + */ +class Property +{ + /** + * @var PropertyDefinitionSpec + */ + private array $definition; + private PropertyCharacteristics $characteristics; + + /** + * @param PropertyDefinitionSpec $definition + */ + public function __construct( + private readonly string $name, + array $definition + ) { + $this->characteristics = new PropertyCharacteristics($definition['propertyCharacteristicsBit']); + unset($definition['propertyCharacteristicsBit']); + + $defaults = [ + 'c' => null, // cascade + 'f' => null, // file upload + 't' => null, // type + 'v' => [], // validators + ]; + + foreach ($defaults as $key => $defaultValue) { + if (!isset($definition[$key])) { + $definition[$key] = $defaultValue; + } + } + + $this->definition = $definition; + } + + public function getName(): string + { + return $this->name; + } + + /** + * Returns the types (string, integer, ...) set by the `@var` doc comment and php property type declarations + * + * Returns empty array if types could not be evaluated + * + * @return list + */ + public function getTypes(): array + { + $type = $this->getType(); + if ($type === null) { + return []; + } + if ($type instanceof BuiltinType && $type->getTypeIdentifier() === TypeIdentifier::MIXED) { + return []; + } + // NullableType extends UnionType, check first + if ($type instanceof NullableType) { + $inner = $type->getWrappedType(); + if ($inner instanceof UnionType) { + return array_map( + static fn(Type $t) => new TypeAdapter($t, forceNullable: true), + $inner->getTypes() + ); + } + if ($inner instanceof IntersectionType) { + return array_map( + static fn(Type $t) => new TypeAdapter($t, forceNullable: true), + $inner->getTypes() + ); + } + return [new TypeAdapter($inner, forceNullable: true)]; + } + if ($type instanceof UnionType) { + $members = array_filter( + $type->getTypes(), + static fn(Type $t) => !($t instanceof BuiltinType && $t->getTypeIdentifier() === TypeIdentifier::NULL) + ); + return array_values(array_map( + static fn(Type $t) => new TypeAdapter($t), + $members + )); + } + if ($type instanceof IntersectionType) { + return array_map( + static fn(Type $t) => new TypeAdapter($t), + $type->getTypes() + ); + } + return [new TypeAdapter($type)]; + } + + /** + * Gets the native `symfony/type-info` type. + */ + public function getType(): ?Type + { + return $this->definition['t']; + } + + /** + * Returns the primary type found in a list of types except LazyLoadingProxy + */ + public function getPrimaryType(): ?TypeAdapter + { + $types = $this->getTypes(); + $filtered = array_values(array_filter( + $types, + static fn(TypeAdapter $t) => $t->getClassName() !== LazyLoadingProxy::class + )); + return $filtered[0] ?? null; + } + + public function getPrimaryCollectionValueType(): ?TypeAdapter + { + $primaryType = $this->getPrimaryType(); + if ($primaryType === null || !$primaryType->isCollection()) { + return null; + } + return $primaryType->getCollectionValueTypes()[0] ?? null; + } + + /** + * @return list + */ + public function getFilteredTypes(callable $callback): array + { + return array_values(array_filter($this->getTypes(), $callback)); + } + + public function filterLazyLoadingProxyAndLazyObjectStorage(TypeAdapter $type): bool + { + return !in_array((string)$type->getClassName(), [LazyLoadingProxy::class, LazyObjectStorage::class], true); + } + + public function isObjectStorageType(): bool + { + $filteredTypes = $this->getFilteredTypes( + static fn(TypeAdapter $type) => in_array((string)$type->getClassName(), [ObjectStorage::class, LazyObjectStorage::class], true) + ); + + return $filteredTypes !== []; + } + + public function isPublic(): bool + { + return $this->characteristics->get(PropertyCharacteristics::VISIBILITY_PUBLIC); + } + + public function isProtected(): bool + { + return $this->characteristics->get(PropertyCharacteristics::VISIBILITY_PROTECTED); + } + + public function isPrivate(): bool + { + return $this->characteristics->get(PropertyCharacteristics::VISIBILITY_PRIVATE); + } + + public function isLazy(): bool + { + return $this->characteristics->get(PropertyCharacteristics::ANNOTATED_LAZY); + } + + public function isTransient(): bool + { + return $this->characteristics->get(PropertyCharacteristics::ANNOTATED_TRANSIENT); + } + + public function isNullable(): bool + { + $primaryType = $this->getPrimaryType(); + return $primaryType === null || $primaryType->isNullable(); + } + + public function getValidators(): array + { + return $this->definition['v']; + } + + public function getFileUpload(): ?array + { + return $this->definition['f']; + } + + public function getCascadeValue(): ?string + { + return $this->definition['c']; + } +} diff --git a/Classes/Reflection/ClassSchema/PropertyCharacteristics.php b/Classes/Reflection/ClassSchema/PropertyCharacteristics.php new file mode 100644 index 0000000..e1f4010 --- /dev/null +++ b/Classes/Reflection/ClassSchema/PropertyCharacteristics.php @@ -0,0 +1,32 @@ +resolveBuiltinType($this->type); + } + + public function getClassName(): ?string + { + return $this->resolveClassName($this->type); + } + + public function isCollection(): bool + { + $type = $this->type instanceof NullableType ? $this->type->getWrappedType() : $this->type; + return $type instanceof CollectionType; + } + + /** + * @return list + */ + public function getCollectionKeyTypes(): array + { + $type = $this->type instanceof NullableType ? $this->type->getWrappedType() : $this->type; + if ($type instanceof CollectionType) { + return $this->decomposeType($type->getCollectionKeyType()); + } + return []; + } + + /** + * @return list + */ + public function getCollectionValueTypes(): array + { + $type = $this->type instanceof NullableType ? $this->type->getWrappedType() : $this->type; + if ($type instanceof CollectionType) { + return $this->decomposeType($type->getCollectionValueType()); + } + return []; + } + + public function isNullable(): bool + { + return $this->forceNullable || $this->type->isNullable(); + } + + /** + * @return list + */ + private function decomposeType(Type $type): array + { + if ($type instanceof UnionType) { + return array_map( + static fn(Type $t) => new self($t), + $type->getTypes() + ); + } + return [new self($type)]; + } + + private function resolveBuiltinType(Type $type): string + { + if ($type instanceof BuiltinType) { + return $type->getTypeIdentifier()->value; + } + if ($type instanceof ObjectType) { + return 'object'; + } + if ($type instanceof WrappingTypeInterface) { + return $this->resolveBuiltinType($type->getWrappedType()); + } + return 'object'; + } + + private function resolveClassName(Type $type): ?string + { + if ($type instanceof ObjectType) { + return $type->getClassName(); + } + if ($type instanceof WrappingTypeInterface) { + return $this->resolveClassName($type->getWrappedType()); + } + return null; + } +} diff --git a/Classes/Reflection/Exception.php b/Classes/Reflection/Exception.php new file mode 100644 index 0000000..21c39f1 --- /dev/null +++ b/Classes/Reflection/Exception.php @@ -0,0 +1,25 @@ +isReadable($subject, $propertyPath)) { + return $accessor->getValue($subject, $propertyPath); + } + + // Use array style property path for instances of \ArrayAccess + // https://symfony.com/doc/current/components/property_access.html#reading-from-arrays + + $propertyPath = self::convertToArrayPropertyPath($propertyPath); + } + + if (is_object($subject)) { + return self::getObjectPropertyValue($subject, $propertyPath); + } + + try { + return self::getArrayIndexValue($subject, self::convertToArrayPropertyPath($propertyPath)); + } catch (NoSuchIndexException) { + return null; + } + } + + /** + * Gets a property path from a given object or array. + * + * If propertyPath is "bla.blubb", then we first call getProperty($object, 'bla'), + * and on the resulting object we call getProperty(..., 'blubb') + * + * For arrays the keys are checked likewise. + * + * @param object|array $subject Object or array to get the property path from + * @param string $propertyPath + * + * @return mixed Value of the property + */ + public static function getPropertyPath(object|array $subject, string $propertyPath): mixed + { + try { + foreach (new PropertyPath($propertyPath) as $pathSegment) { + $subject = self::getPropertyInternal($subject, $pathSegment); + } + } catch (\TypeError|PropertyNotAccessibleException) { + return null; + } + return $subject; + } + + /** + * Set a property for a given object. + * Tries to set the property the following ways: + * - if target is an array, set value + * - if super cow powers should be used, set value through reflection + * - if public setter method exists, call it. + * - if public property exists, set it directly. + * - if the target object is an instance of ArrayAccess, it sets the property + * on it without checking if it existed. + * - else, return FALSE + * + * @param object|array $subject The target object or array + * @param string $propertyName Name of the property to set + * @param mixed $propertyValue Value of the property + * + * @throws \InvalidArgumentException in case $object was not an object or $propertyName was not a string + * @return bool TRUE if the property could be set, FALSE otherwise + */ + public static function setProperty(object|array &$subject, string $propertyName, mixed $propertyValue): bool + { + if (is_array($subject) || $subject instanceof \ArrayAccess) { + $subject[$propertyName] = $propertyValue; + return true; + } + + $accessor = self::createAccessor(); + if ($accessor->isWritable($subject, $propertyName)) { + $accessor->setValue($subject, $propertyName, $propertyValue); + return true; + } + return false; + } + + /** + * Returns an array of properties which can be get with the getProperty() + * method. + * Includes the following properties: + * - which can be get through a public getter method. + * - public properties which can be directly get. + * + * @param object $object Object to receive property names for + * + * @return list Array of all gettable property names + * @throws Exception\UnknownClassException + */ + public static function getGettablePropertyNames(object $object): array + { + if ($object instanceof \stdClass) { + $properties = array_keys((array)$object); + sort($properties); + return $properties; + } + + $classSchema = GeneralUtility::makeInstance(ReflectionService::class) + ->getClassSchema($object); + + $accessiblePropertyNames = []; + foreach ($classSchema->getProperties() as $propertyName => $propertyDefinition) { + if ($propertyDefinition->isPublic()) { + $accessiblePropertyNames[] = $propertyName; + continue; + } + + $accessors = [ + 'get' . ucfirst($propertyName), + 'has' . ucfirst($propertyName), + 'is' . ucfirst($propertyName), + ]; + + foreach ($accessors as $accessor) { + if (!$classSchema->hasMethod($accessor)) { + continue; + } + + if (!$classSchema->getMethod($accessor)->isPublic()) { + continue; + } + + foreach ($classSchema->getMethod($accessor)->getParameters() as $methodParam) { + if (!$methodParam->isOptional()) { + continue 2; + } + } + + if (!is_callable([$object, $accessor])) { + continue; + } + + $accessiblePropertyNames[] = $propertyName; + } + } + + // Fallback mechanism to not break former behaviour + // + // todo: Checking accessor methods of virtual(non-existing) properties should be removed (breaking) in + // upcoming versions. It was an unintentionally added "feature" in the past. It contradicts the method + // name "getGettablePropertyNames". + foreach ($classSchema->getMethods() as $methodName => $methodDefinition) { + $propertyName = null; + if (str_starts_with($methodName, 'get') || str_starts_with($methodName, 'has')) { + $propertyName = lcfirst(substr($methodName, 3)); + } + + if (str_starts_with($methodName, 'is')) { + $propertyName = lcfirst(substr($methodName, 2)); + } + + if ($propertyName === null) { + continue; + } + + if (!$methodDefinition->isPublic()) { + continue; + } + + foreach ($methodDefinition->getParameters() as $methodParam) { + if (!$methodParam->isOptional()) { + continue 2; + } + } + + $accessiblePropertyNames[] = $propertyName; + } + + $accessiblePropertyNames = array_unique($accessiblePropertyNames); + sort($accessiblePropertyNames); + return $accessiblePropertyNames; + } + + /** + * Returns an array of properties which can be set with the setProperty() + * method. + * Includes the following properties: + * - which can be set through a public setter method. + * - public properties which can be directly set. + * + * @param object $object Object to receive property names for + * + * @throws \InvalidArgumentException + * @return list Array of all settable property names + */ + public static function getSettablePropertyNames(object $object): array + { + $accessor = self::createAccessor(); + + if ($object instanceof \stdClass || $object instanceof \ArrayAccess) { + $propertyNames = array_keys((array)$object); + } else { + $classSchema = GeneralUtility::makeInstance(ReflectionService::class)->getClassSchema($object); + + $propertyNames = array_filter( + array_keys($classSchema->getProperties()), + static fn(string $propertyName): bool => $accessor->isWritable($object, $propertyName) + ); + + $setters = array_filter( + array_keys($classSchema->getMethods()), + static fn(string $methodName): bool => str_starts_with($methodName, 'set') && is_callable([$object, $methodName]) + ); + + foreach ($setters as $setter) { + $propertyNames[] = lcfirst(substr($setter, 3)); + } + } + + $propertyNames = array_unique($propertyNames); + sort($propertyNames); + return $propertyNames; + } + + /** + * Tells if the value of the specified property can be set by this Object Accessor. + * + * @param object $object Object containing the property + * @param string $propertyName Name of the property to check + */ + public static function isPropertySettable(object $object, string $propertyName): bool + { + if ($object instanceof \stdClass && array_key_exists($propertyName, get_object_vars($object))) { + return true; + } + if (array_key_exists($propertyName, get_class_vars(get_class($object)))) { + return true; + } + return is_callable([$object, 'set' . ucfirst($propertyName)]); + } + + /** + * Tells if the value of the specified property can be retrieved by this Object Accessor. + * + * @param object|array $object Object containing the property + * @param string $propertyName Name of the property to check + * + * @throws \InvalidArgumentException + */ + public static function isPropertyGettable(object|array $object, string $propertyName): bool + { + if (is_array($object) || ($object instanceof \ArrayAccess && $object->offsetExists($propertyName))) { + $propertyName = self::wrap($propertyName); + } + + return self::createAccessor()->isReadable($object, $propertyName); + } + + /** + * Get all properties (names and their current values) of the current + * $object that are accessible through this class. + * + * @param object $object Object to get all properties from. + * + * @throws \InvalidArgumentException + * @return array Associative array of all properties. + * @todo What to do with ArrayAccess + */ + public static function getGettableProperties(object $object): array + { + $properties = []; + foreach (self::getGettablePropertyNames($object) as $propertyName) { + $properties[$propertyName] = self::getPropertyInternal($object, $propertyName); + } + return $properties; + } + + private static function createAccessor(): PropertyAccessorInterface + { + if (self::$propertyAccessor === null) { + self::$propertyAccessor = PropertyAccess::createPropertyAccessorBuilder() + ->enableExceptionOnInvalidIndex() + ->getPropertyAccessor(); + } + + return self::$propertyAccessor; + } + + /** + * @throws Exception\PropertyNotAccessibleException + */ + private static function getObjectPropertyValue(object $subject, PropertyPath $propertyPath): mixed + { + $accessor = self::createAccessor(); + + if ($accessor->isReadable($subject, $propertyPath)) { + return $accessor->getValue($subject, $propertyPath); + } + + throw new PropertyNotAccessibleException('The property "' . (string)$propertyPath . '" on the subject does not exist.', 1476109666); + } + + private static function getArrayIndexValue(array $subject, PropertyPath $propertyPath): mixed + { + return self::createAccessor()->getValue($subject, $propertyPath); + } + + private static function convertToArrayPropertyPath(PropertyPath $propertyPath): PropertyPath + { + $segments = array_map(static fn(string $segment): string => self::wrap($segment), $propertyPath->getElements()); + + return new PropertyPath(implode('.', $segments)); + } + + private static function wrap(string $segment): string + { + return '[' . $segment . ']'; + } +} diff --git a/Classes/Reflection/ReflectionService.php b/Classes/Reflection/ReflectionService.php new file mode 100644 index 0000000..9956e88 --- /dev/null +++ b/Classes/Reflection/ReflectionService.php @@ -0,0 +1,126 @@ +dataCache->get($this->cacheIdentifier)) !== false) { + $this->classSchemata = $classSchemata; + } + } + + public function __destruct() + { + // The cache write may serialize with an HMAC based on the encryption key. The destructor + // may run late (during shutdown or garbage collection) when the global configuration + // has already been reset - persisting is impossible then and must be skipped, since + // emitted warnings could not be caught by any error handler at that point anymore. + // This extra condition is to ensure a running TYPO3 "bootstrapped" environment, which + // may not be available within the functional test environments, and would then be unable + // to access the cache backend properly (relies on SYS.encryptionKey for example) + // @todo - This must go away, once GLOBAL state vanishes completely, of course + if ($this->dataCacheNeedsUpdate && isset($GLOBALS['TYPO3_CONF_VARS'])) { + $this->dataCache->set($this->cacheIdentifier, $this->classSchemata); + } + } + + /** + * Returns the class schema for the given class + * + * @param mixed $classNameOrObject The class name or an object + * @throws \TYPO3\CMS\Extbase\Reflection\Exception\UnknownClassException + */ + public function getClassSchema($classNameOrObject): ClassSchema + { + $className = is_object($classNameOrObject) ? get_class($classNameOrObject) : $classNameOrObject; + if (isset($this->classSchemata[$className])) { + return $this->classSchemata[$className]; + } + + return $this->buildClassSchema($className); + } + + /** + * Builds class schemata from classes annotated as entities or value objects + * + * @param string $className + * @throws Exception\UnknownClassException + * @return ClassSchema The class schema + */ + protected function buildClassSchema($className): ClassSchema + { + try { + $classSchema = new ClassSchema($className); + } catch (\ReflectionException $e) { + throw new UnknownClassException($e->getMessage() . '. Reflection failed.', 1278450972, $e); + } + $this->classSchemata[$className] = $classSchema; + $this->dataCacheNeedsUpdate = true; + return $classSchema; + } + + /** + * @internal + */ + public function __sleep(): array + { + return []; + } + + /** + * @internal + */ + public function __wakeup(): void + { + $this->dataCache = new NullFrontend('extbase'); + $this->dataCacheNeedsUpdate = false; + $this->cacheIdentifier = ''; + $this->classSchemata = []; + } +} diff --git a/Classes/Routing/ExtbasePluginEnhancer.php b/Classes/Routing/ExtbasePluginEnhancer.php new file mode 100644 index 0000000..63b3425 --- /dev/null +++ b/Classes/Routing/ExtbasePluginEnhancer.php @@ -0,0 +1,243 @@ +routesOfPlugin = $this->configuration['routes'] ?? []; + // Only set the namespace if the plugin+extension keys are given. This allows to also use "namespace" property + // instead from the parent constructor. + if ( + $this->namespace === '' + && isset($this->configuration['extension']) + && isset($this->configuration['plugin']) + ) { + $extensionName = $this->configuration['extension']; + $pluginName = $this->configuration['plugin']; + $extensionName = str_replace(' ', '', ucwords(str_replace('_', ' ', $extensionName))); + $pluginSignature = strtolower($extensionName . '_' . $pluginName); + $this->namespace = 'tx_' . $pluginSignature; + } + return; + } + + /** + * {@inheritdoc} + */ + public function enhanceForMatching(RouteCollection $collection): void + { + $i = 0; + /** @var Route $defaultPageRoute */ + $defaultPageRoute = $collection->get('default'); + foreach ($this->routesOfPlugin as $configuration) { + $route = $this->getVariant($defaultPageRoute, $configuration); + $collection->add($this->namespace . '_' . $i++, $route); + } + } + + /** + * {@inheritdoc} + */ + protected function getVariant(Route $defaultPageRoute, array $configuration): Route + { + $arguments = $configuration['_arguments'] ?? []; + unset($configuration['_arguments']); + + $variableProcessor = $this->getVariableProcessor(); + $routePath = $this->modifyRoutePath($configuration['routePath']); + $routePath = $variableProcessor->deflateRoutePath($routePath, $this->namespace, $arguments); + unset($configuration['routePath']); + $options = array_merge($defaultPageRoute->getOptions(), ['_enhancer' => $this, 'utf8' => true, '_arguments' => $arguments]); + $route = new Route(rtrim($defaultPageRoute->getPath(), '/') . '/' . ltrim($routePath, '/'), [], [], $options); + + $defaults = array_merge_recursive( + $defaultPageRoute->getDefaults(), + $variableProcessor->deflateKeys($this->configuration['defaults'] ?? [], $this->namespace, $arguments) + ); + // only keep `defaults` that are actually used in `routePath` + $defaults = $this->filterValuesByPathVariables( + $route, + $defaults + ); + // apply '_controller' to route defaults + $defaults = array_merge_recursive( + $defaults, + array_intersect_key($configuration, ['_controller' => true]) + ); + $route->setDefaults($defaults); + $this->applyRouteAspects($route, $this->aspects, $this->namespace); + $this->applyRequirements($route, $this->configuration['requirements'] ?? [], $this->namespace); + $this->applyStaticVariables($route, $this->configuration['static'] ?? [], $this->namespace); + return $route; + } + + /** + * {@inheritdoc} + */ + public function enhanceForGeneration(RouteCollection $collection, array $originalParameters): void + { + if (!is_array($originalParameters[$this->namespace] ?? null)) { + return; + } + // apply default controller and action names if not set in parameters + if (!$this->hasControllerActionValues($originalParameters[$this->namespace]) + && !empty($this->configuration['defaultController']) + ) { + $this->applyControllerActionValues( + $this->configuration['defaultController'], + $originalParameters[$this->namespace], + true + ); + } + + $i = 0; + /** @var Route $defaultPageRoute */ + $defaultPageRoute = $collection->get('default'); + foreach ($this->routesOfPlugin as $configuration) { + $variant = $this->getVariant($defaultPageRoute, $configuration); + // The enhancer tells us: This given route does not match the parameters + if (!$this->verifyRequiredParameters($variant, $originalParameters)) { + continue; + } + $parameters = $originalParameters; + unset($parameters[$this->namespace]['action']); + unset($parameters[$this->namespace]['controller']); + $compiledRoute = $variant->compile(); + // contains all given parameters, even if not used as variables in route + $deflatedParameters = $this->deflateParameters($variant, $parameters); + $variables = array_flip($compiledRoute->getPathVariables()); + $mergedParams = array_replace($variant->getDefaults(), $deflatedParameters); + // all params must be given, otherwise we exclude this variant + // (it is allowed that $variables is empty - in this case variables are + // "given" implicitly through controller-action pair in `_controller`) + if (array_diff_key($variables, $mergedParams)) { + continue; + } + $variant->addOptions(['deflatedParameters' => $deflatedParameters]); + $collection->add($this->namespace . '_' . $i++, $variant); + } + } + + /** + * A route has matched the controller/action combination, so ensure that these properties + * are set to tx_blogexample_pi1[controller] and tx_blogexample_pi1[action]. + * + * @param array $parameters Actual parameter payload to be used + * @param array $internals Internal instructions (_route, _controller, ...) + */ + public function inflateParameters(array $parameters, array $internals = []): array + { + $parameters = $this->getVariableProcessor() + ->inflateNamespaceParameters($parameters, $this->namespace); + $parameters[$this->namespace] = $parameters[$this->namespace] ?? []; + + // Invalid if there is no controller given, so this enhancers does not do anything + if (empty($internals['_controller'] ?? null)) { + return $parameters; + } + $this->applyControllerActionValues( + $internals['_controller'], + $parameters[$this->namespace], + false + ); + return $parameters; + } + + /** + * Check if controller+action combination matches + */ + protected function verifyRequiredParameters(Route $route, array $parameters): bool + { + if (!is_array($parameters[$this->namespace])) { + return false; + } + if (!$route->hasDefault('_controller')) { + return false; + } + $controller = $route->getDefault('_controller'); + [$controllerName, $actionName] = explode('::', $controller); + if (!isset($parameters[$this->namespace]['controller']) || $controllerName !== $parameters[$this->namespace]['controller']) { + return false; + } + if (!isset($parameters[$this->namespace]['action']) || $actionName !== $parameters[$this->namespace]['action']) { + return false; + } + return true; + } + /** + * Check if action and controller are not empty. + */ + protected function hasControllerActionValues(array $target): bool + { + return !empty($target['controller']) && !empty($target['action']); + } + + /** + * Add controller and action parameters so they can be used later-on. + * + * @param array $target Reference to target array to be modified + * @param bool $tryUpdate Try updating action value - but only if controller value matches + */ + protected function applyControllerActionValues(string $controllerActionValue, array &$target, bool $tryUpdate = false) + { + if (!str_contains($controllerActionValue, '::')) { + return; + } + [$controllerName, $actionName] = explode('::', $controllerActionValue, 2); + // use default action name if controller matches + if ($tryUpdate && empty($target['action']) && $controllerName === ($target['controller'] ?? null)) { + $target['action'] = $actionName; + // use default controller name if action is defined (implies: non-default-controllers must be given) + } elseif ($tryUpdate && empty($target['controller']) && !empty($target['action'])) { + $target['controller'] = $controllerName; + // fallback and override + } else { + $target['controller'] = $controllerName; + $target['action'] = $actionName; + } + } +} diff --git a/Classes/Security/Exception.php b/Classes/Security/Exception.php new file mode 100644 index 0000000..46852c8 --- /dev/null +++ b/Classes/Security/Exception.php @@ -0,0 +1,22 @@ +value; + } +} diff --git a/Classes/Service/ActionAuthorizationService.php b/Classes/Service/ActionAuthorizationService.php new file mode 100644 index 0000000..fae4007 --- /dev/null +++ b/Classes/Service/ActionAuthorizationService.php @@ -0,0 +1,156 @@ + $authorizeAttributes + */ + public function checkAuthorization( + ActionController $controller, + array $authorizeAttributes, + array $preparedArguments + ): AuthorizationResult { + if ($authorizeAttributes === []) { + return AuthorizationResult::allowed(); + } + + foreach ($authorizeAttributes as $authorize) { + $result = $this->evaluateAuthorizeAttribute($authorize, $controller, $preparedArguments); + if ($result->isDenied()) { + return $result; + } + } + + return AuthorizationResult::allowed(); + } + + protected function evaluateAuthorizeAttribute( + Authorize $authorize, + ActionController $controller, + array $preparedArguments + ): AuthorizationResult { + $userAspect = $this->context->getAspect('frontend.user'); + + if ($authorize->requireLogin && !$userAspect->isLoggedIn()) { + return AuthorizationResult::denied(AuthorizationFailureReason::NOT_LOGGED_IN, $authorize); + } + + if (!$this->checkGroupAccess($authorize, $userAspect)) { + return AuthorizationResult::denied(AuthorizationFailureReason::MISSING_GROUP, $authorize); + } + + if ($authorize->callback !== null && !$this->executeCallback($authorize, $controller, $preparedArguments)) { + return AuthorizationResult::denied(AuthorizationFailureReason::CALLBACK_DENIED, $authorize); + } + + return AuthorizationResult::allowed(); + } + + protected function checkGroupAccess(Authorize $authorize, object $userAspect): bool + { + if (empty($authorize->requireGroups)) { + return true; + } + + $userGroupIds = $userAspect->getGroupIds(); + $userGroupNames = $userAspect->getGroupNames(); + + foreach ($authorize->requireGroups as $requiredGroup) { + if (is_numeric($requiredGroup) && in_array((int)$requiredGroup, $userGroupIds, true)) { + return true; + } + if (!is_numeric($requiredGroup) && in_array($requiredGroup, $userGroupNames, true)) { + return true; + } + } + + return false; + } + + protected function executeCallback( + Authorize $authorize, + ActionController $controller, + array $preparedArguments + ): bool { + if (is_array($authorize->callback)) { + return $this->executeClassCallback($authorize->callback, $preparedArguments); + } + return $this->executeControllerCallback($controller, $authorize->callback, $preparedArguments); + } + + protected function executeClassCallback(array $callback, array $arguments): bool + { + [$className, $methodName] = $callback; + + $instance = $this->getCallbackInstance($className); + $this->validateCallbackMethod($instance, $methodName, $className); + + return (bool)$instance->$methodName(...$arguments); + } + + protected function executeControllerCallback(ActionController $controller, string $methodName, array $arguments): bool + { + $this->validateCallbackMethod($controller, $methodName, $controller::class); + return (bool)$controller->$methodName(...$arguments); + } + + protected function getCallbackInstance(string $className): object + { + if (!class_exists($className)) { + throw new \RuntimeException( + sprintf('Authorization callback class "%s" does not exist', $className), + 1761287267 + ); + } + return GeneralUtility::makeInstance($className); + } + + protected function validateCallbackMethod(object $instance, string $methodName, string $className): void + { + if (!method_exists($instance, $methodName)) { + throw new \RuntimeException( + sprintf('Authorization callback method "%s::%s" does not exist', $className, $methodName), + 1761287268 + ); + } + + $reflectionMethod = new \ReflectionMethod($instance, $methodName); + if (!$reflectionMethod->isPublic()) { + throw new \RuntimeException( + sprintf('Authorization callback method "%s::%s" must be public', $className, $methodName), + 1761287269 + ); + } + } +} diff --git a/Classes/Service/CacheService.php b/Classes/Service/CacheService.php new file mode 100644 index 0000000..48fda4c --- /dev/null +++ b/Classes/Service/CacheService.php @@ -0,0 +1,168 @@ +cacheTagStack = new \SplStack(); + } + + public function getCacheTagStack(): \SplStack + { + return $this->cacheTagStack; + } + + /** + * Clears the page cache + * + * @param int|int[]|string $pageIdsToClear single or multiple pageIds to clear the cache for + * @todo This method should be hardened to only accept integers or an array of integers + */ + public function clearPageCache($pageIdsToClear = null): void + { + if ($pageIdsToClear === null) { + $this->cacheManager->flushCachesInGroup('pages'); + } else { + if (!is_array($pageIdsToClear)) { + $pageIdsToClear = [(int)$pageIdsToClear]; + } + $tags = array_map(static fn(int $item): string => 'pageId_' . $item, $pageIdsToClear); + $this->cacheManager->flushCachesInGroupByTags('pages', $tags); + } + } + + /** + * First, this method checks, if any records are registered (usually via Database Backend) + * to be analyzed for a page record, if so, adds additional page IDs to the pageIdStack. + * + * Walks through the pageIdStack, collects all pageIds + * as array and passes them on to clearPageCache. + */ + public function clearCachesOfRegisteredPageIds(): void + { + $frameworkConfiguration = $this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FRAMEWORK); + if (!empty($frameworkConfiguration['persistence']['enableAutomaticCacheClearing'] ?? false)) { + foreach ($this->clearCacheForTables as $table => $ids) { + foreach ($ids as $id) { + $this->clearPageCacheForGivenRecord($table, $id); + } + } + } + if (!$this->cacheTagStack->isEmpty()) { + $cacheTags = []; + while (!$this->cacheTagStack->isEmpty()) { + $cacheTagValue = $this->cacheTagStack->pop(); + // Add fallback to old behavior. Pushing pageIds directly to the stack is possible. So we need to handle int values as well. + $cacheTags[] = is_int($cacheTagValue) ? sprintf('pageId_%s', $cacheTagValue) : (string)$cacheTagValue; + } + $cacheTags = array_values(array_unique($cacheTags)); + $this->cacheManager->flushCachesInGroupByTags('pages', $cacheTags); + } + } + + /** + * Stores a record into the stack to resolve the page IDs later-on to clear the caches on these pages + * then. + * + * Make sure to call clearCachesOfRegisteredPageIds() afterwards. + * + * @param string $table + * @param int $uid + */ + public function clearCacheForRecord(string $table, int $uid): void + { + if (!is_array($this->clearCacheForTables[$table] ?? null)) { + $this->clearCacheForTables[$table] = []; + } + $this->clearCacheForTables[$table][] = $uid; + } + + /** + * Finds the right PID(s) of a given record and loads the TYPO3 page cache for the given record. + * If the record lies on a page, then we clear the cache of this page. + * If the record has no PID column, we clear the cache of the current page as best-effort. + * + * Much of this functionality is taken from DataHandler::clear_cache() which unfortunately only works with logged-in BE user. + * + * @param string $tableName Table name of the record + * @param int $uid UID of the record + */ + protected function clearPageCacheForGivenRecord(string $tableName, int $uid): void + { + $pageIdsToClear = []; + $storagePage = null; + + $this->getCacheTagStack()->push($tableName); + $this->getCacheTagStack()->push(sprintf('%s_%s', $tableName, $uid)); + $queryBuilder = $this->connectionPool->getQueryBuilderForTable($tableName); + $queryBuilder->getRestrictions()->removeAll(); + $result = $queryBuilder + ->select('pid') + ->from($tableName) + ->where( + $queryBuilder->expr()->eq( + 'uid', + $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT) + ) + ) + ->executeQuery(); + if ($row = $result->fetchAssociative()) { + $storagePage = $row['pid']; + $pageIdsToClear[] = $storagePage; + } + if ($storagePage === null) { + return; + } + + $pageTS = BackendUtility::getPagesTSconfig($storagePage); + if (isset($pageTS['TCEMAIN.']['clearCacheCmd'])) { + $clearCacheCommands = GeneralUtility::trimExplode(',', strtolower((string)$pageTS['TCEMAIN.']['clearCacheCmd']), true); + $clearCacheCommands = array_unique($clearCacheCommands); + foreach ($clearCacheCommands as $clearCacheCommand) { + if (MathUtility::canBeInterpretedAsInteger($clearCacheCommand)) { + $pageIdsToClear[] = $clearCacheCommand; + } + } + } + + foreach ($pageIdsToClear as $pageIdToClear) { + $this->getCacheTagStack()->push('pageId_' . $pageIdToClear); + $this->getCacheTagStack()->push(sprintf('%s_pid_%s', $tableName, $pageIdToClear)); + } + } +} diff --git a/Classes/Service/ExtensionService.php b/Classes/Service/ExtensionService.php new file mode 100644 index 0000000..18d0d5a --- /dev/null +++ b/Classes/Service/ExtensionService.php @@ -0,0 +1,245 @@ +configurationManager = $configurationManager; + } + + /** + * Determines the plugin namespace of the specified plugin (defaults to "tx_[extensionname]_[pluginname]") + * If plugin.tx_$pluginSignature.view.pluginNamespace is set, this value is returned + * If pluginNamespace is not specified "tx_[extensionname]_[pluginname]" is returned. + * + * @param string|null $extensionName name of the extension to retrieve the namespace for + * @param string|null $pluginName name of the plugin to retrieve the namespace for + * @return string plugin namespace + */ + public function getPluginNamespace(?string $extensionName, ?string $pluginName): string + { + // todo: with $extensionName and $pluginName being null, tx__ will be returned here which is questionable. + // find out, if and why this case could happen and maybe avoid this methods being called with null + // arguments afterwards. + $pluginSignature = strtolower($extensionName . '_' . $pluginName); + $defaultPluginNamespace = 'tx_' . $pluginSignature; + $frameworkConfiguration = $this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FRAMEWORK, $extensionName, $pluginName); + if (!isset($frameworkConfiguration['view']['pluginNamespace']) || empty($frameworkConfiguration['view']['pluginNamespace'])) { + return $defaultPluginNamespace; + } + return $frameworkConfiguration['view']['pluginNamespace']; + } + + /** + * Iterates through the global TypoScript configuration and returns the name of the plugin + * that matches specified extensionName, controllerName and actionName. + * If no matching plugin was found, NULL is returned. + * If more than one plugin matches and the current plugin is not configured to handle the action, + * an Exception will be thrown + * + * @param string $extensionName name of the target extension (UpperCamelCase) + * @param string $controllerName name of the target controller (UpperCamelCase) + * @param string|null $actionName name of the target action (lowerCamelCase) + * @return string|null name of the target plugin (UpperCamelCase) or NULL if no matching plugin configuration was found + * @throws Exception + */ + public function getPluginNameByAction(string $extensionName, string $controllerName, ?string $actionName): ?string + { + // check, whether the current plugin is configured to handle the action + if (($pluginName = $this->getPluginNameFromFrameworkConfiguration($extensionName, $controllerName, $actionName)) !== null) { + return $pluginName; + } + + $plugins = $GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['extbase']['extensions'][$extensionName]['plugins'] ?? false; + if (!$plugins) { + return null; + } + $pluginNames = []; + foreach ($plugins as $pluginName => $pluginConfiguration) { + $controllers = $pluginConfiguration['controllers'] ?? []; + $controllerAliases = array_column($controllers, 'actions', 'alias'); + + foreach ($controllerAliases as $pluginControllerName => $pluginControllerActions) { + if (strtolower($pluginControllerName) !== strtolower($controllerName)) { + continue; + } + if (in_array($actionName, $pluginControllerActions, true)) { + $pluginNames[] = $pluginName; + } + } + } + if (count($pluginNames) > 1) { + throw new Exception('There is more than one plugin that can handle this request (Extension: "' . $extensionName . '", Controller: "' . $controllerName . '", action: "' . $actionName . '"). Please specify "pluginName" argument', 1280825466); + } + return !empty($pluginNames) ? $pluginNames[0] : null; + } + + private function getPluginNameFromFrameworkConfiguration(string $extensionName, string $controllerAlias, ?string $actionName): ?string + { + if ($actionName === null) { + return null; + } + + $frameworkConfiguration = $this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FRAMEWORK); + + if (!is_string($pluginName = ($frameworkConfiguration['pluginName'] ?? null))) { + return null; + } + + $configuredExtensionName = $frameworkConfiguration['extensionName'] ?? ''; + $configuredExtensionName = is_string($configuredExtensionName) ? $configuredExtensionName : ''; + + if ($configuredExtensionName === '' || $configuredExtensionName !== $extensionName) { + return null; + } + + $configuredControllers = $frameworkConfiguration['controllerConfiguration'] ?? []; + $configuredControllers = is_array($configuredControllers) ? $configuredControllers : []; + + $configuredActionsByControllerAliases = array_column($configuredControllers, 'actions', 'alias'); + + $actions = $configuredActionsByControllerAliases[$controllerAlias] ?? []; + $actions = is_array($actions) ? $actions : []; + + return in_array($actionName, $actions, true) ? $pluginName : null; + } + + /** + * Determines the target page of the specified plugin. + * If plugin.tx_$pluginSignature.view.defaultPid is set, this value is used as target page id + * If defaultPid is set to "auto", the target pid is determined by loading the tt_content record that contains this plugin + * If the page could not be determined, NULL is returned + * If defaultPid is "auto" and more than one page contains the specified plugin, an Exception is thrown + * + * @param string $extensionName name of the extension to retrieve the target PID for + * @param string $pluginName name of the plugin to retrieve the target PID for + * @return int|null uid of the target page or NULL if target page could not be determined + *@throws Exception + */ + public function getTargetPidByPlugin(string $extensionName, string $pluginName): ?int + { + $frameworkConfiguration = $this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FRAMEWORK, $extensionName, $pluginName); + if (!isset($frameworkConfiguration['view']['defaultPid']) || empty($frameworkConfiguration['view']['defaultPid'])) { + return null; + } + $pluginSignature = strtolower($extensionName . '_' . $pluginName); + if ($frameworkConfiguration['view']['defaultPid'] === 'auto') { + if (!array_key_exists($pluginSignature, $this->targetPidPluginCache)) { + $languageId = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('language', 'id', 0); + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable('tt_content'); + $queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class)); + + $pages = $queryBuilder + ->select('pid') + ->from('tt_content') + ->where( + $queryBuilder->expr()->eq( + 'CType', + $queryBuilder->createNamedParameter($pluginSignature) + ), + $queryBuilder->expr()->eq( + 'sys_language_uid', + $queryBuilder->createNamedParameter($languageId, Connection::PARAM_INT) + ) + ) + ->setMaxResults(2) + ->executeQuery() + ->fetchAllAssociative(); + + if (count($pages) > 1) { + throw new Exception('There is more than one "' . $pluginSignature . '" plugin in the current page tree. Please remove one plugin or set the TypoScript configuration "plugin.tx_' . $pluginSignature . '.view.defaultPid" to a fixed page id', 1280773643); + } + $this->targetPidPluginCache[$pluginSignature] = !empty($pages) ? (int)$pages[0]['pid'] : null; + } + return $this->targetPidPluginCache[$pluginSignature]; + } + return (int)$frameworkConfiguration['view']['defaultPid']; + } + + /** + * This returns the name of the first controller of the given plugin. + * + * @param string $extensionName name of the extension to retrieve the target PID for + * @param string $pluginName name of the plugin to retrieve the target PID for + */ + public function getDefaultControllerNameByPlugin(string $extensionName, string $pluginName): ?string + { + $controllers = $GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['extbase']['extensions'][$extensionName]['plugins'][$pluginName]['controllers'] ?? []; + $controllerAliases = array_column($controllers, 'alias'); + $defaultControllerName = (string)($controllerAliases[0] ?? ''); + return $defaultControllerName !== '' ? $defaultControllerName : null; + } + + /** + * This returns the name of the first action of the given plugin controller. + * + * @param string $extensionName name of the extension to retrieve the target PID for + * @param string $pluginName name of the plugin to retrieve the target PID for + * @param string $controllerName name of the controller to retrieve default action for + */ + public function getDefaultActionNameByPluginAndController(string $extensionName, string $pluginName, string $controllerName): ?string + { + $controllers = $GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['extbase']['extensions'][$extensionName]['plugins'][$pluginName]['controllers'] ?? []; + $controllerActionsByAlias = array_column($controllers, 'actions', 'alias'); + $actions = $controllerActionsByAlias[$controllerName] ?? []; + $defaultActionName = (string)($actions[0] ?? ''); + return $defaultActionName !== '' ? $defaultActionName : null; + } + + /** + * Resolve the page type number to use for building a link for a specific format + * + * @param string|null $extensionName name of the extension that has defined the target page type + * @param string $format The format for which to look up the page type + * @return int Page type number for target page + */ + public function getTargetPageTypeByFormat(?string $extensionName, string $format): int + { + // Default behaviour + $settings = $this->configurationManager->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_FRAMEWORK, $extensionName); + $formatToPageTypeMapping = $settings['view']['formatToPageTypeMapping'] ?? []; + $formatToPageTypeMapping = is_array($formatToPageTypeMapping) ? $formatToPageTypeMapping : []; + return (int)($formatToPageTypeMapping[$format] ?? 0); + } +} diff --git a/Classes/Service/FileHandlingService.php b/Classes/Service/FileHandlingService.php new file mode 100644 index 0000000..d9cad15 --- /dev/null +++ b/Classes/Service/FileHandlingService.php @@ -0,0 +1,457 @@ +getMethod() !== 'POST' || $arguments->count() === 0) { + return; + } + /** @var Argument $argument */ + foreach ($arguments as $argument) { + if (!$argument->getValidator() + || !class_exists($argument->getDataType()) + ) { + // Either argument has no validator (IgnoreValidation) or the datatype of the argument is not a class. + continue; + } + + $dataType = GeneralUtility::getClassName($argument->getDataType()); + $classSchema = $this->reflectionService->getClassSchema($dataType); + foreach ($classSchema->getProperties() as $property) { + $this->addUploadConfigurationForProperty($argument, $property); + } + } + } + + /** + * Adds a new upload configuration for the given property to the given argument. + */ + private function addUploadConfigurationForProperty( + Argument $argument, + Property $property + ): void { + $primaryType = $property->getPrimaryType(); + if (!$primaryType) { + throw new \InvalidArgumentException( + sprintf( + 'There is no @var annotation or type declaration for file upload property "%s" in class "%s".', + $property->getName(), + $argument->getDataType() + ), + 1712309171 + ); + } + + $propertyTargetClassName = $primaryType->getClassName() ?? $primaryType->getBuiltinType(); + if ($propertyTargetClassName !== FileReference::class + && !TypeHandlingUtility::isSimpleType($propertyTargetClassName) + ) { + $primaryCollectionValueType = $property->getPrimaryCollectionValueType(); + if ($propertyTargetClassName === ObjectStorage::class + && $primaryCollectionValueType + && $primaryType->isCollection() + ) { + $propertyTargetClassName = $primaryCollectionValueType->getClassName() ?? $primaryCollectionValueType->getBuiltinType(); + } + } + + // Skip unsupported classes for #[FileUpload] attribute or properties with empty FileUpload configuration + if ($propertyTargetClassName !== FileReference::class || $property->getFileUpload() === null) { + return; + } + + $fileUploadConfiguration = $property->getFileUpload(); + $configurationPropertyName = $property->getName(); + + $configuration = (new FileUploadConfiguration($configurationPropertyName)) + ->initializeWithConfiguration($fileUploadConfiguration); + $configuration->ensureValidConfiguration($propertyTargetClassName); + + $argument->getFileHandlingServiceConfiguration()->addFileUploadConfiguration($configuration); + + // If FileUpload is configured, the property mapping must be skipped + $argument->getPropertyMappingConfiguration()->skipProperties($configurationPropertyName); + } + + /** + * Initializes file deletion configurations for properties of the given argument. + */ + public function initializeFileUploadDeletionConfigurationsFromRequest( + RequestInterface $request, + Arguments $arguments + ): void { + if ($request->getMethod() !== 'POST' || $arguments->count() === 0) { + return; + } + + $pluginNamespace = $this->extensionService->getPluginNamespace( + $request->getControllerExtensionName(), + $request->getPluginName() + ); + $fileDeletions = $request->getParsedBody()[$pluginNamespace][self::DELETE_IDENTIFIER] ?? []; + + // In case of validation errors, file deletions must not be processed + if ($fileDeletions === [] || $this->hasMappingErrorOccurred($request)) { + return; + } + + /** @var Argument $argument */ + foreach ($arguments as $argument) { + if (isset($fileDeletions[$argument->getName()]) && is_array($fileDeletions[$argument->getName()])) { + $this->addDeletionConfigurationsToArgument($argument, $fileDeletions[$argument->getName()]); + } + } + } + + /** + * Maps and persists (if required) uploaded files for the given argument. + */ + public function mapUploadedFilesToArgument(Argument $argument): void + { + foreach ($argument->getFileHandlingServiceConfiguration()->getFileUploadConfigurations() as $configuration) { + $this->mapUploadedFilesToArgumentForConfiguration($argument, $configuration); + } + } + + /** + * Maps uploaded files to the argument for configuration. + * + * Maps uploaded files to the specified property of the argument, if property is allowed in current + * PropertyMappingConfiguration + */ + private function mapUploadedFilesToArgumentForConfiguration( + Argument $argument, + FileUploadConfiguration $configuration + ): void { + $propertyName = $configuration->getPropertyName(); + + if ($this->shouldMapProperty($argument, $propertyName)) { + $argumentValue = $argument->getValue(); + $uploadedFiles = $argument->getUploadedFilesForProperty($propertyName); + $this->mapUploadedFilesToArgumentForProperty( + $argumentValue, + $propertyName, + $uploadedFiles, + $configuration + ); + } + } + + /** + * Maps uploaded files to the specified property of the object, based on the provided configuration. + */ + private function mapUploadedFilesToArgumentForProperty( + mixed $argumentValue, + string $propertyName, + array $uploadedFiles, + FileUploadConfiguration $configuration + ): void { + if ($uploadedFiles === [] + || !ObjectAccess::isPropertyGettable($argumentValue, $propertyName) + || !ObjectAccess::isPropertySettable($argumentValue, $propertyName) + ) { + return; + } + + $classSchema = $this->reflectionService->getClassSchema($argumentValue); + $property = $classSchema->getProperty($propertyName); + if (!$property->getPrimaryType()) { + return; + } + + $isObjectStorage = $property->isObjectStorageType(); + $targetType = $isObjectStorage ? $property->getPrimaryCollectionValueType()->getClassName() : $property->getPrimaryType()->getClassName(); + + if ($targetType === FileReference::class) { + $configuration->ensureValidConfiguration($targetType); + $this->persistUploadedFilesAndMapAsFileReferencesToProperty( + $argumentValue, + $propertyName, + $isObjectStorage, + $configuration, + $uploadedFiles + ); + } + } + + /** + * Moves PSR-7 uploaded files to the target storage defined in the given file upload configuration. + * + * For target property type FileReference, either a new FileReference object is created or a possible existing + * FileReference object is reused and the uploaded file is set. + * + * For target property type ObjectStorage, new FileReference objects are created and attached + * to the property. + */ + private function persistUploadedFilesAndMapAsFileReferencesToProperty( + mixed $argumentValue, + string $propertyName, + bool $isObjectStorage, + FileUploadConfiguration $configuration, + array $uploadedFiles, + ): void { + $uploadFolder = $this->provideUploadFolder($configuration); + $storage = $uploadFolder->getStorage(); + + if ($isObjectStorage) { + /** @var ObjectStorage $currentPropertyValue */ + $currentPropertyValue = ObjectAccess::getProperty($argumentValue, $propertyName); + + foreach ($uploadedFiles as $uploadedFile) { + $targetFilename = $this->getTargetFilename($uploadedFile->getClientFilename(), $configuration); + $this->skipResourceConsistencyCheckForUploads($storage, $uploadedFile, $targetFilename); + $file = $storage->addUploadedFile($uploadedFile, $uploadFolder, $targetFilename, $configuration->getDuplicationBehavior()); + $coreFileReference = $this->createCoreFileReference($file); + $fileReference = $this->createExtbaseFileReference($coreFileReference); + $currentPropertyValue->attach($fileReference); + } + } else { + /** @var UploadedFile $uploadedFile */ + $uploadedFile = $uploadedFiles[0]; + $targetFilename = $this->getTargetFilename($uploadedFile->getClientFilename(), $configuration); + $this->skipResourceConsistencyCheckForUploads($storage, $uploadedFile, $targetFilename); + $file = $storage->addUploadedFile($uploadedFile, $uploadFolder, $targetFilename, $configuration->getDuplicationBehavior()); + $coreFileReference = $this->createCoreFileReference($file); + + /** @var FileReference|null $currentPropertyValue */ + $currentPropertyValue = ObjectAccess::getProperty($argumentValue, $propertyName); + + if ($currentPropertyValue) { + $currentPropertyValue->setOriginalResource($coreFileReference); + } else { + $currentPropertyValue = $this->createExtbaseFileReference($coreFileReference); + } + } + + ObjectAccess::setProperty($argumentValue, $propertyName, $currentPropertyValue); + } + + private function addDeletionConfigurationsToArgument(Argument $argument, array $fileDeletions): void + { + foreach ($fileDeletions as $signedDeletionData) { + $deletionData = $this->hashService->validateAndStripHmac( + $signedDeletionData, + self::DELETE_IDENTIFIER + ); + $deletionData = json_decode($deletionData, true, 512, JSON_THROW_ON_ERROR); + $fileReferenceUid = (int)$deletionData['fileReference']; + $property = $deletionData['property']; + + $argumentValue = $argument->getValue(); + $propertyValue = ObjectAccess::getPropertyPath($argumentValue, $property); + + if ($propertyValue instanceof FileReference) { + if ($propertyValue->getUid() === $fileReferenceUid) { + $argument->getFileHandlingServiceConfiguration() + ->registerFileDeletion($property, $fileReferenceUid); + } + } elseif ($propertyValue instanceof ObjectStorage) { + foreach ($propertyValue as $fileReference) { + if ($fileReference instanceof FileReference && $fileReference->getUid() === $fileReferenceUid) { + $argument->getFileHandlingServiceConfiguration() + ->registerFileDeletion($property, $fileReferenceUid); + } + } + } + } + } + + public function applyDeletionsToArgument(Argument $argument): void + { + $fileUploadDeletionConfigurations = $argument->getFileHandlingServiceConfiguration() + ->getFileUploadDeletionConfigurations(); + + /** @var FileUploadDeletionConfiguration $fileUploadDeletionConfiguration */ + foreach ($fileUploadDeletionConfigurations as $fileUploadDeletionConfiguration) { + $property = $fileUploadDeletionConfiguration->getPropertyName(); + foreach ($fileUploadDeletionConfiguration->getFileReferenceUids() as $fileReferenceUid) { + $argumentValue = $argument->getValue(); + $propertyValue = ObjectAccess::getPropertyPath($argumentValue, $property); + + if ($propertyValue instanceof FileReference) { + if ($propertyValue->getUid() === $fileReferenceUid) { + $propertyValue->getOriginalResource()->getOriginalFile()->delete(); + $propertyValue->getOriginalResource()->delete(); + ObjectAccess::setProperty($argumentValue, $property, null); + } + } elseif ($propertyValue instanceof ObjectStorage) { + foreach ($propertyValue as $fileReference) { + if ($fileReference instanceof FileReference && $fileReference->getUid() === $fileReferenceUid) { + $propertyValue->detach($fileReference); + $fileReference->getOriginalResource()->getOriginalFile()->delete(); + $fileReference->getOriginalResource()->delete(); + } + } + ObjectAccess::setProperty($argumentValue, $property, $propertyValue); + } + } + } + } + + /** + * Checks if a property mapping error has occurred in given request. + */ + private function hasMappingErrorOccurred(RequestInterface $request): bool + { + /** @var ExtbaseRequestParameters $extbaseRequestParameters */ + $extbaseRequestParameters = $request->getAttribute('extbase'); + return $extbaseRequestParameters->getOriginalRequest() !== null; + } + + /** + * Determines whether a property should be mapped for the given argument and property name. + */ + private function shouldMapProperty(Argument $argument, string $propertyName): bool + { + if ($propertyName === '') { + return false; + } + + return $argument->getPropertyMappingConfiguration()->shouldMap($propertyName); + } + + /** + * Returns the target filename to use for the given client filename provided by the file upload. + */ + private function getTargetFilename(string $clientFilename, FileUploadConfiguration $configuration): string + { + $targetFilename = $clientFilename; + + if ($configuration->isAddRandomSuffix()) { + $pathInfo = pathinfo($targetFilename); + $name = $pathInfo['filename']; + $extension = isset($pathInfo['extension']) ? '.' . $pathInfo['extension'] : ''; + $randomSuffix = GeneralUtility::makeInstance(Random::class)->generateRandomHexString(16); + $targetFilename = $name . '-' . $randomSuffix . $extension; + } + + $event = new ModifyUploadedFileTargetFilenameEvent( + targetFilename: $targetFilename, + configuration: $configuration + ); + $this->eventDispatcher->dispatch($event); + return $event->getTargetFilename(); + } + + /** + * Ensures that upload folder exists, creates it if it does not and if automatic folder creation is defined + * @throws FolderDoesNotExistException + */ + private function provideUploadFolder(FileUploadConfiguration $configuration): Folder + { + $uploadFolderIdentifier = $configuration->getUploadFolder(); + try { + return $this->resourceFactory->getFolderObjectFromCombinedIdentifier($uploadFolderIdentifier); + } catch (FolderDoesNotExistException $exception) { + if (!$configuration->isCreateUploadFolderIfNotExist()) { + throw $exception; + } + + [$storageId, $storagePath] = explode(':', $uploadFolderIdentifier, 2); + $storage = $this->storageRepository->getStorageObject((int)$storageId); + + if (!$storage->hasFolder($storagePath)) { + $folder = $storage->createFolder($storagePath); + } else { + $folder = $storage->getFolder($storagePath); + } + + return $folder; + } + } + + private function createCoreFileReference(FileInterface $file): CoreFileReference + { + if (!$file instanceof File) { + throw new \RuntimeException('Given file must be a TYPO3\\CMS\\Core\\Resource.', 1712062607); + } + + return $this->resourceFactory->createFileReferenceObject( + [ + 'uid_local' => $file->getUid(), + 'uid_foreign' => StringUtility::getUniqueId('NEW_'), + 'uid' => StringUtility::getUniqueId('NEW_'), + ] + ); + } + + private function createExtbaseFileReference( + CoreFileReference $falFileReference + ): FileReference { + $fileReference = GeneralUtility::makeInstance(FileReference::class); + $fileReference->setOriginalResource($falFileReference); + return $fileReference; + } +} diff --git a/Classes/Service/ImageService.php b/Classes/Service/ImageService.php new file mode 100644 index 0000000..afa4d9e --- /dev/null +++ b/Classes/Service/ImageService.php @@ -0,0 +1,184 @@ +getOriginalFile(); + } + + $processedImage = $image->process(ProcessedFile::CONTEXT_IMAGECROPSCALEMASK, $processingInstructions); + $this->setCompatibilityValues($processedImage); + + return $processedImage; + } + + /** + * Get public url of image depending on the environment + * + * @param bool|false $absolute Force absolute URL + */ + public function getImageUri(FileInterface $image, bool $absolute = false): string + { + $imageUrl = $image->getPublicUrl(); + if (!$absolute || $imageUrl === null) { + return (string)$imageUrl; + } + // @todo: Change method signature in >=v15 to receive $request, probably as first or second argument. + $request = $GLOBALS['TYPO3_REQUEST'] ?? null; + return GeneralUtility::locationHeaderUrl($imageUrl, $request); + } + + /** + * Get File or FileReference object + * + * This method is a factory and compatibility method that does not belong to + * this service, but is put here for pragmatic reasons for the time being. + * It should be removed once we do not support string sources for images anymore. + * + * @param string $src + * @param FileInterface|\TYPO3\CMS\Extbase\Domain\Model\FileReference|null $image + * @param bool $treatIdAsReference + * @throws \UnexpectedValueException + * @internal + */ + public function getImage(string $src, $image, bool $treatIdAsReference): FileInterface + { + if ($image instanceof File || $image instanceof FileReference) { + // We already received a valid file and therefore just return it + return $image; + } + + if (is_callable([$image, 'getOriginalResource'])) { + // We have a domain model, so we need to fetch the FAL resource object from there + $originalResource = $image->getOriginalResource(); + if (!($originalResource instanceof File || $originalResource instanceof FileReference)) { + throw new \UnexpectedValueException('No original resource could be resolved for supplied file ' . get_class($image), 1625838481); + } + return $originalResource; + } + + if ($image !== null) { + // Some value is given for $image, but it's not a valid type + throw new \UnexpectedValueException( + 'Supplied file must be File or FileReference, ' . get_debug_type($image) . ' given.', + 1625585157 + ); + } + + // Since image is not given, try to resolve an image from the source string + $resolvedImage = $this->getImageFromSourceString($src, $treatIdAsReference); + + if ($resolvedImage instanceof File || $resolvedImage instanceof FileReference) { + return $resolvedImage; + } + + if ($resolvedImage === null) { + // No image could be resolved using the given source string + throw new \UnexpectedValueException('Supplied ' . $src . ' could not be resolved to a File or FileReference.', 1625585158); + } + + // A FileInterface was found, however only File and FileReference are valid + throw new \UnexpectedValueException( + 'Resolved file object type ' . get_class($resolvedImage) . ' for ' . $src . ' must be File or FileReference.', + 1382687163 + ); + } + + /** + * Get File or FileReference object by src + */ + protected function getImageFromSourceString(string $src, bool $treatIdAsReference): ?FileInterface + { + if (($GLOBALS['TYPO3_REQUEST'] ?? null) instanceof ServerRequestInterface + && ApplicationType::fromRequest($GLOBALS['TYPO3_REQUEST'])->isBackend() + && str_starts_with($src, '../') + ) { + $src = substr($src, 3); + } + if (MathUtility::canBeInterpretedAsInteger($src)) { + if ($treatIdAsReference) { + $image = $this->resourceFactory->getFileReferenceObject((int)$src); + } else { + $image = $this->resourceFactory->getFileObject($src); + } + } elseif (str_starts_with($src, 't3://file')) { + // We have a t3://file link to a file in FAL + $data = $this->linkService->resolveByStringRepresentation($src); + $image = $data['file']; + } else { + // We have a combined identifier or legacy (storage 0) path + $image = $this->resourceFactory->retrieveFileOrFolderObject($src); + } + + // Check the resolved image as this could also be a FolderInterface + return $image instanceof FileInterface ? $image : null; + } + + /** + * Set compatibility values in case we are in frontend environment. + */ + protected function setCompatibilityValues(ProcessedFile $processedImage): void + { + $imageResource = ImageResource::createFromProcessedFile($processedImage); + if ($imageResource->getPublicUrl() !== null) { + // only add the processed image to AssetCollector if the public url is not NULL + GeneralUtility::makeInstance(AssetCollector::class)->addMedia( + $imageResource->getPublicUrl(), + $imageResource->getLegacyImageResourceInformation() + ); + } + } +} diff --git a/Classes/Utility/DebuggerUtility.php b/Classes/Utility/DebuggerUtility.php new file mode 100644 index 0000000..2479b2c --- /dev/null +++ b/Classes/Utility/DebuggerUtility.php @@ -0,0 +1,836 @@ + 2000 ? mb_substr($value, 0, 2000) . '...' : $value; + if ($plainText) { + $dump = [ + self::ansiEscapeWrap('"' . implode(PHP_EOL . str_repeat(self::PLAINTEXT_INDENT, $level + 1), mb_str_split($croppedValue, 76)) . '"', '33', $plainText, $ansiColors), + ' (', + mb_strlen($value), + ' chars)', + ]; + } else { + $lines = mb_str_split($croppedValue, 76); + $content = []; + foreach ($lines as $key => $line) { + if ($key > 0) { + $content[] = self::html('br', []); + $content[] = static fn(): string => ' '; + } + $content[] = $line; + } + + $dump = self::html('span', ['class' => 'extbase-debug-string-container'], [ + '\'', + self::html('span', ['class' => 'extbase-debug-string'], $content), + '\' (', + mb_strlen($value), + ' chars)', + ]); + } + } elseif (is_numeric($value)) { + $dump = [ + self::ansiEscapeWrap((string)$value, '35', $plainText, $ansiColors), + ' (', + gettype($value), + ')', + ]; + } elseif (is_bool($value)) { + $dump = $value ? self::ansiEscapeWrap('TRUE', '32', $plainText, $ansiColors) : self::ansiEscapeWrap('FALSE', '32', $plainText, $ansiColors); + } elseif ($value === null || is_resource($value)) { + $dump = gettype($value); + } elseif (is_array($value)) { + return self::renderArray($value, $level + 1, $plainText, $ansiColors, $headerPrefix); + } elseif (is_object($value)) { + if ($value instanceof \Closure) { + return self::renderClosure($value, $level + 1, $plainText, $ansiColors, $headerPrefix); + } + return self::renderObject($value, $level + 1, $plainText, $ansiColors, $headerPrefix); + + } + if ($plainText) { + return [$headerPrefix, $dump]; + } + return self::html('div', ['class' => 'extbase-debug-header'], [$headerPrefix, $dump]); + } + + /** + * Renders a dump of the given array + */ + protected static function renderArray(array $array, int $level, bool $plainText = false, bool $ansiColors = false, array $headerPrefix = []): array|callable + { + $content = []; + $count = count($array); + + $header = $headerPrefix; + $header[] = self::styled('', 'expander', $plainText, $ansiColors); + $header[] = self::styled('array', 'type', $plainText, $ansiColors, 0, 1); + $header[] = $count > 0 ? '(' . $count . ' item' . ($count > 1 ? 's' : '') . ')' : '(empty)'; + if ($level >= self::$maxDepth) { + $header[] = self::styled('max depth', 'filtered', $plainText, $ansiColors, 1); + } else { + $content = self::renderCollection($array, $level, $plainText, $ansiColors); + } + + if ($plainText) { + return [...$header, ...$content]; + } + + if (array_filter($content) === []) { + return self::html('div', ['class' => 'extbase-debug-header'], $header); + } + return self::html('details', ['class' => 'extbase-debugger-tree', 'open' => $level > 1 && $count > 0 ? null : ''], [ + self::html('summary', ['class' => 'extbase-debug-header'], $header), + self::html('div', ['class' => 'extbase-debug-content'], $content), + ]); + } + + /** + * Renders a dump of the given object + */ + protected static function renderObject(object $object, int $level, bool $plainText = false, bool $ansiColors = false, array $headerPrefix = []): array|callable + { + if ($object instanceof LazyLoadingProxy) { + $object = $object->_loadRealInstance(); + if (!is_object($object)) { + return [...$headerPrefix, gettype($object)]; + } + } + $header = self::renderHeader($object, $level, $plainText, $ansiColors, $headerPrefix); + $content = []; + if ($level < self::$maxDepth && !self::isBlacklisted($object) && !(self::isAlreadyRendered($object) && $plainText !== true)) { + $content = self::renderContent($object, $level, $plainText, $ansiColors); + } + if ($plainText) { + return [...$header, ...$content]; + } + + if (array_filter($content) === []) { + return self::html('div', ['class' => 'extbase-debugger-header'], $header); + } + return self::html('details', ['class' => 'extbase-debugger-tree', 'open' => $level > 1 ? null : ''], [ + self::html('summary', ['class' => 'extbase-debug-header', 'id' => spl_object_hash($object)], $header), + self::html('div', ['class' => 'extbase-debug-content'], $content), + ]); + } + + /** + * Renders a dump of the given closure + */ + protected static function renderClosure(\Closure $object, int $level, bool $plainText = false, bool $ansiColors = false, array $headerPrefix = []): array|callable + { + $header = self::renderHeader($object, $level, $plainText, $ansiColors, $headerPrefix); + $content = []; + if ($level < self::$maxDepth && (!self::isAlreadyRendered($object) || $plainText)) { + $content = self::renderContent($object, $level, $plainText, $ansiColors); + } + if ($plainText) { + return [...$header, ...$content]; + } + if (array_filter($content) === []) { + return self::html('div', ['class' => 'extbase-debugger-header'], $header); + } + return self::html('details', ['class' => 'extbase-debugger-tree', 'open' => $level > 1 ? null : ''], [ + self::html('summary', ['class' => 'extbase-debug-header'], $header), + self::html('div', ['class' => 'extbase-debug-content extbase-debug-closure'], $content), + ]); + } + + /** + * Checks if a given object or property should be excluded/filtered + * + * @param object $value A ReflectionProperty or other Object + * @return bool TRUE if the given object should be filtered + */ + protected static function isBlacklisted(object $value): bool + { + if ($value instanceof \ReflectionProperty) { + $result = in_array($value->getName(), self::$blacklistedPropertyNames, true); + } else { + $result = in_array(get_class($value), self::$blacklistedClassNames, true); + } + return $result; + } + + /** + * Checks if a given object was already rendered. + * + * @return bool TRUE if the given object was already rendered + */ + protected static function isAlreadyRendered(object $object): bool + { + return self::$renderedObjects->contains($object); + } + + /** + * Renders the header of a given object/collection. It is usually the class name along with some flags. + * + * @return array string The rendered header with tags + */ + protected static function renderHeader(object $object, int $level, bool $plainText, bool $ansiColors, array $headerPrefix): array + { + $dump = $headerPrefix; + $persistenceType = null; + $className = get_class($object); + $classReflection = new \ReflectionClass($className); + $dump[] = self::styled('', 'expander', $plainText, $ansiColors); + $dump[] = self::styled($className, 'type', $plainText, $ansiColors); + + if (!$object instanceof \Closure) { + if ($object instanceof SingletonInterface) { + $scope = 'singleton'; + } else { + $scope = 'prototype'; + } + $dump[] = self::styled($scope, 'scope', $plainText, $ansiColors, 1); + if ($object instanceof DomainObjectInterface) { + if ($object->_isDirty()) { + $persistenceType = 'modified'; + } elseif ($object->_isNew()) { + $persistenceType = 'transient'; + } else { + $persistenceType = 'persistent'; + } + } + if ($object instanceof ObjectStorage && $object->_isDirty()) { + $persistenceType = 'modified'; + } + if ($object instanceof AbstractEntity) { + $domainObjectType = 'entity'; + } elseif ($object instanceof AbstractValueObject) { + $domainObjectType = 'valueobject'; + } else { + $domainObjectType = 'object'; + } + $persistenceType = $persistenceType === null ? '' : $persistenceType . ' '; + $dump[] = self::styled($persistenceType . $domainObjectType, 'ptype', $plainText, $ansiColors, 1); + } + + if (strpos(implode('|', self::$blacklistedClassNames), get_class($object)) > 0) { + $dump[] = self::styled('filtered', 'filtered', $plainText, $ansiColors, 1); + } elseif (self::$renderedObjects->contains($object) && !$plainText) { + $dump = [ + self::html('a', ['href' => '#' . spl_object_hash($object), 'class' => 'extbase-debug-seeabove'], [ + $dump, + self::html('span', ['class' => 'extbase-debug-filtered'], 'see above'), + ]), + ]; + } elseif ($level >= self::$maxDepth && !$object instanceof \DateTimeInterface) { + $dump[] = self::styled('max depth', 'filtered', $plainText, $ansiColors, 1); + } + + if ($object instanceof \Countable) { + $objectCount = count($object); + $dump[] = $objectCount > 0 ? ' (' . $objectCount . ' items)' : ' (empty)'; + } + if ($object instanceof \DateTimeInterface) { + $dump[] = ' (' . $object->format(\DateTimeInterface::RFC3339) . ', ' . $object->getTimestamp() . ')'; + } + if ($object instanceof DomainObjectInterface && !$object->_isNew()) { + $dump[] = ' (uid=' . $object->getUid() . ', pid=' . $object->getPid() . ')'; + } + + return $dump; + } + + protected static function renderContent(object $object, int $level, bool $plainText, bool $ansiColors): string|array + { + $dump = []; + if ($object instanceof \Iterator || $object instanceof \ArrayObject) { + $dump[] = self::renderCollection($object, $level, $plainText, $ansiColors); + } else { + self::$renderedObjects->attach($object); + if ($object instanceof \Closure) { + if ($plainText) { + $dump[] = PHP_EOL; + } + $dump[] = str_repeat(self::PLAINTEXT_INDENT, $level); + $dump[] = self::styled('function (', 'closure', $plainText, $ansiColors); + + $reflectionFunction = new \ReflectionFunction($object); + $params = []; + $count = 0; + foreach ($reflectionFunction->getParameters() as $parameter) { + if (++$count > 1) { + $params[] = ', '; + } + $isFirst = false; + $type = $parameter->getType(); + // @todo Following code adds for parameter of type array or a class the classname or array + // to the output. All other introduced possible parameter types are not respected yet. + // This should be extended, and also respect possible type combinations like + // union types and union intersect types. + if ($type instanceof \ReflectionNamedType && $type->isBuiltin() && $type->getName() === 'array') { + $params[] = self::styled('array', 'type', $plainText, $ansiColors, 0, 1); + } elseif ($type instanceof \ReflectionNamedType && !$type->isBuiltin() && !empty($type->getName())) { + $params[] = self::styled($type->getName(), 'type', $plainText, $ansiColors, 0, 1); + } + if ($parameter->isPassedByReference()) { + $params[] = '&'; + } + if ($parameter->isVariadic()) { + $params[] = '...'; + } + $params[] = self::styled('$' . $parameter->name, 'property', $plainText, $ansiColors); + if ($parameter->isDefaultValueAvailable()) { + $params[] = ' = '; + $params[] = self::styled(var_export($parameter->getDefaultValue(), true), 'string', $plainText, $ansiColors); + } + } + $dump = [...$dump, ...$params]; + $dump[] = self::styled(') {', 'closure', $plainText, $ansiColors); + $dump[] = PHP_EOL; + + $lines = (array)file((string)$reflectionFunction->getFileName()); + for ($l = (int)$reflectionFunction->getStartLine(); $l < (int)$reflectionFunction->getEndLine() - 1; ++$l) { + $line = (string)($lines[$l] ?? ''); + $dump[] = $line; + } + $dump[] = str_repeat(self::PLAINTEXT_INDENT, $level); + $dump[] = self::styled('}', 'closure', $plainText, $ansiColors); + if ($plainText) { + $dump[] = PHP_EOL; + } + } else { + if (get_class($object) === \stdClass::class) { + $objReflection = new \ReflectionObject($object); + $properties = $objReflection->getProperties(); + } else { + $classReflection = new \ReflectionClass(get_class($object)); + $properties = $classReflection->getProperties(); + } + foreach ($properties as $property) { + if (self::isBlacklisted($property)) { + continue; + } + $visibility = ($property->isProtected() ? 'protected' : ($property->isPrivate() ? 'private' : 'public')); + $header = [ + PHP_EOL . str_repeat(self::PLAINTEXT_INDENT, $level), + self::styled($property->getName(), 'property', $plainText, $ansiColors), + ' => ', + self::styled($visibility, 'visibility', $plainText, $ansiColors, 0, 1), + ]; + if (!$property->isInitialized($object)) { + $header[] = self::styled('uninitialized', 'uninitialized', $plainText, $ansiColors, 0, 1); + if ($plainText) { + $dump[] = $header; + } else { + $dump[] = self::html('div', ['class' => 'extbase-debug-header'], $header); + } + continue; + } + if ($object instanceof DomainObjectInterface && !$object->_isNew() && $object->_isDirty($property->getName())) { + $header[] = self::styled('modified', 'dirty', $plainText, $ansiColors, 1); + } + $dump[] = self::renderDump($property->getValue($object), $level, $plainText, $ansiColors, $header); + } + } + } + return $dump; + } + + protected static function renderCollection(iterable $collection, int $level, bool $plainText, bool $ansiColors): array + { + $dump = []; + foreach ($collection as $key => $value) { + // Note: Due to the TYPO3\CMS\Core\Type\Map implementation, the key can also be an object. + $key = is_object($key) ? get_class($key) : (string)$key; + + $dump = [ + ...$dump, + self::renderDump($value, $level, $plainText, $ansiColors, [ + PHP_EOL . str_repeat(self::PLAINTEXT_INDENT, $level), + self::styled($key, 'property', $plainText, $ansiColors), + ' => ', + ]), + ]; + } + if ($collection instanceof \Iterator && !$collection instanceof \Generator) { + $collection->rewind(); + } + return $dump; + } + + /** + * Wrap a string with the ANSI escape sequence for colorful output + * + * @param string $string The string to wrap + * @param string $ansiColorSequence The ansi color sequence (e.g. "1;37") + * @param bool $plainText If FALSE, the string will be HTML encoded + * @param bool $ansiColors If TRUE, the string will have console colors applied + * @return callable The wrapped or raw string + */ + protected static function ansiEscapeWrap(string $string, string $ansiColorSequence, bool $plainText, bool $ansiColors): callable + { + if ($plainText && $ansiColors) { + return static fn(): string => '[' . $ansiColorSequence . 'm' . self::escapeConsoleText($string) . ''; + } + if ($plainText) { + return static fn(): string => self::escapeConsoleText($string); + } + return static fn(): string => self::escapeHtml($string); + } + + /** + * A var_dump function optimized for Extbase's object structures + * + * @param mixed $variable The value to dump + * @param string $title optional custom title for the debug output + * @param int $maxDepth Sets the max recursion depth of the dump. De- or increase the number according to your needs and memory limit. + * @param bool $plainText If TRUE, the dump is in plain text, if FALSE the debug output is in HTML format. + * @param bool $ansiColors If TRUE (default), ANSI color codes is added to the output, if FALSE the debug output not colored. + * @param bool $return if TRUE, the dump is returned for custom post-processing (e.g. embed in custom HTML). If FALSE (default), the dump is directly displayed. + * @param array $blacklistedClassNames An array of class names (RegEx) to be filtered. Default is an array of some common class names. + * @param array $blacklistedPropertyNames An array of property names and/or array keys (RegEx) to be filtered. Default is an array of some common property names. + * @return string if $return is TRUE, the dump is returned. By default, the dump is directly displayed, and nothing is returned. + */ + public static function var_dump( + $variable, + ?string $title = null, + int $maxDepth = 8, + bool $plainText = false, + bool $ansiColors = true, + bool $return = false, + ?array $blacklistedClassNames = null, + ?array $blacklistedPropertyNames = null + ): string { + self::$maxDepth = $maxDepth; + if ($title === null) { + $title = 'Extbase Variable Dump'; + } + $ansiColors = $plainText && $ansiColors; + if ($ansiColors === true) { + $title = '' . $title . ''; + } + $backupBlacklistedClassNames = self::$blacklistedClassNames; + if (is_array($blacklistedClassNames)) { + self::$blacklistedClassNames = $blacklistedClassNames; + } + $backupBlacklistedPropertyNames = self::$blacklistedPropertyNames; + if (is_array($blacklistedPropertyNames)) { + self::$blacklistedPropertyNames = $blacklistedPropertyNames; + } + self::clearState(); + + $css = self::cssTreeToString([ + '.extbase-debugger-tree' => [ + 'position' => 'relative', + ], + '.extbase-debugger-tree summary' => [ + 'list-style' => 'none', + 'cursor' => 'pointer', + 'white-space' => 'nowrap', + ], + '.extbase-debugger-tree:has(>summary:target)' => [ + 'outline' => 'var(--typo3-debugger-outline, #101010) auto 1px', + 'padding' => '3px', + ], + '.extbase-debugger-tree :is(.extbase-debug-header)>*' => [ + 'vertical-align' => 'top', + ], + '.extbase-debugger-tree .extbase-debug-expander' => [ + 'position' => 'relative', + 'display' => 'none', + 'height' => '1em', + 'aspect-ratio' => '1', + 'margin' => '0 3px 0 0', + 'vertical-align' => '-12%', + 'cursor' => 'pointer', + ], + '.extbase-debugger-tree summary>.extbase-debug-expander' => [ + 'display' => 'inline-block', + ], + // Hide expander on first level + '.extbase-debugger-inner>details>summary>.extbase-debug-expander' => [ + 'display' => 'none', + ], + '.extbase-debugger-tree .extbase-debug-expander::before' => [ + 'content' => '""', + 'position' => 'absolute', + 'inset' => '0', + 'background-size' => '100%', + 'display' => 'inline-block', + 'background-image' => sprintf('url(data:image/svg+xml;base64,%s)', base64_encode( + '' + )), + ], + '.extbase-debugger-tree[open]>summary>.extbase-debug-expander::before' => [ + 'background-image' => sprintf('url(data:image/svg+xml;base64,%s)', base64_encode( + '' + )), + ], + '.extbase-debugger-tree .extbase-debug-content' => [ + 'padding-left' => '3ch', + ], + '.extbase-debugger' => [ + 'display' => 'block', + 'text-align' => 'left', + 'background' => 'var(--typo3-debugger-bg, #2a2a2a)', + 'border' => '1px solid var(--typo3-debugger-border-color, #2a2a2a)', + 'box-shadow' => 'var(--typo3-debugger-box-shadow, 0 3px 0 rgba(0, 0, 0, .5))', + 'margin' => '20px', + 'overflow' => 'hidden', + 'border-radius' => 'var(--typo3-debugger-border-radius, 4px)', + ], + '.extbase-debugger-floating' => [ + 'position' => 'relative', + 'z-index' => '99990', + ], + '.extbase-debugger-top' => [ + 'background' => 'var(--typo3-debugger-top-bg, #444)', + 'font-size' => '12px', + 'font-family' => 'monospace', + 'color' => 'var(--typo3-debugger-top-color, #f1f1f1)', + 'padding' => '6px 15px', + ], + '.extbase-debugger-inner' => [ + 'overflow-x' => 'auto', + ], + '.extbase-debugger-center' => [ + 'padding' => '0 15px', + 'margin' => '15px 0', + 'background-image' => sprintf( + 'repeating-linear-gradient(to bottom, transparent 0, transparent 20px, %1$s 20px, %1$s 40px)', + 'var(--typo3-debugger-bg-variant, #252525)', + ), + 'color' => 'var(--typo3-debugger-color-variant, #999)', + 'word-wrap' => 'break-word', + ], + '.extbase-debugger-center, .extbase-debugger-center :is(.extbase-debug-string, a, p, pre, strong)' => [ + 'font-size' => '12px', + 'font-weight' => '400', + 'font-family' => 'monospace', + 'line-height' => '20px', + 'color' => 'var(--typo3-debugger-color, #f1f1f1)', + ], + '.extbase-debugger-center .extbase-debug-string-container' => [ + 'display' => 'inline-block', + ], + '.extbase-debugger-center :is(.extbase-debug-filtered, .extbase-debug-proxy, .extbase-debug-ptype, .extbase-debug-visibility, .extbase-debug-uninitialized, .extbase-debug-scope, .extbase-debug-dirty)' => [ + 'color' => '#fff', + 'font-size' => '10px', + 'line-height' => '18px', + 'padding' => '2px 4px', + 'margin-right' => '2px', + ], + '.extbase-debugger-center .extbase-debug-unregistered' => [ + 'background-color' => 'var(--typo3-debugger-unregistered-bg, #dce1e8)', + ], + '.extbase-debugger-center .extbase-debug-scope' => [ + 'background-color' => 'var(--typo3-debugger-scope-bg, #497AA2)', + ], + '.extbase-debugger-center .extbase-debug-ptype' => [ + 'background-color' => 'var(--typo3-debugger-ptype-bg, #698747)', + ], + '.extbase-debugger-center .extbase-debug-visibility' => [ + 'background-color' => 'var(--typo3-debugger-visibility-bg, #6c0787)', + ], + '.extbase-debugger-center .extbase-debug-uninitialized' => [ + 'background-color' => 'var(--typo3-debugger-uninitializedy-bg, #698747)', + ], + '.extbase-debugger-center .extbase-debug-dirty' => [ + 'background-color' => 'var(--typo3-debugger-dirty-bg, #664d00)', + ], + '.extbase-debugger-center .extbase-debug-filtered' => [ + 'background-color' => 'var(--typo3-debugger-filtered-bg, #664d00)', + ], + '.extbase-debugger-center .extbase-debug-string' => [ + 'color' => 'var(--typo3-debugger-string-color, #ce9178)', + 'white-space' => 'normal', + ], + '.extbase-debugger-center .extbase-debug-type' => [ + 'color' => 'var(--typo3-debugger-type-color, #569CD6)', + 'padding-right' => '4px', + ], + '.extbase-debugger-center .extbase-debug-closure' => [ + 'color' => 'var(--typo3-debugger-closure-color, #9BA223)', + 'white-space' => 'pre', + ], + '.extbase-debugger-center .extbase-debug-property' => [ + 'color' => 'var(--typo3-debugger-property-color, #f1f1f1)', + ], + '.extbase-debugger-center .extbase-debug-seeabove' => [ + 'display' => 'block', + 'text-decoration' => 'none', + 'font-style' => 'italic', + ], + ]); + + $style = ''; + if (!$plainText && self::$stylesheetEchoed === false) { + $style = self::html( + 'style', + ['nonce' => self::resolveNonceValue(Directive::StyleSrcElem)], + static fn(): string => $css + )(); + self::$stylesheetEchoed = true; + } + if ($plainText) { + $output = $title . self::render( + [ + PHP_EOL, + self::renderDump($variable, 0, true, $ansiColors), + PHP_EOL, + PHP_EOL, + ], + self::escapeConsoleText(...) + ); + + } else { + $output = self::html('div', ['class' => 'extbase-debugger ' . ($return ? 'extbase-debugger-inline' : 'extbase-debugger-floating')], [ + self::html('div', ['class' => 'extbase-debugger-top'], $title), + self::html('div', ['class' => 'extbase-debugger-center'], [ + self::html('div', ['class' => 'extbase-debugger-inner'], [ + self::renderDump($variable, 0, false, false), + ]), + ]), + ])(); + } + self::$blacklistedClassNames = $backupBlacklistedClassNames; + self::$blacklistedPropertyNames = $backupBlacklistedPropertyNames; + if ($return === true) { + return $style . $output; + } + echo $style . $output; + + return ''; + } + + protected static function resolveNonceValue(Directive $directive): string + { + return GeneralUtility::makeInstance(RequestId::class)->nonce->consumeInline($directive); + } + + protected static function styled( + string $content, + string $style, + bool $plainText, + bool $ansiColors, + int $spaceBefore = 0, + int $spaceAfter = 0, + ): callable|string { + $styleMap = [ + 'expander' => '', + 'string' => '33', + 'closure' => '33', + 'type' => '36', + 'property' => '37', + 'ptype' => '42;30', + 'visibility' => '42;30', + 'dirty' => '43;30', + 'scope' => '44;37', + 'filtered' => '47;30', + 'uninitialized' => '45;37', + ]; + if (!isset($styleMap[$style])) { + throw new \InvalidArgumentException('Invalid debugger style: ' . $style, 1726659808); + } + + if ($plainText) { + if ($style === 'expander') { + return ''; + } + return static fn() => [ + str_repeat(' ', $spaceBefore), + self::ansiEscapeWrap($content, $styleMap[$style], $plainText, $ansiColors), + str_repeat(' ', $spaceAfter), + ]; + } + + return self::html('span', ['class' => 'extbase-debug-' . $style], $content); + } + + protected static function html(string $tagName, array $attributes, string|array|callable|null $content = null): callable + { + if ($tagName === '' || !preg_match('/^[a-zA-Z][a-zA-Z0-9-]*$/', $tagName)) { + throw new \InvalidArgumentException('Invalid tag name', 1726659807); + } + return static fn(): string => implode('', [ + '<', + $tagName, + count($attributes) > 0 ? ' ' : '', + // filter null attributes + GeneralUtility::implodeAttributes(array_filter($attributes, static fn(?string $value): bool => $value !== null), true, true), + '>', + ...($content === null ? [] : [ + self::render($content, self::escapeHtml(...)), + '', + ]), + ]); + } + + protected static function escapeHtml(string $text): string + { + return htmlspecialchars($text, ENT_HTML5, 'UTF-8'); + } + + protected static function escapeConsoleText(string $text): string + { + return preg_replace_callback( + '/[\x00-\x1F\x7F]/u', + static fn(array $matches): string => $matches[0] === PHP_EOL ? PHP_EOL : '\\x' . str_pad(dechex(ord($matches[0])), 2, '0', STR_PAD_LEFT), + $text + ) ?? $text; + } + + protected static function render(string|callable|array $content, callable $escape): string + { + if (is_string($content)) { + return $escape($content); + } + if ($content instanceof \Closure) { + $content = $content(); + if (is_string($content)) { + return $content; + } + } + if (is_array($content)) { + return implode('', array_map(static fn(string|callable|array $content): string => self::render($content, $escape), $content)); + } + + throw new \InvalidArgumentException('Invalid callable return type: ' . gettype($content), 1726673500); + } + + /** + * Converts a CSS tree to a CSS stylesheet string + * + * Example input: + * + * [ + * '.my-class' => [ + * 'display' => 'block', + * 'color' => 'black', + * ], + * '.other-class' => [ + * 'display' => 'flex', + * ], + * ] + * + * Output: + * .my-class{display:block;color:black} + * .other-class{display:flex} + * + * @param array> $cssTree + */ + protected static function cssTreeToString(array $cssTree): string + { + $rules = array_map( + static fn(string $selector): string => sprintf( + '%s{%s}', + $selector, + implode( + ';', + array_map( + static fn(string $property): string => sprintf( + '%s:%s', + $property, + $cssTree[$selector][$property] + ), + array_keys($cssTree[$selector]) + ) + ) + ), + array_keys($cssTree) + ); + $stylesheet = implode(PHP_EOL, $rules); + // Optimize away uneeded whitespace + return str_replace(', ', ',', $stylesheet); + } +} diff --git a/Classes/Utility/Exception/InvalidTypeException.php b/Classes/Utility/Exception/InvalidTypeException.php new file mode 100644 index 0000000..266a5de --- /dev/null +++ b/Classes/Utility/Exception/InvalidTypeException.php @@ -0,0 +1,25 @@ + $controllerActions + * @param array $nonCacheableControllerActions + * @internal + */ + public static function registerControllerActions(string $extensionName, string $pluginName, array $controllerActions, array $nonCacheableControllerActions): void + { + if (!is_array($GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['extbase']['extensions'][$extensionName]['plugins'][$pluginName] ?? false)) { + $GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['extbase']['extensions'][$extensionName]['plugins'][$pluginName] = []; + } + foreach ($controllerActions as $controllerClassName => $actionsList) { + $controllerAlias = self::resolveControllerAliasFromControllerClassName($controllerClassName); + + $GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['extbase']['extensions'][$extensionName]['plugins'][$pluginName]['controllers'][$controllerClassName] = [ + 'className' => $controllerClassName, + 'alias' => $controllerAlias, + 'actions' => $actionsList, + ]; + + if (!empty($nonCacheableControllerActions[$controllerClassName])) { + $GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['extbase']['extensions'][$extensionName]['plugins'][$pluginName]['controllers'][$controllerClassName]['nonCacheableActions'] + = $nonCacheableControllerActions[$controllerClassName]; + } + } + } + + /** + * Register an Extbase PlugIn into backend's list of plugins + * FOR USE IN Configuration/TCA/Overrides/tt_content.php + * + * @param string $extensionName The extension name (in UpperCamelCase) or the extension key (in lower_underscore) + * @param string $pluginName must be a unique id for your plugin in UpperCamelCase (the string length of the extension key added to the length of the plugin name should be less than 32!) + * @param string $pluginTitle is a speaking title of the plugin that will be displayed in the drop down menu in the backend + * @param string|null $pluginIcon is an icon identifier or file path prepended with "EXT:", that will be displayed in the drop down menu in the backend (optional) + * @param string $group add this plugin to a plugin group, should be something like "news" or the like, "plugins" as regular + * @param string $pluginDescription additional description + * @param string $flexForm The flex form (data structure) to be used for the plugin. Either a reference to a flex-form XML file (eg. "FILE:EXT:newloginbox/flexform_ds.xml") or the XML directly. + * @throws \InvalidArgumentException + */ + public static function registerPlugin($extensionName, $pluginName, $pluginTitle, $pluginIcon = null, $group = 'plugins', string $pluginDescription = '', string $flexForm = ''): string + { + self::checkPluginNameFormat($pluginName); + self::checkExtensionNameFormat($extensionName); + + $extensionName = str_replace(' ', '', ucwords(str_replace('_', ' ', $extensionName))); + $pluginSignature = strtolower($extensionName) . '_' . strtolower($pluginName); + + ExtensionManagementUtility::addPlugin( + new SelectItem( + 'select', + // set pluginName as default pluginTitle + $pluginTitle ?: $pluginName, + $pluginSignature, + $pluginIcon ?? 'content-plugin', + $group, + $pluginDescription + ), + $flexForm + ); + return $pluginSignature; + } + + /** + * @internal only used for TYPO3 Core + */ + public static function resolveControllerAliasFromControllerClassName(string $controllerClassName): string + { + // This method has been adjusted for TYPO3 10.3 to mitigate the issue that controller aliases + // could not longer be calculated from controller classes when calling + // \TYPO3\CMS\Extbase\Utility\ExtensionUtility::configurePlugin(). + // + // The idea for version 11 is to let the user choose a controller alias and to check for its + // uniqueness per plugin. That way, the core does no longer rely on the namespace of + // controller classes to be in a specific format. + // + // todo: Change the way plugins are registered and enforce a controller alias to be set by + // the user to also free the core from guessing a simple alias by looking at the + // class name. This makes it possible to choose controller class names without a + // controller suffix. + + $strLen = strlen('Controller'); + + if (!str_ends_with($controllerClassName, 'Controller')) { + return ''; + } + + $controllerClassNameWithoutControllerSuffix = substr($controllerClassName, 0, -$strLen); + + if (strrpos($controllerClassNameWithoutControllerSuffix, 'Controller\\') === false) { + $positionOfLastSlash = (int)strrpos($controllerClassNameWithoutControllerSuffix, '\\'); + $positionOfLastSlash += $positionOfLastSlash === 0 ? 0 : 1; + + return substr($controllerClassNameWithoutControllerSuffix, $positionOfLastSlash); + } + + $positionOfControllerNamespacePart = (int)strrpos( + $controllerClassNameWithoutControllerSuffix, + 'Controller\\' + ); + + return substr( + $controllerClassNameWithoutControllerSuffix, + $positionOfControllerNamespacePart + $strLen + 1 + ); + } + + /** + * @param array $controllerActions + * @return array + */ + protected static function actionCommaListToArray(array $controllerActions): array + { + foreach ($controllerActions as $controllerClassName => $actionsList) { + if (is_array($actionsList)) { + continue; + } + $actionsListArray = GeneralUtility::trimExplode(',', (string)$actionsList); + $controllerActions[$controllerClassName] = $actionsListArray; + } + return $controllerActions; + } + + /** + * Check a given extension name for validity. + * + * @param string $extensionName The name of the extension + * @throws \InvalidArgumentException + */ + protected static function checkExtensionNameFormat($extensionName) + { + if (empty($extensionName)) { + throw new \InvalidArgumentException('The extension name must not be empty', 1239891990); + } + } + + /** + * Check a given plugin name for validity. + * + * @param string $pluginName The name of the plugin + * @throws \InvalidArgumentException + */ + protected static function checkPluginNameFormat($pluginName) + { + if (empty($pluginName)) { + throw new \InvalidArgumentException('The plugin name must not be empty', 1239891988); + } + } +} diff --git a/Classes/Utility/LocalizationUtility.php b/Classes/Utility/LocalizationUtility.php new file mode 100644 index 0000000..7af561f --- /dev/null +++ b/Classes/Utility/LocalizationUtility.php @@ -0,0 +1,161 @@ + 42]. + * @param Locale|string|null $languageKey The language key or null for using the current language from the system + * @return string|null The value from LOCAL_LANG or null if no translation was found. + */ + public static function translate(string $key, ?string $extensionName = null, ?array $arguments = null, Locale|string|null $languageKey = null, ?ServerRequestInterface $request = null): ?string + { + if ($key === '') { + // Early return guard: returns null if the key was empty, because the key may be a dynamic value + // (from for example Fluid). Returning null allows null coalescing to a default value when that happens. + return null; + } + + $languageFilePath = null; + if (str_starts_with($key, 'LLL:EXT:')) { + $keyParts = explode(':', $key); + unset($keyParts[0]); + $key = array_pop($keyParts); + $languageFilePath = implode(':', $keyParts); + } elseif ($extensionName) { + if (str_contains($extensionName, '.')) { + // We assume this is a valid domain now + $languageFilePath = $extensionName; + $extensionName = self::getExtensionNameFromDomain($languageFilePath); + } else { + $languageFilePath = GeneralUtility::camelCaseToLowerCaseUnderscored($extensionName) . '.messages'; + } + } elseif (str_contains($key, ':')) { + [$languageFilePath, $key, $extensionName] = self::extractLanguageInfoFromDomainString($key); + } + if ($languageFilePath === null || $key === '') { + throw new \InvalidArgumentException( + 'Parameter $extensionName cannot be empty if a fully-qualified key is not specified.', + 1498144052 + ); + } + $request = $request ?? $GLOBALS['TYPO3_REQUEST'] ?? null; + $locale = self::getLocale($languageKey, $request); + $languageService = GeneralUtility::makeInstance(LanguageServiceFactory::class)->create($locale); + if (!empty($extensionName) && $request instanceof ServerRequestInterface) { + $typoScript = $request->getAttribute('frontend.typoscript'); + if ($typoScript instanceof FrontendTypoScript) { + // Loads local-language values by looking for a "locallang.xlf" file in the plugin resources directory and if found includes it. + // Locallang values set in the TypoScript property "_LOCAL_LANG" are merged onto the values found in the "locallang.xlf" file. + $overrideLabels = $languageService->loadTypoScriptLabelsFromExtension($extensionName, $typoScript, self::getPluginName($request)); + if ($overrideLabels !== []) { + $languageService->overrideLabels($languageFilePath, $overrideLabels); + } + } + } + + try { + return $languageService->translate($key, $languageFilePath, $arguments ?? []); + } catch (\ValueError $e) { + return sprintf('Error: could not translate key "%s" with value "%s" and %d argument(s)!', $key, $e->getMessage(), count($arguments)); + } + } + + /** + * Resolves the currently active locale. + * Using the Locales factory, as it handles dependencies (e.g. "de-AT" falls back to "de"). + */ + protected static function getLocale(Locale|string|null $localeOrLanguageKey, ?ServerRequestInterface $request): Locale + { + if ($localeOrLanguageKey instanceof Locale) { + return $localeOrLanguageKey; + } + $localeFactory = GeneralUtility::makeInstance(Locales::class); + if (is_string($localeOrLanguageKey)) { + return $localeFactory->createLocale($localeOrLanguageKey); + } + return $localeFactory->createLocaleFromRequest($request); + } + + /** + * Allow plugin.tx_myextension._LOCAL_LANG and plugin.tx_myextension_myplugin._LOCAL_LANG + */ + protected static function getPluginName(?ServerRequestInterface $request): string + { + if ($request instanceof Request) { + return strtolower($request->getPluginName()); + } + return ''; + } + + private static function getExtensionNameFromDomain(string $possibleDomain): ?string + { + [$extensionName, $filePart] = explode('.', $possibleDomain, 2); + if ($filePart !== 'messages') { + return null; + } + return $extensionName; + } + + /** + * @return array + */ + private static function extractLanguageInfoFromDomainString(string $key): array + { + $languageFilePath = null; + $extensionName = null; + + if (str_starts_with($key, 'LLL:')) { + $key = substr($key, 4); + } + + [$possibleDomain, $possibleId] = explode(':', $key, 2); + $domainMapper = GeneralUtility::makeInstance(TranslationDomainMapper::class); + $domainResolver = GeneralUtility::makeInstance(TranslationDomainResolver::class); + if ($domainResolver->isValidDomainName($possibleDomain) && $domainMapper->mapDomainToFileName($possibleDomain) !== $possibleDomain) { + $languageFilePath = $possibleDomain; + $key = trim($possibleId); + $extensionName = self::getExtensionNameFromDomain($languageFilePath); + } + return [ + $languageFilePath, + $key, + $extensionName, + ]; + } +} diff --git a/Classes/Utility/TypeHandlingUtility.php b/Classes/Utility/TypeHandlingUtility.php new file mode 100644 index 0000000..c23797b --- /dev/null +++ b/Classes/Utility/TypeHandlingUtility.php @@ -0,0 +1,152 @@ +integer|int|float|double|boolean|bool|string|DateTimeImmutable|DateTime|Country|[A-Z][a-zA-Z0-9\\\\]+|object|resource|array|ArrayObject|SplObjectStorage|TYPO3\\\\CMS\\\\Extbase\\\\Persistence\\\\ObjectStorage)(?:<\\\\?(?P[a-zA-Z0-9\\\\]+)>)?/'; + + /** + * A type pattern to detect literal types. + */ + public const LITERAL_TYPE_PATTERN = '/^(?:integer|int|float|double|boolean|bool|string)$/'; + + /** + * @var array + */ + protected static $collectionTypes = ['array', \ArrayObject::class, \SplObjectStorage::class, ObjectStorage::class]; + + /** + * Returns an array with type information, including element type for + * collection types (array, SplObjectStorage, ...) + * + * @param string $type Type of the property (see PARSE_TYPE_PATTERN) + * @return array An array with information about the type + * @throws \TYPO3\CMS\Extbase\Utility\Exception\InvalidTypeException + */ + public static function parseType(string $type): array + { + $matches = []; + if (preg_match(self::PARSE_TYPE_PATTERN, $type, $matches)) { + $type = self::normalizeType($matches['type']); + $elementType = isset($matches['elementType']) ? self::normalizeType($matches['elementType']) : null; + + if ($elementType !== null && !self::isCollectionType($type)) { + throw new InvalidTypeException('Found an invalid element type declaration in %s. Type "' . $type . '" must not have an element type hint (' . $elementType . ').', 1264093642); + } + + return [ + 'type' => $type, + 'elementType' => $elementType, + ]; + } + throw new InvalidTypeException('Found an invalid element type declaration in %s. A type "' . var_export($type, true) . '" does not exist.', 1264093630); + } + + /** + * Normalize data types so they match the PHP type names: + * int -> integer + * double -> float + * bool -> boolean + * + * @param string $type Data type to unify + * @return string unified data type + */ + public static function normalizeType(string $type): string + { + switch ($type) { + case 'int': + $type = 'integer'; + break; + case 'bool': + $type = 'boolean'; + break; + case 'double': + $type = 'float'; + break; + } + return $type; + } + + /** + * Returns TRUE if the $type is a literal. + */ + public static function isLiteral(string $type): bool + { + return preg_match(self::LITERAL_TYPE_PATTERN, $type) === 1; + } + + /** + * Returns TRUE if the $type is a simple type. + */ + public static function isSimpleType(string $type): bool + { + return in_array(self::normalizeType($type), ['array', 'string', 'float', 'integer', 'boolean'], true); + } + + /** + * Returns TRUE if the $type is a CMS core type object. + * + * @param string|object $type + */ + public static function isCoreType($type): bool + { + return is_subclass_of($type, TypeInterface::class); + } + + /** + * Returns TRUE if the $type is a collection type. + */ + public static function isCollectionType(string $type): bool + { + if (in_array($type, self::$collectionTypes, true)) { + return true; + } + + if (class_exists($type) === true || interface_exists($type) === true) { + foreach (self::$collectionTypes as $collectionType) { + if (is_subclass_of($type, $collectionType) === true) { + return true; + } + } + } + + return false; + } + + /** + * Returns TRUE when the given value can be used in an "in" comparison in a query. + * + * @param mixed $value + */ + public static function isValidTypeForMultiValueComparison($value): bool + { + return is_iterable($value); + } +} diff --git a/Classes/Validation/Error.php b/Classes/Validation/Error.php new file mode 100644 index 0000000..e4f07cf --- /dev/null +++ b/Classes/Validation/Error.php @@ -0,0 +1,32 @@ + + */ + protected \SplObjectStorage $validators; + protected ?ServerRequestInterface $request = null; + protected \SplObjectStorage $validatedInstancesContainer; + + public function setOptions(array $options): void + { + $this->initializeDefaultOptions($options); + } + + /** + * Adds a new validator to the composition. + */ + public function addValidator(ValidatorInterface $validator): void + { + $this->validators->offsetSet($validator); + } + + public function getRequest(): ?ServerRequestInterface + { + return $this->request; + } + + public function setRequest(?ServerRequestInterface $request): void + { + $this->request = $request; + } + + /** + * Removes the specified validator. + * + * @throws NoSuchValidatorException + */ + public function removeValidator(ValidatorInterface $validator): void + { + if (!$this->validators->offsetExists($validator)) { + throw new NoSuchValidatorException('Cannot remove validator because its not in the conjunction.', 1207020177); + } + $this->validators->offsetUnset($validator); + } + + /** + * Returns the number of validators contained in this composition. + */ + public function count(): int + { + return count($this->validators); + } + + /** + * Returns the child validators of this Composite Validator + * + * @return \SplObjectStorage + */ + public function getValidators(): \SplObjectStorage + { + return $this->validators; + } + + /** + * Returns the options for this validator + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * Allows to set a container to keep track of validated instances. + */ + public function setValidatedInstancesContainer(\SplObjectStorage $validatedInstancesContainer): void + { + $this->validatedInstancesContainer = $validatedInstancesContainer; + } + + /** + * Initialize default options. + * @throws InvalidValidationOptionsException + */ + protected function initializeDefaultOptions(array $options): void + { + // check for options given but not supported + if (($unsupportedOptions = array_diff_key($options, $this->supportedOptions)) !== []) { + throw new InvalidValidationOptionsException('Unsupported validation option(s) found: ' . implode(', ', array_keys($unsupportedOptions)), 1339079804); + } + // check for required options being set + array_walk( + $this->supportedOptions, + static function (array $supportedOptionData, string $supportedOptionName, array $options): void { + if (isset($supportedOptionData[3]) && !array_key_exists($supportedOptionName, $options)) { + throw new InvalidValidationOptionsException('Required validation option not set: ' . $supportedOptionName, 1339163922); + } + }, + $options + ); + // merge with default values + $this->options = array_merge( + array_map( + static fn(array $value): mixed => $value[0], + $this->supportedOptions + ), + $options + ); + $this->validators = new \SplObjectStorage(); + } +} diff --git a/Classes/Validation/Validator/AbstractGenericObjectValidator.php b/Classes/Validation/Validator/AbstractGenericObjectValidator.php new file mode 100644 index 0000000..b888530 --- /dev/null +++ b/Classes/Validation/Validator/AbstractGenericObjectValidator.php @@ -0,0 +1,178 @@ +> + */ + protected array $propertyValidators = []; + + /** + * @var \SplObjectStorage + */ + protected $validatedInstancesContainer; + + /** + * Checks if the given value is valid according to the validator, and returns + * the Error Messages object which occurred. + * + * @param mixed $value The value that should be validated + */ + public function validate(mixed $value): Result + { + if (is_object($value) && $this->isValidatedAlready($value)) { + return $this->result; + } + + $this->result = new Result(); + if ($this->acceptsEmptyValues === false || $this->isEmpty($value) === false) { + if (!is_object($value)) { + $this->addError('Object expected, %1$s given.', 1241099149, [gettype($value)]); + } elseif ($this->isValidatedAlready($value) === false) { + $this->markInstanceAsValidated($value); + $this->isValid($value); + } + } + + return $this->result; + } + + /** + * Load the property value to be used for validation. + * In case the object is a doctrine proxy, we need to load the real instance first. + */ + protected function getPropertyValue(object $object, string $propertyName): mixed + { + if (ObjectAccess::isPropertyGettable($object, $propertyName)) { + return ObjectAccess::getProperty($object, $propertyName); + } + throw new \RuntimeException( + sprintf( + 'Could not get value of property "%s::%s", make sure the property is either public or has a getter get%3$s(), a hasser has%3$s() or an isser is%3$s().', + get_class($object), + $propertyName, + ucfirst($propertyName) + ), + 1546632293 + ); + } + + /** + * Checks if the specified property of the given object is valid, and adds + * found errors to the $messages object. + * + * @param \Traversable $validators + */ + protected function checkProperty(mixed $value, \Traversable $validators, string $propertyName): void + { + /** @var Result|null $result */ + $result = null; + foreach ($validators as $validator) { + if ($validator instanceof ObjectValidatorInterface) { + $validator->setValidatedInstancesContainer($this->validatedInstancesContainer); + } + $currentResult = $validator->validate($value); + if ($currentResult->hasMessages()) { + if ($result == null) { + $result = $currentResult; + } else { + $result->merge($currentResult); + } + } + } + if ($result != null) { + $this->result->forProperty($propertyName)->merge($result); + } + } + + /** + * Checks if the given value is valid according to the property validators. + */ + protected function isValid(mixed $object): void + { + foreach ($this->propertyValidators as $propertyName => $validators) { + $propertyValue = $this->getPropertyValue($object, $propertyName); + $this->checkProperty($propertyValue, $validators, $propertyName); + } + } + + /** + * Checks the given object can be validated by the validator implementation + */ + public function canValidate(mixed $object): bool + { + return is_object($object); + } + + /** + * Adds the given validator for validation of the specified property. + */ + public function addPropertyValidator(string $propertyName, ValidatorInterface $validator): void + { + if (!isset($this->propertyValidators[$propertyName])) { + $this->propertyValidators[$propertyName] = new \SplObjectStorage(); + } + $this->propertyValidators[$propertyName]->offsetSet($validator); + } + + protected function isValidatedAlready(object $object): bool + { + if ($this->validatedInstancesContainer === null) { + $this->validatedInstancesContainer = new \SplObjectStorage(); + } + if ($this->validatedInstancesContainer->offsetExists($object)) { + return true; + } + + return false; + } + + protected function markInstanceAsValidated(object $object): void + { + $this->validatedInstancesContainer->offsetSet($object); + } + + /** + * Returns all property validators - or only validators of the specified property + * + * @return ($propertyName is null ? array> : \SplObjectStorage) + */ + public function getPropertyValidators(?string $propertyName = null): array|\SplObjectStorage + { + if ($propertyName !== null) { + return $this->propertyValidators[$propertyName] ?? []; + } + return $this->propertyValidators; + } + + /** + * Allows to set a container to keep track of validated instances. + */ + public function setValidatedInstancesContainer(\SplObjectStorage $validatedInstancesContainer): void + { + $this->validatedInstancesContainer = $validatedInstancesContainer; + } +} diff --git a/Classes/Validation/Validator/AbstractValidator.php b/Classes/Validation/Validator/AbstractValidator.php new file mode 100644 index 0000000..3baf349 --- /dev/null +++ b/Classes/Validation/Validator/AbstractValidator.php @@ -0,0 +1,231 @@ +initializeDefaultOptions($options); + $this->initializeTranslationOptions($options); + } + + public function setRequest(?ServerRequestInterface $request): void + { + $this->request = $request; + } + + /** + * Checks if the given value is valid according to the validator, and returns + * the error messages object which occurred. + * + * @param mixed $value The value that should be validated + */ + public function validate(mixed $value): Result + { + $this->result = new Result(); + if ($this->acceptsEmptyValues === false || $this->isEmpty($value) === false) { + $this->isValid($value); + } + return $this->result; + } + + /** + * Check if $value is valid. If it is not valid, needs to add an error to result. + */ + abstract protected function isValid(mixed $value): void; + + /** + * Creates a new validation error object and adds it to $this->result + * + * @param string $message The error message + * @param int $code The error code (a unix timestamp) + * @param array $arguments Arguments to be replaced in message + * @param string $title title of the error + */ + protected function addError(string $message, int $code, array $arguments = [], string $title = ''): void + { + $this->result->addError(new Error($message, $code, $arguments, $title)); + } + + /** + * Creates a new validation error object for a property and adds it to the proper sub result of $this->result + * + * @param string|array $propertyPath The property path (string or array) + * @param string $message The error message + * @param int $code The error code (a unix timestamp) + * @param array $arguments Arguments to be replaced in message + * @param string $title Title of the error + */ + protected function addErrorForProperty(string|array $propertyPath, string $message, int $code, array $arguments = [], string $title = ''): void + { + $propertyPath = is_array($propertyPath) ? implode('.', $propertyPath) : $propertyPath; + $error = new Error($message, $code, $arguments, $title); + $this->result->forProperty($propertyPath)->addError($error); + } + + /** + * Returns the options of this validator + */ + public function getOptions(): array + { + return $this->options; + } + + public function getRequest(): ?ServerRequestInterface + { + return $this->request; + } + + /** + * TRUE if the given $value is NULL or an empty string ('') + */ + final protected function isEmpty(mixed $value): bool + { + return $value === null || $value === ''; + } + + /** + * Translates an error message using LocalizationUtility::translate() method. If the translate key does not + * start with 'LLL:' and if no extension name is provided, the original translate key is returned. + */ + protected function translateErrorMessage( + string $translateKey, + string $extensionName = '', + array $arguments = [] + ): string { + if ($extensionName === '' && !str_starts_with($translateKey, 'LLL:')) { + return $translateKey; + } + + return LocalizationUtility::translate( + $translateKey, + $extensionName, + $arguments + ) ?? ''; + } + + /** + * Initialize default options. + * @throws InvalidValidationOptionsException + */ + protected function initializeDefaultOptions(array $options): void + { + // check for options given but not supported + if (($unsupportedOptions = array_diff_key($options, $this->supportedOptions)) !== []) { + throw new InvalidValidationOptionsException('Unsupported validation option(s) found: ' . implode(', ', array_keys($unsupportedOptions)), 1379981890); + } + // check for required options being set + array_walk( + $this->supportedOptions, + static function (array $supportedOptionData, string $supportedOptionName, array $options): void { + if (isset($supportedOptionData[3]) && $supportedOptionData[3] === true && !array_key_exists($supportedOptionName, $options)) { + throw new InvalidValidationOptionsException('Required validation option not set: ' . $supportedOptionName, 1379981891); + } + }, + $options + ); + // merge with default values + $this->options = array_merge( + array_map( + static fn(array $value): mixed => $value[0], + $this->supportedOptions + ), + $options + ); + } + + /** + * Ensures that the provided value is either an instance of UploadedFile or an ObjectStorage containing only + * UploadedFile instances. + */ + protected function ensureFileUploadTypes(mixed $value): void + { + if ($value instanceof UploadedFile) { + return; + } + + if ($value instanceof ObjectStorage) { + foreach ($value as $uploadedFile) { + if (!$uploadedFile instanceof UploadedFile) { + throw new \InvalidArgumentException('Value to validate must be an ObjectStorage of TYPO3\\CMS\\Core\\Http\\UploadedFile', 1722763902); + } + } + return; + } + + throw new \InvalidArgumentException('Value to validate must be a TYPO3\\CMS\\Core\\Http\\UploadedFile', 1712057926); + } + + protected function getFileInfo(string $filePath): FileInfo + { + return GeneralUtility::makeInstance(FileInfo::class, $filePath); + } + + /** + * Initializes all registered translation options with custom translation options from the given options array + */ + protected function initializeTranslationOptions(array $options): void + { + foreach ($this->translationOptions as $translationOption) { + if (property_exists($this, $translationOption)) { + $this->$translationOption = $options[$translationOption] ?? $this->$translationOption; + } + } + } +} diff --git a/Classes/Validation/Validator/AlphanumericValidator.php b/Classes/Validation/Validator/AlphanumericValidator.php new file mode 100644 index 0000000..2da7cdd --- /dev/null +++ b/Classes/Validation/Validator/AlphanumericValidator.php @@ -0,0 +1,40 @@ + [null, 'Translation key or message for invalid value', 'string'], + ]; + + /** + * The given $value is valid if it is an alphanumeric string, which is defined as [\pL\d]*. + */ + public function isValid(mixed $value): void + { + if (!is_string($value) || preg_match('/^[\pL\d]*$/u', $value) !== 1) { + $this->addError($this->translateErrorMessage($this->message), 1221551320); + } + } +} diff --git a/Classes/Validation/Validator/BooleanValidator.php b/Classes/Validation/Validator/BooleanValidator.php new file mode 100644 index 0000000..2525d63 --- /dev/null +++ b/Classes/Validation/Validator/BooleanValidator.php @@ -0,0 +1,84 @@ + [null, 'Boolean value', 'boolean|string|integer'], + 'notTrueMessage' => [null, 'Translation key or message for not true value', 'string'], + 'notFalseMessage' => [null, 'Translation key or message for not false value', 'string'], + ]; + + /** + * Check if $value matches the expectation given to the validator. + * If it does not match, the function adds an error to the result. + * + * Also testing for '1' (true), '0' and '' (false) because casting varies between + * tests and actual usage. This makes the validator loose but still keeping functionality. + */ + public function isValid(mixed $value): void + { + // see comment above, check if expectation is NULL, then nothing to do! + if ($this->options['is'] === null) { + return; + } + switch (strtolower((string)$this->options['is'])) { + case 'true': + case '1': + $expectation = true; + break; + case 'false': + case '': + case '0': + $expectation = false; + break; + default: + $this->addError('The given expectation is not valid.', 1361959227); + return; + } + + if ($value !== $expectation) { + if (!is_bool($value)) { + $this->addError($this->translateErrorMessage($this->notTrueMessage), 1361959230); + } else { + if ($expectation) { + $this->addError($this->translateErrorMessage($this->notTrueMessage), 1361959228); + } else { + $this->addError($this->translateErrorMessage($this->notFalseMessage), 1361959229); + } + } + } + } +} diff --git a/Classes/Validation/Validator/CollectionValidator.php b/Classes/Validation/Validator/CollectionValidator.php new file mode 100644 index 0000000..08b6224 --- /dev/null +++ b/Classes/Validation/Validator/CollectionValidator.php @@ -0,0 +1,91 @@ + [null, 'The validator type to use for the collection elements', 'string'], + 'elementType' => [null, 'The type of the elements in the collection', 'string'], + ]; + + public function __construct(private readonly ValidatorResolver $validatorResolver) {} + + /** + * Checks if the given value is valid according to the validator, and returns + * the Error Messages object which occurred. + */ + public function validate(mixed $value): Result + { + $this->result = new Result(); + + if ($this->acceptsEmptyValues === false || $this->isEmpty($value) === false) { + if ((is_object($value) && !TypeHandlingUtility::isCollectionType(get_class($value))) && !is_array($value)) { + $this->addError('The given subject was not a collection.', 1317204797); + return $this->result; + } + if ($value instanceof LazyObjectStorage && !$value->isInitialized()) { + return $this->result; + } + if (is_object($value)) { + if ($this->isValidatedAlready($value)) { + return $this->result; + } + $this->markInstanceAsValidated($value); + } + $this->isValid($value); + } + return $this->result; + } + + /** + * Checks for a collection and if needed validates the items in the collection. + * This is done with the specified element validator or a validator based on + * the given element type. + * + * Either elementValidator or elementType must be given, otherwise validation + * will be skipped. + */ + protected function isValid(mixed $value): void + { + foreach ($value as $index => $collectionElement) { + if (isset($this->options['elementValidator'])) { + $collectionElementValidator = $this->validatorResolver->createValidator($this->options['elementValidator']); + } elseif (isset($this->options['elementType'])) { + $collectionElementValidator = $this->validatorResolver->getBaseValidatorConjunction($this->options['elementType']); + } else { + return; + } + if ($collectionElementValidator instanceof ObjectValidatorInterface) { + $collectionElementValidator->setValidatedInstancesContainer($this->validatedInstancesContainer); + } + $this->result->forProperty((string)$index)->merge($collectionElementValidator->validate($collectionElement)); + } + } +} diff --git a/Classes/Validation/Validator/ConjunctionValidator.php b/Classes/Validation/Validator/ConjunctionValidator.php new file mode 100644 index 0000000..8b69711 --- /dev/null +++ b/Classes/Validation/Validator/ConjunctionValidator.php @@ -0,0 +1,48 @@ +validators = new \SplObjectStorage(); + $this->validatedInstancesContainer = new \SplObjectStorage(); + } + + /** + * Checks if the given value is valid according to the validators of the conjunction. + * Every validator has to be valid, to make the whole conjunction valid. + * + * @param mixed $value The value that should be validated + */ + public function validate(mixed $value): Result + { + $result = new Result(); + foreach ($this->getValidators() as $validator) { + $result->merge($validator->validate($value)); + } + + return $result; + } +} diff --git a/Classes/Validation/Validator/ConstraintDecoratingValidator.php b/Classes/Validation/Validator/ConstraintDecoratingValidator.php new file mode 100644 index 0000000..0f99944 --- /dev/null +++ b/Classes/Validation/Validator/ConstraintDecoratingValidator.php @@ -0,0 +1,123 @@ +disableTranslation()->getValidator(); + $constraintViolationList = $validator->validate($value, $this->constraint); + $result = new Result(); + + foreach ($constraintViolationList as $constraintViolation) { + $arguments = array_values($constraintViolation->getParameters()); + $code = $this->convertConstraintViolationCode($constraintViolation->getCode()); + $messageTemplate = $this->convertConstraintViolationMessageTemplate($constraintViolation); + + $result->addError( + new Error( + $this->translateErrorMessage($messageTemplate, $arguments), + $code, + $arguments, + ), + ); + } + + return $result; + } + + /** + * Convert UUID-based violation code to an integer. + */ + private function convertConstraintViolationCode(?string $code): int + { + if ($code !== null && $code !== '') { + $hash = hash('sha256', $code); + $code = hexdec(substr($hash, 0, 8)); + } + + return (int)$code; + } + + /** + * Convert named placeholders like {{ value }} to sprintf compatible placeholders like %1$s. + * + * Before: 'The value {{ value }} must follow the {{ format }} format.' + * After: 'The value %1$s must follow the %2$s format.' + */ + private function convertConstraintViolationMessageTemplate(ConstraintViolationInterface $constraintViolation): string + { + $placeholderMap = []; + + foreach (array_keys($constraintViolation->getParameters()) as $index => $placeholder) { + $placeholderMap[$placeholder] = '%' . ($index + 1) . '$s'; + } + + return strtr($constraintViolation->getMessageTemplate(), $placeholderMap); + } + + /** + * @param list $arguments + */ + private function translateErrorMessage(string $translateKey, array $arguments = []): string + { + if (!str_starts_with($translateKey, 'LLL:')) { + return $translateKey; + } + + return LocalizationUtility::translate($translateKey, null, $arguments) ?? ''; + } + + public function setOptions(array $options): void + { + // Intentionally left blank. + } + + public function getOptions(): array + { + return []; + } + + public function setRequest(?ServerRequestInterface $request): void + { + // Intentionally left blank. + } + + public function getRequest(): ?ServerRequestInterface + { + return null; + } +} diff --git a/Classes/Validation/Validator/DateTimeValidator.php b/Classes/Validation/Validator/DateTimeValidator.php new file mode 100644 index 0000000..6d31c3e --- /dev/null +++ b/Classes/Validation/Validator/DateTimeValidator.php @@ -0,0 +1,53 @@ + [null, 'Translation key or message for invalid value', 'string'], + ]; + + /** + * Checks if the given value is a valid DateTime object. If this is not + * the case, the function adds an error. + */ + public function isValid(mixed $value): void + { + $this->result->clear(); + if ($value instanceof \DateTimeInterface) { + return; + } + $this->addError( + $this->translateErrorMessage( + $this->message, + '', + [ + gettype($value), + ] + ), + 1238087674, + [gettype($value)] + ); + } +} diff --git a/Classes/Validation/Validator/DisjunctionValidator.php b/Classes/Validation/Validator/DisjunctionValidator.php new file mode 100644 index 0000000..abc87b5 --- /dev/null +++ b/Classes/Validation/Validator/DisjunctionValidator.php @@ -0,0 +1,70 @@ +validators = new \SplObjectStorage(); + $this->validatedInstancesContainer = new \SplObjectStorage(); + } + + /** + * Checks if the given value is valid according to the validators of the + * disjunction. + * + * So only one validator has to be valid, to make the whole disjunction valid. + * Errors are only returned if all validators failed. + * + * @param mixed $value The value that should be validated + */ + public function validate(mixed $value): Result + { + $validators = $this->getValidators(); + if ($validators->count() > 0) { + $result = null; + foreach ($validators as $validator) { + $validatorResult = $validator->validate($value); + if ($validatorResult->hasErrors()) { + if ($result === null) { + $result = $validatorResult; + } else { + $result->merge($validatorResult); + } + } else { + if ($result === null) { + $result = $validatorResult; + } else { + $result->clear(); + } + break; + } + } + } else { + $result = new Result(); + } + + return $result; + } +} diff --git a/Classes/Validation/Validator/EmailAddressValidator.php b/Classes/Validation/Validator/EmailAddressValidator.php new file mode 100644 index 0000000..dbb914c --- /dev/null +++ b/Classes/Validation/Validator/EmailAddressValidator.php @@ -0,0 +1,53 @@ + [null, 'Translation key or message for invalid value', 'string'], + ]; + + /** + * Checks if the given value is a valid email address. + */ + public function isValid(mixed $value): void + { + if (!is_string($value) || !$this->validEmail($value)) { + $this->addError($this->translateErrorMessage($this->message), 1221559976); + } + } + + /** + * Checking syntax of input email address + * + * @param string $emailAddress Input string to evaluate + * @return bool Returns TRUE if the $email address (input string) is valid + */ + private function validEmail(string $emailAddress): bool + { + return GeneralUtility::validEmail($emailAddress); + } +} diff --git a/Classes/Validation/Validator/FileExtensionMimeTypeConsistencyValidator.php b/Classes/Validation/Validator/FileExtensionMimeTypeConsistencyValidator.php new file mode 100644 index 0000000..6b6dcfe --- /dev/null +++ b/Classes/Validation/Validator/FileExtensionMimeTypeConsistencyValidator.php @@ -0,0 +1,96 @@ + [null, 'Translation key or message for inconsistent mime-type for file extension', 'string'], + ]; + + public function isValid(mixed $value): void + { + $this->ensureFileUploadTypes($value); + + if ($value instanceof UploadedFile) { + $this->validateUploadedFile($value); + } elseif ($value instanceof ObjectStorage && $value->count() > 0) { + $index = 0; + foreach ($value as $uploadedFile) { + $this->validateUploadedFile($uploadedFile, $index); + $index++; + } + } + } + + private function validateUploadedFile(UploadedFile $uploadedFile, ?int $index = null): void + { + $fileInfo = $this->getFileInfo($uploadedFile->getTemporaryFileName()); + $mimeType = $fileInfo->getMimeType(); + + // The file extension of the uploaded file must match the mime-type for this file. + // Example: myfile.txt is actually a PDF file (defined by mime-type), but .txt is not associated + // for application/pdf, so this is not valid. + $fileExtension = pathinfo($uploadedFile->getClientFilename(), PATHINFO_EXTENSION); + $assumedMimesTypeOfFileExtension = (new MimeTypeDetector())->getMimeTypesForFileExtension($fileExtension); + + // pass, in case no assumed mime-type was found (e.g., for individual file extension) + if ($mimeType === '' || $assumedMimesTypeOfFileExtension === []) { + return; + } + + // Example in case of "exe", which has over 9000 possible MIME types: + // mime-db/db.json is only aware of "application/octet-stream", "application/x-msdos-program", "application/x-msdownload" + // However, PHP detects this as "application/vnd.microsoft.portable-executable" (PHP 8.4+) or "application/x-dosexec" (PHP < 8.4) + // DefaultConfiguration $GLOBALS['TYPO3_CONF_VARS']['SYS']['FileInfo']['mimeTypeCompatibility'] registers this (and some more), + // so we also need these fallbacks to be evaluated. + $mimeTypeCompatibility = (new MimeTypeCompatibilityTypeGuesser())->getMimeTypeCompatibilityList(); + $additionalMappedMimeType = $mimeTypeCompatibility[$mimeType][$fileExtension] ?? null; + + if (!in_array($mimeType, $assumedMimesTypeOfFileExtension, true) + && !in_array($additionalMappedMimeType, $assumedMimesTypeOfFileExtension, true) + ) { + $message = $this->translateErrorMessage( + $this->notAllowedMessage, + '', + [$mimeType, $fileExtension] + ); + $code = 1754045716; + if ($index !== null) { + $this->addErrorForProperty((string)$index, $message, $code); + } else { + $this->addError($message, $code); + } + } + } +} diff --git a/Classes/Validation/Validator/FileExtensionValidator.php b/Classes/Validation/Validator/FileExtensionValidator.php new file mode 100644 index 0000000..1376243 --- /dev/null +++ b/Classes/Validation/Validator/FileExtensionValidator.php @@ -0,0 +1,108 @@ + [null, 'Allowed file extensions', 'array'], + 'useStorageDefaults' => [null, 'Whether to use the default allowed file extension of the storage', 'bool'], + 'notAllowedMessage' => [null, 'Translation key or message for disallowed file extension', 'string'], + ]; + + public function isValid(mixed $value): void + { + $this->ensureFileUploadTypes($value); + $this->validateOptions(); + + if ($value instanceof UploadedFile) { + $this->validateUploadedFile($value); + } elseif ($value instanceof ObjectStorage && $value->count() > 0) { + $index = 0; + foreach ($value as $uploadedFile) { + $this->validateUploadedFile($uploadedFile, $index); + $index++; + } + } + } + + private function validateUploadedFile(UploadedFile $uploadedFile, ?int $index = null): void + { + $allowedFileExtensions = []; + if (!empty($this->options['useStorageDefaults'])) { + $allowedFileExtensions = GeneralUtility::trimExplode( + ',', + ($GLOBALS['TYPO3_CONF_VARS']['SYS']['textfile_ext'] ?? '') . ',' + . ($GLOBALS['TYPO3_CONF_VARS']['SYS']['mediafile_ext'] ?? '') . ',' + . ($GLOBALS['TYPO3_CONF_VARS']['SYS']['miscfile_ext'] ?? ''), + true + ); + } + if (is_array($this->options['allowedFileExtensions'] ?? null)) { + $allowedFileExtensions = array_merge($allowedFileExtensions, $this->options['allowedFileExtensions']); + } + $allowedFileExtensions = array_map(mb_strtolower(...), $allowedFileExtensions); + $fileExtension = pathinfo($uploadedFile->getClientFilename(), PATHINFO_EXTENSION); + + if (!in_array($fileExtension, $allowedFileExtensions, true)) { + $message = $this->translateErrorMessage( + $this->notAllowedMessage, + '', + [$fileExtension] + ); + $code = 1754043401; + if ($index !== null) { + $this->addErrorForProperty((string)$index, $message, $code); + } else { + $this->addError($message, $code); + } + } + } + + /** + * Checks if this validator is correctly configured + */ + private function validateOptions(): void + { + $hasAllowedFileExtensions = is_array($this->options['allowedFileExtensions'] ?? null) + && $this->options['allowedFileExtensions'] !== []; + $shallUseStorageDefaults = !empty($this->options['useStorageDefaults']); + + if (!$hasAllowedFileExtensions && !$shallUseStorageDefaults) { + throw new InvalidValidationOptionsException( + 'Either the option "allowedFileExtensions" must be an array with at least one item, ' + . 'or the option "useStorageDefaults" must be enabled.', + 1754043328 + ); + } + } +} diff --git a/Classes/Validation/Validator/FileNameValidator.php b/Classes/Validation/Validator/FileNameValidator.php new file mode 100644 index 0000000..2bb4bd1 --- /dev/null +++ b/Classes/Validation/Validator/FileNameValidator.php @@ -0,0 +1,64 @@ + [null, 'Translation key or message for invalid value', 'string'], + ]; + + public function isValid(mixed $value): void + { + $this->ensureFileUploadTypes($value); + + if ($value instanceof UploadedFile) { + $this->validateUploadedFile($value); + } elseif ($value instanceof ObjectStorage && $value->count() > 0) { + $index = 0; + foreach ($value as $uploadedFile) { + $this->validateUploadedFile($uploadedFile, $index); + $index++; + } + } + } + + private function validateUploadedFile(UploadedFile $uploadedFile, ?int $index = null): void + { + if (!GeneralUtility::makeInstance(CoreFileNameValidator::class)->isValid($uploadedFile->getClientFilename())) { + $message = $this->translateErrorMessage($this->message); + $code = 1711367029; + if ($index !== null) { + $this->addErrorForProperty((string)$index, $message, $code); + } else { + $this->addError($message, $code); + } + } + } +} diff --git a/Classes/Validation/Validator/FileSizeValidator.php b/Classes/Validation/Validator/FileSizeValidator.php new file mode 100644 index 0000000..889bc13 --- /dev/null +++ b/Classes/Validation/Validator/FileSizeValidator.php @@ -0,0 +1,114 @@ + ['0B', 'The minimum file size to accept', 'string'], + 'maximum' => [PHP_INT_MAX . 'B', 'The maximum file size to accept', 'string'], + 'lessMessage' => [null, 'Translation key or message for value less than minimum', 'string'], + 'exceedMessage' => [null, 'Translation key or message for value exceeds maximum', 'string'], + 'byteSizeUnits' => [' Bytes| Kilobyte| Megabyte| Gigabyte', 'Byte size units string for "formatSize" function', 'string'], + ]; + + public function isValid(mixed $value): void + { + $this->ensureFileUploadTypes($value); + $this->validateOptions(); + + if ($value instanceof UploadedFile) { + $this->validateUploadedFile($value); + } elseif ($value instanceof ObjectStorage && $value->count() > 0) { + $index = 0; + foreach ($value as $uploadedFile) { + $this->validateUploadedFile($uploadedFile, $index); + $index++; + } + } + } + + private function validateUploadedFile(UploadedFile $uploadedFile, ?int $index = null): void + { + $fileSize = $this->getFileInfo($uploadedFile->getTemporaryFileName())->getSize(); + + $minFileSize = GeneralUtility::getBytesFromSizeMeasurement($this->options['minimum']); + if ($this->options['maximum'] !== PHP_INT_MAX . 'B') { + $maxFileSize = GeneralUtility::getBytesFromSizeMeasurement($this->options['maximum']); + } else { + $maxFileSize = PHP_INT_MAX; + } + + $labels = htmlspecialchars($this->options['byteSizeUnits']); + if ($fileSize < $minFileSize) { + $message = $this->translateErrorMessage( + $this->lessMessage, + '', + [GeneralUtility::formatSize($minFileSize, $labels)] + ); + $code = 1708595754; + if ($index !== null) { + $this->addErrorForProperty((string)$index, $message, $code); + } else { + $this->addError($message, $code); + } + } + if ($fileSize > $maxFileSize) { + $message = $this->translateErrorMessage( + $this->exceedMessage, + '', + [GeneralUtility::formatSize($maxFileSize, $labels)] + ); + $code = 1708595755; + if ($index !== null) { + $this->addErrorForProperty((string)$index, $message, $code); + } else { + $this->addError($message, $code); + } + } + } + + /** + * Checks if this validator is correctly configured + */ + private function validateOptions(): void + { + if (!preg_match('/^(\d*\.?\d+)(B|K|M|G)$/i', $this->options['minimum'])) { + throw new InvalidValidationOptionsException('The option "minimum" has an invalid format. Valid formats are something like this: "10B|K|M|G".', 1708595605); + } + if (!preg_match('/^(\d*\.?\d+)(B|K|M|G)$/i', $this->options['maximum'])) { + throw new InvalidValidationOptionsException('The option "maximum" has an invalid format. Valid formats are something like this: "10B|K|M|G".', 1708595606); + } + } +} diff --git a/Classes/Validation/Validator/FloatValidator.php b/Classes/Validation/Validator/FloatValidator.php new file mode 100644 index 0000000..8c8d428 --- /dev/null +++ b/Classes/Validation/Validator/FloatValidator.php @@ -0,0 +1,46 @@ + [null, 'Translation key or message for invalid value', 'string'], + ]; + + /** + * The given value is valid if it is of type float or a string matching the regular expression [0-9.e+-] + * + * @param mixed $value The value that should be validated + */ + public function isValid(mixed $value): void + { + if (is_float($value)) { + return; + } + + if (!is_string($value) || !str_contains($value, '.') || preg_match('/^[0-9.e+-]+$/', $value) !== 1) { + $this->addError($this->translateErrorMessage($this->message), 1221560288); + } + } +} diff --git a/Classes/Validation/Validator/GenericObjectValidator.php b/Classes/Validation/Validator/GenericObjectValidator.php new file mode 100644 index 0000000..f05d83f --- /dev/null +++ b/Classes/Validation/Validator/GenericObjectValidator.php @@ -0,0 +1,24 @@ + [null, 'The exact width of the image', 'int'], + 'height' => [null, 'The exact height of the image', 'int'], + 'minWidth' => [0, 'The minimum width of the image', 'int'], + 'maxWidth' => [PHP_INT_MAX, 'The maximum width of the image', 'int'], + 'minHeight' => [0, 'The minimum height of the image', 'int'], + 'maxHeight' => [PHP_INT_MAX, 'The maximum heigt of the image', 'int'], + 'heightMessage' => [null, 'Translation key or message for invalid height', 'string'], + 'widthMessage' => [null, 'Translation key or message for invalid width', 'string'], + 'minWidthMessage' => [null, 'Translation key or message for invalid minimum width', 'string'], + 'maxWidthMessage' => [null, 'Translation key or message for invalid maximum width', 'string'], + 'minHeightMessage' => [null, 'Translation key or message for invalid minimum height', 'string'], + 'maxHeightMessage' => [null, 'Translation key or message for invalid maximum height', 'string'], + ]; + + public function isValid(mixed $value): void + { + $this->ensureFileUploadTypes($value); + $this->validateOptions(); + + if ($value instanceof UploadedFile) { + $this->validateUploadedFile($value); + } elseif ($value instanceof ObjectStorage && $value->count() > 0) { + $index = 0; + foreach ($value as $uploadedFile) { + $this->validateUploadedFile($uploadedFile, $index); + $index++; + } + } + } + + private function validateUploadedFile(UploadedFile $uploadedFile, ?int $index = null): void + { + $imageInfo = $this->getImageInfo($uploadedFile->getTemporaryFileName()); + if ($imageInfo->getWidth() === 0 || $imageInfo->getHeight() === 0) { + // Silently ignore files, where the width or height could not be determined. Most likely no image file. + return; + } + + if (isset($this->options['width']) && (int)$this->options['width'] !== $imageInfo->getWidth()) { + $message = $this->translateErrorMessage( + $this->widthMessage, + '', + [$this->options['width']] + ); + $code = 1715964040; + if ($index !== null) { + $this->addErrorForProperty((string)$index, $message, $code); + } else { + $this->addError($message, $code); + } + } + + if (isset($this->options['height']) && (int)$this->options['height'] !== $imageInfo->getHeight()) { + $message = $this->translateErrorMessage( + $this->heightMessage, + '', + [$this->options['height']] + ); + $code = 1715964041; + if ($index !== null) { + $this->addErrorForProperty((string)$index, $message, $code); + } else { + $this->addError($message, $code); + } + } + + if ((int)$this->options['minWidth'] > $imageInfo->getWidth()) { + $message = $this->translateErrorMessage( + $this->minWidthMessage, + '', + [$this->options['minWidth']] + ); + $code = 1715964042; + if ($index !== null) { + $this->addErrorForProperty((string)$index, $message, $code); + } else { + $this->addError($message, $code); + } + } + + if ((int)$this->options['minHeight'] > $imageInfo->getHeight()) { + $message = $this->translateErrorMessage( + $this->minHeightMessage, + '', + [$this->options['minHeight']] + ); + $code = 1715964043; + if ($index !== null) { + $this->addErrorForProperty((string)$index, $message, $code); + } else { + $this->addError($message, $code); + } + } + + if ((int)$this->options['maxWidth'] < $imageInfo->getWidth()) { + $message = $this->translateErrorMessage( + $this->maxWidthMessage, + '', + [$this->options['maxWidth']] + ); + $code = 1715964044; + if ($index !== null) { + $this->addErrorForProperty((string)$index, $message, $code); + } else { + $this->addError($message, $code); + } + } + + if ((int)$this->options['maxHeight'] < $imageInfo->getHeight()) { + $message = $this->translateErrorMessage( + $this->maxHeightMessage, + '', + [$this->options['maxHeight']] + ); + $code = 1715964045; + if ($index !== null) { + $this->addErrorForProperty((string)$index, $message, $code); + } else { + $this->addError($message, $code); + } + } + } + + private function getImageInfo(string $filePath): ImageInfo + { + return GeneralUtility::makeInstance(ImageInfo::class, $filePath); + } + + /** + * Checks if this validator is correctly configured + */ + private function validateOptions(): void + { + if ((int)$this->options['minWidth'] > (int)$this->options['maxWidth']) { + throw new InvalidValidationOptionsException('The option "minWidth" must not be greater than "maxWidth"', 1716008127); + } + if ((int)$this->options['minHeight'] > (int)$this->options['maxHeight']) { + throw new InvalidValidationOptionsException('The option "minHeight" must not be greater than "maxHeight"', 1716008128); + } + } +} diff --git a/Classes/Validation/Validator/IntegerValidator.php b/Classes/Validation/Validator/IntegerValidator.php new file mode 100644 index 0000000..ef753c0 --- /dev/null +++ b/Classes/Validation/Validator/IntegerValidator.php @@ -0,0 +1,40 @@ + [null, 'Translation key or message for invalid value', 'string'], + ]; + + /** + * Checks if the given value is a valid integer. + */ + public function isValid(mixed $value): void + { + if (filter_var($value, FILTER_VALIDATE_INT) === false) { + $this->addError($this->translateErrorMessage($this->message), 1221560494); + } + } +} diff --git a/Classes/Validation/Validator/MimeTypeValidator.php b/Classes/Validation/Validator/MimeTypeValidator.php new file mode 100644 index 0000000..974964f --- /dev/null +++ b/Classes/Validation/Validator/MimeTypeValidator.php @@ -0,0 +1,115 @@ + [null, 'Allowed mime types (using */* IANA media types)', 'array', true], + 'ignoreFileExtensionCheck' => [false, 'If set to "true", the file extension check is disabled. Be aware of security considerations when setting this to "true".', 'boolean'], + 'notAllowedMessage' => [null, 'Translation key or message for not allowed MIME type', 'string'], + 'invalidExtensionMessage' => [null, 'Translation key or message for invalid file extension', 'string'], + ]; + + public function isValid(mixed $value): void + { + $this->ensureFileUploadTypes($value); + $this->validateOptions(); + + if ($value instanceof UploadedFile) { + $this->validateUploadedFile($value); + } elseif ($value instanceof ObjectStorage && $value->count() > 0) { + $index = 0; + foreach ($value as $uploadedFile) { + $this->validateUploadedFile($uploadedFile, $index); + $index++; + } + } + } + + private function validateUploadedFile(UploadedFile $uploadedFile, ?int $index = null): void + { + $fileInfo = $this->getFileInfo($uploadedFile->getTemporaryFileName()); + $mimeType = $fileInfo->getMimeType(); + + $allowedMimeTypes = $this->options['allowedMimeTypes']; + $ignoreFileExtensionCheck = $this->options['ignoreFileExtensionCheck']; + + if (!in_array($mimeType, $allowedMimeTypes, true)) { + $message = $this->translateErrorMessage( + $this->notAllowedMessage, + '', + [$mimeType] + ); + $code = 1708538973; + if ($index !== null) { + $this->addErrorForProperty((string)$index, $message, $code); + } else { + $this->addError($message, $code); + } + } + + if (!$ignoreFileExtensionCheck && !$this->result->hasErrors()) { + // The file extension of the uploaded file must match the mime-type for this file. + // Example: myfile.txt is actually a PDF file (defined by mime-type), but .txt is not associated + // for application/pdf, so this is not valid. + $fileExtension = pathinfo($uploadedFile->getClientFilename(), PATHINFO_EXTENSION); + $assumedMimesTypeOfFileExtension = (new MimeTypeDetector())->getMimeTypesForFileExtension($fileExtension); + if (empty(array_intersect($allowedMimeTypes, $assumedMimesTypeOfFileExtension))) { + $message = $this->translateErrorMessage( + $this->invalidExtensionMessage, + '', + [$fileExtension] + ); + $code = 1718469466; + if ($index !== null) { + $this->addErrorForProperty((string)$index, $message, $code); + } else { + $this->addError($message, $code); + } + } + } + } + + /** + * Checks if this validator is correctly configured + */ + private function validateOptions(): void + { + if (!is_array($this->options['allowedMimeTypes'] ?? false) || $this->options['allowedMimeTypes'] === []) { + throw new InvalidValidationOptionsException('The option "allowedMimeTypes" must be an array with at least one item.', 1708526223); + } + } +} diff --git a/Classes/Validation/Validator/NotEmptyValidator.php b/Classes/Validation/Validator/NotEmptyValidator.php new file mode 100644 index 0000000..707f01f --- /dev/null +++ b/Classes/Validation/Validator/NotEmptyValidator.php @@ -0,0 +1,61 @@ + [null, 'Translation key or message for null value', 'string'], + 'emptyMessage' => [null, 'Translation key or message for empty value', 'string'], + ]; + + /** + * Checks if the given value ($propertyValue) is not empty (NULL, empty string, empty array or empty object). + */ + public function isValid(mixed $value): void + { + if ($value === null) { + $this->addError($this->translateErrorMessage($this->nullMessage), 1221560910); + } + if ($value === '') { + $this->addError($this->translateErrorMessage($this->emptyMessage), 1221560718); + } + if (is_array($value) && empty($value)) { + $this->addError($this->translateErrorMessage($this->emptyMessage), 1347992400); + } + if ($value instanceof \Countable && $value->count() === 0) { + $this->addError($this->translateErrorMessage($this->emptyMessage), 1347992453); + } + } +} diff --git a/Classes/Validation/Validator/NumberRangeValidator.php b/Classes/Validation/Validator/NumberRangeValidator.php new file mode 100644 index 0000000..5b9479d --- /dev/null +++ b/Classes/Validation/Validator/NumberRangeValidator.php @@ -0,0 +1,69 @@ + [0, 'The minimum value to accept', 'integer'], + 'maximum' => [PHP_INT_MAX, 'The maximum value to accept', 'integer'], + 'notValidMessage' => [null, 'Translation key or message for non valid value', 'string'], + 'notInRangeMessage' => [null, 'Translation key or message for value not in range', 'string'], + ]; + + /** + * The given value is valid if it is a number in the specified range. + */ + public function isValid(mixed $value): void + { + if (!is_numeric($value)) { + $this->addError($this->translateErrorMessage($this->notValidMessage), 1221563685); + return; + } + + $minimum = $this->options['minimum']; + $maximum = $this->options['maximum']; + + if ($minimum > $maximum) { + $x = $minimum; + $minimum = $maximum; + $maximum = $x; + } + if ($value < $minimum || $value > $maximum) { + $this->addError($this->translateErrorMessage( + $this->notInRangeMessage, + '', + [ + $minimum, + $maximum, + ] + ), 1221561046, [$minimum, $maximum]); + } + } +} diff --git a/Classes/Validation/Validator/NumberValidator.php b/Classes/Validation/Validator/NumberValidator.php new file mode 100644 index 0000000..1f09fec --- /dev/null +++ b/Classes/Validation/Validator/NumberValidator.php @@ -0,0 +1,40 @@ + [null, 'Translation key or message for invalid value', 'string'], + ]; + + /** + * Checks if the given value is a valid number. + */ + public function isValid(mixed $value): void + { + if (!is_numeric($value)) { + $this->addError($this->translateErrorMessage($this->message), 1221563685); + } + } +} diff --git a/Classes/Validation/Validator/ObjectValidatorInterface.php b/Classes/Validation/Validator/ObjectValidatorInterface.php new file mode 100644 index 0000000..40927b6 --- /dev/null +++ b/Classes/Validation/Validator/ObjectValidatorInterface.php @@ -0,0 +1,31 @@ + ['', 'The regular expression to use for validation, used as given', 'string', true], + 'message' => [null, 'Translation key or message when regular expression results in a no match', 'string'], + ]; + + /** + * Checks if the given value matches the specified regular expression. + * + * @throws InvalidValidationOptionsException + */ + public function isValid(mixed $value): void + { + $result = preg_match($this->options['regularExpression'], $value); + if ($result === 0) { + $this->addError( + $this->translateErrorMessage($this->message), + 1221565130 + ); + } + if ($result === false) { + throw new InvalidValidationOptionsException('regularExpression "' . $this->options['regularExpression'] . '" in RegularExpressionValidator contained an error.', 1298273089); + } + } +} diff --git a/Classes/Validation/Validator/StringLengthValidator.php b/Classes/Validation/Validator/StringLengthValidator.php new file mode 100644 index 0000000..ad86361 --- /dev/null +++ b/Classes/Validation/Validator/StringLengthValidator.php @@ -0,0 +1,118 @@ + [0, 'Minimum length for a valid string', 'integer'], + 'maximum' => [PHP_INT_MAX, 'Maximum length for a valid string', 'integer'], + 'betweenMessage' => [null, 'Translation key or message for value not between minimum and maximum', 'string'], + 'lessMessage' => [null, 'Translation key or message for value less than minimum', 'string'], + 'exceedMessage' => [null, 'Translation key or message for value exceeds maximum', 'string'], + ]; + + /** + * Checks if the given value is a valid string (or can be cast to a string + * if an object is given) and its length is between minimum and maximum + * specified in the validation options. + * + * @throws InvalidValidationOptionsException + */ + public function isValid(mixed $value): void + { + if ($this->options['maximum'] < $this->options['minimum']) { + throw new InvalidValidationOptionsException('The \'maximum\' is shorter than the \'minimum\' in the StringLengthValidator.', 1238107096); + } + + if (is_object($value)) { + if (!method_exists($value, '__toString')) { + $this->addError('The given object could not be converted to a string.', 1238110957); + return; + } + } elseif (!is_string($value)) { + $this->addError('The given value was not a valid string.', 1269883975); + return; + } + + $value = (string)$value; + $stringLength = mb_strlen($value, 'utf-8'); + $isValid = true; + if ($stringLength < $this->options['minimum']) { + $isValid = false; + } + if ($stringLength > $this->options['maximum']) { + $isValid = false; + } + + if ($isValid === false) { + if ($this->options['minimum'] > 0 && $this->options['maximum'] < PHP_INT_MAX) { + $this->addError( + $this->translateErrorMessage( + $this->betweenMessage, + '', + [ + $this->options['minimum'], + $this->options['maximum'], + ] + ), + 1428504122, + [$this->options['minimum'], $this->options['maximum']] + ); + } elseif ($this->options['minimum'] > 0) { + $this->addError( + $this->translateErrorMessage( + $this->lessMessage, + '', + [ + $this->options['minimum'], + ] + ), + 1238108068, + [$this->options['minimum']] + ); + } else { + $this->addError( + $this->translateErrorMessage( + $this->exceedMessage, + '', + [ + $this->options['maximum'], + ] + ), + 1238108069, + [$this->options['maximum']] + ); + } + } + } +} diff --git a/Classes/Validation/Validator/StringValidator.php b/Classes/Validation/Validator/StringValidator.php new file mode 100644 index 0000000..43f1a57 --- /dev/null +++ b/Classes/Validation/Validator/StringValidator.php @@ -0,0 +1,40 @@ + [null, 'Translation key or message for invalid value', 'string'], + ]; + + /** + * Checks if the given value is a string. + */ + public function isValid(mixed $value): void + { + if (!is_string($value)) { + $this->addError($this->translateErrorMessage($this->message), 1238108067); + } + } +} diff --git a/Classes/Validation/Validator/TextValidator.php b/Classes/Validation/Validator/TextValidator.php new file mode 100644 index 0000000..32f6414 --- /dev/null +++ b/Classes/Validation/Validator/TextValidator.php @@ -0,0 +1,44 @@ + [null, 'Translation key or message for invalid value', 'string'], + ]; + + /** + * Checks if the given value is a valid text (contains no XML tags). + * + * Be aware that the value of this check entirely depends on the output context. + * The validated text is not expected to be secure in every circumstance, if you + * want to be sure of that, use a customized regular expression or filter on output. + */ + public function isValid(mixed $value): void + { + if ($value !== strip_tags((string)$value)) { + $this->addError($this->translateErrorMessage($this->message), 1221565786); + } + } +} diff --git a/Classes/Validation/Validator/UrlValidator.php b/Classes/Validation/Validator/UrlValidator.php new file mode 100644 index 0000000..4155aa3 --- /dev/null +++ b/Classes/Validation/Validator/UrlValidator.php @@ -0,0 +1,42 @@ + [null, 'Translation key or message for invalid value', 'string'], + ]; + + /** + * Checks if the given value is a valid url. + */ + public function isValid(mixed $value): void + { + if (!is_string($value) || !GeneralUtility::isValidUrl($value)) { + $this->addError($this->translateErrorMessage($this->message), 1238108078); + } + } +} diff --git a/Classes/Validation/Validator/ValidatorInterface.php b/Classes/Validation/Validator/ValidatorInterface.php new file mode 100644 index 0000000..f22a225 --- /dev/null +++ b/Classes/Validation/Validator/ValidatorInterface.php @@ -0,0 +1,55 @@ + 'Integer', + 'bool' => 'Boolean', + 'double' => 'Float', + 'numeric' => 'Number', + default => ucfirst($type), + }; + } +} diff --git a/Classes/Validation/ValidatorResolver.php b/Classes/Validation/ValidatorResolver.php new file mode 100644 index 0000000..8ce69c4 --- /dev/null +++ b/Classes/Validation/ValidatorResolver.php @@ -0,0 +1,207 @@ +setOptions($validatorOptions); + $validator->setRequest($request); + return $validator; + } catch (NoSuchValidatorException $e) { + GeneralUtility::makeInstance(LogManager::class)->getLogger(__CLASS__)->debug($e->getMessage()); + return null; + } + } + + /** + * Resolves and returns the base validator conjunction for the given data type. + * If no validator could be resolved (which usually means that no validation is necessary), NULL is returned. + * + * @param string $targetClassName The data type to search a validator for. Usually the fully qualified object name + */ + public function getBaseValidatorConjunction( + string $targetClassName, + ?ServerRequestInterface $request = null + ): ConjunctionValidator { + if (!isset($this->baseValidatorConjunctions[$targetClassName])) { + $conjunctionValidator = GeneralUtility::makeInstance(ConjunctionValidator::class); + $this->baseValidatorConjunctions[$targetClassName] = $conjunctionValidator; + // The simpleType check reduces lookups to the class loader + if (!TypeHandlingUtility::isSimpleType($targetClassName) && class_exists($targetClassName)) { + $this->buildBaseValidatorConjunction($conjunctionValidator, $targetClassName, $request); + } + } + return $this->baseValidatorConjunctions[$targetClassName]; + } + + /** + * Builds a base validator conjunction for the given data type. + * + * The base validation rules are those which were declared directly in a class (typically + * a model) through some #[Validate] attributes on properties. + * + * If a property holds a class for which a base validator exists, that property will be + * checked as well, regardless of a validation attribute. + * + * Additionally, if a custom validator was defined for the class in question, it will be added + * to the end of the conjunction. A custom validator is found if it follows the naming convention + * "Replace '\Model\' by '\Validator\' and append 'Validator'". + * + * Example: $targetClassName is TYPO3\Foo\Domain\Model\Quux, then the validator will be found if it has the + * name TYPO3\Foo\Domain\Validator\QuuxValidator + * + * @param class-string $targetClassName The data type to build the validation conjunction for. Needs to be the fully qualified class name. + * @throws NoSuchValidatorException + * @throws \InvalidArgumentException + */ + protected function buildBaseValidatorConjunction( + ConjunctionValidator $conjunctionValidator, + string $targetClassName, + ?ServerRequestInterface $request + ): void { + $classSchema = $this->reflectionService->getClassSchema($targetClassName); + + // Model based validator + /** @var GenericObjectValidator $objectValidator */ + $objectValidator = $this->createValidator(GenericObjectValidator::class); + foreach ($classSchema->getProperties() as $property) { + $primaryType = $property->getPrimaryType(); + if (!$primaryType instanceof TypeAdapter) { + // @todo: The type is only necessary here for further analyzing whether it's a simple type or + // a collection. If this is evaluated in the ClassSchema, this whole code part is not needed + // any longer and can be removed. + throw new \InvalidArgumentException( + sprintf('There is no @var annotation or type declaration for property "%s" in class "%s".', $property->getName(), $targetClassName), + 1363778104 + ); + } + + $propertyTargetClassName = $primaryType->getClassName() ?? $primaryType->getBuiltinType(); + + // Skip transient properties for auto-generated validators (model-typed properties). + // Transient properties are not persisted and may not have public accessors. + if (!TypeHandlingUtility::isSimpleType($propertyTargetClassName) && !$property->isTransient()) { + // The outer simpleType check reduces lookups to the class loader + // @todo: Whether the property holds a simple type or not and whether it holds a collection is known in + // in the ClassSchema. The information could be made available and not evaluated here again. + $primaryCollectionValueType = $property->getPrimaryCollectionValueType(); + if ($primaryType->isCollection() && $primaryCollectionValueType instanceof TypeAdapter) { + /** @var CollectionValidator $collectionValidator */ + $collectionValidator = $this->createValidator( + CollectionValidator::class, + [ + 'elementType' => $primaryCollectionValueType->getClassName() ?? $primaryCollectionValueType->getBuiltinType(), + ], + $request + ); + $objectValidator->addPropertyValidator($property->getName(), $collectionValidator); + } elseif (class_exists($propertyTargetClassName) + && !TypeHandlingUtility::isCoreType($propertyTargetClassName) + && !in_array(SingletonInterface::class, class_implements($propertyTargetClassName) ?: [], true) + ) { + // class_exists($propertyTargetClassName) checks, if the type of the property is an object + // instead of a simple type. Like DateTime or another model. + // + // !TypeHandlingUtility::isCoreType($propertyTargetClassName) checks if the type of the property + // is not a core type, which are Enums and File objects for example. + // @todo: check why these types shouldn't be validated. + // + // !in_array(SingletonInterface::class, class_implements($propertyTargetClassName, true), true) + // checks if the class is an instance of a Singleton + // @todo: check why Singletons shouldn't be validated. + // + // (Alexander Schnitzler) By looking at this code I assume that this is the path for 1:1 + // relations in models. Still, the question remains why it excludes core types and singletons. + // It makes sense on a theoretical level, but I don't see a technical issue allowing these as + // well. + $validatorForProperty = $this->getBaseValidatorConjunction($propertyTargetClassName, $request); + if ($validatorForProperty->count() > 0) { + $objectValidator->addPropertyValidator($property->getName(), $validatorForProperty); + } + } + } + + foreach ($property->getValidators() as $validatorDefinition) { + // @todo: At this point we already have the class name of the validator, thus there is not need + // calling ValidatorClassNameResolver::resolve inside + // \TYPO3\CMS\Extbase\Validation\ValidatorResolver::createValidator once again. However, to + // keep things simple for now, we still use the method createValidator here. In the future, + // createValidator must only accept FQCN's. + if (isset($validatorDefinition['constraint'])) { + $newValidator = new ConstraintDecoratingValidator($validatorDefinition['constraint']); + } else { + $newValidator = $this->createValidator( + $validatorDefinition['className'], + $validatorDefinition['options'], + $request, + ); + } + if ($newValidator === null) { + throw new NoSuchValidatorException( + 'Invalid #[Validate] attribute in ' . $targetClassName . '::' . $property->getName() . ': ' + . 'Could not resolve class name for validator "' . $validatorDefinition['className'] . '".', + 1241098027 + ); + } + $objectValidator->addPropertyValidator($property->getName(), $newValidator); + } + } + + if (!empty($objectValidator->getPropertyValidators())) { + $conjunctionValidator->addValidator($objectValidator); + } + } +} diff --git a/Configuration/Extbase/Persistence/Classes.php b/Configuration/Extbase/Persistence/Classes.php new file mode 100644 index 0000000..e085e2e --- /dev/null +++ b/Configuration/Extbase/Persistence/Classes.php @@ -0,0 +1,15 @@ + [ + 'tableName' => 'sys_file_reference', + ], + \TYPO3\CMS\Extbase\Domain\Model\File::class => [ + 'tableName' => 'sys_file', + ], + \TYPO3\CMS\Extbase\Domain\Model\Category::class => [ + 'tableName' => 'sys_category', + ], +]; diff --git a/Configuration/Services.php b/Configuration/Services.php new file mode 100644 index 0000000..00c72d2 --- /dev/null +++ b/Configuration/Services.php @@ -0,0 +1,34 @@ +registerForAutoconfiguration(Mvc\Controller\ControllerInterface::class)->addTag('extbase.controller'); + $container->registerForAutoconfiguration(Mvc\Controller\ActionController::class)->addTag('extbase.action_controller'); + $container->registerForAutoconfiguration(Validation\Validator\ValidatorInterface::class)->addTag('extbase.validator'); + $container->addCompilerPass(new PublicServicePass('extbase.validator', true)); + + $container->addCompilerPass(new class implements CompilerPassInterface { + public function process(ContainerBuilder $container): void + { + foreach ($container->findTaggedServiceIds('extbase.controller') as $id => $tags) { + $container->findDefinition($id)->setPublic(true); + } + foreach ($container->findTaggedServiceIds('extbase.action_controller') as $id => $tags) { + $container->findDefinition($id)->setShared(false); + } + } + }); + + $container->addCompilerPass(new TypeConverterPass('extbase.type_converter')); + $container->addCompilerPass(new DependencyInjection\RateLimitPass('extbase.action_controller')); + $container->addCompilerPass(new DependencyInjection\AuthorizePass('extbase.action_controller')); +}; diff --git a/Configuration/Services.yaml b/Configuration/Services.yaml new file mode 100644 index 0000000..49dd380 --- /dev/null +++ b/Configuration/Services.yaml @@ -0,0 +1,161 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + TYPO3\CMS\Extbase\: + resource: '../Classes/*' + + # formerly in EXT:extbase/ext_localconf.php + TYPO3\CMS\Extbase\Persistence\QueryInterface: + alias: TYPO3\CMS\Extbase\Persistence\Generic\Query + public: true + TYPO3\CMS\Extbase\Persistence\QueryResultInterface: + alias: TYPO3\CMS\Extbase\Persistence\Generic\QueryResult + public: true + TYPO3\CMS\Extbase\Persistence\PersistenceManagerInterface: + alias: TYPO3\CMS\Extbase\Persistence\Generic\PersistenceManager + public: true + TYPO3\CMS\Extbase\Persistence\Generic\Storage\BackendInterface: + alias: TYPO3\CMS\Extbase\Persistence\Generic\Storage\Typo3DbBackend + TYPO3\CMS\Extbase\Persistence\Generic\QuerySettingsInterface: + alias: TYPO3\CMS\Extbase\Persistence\Generic\Typo3QuerySettings + public: true + TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface: + alias: TYPO3\CMS\Extbase\Configuration\ConfigurationManager + public: true + + cache.extbase: + class: TYPO3\CMS\Core\Cache\Frontend\FrontendInterface + factory: ['@TYPO3\CMS\Core\Cache\CacheManager', 'getCache'] + arguments: ['extbase'] + + TYPO3\CMS\Extbase\Persistence\ClassesConfiguration: + factory: ['@TYPO3\CMS\Extbase\Persistence\ClassesConfigurationFactory', 'createClassesConfiguration'] + + # Content Object for Extbase Plugins + TYPO3\CMS\Extbase\ContentObject\ExtbasePluginContentObject: + tags: + - name: frontend.contentobject + identifier: 'EXTBASEPLUGIN' + + # Type Converters + TYPO3\CMS\Extbase\Property\TypeConverter\ArrayConverter: + tags: + - name: extbase.type_converter + priority: 10 + target: array + sources: array,string + + TYPO3\CMS\Extbase\Property\TypeConverter\BooleanConverter: + tags: + - name: extbase.type_converter + priority: 10 + target: boolean + sources: boolean,string + + TYPO3\CMS\Extbase\Property\TypeConverter\DateTimeConverter: + tags: + - name: extbase.type_converter + priority: 10 + target: DateTime + sources: string,integer,array + + TYPO3\CMS\Extbase\Property\TypeConverter\EnumConverter: + tags: + - name: extbase.type_converter + priority: 20 + target: object + sources: string,integer,float + + TYPO3\CMS\Extbase\Property\TypeConverter\FloatConverter: + tags: + - name: extbase.type_converter + priority: 10 + target: float + sources: float, integer, string + + TYPO3\CMS\Extbase\Property\TypeConverter\IntegerConverter: + tags: + - name: extbase.type_converter + priority: 10 + target: integer + sources: integer, string + + TYPO3\CMS\Extbase\Property\TypeConverter\ObjectStorageConverter: + tags: + - name: extbase.type_converter + priority: 10 + target: TYPO3\CMS\Extbase\Persistence\ObjectStorage + sources: string, array + + TYPO3\CMS\Extbase\Property\TypeConverter\PersistentObjectConverter: + tags: + - name: extbase.type_converter + priority: 20 + target: TYPO3\CMS\Extbase\DomainObject\AbstractDomainObject + sources: integer, string, array + + TYPO3\CMS\Extbase\Property\TypeConverter\ObjectConverter: + tags: + - name: extbase.type_converter + priority: 10 + target: object + sources: array + + TYPO3\CMS\Extbase\Property\TypeConverter\StringConverter: + tags: + - name: extbase.type_converter + priority: 10 + target: string + sources: string, integer + + TYPO3\CMS\Extbase\Property\TypeConverter\CoreTypeConverter: + tags: + - name: extbase.type_converter + priority: 10 + target: TYPO3\CMS\Core\Type\TypeInterface + sources: string, integer, float, boolean, array + + TYPO3\CMS\Extbase\Property\TypeConverter\CountryConverter: + tags: + - name: extbase.type_converter + priority: 10 + target: TYPO3\CMS\Core\Country\Country + sources: string + + # Experimental FAL<->extbase converter + TYPO3\CMS\Extbase\Property\TypeConverter\FileConverter: + tags: + - name: extbase.type_converter + priority: 10 + target: TYPO3\CMS\Extbase\Domain\Model\File + sources: integer, string + + # Experimental FAL<->extbase converter + TYPO3\CMS\Extbase\Property\TypeConverter\FileReferenceConverter: + tags: + - name: extbase.type_converter + priority: 10 + target: TYPO3\CMS\Extbase\Domain\Model\FileReference + sources: integer + + # Experimental FAL<->extbase converter + TYPO3\CMS\Extbase\Property\TypeConverter\FolderConverter: + tags: + - name: extbase.type_converter + priority: 10 + target: TYPO3\CMS\Extbase\Domain\Model\Folder + sources: string + + Doctrine\Instantiator\InstantiatorInterface: + class: \Doctrine\Instantiator\Instantiator + + # Configuration module to inspect Extbase Class Configuration + extbase.configuration.module.provider.classConfiguration: + class: 'TYPO3\CMS\Extbase\ConfigurationModuleProvider\ClassConfigurationProvider' + tags: + - name: 'lowlevel.configuration.module.provider' + identifier: 'extbaseClassConfiguration' + label: 'extbase.messages:class_configuration' diff --git a/Configuration/TCA/Overrides/fe_users.php b/Configuration/TCA/Overrides/fe_users.php new file mode 100644 index 0000000..91dd555 --- /dev/null +++ b/Configuration/TCA/Overrides/fe_users.php @@ -0,0 +1,33 @@ + [ + 'type' => 'tx_extbase_type', + ], + 'columns' => [ + 'tx_extbase_type' => [ + 'exclude' => true, + 'label' => 'LLL:EXT:extbase/Resources/Private/Language/locallang_db.xlf:fe_users.tx_extbase_type', + 'config' => [ + 'type' => 'select', + 'renderType' => 'selectSingle', + 'items' => [ + ['label' => 'LLL:EXT:extbase/Resources/Private/Language/locallang_db.xlf:fe_users.tx_extbase_type.0', 'value' => '0'], + ['label' => 'LLL:EXT:extbase/Resources/Private/Language/locallang_db.xlf:fe_users.tx_extbase_type.Tx_Extbase_Domain_Model_FrontendUser', 'value' => 'Tx_Extbase_Domain_Model_FrontendUser'], + ], + 'default' => 0, + ], + ], + ], + 'types' => [ + 'Tx_Extbase_Domain_Model_FrontendUser' => $GLOBALS['TCA']['fe_users']['types']['0'], + ], + ]; + $GLOBALS['TCA']['fe_users'] = array_replace_recursive($GLOBALS['TCA']['fe_users'], $tca); + \TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addToAllTCAtypes('fe_users', 'tx_extbase_type'); +} else { + $GLOBALS['TCA']['fe_users']['types']['Tx_Extbase_Domain_Model_FrontendUser'] = $GLOBALS['TCA']['fe_users']['types']['0']; +} diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..d159169 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..33e9771 --- /dev/null +++ b/README.rst @@ -0,0 +1,11 @@ +=========================== +TYPO3 extension ``extbase`` +=========================== + +This is an extension framework to create TYPO3 frontend plugins and TYPO3 +backend modules. + +:Repository: https://github.com/typo3/typo3 +:Issues: https://forge.typo3.org/ +:Read online: https://docs.typo3.org/ +:Packagist: https://packagist.org/packages/typo3/cms-extbase diff --git a/Resources/Private/Language/locallang.xlf b/Resources/Private/Language/locallang.xlf new file mode 100644 index 0000000..651e62b --- /dev/null +++ b/Resources/Private/Language/locallang.xlf @@ -0,0 +1,125 @@ + + + +
+ + + The date "%s" was not recognized (for format "%s"). + + + Validation errors for property "%s" + + + The given subject was not a valid email address. + + + The length of the given string was not between %s and %s characters. + + + The length of the given string is less than %s characters. + + + A valid string is expected. + + + The length of the given string exceeded %s characters. + + + The given subject was not a valid alphanumeric string. + + + The given subject was not a valid DateTime. Got: '%s' + + + The given subject was not a valid float. + + + The given subject was not a valid text (e.g. contained XML tags). + + + The given subject did not match the pattern. + + + The given subject was not a valid number. + + + The given subject was not in the valid range (%s - %s). + + + The given subject was NULL. + + + The given subject was empty. + + + The given subject was not a valid number. + + + The given subject was not a valid integer. + + + The given subject was not true. + + + The given subject was not false. + + + The given subject is no valid URL. + + + The file extension '%s' is not allowed. + + + The resolved media type "%s" is not allowed for file extension "%s". + + + The mime type '%s' is not allowed. + + + The file extension provided "%s" does not match to expected media types. + + + Uploading of files with PHP executable file extensions is not allowed. + + + The file must be larger than %s in size. + + + The file must be smaller than %s in size. + + + The image must be exactly %s pixels in width. + + + The image must be exactly %s pixels in height. + + + The image must at least have a width of %s pixels. + + + The image must at least have a height of %s pixels. + + + The image is too wide. The maximum width is %s pixels. + + + The image is too high. The maximum height is %s pixels. + + + You must at least upload %s file(s). + + + The file could not be deleted, because the minimum file upload amount would not be fulfilled after deletion. + + + The maximum amount of %s file(s) is exceeded. + + + Extbase: Class Configuration + + + Too many requests. Please try again later. + + + + diff --git a/Resources/Private/Language/locallang_db.xlf b/Resources/Private/Language/locallang_db.xlf new file mode 100644 index 0000000..abe6df7 --- /dev/null +++ b/Resources/Private/Language/locallang_db.xlf @@ -0,0 +1,17 @@ + + + +
+ + + Record Type + + + Website User + + + Website User (Extbase) + + + + diff --git a/Resources/Public/Icons/Extension.svg b/Resources/Public/Icons/Extension.svg new file mode 100644 index 0000000..bca7a17 --- /dev/null +++ b/Resources/Public/Icons/Extension.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..4058751 --- /dev/null +++ b/composer.json @@ -0,0 +1,69 @@ +{ + "name": "typo3/cms-extbase", + "type": "typo3-cms-framework", + "description": "TYPO3 CMS Extbase - Extension framework to create TYPO3 frontend plugins and TYPO3 backend modules.", + "homepage": "https://typo3.community/", + "funding": [ + { + "type": "membership", + "url": "https://typo3.org/membership" + } + ], + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "TYPO3 Core Team", + "email": "typo3cms@typo3.org", + "role": "Developer" + } + ], + "support": { + "issues": "https://forge.typo3.org/issues/", + "forum": "https://talk.typo3.org/", + "source": "https://github.com/TYPO3/typo3/", + "docs": "https://docs.typo3.org/", + "rss": "https://news.typo3.com/rss/", + "chat": "https://typo3.community/meet/slack/", + "security": "https://typo3.org/security/" + }, + "config": { + "sort-packages": true + }, + "require": { + "doctrine/instantiator": "^1.5 || ^2.0", + "phpdocumentor/reflection-docblock": "^6.0.3", + "phpdocumentor/type-resolver": "^2.0.0", + "phpstan/phpdoc-parser": "^2.1", + "symfony/dependency-injection": "^7.4.8", + "symfony/property-access": "^7.4.8", + "symfony/property-info": "^7.4.8", + "symfony/validator": "^7.4.8", + "typo3/cms-core": "15.0.*@dev" + }, + "suggest": { + "typo3/cms-scheduler": "Additional scheduler tasks" + }, + "conflict": { + "typo3/cms": "*" + }, + "extra": { + "branch-alias": { + "dev-main": "15.0.x-dev" + }, + "typo3/cms": { + "Package": { + "protected": true, + "partOfFactoryDefault": true, + "partOfMinimalUsableSystem": true + }, + "extension-key": "extbase" + } + }, + "autoload": { + "psr-4": { + "TYPO3\\CMS\\Extbase\\": "Classes/" + } + } +} diff --git a/ext_localconf.php b/ext_localconf.php new file mode 100644 index 0000000..9a2b86f --- /dev/null +++ b/ext_localconf.php @@ -0,0 +1,18 @@ +