TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:24 +02:00
commit aad9daaefd
1506 changed files with 94005 additions and 0 deletions
+330
View File
@@ -0,0 +1,330 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Form\Service;
use Psr\Log\LoggerInterface;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Form\Domain\DTO\FormData;
use TYPO3\CMS\Form\Domain\DTO\FormMetadata;
use TYPO3\CMS\Form\Domain\DTO\SearchCriteria;
use TYPO3\CMS\Form\Domain\DTO\StorageContext;
use TYPO3\CMS\Form\Domain\ValueObject\FormIdentifier;
use TYPO3\CMS\Form\Mvc\Persistence\FormPersistenceManagerInterface;
use TYPO3\CMS\Form\Storage\StorageAdapterFactory;
use TYPO3\CMS\Form\Storage\StorageAdapterInterface;
/**
* Service for transferring form definitions between storage backends
*
* Used by the CLI command `form:definition:transfer` and the
* upgrade wizard for migrating file-based forms to database storage.
* Handles reading from a source storage adapter, ensuring identifier
* uniqueness, and writing to a target storage adapter.
*
* @internal
*/
final readonly class FormTransferService
{
public function __construct(
private StorageAdapterFactory $storageAdapterFactory,
private FormPersistenceManagerInterface $formPersistenceManager,
private ConnectionPool $connectionPool,
private LoggerInterface $logger,
) {}
/**
* List all forms in the given source storage
*
* @return list<FormMetadata>
*/
public function listSourceForms(string $sourceType, ?string $formIdentifier = null): array
{
$sourceAdapter = $this->storageAdapterFactory->getAdapterByType($sourceType);
$forms = $sourceAdapter->findAll(new SearchCriteria());
if ($formIdentifier !== null) {
$forms = array_values(array_filter(
$forms,
static fn(FormMetadata $form) => $form->identifier === $formIdentifier,
));
}
return $forms;
}
/**
* Read a form definition from a source storage
*/
public function readForm(string $sourceType, string $persistenceIdentifier): FormData
{
return $this->storageAdapterFactory->getAdapterByType($sourceType)->read(FormIdentifier::fromString($persistenceIdentifier));
}
/**
* Delete a form from a storage
*/
public function deleteForm(string $storageType, string $persistenceIdentifier): void
{
$adapter = $this->storageAdapterFactory->getAdapterByType($storageType);
$adapter->delete(FormIdentifier::fromString($persistenceIdentifier));
}
/**
* Transfer a single form from source to target storage
*
* @return FormTransferResult
*/
public function transferForm(
FormMetadata $sourceForm,
string $sourceType,
string $targetType,
string $targetLocation,
bool $deleteSource = false,
): FormTransferResult {
$sourceAdapter = $this->storageAdapterFactory->getAdapterByType($sourceType);
$targetAdapter = $this->storageAdapterFactory->getAdapterByType($targetType);
// Read from source
$sourcePersistenceIdentifier = $sourceForm->persistenceIdentifier ?? $sourceForm->identifier;
$formData = $sourceAdapter->read(FormIdentifier::fromString($sourcePersistenceIdentifier));
// Ensure unique identifier in target.
// For a move operation the source form will be deleted afterwards, so it must not be
// counted as a duplicate. Therefore only the target adapter is checked for conflicts.
// For a copy operation all adapters are checked to avoid the same logical identifier
// existing in multiple storages simultaneously.
if ($deleteSource) {
$uniqueIdentifier = $this->getUniqueIdentifierInAdapter($targetAdapter, $formData->identifier);
} else {
$uniqueIdentifier = $this->formPersistenceManager->getUniqueIdentifier($formData->identifier);
}
// Build FormData with potentially updated identifier
$targetFormData = $uniqueIdentifier !== $formData->identifier
? FormData::fromArray(array_merge($formData->toArray(), ['identifier' => $uniqueIdentifier]))
: $formData;
// Get unique persistence identifier in target storage
$targetPersistenceIdentifier = $targetAdapter->getUniquePersistenceIdentifier(
$uniqueIdentifier,
$targetLocation,
);
// Write to target
$context = $this->buildStorageContext($targetLocation);
$savedIdentifier = $targetAdapter->write(
FormIdentifier::fromString($targetPersistenceIdentifier),
$targetFormData,
$context,
);
// Optionally delete from source
$sourceDeleted = false;
$deletionError = null;
if ($deleteSource) {
try {
$sourceAdapter->delete(FormIdentifier::fromString($sourcePersistenceIdentifier));
$sourceDeleted = true;
} catch (\Exception $e) {
$deletionError = $e->getMessage();
}
}
return new FormTransferResult(
sourceIdentifier: $sourcePersistenceIdentifier,
targetIdentifier: $savedIdentifier->identifier,
formIdentifier: $uniqueIdentifier,
formName: $sourceForm->name,
sourceDeleted: $sourceDeleted,
deletionError: $deletionError,
);
}
/**
* Get all registered storage type identifiers
*
* @return list<string>
*/
public function getAvailableStorageTypes(): array
{
return $this->storageAdapterFactory->getRegisteredTypeIdentifiers();
}
/**
* Check if a storage type exists
*/
public function hasStorageType(string $typeIdentifier): bool
{
return $this->storageAdapterFactory->hasAdapterType($typeIdentifier);
}
/**
* Get adapter for a storage type (for validation purposes)
*/
public function getAdapter(string $typeIdentifier): StorageAdapterInterface
{
return $this->storageAdapterFactory->getAdapterByType($typeIdentifier);
}
/**
* Get a unique form identifier by checking only the given storage adapter for conflicts.
*
* Used for move operations: since the source form is deleted after transfer, only the
* target storage needs to be free of the identifier — not all storages globally.
*
* @throws \RuntimeException if no unique identifier can be found
*/
private function getUniqueIdentifierInAdapter(StorageAdapterInterface $adapter, string $identifier): string
{
$originalIdentifier = $identifier;
if (!$adapter->existsByFormIdentifier($identifier)) {
return $identifier;
}
for ($attempts = 1; $attempts < 100; $attempts++) {
$identifier = sprintf('%s_%d', $originalIdentifier, $attempts);
if (!$adapter->existsByFormIdentifier($identifier)) {
return $identifier;
}
}
$identifier = $originalIdentifier . '_' . time();
if (!$adapter->existsByFormIdentifier($identifier)) {
return $identifier;
}
throw new \RuntimeException(
sprintf('Could not find a unique identifier for form identifier "%s" after %d attempts', $originalIdentifier, $attempts),
1742477400
);
}
private function buildStorageContext(string $targetLocation): ?StorageContext
{
if (ctype_digit($targetLocation)) {
return StorageContext::create((int)$targetLocation);
}
return null;
}
/**
* Update tt_content FlexForm references from old persistence identifiers
* to new ones.
*
* Uses DOM/XPath parsing to precisely target only the
* `settings.persistenceIdentifier` field in FlexForm XML, avoiding
* false replacements in other fields.
*
* @param array<string, string> $migrationMap Old persistenceIdentifier => new persistenceIdentifier
* @return int Number of updated content element references
*/
public function updateContentElementReferences(array $migrationMap): int
{
$updatedCount = 0;
$connection = $this->connectionPool->getConnectionForTable('tt_content');
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tt_content');
$queryBuilder->getRestrictions()->removeAll()->add(
GeneralUtility::makeInstance(DeletedRestriction::class)
);
$rows = $queryBuilder
->select('uid', 'pi_flexform')
->from('tt_content')
->where(
$queryBuilder->expr()->eq(
'CType',
$queryBuilder->createNamedParameter('form_formframework')
),
$queryBuilder->expr()->isNotNull('pi_flexform'),
$queryBuilder->expr()->neq(
'pi_flexform',
$queryBuilder->createNamedParameter('')
)
)
->executeQuery()
->fetchAllAssociative();
foreach ($rows as $row) {
$flexForm = $row['pi_flexform'];
$newValue = $this->replacePersistenceIdentifierInFlexForm($flexForm, $migrationMap);
if ($newValue !== null && $newValue !== $flexForm) {
$connection->update(
'tt_content',
['pi_flexform' => $newValue],
['uid' => (int)$row['uid']]
);
$updatedCount++;
}
}
return $updatedCount;
}
/**
* Replace the persistenceIdentifier value in FlexForm XML using DOM parsing.
*
* Specifically targets only the <field index="settings.persistenceIdentifier">
* element to avoid replacing values in other FlexForm fields.
*
* @param string $flexFormXml The raw FlexForm XML string
* @param array<string, string> $migrationMap Old persistenceIdentifier => new persistenceIdentifier
* @return string|null The modified XML string, or null if parsing failed or no changes were made
*/
private function replacePersistenceIdentifierInFlexForm(string $flexFormXml, array $migrationMap): ?string
{
$document = new \DOMDocument();
$previousErrorHandling = libxml_use_internal_errors(true);
if (!$document->loadXML($flexFormXml)) {
libxml_clear_errors();
libxml_use_internal_errors($previousErrorHandling);
$this->logger->warning('Could not parse FlexForm XML.');
return null;
}
libxml_clear_errors();
libxml_use_internal_errors($previousErrorHandling);
$xpath = new \DOMXPath($document);
$nodes = $xpath->query('//field[@index="settings.persistenceIdentifier"]/value[@index="vDEF"]');
if ($nodes === false || $nodes->length === 0) {
return null;
}
$modified = false;
foreach ($nodes as $node) {
$currentValue = $node->nodeValue;
if (isset($migrationMap[$currentValue])) {
$node->nodeValue = (string)$migrationMap[$currentValue];
$modified = true;
}
}
if (!$modified) {
return null;
}
return $document->saveXML();
}
}